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