@sanity/sdk-react 3.4.0-rc.0 → 3.4.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 (37) hide show
  1. package/dist/_exports/dashboard.d.ts +112 -34
  2. package/dist/_exports/dashboard.d.ts.map +1 -1
  3. package/dist/_exports/dashboard.js +219 -114
  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-Bjwi4cLk.js} +60 -3
  10. package/dist/useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.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 +11 -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/useCapabilities.test.tsx +84 -0
  24. package/src/hooks/dashboard/useCapabilities.ts +27 -0
  25. package/src/hooks/dashboard/useNavigate.test.ts +392 -5
  26. package/src/hooks/dashboard/useNavigate.ts +169 -26
  27. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
  29. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
  30. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
  31. package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
  32. package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
  33. package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
  34. package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
  35. package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
  36. package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
  37. package/src/hooks/auth/useOAuthTokens.tsx +0 -95
@@ -1,7 +1,9 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {type MessageBusHost, type PayloadOf} from '@sanity/sdk/dashboard'
1
3
  import {renderHook} from '@testing-library/react'
2
- import {beforeEach, describe, expect, it, vi} from 'vitest'
4
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
5
 
4
- import {AppProviders} from '../../../test/test-utils'
6
+ import {AppProviders, renderHook as renderHookWithInstance} from '../../../test/test-utils'
5
7
  import {useWindowConnection} from '../comlink/useWindowConnection'
6
8
  import {useAgentResourceContext} from './useAgentResourceContext'
7
9
 
@@ -243,3 +245,66 @@ describe('useAgentResourceContext', () => {
243
245
  })
244
246
  })
245
247
  })
248
+
249
+ describe('useAgentResourceContext (message bus)', () => {
250
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
251
+ let host: MessageBusHost
252
+
253
+ const collect = (): PayloadOf<'applications.context.update'>[] => {
254
+ const payloads: PayloadOf<'applications.context.update'>[] = []
255
+ host.subscribe('applications.context.update', (message) => payloads.push(message.payload))
256
+ return payloads
257
+ }
258
+
259
+ beforeEach(() => {
260
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
261
+ host = installMessageBus({appId: 'dashboard'})
262
+ })
263
+
264
+ afterEach(() => {
265
+ resetMessageBus()
266
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
267
+ vi.unstubAllGlobals()
268
+ })
269
+
270
+ it('maps the options to an application context payload', () => {
271
+ const payloads = collect()
272
+
273
+ renderHookWithInstance(() =>
274
+ useAgentResourceContext({projectId: 'proj', dataset: 'ds', documentId: 'doc'}),
275
+ )
276
+
277
+ expect(payloads).toEqual([{resource: {id: 'proj.ds', type: 'dataset'}, document: {id: 'doc'}}])
278
+ expect(useWindowConnection).not.toHaveBeenCalled()
279
+ })
280
+
281
+ it('does not re-emit when re-rendered with equal options', () => {
282
+ const payloads = collect()
283
+
284
+ const {rerender} = renderHookWithInstance((options) => useAgentResourceContext(options), {
285
+ initialProps: {projectId: 'proj', dataset: 'ds', documentId: 'doc'},
286
+ })
287
+ rerender({projectId: 'proj', dataset: 'ds', documentId: 'doc'})
288
+
289
+ expect(payloads).toHaveLength(1)
290
+ })
291
+
292
+ it('sets document to null when no documentId is given', () => {
293
+ const payloads = collect()
294
+
295
+ renderHookWithInstance(() => useAgentResourceContext({projectId: 'proj', dataset: 'ds'}))
296
+
297
+ expect(payloads).toEqual([{resource: {id: 'proj.ds', type: 'dataset'}, document: null}])
298
+ })
299
+
300
+ it.each([
301
+ {projectId: '', dataset: 'ds'},
302
+ {projectId: 'proj', dataset: ''},
303
+ ])('publishes null when a required field is missing (%o)', ({projectId, dataset}) => {
304
+ const payloads = collect()
305
+
306
+ renderHookWithInstance(() => useAgentResourceContext({projectId, dataset, documentId: 'doc'}))
307
+
308
+ expect(payloads).toEqual([null])
309
+ })
310
+ })
@@ -1,8 +1,12 @@
1
+ /* eslint-disable react-compiler/react-compiler -- the transport branch in `useAgentResourceContext` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
1
2
  import {type Events, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
+ import {isDashboardEnvironment} from '@sanity/sdk/_internal'
2
4
  import {type FrameMessage} from '@sanity/sdk/comlink'
3
- import {useCallback, useEffect, useRef} from 'react'
5
+ import {type ApplicationContext} from '@sanity/sdk/dashboard'
6
+ import {useCallback, useEffect, useMemo, useRef} from 'react'
4
7
 
5
8
  import {useWindowConnection} from '../comlink/useWindowConnection'
9
+ import {useApplicationContext} from './useApplicationContext'
6
10
 
7
11
  /**
8
12
  * @public
@@ -24,12 +28,14 @@ export interface AgentResourceContextOptions {
24
28
 
25
29
  /**
26
30
  * @public
27
- * Hook for emitting agent resource context updates to the Dashboard.
28
- * This allows the Agent to understand what resource the user is currently
29
- * interacting with (e.g., which document they're editing).
31
+ * Hook for reporting the resource the user is currently interacting with (for example, which
32
+ * document they're editing) so the Agent can understand their context. The hook reports on mount
33
+ * and again whenever the context changes.
30
34
  *
31
- * The hook will automatically emit the context when it changes, and also
32
- * emit the initial context when the hook is first mounted.
35
+ * The two Dashboard runtimes differ in transport, but the signature is the same:
36
+ * - In an iframe (Comlink), it emits the `dashboard/v1/events/agent/resource/update` event.
37
+ * - In a federated app (message bus), it publishes {@link useApplicationContext}'s
38
+ * `applications.context.update` topic. Reach for `useApplicationContext` directly in new code.
33
39
  *
34
40
  * @category Agent
35
41
  * @param options - The resource context options containing projectId, dataset, and optional documentId
@@ -53,6 +59,29 @@ export interface AgentResourceContextOptions {
53
59
  * ```
54
60
  */
