@sanity/sdk-react 0.0.0-alpha.9 → 0.0.0-rc.1

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 (94) hide show
  1. package/README.md +33 -126
  2. package/dist/index.d.ts +4742 -2
  3. package/dist/index.js +1054 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +19 -43
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +90 -0
  8. package/src/components/Login/LoginLinks.tsx +58 -0
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +104 -2
  12. package/src/components/SanityApp.tsx +54 -17
  13. package/src/components/auth/AuthBoundary.test.tsx +2 -2
  14. package/src/components/auth/AuthBoundary.tsx +13 -3
  15. package/src/components/auth/Login.test.tsx +1 -1
  16. package/src/components/auth/Login.tsx +11 -26
  17. package/src/components/auth/LoginCallback.tsx +4 -7
  18. package/src/components/auth/LoginError.tsx +12 -8
  19. package/src/components/auth/LoginFooter.tsx +13 -20
  20. package/src/components/auth/LoginLayout.tsx +8 -9
  21. package/src/components/auth/authTestHelpers.tsx +1 -8
  22. package/src/components/utils.ts +22 -0
  23. package/src/context/SanityInstanceContext.ts +4 -0
  24. package/src/context/SanityProvider.test.tsx +1 -1
  25. package/src/context/SanityProvider.tsx +10 -8
  26. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  27. package/src/hooks/auth/useAuthState.tsx +0 -2
  28. package/src/hooks/auth/useCurrentUser.tsx +26 -20
  29. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  30. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  31. package/src/hooks/client/useClient.ts +8 -30
  32. package/src/hooks/comlink/useFrameConnection.test.tsx +55 -10
  33. package/src/hooks/comlink/useFrameConnection.ts +39 -43
  34. package/src/hooks/comlink/useManageFavorite.test.ts +106 -0
  35. package/src/hooks/comlink/useManageFavorite.ts +101 -0
  36. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +77 -0
  37. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +79 -0
  38. package/src/hooks/comlink/useWindowConnection.test.ts +53 -12
  39. package/src/hooks/comlink/useWindowConnection.ts +69 -29
  40. package/src/hooks/context/useSanityInstance.test.tsx +1 -1
  41. package/src/hooks/context/useSanityInstance.ts +21 -5
  42. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +97 -0
  43. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +274 -0
  44. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +91 -0
  45. package/src/hooks/datasets/useDatasets.ts +37 -0
  46. package/src/hooks/document/useApplyActions.test.ts +5 -4
  47. package/src/hooks/document/useApplyActions.ts +55 -5
  48. package/src/hooks/document/useDocument.test.ts +2 -2
  49. package/src/hooks/document/useDocument.ts +90 -21
  50. package/src/hooks/document/useDocumentEvent.test.ts +13 -3
  51. package/src/hooks/document/useDocumentEvent.ts +36 -4
  52. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -1
  53. package/src/hooks/document/useDocumentSyncStatus.ts +26 -2
  54. package/src/hooks/document/useEditDocument.test.ts +55 -10
  55. package/src/hooks/document/useEditDocument.ts +159 -31
  56. package/src/hooks/document/usePermissions.ts +82 -0
  57. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  58. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  59. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  60. package/src/hooks/infiniteList/useInfiniteList.test.tsx +152 -0
  61. package/src/hooks/infiniteList/useInfiniteList.ts +174 -0
  62. package/src/hooks/paginatedList/usePaginatedList.test.tsx +259 -0
  63. package/src/hooks/paginatedList/usePaginatedList.ts +290 -0
  64. package/src/hooks/preview/usePreview.test.tsx +6 -6
  65. package/src/hooks/preview/usePreview.tsx +12 -9
  66. package/src/hooks/projection/useProjection.test.tsx +218 -0
  67. package/src/hooks/projection/useProjection.ts +147 -0
  68. package/src/hooks/projects/useProject.ts +45 -0
  69. package/src/hooks/projects/useProjects.ts +41 -0
  70. package/src/hooks/query/useQuery.test.tsx +188 -0
  71. package/src/hooks/query/useQuery.ts +103 -0
  72. package/src/hooks/users/useUsers.test.ts +163 -0
  73. package/src/hooks/users/useUsers.ts +107 -0
  74. package/src/utils/getEnv.ts +21 -0
  75. package/src/version.ts +8 -0
  76. package/dist/_chunks-es/context.js +0 -8
  77. package/dist/_chunks-es/context.js.map +0 -1
  78. package/dist/_chunks-es/useLogOut.js +0 -45
  79. package/dist/_chunks-es/useLogOut.js.map +0 -1
  80. package/dist/components.d.ts +0 -111
  81. package/dist/components.js +0 -153
  82. package/dist/components.js.map +0 -1
  83. package/dist/context.d.ts +0 -45
  84. package/dist/context.js +0 -5
  85. package/dist/context.js.map +0 -1
  86. package/dist/hooks.d.ts +0 -3532
  87. package/dist/hooks.js +0 -218
  88. package/dist/hooks.js.map +0 -1
  89. package/src/_exports/components.ts +0 -2
  90. package/src/_exports/context.ts +0 -2
  91. package/src/_exports/hooks.ts +0 -32
  92. package/src/hooks/client/useClient.test.tsx +0 -130
  93. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  94. package/src/hooks/documentCollection/useDocuments.ts +0 -135
