@sanity/sdk-react 3.4.0 → 3.5.0-next.20260922190712
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.d.ts +40 -12
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +49 -12
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +141 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -5
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.js → useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js} +2 -2
- package/dist/{useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.js.map → useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js.map} +1 -1
- package/package.json +2 -2
- package/src/_exports/dashboard.ts +2 -0
- package/src/_exports/sdk-react.ts +3 -0
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/useHandleOAuthCallback.tsx +46 -0
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +16 -0
- package/src/hooks/auth/useOAuthAuthorize.tsx +28 -0
- package/src/hooks/auth/useOAuthTokens.test.tsx +267 -0
- package/src/hooks/auth/useOAuthTokens.tsx +104 -0
- package/src/hooks/dashboard/useApplications.test.tsx +165 -7
- package/src/hooks/dashboard/useApplications.ts +112 -28
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +23 -1
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +2 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOAuthTokensState,
|
|
3
|
+
type OAuthTokens,
|
|
4
|
+
refreshOAuthTokens,
|
|
5
|
+
revokeOAuthTokens,
|
|
6
|
+
type SanityInstance,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
|
|
9
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
10
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The current OAuth token state, plus actions to refresh and revoke it.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export interface UseOAuthTokensResult {
|
|
18
|
+
/**
|
|
19
|
+
* The stored OAuth tokens, or `null` when not logged in via OAuth. The
|
|
20
|
+
* refresh token is omitted — core retains it internally for `refresh`.
|
|
21
|
+
*/
|
|
22
|
+
tokens: Omit<OAuthTokens, 'refreshToken'> | null
|
|
23
|
+
/**
|
|
24
|
+
* Returns whether the access token has expired, comparing the latest stored
|
|
25
|
+
* `expiresAt` against the current time at the moment it is called. Both the
|
|
26
|
+
* tokens and the clock are read at call time, so a reference captured in an
|
|
27
|
+
* earlier render stays accurate after a `refresh`. Reading the clock does not
|
|
28
|
+
* trigger a re-render, so call this in an event handler or effect rather than
|
|
29
|
+
* during render. Returns `false` when there are no tokens.
|
|
30
|
+
*/
|
|
31
|
+
isExpired: () => boolean
|
|
32
|
+
/**
|
|
33
|
+
* Refresh via the OAuth `refresh_token` grant. When there is no refresh token,
|
|
34
|
+
* core clears the stored tokens, logs the user out, and this resolves `null`.
|
|
35
|
+
* Rejects on transient failures (network, 5xx, 408, 429), leaving tokens
|
|
36
|
+
* unchanged so the call can be retried. Also rejects when the server rejects
|
|
37
|
+
* the refresh token itself (other 4xx); core clears the tokens and logs out
|
|
38
|
+
* first, so check `tokens` before retrying.
|
|
39
|
+
*/
|
|
40
|
+
refresh: () => Promise<Omit<OAuthTokens, 'refreshToken'> | null>
|
|
41
|
+
/** Revoke the tokens at the OAuth server, clear them locally, and log out. */
|
|
42
|
+
revoke: () => Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const useOAuthTokensState = createStateSourceHook(getOAuthTokensState)
|
|
46
|
+
const useRefreshOAuthTokens = createCallbackHook(refreshOAuthTokens)
|
|
47
|
+
const useRevokeOAuthTokens = createCallbackHook(revokeOAuthTokens)
|
|
48
|
+
|
|
49
|
+
// Reads core's state source directly rather than the render-time `tokens`
|
|
50
|
+
// snapshot, so a reference captured in an earlier render (e.g. in an event
|
|
51
|
+
// handler that awaits `refresh()`) still reflects the latest tokens.
|
|
52
|
+
const useIsOAuthTokenExpired = createCallbackHook((instance: SanityInstance): boolean => {
|
|
53
|
+
const tokens = getOAuthTokensState(instance).getCurrent()
|
|
54
|
+
return tokens ? tokens.expiresAt.getTime() <= Date.now() : false
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A React hook that exposes the stored OAuth token state along with `refresh`
|
|
59
|
+
* and `revoke` actions.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* The token view is a synchronous read over core's token state source, so the
|
|
63
|
+
* hook re-renders whenever tokens change — including changes made in another
|
|
64
|
+
* tab, which core propagates via `storage` events.
|
|
65
|
+
*
|
|
66
|
+
* @returns The current {@link UseOAuthTokensResult}
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```tsx
|
|
70
|
+
* function TokenStatus() {
|
|
71
|
+
* const {tokens, isExpired, refresh, revoke} = useOAuthTokens()
|
|
72
|
+
*
|
|
73
|
+
* if (!tokens) return <div>Not signed in</div>
|
|
74
|
+
*
|
|
75
|
+
* const handleRefresh = async () => {
|
|
76
|
+
* if (!isExpired()) return
|
|
77
|
+
* try {
|
|
78
|
+
* await refresh()
|
|
79
|
+
* } catch {
|
|
80
|
+
* // Transient failure (tokens unchanged, retry later) or the refresh
|
|
81
|
+
* // token was rejected (tokens now null, user is logged out).
|
|
82
|
+
* }
|
|
83
|
+
* }
|
|
84
|
+
*
|
|
85
|
+
* return (
|
|
86
|
+
* <div>
|
|
87
|
+
* <p>Expires at {tokens.expiresAt.toLocaleTimeString()}</p>
|
|
88
|
+
* <button onClick={handleRefresh}>Refresh if expired</button>
|
|
89
|
+
* <button onClick={() => revoke()}>Revoke tokens</button>
|
|
90
|
+
* </div>
|
|
91
|
+
* )
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export function useOAuthTokens(): UseOAuthTokensResult {
|
|
98
|
+
return {
|
|
99
|
+
tokens: useOAuthTokensState(),
|
|
100
|
+
isExpired: useIsOAuthTokenExpired(),
|
|
101
|
+
refresh: useRefreshOAuthTokens(),
|
|
102
|
+
revoke: useRevokeOAuthTokens(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Application,
|
|
3
|
+
type ApplicationInclude,
|
|
4
|
+
type Installation,
|
|
5
|
+
type InstallationInclude,
|
|
6
|
+
} from '@sanity/sdk'
|
|
1
7
|
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type LocalApplication,
|
|
10
|
+
type MessageBusHost,
|
|
11
|
+
TopicError,
|
|
12
|
+
type ValueOf,
|
|
13
|
+
} from '@sanity/sdk/dashboard'
|
|
3
14
|
import {Suspense} from 'react'
|
|
4
15
|
import {ErrorBoundary} from 'react-error-boundary'
|
|
5
16
|
import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
|
|
@@ -62,7 +73,7 @@ const application = {
|
|
|
62
73
|
title: 'Summary',
|
|
63
74
|
version: '1',
|
|
64
75
|
moduleId: 'views/summary',
|
|
65
|
-
metadata:
|
|
76
|
+
metadata: {size: 'small'},
|
|
66
77
|
},
|
|
67
78
|
{
|
|
68
79
|
id: 'asset-source-1',
|
|
@@ -84,7 +95,7 @@ const application = {
|
|
|
84
95
|
},
|
|
85
96
|
],
|
|
86
97
|
},
|
|
87
|
-
}
|
|
98
|
+
} satisfies Application<ApplicationInclude>
|
|
88
99
|
|
|
89
100
|
const nonFederatedApplication = {
|
|
90
101
|
...application,
|
|
@@ -95,7 +106,7 @@ const nonFederatedApplication = {
|
|
|
95
106
|
title: 'Legacy',
|
|
96
107
|
isSingleton: false,
|
|
97
108
|
config: {},
|
|
98
|
-
}
|
|
109
|
+
} satisfies Application<ApplicationInclude>
|
|
99
110
|
|
|
100
111
|
const nonSingletonApplication = {
|
|
101
112
|
...application,
|
|
@@ -105,7 +116,7 @@ const nonSingletonApplication = {
|
|
|
105
116
|
slug: 'canvas',
|
|
106
117
|
title: 'Canvas',
|
|
107
118
|
isSingleton: false,
|
|
108
|
-
}
|
|
119
|
+
} satisfies Application<ApplicationInclude>
|
|
109
120
|
|
|
110
121
|
const externalApplication = {
|
|
111
122
|
...application,
|
|
@@ -113,7 +124,57 @@ const externalApplication = {
|
|
|
113
124
|
name: 'external',
|
|
114
125
|
slug: null,
|
|
115
126
|
externalUrl: 'https://apps.example.com/external/index.html',
|
|
116
|
-
}
|
|
127
|
+
} satisfies Application<ApplicationInclude>
|
|
128
|
+
|
|
129
|
+
// The workbench synthesises a dev-server app into the deployed shape and marks it with `local`.
|
|
130
|
+
const localApplication = {
|
|
131
|
+
...application,
|
|
132
|
+
id: 'application-5',
|
|
133
|
+
name: 'dev',
|
|
134
|
+
slug: null,
|
|
135
|
+
isSingleton: false,
|
|
136
|
+
externalUrl: 'http://localhost:3333',
|
|
137
|
+
organizationId: 'local',
|
|
138
|
+
local: {host: 'localhost', port: 3333},
|
|
139
|
+
} satisfies LocalApplication
|
|
140
|
+
|
|
141
|
+
const installation = {
|
|
142
|
+
id: 'installation-1',
|
|
143
|
+
applicationId: 'app-remote-1',
|
|
144
|
+
organizationId: 'organization-1',
|
|
145
|
+
installedBy: 'user-1',
|
|
146
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
147
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
148
|
+
application: {
|
|
149
|
+
title: 'Remote App',
|
|
150
|
+
name: 'remote',
|
|
151
|
+
reference: 'sanity/remote',
|
|
152
|
+
slug: 'remote',
|
|
153
|
+
icon: null,
|
|
154
|
+
// Distinct from the installing org: the bundle is hosted under the publisher's org.
|
|
155
|
+
organizationId: 'organization-publisher',
|
|
156
|
+
},
|
|
157
|
+
interfaces: [
|
|
158
|
+
{
|
|
159
|
+
id: 'remote-view-1',
|
|
160
|
+
type: 'app',
|
|
161
|
+
name: 'remote',
|
|
162
|
+
title: 'Remote App',
|
|
163
|
+
version: '1',
|
|
164
|
+
moduleId: 'App',
|
|
165
|
+
metadata: null,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'remote-panel-1',
|
|
169
|
+
type: 'panel',
|
|
170
|
+
name: 'remote-panel',
|
|
171
|
+
title: 'Remote Panel',
|
|
172
|
+
version: '1',
|
|
173
|
+
moduleId: 'views/panel',
|
|
174
|
+
metadata: null,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
} satisfies Installation<InstallationInclude>
|
|
117
178
|
|
|
118
179
|
const emitApplications = (value: unknown[]) =>
|
|
119
180
|
host.connections.subscribe((client) =>
|
|
@@ -140,8 +201,12 @@ describe('useApplications', () => {
|
|
|
140
201
|
const {result} = renderHook(() => useApplications())
|
|
141
202
|
|
|
142
203
|
expectTypeOf(result.current).toEqualTypeOf<DashboardApplication[]>()
|
|
204
|
+
// The application member keeps its raw deployment fields; the union also admits installations.
|
|
143
205
|
expectTypeOf<
|
|
144
|
-
Extract<
|
|
206
|
+
Extract<
|
|
207
|
+
keyof Exclude<DashboardApplication, {type: 'installation'}>,
|
|
208
|
+
'activeDeployment' | 'config'
|
|
209
|
+
>
|
|
145
210
|
>().toEqualTypeOf<'activeDeployment' | 'config'>()
|
|
146
211
|
const [federated, nonFederated, nonSingleton] = result.current
|
|
147
212
|
expect(federated).toMatchObject({
|
|
@@ -180,6 +245,99 @@ describe('useApplications', () => {
|
|
|
180
245
|
])
|
|
181
246
|
expect(nonFederated).toMatchObject({views: [], webWorkers: []})
|
|
182
247
|
expect(nonSingleton?.views[0]?.module.entry).toBe('https://canvas.sanity.studio')
|
|
248
|
+
expect(result.current.map(({isLocal}) => isLocal)).toEqual([false, false, false])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('marks dev-server applications local and loads their modules from the dev server', () => {
|
|
252
|
+
emitApplications([application, localApplication, installation])
|
|
253
|
+
|
|
254
|
+
const {result} = renderHook(() => useApplications())
|
|
255
|
+
|
|
256
|
+
expect(result.current.map(({id, isLocal}) => [id, isLocal])).toEqual([
|
|
257
|
+
['application-1', false],
|
|
258
|
+
['application-5', true],
|
|
259
|
+
['installation-1', false],
|
|
260
|
+
])
|
|
261
|
+
const local = result.current[1]
|
|
262
|
+
expect(local).toMatchObject({type: 'coreApp', local: {host: 'localhost', port: 3333}})
|
|
263
|
+
expect(local?.views[0]?.module.entry).toBe('http://localhost:3333')
|
|
264
|
+
expect(local?.views[0]?.application).toMatchObject({id: 'application-5', isLocal: true})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('shapes installations alongside applications in one consistent shape', () => {
|
|
268
|
+
emitApplications([application, installation])
|
|
269
|
+
|
|
270
|
+
const {result} = renderHook(() => useApplications())
|
|
271
|
+
|
|
272
|
+
const shaped = result.current.find(({id}) => id === 'installation-1')
|
|
273
|
+
expect(shaped).toMatchObject({
|
|
274
|
+
id: 'installation-1',
|
|
275
|
+
type: 'installation',
|
|
276
|
+
title: 'Remote App',
|
|
277
|
+
name: 'remote',
|
|
278
|
+
reference: 'sanity/remote',
|
|
279
|
+
slug: 'remote',
|
|
280
|
+
icon: null,
|
|
281
|
+
isSingleton: true,
|
|
282
|
+
visibility: 'default',
|
|
283
|
+
externalUrl: null,
|
|
284
|
+
organizationId: 'organization-publisher',
|
|
285
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
286
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
287
|
+
installation,
|
|
288
|
+
})
|
|
289
|
+
// Raw record fields must not leak onto the shaped entry, nor the raw record onto its views.
|
|
290
|
+
expect(shaped).not.toHaveProperty('applicationId')
|
|
291
|
+
expect(shaped).not.toHaveProperty('installedBy')
|
|
292
|
+
expect(shaped?.views[0]?.application).not.toHaveProperty('installation')
|
|
293
|
+
expect(shaped?.views).toEqual([
|
|
294
|
+
expect.objectContaining({
|
|
295
|
+
name: 'remote',
|
|
296
|
+
surface: 'window',
|
|
297
|
+
module: {
|
|
298
|
+
entry: 'https://remote-apps-organization-publisher.sanity.run',
|
|
299
|
+
moduleId: 'installation-1/App',
|
|
300
|
+
version: '1',
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
expect.objectContaining({
|
|
304
|
+
name: 'remote-panel',
|
|
305
|
+
surface: 'panel',
|
|
306
|
+
module: expect.objectContaining({moduleId: 'installation-1/views/panel'}),
|
|
307
|
+
}),
|
|
308
|
+
])
|
|
309
|
+
expect(shaped?.webWorkers).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('exposes no views for an installation without interfaces', () => {
|
|
313
|
+
const {interfaces: _interfaces, ...withoutInterfaces} = installation
|
|
314
|
+
emitApplications([withoutInterfaces])
|
|
315
|
+
|
|
316
|
+
const {result} = renderHook(() => useApplications())
|
|
317
|
+
|
|
318
|
+
expect(result.current[0]).toMatchObject({id: 'installation-1', views: [], webWorkers: []})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('exposes no views for an installation without a slug to derive an origin from', () => {
|
|
322
|
+
emitApplications([{...installation, application: {...installation.application, slug: null}}])
|
|
323
|
+
|
|
324
|
+
const {result} = renderHook(() => useApplications())
|
|
325
|
+
|
|
326
|
+
expect(result.current[0]).toMatchObject({id: 'installation-1', views: [], webWorkers: []})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('loads a dev-server application without a module federation manifest', () => {
|
|
330
|
+
const {config: _config, ...withoutManifest} = localApplication
|
|
331
|
+
emitApplications([withoutManifest])
|
|
332
|
+
|
|
333
|
+
const {result} = renderHook(() => useApplications())
|
|
334
|
+
|
|
335
|
+
expect(result.current[0]?.views.map(({name}) => name)).toEqual([
|
|
336
|
+
'inbox',
|
|
337
|
+
'notifications',
|
|
338
|
+
'summary',
|
|
339
|
+
'library',
|
|
340
|
+
])
|
|
183
341
|
})
|
|
184
342
|
|
|
185
343
|
it('follows topic updates without remapping unchanged lists', () => {
|
|
@@ -1,62 +1,94 @@
|
|
|
1
|
-
import {type ApplicationBase} from '@sanity/sdk'
|
|
1
|
+
import {type ApplicationBase, type Installation, type InstallationInclude} from '@sanity/sdk'
|
|
2
2
|
import {getApplicationOrigin} from '@sanity/sdk/_internal'
|
|
3
3
|
import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
|
|
4
4
|
import {useMemo} from 'react'
|
|
5
5
|
|
|
6
6
|
import {useTopic} from './useTopic'
|
|
7
7
|
|
|
8
|
-
type
|
|
8
|
+
type DashboardTopicEntry = Extract<
|
|
9
9
|
NonNullable<ValueOf<'applications.list'>>,
|
|
10
10
|
{ok: true}
|
|
11
11
|
>['value'][number]
|
|
12
|
+
// The published union distinguishes an `Application` (has `type`) from an `Installation` (has not).
|
|
13
|
+
type DashboardTopicApplication = Extract<DashboardTopicEntry, {type: unknown}>
|
|
14
|
+
type DashboardTopicInstallation = Exclude<DashboardTopicEntry, {type: unknown}>
|
|
12
15
|
type DashboardApplicationInterface = NonNullable<
|
|
13
16
|
NonNullable<DashboardTopicApplication['activeDeployment']>['interfaces']
|
|
14
17
|
>[number]
|
|
15
18
|
type ViewInterface = Exclude<DashboardApplicationInterface, {type: 'worker'}>
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
*
|
|
21
|
+
* The shared identity fields attached to each {@link DashboardView.application} and
|
|
22
|
+
* {@link DashboardWebWorker.application}, and the input to module loading. Widens
|
|
23
|
+
* {@link ApplicationBase} so `type` also covers installations.
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export type DashboardApplicationBase = Omit<ApplicationBase, 'type'> & {
|
|
27
|
+
readonly type: ApplicationBase['type'] | 'installation'
|
|
28
|
+
/** Served by a CLI dev server rather than a deployment. */
|
|
29
|
+
readonly isLocal: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A dashboard view exposed by an application or installation.
|
|
19
34
|
* @public
|
|
20
35
|
*/
|
|
21
36
|
export type DashboardView = {
|
|
22
37
|
[Type in ViewInterface['type']]: Omit<Extract<ViewInterface, {type: Type}>, 'type'> & {
|
|
23
|
-
readonly application:
|
|
38
|
+
readonly application: DashboardApplicationBase
|
|
24
39
|
readonly module: RemoteModuleRef
|
|
25
40
|
readonly surface: Type extends 'app' ? 'window' : Type
|
|
26
41
|
}
|
|
27
42
|
}[ViewInterface['type']]
|
|
28
43
|
|
|
29
44
|
/**
|
|
30
|
-
* A web worker exposed by an application.
|
|
45
|
+
* A web worker exposed by an application or installation.
|
|
31
46
|
* @public
|
|
32
47
|
*/
|
|
33
48
|
export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: 'worker'}> & {
|
|
34
|
-
readonly application:
|
|
49
|
+
readonly application: DashboardApplicationBase
|
|
35
50
|
readonly module: RemoteModuleRef
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
|
-
*
|
|
54
|
+
* An installation shaped into the shared application base, with its raw record kept under
|
|
55
|
+
* `installation` for consumers that need the full record.
|
|
40
56
|
* @public
|
|
41
57
|
*/
|
|
42
|
-
export type
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
58
|
+
export type DashboardInstallation = Omit<DashboardApplicationBase, 'type'> & {
|
|
59
|
+
readonly type: 'installation'
|
|
60
|
+
readonly installation: Installation<InstallationInclude>
|
|
45
61
|
}
|
|
46
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A dashboard entry — an application or an installation — in one consistent shape, with its
|
|
65
|
+
* loadable views and web workers. `type` distinguishes the kinds and `isLocal` marks dev-server
|
|
66
|
+
* applications; render a list without branching.
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
export type DashboardApplication = (DashboardTopicApplication | DashboardInstallation) &
|
|
70
|
+
Pick<DashboardApplicationBase, 'isLocal'> & {
|
|
71
|
+
readonly views: DashboardView[]
|
|
72
|
+
readonly webWorkers: DashboardWebWorker[]
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
// Only a federated deployment (one with a module federation manifest) exposes loadable modules.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
config,
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
// A dev server is always federated, whether or not the workbench synthesised a manifest for it.
|
|
77
|
+
const loadableInterfaces = (
|
|
78
|
+
{activeDeployment, config}: DashboardTopicApplication,
|
|
79
|
+
isLocal: boolean,
|
|
80
|
+
): readonly DashboardApplicationInterface[] =>
|
|
81
|
+
isLocal || config?.mfManifest !== undefined ? (activeDeployment?.interfaces ?? []) : []
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
// Turns an interface list into views and web workers loaded from `base`'s origin. Shared by
|
|
84
|
+
// applications and installations so the surface mapping lives in one place.
|
|
85
|
+
const loadModules = (
|
|
86
|
+
base: DashboardApplicationBase,
|
|
87
|
+
interfaces: readonly DashboardApplicationInterface[],
|
|
88
|
+
): {views: DashboardView[]; webWorkers: DashboardWebWorker[]} => {
|
|
57
89
|
// Nothing to load without interfaces or an origin to load them from.
|
|
58
|
-
const entry = interfaces.length === 0 ? null : getApplicationOrigin(
|
|
59
|
-
if (entry === null) return {
|
|
90
|
+
const entry = interfaces.length === 0 ? null : getApplicationOrigin(base)
|
|
91
|
+
if (entry === null) return {views: [], webWorkers: []}
|
|
60
92
|
|
|
61
93
|
const views: DashboardView[] = []
|
|
62
94
|
const webWorkers: DashboardWebWorker[] = []
|
|
@@ -64,12 +96,12 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
|
|
|
64
96
|
for (const extension of interfaces) {
|
|
65
97
|
const module: RemoteModuleRef = {
|
|
66
98
|
entry,
|
|
67
|
-
moduleId: `${
|
|
99
|
+
moduleId: `${base.id}/${extension.moduleId}`,
|
|
68
100
|
version: extension.version,
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
if (extension.type === 'worker') {
|
|
72
|
-
webWorkers.push({...extension, application:
|
|
104
|
+
webWorkers.push({...extension, application: base, module})
|
|
73
105
|
continue
|
|
74
106
|
}
|
|
75
107
|
|
|
@@ -78,20 +110,66 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
|
|
|
78
110
|
// cast is checked by the `DashboardView` mapping above and the surface assertions in the tests.
|
|
79
111
|
views.push({
|
|
80
112
|
...view,
|
|
81
|
-
application:
|
|
113
|
+
application: base,
|
|
82
114
|
module,
|
|
83
115
|
surface: type === 'app' ? 'window' : type,
|
|
84
116
|
} as DashboardView)
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
return {
|
|
119
|
+
return {views, webWorkers}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
|
|
123
|
+
const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
|
|
124
|
+
const isLocal = 'local' in application
|
|
125
|
+
return {
|
|
126
|
+
...application,
|
|
127
|
+
isLocal,
|
|
128
|
+
...loadModules({...applicationBase, isLocal}, loadableInterfaces(application, isLocal)),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const toInstallation = (installation: DashboardTopicInstallation): DashboardApplication => {
|
|
133
|
+
const {application} = installation
|
|
134
|
+
// `id` is the installation record's id, not its `applicationId`, so two installs of one app never
|
|
135
|
+
// collide. `organizationId` is the publisher's: the singleton bundle is hosted under the org that
|
|
136
|
+
// published it, and `isSingleton: true` with `externalUrl: null` routes the origin through
|
|
137
|
+
// `getApplicationOrigin`'s singleton branch (`https://<slug>-apps-<publisherOrgId>.sanity.run`).
|
|
138
|
+
// The installing org stays on the raw record. The workbench always serves installations
|
|
139
|
+
// federated, so their interfaces are never gated.
|
|
140
|
+
const base: DashboardApplicationBase = {
|
|
141
|
+
id: installation.id,
|
|
142
|
+
type: 'installation',
|
|
143
|
+
isLocal: false,
|
|
144
|
+
title: application.title,
|
|
145
|
+
name: application.name,
|
|
146
|
+
reference: application.reference,
|
|
147
|
+
icon: application.icon,
|
|
148
|
+
isSingleton: true,
|
|
149
|
+
visibility: 'default',
|
|
150
|
+
slug: application.slug,
|
|
151
|
+
externalUrl: null,
|
|
152
|
+
organizationId: application.organizationId,
|
|
153
|
+
createdAt: installation.createdAt,
|
|
154
|
+
updatedAt: installation.updatedAt,
|
|
155
|
+
}
|
|
156
|
+
// The raw record rides only the entry, not every view's `application`.
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
type: 'installation',
|
|
160
|
+
installation,
|
|
161
|
+
...loadModules(base, installation.interfaces ?? []),
|
|
162
|
+
}
|
|
88
163
|
}
|
|
89
164
|
|
|
90
165
|
/**
|
|
91
|
-
* Returns the applications available in the dashboard.
|
|
166
|
+
* Returns the applications and installations available in the dashboard, in one consistent shape.
|
|
92
167
|
*
|
|
93
|
-
* Suspends until the dashboard publishes its
|
|
94
|
-
*
|
|
168
|
+
* Suspends until the dashboard publishes its list; a cleared list is empty. Throws a `TopicError`
|
|
169
|
+
* to the nearest error boundary when the dashboard fails to load. Every entry exposes the same base
|
|
170
|
+
* fields plus `views` and `webWorkers`; `type` distinguishes applications (`'studio' | 'coreApp'`)
|
|
171
|
+
* from installations (`'installation'`), which also carry the raw record under `installation`.
|
|
172
|
+
* `isLocal` marks applications served by a CLI dev server.
|
|
95
173
|
*
|
|
96
174
|
* @example
|
|
97
175
|
* ```tsx
|
|
@@ -105,5 +183,11 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
|
|
|
105
183
|
*/
|
|
106
184
|
export function useApplications(): DashboardApplication[] {
|
|
107
185
|
const applications = useTopic('applications.list')
|
|
108
|
-
return useMemo(
|
|
186
|
+
return useMemo(
|
|
187
|
+
() =>
|
|
188
|
+
applications?.map((entry) =>
|
|
189
|
+
'type' in entry ? toApplication(entry) : toInstallation(entry),
|
|
190
|
+
) ?? [],
|
|
191
|
+
[applications],
|
|
192
|
+
)
|
|
109
193
|
}
|
|
@@ -305,7 +305,22 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
|
|
|
305
305
|
})
|
|
306
306
|
|
|
307
307
|
it('maps studio workspaces to resources keyed by projectId:dataset', () => {
|
|
308
|
-
|
|
308
|
+
// An installation (no `type`) shares the topic; the hook must tolerate it and map only studios.
|
|
309
|
+
const installation = {id: 'installation-1', applicationId: 'app-remote-1'}
|
|
310
|
+
// A dev-server studio is still a studio, addressed by its dev server.
|
|
311
|
+
const localStudio = {
|
|
312
|
+
...studio,
|
|
313
|
+
id: 'studio-local',
|
|
314
|
+
slug: null,
|
|
315
|
+
externalUrl: 'http://localhost:3333',
|
|
316
|
+
local: {host: 'localhost', port: 3333},
|
|
317
|
+
activeDeployment: {
|
|
318
|
+
...deployment,
|
|
319
|
+
applicationId: 'studio-local',
|
|
320
|
+
workspaces: [{...workspace, id: 'workspace-local', projectId: 'project3'}],
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
emitApplications([studio, coreApp, installation, localStudio])
|
|
309
324
|
|
|
310
325
|
const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
|
|
311
326
|
|
|
@@ -327,6 +342,13 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
|
|
|
327
342
|
expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
|
|
328
343
|
],
|
|
329
344
|
'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
|
|
345
|
+
'project3:dataset1': [
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
id: 'workspace-local',
|
|
348
|
+
userApplicationId: 'studio-local',
|
|
349
|
+
url: 'http://localhost:3333',
|
|
350
|
+
}),
|
|
351
|
+
],
|
|
330
352
|
})
|
|
331
353
|
})
|
|
332
354
|
|
|
@@ -79,7 +79,8 @@ export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult
|
|
|
79
79
|
// The legacy Comlink protocol models studios at the workspace level, so each workspace of a
|
|
80
80
|
// studio's active deployment becomes one resource, addressed by the studio's origin.
|
|
81
81
|
function toResources(application: DashboardApplications[number]): DashboardResource[] {
|
|
82
|
-
|
|
82
|
+
// Installations join the topic union without a `type`; only studios yield workspace resources.
|
|
83
|
+
if (!('type' in application) || application.type !== 'studio') return []
|
|
83
84
|
const url = getApplicationOrigin(application) ?? ''
|
|
84
85
|
return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
|
|
85
86
|
id: workspace.id,
|