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

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 (116) hide show
  1. package/README.md +38 -67
  2. package/dist/index.d.ts +4811 -2
  3. package/dist/index.js +1069 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +27 -58
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +4 -14
  8. package/src/components/Login/LoginLinks.tsx +16 -31
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +156 -0
  12. package/src/components/SanityApp.tsx +90 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +6 -19
  14. package/src/components/auth/AuthBoundary.tsx +20 -4
  15. package/src/components/auth/Login.test.tsx +2 -16
  16. package/src/components/auth/Login.tsx +11 -30
  17. package/src/components/auth/LoginCallback.test.tsx +5 -20
  18. package/src/components/auth/LoginCallback.tsx +9 -14
  19. package/src/components/auth/LoginError.test.tsx +2 -17
  20. package/src/components/auth/LoginError.tsx +11 -16
  21. package/src/components/auth/LoginFooter.test.tsx +2 -16
  22. package/src/components/auth/LoginFooter.tsx +8 -24
  23. package/src/components/auth/LoginLayout.test.tsx +2 -16
  24. package/src/components/auth/LoginLayout.tsx +8 -38
  25. package/src/components/auth/authTestHelpers.tsx +11 -0
  26. package/src/components/utils.ts +22 -0
  27. package/src/context/SanityInstanceContext.ts +4 -0
  28. package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
  29. package/src/context/SanityProvider.tsx +50 -0
  30. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  31. package/src/hooks/auth/useAuthState.tsx +4 -5
  32. package/src/hooks/auth/useAuthToken.tsx +1 -1
  33. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  34. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  35. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  36. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  37. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  38. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  39. package/src/hooks/auth/useLogOut.tsx +1 -1
  40. package/src/hooks/auth/useLoginUrls.tsx +1 -0
  41. package/src/hooks/client/useClient.ts +9 -30
  42. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  43. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  44. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  45. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  46. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  47. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  48. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  49. package/src/hooks/comlink/useWindowConnection.ts +122 -0
  50. package/src/hooks/context/useSanityInstance.test.tsx +2 -2
  51. package/src/hooks/context/useSanityInstance.ts +24 -8
  52. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  53. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  54. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  55. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  56. package/src/hooks/datasets/useDatasets.ts +40 -0
  57. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  58. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  59. package/src/hooks/document/useDocument.test.ts +81 -0
  60. package/src/hooks/document/useDocument.ts +107 -0
  61. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  62. package/src/hooks/document/useDocumentEvent.ts +54 -0
  63. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  64. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  65. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  66. package/src/hooks/document/useEditDocument.test.ts +179 -0
  67. package/src/hooks/document/useEditDocument.ts +195 -0
  68. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  69. package/src/hooks/documents/useDocuments.ts +174 -0
  70. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  71. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  72. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  73. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  74. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  75. package/src/hooks/preview/usePreview.test.tsx +19 -10
  76. package/src/hooks/preview/usePreview.tsx +67 -13
  77. package/src/hooks/projection/useProjection.test.tsx +218 -0
  78. package/src/hooks/projection/useProjection.ts +147 -0
  79. package/src/hooks/projects/useProject.ts +48 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +103 -0
  83. package/src/hooks/users/useUsers.test.ts +163 -0
  84. package/src/hooks/users/useUsers.ts +107 -0
  85. package/src/utils/getEnv.ts +21 -0
  86. package/src/version.ts +8 -0
  87. package/src/vite-env.d.ts +10 -0
  88. package/dist/_chunks-es/useLogOut.js +0 -44
  89. package/dist/_chunks-es/useLogOut.js.map +0 -1
  90. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  91. package/dist/components.d.ts +0 -257
  92. package/dist/components.js +0 -316
  93. package/dist/components.js.map +0 -1
  94. package/dist/hooks.d.ts +0 -187
  95. package/dist/hooks.js +0 -81
  96. package/dist/hooks.js.map +0 -1
  97. package/src/_exports/components.ts +0 -13
  98. package/src/_exports/hooks.ts +0 -9
  99. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  100. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  101. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  102. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  103. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  104. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  105. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  106. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  107. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  108. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  109. package/src/components/context/SanityProvider.tsx +0 -42
  110. package/src/css/css.config.js +0 -220
  111. package/src/css/paramour.css +0 -2347
  112. package/src/css/styles.css +0 -11
  113. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  114. package/src/hooks/client/useClient.test.tsx +0 -130
  115. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  116. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -0,0 +1,179 @@