@@ -3,6 +3,7 @@ import {
3
3
  type DocumentHandle,
4
4
  editDocument,
5
5
  getDocumentState,
6
+ getResourceId,
6
7
  type JsonMatch,
7
8
  type JsonMatchPath,
8
9
  resolveDocument,
@@ -15,53 +16,180 @@ import {useApplyActions} from './useApplyActions'
15
16
 
16
17
  const ignoredKeys = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
17
18
 
18
- /** @beta */
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
+ */
19
67
  export function useEditDocument<
20
68
  TDocument extends SanityDocument,
21
69
  TPath extends JsonMatchPath<TDocument>,
22
70
  >(
23
- doc: string | DocumentHandle<TDocument>,
71
+ doc: DocumentHandle<TDocument>,
24
72
  path: TPath,
25
- ): (nextValue: JsonMatch<TDocument, TPath>) => Promise<ActionsResult<TDocument>>
26
- /** @beta */
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
+ */
27
138
  export function useEditDocument<TDocument extends SanityDocument>(
28
- doc: string | DocumentHandle<TDocument>,
29
- ): (nextValue: Partial<TDocument>) => Promise<ActionsResult<TDocument>>
30
- /** @beta */
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
+ */
31
150
  export function useEditDocument(
32
- doc: string | DocumentHandle,
151
+ doc: DocumentHandle,
33
152
  path?: string,
34
- ): (nextValue: unknown) => Promise<ActionsResult> {
35
- const documentId = typeof doc === 'string' ? doc : doc._id
36
- const instance = useSanityInstance()
37
- const apply = useApplyActions()
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 = useApplyActions(resourceId)
38
158
  const isDocumentReady = useCallback(
39
159
  () => getDocumentState(instance, documentId).getCurrent() !== undefined,
40
160
  [instance, documentId],
41
161
  )
42
162
  if (!isDocumentReady()) throw resolveDocument(instance, documentId)
43
163
 
44
- return useCallback(
45
- (next: unknown) => {
46
- if (path) {
47
- return apply(editDocument(documentId, {set: {[path]: next}}))
48
- }
164
+ return (updater: Updater<unknown>) => {
165
+ if (path) {
166
+ const nextValue =
167
+ typeof updater === 'function'
168
+ ? updater(getDocumentState(instance, documentId, path).getCurrent())
169
+ : updater
49
170
 
50
- const current = getDocumentState(instance, documentId).getCurrent()
171
+ return apply(editDocument(doc, {set: {[path]: nextValue}}))
172
+ }
51
173
 
52
- if (typeof next !== 'object' || !next) {
53
- throw new Error(
54
- `No path was provided to \`useEditDocument\` and the value provided was not a document object.`,
55
- )
56
- }
174
+ const current = getDocumentState(instance, documentId).getCurrent()
175
+ const nextValue = typeof updater === 'function' ? updater(current) : updater
57
176
 
58
- const editActions = Object.entries(next)
59
- .filter(([key]) => !ignoredKeys.includes(key))
60
- .filter(([key, value]) => current?.[key] !== value)
61
- .map(([key, value]) => editDocument(documentId, {set: {[key]: value}}))
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
+ }
62
182
 
63
- return apply(editActions)
64
- },
65
- [apply, documentId, instance, path],
66
- )
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
+ }
67
195
  }
