@sanity/sdk-react 0.0.0-alpha.21 → 0.0.0-alpha.23

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 (71) hide show
  1. package/dist/index.d.ts +502 -3460
  2. package/dist/index.js +400 -465
  3. package/dist/index.js.map +1 -1
  4. package/package.json +17 -15
  5. package/src/_exports/index.ts +4 -5
  6. package/src/components/SDKProvider.test.tsx +78 -54
  7. package/src/components/SDKProvider.tsx +31 -26
  8. package/src/components/SanityApp.test.tsx +121 -15
  9. package/src/components/SanityApp.tsx +26 -15
  10. package/src/components/auth/AuthBoundary.test.tsx +32 -14
  11. package/src/components/auth/AuthBoundary.tsx +53 -23
  12. package/src/components/auth/LoginCallback.test.tsx +19 -6
  13. package/src/components/auth/LoginCallback.tsx +2 -11
  14. package/src/components/auth/LoginError.test.tsx +12 -4
  15. package/src/components/auth/LoginError.tsx +13 -21
  16. package/src/components/auth/LoginFooter.test.tsx +7 -3
  17. package/src/context/ResourceProvider.test.tsx +157 -0
  18. package/src/context/ResourceProvider.tsx +111 -0
  19. package/src/context/SanityInstanceContext.ts +1 -1
  20. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  21. package/src/hooks/client/useClient.ts +2 -1
  22. package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
  23. package/src/hooks/comlink/useManageFavorite.ts +37 -13
  24. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
  25. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
  26. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  27. package/src/hooks/context/useSanityInstance.ts +66 -26
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
  30. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
  31. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
  32. package/src/hooks/datasets/useDatasets.ts +15 -4
  33. package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
  34. package/src/hooks/document/useApplyDocumentActions.ts +6 -31
  35. package/src/hooks/document/useDocument.test.ts +2 -2
  36. package/src/hooks/document/useDocument.ts +40 -19
  37. package/src/hooks/document/useDocumentEvent.test.ts +2 -3
  38. package/src/hooks/document/useDocumentEvent.ts +7 -11
  39. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  40. package/src/hooks/document/useDocumentPermissions.ts +31 -23
  41. package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
  42. package/src/hooks/document/useEditDocument.test.ts +2 -3
  43. package/src/hooks/document/useEditDocument.ts +43 -29
  44. package/src/hooks/documents/useDocuments.test.tsx +30 -3
  45. package/src/hooks/documents/useDocuments.ts +20 -7
  46. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  47. package/src/hooks/helpers/createCallbackHook.tsx +2 -3
  48. package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
  49. package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
  50. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
  51. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
  52. package/src/hooks/preview/usePreview.test.tsx +66 -7
  53. package/src/hooks/preview/usePreview.tsx +17 -12
  54. package/src/hooks/projection/useProjection.test.tsx +68 -3
  55. package/src/hooks/projection/useProjection.ts +21 -24
  56. package/src/hooks/projects/useProject.ts +7 -4
  57. package/src/hooks/query/useQuery.ts +32 -14
  58. package/src/hooks/users/useUsers.test.tsx +330 -0
  59. package/src/hooks/users/useUsers.ts +65 -52
  60. package/src/components/Login/LoginLinks.test.tsx +0 -90
  61. package/src/components/Login/LoginLinks.tsx +0 -58
  62. package/src/components/auth/Login.test.tsx +0 -27
  63. package/src/components/auth/Login.tsx +0 -39
  64. package/src/components/auth/LoginLayout.test.tsx +0 -19
  65. package/src/components/auth/LoginLayout.tsx +0 -69
  66. package/src/components/auth/authTestHelpers.tsx +0 -11
  67. package/src/context/SanityProvider.test.tsx +0 -25
  68. package/src/context/SanityProvider.tsx +0 -50
  69. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  70. package/src/hooks/auth/useLoginUrls.tsx +0 -52
  71. package/src/hooks/users/useUsers.test.ts +0 -163
@@ -1,9 +1,4 @@
1
- import {
2
- type DocumentEvent,
3
- type DocumentHandle,
4
- getResourceId,
5
- subscribeDocumentEvents,
6
- } from '@sanity/sdk'
1
+ import {type DatasetHandle, type DocumentEvent, subscribeDocumentEvents} from '@sanity/sdk'
7
2
  import {useCallback, useEffect, useInsertionEffect, useRef} from 'react'
