@sanity/sdk-react 2.4.0 → 2.6.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 (30) hide show
  1. package/README.md +652 -4
  2. package/dist/index.d.ts +85 -14
  3. package/dist/index.js +184 -57
  4. package/dist/index.js.map +1 -1
  5. package/package.json +9 -8
  6. package/src/_exports/sdk-react.ts +5 -0
  7. package/src/components/SDKProvider.tsx +8 -3
  8. package/src/components/SanityApp.tsx +2 -1
  9. package/src/context/SourcesContext.tsx +7 -0
  10. package/src/context/renderSanityApp.test.tsx +355 -0
  11. package/src/context/renderSanityApp.tsx +48 -0
  12. package/src/hooks/agent/useAgentResourceContext.test.tsx +245 -0
  13. package/src/hooks/agent/useAgentResourceContext.ts +106 -0
  14. package/src/hooks/context/useSource.tsx +34 -0
  15. package/src/hooks/dashboard/useDispatchIntent.test.ts +25 -22
  16. package/src/hooks/dashboard/useDispatchIntent.ts +9 -10
  17. package/src/hooks/dashboard/utils/{getResourceIdFromDocumentHandle.test.ts → useResourceIdFromDocumentHandle.test.ts} +33 -59
  18. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +46 -0
  19. package/src/hooks/document/useApplyDocumentActions.test.ts +124 -9
  20. package/src/hooks/document/useApplyDocumentActions.ts +44 -4
  21. package/src/hooks/document/useDocumentPermissions.test.tsx +3 -3
  22. package/src/hooks/document/useDocumentPermissions.ts +9 -6
  23. package/src/hooks/document/useEditDocument.ts +3 -0
  24. package/src/hooks/documents/useDocuments.ts +3 -2
  25. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +1 -0
  26. package/src/hooks/query/useQuery.ts +21 -8
  27. package/src/hooks/releases/usePerspective.test.tsx +1 -0
  28. package/src/hooks/releases/usePerspective.ts +1 -1
  29. package/src/hooks/dashboard/types.ts +0 -12
  30. package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.ts +0 -53