@@ -0,0 +1,82 @@
1
+ import {
2
+ type DocumentAction,
3
+ getPermissionsState,
4
+ getResourceId,
5
+ type PermissionsResult,
6
+ } from '@sanity/sdk'
7
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
8
+ import {filter, firstValueFrom} from 'rxjs'
9
+
10
+ import {useSanityInstance} from '../context/useSanityInstance'
11
+
12
+ /**
13
+ *
14
+ * @beta
15
+ *
16
+ * Check if the current user has the specified permissions for the given document actions.
17
+ *
18
+ * @category Permissions
19
+ * @param actions - One more more calls to a particular document action function for a given document
20
+ * @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
+ *
22
+ * @example Checking for permission to publish a document
23
+ * ```ts
24
+ * import {usePermissions, useApplyActions} from '@sanity/sdk-react'
25
+ * import {publishDocument} from '@sanity/sdk'
26
+ *
27
+ * export function PublishButton({doc}: {doc: DocumentHandle}) {
28
+ * const publishPermissions = usePermissions(publishDocument(doc))
29
+ * const applyAction = useApplyActions()
30
+ *
31
+ * return (
32
+ * <>
33
+ * <button
34
+ * disabled={!publishPermissions.allowed}
35
+ * onClick={() => applyAction(publishDocument(doc))}
36
+ * popoverTarget={`${publishPermissions.allowed ? undefined : 'publishButtonPopover'}`}
37
+ * >
38
+ * Publish
39
+ * </button>
40
+ * {!publishPermissions.allowed && (
41
+ * <div popover id="publishButtonPopover">
42
+ * {publishPermissions.message}
43
+ * </div>
44
+ * )}
45
+ * </>
46
+ * )
47
+ * }
48
+ * ```
49
+ */
50
+ export function usePermissions(actions: DocumentAction | DocumentAction[]): PermissionsResult {
51
+ // if actions is an array, we need to check each action to see if the resourceId is the same
52
+ if (Array.isArray(actions)) {
53
+ const resourceIds = actions.map((action) => action.resourceId)
54
+ const uniqueResourceIds = new Set(resourceIds)
55
+ if (uniqueResourceIds.size !== 1) {
56
+ throw new Error('All actions must have the same resourceId')
57
+ }
58
+ }
59
+ const resourceId = Array.isArray(actions)
60
+ ? getResourceId(actions[0].resourceId)
61
+ : getResourceId(actions.resourceId)
62
+
63
+ const instance = useSanityInstance(resourceId)
64
+ const isDocumentReady = useCallback(
65
+ () => getPermissionsState(instance, actions).getCurrent() !== undefined,
66
+ [actions, instance],
67
+ )
68
+ if (!isDocumentReady()) {
69
+ throw firstValueFrom(
70
+ getPermissionsState(instance, actions).observable.pipe(
71
+ filter((result) => result !== undefined),
72
+ ),
73
+ )
74
+ }
75
+
76
+ const {subscribe, getCurrent} = useMemo(
77
+ () => getPermissionsState(instance, actions),
78
+ [actions, instance],
79
+ )
80
+
81
+ return useSyncExternalStore(subscribe, getCurrent) as PermissionsResult
82
+ }
@@ -1,13 +1,14 @@
1
- import {type SanityInstance} from '@sanity/sdk'
1
+ import {type ResourceId, type SanityInstance} from '@sanity/sdk'
2
2
  import {useCallback} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
5
 
6
6
  export function createCallbackHook<TParams extends unknown[], TReturn>(
7
7
  callback: (instance: SanityInstance, ...params: TParams) => TReturn,
8
+ resourceId?: ResourceId,
8
9
  ): () => (...params: TParams) => TReturn {
9
10
  function useHook() {
10
- const instance = useSanityInstance()
11
+ const instance = useSanityInstance(resourceId)
11
12
  return useCallback((...params: TParams) => callback(instance, ...params), [instance])
12
13
  }
13
14
 
@@ -127,4 +127,70 @@ describe('createStateSourceHook', () => {
127
127
  expect(result.current).toEqual('test_123')
128
128
  expect(stateSourceFactory).toHaveBeenCalledWith(mockInstance, 'test', 123)
129
129
  })
130
+
131
+ it('should throw suspender promise when shouldSuspend is true', () => {
132
+ const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
133
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
134
+
135
+ const mockGetState = vi.fn().mockReturnValue({
136
+ subscribe: vi.fn(),
137
+ getCurrent: vi.fn().mockReturnValue('state'),
138
+ observable: throwError(() => new Error('unexpected usage of observable')),
139
+ })
140
+
141
+ const mockShouldSuspend = vi.fn().mockReturnValue(true)
142
+ const mockSuspender = vi.fn().mockReturnValue(Promise.resolve())
143
+
144
+ const options = {
145
+ getState: mockGetState,
146
+ shouldSuspend: mockShouldSuspend,
147
+ suspender: mockSuspender,
148
+ }
149
+
150
+ const useTestHook = createStateSourceHook(options)
151
+ const {result} = renderHook(() => {
152
+ try {
153
+ useTestHook('param1', 2)
154
+ } catch (e) {
155
+ return e
156
+ }
157
+ })
158
+
159
+ expect(mockShouldSuspend).toHaveBeenCalledWith(mockInstance, 'param1', 2)
160
+ expect(mockSuspender).toHaveBeenCalledWith(mockInstance, 'param1', 2)
161
+ expect(result.current).toBe(mockSuspender.mock.results[0].value)
162
+ expect(mockGetState).not.toHaveBeenCalled()
163
+ })
164
+
165
+ it('should not suspend when shouldSuspend returns false', () => {
166
+ const mockInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
167
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
168
+
169
+ const mockState = {value: 'test'}
170
+ const mockSubscribe = vi.fn()
171
+ const mockGetCurrent = vi.fn(() => mockState)
172
+
173
+ const mockGetState = vi.fn().mockReturnValue({
174
+ subscribe: mockSubscribe,
175
+ getCurrent: mockGetCurrent,
176
+ observable: throwError(() => new Error('unexpected usage of observable')),
177
+ })
178
+
179
+ const mockShouldSuspend = vi.fn().mockReturnValue(false)
180
+ const mockSuspender = vi.fn()
181
+
182
+ const options = {
183
+ getState: mockGetState,
184
+ shouldSuspend: mockShouldSuspend,
185
+ suspender: mockSuspender,
186
+ }
187
+
188
+ const useTestHook = createStateSourceHook(options)
189
+ const {result} = renderHook(() => useTestHook('param', 123))
190
+
191
+ expect(mockShouldSuspend).toHaveBeenCalledWith(mockInstance, 'param', 123)
192
+ expect(mockSuspender).not.toHaveBeenCalled()
193
+ expect(mockGetState).toHaveBeenCalledWith(mockInstance, 'param', 123)
194
+ expect(result.current).toBe(mockState)
195
+ })
130
196
  })
