@sanity/sdk-react 3.0.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 +498 -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 +10 -115
- package/dist/index.js.map +1 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js +314 -0
- 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 +71 -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 +70 -20
- 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 +4 -4
- 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/src/utils/resolveOrgResources.test.ts +2 -2
- package/src/utils/resolveOrgResources.ts +2 -2
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +0 -177
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {type ApplicationBase} from '@sanity/sdk'
|
|
2
|
+
import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {useMemo} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useTopic} from './useTopic'
|
|
6
|
+
|
|
7
|
+
type DashboardTopicApplication = Extract<
|
|
8
|
+
NonNullable<ValueOf<'applications.list'>>,
|
|
9
|
+
{ok: true}
|
|
10
|
+
>['value'][number]
|
|
11
|
+
type DashboardApplicationInterface = NonNullable<
|
|
12
|
+
NonNullable<DashboardTopicApplication['activeDeployment']>['interfaces']
|
|
13
|
+
>[number]
|
|
14
|
+
type ViewInterface = Exclude<DashboardApplicationInterface, {type: 'worker'}>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A dashboard view exposed by an application.
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export type DashboardView = {
|
|
21
|
+
[Type in ViewInterface['type']]: Omit<Extract<ViewInterface, {type: Type}>, 'type'> & {
|
|
22
|
+
readonly application: ApplicationBase
|
|
23
|
+
readonly module: RemoteModuleRef
|
|
24
|
+
readonly surface: Type extends 'app' ? 'window' : Type
|
|
25
|
+
}
|
|
26
|
+
}[ViewInterface['type']]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A web worker exposed by an application.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: 'worker'}> & {
|
|
33
|
+
readonly application: ApplicationBase
|
|
34
|
+
readonly module: RemoteModuleRef
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The minimal Brett application fields with its loadable views and web workers.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export type DashboardApplication = ApplicationBase & {
|
|
42
|
+
readonly views: DashboardView[]
|
|
43
|
+
readonly webWorkers: DashboardWebWorker[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
|
|
47
|
+
|
|
48
|
+
// `null` when neither address exists: the caller exposes no modules for that application
|
|
49
|
+
// rather than failing the whole list on one bad record.
|
|
50
|
+
const applicationOrigin = (application: ApplicationBase): string | null => {
|
|
51
|
+
if (application.externalUrl !== null) return new URL(application.externalUrl).origin
|
|
52
|
+
if (application.slug === null) return null
|
|
53
|
+
|
|
54
|
+
// Read at runtime, not via a bundler define: a remote is built once and runs in whichever
|
|
55
|
+
// host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
|
|
56
|
+
const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
|
|
57
|
+
if (application.isSingleton) {
|
|
58
|
+
const domain = staging ? 'run.sanity.work' : 'sanity.run'
|
|
59
|
+
return `https://${application.slug}-apps-${application.organizationId}.${domain}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
|
|
63
|
+
return `https://${application.slug}.${domain}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Only a federated deployment (one with a module federation manifest) exposes loadable modules.
|
|
67
|
+
const loadableInterfaces = ({
|
|
68
|
+
activeDeployment,
|
|
69
|
+
config,
|
|
70
|
+
}: DashboardTopicApplication): readonly DashboardApplicationInterface[] =>
|
|
71
|
+
config?.mfManifest === undefined ? [] : (activeDeployment?.interfaces ?? [])
|
|
72
|
+
|
|
73
|
+
const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
|
|
74
|
+
const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
|
|
75
|
+
const interfaces = loadableInterfaces(application)
|
|
76
|
+
// Nothing to load without interfaces or an origin to load them from.
|
|
77
|
+
const entry = interfaces.length === 0 ? null : applicationOrigin(applicationBase)
|
|
78
|
+
if (entry === null) return {...applicationBase, views: [], webWorkers: []}
|
|
79
|
+
|
|
80
|
+
const views: DashboardView[] = []
|
|
81
|
+
const webWorkers: DashboardWebWorker[] = []
|
|
82
|
+
|
|
83
|
+
for (const extension of interfaces) {
|
|
84
|
+
const module: RemoteModuleRef = {
|
|
85
|
+
entry,
|
|
86
|
+
moduleId: `${applicationBase.id}/${extension.moduleId}`,
|
|
87
|
+
version: extension.version,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (extension.type === 'worker') {
|
|
91
|
+
webWorkers.push({...extension, application: applicationBase, module})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const {type, ...view} = extension
|
|
96
|
+
// TS cannot correlate `surface` with the narrowed `type` across the mapped union; the
|
|
97
|
+
// cast is checked by the `DashboardView` mapping above and the surface assertions in the tests.
|
|
98
|
+
views.push({
|
|
99
|
+
...view,
|
|
100
|
+
application: applicationBase,
|
|
101
|
+
module,
|
|
102
|
+
surface: type === 'app' ? 'window' : type,
|
|
103
|
+
} as DashboardView)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {...applicationBase, views, webWorkers}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the applications available in the dashboard.
|
|
111
|
+
*
|
|
112
|
+
* Suspends until the dashboard publishes its application list; a cleared list is empty. Throws a
|
|
113
|
+
* `TopicError` to the nearest error boundary when the dashboard fails to load applications.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* function Applications() {
|
|
118
|
+
* const applications = useApplications()
|
|
119
|
+
* return applications.map((application) => <div key={application.id}>{application.title}</div>)
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @public
|
|
124
|
+
*/
|
|
125
|
+
export function useApplications(): DashboardApplication[] {
|
|
126
|
+
const applications = useTopic('applications.list')
|
|
127
|
+
return useMemo(() => applications?.map(toApplication) ?? [], [applications])
|
|
128
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {
|
|
3
|
+
type MessageBusEmitOptions,
|
|
4
|
+
type MessageBusEmitResult,
|
|
5
|
+
type MessageBusHost,
|
|
6
|
+
type PayloadOf,
|
|
7
|
+
type ReplyOf,
|
|
8
|
+
} from '@sanity/sdk/dashboard'
|
|
9
|
+
import {Suspense, use, useState} from 'react'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {act, render, renderHook, screen} from '../../../test/test-utils'
|
|
13
|
+
import {useEmit} from './useEmit'
|
|
14
|
+
|
|
15
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
16
|
+
|
|
17
|
+
let host: MessageBusHost
|
|
18
|
+
|
|
19
|
+
describe('useEmit', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// The SDK resolves its own app ID from the CLI-embedded global.
|
|
22
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
23
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetMessageBus()
|
|
28
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
29
|
+
vi.unstubAllGlobals()
|
|
30
|
+
vi.restoreAllMocks()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns a stable, typed emitter and resolves with the topic reply', async () => {
|
|
34
|
+
host.subscribe('auth.token.refresh', (message) => message.reply('token'))
|
|
35
|
+
const {result, rerender} = renderHook(() => useEmit('auth.token.refresh'))
|
|
36
|
+
const emit = result.current
|
|
37
|
+
|
|
38
|
+
expectTypeOf(emit).toEqualTypeOf<
|
|
39
|
+
(
|
|
40
|
+
payload?: void,
|
|
41
|
+
options?: MessageBusEmitOptions,
|
|
42
|
+
) => MessageBusEmitResult<ReplyOf<'auth.token.refresh'>>
|
|
43
|
+
>()
|
|
44
|
+
|
|
45
|
+
await expect(emit()).resolves.toBe('token')
|
|
46
|
+
|
|
47
|
+
rerender()
|
|
48
|
+
expect(result.current).toBe(emit)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('delivers fire-and-forget payloads', () => {
|
|
52
|
+
const payloads: PayloadOf<'panels.mode.set'>[] = []
|
|
53
|
+
host.subscribe('panels.mode.set', (message) => payloads.push(message.payload))
|
|
54
|
+
const {result} = renderHook(() => useEmit('panels.mode.set'))
|
|
55
|
+
const payload = {name: 'comments', mode: 'aside'} as const
|
|
56
|
+
|
|
57
|
+
result.current(payload)
|
|
58
|
+
|
|
59
|
+
expect(payloads).toEqual([payload])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('forwards emit options to the bus', async () => {
|
|
63
|
+
host.subscribe('auth.token.refresh', () => {
|
|
64
|
+
// never replies; the caller's signal must be what ends the wait
|
|
65
|
+
})
|
|
66
|
+
const {result} = renderHook(() => useEmit('auth.token.refresh'))
|
|
67
|
+
|
|
68
|
+
await expect(result.current(undefined, {signal: AbortSignal.abort()})).rejects.toMatchObject({
|
|
69
|
+
code: 'ABORTED',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('leaves an ignored result unawaited, so a missing responder is not an unhandled rejection', async () => {
|
|
74
|
+
// No responder for the topic: awaiting the result would reject with NO_RESPONDER. Vitest
|
|
75
|
+
// fails the run on an unhandled rejection, so reaching the end of this test is the assertion.
|
|
76
|
+
const {result} = renderHook(() => useEmit('auth.token.refresh'))
|
|
77
|
+
|
|
78
|
+
result.current()
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('can suspend on an emitted reply', async () => {
|
|
83
|
+
let reply: (() => void) | undefined
|
|
84
|
+
host.subscribe('auth.token.refresh', (message) => {
|
|
85
|
+
reply = () => message.reply('token')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function Token({request}: {request: MessageBusEmitResult<string>}) {
|
|
89
|
+
return use(request)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function RefreshToken() {
|
|
93
|
+
const emit = useEmit('auth.token.refresh')
|
|
94
|
+
const [request, setRequest] = useState<MessageBusEmitResult<string> | null>(null)
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<button onClick={() => setRequest(emit())}>Refresh</button>
|
|
98
|
+
<Suspense fallback="Loading">{request && <Token request={request} />}</Suspense>
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(<RefreshToken />)
|
|
104
|
+
await act(async () => {
|
|
105
|
+
screen.getByRole('button').click()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(screen.getByText('Loading')).toBeInTheDocument()
|
|
109
|
+
|
|
110
|
+
await act(async () => reply?.())
|
|
111
|
+
|
|
112
|
+
expect(await screen.findByText('token')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('throws when used outside a dashboard application', () => {
|
|
116
|
+
resetMessageBus()
|
|
117
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
118
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
119
|
+
|
|
120
|
+
expect(() => renderHook(() => useEmit('auth.token.refresh'))).toThrow(
|
|
121
|
+
'Cannot emit topic "auth.token.refresh" without an installed dashboard message bus',
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
})
|