@sanity/sdk-react 2.9.0 → 2.11.0
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 +338 -215
- package/dist/index.js +564 -342
- package/dist/index.js.map +1 -1
- package/package.json +9 -14
- package/src/_exports/index.ts +2 -0
- package/src/_exports/sdk-react.ts +8 -0
- package/src/components/SDKProvider.test.tsx +5 -12
- package/src/components/SDKProvider.tsx +58 -28
- package/src/components/SanityApp.tsx +2 -2
- package/src/components/auth/AuthBoundary.tsx +8 -1
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +100 -56
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +55 -0
- package/src/constants.ts +5 -0
- package/src/context/DefaultResourceContext.ts +10 -0
- package/src/context/PerspectiveContext.ts +12 -0
- package/src/context/ResourceProvider.test.tsx +2 -2
- package/src/context/ResourceProvider.tsx +56 -51
- package/src/context/ResourcesContext.tsx +7 -0
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/hooks/agent/agentActions.ts +55 -38
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +42 -111
- package/src/hooks/context/useSanityInstance.ts +28 -50
- package/src/hooks/dashboard/useDispatchIntent.test.ts +11 -7
- package/src/hooks/dashboard/useDispatchIntent.ts +7 -7
- package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -17
- package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +46 -81
- package/src/hooks/document/useApplyDocumentActions.ts +33 -67
- package/src/hooks/document/useDocument.ts +4 -6
- package/src/hooks/document/useDocumentEvent.ts +8 -7
- package/src/hooks/document/useDocumentPermissions.test.tsx +60 -152
- package/src/hooks/document/useDocumentPermissions.ts +78 -55
- package/src/hooks/document/useDocumentSyncStatus.ts +2 -2
- package/src/hooks/document/useEditDocument.test.tsx +25 -60
- package/src/hooks/document/useEditDocument.ts +3 -3
- package/src/hooks/documents/useDocuments.ts +19 -11
- package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +169 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +2 -2
- package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
- package/src/hooks/organizations/useOrganization.test.ts +65 -0
- package/src/hooks/organizations/useOrganization.ts +40 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
- package/src/hooks/organizations/useOrganizations.test.ts +85 -0
- package/src/hooks/organizations/useOrganizations.ts +45 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +29 -14
- package/src/hooks/presence/usePresence.test.tsx +56 -9
- package/src/hooks/presence/usePresence.ts +16 -4
- package/src/hooks/preview/useDocumentPreview.tsx +8 -10
- package/src/hooks/projection/useDocumentProjection.ts +7 -9
- package/src/hooks/projects/useProject.test-d.ts +49 -0
- package/src/hooks/projects/useProject.ts +33 -41
- package/src/hooks/projects/useProjects.test-d.ts +49 -0
- package/src/hooks/projects/useProjects.ts +17 -23
- package/src/hooks/query/useQuery.ts +11 -10
- package/src/hooks/releases/useActiveReleases.ts +14 -14
- package/src/hooks/releases/usePerspective.ts +11 -16
- package/src/hooks/users/useUser.ts +1 -1
- package/src/hooks/users/useUsers.ts +1 -1
- package/src/context/SourcesContext.tsx +0 -7
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -107
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createGroqSearchFilter,
|
|
3
|
-
type DatasetHandle,
|
|
4
3
|
type DocumentHandle,
|
|
4
|
+
isDatasetResource,
|
|
5
5
|
type QueryOptions,
|
|
6
6
|
} from '@sanity/sdk'
|
|
7
|
+
import {pickProperties} from '@sanity/sdk/_internal'
|
|
7
8
|
import {type SortOrderingItem} from '@sanity/types'
|
|
8
|
-
import {
|
|
9
|
-
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
9
|
+
import {useCallback, useMemo, useState} from 'react'
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {type ResourceHandle} from '../../config/handles'
|
|
12
|
+
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
12
13
|
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
13
14
|
import {useQuery} from '../query/useQuery'
|
|
14
15
|
|
|
@@ -25,7 +26,7 @@ export interface DocumentsOptions<
|
|
|
25
26
|
TDataset extends string = string,
|
|
26
27
|
TProjectId extends string = string,
|
|
27
28
|
>
|
|
28
|
-
extends
|
|
29
|
+
extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
|
|
29
30
|
/**
|
|
30
31
|
* Filter documents by their `_type`. Can be a single type or an array of types.
|
|
31
32
|
*/
|
|
@@ -202,14 +203,14 @@ export function useDocuments<
|
|
|
202
203
|
filter,
|
|
203
204
|
orderings,
|
|
204
205
|
documentType,
|
|
205
|
-
...
|
|
206
|
+
...rawOptions
|
|
206
207
|
}: DocumentsOptions<TDocumentType, TDataset, TProjectId>): DocumentsResponse<
|
|
207
208
|
TDocumentType,
|
|
208
209
|
TDataset,
|
|
209
210
|
TProjectId
|
|
210
211
|
> {
|
|
211
212
|
useTrackHookUsage('useDocuments')
|
|
212
|
-
const
|
|
213
|
+
const options = useNormalizedResourceOptions(rawOptions)
|
|
213
214
|
const [limit, setLimit] = useState(batchSize)
|
|
214
215
|
const documentTypes = useMemo(
|
|
215
216
|
() =>
|
|
@@ -230,9 +231,11 @@ export function useDocuments<
|
|
|
230
231
|
types: documentTypes,
|
|
231
232
|
...options,
|
|
232
233
|
})
|
|
233
|
-
|
|
234
|
+
const [prevKey, setPrevKey] = useState(key)
|
|
235
|
+
if (prevKey !== key) {
|
|
236
|
+
setPrevKey(key)
|
|
234
237
|
setLimit(batchSize)
|
|
235
|
-
}
|
|
238
|
+
}
|
|
236
239
|
|
|
237
240
|
const filterClause = useMemo(() => {
|
|
238
241
|
const conditions: string[] = []
|
|
@@ -281,9 +284,14 @@ export function useDocuments<
|
|
|
281
284
|
query: `{"count":${countQuery},"data":${dataQuery}}`,
|
|
282
285
|
params: {
|
|
283
286
|
...params,
|
|
287
|
+
// these are passed back to the user as part of each document handle
|
|
284
288
|
__handle: {
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
// keep projectId/dataset for backward compat until v4; resource is added
|
|
290
|
+
// intentionally so that hook consumers can resolve the correct resource
|
|
291
|
+
...(options.resource && isDatasetResource(options.resource)
|
|
292
|
+
? pickProperties(options.resource, ['projectId', 'dataset'])
|
|
293
|
+
: {}),
|
|
294
|
+
...pickProperties(options, ['perspective', 'resource']),
|
|
287
295
|
},
|
|
288
296
|
__types: documentTypes,
|
|
289
297
|
},
|
|
@@ -19,11 +19,10 @@ export function createStateSourceHook<TParams extends unknown[], TState>(
|
|
|
19
19
|
options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
|
|
20
20
|
): (...params: TParams) => TState {
|
|
21
21
|
const getState = typeof options === 'function' ? options : options.getState
|
|
22
|
-
const getConfig = 'getConfig' in options ? options.getConfig : undefined
|
|
23
22
|
const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
|
|
24
23
|
|
|
25
24
|
function useHook(...params: TParams) {
|
|
26
|
-
const instance = useSanityInstance(
|
|
25
|
+
const instance = useSanityInstance()
|
|
27
26
|
|
|
28
27
|
if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
|
|
29
28
|
throw suspense.suspender(instance, ...params)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import {createSanityInstance, type DocumentHandle} from '@sanity/sdk'
|
|
2
|
+
import {type ReactNode} from 'react'
|
|
3
|
+
import {describe, expect, it} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {renderHook, resources} from '../../../test/test-utils'
|
|
6
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
8
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
9
|
+
import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
|
|
10
|
+
|
|
11
|
+
// Wrapper that sets ResourceContext via the `resource` prop (tier 3).
|
|
12
|
+
// Includes ResourcesContext so resourceName resolution also works in these tests.
|
|
13
|
+
function ResourceContextWrapper({
|
|
14
|
+
children,
|
|
15
|
+
resource,
|
|
16
|
+
}: {
|
|
17
|
+
children: ReactNode
|
|
18
|
+
resource: {projectId: string; dataset: string}
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<ResourceProvider resource={resource} fallback={null}>
|
|
22
|
+
<ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
|
|
23
|
+
</ResourceProvider>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Wrapper that provides an instance with no projectId/dataset and no ResourceContext (tier 5).
|
|
28
|
+
const bareInstance = createSanityInstance({})
|
|
29
|
+
function NoResourceWrapper({children}: {children: ReactNode}) {
|
|
30
|
+
return (
|
|
31
|
+
<SanityInstanceContext.Provider value={bareInstance}>{children}</SanityInstanceContext.Provider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useNormalizedResourceOptions', () => {
|
|
36
|
+
describe('tier 1 — explicit options', () => {
|
|
37
|
+
it('uses an explicit dataset resource object', () => {
|
|
38
|
+
const {result} = renderHook(() =>
|
|
39
|
+
useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
|
|
40
|
+
)
|
|
41
|
+
expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('uses an explicit media-library resource object', () => {
|
|
45
|
+
const {result} = renderHook(() =>
|
|
46
|
+
useNormalizedResourceOptions({resource: {mediaLibraryId: 'ml-123'}}),
|
|
47
|
+
)
|
|
48
|
+
expect(result.current.resource).toEqual({mediaLibraryId: 'ml-123'})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses an explicit canvas resource object', () => {
|
|
52
|
+
const {result} = renderHook(() =>
|
|
53
|
+
useNormalizedResourceOptions({resource: {canvasId: 'canvas-123'}}),
|
|
54
|
+
)
|
|
55
|
+
expect(result.current.resource).toEqual({canvasId: 'canvas-123'})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves resourceName to a named dataset resource', () => {
|
|
59
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
|
|
60
|
+
expect(result.current.resource).toEqual({
|
|
61
|
+
projectId: 'resource-project-id',
|
|
62
|
+
dataset: 'resource-dataset',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('resolves resourceName to a named media-library resource', () => {
|
|
67
|
+
const {result} = renderHook(() =>
|
|
68
|
+
useNormalizedResourceOptions({resourceName: 'media-library'}),
|
|
69
|
+
)
|
|
70
|
+
expect(result.current.resource).toEqual({mediaLibraryId: 'media-library-id'})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('resolves resourceName to a named canvas resource', () => {
|
|
74
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'canvas'}))
|
|
75
|
+
expect(result.current.resource).toEqual({canvasId: 'canvas-id'})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('throws when resourceName is not registered', () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
renderHook(() => useNormalizedResourceOptions({resourceName: 'unknown'})),
|
|
81
|
+
).toThrow(/no resource named/i)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('throws when both resource and resourceName are provided', () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
renderHook(() =>
|
|
87
|
+
useNormalizedResourceOptions({
|
|
88
|
+
resource: {projectId: 'p', dataset: 'd'},
|
|
89
|
+
resourceName: 'dataset',
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
).toThrow()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('resolves deprecated `source` as `resource`', () => {
|
|
96
|
+
const {result} = renderHook(() =>
|
|
97
|
+
useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
|
|
98
|
+
)
|
|
99
|
+
expect(result.current.resource).toEqual({projectId: 'src', dataset: 'src-ds'})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('resolves deprecated `sourceName` as `resourceName`', () => {
|
|
103
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
|
|
104
|
+
expect(result.current.resource).toEqual({
|
|
105
|
+
projectId: 'resource-project-id',
|
|
106
|
+
dataset: 'resource-dataset',
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('tier 2 — bare projectId/dataset in options', () => {
|
|
112
|
+
it('synthesizes a resource from projectId + dataset', () => {
|
|
113
|
+
const {result} = renderHook(() =>
|
|
114
|
+
useNormalizedResourceOptions({projectId: 'opt', dataset: 'opt-ds'}),
|
|
115
|
+
)
|
|
116
|
+
expect(result.current.resource).toEqual({projectId: 'opt', dataset: 'opt-ds'})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls through to context when only projectId is provided (no dataset)', () => {
|
|
120
|
+
// Only projectId is not enough to synthesize — should fall back to context resource
|
|
121
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({projectId: 'opt'}))
|
|
122
|
+
// Default test-utils: ResourceProvider projectId="test" dataset="test" → tier-3 via config synthesis
|
|
123
|
+
expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('tier 3 — ResourceContext', () => {
|
|
128
|
+
it('uses ResourceContext set via ResourceProvider `resource` prop', () => {
|
|
129
|
+
const contextResource = {projectId: 'ctx-project', dataset: 'ctx-dataset'}
|
|
130
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}), {
|
|
131
|
+
wrapper: ({children}) => (
|
|
132
|
+
<ResourceContextWrapper resource={contextResource}>{children}</ResourceContextWrapper>
|
|
133
|
+
),
|
|
134
|
+
})
|
|
135
|
+
expect(result.current.resource).toEqual(contextResource)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('uses ResourceContext synthesized from ResourceProvider projectId/dataset', () => {
|
|
139
|
+
// ResourceProvider with projectId/dataset (no explicit resource prop) synthesizes ResourceContext
|
|
140
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}))
|
|
141
|
+
// Default test-utils: ResourceProvider projectId="test" dataset="test"
|
|
142
|
+
expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('explicit resource in options takes precedence over ResourceContext', () => {
|
|
146
|
+
const {result} = renderHook(
|
|
147
|
+
() =>
|
|
148
|
+
useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
|
|
149
|
+
{
|
|
150
|
+
wrapper: ({children}) => (
|
|
151
|
+
<ResourceContextWrapper resource={{projectId: 'ctx-project', dataset: 'ctx-dataset'}}>
|
|
152
|
+
{children}
|
|
153
|
+
</ResourceContextWrapper>
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('tier 4 — SanityInstance config fallback', () => {
|
|
162
|
+
it('falls back to instance projectId/dataset when ResourceContext is not set', () => {
|
|
163
|
+
// Bare SanityInstanceContext with config — no ResourceProvider, so no ResourceContext
|
|
164
|
+
const instanceWithConfig = createSanityInstance({projectId: 'inst', dataset: 'inst-ds'})
|
|
165
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}), {
|
|
166
|
+
wrapper: ({children}) => (
|
|
167
|
+
<SanityInstanceContext.Provider value={instanceWithConfig}>
|
|
168
|
+
{children}
|
|
169
|
+
</SanityInstanceContext.Provider>
|
|
170
|
+
),
|
|
171
|
+
})
|
|
172
|
+
expect(result.current.resource).toEqual({projectId: 'inst', dataset: 'inst-ds'})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('tier 5 — no resource available', () => {
|
|
177
|
+
it('returns no resource when neither options, context, nor instance config provide one', () => {
|
|
178
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}), {
|
|
179
|
+
wrapper: NoResourceWrapper,
|
|
180
|
+
})
|
|
181
|
+
expect(result.current).not.toHaveProperty('resource')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('perspective resolution', () => {
|
|
186
|
+
it('uses explicit perspective from options', () => {
|
|
187
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}))
|
|
188
|
+
expect(result.current.perspective).toBe('published')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('falls back to PerspectiveContext when no perspective in options', () => {
|
|
192
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}), {
|
|
193
|
+
wrapper: ({children}) => (
|
|
194
|
+
<ResourceProvider perspective="previewDrafts" fallback={null}>
|
|
195
|
+
{children}
|
|
196
|
+
</ResourceProvider>
|
|
197
|
+
),
|
|
198
|
+
})
|
|
199
|
+
expect(result.current.perspective).toBe('previewDrafts')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('explicit perspective overrides PerspectiveContext', () => {
|
|
203
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}), {
|
|
204
|
+
wrapper: ({children}) => (
|
|
205
|
+
<ResourceProvider perspective="previewDrafts" fallback={null}>
|
|
206
|
+
{children}
|
|
207
|
+
</ResourceProvider>
|
|
208
|
+
),
|
|
209
|
+
})
|
|
210
|
+
expect(result.current.perspective).toBe('published')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('omits perspective from result when not set', () => {
|
|
214
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({}), {
|
|
215
|
+
wrapper: NoResourceWrapper,
|
|
216
|
+
})
|
|
217
|
+
expect(result.current).not.toHaveProperty('perspective')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('field stripping', () => {
|
|
222
|
+
it('strips resourceName from the result', () => {
|
|
223
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
|
|
224
|
+
expect(result.current).not.toHaveProperty('resourceName')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('strips projectId and dataset from the result when synthesized into resource', () => {
|
|
228
|
+
const {result} = renderHook(() =>
|
|
229
|
+
useNormalizedResourceOptions({projectId: 'p', dataset: 'd'}),
|
|
230
|
+
)
|
|
231
|
+
expect(result.current).not.toHaveProperty('projectId')
|
|
232
|
+
expect(result.current).not.toHaveProperty('dataset')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('strips deprecated source from the result', () => {
|
|
236
|
+
const {result} = renderHook(() =>
|
|
237
|
+
useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
|
|
238
|
+
)
|
|
239
|
+
expect(result.current).not.toHaveProperty('source')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('strips deprecated sourceName from the result', () => {
|
|
243
|
+
const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
|
|
244
|
+
expect(result.current).not.toHaveProperty('sourceName')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('preserves unrelated fields', () => {
|
|
248
|
+
const opts: DocumentHandle = {documentId: 'doc-1', documentType: 'article'}
|
|
249
|
+
const {result} = renderHook(() => useNormalizedResourceOptions(opts))
|
|
250
|
+
expect(result.current).toMatchObject({documentId: 'doc-1', documentType: 'article'})
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
|
|
2
|
+
import {useContext, useMemo} from 'react'
|
|
3
|
+
|
|
4
|
+
import {ResourceContext} from '../../context/DefaultResourceContext'
|
|
5
|
+
import {PerspectiveContext} from '../../context/PerspectiveContext'
|
|
6
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
7
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
8
|
+
|
|
9
|
+
type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds React hook support (resourceName resolution) to core types.
|
|
13
|
+
* Prefer using the React-layer handle types (ResourceHandle, DocumentHandle)
|
|
14
|
+
* from `@sanity/sdk-react` — this wrapper is kept for cases where overloads
|
|
15
|
+
* don't fit (e.g. non-handle options objects).
|
|
16
|
+
*
|
|
17
|
+
* @typeParam T - The core type to extend (must have optional `resource` field)
|
|
18
|
+
* @beta
|
|
19
|
+
*/
|
|
20
|
+
export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
|
|
21
|
+
/**
|
|
22
|
+
* Optional name of a resource to resolve from context.
|
|
23
|
+
* If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
|
|
24
|
+
* @beta
|
|
25
|
+
*/
|
|
26
|
+
resourceName?: string
|
|
27
|
+
/**
|
|
28
|
+
* @deprecated Use `resourceName` instead.
|
|
29
|
+
* @beta
|
|
30
|
+
*/
|
|
31
|
+
sourceName?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
|
|
36
|
+
* using the provided resources map. Use this when options are only available at call time
|
|
37
|
+
* (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
|
|
38
|
+
*
|
|
39
|
+
* @typeParam T - The options type (must include optional resource field)
|
|
40
|
+
* @param options - Options that may include `resourceName` and/or `resource`
|
|
41
|
+
* @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
|
|
42
|
+
* @param contextResource - Resource from context (from ResourceContext)
|
|
43
|
+
* @param contextPerspective - Perspective from context (from PerspectiveContext)
|
|
44
|
+
* @returns Normalized options with `resourceName` removed and `resource` resolved
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeResourceOptions<
|
|
48
|
+
T extends {
|
|
49
|
+
resource?: DocumentResource
|
|
50
|
+
resourceName?: string
|
|
51
|
+
source?: DocumentResource
|
|
52
|
+
sourceName?: string
|
|
53
|
+
projectId?: string
|
|
54
|
+
dataset?: string
|
|
55
|
+
perspective?: unknown
|
|
56
|
+
},
|
|
57
|
+
>(
|
|
58
|
+
options: T,
|
|
59
|
+
resources: Record<string, DocumentResource>,
|
|
60
|
+
contextResource?: DocumentResource,
|
|
61
|
+
contextPerspective?: PerspectiveHandle['perspective'],
|
|
62
|
+
): Omit<T, NormalizedResourceFields> {
|
|
63
|
+
const {resourceName, sourceName, source, projectId, dataset, ...rest} = options
|
|
64
|
+
|
|
65
|
+
// Coalesce deprecated aliases to their canonical equivalents
|
|
66
|
+
const effectiveResourceName = resourceName ?? sourceName
|
|
67
|
+
const effectiveResource = options.resource ?? source
|
|
68
|
+
|
|
69
|
+
if (effectiveResourceName && effectiveResource) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let resolvedResource: DocumentResource | undefined
|
|
76
|
+
|
|
77
|
+
// Tier (a): explicit resource object or resourceName lookup
|
|
78
|
+
if (effectiveResource) {
|
|
79
|
+
resolvedResource = effectiveResource
|
|
80
|
+
} else if (effectiveResourceName) {
|
|
81
|
+
if (!Object.hasOwn(resources, effectiveResourceName)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
resolvedResource = resources[effectiveResourceName]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Tier (b): projectId or dataset in options → synthesize a resource
|
|
90
|
+
if (!resolvedResource && projectId && dataset) {
|
|
91
|
+
resolvedResource = {
|
|
92
|
+
projectId,
|
|
93
|
+
dataset,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Tier (c): fall back to whatever ResourceContext provides
|
|
98
|
+
if (!resolvedResource) {
|
|
99
|
+
resolvedResource = contextResource
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Inject perspective from context when not explicitly provided in options
|
|
103
|
+
const resolvedPerspective = Object.hasOwn(options, 'perspective')
|
|
104
|
+
? options.perspective
|
|
105
|
+
: contextPerspective
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...rest,
|
|
109
|
+
...(resolvedResource !== undefined && {resource: resolvedResource}),
|
|
110
|
+
...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns the effective context resource: the `ResourceContext` value if set,
|
|
116
|
+
* otherwise a resource synthesized from the current `SanityInstance` config
|
|
117
|
+
* (tier-d fallback — returns `undefined` for studio-style configs with no project).
|
|
118
|
+
*
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
export function useEffectiveContextResource(): DocumentResource | undefined {
|
|
122
|
+
const contextResource = useContext(ResourceContext)
|
|
123
|
+
const instance = useContext(SanityInstanceContext)
|
|
124
|
+
const {projectId, dataset} = instance?.config ?? {}
|
|
125
|
+
|
|
126
|
+
return useMemo(() => {
|
|
127
|
+
if (contextResource) return contextResource
|
|
128
|
+
if (projectId && dataset) return {projectId, dataset}
|
|
129
|
+
return undefined
|
|
130
|
+
}, [contextResource, projectId, dataset])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
|
|
135
|
+
*
|
|
136
|
+
* Resolution priority for resource:
|
|
137
|
+
* 1. Explicit `resource` or `resourceName` in options
|
|
138
|
+
* 2. Bare `projectId`/`dataset` pair in options → synthesized into a resource
|
|
139
|
+
* 3. `ResourceContext` value (set by `ResourceProvider` / `SDKProvider`)
|
|
140
|
+
* 4. Current `SanityInstance` config — falls back to `undefined` for studio configs
|
|
141
|
+
*
|
|
142
|
+
* Resolution priority for perspective:
|
|
143
|
+
* 1. Explicit `perspective` in options
|
|
144
|
+
* 2. `PerspectiveContext` value (set by `ResourceProvider`)
|
|
145
|
+
*
|
|
146
|
+
* @internal
|
|
147
|
+
*/
|
|
148
|
+
export function useNormalizedResourceOptions<
|
|
149
|
+
T extends {
|
|
150
|
+
resource?: DocumentResource
|
|
151
|
+
resourceName?: string
|
|
152
|
+
source?: DocumentResource
|
|
153
|
+
sourceName?: string
|
|
154
|
+
projectId?: string
|
|
155
|
+
dataset?: string
|
|
156
|
+
perspective?: PerspectiveHandle['perspective']
|
|
157
|
+
},
|
|
158
|
+
>(
|
|
159
|
+
options: T,
|
|
160
|
+
): Omit<T, NormalizedResourceFields> & {
|
|
161
|
+
resource?: DocumentResource
|
|
162
|
+
perspective?: PerspectiveHandle['perspective']
|
|
163
|
+
} {
|
|
164
|
+
const resources = useContext(ResourcesContext)
|
|
165
|
+
const effectiveContextResource = useEffectiveContextResource()
|
|
166
|
+
const contextPerspective = useContext(PerspectiveContext)
|
|
167
|
+
|
|
168
|
+
return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
|
|
169
|
+
}
|
|
@@ -18,8 +18,8 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
18
18
|
*/
|
|
19
19
|
export function useTrackHookUsage(hookName: string): void {
|
|
20
20
|
const instance = useSanityInstance()
|
|
21
|
-
const tracked = useRef(
|
|
22
|
-
if (
|
|
21
|
+
const tracked = useRef<true | null>(null)
|
|
22
|
+
if (tracked.current === null) {
|
|
23
23
|
tracked.current = true
|
|
24
24
|
trackHookMounted(instance, hookName)
|
|
25
25
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {type Organization, type OrganizationMember} from '@sanity/sdk'
|
|
2
|
+
import {expectTypeOf, test} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useOrganization} from './useOrganization'
|
|
5
|
+
|
|
6
|
+
test('useOrganization — no flags: members and features both omitted', () => {
|
|
7
|
+
expectTypeOf(useOrganization({organizationId: 'org_1'})).toEqualTypeOf<
|
|
8
|
+
Organization<false, false>
|
|
9
|
+
>()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('useOrganization — includeMembers: true adds members to the type', () => {
|
|
13
|
+
expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers: true})).toEqualTypeOf<
|
|
14
|
+
Organization<true, false>
|
|
15
|
+
>()
|
|
16
|
+
type Result = ReturnType<typeof useOrganization<true, false>>
|
|
17
|
+
expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[]>()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('useOrganization — includeFeatures: true adds features to the type', () => {
|
|
21
|
+
expectTypeOf(useOrganization({organizationId: 'org_1', includeFeatures: true})).toEqualTypeOf<
|
|
22
|
+
Organization<false, true>
|
|
23
|
+
>()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('useOrganization — both flags true → both arrays present', () => {
|
|
27
|
+
expectTypeOf(
|
|
28
|
+
useOrganization({organizationId: 'org_1', includeMembers: true, includeFeatures: true}),
|
|
29
|
+
).toEqualTypeOf<Organization<true, true>>()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('useOrganization — both flags false → bare base shape', () => {
|
|
33
|
+
expectTypeOf(
|
|
34
|
+
useOrganization({organizationId: 'org_1', includeMembers: false, includeFeatures: false}),
|
|
35
|
+
).toEqualTypeOf<Organization<false, false>>()
|
|
36
|
+
type Result = ReturnType<typeof useOrganization<false, false>>
|
|
37
|
+
expectTypeOf<Result['id']>().toEqualTypeOf<string>()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('useOrganization — rejects non-boolean flag values', () => {
|
|
41
|
+
// @ts-expect-error — includeMembers must be a boolean
|
|
42
|
+
void useOrganization({organizationId: 'org_1', includeMembers: 'yes'})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('useOrganization — non-literal boolean flag makes members optional', () => {
|
|
46
|
+
const includeMembers = false as boolean
|
|
47
|
+
expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers})).toEqualTypeOf<
|
|
48
|
+
Organization<boolean, false>
|
|
49
|
+
>()
|
|
50
|
+
type Result = ReturnType<typeof useOrganization<boolean, false>>
|
|
51
|
+
expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
|
|
52
|
+
expectTypeOf<Pick<Result, 'members'>>().toEqualTypeOf<{members?: OrganizationMember[]}>()
|
|
53
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {getOrganizationState, type OrganizationOptions, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
5
|
+
|
|
6
|
+
vi.mock('@sanity/sdk', () => ({
|
|
7
|
+
getOrganizationState: vi.fn(() => ({
|
|
8
|
+
getCurrent: vi.fn(() => undefined),
|
|
9
|
+
})),
|
|
10
|
+
resolveOrganization: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
vi.mock('../helpers/createStateSourceHook', () => ({
|
|
13
|
+
createStateSourceHook: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('useOrganization', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.resetModules()
|
|
19
|
+
vi.mock('@sanity/sdk', () => ({
|
|
20
|
+
getOrganizationState: vi.fn(() => ({
|
|
21
|
+
getCurrent: vi.fn(() => undefined),
|
|
22
|
+
})),
|
|
23
|
+
resolveOrganization: vi.fn(),
|
|
24
|
+
}))
|
|
25
|
+
vi.mock('../helpers/createStateSourceHook', () => ({
|
|
26
|
+
createStateSourceHook: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should call createStateSourceHook with correct arguments on import', async () => {
|
|
31
|
+
await import('./useOrganization')
|
|
32
|
+
|
|
33
|
+
expect(createStateSourceHook).toHaveBeenCalled()
|
|
34
|
+
expect(createStateSourceHook).toHaveBeenCalledWith(
|
|
35
|
+
expect.objectContaining({
|
|
36
|
+
getState: expect.any(Function),
|
|
37
|
+
shouldSuspend: expect.any(Function),
|
|
38
|
+
suspender: expect.any(Function),
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('shouldSuspend should call getOrganizationState and getCurrent', async () => {
|
|
44
|
+
await import('./useOrganization')
|
|
45
|
+
|
|
46
|
+
const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
|
|
47
|
+
expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0)
|
|
48
|
+
const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
|
|
49
|
+
const shouldSuspend = createStateSourceHookArgs.shouldSuspend
|
|
50
|
+
|
|
51
|
+
const mockInstance = {} as SanityInstance
|
|
52
|
+
const mockOptions: OrganizationOptions = {organizationId: 'org_1'}
|
|
53
|
+
|
|
54
|
+
const result = shouldSuspend(mockInstance, mockOptions)
|
|
55
|
+
|
|
56
|
+
const mockGetOrganizationState = getOrganizationState as ReturnType<typeof vi.fn>
|
|
57
|
+
expect(mockGetOrganizationState).toHaveBeenCalledWith(mockInstance, mockOptions)
|
|
58
|
+
|
|
59
|
+
expect(mockGetOrganizationState.mock.results.length).toBeGreaterThan(0)
|
|
60
|
+
const getOrganizationStateMockResult = mockGetOrganizationState.mock.results[0].value
|
|
61
|
+
expect(getOrganizationStateMockResult.getCurrent).toHaveBeenCalled()
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOrganizationState,
|
|
3
|
+
type Organization,
|
|
4
|
+
type OrganizationOptions,
|
|
5
|
+
resolveOrganization,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
|
|
8
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns metadata for a given organisation.
|
|
12
|
+
*
|
|
13
|
+
* @category Organizations
|
|
14
|
+
* @param options - Configuration options
|
|
15
|
+
* @returns The metadata for the organisation. `members` is included only when
|
|
16
|
+
* `includeMembers: true`; `features` is included only when `includeFeatures: true`.
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* function OrganizationName({organizationId}: {organizationId: string}) {
|
|
20
|
+
* const organization = useOrganization({organizationId})
|
|
21
|
+
*
|
|
22
|
+
* return <h1>{organization.name}</h1>
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const organizationWithMembers = useOrganization({organizationId, includeMembers: true})
|
|
28
|
+
* const organizationWithFeatures = useOrganization({organizationId, includeFeatures: true})
|
|
29
|
+
* ```
|
|
30
|
+
* @public
|
|
31
|
+
* @function
|
|
32
|
+
*/
|
|
33
|
+
export const useOrganization = createStateSourceHook({
|
|
34
|
+
getState: getOrganizationState,
|
|
35
|
+
shouldSuspend: (instance, ...params) =>
|
|
36
|
+
getOrganizationState(instance, ...params).getCurrent() === undefined,
|
|
37
|
+
suspender: resolveOrganization,
|
|
38
|
+
}) as <IncludeMembers extends boolean = false, IncludeFeatures extends boolean = false>(
|
|
39
|
+
options: OrganizationOptions<IncludeMembers, IncludeFeatures>,
|
|
40
|
+
) => Organization<IncludeMembers, IncludeFeatures>
|