8
3
 
9
4
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -12,13 +7,14 @@ import {useSanityInstance} from '../context/useSanityInstance'
12
7
  *
13
8
  * @beta
14
9
  *
15
- * Subscribes an event handler to events in your applications document store, such as document
10
+ * Subscribes an event handler to events in your application's document store, such as document
16
11
  * creation, deletion, and updates.
17
12
  *
18
13
  * @category Documents
19
14
  * @param handler - The event handler to register.
20
- * @param doc - The document to subscribe to events for. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
21
- * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
15
+ * @param doc - The document to subscribe to events for. If you pass a `DocumentHandle` with specified `projectId` and `dataset`,
16
+ * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `projectId` or `dataset` is provided,
17
+ * the document will use the nearest instance from context.
22
18
  * @example
23
19
  * ```
24
20
  * import {useDocumentEvent} from '@sanity/sdk-react'
@@ -35,7 +31,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
35
31
  */
36
32
  export function useDocumentEvent(
37
33
  handler: (documentEvent: DocumentEvent) => void,
38
- doc: DocumentHandle,
34
+ dataset: DatasetHandle,
39
35
  ): void {
40
36
  const ref = useRef(handler)
41
37
 
@@ -47,7 +43,7 @@ export function useDocumentEvent(
47
43
  return ref.current(documentEvent)
48
44
  }, [])
49
45
 
50
- const instance = useSanityInstance(getResourceId(doc.resourceId))
46
+ const instance = useSanityInstance(dataset)
51
47
  useEffect(() => {
52
48
  return subscribeDocumentEvents(instance, stableHandler)
53
49
  }, [instance, stableHandler])
@@ -0,0 +1,204 @@
1
+ import {
2
+ type DocumentAction,
3
+ type DocumentPermissionsResult,
4
+ getPermissionsState,
5
+ type SanityInstance,
6
+ } from '@sanity/sdk'
7
+ import {act, renderHook, waitFor} from '@testing-library/react'
8
+ import {BehaviorSubject, firstValueFrom} from 'rxjs'
9
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+ import {useDocumentPermissions} from './useDocumentPermissions'
13
+
14
+ // Mock dependencies before any imports
15
+ vi.mock('../context/useSanityInstance', () => ({
16
+ useSanityInstance: vi.fn(),
17
+ }))
18
+
19
+ vi.mock('@sanity/sdk', () => ({
20
+ getPermissionsState: vi.fn(),
21
+ }))
22
+
23
+ // Move this mock to the top level
24
+ vi.mock('rxjs', async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import('rxjs')>()
26
+ return {
27
+ ...actual,
28
+ firstValueFrom: vi.fn().mockImplementation(() => Promise.resolve()),
29
+ }
30
+ })
31
+
32
+ describe('usePermissions', () => {
33
+ const mockInstance = {id: 'mock-instance'} as unknown as SanityInstance
34
+ const mockAction: DocumentAction = {
35
+ type: 'document.publish',
36
+ documentId: 'doc1',
37
+ documentType: 'article',
38
+ projectId: 'project1',
39
+ dataset: 'dataset1',
40
+ }
41
+
42
+ const mockPermissionAllowed: DocumentPermissionsResult = {allowed: true}
43
+ const mockPermissionDenied: DocumentPermissionsResult = {
44
+ allowed: false,
45
+ message: 'Permission denied for document.publish',
46
+ reasons: [
47
+ {
48
+ type: 'access',
49
+ message: 'You do not have permission to publish this document',
50
+ documentId: 'doc1',
51
+ },
52
+ ],
53
+ }
54
+
55
+ let permissionsSubject: BehaviorSubject<DocumentPermissionsResult | undefined>
56
+ let mockSubscribe: ReturnType<typeof vi.fn>
57
+ let mockGetCurrent: ReturnType<typeof vi.fn>
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks()
61
+
62
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
63
+
64
+ // Create a subject to simulate permissions state updates
65
+ permissionsSubject = new BehaviorSubject<DocumentPermissionsResult | undefined>(
66
+ mockPermissionAllowed,
67
+ )
68
+
69
+ mockSubscribe = vi.fn((callback) => {
70
+ // Set up subscription to our subject
71
+ const subscription = permissionsSubject.subscribe(callback)
72
+ // Return an unsubscribe function
73
+ return () => subscription.unsubscribe()
74
+ })
75
+
76
+ mockGetCurrent = vi.fn(() => permissionsSubject.getValue())
77
+
78
+ // Set up the getPermissionsState mock
79
+ vi.mocked(getPermissionsState).mockReturnValue({
80
+ observable: permissionsSubject.asObservable(),
81
+ subscribe: mockSubscribe,
82
+ getCurrent: mockGetCurrent,
83
+ })
84
+ })
85
+
86
+ afterEach(() => {
87
+ permissionsSubject.complete()
88
+ })
89
+
90
+ it('should return permissions result from getPermissionsState', () => {
91
+ // Initialize with permission allowed
92
+ act(() => {
93
+ permissionsSubject.next(mockPermissionAllowed)
94
+ })
95
+
96
+ const {result} = renderHook(() => useDocumentPermissions(mockAction))
97
+
98
+ expect(useSanityInstance).toHaveBeenCalledWith({
99
+ projectId: mockAction.projectId,
100
+ dataset: mockAction.dataset,
101
+ })
102
+ expect(getPermissionsState).toHaveBeenCalledWith(mockInstance, mockAction)
103
+ expect(result.current).toEqual(mockPermissionAllowed)
104
+ })
105
+
106
+ it('should handle permission denied state', () => {
107
+ // Initialize with permission denied
108
+ act(() => {
109
+ permissionsSubject.next(mockPermissionDenied)
110
+ })
111
+
112
+ const {result} = renderHook(() => useDocumentPermissions(mockAction))
113
+
114
+ expect(result.current).toEqual(mockPermissionDenied)
115
+ expect(result.current.allowed).toBe(false)
116
+ expect(result.current.message).toBe('Permission denied for document.publish')
117
+ expect(result.current.reasons).toHaveLength(1)
118
+ })
119
+
120
+ it('should accept an array of actions', () => {
121
+ const actions = [mockAction, {...mockAction, documentId: 'doc2'}]
122
+
123
+ renderHook(() => useDocumentPermissions(actions))
124
+
125
+ expect(getPermissionsState).toHaveBeenCalledWith(mockInstance, actions)
126
+ })
127
+
128
+ it('should throw an error if actions have mismatched project IDs', () => {
129
+ const actions = [
130
+ mockAction,
131
+ {...mockAction, projectId: 'different-project', documentId: 'doc2'},
132
+ ]
133
+
134
+ expect(() => {
135
+ renderHook(() => useDocumentPermissions(actions))
136
+ }).toThrow(/Mismatched project IDs found in actions/)
137
+ })
138
+
139
+ it('should throw an error if actions have mismatched datasets', () => {
140
+ const actions = [mockAction, {...mockAction, dataset: 'different-dataset', documentId: 'doc2'}]
141
+
142
+ expect(() => {
143
+ renderHook(() => useDocumentPermissions(actions))
144
+ }).toThrow(/Mismatched datasets found in actions/)
145
+ })
146
+
147
+ it('should wait for permissions to be ready before rendering', async () => {
148
+ // Set up initial value as undefined (not ready)
149
+ act(() => {
150
+ permissionsSubject.next(undefined)
151
+ })
152
+
153
+ // Setup a resolved promise for firstValueFrom
154
+ const mockPromise = Promise.resolve(mockPermissionAllowed)
155
+ vi.mocked(firstValueFrom).mockReturnValueOnce(mockPromise)
156
+
157
+ // This should throw the promise and suspend
158
+ const {result} = renderHook(() => {
159
+ try {
160
+ return useDocumentPermissions(mockAction)
161
+ } catch (error) {
162
+ if (error instanceof Promise) {
163
+ return 'suspended'
164
+ }
165
+ throw error
166
+ }
167
+ })
168
+
169
+ expect(result.current).toBe('suspended')
170
+
171
+ // Resolve the promise by setting a value
172
+ act(() => {
173
+ permissionsSubject.next(mockPermissionAllowed)
174
+ })
175
+
176
+ // Now it should render properly
177
+ await waitFor(() => {
178
+ expect(getPermissionsState).toHaveBeenCalledWith(mockInstance, mockAction)
179
+ })
180
+ })
181
+
182
+ it('should react to permission state changes', async () => {
183
+ // Start with permission allowed
184
+ act(() => {
185
+ permissionsSubject.next(mockPermissionAllowed)
186
+ })
187
+
188
+ const {result, rerender} = renderHook(() => useDocumentPermissions(mockAction))
189
+
190
+ expect(result.current).toEqual(mockPermissionAllowed)
191
+
192
+ // Change to permission denied
193
+ act(() => {
194
+ permissionsSubject.next(mockPermissionDenied)
195
+ })
196
+
197
+ // Rerender to trigger the update
198
+ rerender()
199
+
200
+ await waitFor(() => {
201
+ expect(result.current).toEqual(mockPermissionDenied)
202
+ })
203
+ })
204
+ })
@@ -1,9 +1,4 @@
1
- import {
2
- type DocumentAction,
3
- type DocumentPermissionsResult,
4
- getPermissionsState,
5
- getResourceId,
6
- } from '@sanity/sdk'
1
+ import {type DocumentAction, type DocumentPermissionsResult, getPermissionsState} from '@sanity/sdk'
7
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
8
3
  import {filter, firstValueFrom} from 'rxjs'
9
4
 
@@ -16,7 +11,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
16
11
  * Check if the current user has the specified permissions for the given document actions.
17
12
  *
18
13
  * @category Permissions
19
- * @param actions - One more more calls to a particular document action function for a given document
14
+ * @param actionOrActions - One more more calls to a particular document action function for a given document
20
15
  * @returns An object that specifies whether the action is allowed; if the action is not allowed, an explanatory message and list of reasons is also provided.
21
16
  *
22
17
  * @example Checking for permission to publish a document
@@ -48,36 +43,49 @@ import {useSanityInstance} from '../context/useSanityInstance'
48
43
  * ```
