@sanity/sdk-react 3.4.0 → 3.5.0-next.20260922182803

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.
@@ -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 DashboardTopicApplication = Extract<
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
- * A dashboard view exposed by an application.
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: ApplicationBase
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: ApplicationBase
49
+ readonly application: DashboardApplicationBase
35
50
  readonly module: RemoteModuleRef
36
51
  }
37
52
 
38
53
  /**
39
- * The minimal Brett application fields with its loadable views and web workers.
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 DashboardApplication = DashboardTopicApplication & {
43
- readonly views: DashboardView[]
44
- readonly webWorkers: DashboardWebWorker[]
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
- const loadableInterfaces = ({
49
- activeDeployment,
50
- config,
51
- }: DashboardTopicApplication): readonly DashboardApplicationInterface[] =>
52
- config?.mfManifest === undefined ? [] : (activeDeployment?.interfaces ?? [])
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
- const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
55
- const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
56
- const interfaces = loadableInterfaces(application)
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(applicationBase)
59
- if (entry === null) return {...application, views: [], webWorkers: []}
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: `${applicationBase.id}/${extension.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: applicationBase, module})
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: applicationBase,
113
+ application: base,
82
114
  module,
83
115
  surface: type === 'app' ? 'window' : type,
84
116
  } as DashboardView)
85
117
  }
86
118
 
87
- return {...application, views, webWorkers}
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 application list; a cleared list is empty. Throws a
94
- * `TopicError` to the nearest error boundary when the dashboard fails to load applications.
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(() => applications?.map(toApplication) ?? [], [applications])
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
- emitApplications([studio, coreApp])
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
- if (application.type !== 'studio') return []
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,