@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.
- package/README.md +38 -67
- package/dist/index.d.ts +4811 -2
- package/dist/index.js +1069 -2
- package/dist/index.js.map +1 -1
- package/package.json +27 -58
- package/src/_exports/index.ts +66 -10
- package/src/components/Login/LoginLinks.test.tsx +4 -14
- package/src/components/Login/LoginLinks.tsx +16 -31
- package/src/components/SDKProvider.test.tsx +79 -0
- package/src/components/SDKProvider.tsx +42 -0
- package/src/components/SanityApp.test.tsx +156 -0
- package/src/components/SanityApp.tsx +90 -0
- package/src/components/auth/AuthBoundary.test.tsx +6 -19
- package/src/components/auth/AuthBoundary.tsx +20 -4
- package/src/components/auth/Login.test.tsx +2 -16
- package/src/components/auth/Login.tsx +11 -30
- package/src/components/auth/LoginCallback.test.tsx +5 -20
- package/src/components/auth/LoginCallback.tsx +9 -14
- package/src/components/auth/LoginError.test.tsx +2 -17
- package/src/components/auth/LoginError.tsx +11 -16
- package/src/components/auth/LoginFooter.test.tsx +2 -16
- package/src/components/auth/LoginFooter.tsx +8 -24
- package/src/components/auth/LoginLayout.test.tsx +2 -16
- package/src/components/auth/LoginLayout.tsx +8 -38
- package/src/components/auth/authTestHelpers.tsx +11 -0
- package/src/components/utils.ts +22 -0
- package/src/context/SanityInstanceContext.ts +4 -0
- package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
- package/src/context/SanityProvider.tsx +50 -0
- package/src/hooks/_synchronous-groq-js.mjs +4 -0
- package/src/hooks/auth/useAuthState.tsx +4 -5
- package/src/hooks/auth/useAuthToken.tsx +1 -1
- package/src/hooks/auth/useCurrentUser.tsx +28 -4
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
- package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
- package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
- package/src/hooks/auth/useLogOut.test.tsx +2 -2
- package/src/hooks/auth/useLogOut.tsx +1 -1
- package/src/hooks/auth/useLoginUrls.tsx +1 -0
- package/src/hooks/client/useClient.ts +9 -30
- package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
- package/src/hooks/comlink/useFrameConnection.ts +107 -0
- package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
- package/src/hooks/comlink/useManageFavorite.ts +130 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
- package/src/hooks/comlink/useWindowConnection.ts +122 -0
- package/src/hooks/context/useSanityInstance.test.tsx +2 -2
- package/src/hooks/context/useSanityInstance.ts +24 -8
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
- package/src/hooks/datasets/useDatasets.ts +40 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
- package/src/hooks/document/useApplyDocumentActions.ts +75 -0
- package/src/hooks/document/useDocument.test.ts +81 -0
- package/src/hooks/document/useDocument.ts +107 -0
- package/src/hooks/document/useDocumentEvent.test.ts +63 -0
- package/src/hooks/document/useDocumentEvent.ts +54 -0
- package/src/hooks/document/useDocumentPermissions.ts +84 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
- package/src/hooks/document/useEditDocument.test.ts +179 -0
- package/src/hooks/document/useEditDocument.ts +195 -0
- package/src/hooks/documents/useDocuments.test.tsx +152 -0
- package/src/hooks/documents/useDocuments.ts +174 -0
- package/src/hooks/helpers/createCallbackHook.tsx +3 -2
- package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
- package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
- package/src/hooks/preview/usePreview.test.tsx +19 -10
- package/src/hooks/preview/usePreview.tsx +67 -13
- package/src/hooks/projection/useProjection.test.tsx +218 -0
- package/src/hooks/projection/useProjection.ts +147 -0
- package/src/hooks/projects/useProject.ts +48 -0
- package/src/hooks/projects/useProjects.ts +45 -0
- package/src/hooks/query/useQuery.test.tsx +188 -0
- package/src/hooks/query/useQuery.ts +103 -0
- package/src/hooks/users/useUsers.test.ts +163 -0
- package/src/hooks/users/useUsers.ts +107 -0
- package/src/utils/getEnv.ts +21 -0
- package/src/version.ts +8 -0
- package/src/vite-env.d.ts +10 -0
- package/dist/_chunks-es/useLogOut.js +0 -44
- package/dist/_chunks-es/useLogOut.js.map +0 -1
- package/dist/assets/bundle-CcAyERuZ.css +0 -11
- package/dist/components.d.ts +0 -257
- package/dist/components.js +0 -316
- package/dist/components.js.map +0 -1
- package/dist/hooks.d.ts +0 -187
- package/dist/hooks.js +0 -81
- package/dist/hooks.js.map +0 -1
- package/src/_exports/components.ts +0 -13
- package/src/_exports/hooks.ts +0 -9
- package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
- package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
- package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
- package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
- package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
- package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
- package/src/components/context/SanityProvider.tsx +0 -42
- package/src/css/css.config.js +0 -220
- package/src/css/paramour.css +0 -2347
- package/src/css/styles.css +0 -11
- package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
- package/src/hooks/client/useClient.test.tsx +0 -130
- package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
- package/src/hooks/documentCollection/useDocuments.ts +0 -87
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
+
}
|