Skip to content

Commit

Permalink
feat(sanity): add Rendering Context Store
Browse files Browse the repository at this point in the history
  • Loading branch information
juice49 committed Mar 4, 2025
1 parent ff2fee9 commit 0774445
Show file tree
Hide file tree
Showing 9 changed files with 351 additions and 0 deletions.
23 changes: 23 additions & 0 deletions packages/sanity/src/core/store/_legacy/datastores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {createDocumentPreviewStore, type DocumentPreviewStore} from '../../previ
import {useSource, useWorkspace} from '../../studio'
import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient'
import {createKeyValueStore, type KeyValueStore} from '../key-value'
import {createRenderingContextStore} from '../renderingContext/createRenderingContextStore'
import {type RenderingContextStore} from '../renderingContext/types'
import {useCurrentUser} from '../user'
import {
type ConnectionStatusStore,
Expand Down Expand Up @@ -289,3 +291,24 @@ export function useKeyValueStore(): KeyValueStore {
return keyValueStore
}, [client, resourceCache, workspace])
}

/** @internal */
export function useRenderingContextStore(): RenderingContextStore {
const resourceCache = useResourceCache()

return useMemo(() => {
const renderingContextStore =
resourceCache.get<RenderingContextStore>({
dependencies: [],
namespace: 'RenderingContextStore',
}) || createRenderingContextStore()

resourceCache.set({
dependencies: [],
namespace: 'RenderingContextStore',
value: renderingContextStore,
})

return renderingContextStore
}, [resourceCache])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {expect, it} from 'vitest'

import {coreUiRenderingContext} from './coreUiRenderingContext'

const coreUiProductionContext = {
mode: 'core-ui',
env: 'production',
}

const coreUiStagingContext = {
mode: 'core-ui',
env: 'staging',
}

const coreUiUnsupportedContext = {
mode: 'evil-mode',
env: 'production',
}

it('ignores the `_context` URL search parameter if the mode is not "core-ui"', () => {
expect(() => coreUiRenderingContext(urlSearch(coreUiUnsupportedContext))).toMatchEmissions([
[undefined, undefined],
])
})

it('parses the `_context` URL search parameter', () => {
expect(() => coreUiRenderingContext(urlSearch(coreUiProductionContext))).toMatchEmissions([
[
undefined,
{
name: 'coreUi',
metadata: {
environment: 'production',
},
},
],
])

expect(() => coreUiRenderingContext(urlSearch(coreUiStagingContext))).toMatchEmissions([
[
undefined,
{
name: 'coreUi',
metadata: {
environment: 'staging',
},
},
],
])
})

it('fails gracefully if the `_context` URL search parameter cannot be parsed', () => {
const invalidCoreUiContextString = urlSearch(coreUiProductionContext).slice(0, -10)

expect(() => coreUiRenderingContext(invalidCoreUiContextString)).toMatchEmissions([
[undefined, undefined],
])
})

function urlSearch(context: unknown): string {
return new URLSearchParams({
_context: JSON.stringify(context),
}).toString()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
catchError,
distinctUntilChanged,
map,
of,
type OperatorFunction,
pipe,
switchMap,
} from 'rxjs'

import {isCoreUiRenderingContext, type StudioRenderingContext} from './types'

// Core UI Rendering Context is provided via the URL query string, and remains static the entire
// duration Studio is rendered inside the Core UI iframe.
//
// However, the URL query string is liable to be lost during Studio's lifecycle (for example, when
// the user navigates to a different tool). Therefore, the URL query string is captured as soon as
// this code is evaluated, and later referenced when a consumer subscribes to the store.
const INITIAL_URL_SEARCH = location?.search

const CORE_UI_MODE_NAME = 'core-ui'
const CORE_UI_CONTEXT_SEARCH_PARAM = '_context'

/**
* @internal
*/
export function coreUiRenderingContext(
urlSearch: string = INITIAL_URL_SEARCH,
): OperatorFunction<StudioRenderingContext | undefined, StudioRenderingContext | undefined> {
return pipe(
switchMap((renderingContext) => {
if (renderingContext) {
return of(renderingContext)
}

return of(urlSearch).pipe(
distinctUntilChanged(),
map((search) => new URLSearchParams(search).get(CORE_UI_CONTEXT_SEARCH_PARAM)),
map((serializedContext) => {
if (serializedContext === null) {
return undefined
}

const {mode, env} = JSON.parse(serializedContext)

const coreUirenderingContext = {
name: mode === CORE_UI_MODE_NAME ? 'coreUi' : undefined,
metadata: {
environment: env,
},
}

if (isCoreUiRenderingContext(coreUirenderingContext)) {
return coreUirenderingContext
}

return undefined
}),
catchError((error) => {
console.warn('Error parsing rendering context:', error)
return of(undefined)
}),
)
}),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {firstValueFrom} from 'rxjs'
import {describe, expect, it} from 'vitest'

import {createRenderingContextStore} from './createRenderingContextStore'

describe('renderingContext', () => {
it('emits rendering context', async () => {
const {renderingContext} = createRenderingContextStore()

expect(await firstValueFrom(renderingContext)).toEqual({
name: 'default',
metadata: {},
})
})
})

describe('capabilities', () => {
it('emits capabilities', async () => {
const {capabilities} = createRenderingContextStore()
expect(await firstValueFrom(capabilities)).toEqual({})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {of, shareReplay} from 'rxjs'

import {coreUiRenderingContext} from './coreUiRenderingContext'
import {defaultRenderingContext} from './defaultRenderingContext'
import {listCapabilities} from './listCapabilities'
import {type RenderingContextStore} from './types'

/**
* Rendering Context Store provides information about where Studio is being rendered, and which
* capabilities are provided by the rendering context.
*
* This can be used to adapt parts of the Studio UI that are provided by the rendering context,
* such as the global user menu.
*
* @internal
*/
export function createRenderingContextStore(): RenderingContextStore {
const renderingContext = of(undefined).pipe(
coreUiRenderingContext(),
defaultRenderingContext(),
shareReplay(1),
)

const capabilities = renderingContext.pipe(listCapabilities(), shareReplay(1))

return {
renderingContext,
capabilities,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {expect, it} from 'vitest'

import {defaultRenderingContext} from './defaultRenderingContext'

it("emits the subject if it's not `undefined`", () => {
expect(defaultRenderingContext).toMatchEmissions([
[
{
name: 'coreUi',
metadata: {
environment: 'production',
},
},
{
name: 'coreUi',
metadata: {
environment: 'production',
},
},
],
])
})

it('emits the the default context if the subject is `undefined`', () => {
expect(defaultRenderingContext).toMatchEmissions([
[
undefined,
{
name: 'default',
metadata: {},
},
],
])
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {map, type OperatorFunction} from 'rxjs'

import {type DefaultRenderingContext, type StudioRenderingContext} from './types'

const DEFAULT_RENDERING_CONTEXT: DefaultRenderingContext = {
name: 'default',
metadata: {},
}

/**
* @internal
*/
export function defaultRenderingContext(): OperatorFunction<
StudioRenderingContext | undefined,
StudioRenderingContext
> {
return map((renderingContext) => renderingContext ?? DEFAULT_RENDERING_CONTEXT)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {map, type OperatorFunction} from 'rxjs'

import {type CapabilityRecord, type StudioRenderingContext} from './types'

const capabilitiesByRenderingContext: Record<StudioRenderingContext['name'], CapabilityRecord> = {
coreUi: {
globalUserMenu: true,
globalWorkspaceControl: true,
},
default: {},
}

/**
* @internal
*/
export function listCapabilities(): OperatorFunction<StudioRenderingContext, CapabilityRecord> {
return map((renderingContext) => capabilitiesByRenderingContext[renderingContext.name])
}
76 changes: 76 additions & 0 deletions packages/sanity/src/core/store/renderingContext/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {type Observable} from 'rxjs'

/**
* @internal
*/
export type BaseStudioRenderingContext<
Name extends string = string,
Metadata = Record<PropertyKey, never>,
> = {
name: Name
metadata: Metadata
}

/**
* @internal
*/
export type DefaultRenderingContext = BaseStudioRenderingContext<'default'>

/**
* @internal
*/
export type CoreUiRenderingContext = BaseStudioRenderingContext<
'coreUi',
{
environment: string
}
>

/**
* @internal
*/
export type StudioRenderingContext = DefaultRenderingContext | CoreUiRenderingContext

/**
* @internal
*/
export const capabilities = ['globalUserMenu', 'globalWorkspaceControl'] as const

/**
* @internal
*/
export type Capability = (typeof capabilities)[number]

/**
* @internal
*/
export type CapabilityRecord = Partial<Record<Capability, boolean>>

/**
* @internal
*/
export type RenderingContextStore = {
renderingContext: Observable<StudioRenderingContext>
capabilities: Observable<CapabilityRecord>
}

/**
* Check whether the provided value satisfies the `CoreUiRenderingContext` type.
*
* @internal
*/
export function isCoreUiRenderingContext(
maybeCoreUiRenderingContext: unknown,
): maybeCoreUiRenderingContext is CoreUiRenderingContext {
return (
typeof maybeCoreUiRenderingContext === 'object' &&
maybeCoreUiRenderingContext !== null &&
'name' in maybeCoreUiRenderingContext &&
maybeCoreUiRenderingContext.name === 'coreUi' &&
'metadata' in maybeCoreUiRenderingContext &&
typeof maybeCoreUiRenderingContext.metadata === 'object' &&
maybeCoreUiRenderingContext.metadata !== null &&
'environment' in maybeCoreUiRenderingContext.metadata &&
typeof maybeCoreUiRenderingContext.metadata.environment === 'string'
)
}

0 comments on commit 0774445

Please sign in to comment.