@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.
Files changed (57) hide show
  1. package/dist/_exports/dashboard-internal.d.ts +2 -0
  2. package/dist/_exports/dashboard-internal.js +2 -0
  3. package/dist/_exports/dashboard.d.ts +498 -7
  4. package/dist/_exports/dashboard.d.ts.map +1 -1
  5. package/dist/_exports/dashboard.js +623 -2
  6. package/dist/_exports/dashboard.js.map +1 -1
  7. package/dist/index.d.ts +90 -87
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +10 -115
  10. package/dist/index.js.map +1 -1
  11. package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js +314 -0
  12. package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
  13. package/package.json +34 -32
  14. package/src/_exports/dashboard-internal.ts +1 -0
  15. package/src/_exports/dashboard.test-d.ts +64 -0
  16. package/src/_exports/dashboard.ts +71 -0
  17. package/src/_exports/index.ts +1 -1
  18. package/src/components/auth/AuthBoundary.test.tsx +34 -0
  19. package/src/components/auth/AuthBoundary.tsx +1 -2
  20. package/src/context/DashboardTokenRefresh.test.tsx +124 -22
  21. package/src/context/DashboardTokenRefresh.tsx +70 -20
  22. package/src/dashboard/createRemoteInstance.test.ts +168 -0
  23. package/src/dashboard/createRemoteInstance.ts +123 -0
  24. package/src/dashboard/module.ts +39 -0
  25. package/src/dashboard/remoteClientState.ts +27 -0
  26. package/src/dashboard/urlFor.test.ts +246 -0
  27. package/src/dashboard/urlFor.ts +462 -0
  28. package/src/hooks/applications/useApplication.test-d.ts +1 -1
  29. package/src/hooks/dashboard/useApplication.test.tsx +59 -0
  30. package/src/hooks/dashboard/useApplication.ts +22 -0
  31. package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
  32. package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
  33. package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
  34. package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
  35. package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
  36. package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
  37. package/src/hooks/dashboard/useApplications.test.tsx +249 -0
  38. package/src/hooks/dashboard/useApplications.ts +128 -0
  39. package/src/hooks/dashboard/useEmit.test.tsx +124 -0
  40. package/src/hooks/dashboard/useEmit.ts +78 -0
  41. package/src/hooks/dashboard/useNavigate.ts +1 -3
  42. package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
  43. package/src/hooks/dashboard/useRemoteClient.ts +10 -0
  44. package/src/hooks/dashboard/useTopic.test.tsx +200 -0
  45. package/src/hooks/dashboard/useTopic.ts +29 -0
  46. package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
  47. package/src/hooks/document/useApplyDocumentActions.ts +3 -4
  48. package/src/hooks/document/useCreateDocument.ts +4 -4
  49. package/src/hooks/document/useDocument.ts +11 -6
  50. package/src/hooks/document/useEditDocument.ts +5 -5
  51. package/src/hooks/projection/useDocumentProjection.ts +6 -7
  52. package/src/hooks/query/useQuery.ts +3 -4
  53. package/src/utils/resolveOrgResources.test.ts +2 -2
  54. package/src/utils/resolveOrgResources.ts +2 -2
  55. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +0 -177
  56. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +0 -1
  57. package/src/context/dashboardToken.ts +0 -63