1
+ // tests/useEditDocument.test.ts
2
+ import {
3
+ createSanityInstance,
4
+ type DocumentHandle,
5
+ editDocument,
6
+ getDocumentState,
7
+ resolveDocument,
8
+ type StateSource,
9
+ } from '@sanity/sdk'
10
+ import {type SanityDocument} from '@sanity/types'
11
+ import {renderHook} from '@testing-library/react'
12
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
13
+
14
+ import {useSanityInstance} from '../context/useSanityInstance'
15
+ import {useApplyDocumentActions} from './useApplyDocumentActions'
16
+ import {useEditDocument} from './useEditDocument'
17
+
18
+ vi.mock('@sanity/sdk', async (importOriginal) => {
19
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
20
+ return {
21
+ ...original,
22
+ getDocumentState: vi.fn(),
23
+ resolveDocument: vi.fn(),
24
+ editDocument: vi.fn(original.editDocument),
25
+ }
26
+ })
27
+
28
+ vi.mock('../context/useSanityInstance', () => ({
29
+ useSanityInstance: vi.fn(),
30
+ }))
31
+
32
+ vi.mock('./useApplyDocumentActions', () => ({
33
+ useApplyDocumentActions: vi.fn(),
34
+ }))
35
+
36
+ // Create a fake instance to be returned by useSanityInstance.
37
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
38
+
39
+ const doc = {
40
+ _id: 'doc1',
41
+ foo: 'bar',
42
+ _type: 'book',
43
+ _rev: 'tx0',
44
+ _createdAt: '2025-02-06T00:11:00.000Z',
45
+ _updatedAt: '2025-02-06T00:11:00.000Z',
46
+ } satisfies SanityDocument
47
+
48
+ const docHandle: DocumentHandle<SanityDocument> = {
49
+ _id: 'doc1',
50
+ _type: 'book',
51
+ resourceId: 'document:p.d:doc1',
52
+ }
53
+
54
+ describe('useEditDocument hook', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks()
57
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
58
+ })
59
+
60
+ it('applies a single edit action for the given path', async () => {
61
+ const getCurrent = vi.fn().mockReturnValue(doc)
62
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
63
+ vi.mocked(getDocumentState).mockReturnValue({
64
+ getCurrent,
65
+ subscribe,
66
+ } as unknown as StateSource<SanityDocument>)
67
+
68
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx1'})
69
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
70
+
71
+ const {result} = renderHook(() => useEditDocument(docHandle, 'foo'))
72
+ const promise = result.current('newValue')
73
+ expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'newValue'}})
74
+ expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'newValue'}}))
75
+ const actionsResult = await promise
76
+ expect(actionsResult).toEqual({transactionId: 'tx1'})
77
+ })
78
+
79
+ it('applies edit actions for changed fields', async () => {
80
+ // Set up current document state.
81
+ const currentDoc = {...doc, foo: 'bar', extra: 'old'}
82
+ const getCurrent = vi.fn().mockReturnValue(currentDoc)
83
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
84
+ vi.mocked(getDocumentState).mockReturnValue({
85
+ getCurrent,
86
+ subscribe,
87
+ } as unknown as StateSource<SanityDocument>)
88
+
89
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx2'})
90
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
91
+
92
+ const {result} = renderHook(() => useEditDocument(docHandle))
93
+ const promise = result.current({...doc, foo: 'baz', extra: 'old', _id: 'doc1'})
94
+ expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
95
+ const actionsResult = await promise
96
+ expect(actionsResult).toEqual({transactionId: 'tx2'})
97
+ })
98
+
99
+ it('applies a single edit action using an updater function for the given path', async () => {
100
+ const getCurrent = vi.fn().mockReturnValue(doc.foo)
101
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
102
+ vi.mocked(getDocumentState).mockReturnValue({
103
+ getCurrent,
104
+ subscribe,
105
+ } as unknown as StateSource<SanityDocument>)
106
+
107
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx3'})
108
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
109
+
110
+ const {result} = renderHook(() => useEditDocument(docHandle, 'foo'))
111
+ const promise = result.current((prev: unknown) => `${prev}Updated`) // 'bar' becomes 'barUpdated'
112
+ expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'barUpdated'}})
113
+ expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'barUpdated'}}))
114
+ const actionsResult = await promise
115
+ expect(actionsResult).toEqual({transactionId: 'tx3'})
116
+ })
117
+
118
+ it('applies edit actions using an updater function for the entire document', async () => {
119
+ const currentDoc = {...doc, foo: 'bar', extra: 'old'}
120
+ const getCurrent = vi.fn().mockReturnValue(currentDoc)
121
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
122
+ vi.mocked(getDocumentState).mockReturnValue({
123
+ getCurrent,
124
+ subscribe,
125
+ } as unknown as StateSource<SanityDocument>)
126
+
127
+ const apply = vi.fn().mockResolvedValue({transactionId: 'tx4'})
128
+ vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
129
+
130
+ const {result} = renderHook(() => useEditDocument(docHandle))
131
+ const promise = result.current((prevDoc) => ({...prevDoc, foo: 'baz'}))
132
+ expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
133
+ const actionsResult = await promise
134
+ expect(actionsResult).toEqual({transactionId: 'tx4'})
135
+ })
136
+
137
+ it('throws an error if next value is not an object', () => {
138
+ const getCurrent = vi.fn().mockReturnValue(doc)
139
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
140
+ vi.mocked(getDocumentState).mockReturnValue({
141
+ getCurrent,
142
+ subscribe,
143
+ } as unknown as StateSource<SanityDocument>)
144
+
145
+ const fakeApply = vi.fn()
146
+ vi.mocked(useApplyDocumentActions).mockReturnValue(fakeApply)
147
+
148
+ const {result} = renderHook(() => useEditDocument(docHandle))
149
+ expect(() => result.current('notAnObject' as unknown as SanityDocument)).toThrowError(
150
+ 'No path was provided to `useEditDocument` and the value provided was not a document object.',
151
+ )
152
+ })
153
+
154
+ it('throws a promise (suspends) when the document is not ready', () => {
155
+ const getCurrent = vi.fn().mockReturnValue(undefined)
156
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
157
+ vi.mocked(getDocumentState).mockReturnValue({
158
+ getCurrent,
159
+ subscribe,
160
+ } as unknown as StateSource<unknown>)
161
+
162
+ const resolveDocPromise = Promise.resolve(doc)
163
+
164
+ // Also, simulate resolveDocument to return a known promise.
165
+ vi.mocked(resolveDocument).mockReturnValue(resolveDocPromise)
166
+
167
+ // Render the hook and capture the thrown promise.
168
+ const {result} = renderHook(() => {
169
+ try {
170
+ return useEditDocument(docHandle)
171
+ } catch (e) {
172
+ return e
173
+ }
174
+ })
175
+
176
+ // When the document is not ready, the hook throws the promise from resolveDocument.
177
+ expect(result.current).toBe(resolveDocPromise)
178
+ })
179
+ })
@@ -0,0 +1,195 @@
1
+ import {
2
+ type ActionsResult,
3
+ type DocumentHandle,
4
+ editDocument,
5
+ getDocumentState,
6
+ getResourceId,
7
+ type JsonMatch,
8
+ type JsonMatchPath,
9
+ resolveDocument,
10
+ } from '@sanity/sdk'
11
+ import {type SanityDocument} from '@sanity/types'
12
+ import {useCallback} from 'react'
13
+
14
+ import {useSanityInstance} from '../context/useSanityInstance'
15
+ import {useApplyDocumentActions} from './useApplyDocumentActions'
16
+
17
+ const ignoredKeys = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
18
+
19
+ type Updater<TValue> = TValue | ((nextValue: TValue) => TValue)
20
+
21
+ /**
22
+ *
23
+ * @beta
24
+ *
25
+ * ## useEditDocument(doc, path)
26
+ * Edit a nested value within a document
27
+ *
28
+ * @category Documents
29
+ * @param doc - The document to be edited; either as a document handle or the document’s ID a string
30
+ * @param path - The path to the nested value to be edited
31
+ * @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 document’s name by providing the new value directly
33
+ * ```
34
+ * const handle = { _id: 'documentId', _type: 'documentType' }
35
+ * const name = useDocument(handle, 'name')
36
+ * const editName = useEditDocument(handle, 'name')
37
+ *
38
+ * function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
39
+ * editName(event.target.value)
40
+ * }
41
+ *
42
+ * return (
43
+ * <input type='text' value={name} onChange={handleNameChange} />
44
+ * )
45
+ * ```
46
+ *
47
+ * @example Update a count on a document by providing an updater function
48
+ * ```
49
+ * const handle = { _id: 'documentId', _type: 'documentType' }
50
+ * const count = useDocument(handle, 'count')
51
+ * const editCount = useEditDocument(handle, 'count')
52
+ *
53
+ * function incrementCount() {
54
+ * editCount(previousCount => previousCount + 1)
55
+ * }
56
+ *
57
+ * return (
58
+ * <>
59
+ * <button onClick={incrementCount}>
60
+ * Increment
61
+ * </button>
62
+ * Current count: {count}
63
+ * </>
64
+ * )
65
+ * ```
66
+ */
67
+ export function useEditDocument<
68
+ TDocument extends SanityDocument,
69
+ TPath extends JsonMatchPath<TDocument>,
70
+ >(
71
+ doc: DocumentHandle<TDocument>,
72
+ path: TPath,
73
+ ): (nextValue: Updater<JsonMatch<TDocument, TPath>>) => Promise<ActionsResult<TDocument>>
74
+
75
+ /**
76
+ *
77
+ * @beta
78
+ *
79
+ * ## useEditDocument(doc)
80
+ * 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.
83
+ * @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
+ * @example
85
+ * ```
86
+ * const myDocumentHandle = { _id: 'documentId', _type: 'documentType' }
87
+ *
88
+ * const myDocument = useDocument(myDocumentHandle)
89
+ * const { title, price } = myDocument
90
+ *
91
+ * const editMyDocument = useEditDocument(myDocumentHandle)
92
+ *
93
+ * function handleFieldChange(e: React.ChangeEvent<HTMLInputElement>) {
94
+ * const {name, value} = e.currentTarget
95
+ * // Use an updater function to update the document state based on the previous state
96
+ * editMyDocument(previousDocument => ({
97
+ * ...previousDocument,
98
+ * [name]: value
99
+ * }))
100
+ * }
101
+ *
102
+ * function handleSaleChange(e: React.ChangeEvent<HTMLInputElement>) {
103
+ * const { checked } = e.currentTarget
104
+ * if (checked) {
105
+ * // Use an updater function to add a new salePrice field;
106
+ * // set it at a 20% discount off the normal price
107
+ * editMyDocument(previousDocument => ({
108
+ * ...previousDocument,
109
+ * salePrice: previousDocument.price * 0.8,
110
+ * }))
111
+ * } else {
112
+ * // Get the document state without the salePrice field
113
+ * const { salePrice, ...rest } = myDocument
114
+ * // Update the document state to remove the salePrice field
115
+ * editMyDocument(rest)
116
+ * }
117
+ * }
118
+ *
119
+ * return (
120
+ * <>
121
+ * <form onSubmit={e => e.preventDefault()}>
122
+ * <input name='title' type='text' value={title} onChange={handleFieldChange} />
123
+ * <input name='price' type='number' value={price} onChange={handleFieldChange} />
124
+ * <input
125
+ * name='salePrice'
126
+ * type='checkbox'
127
+ * checked={Object(myDocument).hasOwnProperty('salePrice')}
128
+ * onChange={handleSaleChange}
129
+ * />
130
+ * </form>
131
+ * <pre><code>
132
+ * {JSON.stringify(myDocument, null, 2)}
133
+ * </code></pre>
134
+ * </>
135
+ * )
136
+ * ```
137
+ */
138
+ export function useEditDocument<TDocument extends SanityDocument>(
139
+ doc: DocumentHandle<TDocument>,
140
+ ): (nextValue: Updater<TDocument>) => Promise<ActionsResult<TDocument>>
141
+
142
+ /**
143
+ *
144
+ * @beta
145
+ *
146
+ * Enables editing of a document’s state.
147
+ * When called with a `path` argument, the hook will return a function for updating a nested value.
148
+ * When called without a `path` argument, the hook will return a function for updating the entire document.
149
+ */
150
+ export function useEditDocument(
151
+ doc: DocumentHandle,
152
+ path?: string,
153
+ ): (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)
158
+ const isDocumentReady = useCallback(
159
+ () => getDocumentState(instance, documentId).getCurrent() !== undefined,
160
+ [instance, documentId],
161
+ )
162
+ if (!isDocumentReady()) throw resolveDocument(instance, documentId)
163
+
164
+ return (updater: Updater<unknown>) => {
165
+ if (path) {
166
+ const nextValue =
167
+ typeof updater === 'function'
168
+ ? updater(getDocumentState(instance, documentId, path).getCurrent())
169
+ : updater
170
+
171
+ return apply(editDocument(doc, {set: {[path]: nextValue}}))
172
+ }
173
+
174
+ const current = getDocumentState(instance, documentId).getCurrent()
175
+ const nextValue = typeof updater === 'function' ? updater(current) : updater
176
+
177
+ if (typeof nextValue !== 'object' || !nextValue) {
178
+ throw new Error(
179
+ `No path was provided to \`useEditDocument\` and the value provided was not a document object.`,
180
+ )
181
+ }
182
+
183
+ const allKeys = Object.keys({...current, ...nextValue})
184
+ const editActions = allKeys
185
+ .filter((key) => !ignoredKeys.includes(key))
186
+ .filter((key) => current?.[key] !== nextValue[key])
187
+ .map((key) =>
188
+ key in nextValue
189
+ ? editDocument(doc, {set: {[key]: nextValue[key]}})
190
+ : editDocument(doc, {unset: [key]}),
191
+ )
192
+
193
+ return apply(editActions)
194
+ }
195
+ }
@@ -0,0 +1,152 @@
1
+ import {act, renderHook} from '@testing-library/react'
2
+ import {describe, vi} from 'vitest'
3
+
4
+ import {evaluateSync, parse} from '../_synchronous-groq-js.mjs'
5
+ import {useQuery} from '../query/useQuery'
6
+ import {useDocuments} from './useDocuments'
7
+
8
+ vi.mock('../query/useQuery')
9
+
10
+ describe('useDocuments', () => {
11
+ beforeEach(() => {
12
+ const dataset = [
13
+ {
14
+ _id: 'movie1',
15
+ _type: 'movie',
16
+ title: 'The Matrix',
17
+ releaseYear: 1999,
18
+ _createdAt: '2021-03-09T00:00:00.000Z',
19
+ _updatedAt: '2021-03-09T00:00:00.000Z',
20
+ _rev: 'tx0',
21
+ },
22
+ {
23
+ _id: 'movie2',
24
+ _type: 'movie',
25
+ title: 'Inception',
26
+ releaseYear: 2010,
27
+ _createdAt: '2021-03-10T00:00:00.000Z',
28
+ _updatedAt: '2021-03-10T00:00:00.000Z',
29
+ _rev: 'tx1',
30
+ },
31
+ {
32
+ _id: 'movie3',
33
+ _type: 'movie',
34
+ title: 'Interstellar',
35
+ releaseYear: 2014,
36
+ _createdAt: '2021-03-11T00:00:00.000Z',
37
+ _updatedAt: '2021-03-11T00:00:00.000Z',
38
+ _rev: 'tx2',
39
+ },
40
+ {
41
+ _id: 'book1',
42
+ _type: 'book',
43
+ title: 'Dune',
44
+ _createdAt: '2021-03-12T00:00:00.000Z',
45
+ _updatedAt: '2021-03-12T00:00:00.000Z',
46
+ _rev: 'tx3',
47
+ },
48
+ {
49
+ _id: 'movie4',
50
+ _type: 'movie',
51
+ title: 'The Dark Knight',
52
+ releaseYear: 2008,
53
+ _createdAt: '2021-03-13T00:00:00.000Z',
54
+ _updatedAt: '2021-03-13T00:00:00.000Z',
55
+ _rev: 'tx4',
56
+ },
57
+ {
58
+ _id: 'movie5',
59
+ _type: 'movie',
60
+ title: 'Pulp Fiction',
61
+ releaseYear: 1994,
62
+ _createdAt: '2021-03-14T00:00:00.000Z',
63
+ _updatedAt: '2021-03-14T00:00:00.000Z',
64
+ _rev: 'tx5',
65
+ },
66
+ ]
67
+
68
+ vi.mocked(useQuery).mockImplementation((query, options) => {
69
+ const result = evaluateSync(parse(query), {dataset, params: options?.params}).get()
70
+ return {
71
+ data: result,
72
+ isPending: false,
73
+ }
74
+ })
75
+ })
76
+
77
+ it('should respect custom page size', () => {
78
+ const customBatchSize = 2
79
+ const {result} = renderHook(() => useDocuments({batchSize: customBatchSize}))
80
+
81
+ expect(result.current.data.length).toBe(customBatchSize)
82
+ })
83
+
84
+ it('should filter by document type', () => {
85
+ const {result} = renderHook(() => useDocuments({filter: '_type == "movie"'}))
86
+
87
+ expect(result.current.data.every((doc) => doc._type === 'movie')).toBe(true)
88
+ expect(result.current.count).toBe(5) // 5 movies in the dataset
89
+ })
90
+
91
+ // groq-js doesn't support search filters yet
92
+ it.skip('should apply search filter', () => {
93
+ const {result} = renderHook(() => useDocuments({search: 'inter'}))
94
+
95
+ // Should match "Interstellar"
96
+ expect(result.current.data.some((doc) => doc._id === 'movie3')).toBe(true)
97
+ })
98
+
99
+ it('should apply ordering', () => {
100
+ const {result} = renderHook(() =>
101
+ useDocuments({
102
+ filter: '_type == "movie"',
103
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
104
+ }),
105
+ )
106
+
107
+ // First item should be the most recent movie (Interstellar, 2014)
108
+ expect(result.current.data[0]._id).toBe('movie3')
109
+ })
110
+
111
+ it('should load more data when loadMore is called', () => {
112
+ const batchSize = 2
113
+ const {result} = renderHook(() => useDocuments({batchSize: batchSize}))
114
+
115
+ expect(result.current.data.length).toBe(batchSize)
116
+
117
+ act(() => {
118
+ result.current.loadMore()
119
+ })
120
+
121
+ expect(result.current.data.length).toBe(batchSize * 2)
122
+ })
123
+
124
+ it('should indicate when there is more data to load', () => {
125
+ const {result} = renderHook(() => useDocuments({batchSize: 3}))
126
+ expect(result.current.hasMore).toBe(true)
127
+ // Load all remaining data
128
+ act(() => {
129
+ result.current.loadMore()
130
+ })
131
+ expect(result.current.hasMore).toBe(false)
132
+ })
133
+
134
+ // New test case for resetting limit when filter changes
135
+ it('should reset limit when filter changes', () => {
136
+ const {result, rerender} = renderHook((props) => useDocuments(props), {
137
+ initialProps: {batchSize: 2, filter: ''},
138
+ })
139
+ // Initially, data length equals pageSize (2)
140
+ expect(result.current.data.length).toBe(2)
141
+ // Load more to increase limit
142
+ act(() => {
143
+ result.current.loadMore()
144
+ })
145
+ // After loadMore, data length should be increased (2 + 2 = 4)
146
+ expect(result.current.data.length).toBe(4)
147
+ // Now update filter to trigger resetting the limit
148
+ rerender({batchSize: 2, filter: '_type == "movie"'})
149
+ // With the filter applied, the limit is reset to pageSize (i.e. 2)
150
+ expect(result.current.data.length).toBe(2)
151
+ })
152
+ })
@@ -0,0 +1,174 @@
1
+ import {type DocumentHandle, type QueryOptions} from '@sanity/sdk'
2
+ import {type SortOrderingItem} from '@sanity/types'
3
+ import {useCallback, useEffect, useMemo, useState} from 'react'
4
+
5
+ import {useQuery} from '../query/useQuery'
6
+
7
+ const DEFAULT_BATCH_SIZE = 25
8
+ const DEFAULT_PERSPECTIVE = 'drafts'
9
+
10
+ /**
11
+ * Result structure returned from the infinite list query
12
+ * @internal
13
+ */
14
+ interface UseDocumentsQueryResult {
15
+ count: number
16
+ data: DocumentHandle[]
17
+ }
18
+
19
+ /**
20
+ * Configuration options for the useDocuments hook
21
+ *
22
+ * @beta
23
+ * @category Types
24
+ */
25
+ export interface DocumentsOptions extends QueryOptions {
26
+ /**
27
+ * GROQ filter expression to apply to the query
28
+ */
29
+ filter?: string
30
+ /**
31
+ * Number of items to load per batch (defaults to 25)
32
+ */
33
+ batchSize?: number
34
+ /**
35
+ * Sorting configuration for the results
36
+ */
37
+ orderings?: SortOrderingItem[]
38
+ /**
39
+ * Text search query to filter results
40
+ */
41
+ search?: string
42
+ }
43
+
44
+ /**
45
+ * Return value from the useDocuments hook
46
+ *
47
+ * @beta
48
+ * @category Types
49
+ */
50
+ export interface DocumentsResponse {
51
+ /**
52
+ * Array of document handles for the current batch
53
+ */
54
+ data: DocumentHandle[]
55
+ /**
56
+ * Whether there are more items available to load
57
+ */
58
+ hasMore: boolean
59
+ /**
60
+ * Total count of items matching the query
61
+ */
62
+ count: number
63
+ /**
64
+ * Whether a query is currently in progress
65
+ */
66
+ isPending: boolean
67
+ /**
68
+ * Function to load the next batch of results
69
+ */
70
+ loadMore: () => void
71
+ }
72
+
73
+ /**
74
+ * Retrieves batches of {@link DocumentHandle}s, narrowed by optional filters, text searches, and custom ordering,
75
+ * with infinite scrolling support. The number of document handles returned per batch is customizable,
76
+ * and additional batches can be loaded using the supplied `loadMore` function.
77
+ *
78
+ * @beta
79
+ * @category Documents
80
+ * @param options - Configuration options for the infinite list
81
+ * @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
83
+ * ```tsx
84
+ * const {data, hasMore, isPending, loadMore} = useDocuments({
85
+ * filter: '_type == "post"',
86
+ * search: searchTerm,
87
+ * batchSize: 10,
88
+ * orderings: [{field: '_createdAt', direction: 'desc'}]
89
+ * })
90
+ *
91
+ * return (
92
+ * <div>
93
+ * Total documents: {count}
94
+ * <ol>
95
+ * {data.map((doc) => (
96
+ * <li key={doc._id}>
97
+ * <MyDocumentComponent doc={doc} />
98
+ * </li>
99
+ * ))}
100
+ * </ol>
101
+ * {hasMore && <button onClick={loadMore}>Load More</button>}
102
+ * </div>
103
+ * )
104
+ * ```
105
+ *
106
+ */
107
+ export function useDocuments({
108
+ batchSize = DEFAULT_BATCH_SIZE,
109
+ params,
110
+ search,
111
+ filter,
112
+ orderings,
113
+ ...options
114
+ }: DocumentsOptions): DocumentsResponse {
115
+ const perspective = options.perspective ?? DEFAULT_PERSPECTIVE
116
+ const [limit, setLimit] = useState(batchSize)
117
+
118
+ // Reset the limit to the current batchSize whenever any query parameters
119
+ // (filter, search, params, orderings) or batchSize changes
120
+ const key = JSON.stringify({filter, search, params, orderings, batchSize})
121
+ useEffect(() => {
122
+ setLimit(batchSize)
123
+ }, [key, batchSize])
124
+
125
+ const filterClause = useMemo(() => {
126
+ const conditions: string[] = []
127
+
128
+ // Add search query if specified
129
+ if (search?.trim()) {
130
+ conditions.push(`[@] match text::query("${search.trim()}")`)
131
+ }
132
+
133
+ // Add additional filter if specified
134
+ if (filter) {
135
+ conditions.push(`(${filter})`)
136
+ }
137
+
138
+ return conditions.length ? `[${conditions.join(' && ')}]` : ''
139
+ }, [filter, search])
140
+
141
+ const orderClause = orderings
142
+ ? `| order(${orderings
143
+ .map((ordering) =>
144
+ [ordering.field, ordering.direction.toLowerCase()]
145
+ .map((str) => str.trim())
146
+ .filter(Boolean)
147
+ .join(' '),
148
+ )
149
+ .join(',')})`
150
+ : ''
151
+
152
+ const dataQuery = `*${filterClause}${orderClause}[0...${limit}]{_id,_type}`
153
+ const countQuery = `count(*${filterClause})`
154
+
155
+ const {
156
+ data: {count, data},
157
+ isPending,
158
+ } = useQuery<UseDocumentsQueryResult>(`{"count":${countQuery},"data":${dataQuery}}`, {
159
+ ...options,
160
+ params,
161
+ perspective,
162
+ })
163
+
164
+ const hasMore = data.length < count
165
+
166
+ const loadMore = useCallback(() => {
167
+ setLimit((prev) => Math.min(prev + batchSize, count))
168
+ }, [count, batchSize])
169
+
170
+ return useMemo(
171
+ () => ({data, hasMore, count, isPending, loadMore}),
172
+ [data, hasMore, count, isPending, loadMore],
173
+ )
174
+ }