@@ -0,0 +1,245 @@
1
+ import {renderHook} from '@testing-library/react'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {AppProviders} from '../../../test/test-utils'
5
+ import {useWindowConnection} from '../comlink/useWindowConnection'
6
+ import {useAgentResourceContext} from './useAgentResourceContext'
7
+
8
+ vi.mock('../comlink/useWindowConnection', () => ({
9
+ useWindowConnection: vi.fn(),
10
+ }))
11
+
12
+ describe('useAgentResourceContext', () => {
13
+ let mockSendMessage = vi.fn()
14
+
15
+ beforeEach(() => {
16
+ mockSendMessage = vi.fn()
17
+ vi.mocked(useWindowConnection).mockImplementation(() => {
18
+ return {
19
+ sendMessage: mockSendMessage,
20
+ fetch: vi.fn(),
21
+ }
22
+ })
23
+ })
24
+
25
+ it('should send context update on mount', () => {
26
+ renderHook(
27
+ () =>
28
+ useAgentResourceContext({
29
+ projectId: 'test-project',
30
+ dataset: 'production',
31
+ documentId: 'doc-123',
32
+ }),
33
+ {wrapper: AppProviders},
34
+ )
35
+
36
+ expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/agent/resource/update', {
37
+ projectId: 'test-project',
38
+ dataset: 'production',
39
+ documentId: 'doc-123',
40
+ })
41
+ })
42
+
43
+ it('should send context update without documentId', () => {
44
+ renderHook(
45
+ () =>
46
+ useAgentResourceContext({
47
+ projectId: 'test-project',
48
+ dataset: 'production',
49
+ }),
50
+ {wrapper: AppProviders},
51
+ )
52
+
53
+ expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/agent/resource/update', {
54
+ projectId: 'test-project',
55
+ dataset: 'production',
56
+ documentId: undefined,
57
+ })
58
+ })
59
+
60
+ it('should send context update when context changes', () => {
61
+ const {rerender} = renderHook(
62
+ ({documentId}: {documentId: string}) =>
63
+ useAgentResourceContext({
64
+ projectId: 'test-project',
65
+ dataset: 'production',
66
+ documentId,
67
+ }),
68
+ {
69
+ wrapper: AppProviders,
70
+ initialProps: {documentId: 'doc-123'},
71
+ },
72
+ )
73
+
74
+ expect(mockSendMessage).toHaveBeenCalledTimes(1)
75
+ expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/agent/resource/update', {
76
+ projectId: 'test-project',
77
+ dataset: 'production',
78
+ documentId: 'doc-123',
79
+ })
80
+
81
+ // Change documentId
82
+ rerender({documentId: 'doc-456'})
83
+
84
+ expect(mockSendMessage).toHaveBeenCalledTimes(2)
85
+ expect(mockSendMessage).toHaveBeenLastCalledWith('dashboard/v1/events/agent/resource/update', {
86
+ projectId: 'test-project',
87
+ dataset: 'production',
88
+ documentId: 'doc-456',
89
+ })
90
+ })
91
+
92
+ it('should not send duplicate updates for the same context', () => {
93
+ const {rerender} = renderHook(
94
+ ({documentId}: {documentId: string}) =>
95
+ useAgentResourceContext({
96
+ projectId: 'test-project',
97
+ dataset: 'production',
98
+ documentId,
99
+ }),
100
+ {
101
+ wrapper: AppProviders,
102
+ initialProps: {documentId: 'doc-123'},
103
+ },
104
+ )
105
+
106
+ expect(mockSendMessage).toHaveBeenCalledTimes(1)
107
+
108
+ // Re-render with the same documentId
109
+ rerender({documentId: 'doc-123'})
110
+
111
+ // Should still only be called once
112
+ expect(mockSendMessage).toHaveBeenCalledTimes(1)
113
+ })
114
+
115
+ it('should warn when projectId is missing', () => {
116
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
117
+
118
+ renderHook(
119
+ () =>
120
+ useAgentResourceContext({
121
+ projectId: '',
122
+ dataset: 'production',
123
+ documentId: 'doc-123',
124
+ }),
125
+ {wrapper: AppProviders},
126
+ )
127
+
128
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
129
+ '[useAgentResourceContext] projectId and dataset are required',
130
+ {projectId: '', dataset: 'production'},
131
+ )
132
+ expect(mockSendMessage).not.toHaveBeenCalled()
133
+
134
+ consoleWarnSpy.mockRestore()
135
+ })
136
+
137
+ it('should warn when dataset is missing', () => {
138
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
139
+
140
+ renderHook(
141
+ () =>
142
+ useAgentResourceContext({
143
+ projectId: 'test-project',
144
+ dataset: '',
145
+ documentId: 'doc-123',
146
+ }),
147
+ {wrapper: AppProviders},
148
+ )
149
+
150
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
151
+ '[useAgentResourceContext] projectId and dataset are required',
152
+ {projectId: 'test-project', dataset: ''},
153
+ )
154
+ expect(mockSendMessage).not.toHaveBeenCalled()
155
+
156
+ consoleWarnSpy.mockRestore()
157
+ })
158
+
159
+ it('should handle errors when sending messages', () => {
160
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
161
+ mockSendMessage.mockImplementation(() => {
162
+ throw new Error('Failed to send message')
163
+ })
164
+
165
+ // Should not throw, but should log error
166
+ expect(() =>
167
+ renderHook(
168
+ () =>
169
+ useAgentResourceContext({
170
+ projectId: 'test-project',
171
+ dataset: 'production',
172
+ documentId: 'doc-123',
173
+ }),
174
+ {wrapper: AppProviders},
175
+ ),
176
+ ).not.toThrow()
177
+
178
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
179
+ '[useAgentResourceContext] Failed to update context:',
180
+ expect.any(Error),
181
+ )
182
+
183
+ consoleErrorSpy.mockRestore()
184
+ })
185
+
186
+ it('should update context when switching between documents', () => {
187
+ const {rerender} = renderHook(
188
+ ({documentId}: {documentId: string}) =>
189
+ useAgentResourceContext({
190
+ projectId: 'test-project',
191
+ dataset: 'production',
192
+ documentId,
193
+ }),
194
+ {
195
+ wrapper: AppProviders,
196
+ initialProps: {documentId: 'doc-123'},
197
+ },
198
+ )
199
+
200
+ expect(mockSendMessage).toHaveBeenCalledTimes(1)
201
+
202
+ // Switch to document 456
203
+ rerender({documentId: 'doc-456'})
204
+ expect(mockSendMessage).toHaveBeenCalledTimes(2)
205
+
206
+ // Switch to document 789
207
+ rerender({documentId: 'doc-789'})
208
+ expect(mockSendMessage).toHaveBeenCalledTimes(3)
209
+
210
+ // Switch back to document 123
211
+ rerender({documentId: 'doc-123'})
212
+ expect(mockSendMessage).toHaveBeenCalledTimes(4)
213
+ })
214
+
215
+ it('should update context when document is cleared', () => {
216
+ const {rerender} = renderHook(
217
+ ({documentId}: {documentId: string | undefined}) =>
218
+ useAgentResourceContext({
219
+ projectId: 'test-project',
220
+ dataset: 'production',
221
+ documentId,
222
+ }),
223
+ {
224
+ wrapper: AppProviders,
225
+ initialProps: {documentId: 'doc-123' as string | undefined},
226
+ },
227
+ )
228
+
229
+ expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/events/agent/resource/update', {
230
+ projectId: 'test-project',
231
+ dataset: 'production',
232
+ documentId: 'doc-123',
233
+ })
234
+
235
+ // Clear documentId
236
+ rerender({documentId: undefined})
237
+
238
+ expect(mockSendMessage).toHaveBeenCalledTimes(2)
239
+ expect(mockSendMessage).toHaveBeenLastCalledWith('dashboard/v1/events/agent/resource/update', {
240
+ projectId: 'test-project',
241
+ dataset: 'production',
242
+ documentId: undefined,
243
+ })
244
+ })
245
+ })
@@ -0,0 +1,106 @@
1
+ import {type Events, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
+ import {type FrameMessage} from '@sanity/sdk'
3
+ import {useCallback, useEffect, useRef} from 'react'
4
+
5
+ import {useWindowConnection} from '../comlink/useWindowConnection'
6
+
7
+ /**
8
+ * @public
9
+ */
10
+ export interface AgentResourceContextOptions {
11
+ /**
12
+ * The project ID of the current context
13
+ */
14
+ projectId: string
15
+ /**
16
+ * The dataset of the current context
17
+ */
18
+ dataset: string
19
+ /**
20
+ * Optional document ID if the user is viewing/editing a specific document
21
+ */
22
+ documentId?: string
23
+ }
24
+
25
+ /**
26
+ * @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).
30
+ *
31
+ * The hook will automatically emit the context when it changes, and also
32
+ * emit the initial context when the hook is first mounted.
33
+ *
34
+ * @category Agent
35
+ * @param options - The resource context options containing projectId, dataset, and optional documentId
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * import {useAgentResourceContext} from '@sanity/sdk-react'
40
+ *
41
+ * function MyComponent() {
42
+ * const documentId = 'my-document-id'
43
+ *
44
+ * // Automatically updates the Agent's context whenever the document changes
45
+ * useAgentResourceContext({
46
+ * projectId: 'my-project',
47
+ * dataset: 'production',
48
+ * documentId,
49
+ * })
50
+ *
51
+ * return <div>Editing document: {documentId}</div>
52
+ * }
53
+ * ```
54
+ */
55
+ export function useAgentResourceContext(options: AgentResourceContextOptions): void {
56
+ const {projectId, dataset, documentId} = options
57
+ const {sendMessage} = useWindowConnection<Events.AgentResourceUpdateMessage, FrameMessage>({
58
+ name: SDK_NODE_NAME,
59
+ connectTo: SDK_CHANNEL_NAME,
60
+ })
61
+
62
+ // Track the last sent context to avoid duplicate updates
63
+ const lastContextRef = useRef<string | null>(null)
64
+
65
+ const updateContext = useCallback(() => {
66
+ // Validate required fields
67
+ if (!projectId || !dataset) {
68
+ // eslint-disable-next-line no-console
69
+ console.warn('[useAgentResourceContext] projectId and dataset are required', {
70
+ projectId,
71
+ dataset,
72
+ })
73
+ return
74
+ }
75
+
76
+ // Create a stable key for the current context
77
+ const contextKey = `${projectId}:${dataset}:${documentId || ''}`
78
+
79
+ // Skip if context hasn't changed
80
+ if (lastContextRef.current === contextKey) {
81
+ return
82
+ }
83
+
84
+ try {
85
+ const message: Events.AgentResourceUpdateMessage = {
86
+ type: 'dashboard/v1/events/agent/resource/update',
87
+ data: {
88
+ projectId,
89
+ dataset,
90
+ documentId,
91
+ },
92
+ }
93
+
94
+ sendMessage(message.type, message.data)
95
+ lastContextRef.current = contextKey
96
+ } catch (error) {
97
+ // eslint-disable-next-line no-console
98
+ console.error('[useAgentResourceContext] Failed to update context:', error)
99
+ }
100
+ }, [projectId, dataset, documentId, sendMessage])
101
+
102
+ // Update context whenever it changes
103
+ useEffect(() => {
104
+ updateContext()
105
+ }, [updateContext])
106
+ }
@@ -0,0 +1,34 @@
1
+ import {type DatasetHandle, type DocumentHandle, type DocumentSource} from '@sanity/sdk'
2
+ import {useContext} from 'react'
3
+
4
+ import {SourcesContext} from '../../context/SourcesContext'
5
+
6
+ /** Retrieves the named source from context.
7
+ * @beta
8
+ * @param name - The name of the source to retrieve.
9
+ * @returns The source.
10
+ * @example
11
+ * ```tsx
12
+ * const source = useSource('my-source')
13
+ * ```
14
+ */
15
+ export function useSource(options: DocumentHandle | DatasetHandle): DocumentSource | undefined {
16
+ const sources = useContext(SourcesContext)
17
+
18
+ // this might return the "default" source in the future once we implement it?
19
+ if (!options.sourceName && !options.source) {
20
+ return undefined
21
+ }
22
+
23
+ if (options.source) {
24
+ return options.source
25
+ }
26
+
27
+ if (options.sourceName && !Object.hasOwn(sources, options.sourceName)) {
28
+ throw new Error(
29
+ `There's no source named ${JSON.stringify(options.sourceName)} in context. Please use <SourceProvider>.`,
30
+ )
31
+ }
32
+
33
+ return options.sourceName ? sources[options.sourceName] : undefined
34
+ }
@@ -1,8 +1,7 @@
1
- import {canvasSource, type DocumentHandle, mediaLibrarySource} from '@sanity/sdk'
2
- import {renderHook} from '@testing-library/react'
1
+ import {type DocumentHandle} from '@sanity/sdk'
3
2
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
3
 
5
- import {type DocumentHandleWithSource} from './types'
4
+ import {renderHook} from '../../../test/test-utils'
6
5
  import {useDispatchIntent} from './useDispatchIntent'
7
6
 
8
7
  // Mock the useWindowConnection hook
@@ -38,10 +37,7 @@ describe('useDispatchIntent', () => {
38
37
  })
39
38
 
40
39
  it('should throw error when neither action nor intentId is provided', () => {
41
- const {result} = renderHook(() =>
42
- // @ts-expect-error - Testing runtime error when neither is provided
43
- useDispatchIntent({documentHandle: mockDocumentHandle}),
44
- )
40
+ const {result} = renderHook(() => useDispatchIntent({documentHandle: mockDocumentHandle}))
45
41
 
46
42
  expect(() => result.current.dispatchIntent()).toThrow(
47
43
  'useDispatchIntent: Either `action` or `intentId` must be provided.',
@@ -65,9 +61,13 @@ describe('useDispatchIntent', () => {
65
61
  })
66
62
 
67
63
  it('should use memoized dispatchIntent function', () => {
68
- const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
69
- initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
70
- })
64
+ const params = {action: 'edit' as const, documentHandle: mockDocumentHandle}
65
+ const {result, rerender} = renderHook(
66
+ ({params: hookParams}: {params: typeof params}) => useDispatchIntent(hookParams),
67
+ {
68
+ initialProps: {params},
69
+ },
70
+ )
71
71
 
72
72
  const firstDispatchIntent = result.current.dispatchIntent
73
73
 
@@ -78,9 +78,12 @@ describe('useDispatchIntent', () => {
78
78
  })
79
79
 
80
80
  it('should create new dispatchIntent function when documentHandle changes', () => {
81
- const {result, rerender} = renderHook(({params}) => useDispatchIntent(params), {
82
- initialProps: {params: {action: 'edit' as const, documentHandle: mockDocumentHandle}},
83
- })
81
+ const {result, rerender} = renderHook(
82
+ (params: {action: 'edit'; documentHandle: DocumentHandle}) => useDispatchIntent(params),
83
+ {
84
+ initialProps: {action: 'edit' as const, documentHandle: mockDocumentHandle},
85
+ },
86
+ )
84
87
 
85
88
  const firstDispatchIntent = result.current.dispatchIntent
86
89
 
@@ -91,7 +94,7 @@ describe('useDispatchIntent', () => {
91
94
  dataset: 'new-dataset',
92
95
  }
93
96
 
94
- rerender({params: {action: 'edit' as const, documentHandle: newDocumentHandle}})
97
+ rerender({action: 'edit' as const, documentHandle: newDocumentHandle})
95
98
 
96
99
  expect(result.current.dispatchIntent).not.toBe(firstDispatchIntent)
97
100
  })
@@ -163,10 +166,10 @@ describe('useDispatchIntent', () => {
163
166
  })
164
167
 
165
168
  it('should send intent message with media library source', () => {
166
- const mockMediaLibraryHandle: DocumentHandleWithSource = {
169
+ const mockMediaLibraryHandle: DocumentHandle = {
167
170
  documentId: 'test-asset-id',
168
171
  documentType: 'sanity.asset',
169
- source: mediaLibrarySource('mlPGY7BEqt52'),
172
+ sourceName: 'media-library',
170
173
  }
171
174
 
172
175
  const {result} = renderHook(() =>
@@ -185,17 +188,17 @@ describe('useDispatchIntent', () => {
185
188
  type: 'sanity.asset',
186
189
  },
187
190
  resource: {
188
- id: 'mlPGY7BEqt52',
191
+ id: 'media-library-id',
189
192
  type: 'media-library',
190
193
  },
191
194
  })
192
195
  })
193
196
 
194
197
  it('should send intent message with canvas source', () => {
195
- const mockCanvasHandle: DocumentHandleWithSource = {
198
+ const mockCanvasHandle: DocumentHandle = {
196
199
  documentId: 'test-canvas-document-id',
197
200
  documentType: 'sanity.canvas.document',
198
- source: canvasSource('canvas123'),
201
+ sourceName: 'canvas',
199
202
  }
200
203
 
201
204
  const {result} = renderHook(() =>
@@ -214,7 +217,7 @@ describe('useDispatchIntent', () => {
214
217
  type: 'sanity.canvas.document',
215
218
  },
216
219
  resource: {
217
- id: 'canvas123',
220
+ id: 'canvas-id',
218
221
  type: 'canvas',
219
222
  },
220
223
  })
@@ -230,12 +233,12 @@ describe('useDispatchIntent', () => {
230
233
  const {result} = renderHook(() =>
231
234
  useDispatchIntent({
232
235
  action: 'edit',
233
- documentHandle: invalidHandle as unknown as DocumentHandleWithSource,
236
+ documentHandle: invalidHandle as unknown as DocumentHandle,
234
237
  }),
235
238
  )
236
239
 
237
240
  expect(() => result.current.dispatchIntent()).toThrow(
238
- 'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
241
+ 'useDispatchIntent: Either `sourceName` or both `projectId` and `dataset` must be provided in documentHandle.',
239
242
  )
240
243
  })
241
244
  })
@@ -1,10 +1,9 @@
1
1
  import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
- import {type FrameMessage} from '@sanity/sdk'
2
+ import {type DocumentHandle, type FrameMessage} from '@sanity/sdk'
3
3
  import {useCallback} from 'react'
4
4
 
5
5
  import {useWindowConnection} from '../comlink/useWindowConnection'
6
- import {type DocumentHandleWithSource} from './types'
7
- import {getResourceIdFromDocumentHandle} from './utils/getResourceIdFromDocumentHandle'
6
+ import {useResourceIdFromDocumentHandle} from './utils/useResourceIdFromDocumentHandle'
8
7
 
9
8
  /**
10
9
  * Message type for sending intents to the dashboard
@@ -42,7 +41,7 @@ interface DispatchIntent {
42
41
  interface UseDispatchIntentParams {
43
42
  action?: 'edit'
44
43
  intentId?: string
45
- documentHandle: DocumentHandleWithSource
44
+ documentHandle: DocumentHandle
46
45
  parameters?: Record<string, unknown>
47
46
  }
48
47
 
@@ -104,13 +103,15 @@ export function useDispatchIntent(params: UseDispatchIntentParams): DispatchInte
104
103
  connectTo: SDK_CHANNEL_NAME,
105
104
  })
106
105
 
106
+ const resource = useResourceIdFromDocumentHandle(documentHandle)
107
+
107
108
  const dispatchIntent = useCallback(() => {
108
109
  try {
109
110
  if (!action && !intentId) {
110
111
  throw new Error('useDispatchIntent: Either `action` or `intentId` must be provided.')
111
112
  }
112
113
 
113
- const {projectId, dataset, source} = documentHandle
114
+ const {projectId, dataset, sourceName} = documentHandle
114
115
 
115
116
  if (action && intentId) {
116
117
  // eslint-disable-next-line no-console -- warn if both action and intentId are provided
@@ -119,14 +120,12 @@ export function useDispatchIntent(params: UseDispatchIntentParams): DispatchInte
119
120
  )
120
121
  }
121
122
 
122
- if (!source && (!projectId || !dataset)) {
123
+ if (!sourceName && (!projectId || !dataset)) {
123
124
  throw new Error(
124
- 'useDispatchIntent: Either `source` or both `projectId` and `dataset` must be provided in documentHandle.',
125
+ 'useDispatchIntent: Either `sourceName` or both `projectId` and `dataset` must be provided in documentHandle.',
125
126
  )
126
127
  }
127
128
 
128
- const resource = getResourceIdFromDocumentHandle(documentHandle)
129
-
130
129
  const message: IntentMessage = {
131
130
  type: 'dashboard/v1/events/intents/dispatch-intent',
132
131
  data: {
@@ -150,7 +149,7 @@ export function useDispatchIntent(params: UseDispatchIntentParams): DispatchInte
150
149
  console.error('Failed to dispatch intent:', error)
151
150
  throw error
152
151
  }
153
- }, [action, intentId, documentHandle, parameters, sendMessage])
152
+ }, [action, intentId, documentHandle, parameters, sendMessage, resource.id, resource.type])
154
153
 
155
154
  return {
156
155
  dispatchIntent,