@sanity/sdk-react 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +90 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/components/auth/AuthBoundary.test.tsx +34 -0
- package/src/components/auth/AuthBoundary.tsx +1 -2
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -19,7 +19,7 @@ test('useApplication — no include: the base application', () => {
|
|
|
19
19
|
test('useApplication — config.studio include adds config.studio', () => {
|
|
20
20
|
const result = useApplication('app_1', {include: ['config.studio']})
|
|
21
21
|
expectTypeOf(result.data).toEqualTypeOf<Application<'config.studio'>>()
|
|
22
|
-
expectTypeOf(result.data.config.studio).toEqualTypeOf<ApplicationStudioConfig>()
|
|
22
|
+
expectTypeOf(result.data.config.studio).toEqualTypeOf<ApplicationStudioConfig | undefined>()
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
test('useApplication — a deployment-child include forces the deployment in', () => {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {renderHook} from '../../../test/test-utils'
|
|
6
|
+
import {useApplication} from './useApplication'
|
|
7
|
+
import {type DashboardApplication} from './useApplications'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
const application = {
|
|
14
|
+
id: 'application-1',
|
|
15
|
+
type: 'coreApp',
|
|
16
|
+
title: 'Inbox',
|
|
17
|
+
name: 'inbox',
|
|
18
|
+
reference: 'sanity/inbox',
|
|
19
|
+
icon: null,
|
|
20
|
+
isSingleton: true,
|
|
21
|
+
visibility: 'default',
|
|
22
|
+
slug: 'inbox',
|
|
23
|
+
externalUrl: null,
|
|
24
|
+
organizationId: 'organization-1',
|
|
25
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
26
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useApplication', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
32
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
33
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
34
|
+
host.connections.subscribe((client) =>
|
|
35
|
+
client.emit('applications.list', {
|
|
36
|
+
ok: true,
|
|
37
|
+
value: [application],
|
|
38
|
+
} as ValueOf<'applications.list'>),
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
resetMessageBus()
|
|
44
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
45
|
+
vi.unstubAllGlobals()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns an application by id or null', () => {
|
|
49
|
+
const {result, rerender} = renderHook(({applicationId}) => useApplication(applicationId), {
|
|
50
|
+
initialProps: {applicationId: application.id},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expectTypeOf(result.current).toEqualTypeOf<DashboardApplication | null>()
|
|
54
|
+
expect(result.current?.id).toBe(application.id)
|
|
55
|
+
|
|
56
|
+
rerender({applicationId: 'missing'})
|
|
57
|
+
expect(result.current).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {type DashboardApplication, useApplications} from './useApplications'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a dashboard application by id, or `null` when it is unavailable.
|
|
5
|
+
*
|
|
6
|
+
* Suspends until the dashboard publishes its application list.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function SelectedApplication({id}: {id: string}) {
|
|
11
|
+
* const application = useApplication(id)
|
|
12
|
+
* return application ? <h1>{application.title}</h1> : <p>Application unavailable</p>
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export function useApplication(
|
|
19
|
+
applicationId: DashboardApplication['id'],
|
|
20
|
+
): DashboardApplication | null {
|
|
21
|
+
return useApplications().find(({id}) => id === applicationId) ?? null
|
|
22
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type ApplicationConfig, type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {Suspense} from 'react'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {act, render, renderHook, screen} from '../../../test/test-utils'
|
|
7
|
+
import {type ApplicationConfigSelector, useApplicationConfig} from './useApplicationConfig'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
// The host writes state to each connection, so publish by emitting to every connection's client.
|
|
14
|
+
const emitConfigs = (value: ApplicationConfig[] | null) =>
|
|
15
|
+
host.connections.subscribe((client) => client.emit('applications.config', value))
|
|
16
|
+
|
|
17
|
+
const typeConfig: ApplicationConfig = {
|
|
18
|
+
appType: 'media-library',
|
|
19
|
+
entry: 'https://media-library-config.sanity.run',
|
|
20
|
+
moduleId: 'configs/installation_config',
|
|
21
|
+
version: '1',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const appConfig: ApplicationConfig = {
|
|
25
|
+
appId: 'application-1',
|
|
26
|
+
appType: 'media-library',
|
|
27
|
+
entry: 'https://application-config.sanity.run',
|
|
28
|
+
moduleId: 'configs/installation_config',
|
|
29
|
+
version: '2',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('useApplicationConfig', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
35
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetMessageBus()
|
|
40
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
41
|
+
vi.unstubAllGlobals()
|
|
42
|
+
vi.restoreAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('selects a config by application type or application id', () => {
|
|
46
|
+
emitConfigs([typeConfig, appConfig])
|
|
47
|
+
|
|
48
|
+
const {result, rerender} = renderHook<
|
|
49
|
+
{selector: ApplicationConfigSelector},
|
|
50
|
+
ApplicationConfig | null
|
|
51
|
+
>(({selector}) => useApplicationConfig(selector), {
|
|
52
|
+
initialProps: {selector: {appType: 'media-library'}},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expectTypeOf(result.current).toEqualTypeOf<ApplicationConfig | null>()
|
|
56
|
+
expect(result.current).toBe(typeConfig)
|
|
57
|
+
|
|
58
|
+
rerender({selector: {appId: 'application-1'}})
|
|
59
|
+
expect(result.current).toBe(appConfig)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('selects the type-level config for an appType query regardless of emit order', () => {
|
|
63
|
+
// App-scoped config first: a naive find() would return it for a type query.
|
|
64
|
+
emitConfigs([appConfig, typeConfig])
|
|
65
|
+
|
|
66
|
+
const {result} = renderHook(() => useApplicationConfig({appType: 'media-library'}))
|
|
67
|
+
|
|
68
|
+
expect(result.current).toBe(typeConfig)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns null when no config matches the selector', () => {
|
|
72
|
+
emitConfigs([typeConfig, appConfig])
|
|
73
|
+
|
|
74
|
+
const {result} = renderHook(() => useApplicationConfig({appId: 'missing'}))
|
|
75
|
+
|
|
76
|
+
expect(result.current).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('suspends until the dashboard publishes its configs', async () => {
|
|
80
|
+
function MediaLibrary() {
|
|
81
|
+
const config = useApplicationConfig({appType: 'media-library'})
|
|
82
|
+
return <span>{config?.moduleId ?? 'none'}</span>
|
|
83
|
+
}
|
|
84
|
+
render(
|
|
85
|
+
<Suspense fallback="Loading">
|
|
86
|
+
<MediaLibrary />
|
|
87
|
+
</Suspense>,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
emitConfigs([typeConfig])
|
|
94
|
+
})
|
|
95
|
+
expect(await screen.findByText('configs/installation_config')).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('follows topic updates', () => {
|
|
99
|
+
emitConfigs([typeConfig])
|
|
100
|
+
const {result} = renderHook(() => useApplicationConfig({appId: 'application-1'}))
|
|
101
|
+
expect(result.current).toBeNull()
|
|
102
|
+
|
|
103
|
+
act(() => emitConfigs([typeConfig, appConfig]))
|
|
104
|
+
expect(result.current).toBe(appConfig)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {type Application} from '@sanity/sdk'
|
|
2
|
+
import {type ApplicationConfig, type ApplicationConfigAppType} from '@sanity/sdk/dashboard'
|
|
3
|
+
|
|
4
|
+
import {useApplicationConfigs} from './useApplicationConfigs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Selects an application configuration by application id or application type.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export type ApplicationConfigSelector =
|
|
11
|
+
| {appId: Application['id']; appType?: never}
|
|
12
|
+
| {appId?: never; appType: ApplicationConfigAppType}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns an application configuration by application id or application type, or `null` when
|
|
16
|
+
* none matches.
|
|
17
|
+
*
|
|
18
|
+
* An `appType` query matches the type-level config only (the one without an `appId`), so it is
|
|
19
|
+
* independent of the order the dashboard published configs in. An `appId` query matches that
|
|
20
|
+
* application's config.
|
|
21
|
+
*
|
|
22
|
+
* Suspends until the dashboard publishes its application configs.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* function MediaLibraryConfig() {
|
|
27
|
+
* const config = useApplicationConfig({appType: 'media-library'})
|
|
28
|
+
* return config ? <span>{config.moduleId}</span> : <p>Not installed</p>
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
export function useApplicationConfig(
|
|
35
|
+
selector: ApplicationConfigSelector,
|
|
36
|
+
): ApplicationConfig | null {
|
|
37
|
+
const configs = useApplicationConfigs()
|
|
38
|
+
return (
|
|
39
|
+
configs.find((config) =>
|
|
40
|
+
selector.appId === undefined
|
|
41
|
+
? config.appType === selector.appType && config.appId === undefined
|
|
42
|
+
: config.appId === selector.appId,
|
|
43
|
+
) ?? null
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type ApplicationConfig, type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {Suspense} from 'react'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {act, render, renderHook, screen} from '../../../test/test-utils'
|
|
7
|
+
import {useApplicationConfigs} from './useApplicationConfigs'
|
|
8
|
+
|
|
9
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
10
|
+
|
|
11
|
+
let host: MessageBusHost
|
|
12
|
+
|
|
13
|
+
// The host writes state to each connection, so publish by emitting to every connection's client.
|
|
14
|
+
const emitConfigs = (value: ApplicationConfig[] | null) =>
|
|
15
|
+
host.connections.subscribe((client) => client.emit('applications.config', value))
|
|
16
|
+
|
|
17
|
+
const configs: ApplicationConfig[] = [
|
|
18
|
+
{
|
|
19
|
+
appType: 'media-library',
|
|
20
|
+
entry: 'https://media-library-config.sanity.run',
|
|
21
|
+
moduleId: 'configs/installation_config',
|
|
22
|
+
version: '1',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
appId: 'application-1',
|
|
26
|
+
appType: 'media-library',
|
|
27
|
+
entry: 'https://application-config.sanity.run',
|
|
28
|
+
moduleId: 'configs/installation_config',
|
|
29
|
+
version: '2',
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe('useApplicationConfigs', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
36
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
resetMessageBus()
|
|
41
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
42
|
+
vi.unstubAllGlobals()
|
|
43
|
+
vi.restoreAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns every published application config', () => {
|
|
47
|
+
emitConfigs(configs)
|
|
48
|
+
|
|
49
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
50
|
+
|
|
51
|
+
expectTypeOf(result.current).toEqualTypeOf<readonly ApplicationConfig[]>()
|
|
52
|
+
expect(result.current).toEqual(configs)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('suspends until the dashboard publishes its configs', async () => {
|
|
56
|
+
function Configs() {
|
|
57
|
+
return <span>{useApplicationConfigs().length} configs</span>
|
|
58
|
+
}
|
|
59
|
+
render(
|
|
60
|
+
<Suspense fallback="Loading">
|
|
61
|
+
<Configs />
|
|
62
|
+
</Suspense>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
66
|
+
|
|
67
|
+
await act(async () => {
|
|
68
|
+
emitConfigs(configs)
|
|
69
|
+
})
|
|
70
|
+
expect(await screen.findByText('2 configs')).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns an empty list when the dashboard clears its configs', () => {
|
|
74
|
+
emitConfigs(null)
|
|
75
|
+
|
|
76
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
77
|
+
|
|
78
|
+
expect(result.current).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('follows topic updates', () => {
|
|
82
|
+
emitConfigs(null)
|
|
83
|
+
const {result} = renderHook(() => useApplicationConfigs())
|
|
84
|
+
expect(result.current).toEqual([])
|
|
85
|
+
|
|
86
|
+
act(() => emitConfigs(configs))
|
|
87
|
+
expect(result.current).toEqual(configs)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {type ApplicationConfig} from '@sanity/sdk/dashboard'
|
|
2
|
+
|
|
3
|
+
import {useTopic} from './useTopic'
|
|
4
|
+
|
|
5
|
+
// Stable, frozen reference for the cleared/absent case so the return value only changes with the
|
|
6
|
+
// topic and callers cannot mutate the shared empty list.
|
|
7
|
+
const NO_CONFIGS: readonly ApplicationConfig[] = Object.freeze([])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the application configuration modules available in the dashboard.
|
|
11
|
+
*
|
|
12
|
+
* Suspends until the dashboard publishes its application configs; a cleared list is empty.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* function Applications() {
|
|
17
|
+
* const configs = useApplicationConfigs()
|
|
18
|
+
* return configs.map((config) => <div key={config.moduleId}>{config.appType}</div>)
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export function useApplicationConfigs(): readonly ApplicationConfig[] {
|
|
25
|
+
return useTopic('applications.config') ?? NO_CONFIGS
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {type Application} from '@sanity/sdk'
|
|
2
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
3
|
+
import {type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
4
|
+
import {Suspense} from 'react'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {act, render, screen} from '../../../test/test-utils'
|
|
8
|
+
import {useApplicationForegroundId} from './useApplicationForegroundId'
|
|
9
|
+
|
|
10
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
11
|
+
|
|
12
|
+
let host: MessageBusHost
|
|
13
|
+
|
|
14
|
+
describe('useApplicationForegroundId', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
17
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
18
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
resetMessageBus()
|
|
23
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
24
|
+
vi.unstubAllGlobals()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('suspends until Dashboard publishes, then follows updates', async () => {
|
|
28
|
+
function Foreground() {
|
|
29
|
+
const id = useApplicationForegroundId()
|
|
30
|
+
expectTypeOf(id).toEqualTypeOf<Application['id'] | null>()
|
|
31
|
+
return <span>{id ?? 'none'}</span>
|
|
32
|
+
}
|
|
33
|
+
render(
|
|
34
|
+
<Suspense fallback="Loading">
|
|
35
|
+
<Foreground />
|
|
36
|
+
</Suspense>,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
40
|
+
|
|
41
|
+
// Dashboard publishes `null` on boot when no application is in the foreground.
|
|
42
|
+
await act(async () => {
|
|
43
|
+
host.connections.subscribe((client) => client.emit('applications.foreground', null))
|
|
44
|
+
})
|
|
45
|
+
expect(await screen.findByText('none')).toBeInTheDocument()
|
|
46
|
+
|
|
47
|
+
act(() =>
|
|
48
|
+
host.connections.subscribe((client) =>
|
|
49
|
+
client.emit('applications.foreground', 'application-1'),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
expect(screen.getByText('application-1')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {type Application} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {useTopic} from './useTopic'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the id of the application in the foreground, or `null` when Dashboard has none.
|
|
7
|
+
*
|
|
8
|
+
* Suspends until Dashboard publishes the foreground application.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* function ForegroundApplication() {
|
|
13
|
+
* const foregroundId = useApplicationForegroundId()
|
|
14
|
+
* return <span>{foregroundId ?? 'No foreground application'}</span>
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export function useApplicationForegroundId(): Application['id'] | null {
|
|
21
|
+
return useTopic('applications.foreground')
|
|
22
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type MessageBusHost, TopicError, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {Suspense} from 'react'
|
|
4
|
+
import {ErrorBoundary} from 'react-error-boundary'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {act, render, renderHook, screen} from '../../../test/test-utils'
|
|
8
|
+
import {type DashboardApplication, useApplications} from './useApplications'
|
|
9
|
+
|
|
10
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
11
|
+
|
|
12
|
+
let host: MessageBusHost
|
|
13
|
+
|
|
14
|
+
const application = {
|
|
15
|
+
id: 'application-1',
|
|
16
|
+
type: 'coreApp',
|
|
17
|
+
title: 'Inbox',
|
|
18
|
+
name: 'inbox',
|
|
19
|
+
reference: 'sanity/inbox',
|
|
20
|
+
icon: null,
|
|
21
|
+
isSingleton: true,
|
|
22
|
+
visibility: 'default',
|
|
23
|
+
slug: 'inbox',
|
|
24
|
+
externalUrl: null,
|
|
25
|
+
organizationId: 'organization-1',
|
|
26
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
27
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
28
|
+
config: {mfManifest: {}},
|
|
29
|
+
activeDeployment: {
|
|
30
|
+
id: 'deployment-1',
|
|
31
|
+
applicationId: 'application-1',
|
|
32
|
+
size: 100,
|
|
33
|
+
version: '1.0.0',
|
|
34
|
+
isAutoUpdating: false,
|
|
35
|
+
isActiveDeployment: true,
|
|
36
|
+
deployedBy: 'user-1',
|
|
37
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
38
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
39
|
+
interfaces: [
|
|
40
|
+
{
|
|
41
|
+
id: 'view-1',
|
|
42
|
+
type: 'app',
|
|
43
|
+
name: 'inbox',
|
|
44
|
+
title: 'Inbox',
|
|
45
|
+
version: '1',
|
|
46
|
+
moduleId: 'App',
|
|
47
|
+
metadata: null,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'panel-1',
|
|
51
|
+
type: 'panel',
|
|
52
|
+
name: 'notifications',
|
|
53
|
+
title: 'Notifications',
|
|
54
|
+
version: '1',
|
|
55
|
+
moduleId: 'views/notifications',
|
|
56
|
+
metadata: {dock: {group: 'dock.applications', order: 1}},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'tile-1',
|
|
60
|
+
type: 'tile',
|
|
61
|
+
name: 'summary',
|
|
62
|
+
title: 'Summary',
|
|
63
|
+
version: '1',
|
|
64
|
+
moduleId: 'views/summary',
|
|
65
|
+
metadata: null,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'asset-source-1',
|
|
69
|
+
type: 'asset_source',
|
|
70
|
+
name: 'library',
|
|
71
|
+
title: 'Library',
|
|
72
|
+
version: '1',
|
|
73
|
+
moduleId: 'views/library',
|
|
74
|
+
metadata: null,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'worker-1',
|
|
78
|
+
type: 'worker',
|
|
79
|
+
name: 'sync',
|
|
80
|
+
title: 'Sync',
|
|
81
|
+
version: '1',
|
|
82
|
+
moduleId: 'services/sync',
|
|
83
|
+
metadata: null,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const nonFederatedApplication = {
|
|
90
|
+
...application,
|
|
91
|
+
id: 'application-2',
|
|
92
|
+
name: 'legacy',
|
|
93
|
+
reference: 'organization-1/legacy',
|
|
94
|
+
slug: 'legacy',
|
|
95
|
+
title: 'Legacy',
|
|
96
|
+
isSingleton: false,
|
|
97
|
+
config: {},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nonSingletonApplication = {
|
|
101
|
+
...application,
|
|
102
|
+
id: 'application-3',
|
|
103
|
+
name: 'canvas',
|
|
104
|
+
reference: 'organization-1/canvas',
|
|
105
|
+
slug: 'canvas',
|
|
106
|
+
title: 'Canvas',
|
|
107
|
+
isSingleton: false,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const externalApplication = {
|
|
111
|
+
...application,
|
|
112
|
+
id: 'application-4',
|
|
113
|
+
name: 'external',
|
|
114
|
+
slug: null,
|
|
115
|
+
externalUrl: 'https://apps.example.com/external/index.html',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const emitApplications = (value: unknown[]) =>
|
|
119
|
+
host.connections.subscribe((client) =>
|
|
120
|
+
client.emit('applications.list', {ok: true, value} as ValueOf<'applications.list'>),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
describe('useApplications', () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
126
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
127
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
resetMessageBus()
|
|
132
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
133
|
+
vi.unstubAllGlobals()
|
|
134
|
+
vi.restoreAllMocks()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns minimal applications with loadable views and web workers', () => {
|
|
138
|
+
emitApplications([application, nonFederatedApplication, nonSingletonApplication])
|
|
139
|
+
|
|
140
|
+
const {result} = renderHook(() => useApplications())
|
|
141
|
+
|
|
142
|
+
expectTypeOf(result.current).toEqualTypeOf<DashboardApplication[]>()
|
|
143
|
+
const [federated, nonFederated, nonSingleton] = result.current
|
|
144
|
+
expect(federated).not.toHaveProperty('activeDeployment')
|
|
145
|
+
expect(federated).not.toHaveProperty('config')
|
|
146
|
+
expect(federated?.views).toEqual([
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
application: expect.objectContaining({id: 'application-1'}),
|
|
149
|
+
module: {
|
|
150
|
+
entry: 'https://inbox-apps-organization-1.sanity.run',
|
|
151
|
+
moduleId: 'application-1/App',
|
|
152
|
+
version: '1',
|
|
153
|
+
},
|
|
154
|
+
name: 'inbox',
|
|
155
|
+
surface: 'window',
|
|
156
|
+
}),
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
module: expect.objectContaining({moduleId: 'application-1/views/notifications'}),
|
|
159
|
+
name: 'notifications',
|
|
160
|
+
surface: 'panel',
|
|
161
|
+
}),
|
|
162
|
+
expect.objectContaining({name: 'summary', surface: 'tile'}),
|
|
163
|
+
expect.objectContaining({name: 'library', surface: 'asset_source'}),
|
|
164
|
+
])
|
|
165
|
+
expect(federated?.webWorkers).toEqual([
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
module: expect.objectContaining({moduleId: 'application-1/services/sync'}),
|
|
168
|
+
name: 'sync',
|
|
169
|
+
type: 'worker',
|
|
170
|
+
}),
|
|
171
|
+
])
|
|
172
|
+
expect(nonFederated).toMatchObject({views: [], webWorkers: []})
|
|
173
|
+
expect(nonSingleton?.views[0]?.module.entry).toBe('https://canvas.sanity.studio')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('follows topic updates without remapping unchanged lists', () => {
|
|
177
|
+
emitApplications([application])
|
|
178
|
+
const {result, rerender} = renderHook(() => useApplications())
|
|
179
|
+
const first = result.current
|
|
180
|
+
|
|
181
|
+
rerender()
|
|
182
|
+
expect(result.current).toBe(first)
|
|
183
|
+
|
|
184
|
+
act(() => emitApplications([application, nonSingletonApplication]))
|
|
185
|
+
expect(result.current.map(({id}) => id)).toEqual(['application-1', 'application-3'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('hosts external application modules at their external origin', () => {
|
|
189
|
+
emitApplications([externalApplication])
|
|
190
|
+
|
|
191
|
+
const {result} = renderHook(() => useApplications())
|
|
192
|
+
|
|
193
|
+
expect(result.current[0]?.views[0]?.module.entry).toBe('https://apps.example.com')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('exposes no views or web workers for an application without a resolvable origin', () => {
|
|
197
|
+
// One bad record must not take the whole list down for every consumer.
|
|
198
|
+
const unaddressable = {...application, id: 'application-5', slug: null, externalUrl: null}
|
|
199
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
200
|
+
emitApplications([unaddressable, nonSingletonApplication])
|
|
201
|
+
|
|
202
|
+
const {result} = renderHook(() => useApplications())
|
|
203
|
+
|
|
204
|
+
expect(result.current.map(({id, views, webWorkers}) => ({id, views, webWorkers}))).toEqual([
|
|
205
|
+
{id: 'application-5', views: [], webWorkers: []},
|
|
206
|
+
expect.objectContaining({id: 'application-3'}),
|
|
207
|
+
])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('uses the staging application origin', () => {
|
|
211
|
+
vi.stubGlobal('__SANITY_STAGING__', true)
|
|
212
|
+
emitApplications([application, nonSingletonApplication])
|
|
213
|
+
|
|
214
|
+
const {result} = renderHook(() => useApplications())
|
|
215
|
+
|
|
216
|
+
expect(result.current.map(({views}) => views[0]?.module.entry)).toEqual([
|
|
217
|
+
'https://inbox-apps-organization-1.run.sanity.work',
|
|
218
|
+
'https://canvas.studio.sanity.work',
|
|
219
|
+
])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns an empty list when the dashboard clears its applications', () => {
|
|
223
|
+
host.connections.subscribe((client) => client.emit('applications.list', null))
|
|
224
|
+
|
|
225
|
+
const {result} = renderHook(() => useApplications())
|
|
226
|
+
|
|
227
|
+
expect(result.current).toEqual([])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('throws a TopicError to the error boundary when the dashboard fails to load applications', () => {
|
|
231
|
+
host.connections.subscribe((client) => client.emit('applications.list', {ok: false}))
|
|
232
|
+
const onError = vi.fn()
|
|
233
|
+
|
|
234
|
+
function Applications() {
|
|
235
|
+
return <span>{useApplications().length} applications</span>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
render(
|
|
239
|
+
<ErrorBoundary fallback={<span>Failed</span>} onError={onError}>
|
|
240
|
+
<Suspense fallback="Loading">
|
|
241
|
+
<Applications />
|
|
242
|
+
</Suspense>
|
|
243
|
+
</ErrorBoundary>,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
expect(screen.getByText('Failed')).toBeInTheDocument()
|
|
247
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(TopicError)
|
|
248
|
+
})
|
|
249
|
+
})
|