@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
@@ -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, 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,259 @@
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 {usePaginatedDocuments} from './usePaginatedDocuments'
7
+
8
+ vi.mock('../query/useQuery')
9
+
10
+ describe('usePaginatedDocuments', () => {
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 customPageSize = 2
79
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize: customPageSize}))
80
+
81
+ expect(result.current.pageSize).toBe(customPageSize)
82
+ expect(result.current.data.length).toBeLessThanOrEqual(customPageSize)
83
+ })
84
+
85
+ it('should filter by document type', () => {
86
+ const {result} = renderHook(() => usePaginatedDocuments({filter: '_type == "movie"'}))
87
+
88
+ expect(result.current.data.every((doc) => doc._type === 'movie')).toBe(true)
89
+ expect(result.current.count).toBe(5) // 5 movies in the dataset
90
+ })
91
+
92
+ // groq-js doesn't support search filters yet
93
+ it.skip('should apply search filter', () => {
94
+ const {result} = renderHook(() => usePaginatedDocuments({search: 'inter'}))
95
+
96
+ // Should match "Interstellar"
97
+ expect(result.current.data.some((doc) => doc._id === 'movie3')).toBe(true)
98
+ })
99
+
100
+ it('should apply ordering', () => {
101
+ const {result} = renderHook(() =>
102
+ usePaginatedDocuments({
103
+ filter: '_type == "movie"',
104
+ orderings: [{field: 'releaseYear', direction: 'desc'}],
105
+ }),
106
+ )
107
+
108
+ // First item should be the most recent movie (Interstellar, 2014)
109
+ expect(result.current.data[0]._id).toBe('movie3')
110
+ })
111
+
112
+ it('should calculate pagination values correctly', () => {
113
+ const pageSize = 2
114
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
115
+
116
+ expect(result.current.currentPage).toBe(1)
117
+ expect(result.current.totalPages).toBe(3) // 6 items with page size 2
118
+ expect(result.current.startIndex).toBe(0)
119
+ expect(result.current.endIndex).toBe(2)
120
+ expect(result.current.count).toBe(6)
121
+ })
122
+
123
+ it('should navigate to next page', () => {
124
+ const pageSize = 2
125
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
126
+
127
+ expect(result.current.currentPage).toBe(1)
128
+ expect(result.current.data.length).toBe(pageSize)
129
+
130
+ act(() => {
131
+ result.current.nextPage()
132
+ })
133
+
134
+ expect(result.current.currentPage).toBe(2)
135
+ expect(result.current.startIndex).toBe(pageSize)
136
+ expect(result.current.endIndex).toBe(pageSize * 2)
137
+ })
138
+
139
+ it('should navigate to previous page', () => {
140
+ const pageSize = 2
141
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
142
+
143
+ // Go to page 2 first
144
+ act(() => {
145
+ result.current.nextPage()
146
+ })
147
+
148
+ expect(result.current.currentPage).toBe(2)
149
+
150
+ // Then go back to page 1
151
+ act(() => {
152
+ result.current.previousPage()
153
+ })
154
+
155
+ expect(result.current.currentPage).toBe(1)
156
+ expect(result.current.startIndex).toBe(0)
157
+ })
158
+
159
+ it('should navigate to first page', () => {
160
+ const pageSize = 2
161
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
162
+
163
+ // Go to last page first
164
+ act(() => {
165
+ result.current.lastPage()
166
+ })
167
+
168
+ expect(result.current.currentPage).toBe(3) // Last page (3rd page)
169
+
170
+ // Then go back to first page
171
+ act(() => {
172
+ result.current.firstPage()
173
+ })
174
+
175
+ expect(result.current.currentPage).toBe(1)
176
+ expect(result.current.startIndex).toBe(0)
177
+ })
178
+
179
+ it('should navigate to last page', () => {
180
+ const pageSize = 2
181
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
182
+
183
+ act(() => {
184
+ result.current.lastPage()
185
+ })
186
+
187
+ expect(result.current.currentPage).toBe(3) // Last page (3rd page)
188
+ expect(result.current.startIndex).toBe(4) // Index 4-5 for the last page
189
+ })
190
+
191
+ it('should navigate to specific page', () => {
192
+ const pageSize = 2
193
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
194
+
195
+ act(() => {
196
+ result.current.goToPage(2) // Go to page 2
197
+ })
198
+
199
+ expect(result.current.currentPage).toBe(2)
200
+ expect(result.current.startIndex).toBe(2) // Index 2-3 for page 2
201
+
202
+ // Should not navigate to invalid page numbers
203
+ act(() => {
204
+ result.current.goToPage(0) // Invalid page
205
+ })
206
+
207
+ expect(result.current.currentPage).toBe(2) // Should remain on page 2
208
+
209
+ act(() => {
210
+ result.current.goToPage(10) // Invalid page
211
+ })
212
+
213
+ expect(result.current.currentPage).toBe(2) // Should remain on page 2
214
+ })
215
+
216
+ it('should set page availability flags correctly', () => {
217
+ const pageSize = 2
218
+ const {result} = renderHook(() => usePaginatedDocuments({pageSize}))
219
+ // On first page
220
+ expect(result.current.hasFirstPage).toBe(false)
221
+ expect(result.current.hasPreviousPage).toBe(false)
222
+ expect(result.current.hasNextPage).toBe(true)
223
+ expect(result.current.hasLastPage).toBe(true)
224
+ // Go to middle page
225
+ act(() => {
226
+ result.current.nextPage()
227
+ })
228
+ expect(result.current.hasFirstPage).toBe(true)
229
+ expect(result.current.hasPreviousPage).toBe(true)
230
+ expect(result.current.hasNextPage).toBe(true)
231
+ expect(result.current.hasLastPage).toBe(true)
232
+ // Go to last page
233
+ act(() => {
234
+ result.current.lastPage()
235
+ })
236
+ expect(result.current.hasFirstPage).toBe(true)
237
+ expect(result.current.hasPreviousPage).toBe(true)
238
+ expect(result.current.hasNextPage).toBe(false)
239
+ expect(result.current.hasLastPage).toBe(false)
240
+ })
241
+
242
+ // New test case for resetting the current page when filter changes
243
+ it('should reset current page when filter changes', () => {
244
+ const {result, rerender} = renderHook((props) => usePaginatedDocuments(props), {
245
+ initialProps: {pageSize: 2, filter: ''},
246
+ })
247
+ // Initially, current page should be 1
248
+ expect(result.current.currentPage).toBe(1)
249
+ // Navigate to next page
250
+ act(() => {
251
+ result.current.nextPage()
252
+ })
253
+ expect(result.current.currentPage).toBe(2)
254
+ // Now update filter, which should reset the page to the first page
255
+ rerender({pageSize: 2, filter: '_type == "movie"'})
256
+ expect(result.current.currentPage).toBe(1)
257
+ expect(result.current.startIndex).toBe(0)
258
+ })
259
+ })
@@ -0,0 +1,290 @@
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_PERSPECTIVE = 'drafts'
8
+
9
+ /**
10
+ * Configuration options for the usePaginatedDocuments hook
11
+ *
12
+ * @beta
13
+ * @category Types
14
+ */
15
+ export interface PaginatedDocumentsOptions extends QueryOptions {
16
+ /**
17
+ * GROQ filter expression to apply to the query
18
+ */
19
+ filter?: string
20
+ /**
21
+ * Number of items to display per page (defaults to 25)
22
+ */
23
+ pageSize?: number
24
+ /**
25
+ * Sorting configuration for the results
26
+ */
27
+ orderings?: SortOrderingItem[]
28
+ /**
29
+ * Text search query to filter results
30
+ */
31
+ search?: string
32
+ }
33
+
34
+ /**
35
+ * Return value from the usePaginatedDocuments hook
36
+ *
37
+ * @beta
38
+ * @category Types
39
+ */
40
+ export interface PaginatedDocumentsResponse {
41
+ /**
42
+ * Array of document handles for the current page
43
+ */
44
+ data: DocumentHandle[]
45
+ /**
46
+ * Whether a query is currently in progress
47
+ */
48
+ isPending: boolean
49
+
50
+ /**
51
+ * Number of items displayed per page
52
+ */
53
+ pageSize: number
54
+ /**
55
+ * Current page number (1-indexed)
56
+ */
57
+ currentPage: number
58
+ /**
59
+ * Total number of pages available
60
+ */
61
+ totalPages: number
62
+
63
+ /**
64
+ * Starting index of the current page (0-indexed)
65
+ */
66
+ startIndex: number
67
+ /**
68
+ * Ending index of the current page (exclusive, 0-indexed)
69
+ */
70
+ endIndex: number
71
+ /**
72
+ * Total count of items matching the query
73
+ */
74
+ count: number
75
+
76
+ /**
77
+ * Navigate to the first page
78
+ */
79
+ firstPage: () => void
80
+ /**
81
+ * Whether there is a first page available to navigate to
82
+ */
83
+ hasFirstPage: boolean
84
+
85
+ /**
86
+ * Navigate to the previous page
87
+ */
88
+ previousPage: () => void
89
+ /**
90
+ * Whether there is a previous page available to navigate to
91
+ */
92
+ hasPreviousPage: boolean
93
+
94
+ /**
95
+ * Navigate to the next page
96
+ */
97
+ nextPage: () => void
98
+ /**
99
+ * Whether there is a next page available to navigate to
100
+ */
101
+ hasNextPage: boolean
102
+
103
+ /**
104
+ * Navigate to the last page
105
+ */
106
+ lastPage: () => void
107
+ /**
108
+ * Whether there is a last page available to navigate to
109
+ */
110
+ hasLastPage: boolean
111
+
112
+ /**
113
+ * Navigate to a specific page number
114
+ * @param pageNumber - The page number to navigate to (1-indexed)
115
+ */
116
+ goToPage: (pageNumber: number) => void
117
+ }
118
+
119
+ /**
120
+ * Retrieves pages of {@link DocumentHandle}s, narrowed by optional filters, text searches, and custom ordering,
121
+ * with support for traditional paginated interfaces. The number of document handles returned per page is customizable,
122
+ * while page navigation is handled via the included navigation functions.
123
+ *
124
+ * @beta
125
+ * @category Documents
126
+ * @param options - Configuration options for the paginated list
127
+ * @returns An object containing the current page of document handles, the loading and pagination state, and navigation functions
128
+ * @example
129
+ * ```tsx
130
+ * const {
131
+ * data,
132
+ * isPending,
133
+ * currentPage,
134
+ * totalPages,
135
+ * nextPage,
136
+ * previousPage,
137
+ * hasNextPage,
138
+ * hasPreviousPage
139
+ * } = usePaginatedDocuments({
140
+ * filter: '_type == "post"',
141
+ * search: searchTerm,
142
+ * pageSize: 10,
143
+ * orderings: [{field: '_createdAt', direction: 'desc'}]
144
+ * })
145
+ *
146
+ * return (
147
+ * <>
148
+ * <table>
149
+ * {data.map(doc => (
150
+ * <MyTableRowComponent key={doc._id} doc={doc} />
151
+ * ))}
152
+ * </table>
153
+ * <>
154
+ * {hasPreviousPage && <button onClick={previousPage}>Previous</button>}
155
+ * {currentPage} / {totalPages}
156
+ * {hasNextPage && <button onClick={nextPage}>Next</button>}
157
+ * </>
158
+ * </>
159
+ * )
160
+ * ```
161
+ *
162
+ */
163
+ export function usePaginatedDocuments({
164
+ filter = '',
165
+ pageSize = 25,
166
+ params = {},
167
+ orderings,
168
+ search,
169
+ ...options
170
+ }: PaginatedDocumentsOptions = {}): PaginatedDocumentsResponse {
171
+ const [pageIndex, setPageIndex] = useState(0)
172
+ const key = JSON.stringify({filter, search, params, orderings, pageSize})
173
+ // Reset the pageIndex to 0 whenever any query parameters (filter, search,
174
+ // params, orderings) or pageSize changes
175
+ useEffect(() => {
176
+ setPageIndex(0)
177
+ }, [key])
178
+
179
+ const startIndex = pageIndex * pageSize
180
+ const endIndex = (pageIndex + 1) * pageSize
181
+ const perspective = options.perspective ?? DEFAULT_PERSPECTIVE
182
+
183
+ const filterClause = useMemo(() => {
184
+ const conditions: string[] = []
185
+
186
+ // Add search query if specified
187
+ if (search?.trim()) {
188
+ conditions.push(`[@] match text::query("${search.trim()}")`)
189
+ }
190
+
191
+ // Add additional filter if specified
192
+ if (filter) {
193
+ conditions.push(`(${filter})`)
194
+ }
195
+
196
+ return conditions.length ? `[${conditions.join(' && ')}]` : ''
197
+ }, [filter, search])
198
+
199
+ const orderClause = orderings
200
+ ? `| order(${orderings
201
+ .map((ordering) =>
202
+ [ordering.field, ordering.direction.toLowerCase()]
203
+ .map((str) => str.trim())
204
+ .filter(Boolean)
205
+ .join(' '),
206
+ )
207
+ .join(',')})`
208
+ : ''
209
+
210
+ const dataQuery = `*${filterClause}${orderClause}[${startIndex}...${endIndex}]{_id,_type}`
211
+ const countQuery = `count(*${filterClause})`
212
+
213
+ const {
214
+ data: {data, count},
215
+ isPending,
216
+ } = useQuery<{data: DocumentHandle[]; count: number}>(
217
+ `{"data":${dataQuery},"count":${countQuery}}`,
218
+ {
219
+ ...options,
220
+ perspective,
221
+ params,
222
+ },
223
+ )
224
+
225
+ const totalPages = Math.ceil(count / pageSize)
226
+ const currentPage = pageIndex + 1
227
+
228
+ // Navigation methods
229
+ const firstPage = useCallback(() => setPageIndex(0), [])
230
+ const previousPage = useCallback(() => setPageIndex((prev) => Math.max(prev - 1, 0)), [])
231
+ const nextPage = useCallback(
232
+ () => setPageIndex((prev) => Math.min(prev + 1, totalPages - 1)),
233
+ [totalPages],
234
+ )
235
+ const lastPage = useCallback(() => setPageIndex(totalPages - 1), [totalPages])
236
+ const goToPage = useCallback(
237
+ (pageNumber: number) => {
238
+ if (pageNumber < 1 || pageNumber > totalPages) return
239
+ setPageIndex(pageNumber - 1)
240
+ },
241
+ [totalPages],
242
+ )
243
+
244
+ // Boolean flags for page availability
245
+ const hasFirstPage = pageIndex > 0
246
+ const hasPreviousPage = pageIndex > 0
247
+ const hasNextPage = pageIndex < totalPages - 1
248
+ const hasLastPage = pageIndex < totalPages - 1
249
+
250
+ return useMemo(
251
+ () => ({
252
+ data,
253
+ isPending,
254
+ pageSize,
255
+ currentPage,
256
+ totalPages,
257
+ startIndex,
258
+ endIndex,
259
+ count,
260
+ firstPage,
261
+ hasFirstPage,
262
+ previousPage,
263
+ hasPreviousPage,
264
+ nextPage,
265
+ hasNextPage,
266
+ lastPage,
267
+ hasLastPage,
268
+ goToPage,
269
+ }),
270
+ [
271
+ data,
272
+ isPending,
273
+ pageSize,
274
+ currentPage,
275
+ totalPages,
276
+ startIndex,
277
+ endIndex,
278
+ count,
279
+ firstPage,
280
+ hasFirstPage,
281
+ previousPage,
282
+ hasPreviousPage,
283
+ nextPage,
284
+ hasNextPage,
285
+ lastPage,
286
+ hasLastPage,
287
+ goToPage,
288
+ ],
289
+ )
290
+ }