55
61
  export function useAgentResourceContext(options: AgentResourceContextOptions): void {
62
+ // The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
63
+ // always runs and the other never does.
64
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
65
+ if (isDashboardEnvironment()) return useBusAgentResourceContext(options)
66
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
67
+ return useComlinkAgentResourceContext(options)
68
+ }
69
+
70
+ function useBusAgentResourceContext({projectId, dataset, documentId}: AgentResourceContextOptions) {
71
+ const context = useMemo<ApplicationContext | null>(
72
+ () =>
73
+ projectId && dataset
74
+ ? {
75
+ resource: {id: `${projectId}.${dataset}`, type: 'dataset'},
76
+ document: documentId ? {id: documentId} : null,
77
+ }
78
+ : null,
79
+ [projectId, dataset, documentId],
80
+ )
81
+ useApplicationContext(context)
82
+ }
83
+
84
+ function useComlinkAgentResourceContext(options: AgentResourceContextOptions): void {
56
85
  const {projectId, dataset, documentId} = options
57
86
  const {sendMessage} = useWindowConnection<Events.AgentResourceUpdateMessage, FrameMessage>({
58
87
  name: SDK_NODE_NAME,
@@ -0,0 +1,95 @@
1
+ import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
+ import {
3
+ type ApplicationContext,
4
+ type MessageBusHost,
5
+ type MessageBusMeta,
6
+ type PayloadOf,
7
+ } from '@sanity/sdk/dashboard'
8
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
9
+
10
+ import {renderHook} from '../../../test/test-utils'
11
+ import {useApplicationContext} from './useApplicationContext'
12
+
13
+ type ContextUpdate = {
14
+ payload: PayloadOf<'applications.context.update'>
15
+ meta: MessageBusMeta
16
+ }
17
+
18
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
19
+
20
+ let host: MessageBusHost
21
+
22
+ const context: ApplicationContext = {
23
+ resource: {id: 'proj.ds', type: 'dataset'},
24
+ document: {id: 'doc'},
25
+ }
26
+
27
+ describe('useApplicationContext', () => {
28
+ beforeEach(() => {
29
+ // The SDK resolves its own app ID from the CLI-embedded global.
30
+ vi.stubGlobal('__SANITY_APP_ID__', 'app')
31
+ host = installMessageBus({appId: 'dashboard'})
32
+ })
33
+
34
+ afterEach(() => {
35
+ resetMessageBus()
36
+ delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
37
+ vi.unstubAllGlobals()
38
+ })
39
+
40
+ const collect = (): ContextUpdate[] => {
41
+ const messages: ContextUpdate[] = []
42
+ host.subscribe('applications.context.update', (message) =>
43
+ messages.push({payload: message.payload, meta: message.meta}),
44
+ )
45
+ return messages
46
+ }
47
+
48
+ it('emits the context on mount', () => {
49
+ const messages = collect()
50
+
51
+ renderHook(() => useApplicationContext(context))
52
+
53
+ expect(messages.map((message) => message.payload)).toEqual([context])
54
+ })
55
+
56
+ it('does not re-emit when re-rendered with the same context', () => {
57
+ const messages = collect()
58
+
59
+ const {rerender} = renderHook(({value}) => useApplicationContext(value), {
60
+ initialProps: {value: context},
61
+ })
62
+ rerender({value: context})
63
+
64
+ expect(messages).toHaveLength(1)
65
+ })
66
+
67
+ it('clears the old context before publishing a new one', () => {
68
+ const messages = collect()
69
+
70
+ const {rerender} = renderHook(({value}) => useApplicationContext(value), {
71
+ initialProps: {value: context},
72
+ })
73
+ const next: ApplicationContext = {resource: {id: 'proj.ds', type: 'dataset'}, document: null}
74
+ rerender({value: next})
75
+
76
+ expect(messages.map((message) => message.payload)).toEqual([context, null, next])
77
+ })
78
+
79
+ it('emits null on unmount', () => {
80
+ const messages = collect()
81
+
82
+ const {unmount} = renderHook(() => useApplicationContext(context))
83
+ unmount()
84
+
85
+ expect(messages.map((message) => message.payload)).toEqual([context, null])
86
+ })
87
+
88
+ it('stamps the sending application id on the message', () => {
89
+ const messages = collect()
90
+
91
+ renderHook(() => useApplicationContext(context))
92
+
93
+ expect(messages[0].meta.appId).toBe('app')
94
+ })
95
+ })
@@ -0,0 +1,47 @@
1
+ import {type ApplicationContext} from '@sanity/sdk/dashboard'
2
+ import {useEffect} from 'react'
3
+
4
+ import {useEmit} from './useEmit'
5
+
6
+ /**
7
+ * Publishes what this application is currently showing to the Dashboard message bus, so the host
8
+ * can hold the foreground application's context. Pass `null` when nothing is open.
9
+ *
10
+ * The context is published on mount and whenever it changes, and cleared with `null` on unmount.
11
+ * A change publishes `null` before the new context, as the component has stopped showing the old
12
+ * one. Pass a memoised value: the hook compares by reference, so a new object on every render
13
+ * republishes on every render.
14
+ *
15
+ * The host reads the sending application from `message.meta.appId`, so the payload carries no
16
+ * application ID.
17
+ *
18
+ * @param context - The application context to publish, or `null` to clear it
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import {useApplicationContext} from '@sanity/sdk-react/dashboard'
23
+ * import {useMemo} from 'react'
24
+ *
25
+ * function DocumentView({documentId}: {documentId: string}) {
26
+ * const context = useMemo(
27
+ * () => ({resource: {id: 'my-project.production', type: 'dataset' as const}, document: {id: documentId}}),
28
+ * [documentId],
29
+ * )
30
+ * useApplicationContext(context)
31
+ * return <div>Editing {documentId}</div>
32
+ * }
33
+ * ```
34
+ *
35
+ * @public
36
+ * @category Dashboard
37
+ */
38
+ export function useApplicationContext(context: ApplicationContext | null): void {
39
+ const emit = useEmit('applications.context.update')
40
+
41
+ useEffect(() => {
42
+ emit(context)
43
+ return () => {
44
+ emit(null)
45
+ }
46
+ }, [emit, context])
47
+ }
@@ -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
+ }