49
44
  */
50
45
  export function useDocumentPermissions(
51
- actions: DocumentAction | DocumentAction[],
46
+ actionOrActions: DocumentAction | DocumentAction[],
52
47
  ): DocumentPermissionsResult {
53
- // if actions is an array, we need to check each action to see if the resourceId is the same
54
- if (Array.isArray(actions)) {
55
- const resourceIds = actions.map((action) => action.resourceId)
56
- const uniqueResourceIds = new Set(resourceIds)
57
- if (uniqueResourceIds.size !== 1) {
58
- throw new Error('All actions must have the same resourceId')
48
+ const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
49
+ // if actions is an array, we need to check that all actions belong to the same project and dataset
50
+ let projectId
51
+ let dataset
52
+
53
+ for (const action of actions) {
54
+ if (action.projectId) {
55
+ if (!projectId) projectId = action.projectId
56
+ if (action.projectId !== projectId) {
57
+ throw new Error(
58
+ `Mismatched project IDs found in actions. All actions must belong to the same project. Found "${action.projectId}" but expected "${projectId}".`,
59
+ )
60
+ }
61
+
62
+ if (action.dataset) {
63
+ if (!dataset) dataset = action.dataset
64
+ if (action.dataset !== dataset) {
65
+ throw new Error(
66
+ `Mismatched datasets found in actions. All actions must belong to the same dataset. Found "${action.dataset}" but expected "${dataset}".`,
67
+ )
68
+ }
69
+ }
59
70
  }
60
71
  }
61
- const resourceId = Array.isArray(actions)
62
- ? getResourceId(actions[0].resourceId)
63
- : getResourceId(actions.resourceId)
64
72
 
65
- const instance = useSanityInstance(resourceId)
73
+ const instance = useSanityInstance({projectId, dataset})
66
74
  const isDocumentReady = useCallback(
67
- () => getPermissionsState(instance, actions).getCurrent() !== undefined,
68
- [actions, instance],
75
+ () => getPermissionsState(instance, actionOrActions).getCurrent() !== undefined,
76
+ [actionOrActions, instance],
69
77
  )
70
78
  if (!isDocumentReady()) {
71
79
  throw firstValueFrom(
72
- getPermissionsState(instance, actions).observable.pipe(
80
+ getPermissionsState(instance, actionOrActions).observable.pipe(
73
81
  filter((result) => result !== undefined),
74
82
  ),
75
83
  )
76
84
  }
77
85
 
78
86
  const {subscribe, getCurrent} = useMemo(
79
- () => getPermissionsState(instance, actions),
80
- [actions, instance],
87
+ () => getPermissionsState(instance, actionOrActions),
88
+ [actionOrActions, instance],
81
89
  )
82
90
 
83
91
  return useSyncExternalStore(subscribe, getCurrent) as DocumentPermissionsResult
@@ -4,15 +4,16 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
4
4
 
5
5
  type UseDocumentSyncStatus = {
6
6
  /**
7
- * Exposes the documents sync status between local and remote document states.
7
+ * Exposes the document's sync status between local and remote document states.
8
8
  *
9
9
  * @category Documents
10
- * @param doc - The document handle to get sync status for. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
11
- * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
10
+ * @param doc - The document handle to get sync status for. If you pass a `DocumentHandle` with specified `projectId` and `dataset`,
11
+ * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `projectId` or `dataset` is provided,
12
+ * the document will use the nearest instance from context.
12
13
  * @returns `true` if local changes are synced with remote, `false` if the changes are not synced, and `undefined` if the document is not found
13
14
  * @example Disable a Save button when there are no changes to sync
14
15
  * ```
15
- * const myDocumentHandle = { _id: 'documentId', _type: 'documentType', resourceId: 'document:projectId:dataset:documentId' }
16
+ * const myDocumentHandle = { documentId: 'documentId', documentType: 'documentType', projectId: 'projectId', dataset: 'dataset' }
16
17
  * const documentSynced = useDocumentSyncStatus(myDocumentHandle)
17
18
  *
18
19
  * return (
@@ -46,9 +46,8 @@ const doc = {
46
46
  } satisfies SanityDocument
47
47
 
48
48
  const docHandle: DocumentHandle<SanityDocument> = {
49
- _id: 'doc1',
50
- _type: 'book',
51
- resourceId: 'document:p.d:doc1',
49
+ documentId: 'doc1',
50
+ documentType: 'book',
52
51
  }
53
52
 
54
53
  describe('useEditDocument hook', () => {
@@ -3,7 +3,6 @@ import {
3
3
  type DocumentHandle,
4
4
  editDocument,
5
5
  getDocumentState,
6
- getResourceId,
7
6
  type JsonMatch,
8
7
  type JsonMatchPath,
9
8
  resolveDocument,
@@ -26,12 +25,18 @@ type Updater<TValue> = TValue | ((nextValue: TValue) => TValue)
26
25
  * Edit a nested value within a document
27
26
  *
28
27
  * @category Documents
29
- * @param doc - The document to be edited; either as a document handle or the document’s ID a string
28
+ * @param docHandle - The document to be edited, specified as a DocumentHandle
30
29
  * @param path - The path to the nested value to be edited
31
30
  * @returns A function to update the nested value. Accepts either a new value, or an updater function that exposes the previous value and returns a new value.
32
- * @example Update a documents name by providing the new value directly
33
- * ```
34
- * const handle = { _id: 'documentId', _type: 'documentType' }
31
+ * @example Update a document's name by providing the new value directly
32
+ * ```tsx
33
+ * const handle = {
34
+ * documentId: 'movie-123',
35
+ * documentType: 'movie',
36
+ * projectId: 'abc123',
37
+ * dataset: 'production'
38
+ * }
39
+ *
35
40
  * const name = useDocument(handle, 'name')
36
41
  * const editName = useEditDocument(handle, 'name')
37
42
  *
@@ -45,8 +50,14 @@ type Updater<TValue> = TValue | ((nextValue: TValue) => TValue)
45
50
  * ```
46
51
  *
47
52
  * @example Update a count on a document by providing an updater function
48
- * ```
49
- * const handle = { _id: 'documentId', _type: 'documentType' }
53
+ * ```tsx
54
+ * const handle = {
55
+ * documentId: 'counter-123',
56
+ * documentType: 'counter',
57
+ * projectId: 'abc123',
58
+ * dataset: 'production'
59
+ * }
60
+ *
50
61
  * const count = useDocument(handle, 'count')
51
62
  * const editCount = useEditDocument(handle, 'count')
52
63
  *
@@ -68,7 +79,7 @@ export function useEditDocument<
68
79
  TDocument extends SanityDocument,
69
80
  TPath extends JsonMatchPath<TDocument>,
70
81
  >(
71
- doc: DocumentHandle<TDocument>,
82
+ docHandle: DocumentHandle<TDocument>,
72
83
  path: TPath,
73
84
  ): (nextValue: Updater<JsonMatch<TDocument, TPath>>) => Promise<ActionsResult<TDocument>>
74
85
 
@@ -78,15 +89,20 @@ export function useEditDocument<
78
89
  *
79
90
  * ## useEditDocument(doc)
80
91
  * Edit an entire document
81
- * @param doc - The document to be edited; either as a document handle or the document’s ID a string. If you pass a `DocumentHandle` with a `resourceId` (in the format of `document:projectId.dataset:documentId`)
82
- * the document will be read from the specified Sanity project and dataset that is included in the handle. If no `resourceId` is provided, the default project and dataset from your `SanityApp` configuration will be used.
92
+ * @param docHandle - The document to be edited, specified as a DocumentHandle.
93
+ * The hook will automatically use the Sanity instance that matches the project and dataset specified in the handle.
83
94
  * @returns A function to update the document state. Accepts either a new document state, or an updater function that exposes the previous document state and returns the new document state.
84
95
  * @example
85
- * ```
86
- * const myDocumentHandle = { _id: 'documentId', _type: 'documentType' }
96
+ * ```tsx
97
+ * const myDocumentHandle = {
98
+ * documentId: 'product-123',
99
+ * documentType: 'product',
100
+ * projectId: 'abc123',
101
+ * dataset: 'production'
102
+ * }
87
103
  *
88
104
  * const myDocument = useDocument(myDocumentHandle)
89
- * const { title, price } = myDocument
105
+ * const { title, price } = myDocument ?? {}
90
106
  *
91
107
  * const editMyDocument = useEditDocument(myDocumentHandle)
92
108
  *
@@ -124,7 +140,7 @@ export function useEditDocument<
124
140
  * <input
125
141
  * name='salePrice'
126
142
  * type='checkbox'
127
- * checked={Object(myDocument).hasOwnProperty('salePrice')}
143
+ * checked={myDocument && 'salePrice' in myDocument}
128
144
  * onChange={handleSaleChange}
129
145
  * />
130
146
  * </form>
@@ -136,7 +152,7 @@ export function useEditDocument<
136
152
  * ```
137
153
  */
138
154
  export function useEditDocument<TDocument extends SanityDocument>(
139
- doc: DocumentHandle<TDocument>,
155
+ docHandle: DocumentHandle<TDocument>,
140
156
  ): (nextValue: Updater<TDocument>) => Promise<ActionsResult<TDocument>>
141
157
 
142
158
  /**
@@ -148,30 +164,28 @@ export function useEditDocument<TDocument extends SanityDocument>(
148
164
  * When called without a `path` argument, the hook will return a function for updating the entire document.
149
165
  */
150
166
  export function useEditDocument(
151
- doc: DocumentHandle,
167
+ docHandle: DocumentHandle,
152
168
  path?: string,
153
169
  ): (updater: Updater<unknown>) => Promise<ActionsResult> {
154
- const resourceId = getResourceId(doc.resourceId)!
155
- const documentId = doc._id
156
- const instance = useSanityInstance(resourceId)
157
- const apply = useApplyDocumentActions(resourceId)
170
+ const instance = useSanityInstance(docHandle)
171
+ const apply = useApplyDocumentActions()
158
172
  const isDocumentReady = useCallback(
159
- () => getDocumentState(instance, documentId).getCurrent() !== undefined,
160
- [instance, documentId],
173
+ () => getDocumentState(instance, docHandle).getCurrent() !== undefined,
174
+ [instance, docHandle],
161
175
  )
162
- if (!isDocumentReady()) throw resolveDocument(instance, documentId)
176
+ if (!isDocumentReady()) throw resolveDocument(instance, docHandle)
163
177
 
164
178
  return (updater: Updater<unknown>) => {
165
179
  if (path) {
166
180
  const nextValue =
167
181
  typeof updater === 'function'
168
- ? updater(getDocumentState(instance, documentId, path).getCurrent())
182
+ ? updater(getDocumentState(instance, docHandle, path).getCurrent())
169
183
  : updater
170
184
 
171
- return apply(editDocument(doc, {set: {[path]: nextValue}}))
185
+ return apply(editDocument(docHandle, {set: {[path]: nextValue}}))
172
186
  }
173
187
 
174
- const current = getDocumentState(instance, documentId).getCurrent()
188
+ const current = getDocumentState(instance, docHandle).getCurrent() as object | null | undefined
175
189
  const nextValue = typeof updater === 'function' ? updater(current) : updater
176
190
 
177
191
  if (typeof nextValue !== 'object' || !nextValue) {
@@ -183,11 +197,11 @@ export function useEditDocument(
183
197
  const allKeys = Object.keys({...current, ...nextValue})
184
198
  const editActions = allKeys
185
199
  .filter((key) => !ignoredKeys.includes(key))
186
- .filter((key) => current?.[key] !== nextValue[key])
200
+ .filter((key) => current?.[key as keyof typeof current] !== nextValue[key])
187
201
  .map((key) =>
188
202
  key in nextValue
189
- ? editDocument(doc, {set: {[key]: nextValue[key]}})
190
- : editDocument(doc, {unset: [key]}),
203
+ ? editDocument(docHandle, {set: {[key]: nextValue[key]}})
204
+ : editDocument(docHandle, {unset: [key]}),
191
205
  )
192
206
 
193
207
  return apply(editActions)
@@ -1,11 +1,14 @@
1
+ import {type SanityInstance} from '@sanity/sdk'
1
2
  import {act, renderHook} from '@testing-library/react'
2
3
  import {describe, vi} from 'vitest'
3
4
 
4
5
  import {evaluateSync, parse} from '../_synchronous-groq-js.mjs'
6
+ import {useSanityInstance} from '../context/useSanityInstance'
5
7
  import {useQuery} from '../query/useQuery'
6
8
  import {useDocuments} from './useDocuments'
7
9
 
8
10
  vi.mock('../query/useQuery')
11
+ vi.mock('../context/useSanityInstance')
9
12
 
10
13
  describe('useDocuments', () => {
11
14
  beforeEach(() => {
@@ -72,6 +75,7 @@ describe('useDocuments', () => {
72
75
  isPending: false,
73
76
  }
74
77
  })
78
+ vi.mocked(useSanityInstance).mockReturnValue({config: {}} as SanityInstance)
75
79
  })
76
80
 
77
81
  it('should respect custom page size', () => {
@@ -84,7 +88,7 @@ describe('useDocuments', () => {
84
88
  it('should filter by document type', () => {
85
89
  const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'}))
86
90
 
87
- expect(result.current.data.every((doc) => doc._type === 'movie')).toBe(true)
91
+ expect(result.current.data.every((doc) => doc.documentType === 'movie')).toBe(true)
88
92
  expect(result.current.count).toBe(5) // 5 movies in the dataset
89
93
  })
90
94
 
@@ -93,7 +97,7 @@ describe('useDocuments', () => {
93
97
  const {result} = renderHook(() => useDocuments({search: 'inter'}))
94
98
 
95
99
  // Should match "Interstellar"
96
- expect(result.current.data.some((doc) => doc._id === 'movie3')).toBe(true)
100
+ expect(result.current.data.some((doc) => doc.documentId === 'movie3')).toBe(true)
97
101
  })
98
102
 
99
103
  it('should apply ordering', () => {
@@ -105,7 +109,7 @@ describe('useDocuments', () => {
105
109
  )
106
110
 
107
111
  // First item should be the most recent movie (Interstellar, 2014)
108
- expect(result.current.data[0]._id).toBe('movie3')
112
+ expect(result.current.data[0].documentId).toBe('movie3')
109
113
  })
110
114
 
111
115
  it('should load more data when loadMore is called', () => {
@@ -149,4 +153,27 @@ describe('useDocuments', () => {
149
153
  // With the filter applied, the limit is reset to pageSize (i.e. 2)
150
154
  expect(result.current.data.length).toBe(2)
151
155
  })
156
+
157
+ it('should add projectId and dataset to document handles', () => {
158
+ // Update the mock to include specific projectId and dataset
159
+ vi.mocked(useSanityInstance).mockReturnValue({
160
+ config: {
161
+ projectId: 'test-project',
162
+ dataset: 'test-dataset',
163
+ },
164
+ } as SanityInstance)
165
+
166
+ const {result} = renderHook(() => useDocuments({}))
167
+
168
+ // Check that the first document handle has the projectId and dataset
169
+ expect(result.current.data[0].projectId).toBe('test-project')
170
+ expect(result.current.data[0].dataset).toBe('test-dataset')
171
+
172
+ // Verify all document handles have these properties
173
+ expect(
174
+ result.current.data.every(
175
+ (doc) => doc.projectId === 'test-project' && doc.dataset === 'test-dataset',
176
+ ),
177
+ ).toBe(true)
178
+ })
152
179
  })
@@ -1,7 +1,9 @@
1
1
  import {type DocumentHandle, type QueryOptions} from '@sanity/sdk'
2
2
  import {type SortOrderingItem} from '@sanity/types'
3
+ import {pick} from 'lodash-es'
3
4
  import {useCallback, useEffect, useMemo, useState} from 'react'
4
5
 
6
+ import {useSanityInstance} from '../context/useSanityInstance'
5
7
  import {useQuery} from '../query/useQuery'
6
8
 
7
9
  const DEFAULT_BATCH_SIZE = 25
@@ -79,9 +81,15 @@ export interface DocumentsResponse {
79
81
  * @category Documents
80
82
  * @param options - Configuration options for the infinite list
81
83
  * @returns An object containing the list of document handles, the loading state, the total count of retrieved document handles, and a function to load more
82
- * @example
84
+ *
85
+ * @remarks
86
+ * - The returned document handles include projectId and dataset information from the current Sanity instance
87
+ * - This makes them ready to use with document operations and other document hooks
88
+ * - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
89
+ *
90
+ * @example Basic infinite list with loading more
83
91
  * ```tsx
84
- * const {data, hasMore, isPending, loadMore} = useDocuments({
92
+ * const { data, hasMore, isPending, loadMore, count } = useDocuments({
85
93
  * filter: '_type == "post"',
86
94
  * search: searchTerm,
87
95
  * batchSize: 10,
@@ -93,16 +101,17 @@ export interface DocumentsResponse {
93
101
  * Total documents: {count}
94
102
  * <ol>
95
103
  * {data.map((doc) => (
96
- * <li key={doc._id}>
104
+ * <li key={doc.documentId}>
97
105
  * <MyDocumentComponent doc={doc} />
98
106
  * </li>
99
107
  * ))}
100
108
  * </ol>
101
- * {hasMore && <button onClick={loadMore}>Load More</button>}
109
+ * {hasMore && <button onClick={loadMore} disabled={isPending}>
110
+ * {isPending ? 'Loading...' : 'Load More'}
111
+ * </button>}
102
112
  * </div>
103
113
  * )
104
114
  * ```
105
- *
106
115
  */
107
116
  export function useDocuments({
108
117
  batchSize = DEFAULT_BATCH_SIZE,
@@ -112,6 +121,7 @@ export function useDocuments({
112
121
  orderings,
113
122
  ...options
114
123
  }: DocumentsOptions): DocumentsResponse {
124
+ const instance = useSanityInstance(options)
115
125
  const perspective = options.perspective ?? DEFAULT_PERSPECTIVE
116
126
  const [limit, setLimit] = useState(batchSize)
117
127
 
@@ -149,7 +159,7 @@ export function useDocuments({
149
159
  .join(',')})`
150
160
  : ''
151
161
 
152
- const dataQuery = `*${filterClause}${orderClause}[0...${limit}]{_id,_type}`
162
+ const dataQuery = `*${filterClause}${orderClause}[0...${limit}]{"documentId":_id,"documentType":_type,...$__dataset}`
153
163
  const countQuery = `count(*${filterClause})`
154
164
 
155
165
  const {
@@ -157,7 +167,10 @@ export function useDocuments({
157
167
  isPending,
158
168
  } = useQuery<UseDocumentsQueryResult>(`{"count":${countQuery},"data":${dataQuery}}`, {
159
169
  ...options,
160
- params,
170
+ params: {
171
+ ...params,
172
+ __dataset: pick(instance.config, 'projectId', 'dataset'),
173
+ },
161
174
  perspective,
162
175
  })
163
176