@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,123 @@
1
+ import {createInstance as createMFInstance} from '@module-federation/runtime'
2
+ import {
3
+ type ModuleFederationRuntimePlugin,
4
+ type UserOptions,
5
+ } from '@module-federation/runtime/types'
6
+ import {createLogger} from '@sanity/sdk/_internal'
7
+
8
+ type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
9
+
10
+ /**
11
+ * @public
12
+ */
13
+ export type FederationRemote = {
14
+ name: string
15
+ entry: string
16
+ }
17
+
18
+ const MANIFEST_FILE = 'mf-manifest.json'
19
+
20
+ function withManifest<T extends {entry: string}>(remote: T): T {
21
+ if (remote.entry.endsWith(`/${MANIFEST_FILE}`)) return remote
22
+ const base = remote.entry.endsWith('/') ? remote.entry : `${remote.entry}/`
23
+ return {...remote, entry: new URL(MANIFEST_FILE, base).href}
24
+ }
25
+
26
+ /**
27
+ * @public
28
+ */
29
+ export type CreateRemoteInstanceOptions = MakeOptional<
30
+ Pick<UserOptions, 'name' | 'remotes' | 'shared' | 'plugins'>,
31
+ 'remotes' | 'plugins'
32
+ >
33
+
34
+ /**
35
+ * @public
36
+ */
37
+ export interface RemoteInstance {
38
+ registerRemotes(remotes: FederationRemote[]): void
39
+ /** Loads and evaluates a remote expose. */
40
+ loadRemote<T>(id: string): Promise<T | null>
41
+ /** Preloads assets without evaluating them; omit exposes to warm the whole remote. */
42
+ preloadRemote(name: string, exposes?: string[]): Promise<void>
43
+ }
44
+
45
+ /**
46
+ * Creates a client for registering, loading, and preloading federated modules.
47
+ * @public
48
+ */
49
+ export function createRemoteInstance(options: CreateRemoteInstanceOptions): RemoteInstance {
50
+ const logger = createLogger(options.name)
51
+ const instance = createMFInstance({
52
+ ...options,
53
+ plugins: [log(logger.debug), ...(options.plugins ?? [])],
54
+ remotes:
55
+ options.remotes?.map((remote) => ('entry' in remote ? withManifest(remote) : remote)) ?? [],
56
+ })
57
+
58
+ return {
59
+ registerRemotes: (remotes) =>
60
+ instance.registerRemotes(remotes.map(withManifest), {force: false}),
61
+ loadRemote: async <T>(id: string) => {
62
+ let remoteModule: T | null
63
+
64
+ try {
65
+ remoteModule = await instance.loadRemote<T>(id)
66
+ } catch (error) {
67
+ throw new Error(`Failed to load remote module "${id}"`, {
68
+ cause: error,
69
+ })
70
+ }
71
+
72
+ return remoteModule
73
+ },
74
+ // Include async chunks so lazy code is warm before the remote loads.
75
+ preloadRemote: (name, exposes) =>
76
+ instance.preloadRemote([{nameOrAlias: name, exposes, resourceCategory: 'all'}]),
77
+ }
78
+ }
79
+
80
+ function log(
81
+ onDebug: (message: string, context?: Record<string, unknown>) => void,
82
+ ): ModuleFederationRuntimePlugin {
83
+ const logEvent = (eventName: string, args: object) => {
84
+ // `origin` logs the whole instance, which is noisy
85
+ const {origin: _origin, ...data} = args as Record<string, unknown>
86
+
87
+ onDebug(`[Lifecycle] ${eventName}`, {...data, internal: true})
88
+ }
89
+
90
+ return {
91
+ name: 'sanity-logger',
92
+ beforeInit(args) {
93
+ logEvent('beforeInit', args)
94
+ return args
95
+ },
96
+ beforeRegisterRemote(args) {
97
+ logEvent('beforeRegisterRemote', args)
98
+ return args
99
+ },
100
+ beforePreloadRemote(args) {
101
+ logEvent('beforePreloadRemote', args)
102
+ },
103
+ beforeRequest(args) {
104
+ logEvent('beforeRequest', args)
105
+ return args
106
+ },
107
+ afterResolve(args) {
108
+ logEvent('afterResolve', args)
109
+ return args
110
+ },
111
+ onLoad(args) {
112
+ logEvent('onLoad', args)
113
+ return args
114
+ },
115
+ loadShare(args) {
116
+ logEvent('loadShare', args)
117
+ },
118
+ beforeLoadShare(args) {
119
+ logEvent('beforeLoadShare', args)
120
+ return args
121
+ },
122
+ }
123
+ }
@@ -0,0 +1,39 @@
1
+ import * as React from 'react'
2
+
3
+ const MODULE_SLOT_KEY = Symbol.for('sanity.os.module')
4
+
5
+ type ModuleContext = React.Context<string | undefined>
6
+ type ModuleSlot = WeakMap<typeof React.createContext, ModuleContext>
7
+
8
+ /**
9
+ * Returns the React context that carries the current federation module id.
10
+ *
11
+ * @remarks
12
+ * This is the slot the CLI-generated wrapper populates with
13
+ * `renderOptions.moduleId`. The context lives in a per-React-copy slot on
14
+ * `globalThis` so the CLI wrapper and the SDK share the same context even
15
+ * across module copies. Each React copy gets its own context, since a context
16
+ * created by one copy is inert in another.
17
+ *
18
+ * The slot is keyed on `React.createContext` rather than the `React` namespace
19
+ * object: `import * as React` and `import React from 'react'` can yield
20
+ * different wrapper objects for the same React copy under bundler interop,
21
+ * whereas the `createContext` function is the same reference under both.
22
+ *
23
+ * The provider side lives in the CLI, which must not import the SDK:
24
+ * `packages/@sanity/workbench-cli/src/actions/build/render-remote.ts` in
25
+ * `sanity-io/cli` reconstructs this accessor (same symbol, same key) and
26
+ * wraps `App` in the context's `Provider`. Nothing in this repo exports it.
27
+ * @internal
28
+ */
29
+ export function getDashboardModuleContext(): ModuleContext {
30
+ const globals = globalThis as {[MODULE_SLOT_KEY]?: ModuleSlot}
31
+ const slot = (globals[MODULE_SLOT_KEY] ??= new WeakMap())
32
+ const key = React.createContext
33
+ let context = slot.get(key)
34
+ if (!context) {
35
+ context = React.createContext<string | undefined>(undefined)
36
+ slot.set(key, context)
37
+ }
38
+ return context
39
+ }
@@ -0,0 +1,27 @@
1
+ import {type SanityInstance, type StateSource} from '@sanity/sdk'
2
+ import {of} from 'rxjs'
3
+
4
+ import {createRemoteInstance, type RemoteInstance} from './createRemoteInstance'
5
+
6
+ const remoteClientStates = new WeakMap<SanityInstance, StateSource<RemoteInstance>>()
7
+
8
+ /** Returns the instance's remote client state, creating it on first use. @internal */
9
+ export function getRemoteClientState(instance: SanityInstance): StateSource<RemoteInstance> {
10
+ if (instance.isDisposed()) {
11
+ throw new Error('Cannot create a remote client for a disposed Sanity instance')
12
+ }
13
+
14
+ const current = remoteClientStates.get(instance)
15
+ if (current) return current
16
+
17
+ const client = createRemoteInstance({name: `sanity-remote-${instance.instanceId}`})
18
+ const state = {
19
+ getCurrent: () => client,
20
+ observable: of(client),
21
+ subscribe: () => () => {},
22
+ }
23
+
24
+ remoteClientStates.set(instance, state)
25
+ instance.onDispose(() => remoteClientStates.delete(instance))
26
+ return state
27
+ }
@@ -0,0 +1,246 @@
1
+ import {describe, expect, expectTypeOf, it} from 'vitest'
2
+
3
+ import {
4
+ type CanvasUrl,
5
+ type CoreApplicationUrl,
6
+ type DashboardUrl,
7
+ type MediaLibraryUrl,
8
+ type StudioIntentUrl,
9
+ type StudioUrl,
10
+ type StudioWorkspaceUrl,
11
+ UrlBuilder,
12
+ urlFor,
13
+ } from './urlFor'
14
+
15
+ class AcmeDocumentUrlBuilder extends UrlBuilder {
16
+ perspective(perspective: string): this {
17
+ return this.edit((url) => url.searchParams.set('perspective', perspective))
18
+ }
19
+
20
+ panel(panelId: string): this {
21
+ return this.edit((url) => {
22
+ url.hash = `panel/${panelId}`
23
+ })
24
+ }
25
+ }
26
+
27
+ class AcmeUrlBuilder extends UrlBuilder {
28
+ static readonly namespace = 'acme'
29
+
30
+ context(contextId: string): this {
31
+ return this.append('contexts', contextId)
32
+ }
33
+
34
+ document(documentId: string): AcmeDocumentUrlBuilder {
35
+ return this.transitionTo(AcmeDocumentUrlBuilder, 'documents', documentId)
36
+ }
37
+ }
38
+
39
+ class PersonaUrlBuilder extends UrlBuilder {
40
+ static readonly namespace = 'persona'
41
+
42
+ person(personId: string): this {
43
+ return this.append('people', personId)
44
+ }
45
+ }
46
+
47
+ class CanvasAliasUrlBuilder extends UrlBuilder {
48
+ static readonly namespace = 'canvas'
49
+ }
50
+
51
+ describe('studio', () => {
52
+ it('builds studio URLs', () => {
53
+ expect(urlFor.studios().url()).toBe('/studios/')
54
+ expect(urlFor.studios('studio-1').url()).toBe('/studios/studio-1')
55
+ expect(urlFor.studios('studio-1').workspace('default').url()).toBe('/studios/studio-1/default')
56
+ })
57
+
58
+ it('builds edit intent URLs', () => {
59
+ expect(
60
+ urlFor
61
+ .studios('studio-1')
62
+ .workspace('default')
63
+ .intent('edit', {id: 'drafts.document-1', type: 'article'})
64
+ .url(),
65
+ ).toBe('/studios/studio-1/default/intent/edit/id=drafts.document-1;type=article/')
66
+
67
+ expect(urlFor.studios('studio-1').intent('edit', {id: 'document-1', mode: 'focus'}).url()).toBe(
68
+ '/studios/studio-1/intent/edit/id=document-1;mode=focus/',
69
+ )
70
+ })
71
+
72
+ it('builds create and release intent URLs', () => {
73
+ expect(
74
+ urlFor.studios('studio-1').intent('create', {template: 'article', type: 'article'}).url(),
75
+ ).toBe('/studios/studio-1/intent/create/template=article;type=article/')
76
+ expect(urlFor.studios('studio-1').intent('release', {id: 'release-1'}).url()).toBe(
77
+ '/studios/studio-1/intent/release/id=release-1/',
78
+ )
79
+ })
80
+
81
+ it('adds perspectives and comments to intent URLs', () => {
82
+ const intent = urlFor
83
+ .studios('studio-1')
84
+ .intent('edit', {id: 'document/1', type: 'press release'})
85
+
86
+ expect(intent.perspective('release/1').url()).toBe(
87
+ '/studios/studio-1/intent/edit/id=document%2F1;type=press%20release/?perspective=release%2F1',
88
+ )
89
+ expect(intent.comment('comment/1').url()).toBe(
90
+ '/studios/studio-1/intent/edit/id=document%2F1;type=press%20release;inspect=sanity%2Fcomments;comment=comment%2F1/',
91
+ )
92
+ })
93
+
94
+ it('replaces an earlier comment instead of stacking one', () => {
95
+ expect(
96
+ urlFor
97
+ .studios('studio-1')
98
+ .intent('edit', {id: 'document-1'})
99
+ .comment('comment-1')
100
+ .comment('comment-2')
101
+ .url(),
102
+ ).toBe(
103
+ '/studios/studio-1/intent/edit/id=document-1;inspect=sanity%2Fcomments;comment=comment-2/',
104
+ )
105
+ })
106
+
107
+ it('builds studio task URLs', () => {
108
+ expect(urlFor.studios('studio-1').workspace('default').task('task/1').url()).toBe(
109
+ '/studios/studio-1/default?selectedTask=task%2F1',
110
+ )
111
+ expect(urlFor.studios('studio-1').intent('edit', {id: 'document-1'}).task('task-1').url()).toBe(
112
+ '/studios/studio-1/intent/edit/id=document-1/?selectedTask=task-1',
113
+ )
114
+ })
115
+
116
+ it('builds arbitrary studio paths', () => {
117
+ expect(urlFor.studios('studio-1').path('custom', 'document/1').url()).toBe(
118
+ '/studios/studio-1/custom/document/1',
119
+ )
120
+ })
121
+
122
+ it('encodes studio and workspace identifiers', () => {
123
+ expect(urlFor.studios('studio/1').workspace('main space').url()).toBe(
124
+ '/studios/studio%2F1/main%20space',
125
+ )
126
+ })
127
+
128
+ it('exposes studio-specific builder interfaces', () => {
129
+ expectTypeOf(urlFor.studios()).toEqualTypeOf<DashboardUrl>()
130
+ expectTypeOf(urlFor.studios('studio-1')).toEqualTypeOf<StudioUrl>()
131
+ expectTypeOf(
132
+ urlFor.studios('studio-1').workspace('default'),
133
+ ).toEqualTypeOf<StudioWorkspaceUrl>()
134
+ expectTypeOf(
135
+ urlFor.studios('studio-1').intent('edit', {id: 'document-1'}),
136
+ ).toEqualTypeOf<StudioIntentUrl>()
137
+ })
138
+ })
139
+
140
+ describe('coreApp', () => {
141
+ it('builds collection and application URLs', () => {
142
+ expect(urlFor.applications().url()).toBe('/applications/')
143
+ expect(urlFor.applications('app-1').url()).toBe('/applications/app-1')
144
+ expect(urlFor.applications('app-1').path('documents', 'document/1').url()).toBe(
145
+ '/applications/app-1/documents/document/1',
146
+ )
147
+ })
148
+
149
+ it('exposes application-specific builder interfaces', () => {
150
+ expectTypeOf(urlFor.applications()).toEqualTypeOf<DashboardUrl>()
151
+ expectTypeOf(urlFor.applications('app-1')).toEqualTypeOf<CoreApplicationUrl>()
152
+ })
153
+ })
154
+
155
+ describe('Media Library', () => {
156
+ it('builds media library URLs', () => {
157
+ expect(urlFor.mediaLibrary().url()).toBe('/media')
158
+ expect(urlFor.mediaLibrary().asset('asset/1').url()).toBe('/media/assets/asset%2F1')
159
+ expect(urlFor.mediaLibrary().collection('collection/1').url()).toBe(
160
+ '/media/collections/collection%2F1',
161
+ )
162
+ })
163
+
164
+ it('exposes the Media Library builder interface', () => {
165
+ expectTypeOf(urlFor.mediaLibrary()).toEqualTypeOf<MediaLibraryUrl>()
166
+ })
167
+ })
168
+
169
+ describe('Canvas', () => {
170
+ it('builds Canvas URLs', () => {
171
+ expect(urlFor.canvas().document('document/1').url()).toBe('/canvas/doc/document%2F1')
172
+ })
173
+
174
+ it('exposes the Canvas builder interface', () => {
175
+ expectTypeOf(urlFor.canvas()).toEqualTypeOf<CanvasUrl>()
176
+ })
177
+ })
178
+
179
+ describe('UrlBuilder', () => {
180
+ it('returns relative and absolute URL forms', () => {
181
+ const origin = 'https://dashboard.sanity.io'
182
+ const builder = new UrlBuilder(new URL('/applications/app-1', origin))
183
+ const absolute = new URL('/applications/app-1', origin)
184
+
185
+ expect(builder.toString()).toBe('/applications/app-1')
186
+ expect(builder.url()).toBe('/applications/app-1')
187
+ expect(builder.toURL({origin})).toEqual(absolute)
188
+ expect(builder.url({origin})).toBe(absolute.href)
189
+ })
190
+
191
+ it('keeps builders immutable', () => {
192
+ const builder = new AcmeUrlBuilder(new URL('https://dashboard.sanity.io/acme'))
193
+
194
+ builder.context('context-1')
195
+
196
+ expect(builder.url()).toBe('/acme')
197
+ })
198
+
199
+ it('extends the URL builder with custom builder classes', () => {
200
+ const extendedUrlFor = urlFor.extend({acme: AcmeUrlBuilder})
201
+ const reextendedUrlFor = extendedUrlFor.extend({persona: PersonaUrlBuilder})
202
+
203
+ expect(
204
+ extendedUrlFor
205
+ .acme()
206
+ .context('context/1')
207
+ .document('document/1')
208
+ .perspective('published')
209
+ .panel('review 1')
210
+ .url(),
211
+ ).toBe(
212
+ '/acme/contexts/context%2F1/documents/document%2F1?perspective=published#panel/review%201',
213
+ )
214
+ expect(reextendedUrlFor.persona().person('person/1').url()).toBe('/persona/people/person%2F1')
215
+ expect(reextendedUrlFor.acme().url()).toBe('/acme')
216
+ expect(urlFor).not.toHaveProperty('acme')
217
+
218
+ expectTypeOf(extendedUrlFor.acme()).toEqualTypeOf<AcmeUrlBuilder>()
219
+ expectTypeOf(
220
+ extendedUrlFor.acme().document('document-1'),
221
+ ).toEqualTypeOf<AcmeDocumentUrlBuilder>()
222
+ expectTypeOf(reextendedUrlFor.persona()).toEqualTypeOf<PersonaUrlBuilder>()
223
+ })
224
+
225
+ it('rejects duplicate URL namespaces', () => {
226
+ const extendedUrlFor = urlFor.extend({acme: AcmeUrlBuilder})
227
+
228
+ expect(() => urlFor.extend({canvas: AcmeUrlBuilder} as never)).toThrow(
229
+ 'URL builder "canvas" already exists',
230
+ )
231
+ expect(() => urlFor.extend({canvasAlias: CanvasAliasUrlBuilder})).toThrow(
232
+ 'URL namespace "canvas" already exists',
233
+ )
234
+ expect(() => extendedUrlFor.extend({acmeAlias: AcmeUrlBuilder})).toThrow(
235
+ 'URL namespace "acme" already exists',
236
+ )
237
+ expect(() => urlFor.extend({first: AcmeUrlBuilder, second: AcmeUrlBuilder})).toThrow(
238
+ 'URL namespace "acme" already exists',
239
+ )
240
+ })
241
+
242
+ it('builds the home URL', () => {
243
+ expect(urlFor.home().url()).toBe('/')
244
+ expectTypeOf(urlFor.home()).toEqualTypeOf<DashboardUrl>()
245
+ })
246
+ })