@sanity/sdk-react 3.4.0-rc.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.
Files changed (41) hide show
  1. package/dist/_exports/dashboard.d.ts +151 -45
  2. package/dist/_exports/dashboard.d.ts.map +1 -1
  3. package/dist/_exports/dashboard.js +267 -125
  4. package/dist/_exports/dashboard.js.map +1 -1
  5. package/dist/index.d.ts +21 -141
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +66 -147
  8. package/dist/index.js.map +1 -1
  9. package/dist/{useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js → useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js} +61 -4
  10. package/dist/useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js.map +1 -0
  11. package/package.json +9 -9
  12. package/src/_exports/dashboard.test-d.ts +3 -0
  13. package/src/_exports/dashboard.ts +13 -2
  14. package/src/_exports/sdk-react.ts +0 -3
  15. package/src/components/auth/AuthBoundary.test.tsx +2 -81
  16. package/src/components/auth/AuthBoundary.tsx +3 -17
  17. package/src/components/auth/LoginCallback.test.tsx +7 -46
  18. package/src/components/auth/LoginCallback.tsx +4 -22
  19. package/src/hooks/dashboard/useAgentResourceContext.test.tsx +67 -2
  20. package/src/hooks/dashboard/useAgentResourceContext.ts +35 -6
  21. package/src/hooks/dashboard/useApplicationContext.test.tsx +95 -0
  22. package/src/hooks/dashboard/useApplicationContext.ts +47 -0
  23. package/src/hooks/dashboard/useApplications.test.tsx +165 -7
  24. package/src/hooks/dashboard/useApplications.ts +112 -28
  25. package/src/hooks/dashboard/useCapabilities.test.tsx +84 -0
  26. package/src/hooks/dashboard/useCapabilities.ts +27 -0
  27. package/src/hooks/dashboard/useNavigate.test.ts +392 -5
  28. package/src/hooks/dashboard/useNavigate.ts +169 -26
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
  30. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
  31. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
  32. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
  33. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +23 -1
  34. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +2 -1
  35. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
  36. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  37. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  38. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  39. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  40. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  41. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -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
  }
@@ -0,0 +1,84 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type CapabilityRecord, 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 {useCapabilities} from './useCapabilities'
8
+
9
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
10
+
11
+ let host: MessageBusHost
12
+
13
+ describe('useCapabilities', () => {
14
+ beforeEach(() => {
15
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
16
+ host = installMessageBus({appId: 'dashboard'})
17
+ })
18
+
19
+ afterEach(() => {
20
+ resetMessageBus()
21
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
22
+ vi.unstubAllGlobals()
23
+ })
24
+
25
+ it('suspends until the host publishes, then follows updates', async () => {
26
+ function Capabilities() {
27
+ const capabilities = useCapabilities()
28
+ expectTypeOf(capabilities).toEqualTypeOf<CapabilityRecord>()
29
+ return <span>{capabilities.favorites ? 'favorites' : 'no favorites'}</span>
30
+ }
31
+
32
+ render(
33
+ <Suspense fallback="Loading">
34
+ <Capabilities />
35
+ </Suspense>,
36
+ )
37
+
38
+ expect(screen.getByText('Loading')).toBeInTheDocument()
39
+
40
+ await act(async () => {
41
+ host.connections.subscribe((client) =>
42
+ client.emit('applications.capabilities', {globalUserMenu: true, history: true}),
43
+ )
44
+ })
45
+ expect(await screen.findByText('no favorites')).toBeInTheDocument()
46
+
47
+ act(() =>
48
+ host.connections.subscribe((client) =>
49
+ client.emit('applications.capabilities', {favorites: true}),
50
+ ),
51
+ )
52
+ expect(screen.getByText('favorites')).toBeInTheDocument()
53
+ })
54
+
55
+ it('treats an empty record as published, not unpublished', async () => {
56
+ function Capabilities() {
57
+ const {globalUserMenu} = useCapabilities()
58
+ return <span>{globalUserMenu ? 'menu' : 'no capabilities'}</span>
59
+ }
60
+
61
+ render(
62
+ <Suspense fallback="Loading">
63
+ <Capabilities />
64
+ </Suspense>,
65
+ )
66
+
67
+ expect(screen.getByText('Loading')).toBeInTheDocument()
68
+
69
+ await act(async () => {
70
+ host.connections.subscribe((client) => client.emit('applications.capabilities', {}))
71
+ })
72
+ expect(await screen.findByText('no capabilities')).toBeInTheDocument()
73
+ })
74
+
75
+ it('throws when used outside a dashboard application', () => {
76
+ resetMessageBus()
77
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
78
+ vi.spyOn(console, 'error').mockImplementation(() => {})
79
+
80
+ expect(() => renderHook(() => useCapabilities())).toThrow(
81
+ 'Cannot read topic "applications.capabilities" without an installed dashboard message bus',
82
+ )
83
+ })
84
+ })
@@ -0,0 +1,27 @@
1
+ import {type TopicData} from '@sanity/sdk/dashboard'
2
+
3
+ import {useTopic} from './useTopic'
4
+
5
+ /**
6
+ * Returns the capabilities the host provides.
7
+ *
8
+ * A capability is something the host provides; an application reads this record to hide its own
9
+ * implementation of anything the host provides, or to skip publishing to a capability the host
10
+ * does not have. A missing key means the host does not provide that capability.
11
+ *
12
+ * Suspends until the host publishes the record.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * function UserMenu() {
17
+ * const {globalUserMenu} = useCapabilities()
18
+ * if (globalUserMenu) return null
19
+ * return <LocalUserMenu />
20
+ * }
21
+ * ```
22
+ *
23
+ * @public
24
+ */
25
+ export function useCapabilities(): TopicData<'applications.capabilities'> {
26
+ return useTopic('applications.capabilities')
27
+ }