@@ -0,0 +1,462 @@
1
+ /** @public */
2
+ export interface DashboardUrl {
3
+ url(options?: {origin?: string}): string
4
+ toURL(options: {origin: string}): URL
5
+ toString(): string
6
+ }
7
+
8
+ // URL requires an origin; public output always strips this parsing base.
9
+ const relativeUrlBase = 'https://dashboard.invalid'
10
+
11
+ /** @public */
12
+ export interface EditIntentParameters {
13
+ id: string
14
+ /** The document schema type, for example `post`. */
15
+ type?: string
16
+ /** Requests a Studio edit mode, such as `structure` or `presentation`. */
17
+ mode?: 'structure' | 'presentation' | (string & Record<never, never>)
18
+ }
19
+
20
+ /** @public */
21
+ export interface CreateIntentParameters {
22
+ template: string
23
+ type: string
24
+ }
25
+
26
+ /** @public */
27
+ export interface ReleaseIntentParameters {
28
+ id: string
29
+ }
30
+
31
+ /** @public */
32
+ export interface StudioWorkspaceUrl extends DashboardUrl {
33
+ intent(intent: 'edit', parameters: EditIntentParameters): StudioIntentUrl
34
+ intent(intent: 'create', parameters: CreateIntentParameters): StudioIntentUrl
35
+ intent(intent: 'release', parameters: ReleaseIntentParameters): StudioIntentUrl
36
+ path(...path: string[]): DashboardUrl
37
+ task(taskId: string): DashboardUrl
38
+ }
39
+
40
+ /** @public */
41
+ export interface StudioUrl extends StudioWorkspaceUrl {
42
+ workspace(workspace: string): StudioWorkspaceUrl
43
+ }
44
+
45
+ /** @public */
46
+ export interface StudioIntentUrl extends DashboardUrl {
47
+ perspective(perspective: string): StudioIntentUrl
48
+ comment(commentId: string): StudioIntentUrl
49
+ task(taskId: string): DashboardUrl
50
+ }
51
+
52
+ /** @public */
53
+ export interface CoreApplicationUrl extends DashboardUrl {
54
+ path(...path: string[]): DashboardUrl
55
+ }
56
+
57
+ /** @public */
58
+ export interface MediaLibraryUrl extends DashboardUrl {
59
+ asset(assetId: string): DashboardUrl
60
+ collection(collectionId: string): DashboardUrl
61
+ }
62
+
63
+ /** @public */
64
+ export interface CanvasUrl extends DashboardUrl {
65
+ document(documentId: string): DashboardUrl
66
+ }
67
+
68
+ type IntentArguments =
69
+ | [intent: 'edit', parameters: EditIntentParameters]
70
+ | [intent: 'create', parameters: CreateIntentParameters]
71
+ | [intent: 'release', parameters: ReleaseIntentParameters]
72
+
73
+ type BuilderRegistry = Readonly<
74
+ Record<
75
+ string,
76
+ {
77
+ readonly namespace: string
78
+ new (url: URL): UrlBuilder
79
+ }
80
+ >
81
+ >
82
+
83
+ type BuilderMethods<Builders extends BuilderRegistry> = {
84
+ readonly [Name in keyof Builders]: () => InstanceType<Builders[Name]>
85
+ }
86
+
87
+ const splitPath = (path: readonly string[]) =>
88
+ path.flatMap((part) => part.split('/')).filter(Boolean)
89
+
90
+ const intentParametersOf = (...[intent, parameters]: IntentArguments) => {
91
+ const searchParameters = new URLSearchParams()
92
+
93
+ switch (intent) {
94
+ case 'edit': {
95
+ const {id, type, mode} = parameters
96
+ searchParameters.set('id', id)
97
+ if (type !== undefined) searchParameters.set('type', type)
98
+ if (mode !== undefined) searchParameters.set('mode', mode)
99
+ break
100
+ }
101
+ case 'create': {
102
+ searchParameters.set('template', parameters.template)
103
+ searchParameters.set('type', parameters.type)
104
+ break
105
+ }
106
+ case 'release': {
107
+ searchParameters.set('id', parameters.id)
108
+ break
109
+ }
110
+ }
111
+
112
+ return searchParameters
113
+ }
114
+
115
+ const parseIntentParameters = (parameters: string) =>
116
+ new URLSearchParams(parameters.replaceAll(';', '&'))
117
+
118
+ const serializeIntentParameters = (parameters: URLSearchParams) =>
119
+ Array.from(
120
+ parameters,
121
+ ([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
122
+ ).join(';')
123
+
124
+ const appendSegments = (url: URL, segments: readonly string[]): URL => {
125
+ const nextUrl = new URL(url)
126
+ if (segments.length === 0) return nextUrl
127
+
128
+ nextUrl.pathname = `${nextUrl.pathname.replace(/\/$/, '')}/${segments
129
+ .map(encodeURIComponent)
130
+ .join('/')}`
131
+ return nextUrl
132
+ }
133
+
134
+ /**
135
+ * Base class for immutable URL grammars.
136
+ *
137
+ * @public
138
+ */
139
+ export class UrlBuilder implements DashboardUrl {
140
+ readonly #url: URL
141
+
142
+ /**
143
+ * Creates an immutable URL builder at the supplied URL.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * const builder = new UrlBuilder(
148
+ * new URL('/applications/my-app', 'https://dashboard.sanity.io'),
149
+ * )
150
+ *
151
+ * builder.url() // '/applications/my-app'
152
+ * ```
153
+ */
154
+ constructor(url: URL) {
155
+ this.#url = new URL(url)
156
+ }
157
+
158
+ /**
159
+ * Returns a builder with each value appended as one encoded path segment.
160
+ *
161
+ * Route literals and identifiers can be passed together. A slash inside a value stays within
162
+ * that segment instead of creating another route level.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * class DocumentUrlBuilder extends UrlBuilder {
167
+ * document(documentId: string) {
168
+ * return this.append('documents', documentId)
169
+ * }
170
+ * }
171
+ *
172
+ * const builder = new DocumentUrlBuilder(new URL('https://dashboard.sanity.io'))
173
+ * builder.document('drafts/document-1').url()
174
+ * // '/documents/drafts%2Fdocument-1'
175
+ * ```
176
+ */
177
+ protected append(...segments: string[]): this {
178
+ return this.#create(appendSegments(this.#url, segments))
179
+ }
180
+
181
+ /**
182
+ * Returns a builder with changes made through the platform `URL` API.
183
+ *
184
+ * This supports URL details outside the path grammar, including search parameters and hashes.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * class DocumentUrlBuilder extends UrlBuilder {
189
+ * perspective(name: string) {
190
+ * return this.edit((url) => url.searchParams.set('perspective', name))
191
+ * }
192
+ *
193
+ * panel(panelId: string) {
194
+ * return this.edit((url) => {
195
+ * url.hash = `panel/${panelId}`
196
+ * })
197
+ * }
198
+ * }
199
+ * ```
200
+ */
201
+ protected edit(update: (url: URL) => void): this {
202
+ const url = new URL(this.#url)
203
+ update(url)
204
+ return this.#create(url)
205
+ }
206
+
207
+ /**
208
+ * Returns another builder type rooted at the appended path segments.
209
+ *
210
+ * Use this inside a custom builder method only when its route enters a child grammar with
211
+ * different methods; use `append` when chaining stays on the current grammar.
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * class DocumentUrlBuilder extends UrlBuilder {
216
+ * perspective(name: string) {
217
+ * return this.edit((url) => url.searchParams.set('perspective', name))
218
+ * }
219
+ * }
220
+ *
221
+ * class ApplicationUrlBuilder extends UrlBuilder {
222
+ * document(documentId: string) {
223
+ * return this.transitionTo(DocumentUrlBuilder, 'documents', documentId)
224
+ * }
225
+ * }
226
+ *
227
+ * const builder = new ApplicationUrlBuilder(new URL('https://dashboard.sanity.io'))
228
+ * builder.document('document-1').perspective('published').url()
229
+ * // '/documents/document-1?perspective=published'
230
+ * ```
231
+ */
232
+ protected transitionTo<Builder extends UrlBuilder>(
233
+ Builder: new (url: URL) => Builder,
234
+ ...segments: string[]
235
+ ): Builder {
236
+ return new Builder(appendSegments(this.#url, segments))
237
+ }
238
+
239
+ /**
240
+ * Returns the URL as a relative string by default.
241
+ *
242
+ * Supplying an origin returns an absolute URL string for the same dashboard route.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * const builder = new UrlBuilder(
247
+ * new URL('/applications/my-app', 'https://dashboard.sanity.io'),
248
+ * )
249
+ *
250
+ * builder.url() // '/applications/my-app'
251
+ * builder.url({origin: 'https://dashboard.sanity.io'})
252
+ * // 'https://dashboard.sanity.io/applications/my-app'
253
+ * ```
254
+ */
255
+ url(options?: {origin?: string}): string {
256
+ const relativeUrl = this.#relativeUrl()
257
+ return options?.origin === undefined ? relativeUrl : new URL(relativeUrl, options.origin).href
258
+ }
259
+
260
+ /**
261
+ * Returns the URL as a platform `URL` object using the supplied origin.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const builder = new UrlBuilder(
266
+ * new URL('/applications/my-app', 'https://dashboard.sanity.io'),
267
+ * )
268
+ *
269
+ * builder.toURL({origin: 'https://dashboard.sanity.io'}).pathname
270
+ * // '/applications/my-app'
271
+ * ```
272
+ */
273
+ toURL({origin}: {origin: string}): URL {
274
+ return new URL(this.#relativeUrl(), origin)
275
+ }
276
+
277
+ /**
278
+ * Returns the same relative URL as {@link UrlBuilder.url}.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * const builder = new UrlBuilder(
283
+ * new URL('/applications/my-app', 'https://dashboard.sanity.io'),
284
+ * )
285
+ *
286
+ * `${builder}` // '/applications/my-app'
287
+ * ```
288
+ */
289
+ toString(): string {
290
+ return this.url()
291
+ }
292
+
293
+ #relativeUrl(): string {
294
+ return `${this.#url.pathname}${this.#url.search}${this.#url.hash}`
295
+ }
296
+
297
+ #create(url: URL): this {
298
+ const Builder = this.constructor as new (url: URL) => this
299
+ return new Builder(url)
300
+ }
301
+ }
302
+
303
+ class CoreApplicationUrlBuilder extends UrlBuilder implements CoreApplicationUrl {
304
+ static readonly namespace = 'applications'
305
+
306
+ path(...path: string[]): this {
307
+ return this.append(...splitPath(path))
308
+ }
309
+ }
310
+
311
+ class MediaLibraryUrlBuilder extends UrlBuilder implements MediaLibraryUrl {
312
+ static readonly namespace = 'media'
313
+
314
+ asset(assetId: string): DashboardUrl {
315
+ return this.append('assets', assetId)
316
+ }
317
+
318
+ collection(collectionId: string): DashboardUrl {
319
+ return this.append('collections', collectionId)
320
+ }
321
+ }
322
+
323
+ class CanvasUrlBuilder extends UrlBuilder implements CanvasUrl {
324
+ static readonly namespace = 'canvas'
325
+
326
+ document(documentId: string): DashboardUrl {
327
+ return this.append('doc', documentId)
328
+ }
329
+ }
330
+
331
+ class StudioUrlBuilder
332
+ extends UrlBuilder
333
+ implements StudioUrl, StudioWorkspaceUrl, StudioIntentUrl
334
+ {
335
+ static readonly namespace = 'studios'
336
+
337
+ workspace(workspace: string): StudioWorkspaceUrl {
338
+ return this.append(workspace)
339
+ }
340
+
341
+ intent(intent: 'edit', parameters: EditIntentParameters): StudioIntentUrl
342
+ intent(intent: 'create', parameters: CreateIntentParameters): StudioIntentUrl
343
+ intent(intent: 'release', parameters: ReleaseIntentParameters): StudioIntentUrl
344
+ intent(...args: IntentArguments): StudioIntentUrl {
345
+ return this.append('intent', args[0]).edit((url) => {
346
+ url.pathname = `${url.pathname}/${serializeIntentParameters(intentParametersOf(...args))}/`
347
+ })
348
+ }
349
+
350
+ perspective(perspective: string): StudioIntentUrl {
351
+ return this.edit((url) => url.searchParams.set('perspective', perspective))
352
+ }
353
+
354
+ comment(commentId: string): StudioIntentUrl {
355
+ return this.edit((url) => {
356
+ const segments = url.pathname.split('/')
357
+ const parametersIndex = segments.length - 2
358
+ const parameters = parseIntentParameters(segments[parametersIndex]!)
359
+ parameters.set('inspect', 'sanity/comments')
360
+ parameters.set('comment', commentId)
361
+ segments[parametersIndex] = serializeIntentParameters(parameters)
362
+ url.pathname = segments.join('/')
363
+ })
364
+ }
365
+
366
+ task(taskId: string): DashboardUrl {
367
+ return this.edit((url) => url.searchParams.set('selectedTask', taskId))
368
+ }
369
+
370
+ path(...path: string[]): DashboardUrl {
371
+ return this.append(...splitPath(path))
372
+ }
373
+ }
374
+
375
+ /** @public */
376
+ export interface Urls {
377
+ studios(): DashboardUrl
378
+ studios(appId: string): StudioUrl
379
+ applications(): DashboardUrl
380
+ applications(appId: string): CoreApplicationUrl
381
+ mediaLibrary(): MediaLibraryUrl
382
+ canvas(): CanvasUrl
383
+ home(): DashboardUrl
384
+ extend<
385
+ const Builders extends Readonly<
386
+ Record<
387
+ string,
388
+ {
389
+ readonly namespace: string
390
+ new (url: URL): UrlBuilder
391
+ }
392
+ >
393
+ >,
394
+ >(
395
+ builders: Builders & Partial<Record<keyof this, never>>,
396
+ ): this & {readonly [Name in keyof Builders]: () => InstanceType<Builders[Name]>}
397
+ }
398
+
399
+ const createRootBuilder = <Builder extends UrlBuilder>(
400
+ Builder: (new (url: URL) => Builder) & {readonly namespace: string},
401
+ ...segments: string[]
402
+ ): Builder =>
403
+ new Builder(appendSegments(new URL(relativeUrlBase), [Builder.namespace, ...segments]))
404
+
405
+ const createUrls = <const Builders extends BuilderRegistry>(
406
+ builders: Builders,
407
+ ): Urls & BuilderMethods<Builders> => {
408
+ function studios(): DashboardUrl
409
+ function studios(appId: string): StudioUrl
410
+ function studios(appId?: string): DashboardUrl | StudioUrl {
411
+ return appId === undefined
412
+ ? new UrlBuilder(new URL('/studios/', relativeUrlBase))
413
+ : createRootBuilder(StudioUrlBuilder, appId)
414
+ }
415
+
416
+ function applications(): DashboardUrl
417
+ function applications(appId: string): CoreApplicationUrl
418
+ function applications(appId?: string): DashboardUrl | CoreApplicationUrl {
419
+ return appId === undefined
420
+ ? new UrlBuilder(new URL('/applications/', relativeUrlBase))
421
+ : createRootBuilder(CoreApplicationUrlBuilder, appId)
422
+ }
423
+
424
+ const methods = Object.fromEntries(
425
+ Object.entries(builders).map(([name, Builder]) => [name, () => createRootBuilder(Builder)]),
426
+ )
427
+
428
+ const urls = {
429
+ studios,
430
+ applications,
431
+ mediaLibrary: () => createRootBuilder(MediaLibraryUrlBuilder),
432
+ canvas: () => createRootBuilder(CanvasUrlBuilder),
433
+ home: () => new UrlBuilder(new URL(relativeUrlBase)),
434
+ extend<const AddedBuilders extends BuilderRegistry>(addedBuilders: AddedBuilders) {
435
+ const namespaces = new Set([
436
+ '',
437
+ StudioUrlBuilder.namespace,
438
+ CoreApplicationUrlBuilder.namespace,
439
+ MediaLibraryUrlBuilder.namespace,
440
+ CanvasUrlBuilder.namespace,
441
+ ...Object.values(builders).map((Builder) => Builder.namespace),
442
+ ])
443
+
444
+ for (const [name, Builder] of Object.entries(addedBuilders)) {
445
+ if (name in urls || name in methods) {
446
+ throw new Error(`URL builder "${name}" already exists`)
447
+ }
448
+ if (namespaces.has(Builder.namespace)) {
449
+ throw new Error(`URL namespace "${Builder.namespace}" already exists`)
450
+ }
451
+ namespaces.add(Builder.namespace)
452
+ }
453
+
454
+ return createUrls({...builders, ...addedBuilders})
455
+ },
456
+ }
457
+
458
+ return Object.assign(urls, methods) as Urls & BuilderMethods<Builders>
459
+ }
460
+
461
+ /** @public */
462
+ export const urlFor: Urls = createUrls({})
@@ -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
+ }