@@ -1,20 +1,39 @@
1
- import {type SanityInstance, type StateSource} from '@sanity/sdk'
2
- import {useMemo, useSyncExternalStore} from 'react'
1
+ import {type ResourceId, type SanityInstance, type StateSource} from '@sanity/sdk'
2
+ import {useSyncExternalStore} from 'react'
3
3
 
4
4
  import {useSanityInstance} from '../context/useSanityInstance'
5
5
 
6
+ type StateSourceFactory<TParams extends unknown[], TState> = (
7
+ instance: SanityInstance,
8
+ ...params: TParams
9
+ ) => StateSource<TState>
10
+
11
+ interface CreateStateSourceHookOptions<TParams extends unknown[], TState> {
12
+ getState: StateSourceFactory<TParams, TState>
13
+ shouldSuspend?: (instance: SanityInstance, ...params: TParams) => boolean
14
+ suspender?: (instance: SanityInstance, ...params: TParams) => Promise<unknown>
15
+ getResourceId?: (...params: TParams) => ResourceId | undefined
16
+ }
17
+
6
18
  export function createStateSourceHook<TParams extends unknown[], TState>(
7
- stateSourceFactory: (instance: SanityInstance, ...params: TParams) => StateSource<TState>,
19
+ options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
8
20
  ): (...params: TParams) => TState {
21
+ const getState = typeof options === 'function' ? options : options.getState
22
+ const getResourceId = 'getResourceId' in options ? options.getResourceId : undefined
23
+ const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
24
+
9
25
  function useHook(...params: TParams) {
10
- const instance = useSanityInstance()
11
- const {subscribe, getCurrent} = useMemo(
12
- () => stateSourceFactory(instance, ...params),
13
- // eslint-disable-next-line react-hooks/exhaustive-deps
14
- [instance, ...params],
15
- )
26
+ let resourceId: ResourceId | undefined
27
+ if (getResourceId) {
28
+ resourceId = getResourceId(...params)
29
+ }
30
+ const instance = useSanityInstance(resourceId)
31
+ if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
32
+ throw suspense.suspender(instance, ...params)
33
+ }
16
34
 
17
- return useSyncExternalStore(subscribe, getCurrent)
35
+ const state = getState(instance, ...params)
36
+ return useSyncExternalStore(state.subscribe, state.getCurrent)
18
37
  }
19
38
 
20
39
  return useHook
@@ -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 {useInfiniteList} from './useInfiniteList'
7
+
8
+ vi.mock('../query/useQuery')
9
+
10
+ describe('useInfiniteList', () => {
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(() => useInfiniteList({batchSize: customBatchSize}))
80
+
81
+ expect(result.current.data.length).toBe(customBatchSize)
82
+ })
83
+
84
+ it('should filter by document type', () => {
85
+ const {result} = renderHook(() => useInfiniteList({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(() => useInfiniteList({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
+ useInfiniteList({
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(() => useInfiniteList({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(() => useInfiniteList({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) => useInfiniteList(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
+ })