@sanity/sdk-react 2.15.0 → 2.17.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/README.md +22 -1
- package/dist/index.d.ts +55 -2
- package/dist/index.js +524 -332
- package/dist/index.js.map +1 -1
- package/package.json +16 -16
- package/src/components/SDKProvider.test.tsx +135 -22
- package/src/components/SDKProvider.tsx +54 -17
- package/src/components/SanityApp.tsx +29 -0
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +57 -1
- package/src/context/ResourceProvider.tsx +30 -9
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +20 -8
- package/src/hooks/document/useApplyDocumentActions.ts +1 -1
- package/src/hooks/document/useDocument.ts +2 -2
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/projects/useProject.test.tsx +114 -0
- package/src/hooks/projects/useProject.ts +17 -7
- package/src/hooks/users/useUsers.test.tsx +101 -2
- package/src/hooks/users/useUsers.ts +35 -3
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/datasets/useDatasets.test.ts +0 -80
- package/src/hooks/projects/useProject.test.ts +0 -80
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {type ReactElement, type ReactNode, use, useMemo} from 'react'
|
|
3
|
+
|
|
4
|
+
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
5
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
6
|
+
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
7
|
+
import {ResourcesContext} from './ResourcesContext'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME = 'media-library'
|
|
10
|
+
const DEFAULT_CANVAS_RESOURCE_NAME = 'canvas'
|
|
11
|
+
|
|
12
|
+
type OrgResource = typeof DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME | typeof DEFAULT_CANVAS_RESOURCE_NAME
|
|
13
|
+
type OrgResourcePromises = Map<OrgResource, PromiseLike<DocumentResource | undefined>>
|
|
14
|
+
|
|
15
|
+
// Module-level cache keyed by SanityInstance so Promise references are stable
|
|
16
|
+
// across Suspense unmount/remount cycles. React's use() tracks promises by
|
|
17
|
+
// identity, so stable references prevent unnecessary re-suspensions.
|
|
18
|
+
// WeakMap entries are GC'd when the instance is disposed.
|
|
19
|
+
// There is only ever one org at a time, so the instance alone is a sufficient
|
|
20
|
+
// key — organizationId only scopes the fetch, it doesn't need to key the cache.
|
|
21
|
+
const inferredResourceCache = new WeakMap<SanityInstance, OrgResourcePromises>()
|
|
22
|
+
|
|
23
|
+
function getOrgResourcePromises(
|
|
24
|
+
instance: SanityInstance,
|
|
25
|
+
organizationId: string,
|
|
26
|
+
): OrgResourcePromises {
|
|
27
|
+
let promises = inferredResourceCache.get(instance)
|
|
28
|
+
if (!promises) {
|
|
29
|
+
const basePromise = resolveOrgResources(instance, organizationId).then(
|
|
30
|
+
(result) => result,
|
|
31
|
+
(error) => {
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn('[sanity/sdk] Failed to infer org resources:', error)
|
|
34
|
+
return {mediaLibrary: undefined, canvas: undefined}
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
promises = new Map([
|
|
38
|
+
[DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME, basePromise.then((r) => r.mediaLibrary)],
|
|
39
|
+
[DEFAULT_CANVAS_RESOURCE_NAME, basePromise.then((r) => r.canvas)],
|
|
40
|
+
])
|
|
41
|
+
inferredResourceCache.set(instance, promises)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return promises
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inner component that suspends via use() until both inference promises settle.
|
|
49
|
+
* Requires a Suspense boundary from the caller (usually a top ResourceProvider).
|
|
50
|
+
*/
|
|
51
|
+
function InferredResourcesProvider({
|
|
52
|
+
instance,
|
|
53
|
+
orgId,
|
|
54
|
+
explicitResources,
|
|
55
|
+
children,
|
|
56
|
+
}: {
|
|
57
|
+
instance: SanityInstance
|
|
58
|
+
orgId: string
|
|
59
|
+
explicitResources: Record<string, DocumentResource>
|
|
60
|
+
children: ReactNode
|
|
61
|
+
}): ReactElement {
|
|
62
|
+
const promises = getOrgResourcePromises(instance, orgId)
|
|
63
|
+
const ml = use(promises.get(DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME)!)
|
|
64
|
+
const canvas = use(promises.get(DEFAULT_CANVAS_RESOURCE_NAME)!)
|
|
65
|
+
|
|
66
|
+
const resources = useMemo(() => {
|
|
67
|
+
const inferred: Record<string, DocumentResource> = {}
|
|
68
|
+
if (ml !== undefined) inferred[DEFAULT_MEDIA_LIBRARY_RESOURCE_NAME] = ml
|
|
69
|
+
if (canvas !== undefined) inferred[DEFAULT_CANVAS_RESOURCE_NAME] = canvas
|
|
70
|
+
return {...inferred, ...explicitResources}
|
|
71
|
+
}, [ml, canvas, explicitResources])
|
|
72
|
+
|
|
73
|
+
return <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* When `inferMediaLibraryAndCanvas` is set, this component suspends until
|
|
78
|
+
* inference resolves. The nearest Suspense boundary (e.g. from ResourceProvider)
|
|
79
|
+
* shows its fallback during that window.
|
|
80
|
+
|
|
81
|
+
* If a user explicitly names a 'media-library' or 'canvas' resource,
|
|
82
|
+
* this component will use that resource instead of inferring it.
|
|
83
|
+
*/
|
|
84
|
+
export function OrganizationResourcesProvider({
|
|
85
|
+
resources: explicitResources = {},
|
|
86
|
+
inferMediaLibraryAndCanvas,
|
|
87
|
+
children,
|
|
88
|
+
}: {
|
|
89
|
+
resources?: Record<string, DocumentResource>
|
|
90
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
91
|
+
children: ReactNode
|
|
92
|
+
}): ReactElement {
|
|
93
|
+
const instance = useSanityInstance()
|
|
94
|
+
const orgId = useDashboardOrganizationId()
|
|
95
|
+
|
|
96
|
+
if (!inferMediaLibraryAndCanvas || !orgId) {
|
|
97
|
+
return (
|
|
98
|
+
<ResourcesContext.Provider value={explicitResources}>{children}</ResourcesContext.Provider>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<InferredResourcesProvider
|
|
104
|
+
instance={instance}
|
|
105
|
+
orgId={orgId}
|
|
106
|
+
explicitResources={explicitResources}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</InferredResourcesProvider>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {createContext} from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carries a `projectId` for a subtree even when a complete
|
|
5
|
+
* `DatasetResource` can't be formed — e.g. a `<ResourceProvider projectId="…">`
|
|
6
|
+
* with no `dataset` and no parent resource to inherit one from.
|
|
7
|
+
*
|
|
8
|
+
* Project-scoped hooks (`useProject`, `useDatasets`, `useUsers`) read this as a
|
|
9
|
+
* fallback so they can still resolve a project in that case (the only hooks that would work with a config like this.)
|
|
10
|
+
*
|
|
11
|
+
* @remarks Temporary bridge for the dataset-less project scope.
|
|
12
|
+
* Remove in the next major version; those hooks should just always use a projectId or default to a resource available.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export const ProjectContext = createContext<string | undefined>(undefined)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
1
|
+
import {type DocumentResource, type SanityConfig, type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {act, render, screen} from '@testing-library/react'
|
|
3
3
|
import {StrictMode, use, useEffect} from 'react'
|
|
4
4
|
import {describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
|
+
import {ResourceContext} from './DefaultResourceContext'
|
|
7
|
+
import {ProjectContext} from './ProjectContext'
|
|
6
8
|
import {ResourceProvider} from './ResourceProvider'
|
|
7
9
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
8
10
|
|
|
@@ -164,4 +166,58 @@ describe('ResourceProvider', () => {
|
|
|
164
166
|
await new Promise((r) => setTimeout(r, 0))
|
|
165
167
|
consoleSpy.mockRestore()
|
|
166
168
|
})
|
|
169
|
+
|
|
170
|
+
it('inherits the missing half of a partial nested config from the parent resource', async () => {
|
|
171
|
+
const {promise, resolve} = promiseWithResolvers<DocumentResource | undefined>()
|
|
172
|
+
const CaptureResource = () => {
|
|
173
|
+
const resource = use(ResourceContext)
|
|
174
|
+
useEffect(() => resolve(resource), [resource])
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
render(
|
|
179
|
+
<ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
|
|
180
|
+
{/* Partial config: only `dataset`. It should inherit `projectId` from the
|
|
181
|
+
parent resource and resolve to a complete DatasetResource rather than
|
|
182
|
+
being dropped in favor of the parent's dataset. */}
|
|
183
|
+
<ResourceProvider dataset="child-dataset" fallback={null}>
|
|
184
|
+
<CaptureResource />
|
|
185
|
+
</ResourceProvider>
|
|
186
|
+
</ResourceProvider>,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
await expect(promise).resolves.toEqual({
|
|
190
|
+
projectId: 'parent-project',
|
|
191
|
+
dataset: 'child-dataset',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('does not merge a nested bare projectId with the enveloping dataset', async () => {
|
|
196
|
+
const captured = promiseWithResolvers<{
|
|
197
|
+
resource: DocumentResource | undefined
|
|
198
|
+
projectId: string | undefined
|
|
199
|
+
}>()
|
|
200
|
+
const Capture = () => {
|
|
201
|
+
const resource = use(ResourceContext)
|
|
202
|
+
const projectId = use(ProjectContext)
|
|
203
|
+
useEffect(() => captured.resolve({resource, projectId}), [resource, projectId])
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
render(
|
|
208
|
+
<ResourceProvider projectId="parent-project" dataset="parent-dataset" fallback={null}>
|
|
209
|
+
{/* Bare `projectId`: switches project scope. It must surface as the
|
|
210
|
+
project but must NOT adopt the parent's dataset — that dataset belongs
|
|
211
|
+
to a different project. */}
|
|
212
|
+
<ResourceProvider projectId="child-project" fallback={null}>
|
|
213
|
+
<Capture />
|
|
214
|
+
</ResourceProvider>
|
|
215
|
+
</ResourceProvider>,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
await expect(captured.promise).resolves.toEqual({
|
|
219
|
+
resource: undefined,
|
|
220
|
+
projectId: 'child-project',
|
|
221
|
+
})
|
|
222
|
+
})
|
|
167
223
|
})
|
|
@@ -11,6 +11,7 @@ import {useContext, useEffect, useMemo, useRef, useState} from 'react'
|
|
|
11
11
|
|
|
12
12
|
import {ResourceContext} from './DefaultResourceContext'
|
|
13
13
|
import {PerspectiveContext} from './PerspectiveContext'
|
|
14
|
+
import {ProjectContext} from './ProjectContext'
|
|
14
15
|
import {SanityInstanceContext} from './SanityInstanceContext'
|
|
15
16
|
import {SanityInstanceProvider} from './SanityInstanceProvider'
|
|
16
17
|
|
|
@@ -63,21 +64,39 @@ export function ResourceProvider({
|
|
|
63
64
|
const parentPerspective = useContext(PerspectiveContext)
|
|
64
65
|
const parentResource = useContext(ResourceContext)
|
|
65
66
|
const parentInstance = useContext(SanityInstanceContext)
|
|
67
|
+
const parentProjectId = useContext(ProjectContext)
|
|
66
68
|
|
|
67
69
|
const {projectId, dataset, perspective} = config
|
|
68
70
|
|
|
69
71
|
const [instance] = useState<SanityInstance>(() => parentInstance ?? createSanityInstance(config))
|
|
70
72
|
|
|
71
73
|
const configResource: DatasetResource | undefined = useMemo(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
// Historically, we allowed asymmetric merging: If you provided JUST a dataset, we'd merge it with the parent projectId.
|
|
75
|
+
//
|
|
76
|
+
// This backwards-compatible merging should be removed in the next major version.
|
|
77
|
+
if (projectId && dataset) return {projectId, dataset}
|
|
78
|
+
if (dataset && parentProjectId) return {projectId: parentProjectId, dataset}
|
|
75
79
|
return undefined
|
|
76
|
-
}, [projectId, dataset])
|
|
80
|
+
}, [projectId, dataset, parentProjectId])
|
|
77
81
|
|
|
78
82
|
const effectiveResource = useMemo(() => {
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
if (resource) return resource
|
|
84
|
+
if (configResource) return configResource
|
|
85
|
+
// A projectId with no dataset historically created its own scope, so no resource is needed.
|
|
86
|
+
if (projectId) return undefined
|
|
87
|
+
return parentResource
|
|
88
|
+
}, [resource, configResource, projectId, parentResource])
|
|
89
|
+
|
|
90
|
+
// Historically, ResourceProviders allowed a bare `projectId` (no dataset, no resource to complete it) to be provided.
|
|
91
|
+
// This keeps that behavior intact for backwards compatibility, even though we prefer resources everywhere.
|
|
92
|
+
//
|
|
93
|
+
// This should be removed in the next major version.
|
|
94
|
+
const effectiveProjectId = useMemo(() => {
|
|
95
|
+
if (effectiveResource && isDatasetResource(effectiveResource)) {
|
|
96
|
+
return effectiveResource.projectId
|
|
97
|
+
}
|
|
98
|
+
return projectId ?? parentProjectId
|
|
99
|
+
}, [effectiveResource, projectId, parentProjectId])
|
|
81
100
|
|
|
82
101
|
useEffect(() => {
|
|
83
102
|
if (effectiveResource && isDatasetResource(effectiveResource))
|
|
@@ -113,9 +132,11 @@ export function ResourceProvider({
|
|
|
113
132
|
return (
|
|
114
133
|
<SanityInstanceProvider instance={instance} fallback={fallback ?? DEFAULT_FALLBACK}>
|
|
115
134
|
<ResourceContext.Provider value={effectiveResource}>
|
|
116
|
-
<
|
|
117
|
-
{
|
|
118
|
-
|
|
135
|
+
<ProjectContext.Provider value={effectiveProjectId}>
|
|
136
|
+
<PerspectiveContext.Provider value={perspective ?? parentPerspective}>
|
|
137
|
+
{children}
|
|
138
|
+
</PerspectiveContext.Provider>
|
|
139
|
+
</ProjectContext.Provider>
|
|
119
140
|
</ResourceContext.Provider>
|
|
120
141
|
</SanityInstanceProvider>
|
|
121
142
|
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
+
import {
|
|
3
|
+
createSanityInstance,
|
|
4
|
+
getDatasetsState,
|
|
5
|
+
resolveDatasets,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {type ReactNode} from 'react'
|
|
9
|
+
import {type Observable} from 'rxjs'
|
|
10
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {renderHook} from '../../../test/test-utils'
|
|
13
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
14
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
15
|
+
import {useDatasets} from './useDatasets'
|
|
16
|
+
|
|
17
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
+
return {...original, getDatasetsState: vi.fn(), resolveDatasets: vi.fn()}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const stateSource = (
|
|
23
|
+
current: DatasetsResponse | undefined,
|
|
24
|
+
): StateSource<DatasetsResponse | undefined> =>
|
|
25
|
+
({
|
|
26
|
+
getCurrent: vi.fn(() => current),
|
|
27
|
+
subscribe: vi.fn(),
|
|
28
|
+
get observable(): Observable<unknown> {
|
|
29
|
+
throw new Error('Not implemented')
|
|
30
|
+
},
|
|
31
|
+
}) as unknown as StateSource<DatasetsResponse | undefined>
|
|
32
|
+
|
|
33
|
+
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
34
|
+
|
|
35
|
+
describe('useDatasets', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks()
|
|
38
|
+
vi.mocked(getDatasetsState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('resolves the projectId from the instance config resource', () => {
|
|
42
|
+
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
43
|
+
renderHook(() => useDatasets())
|
|
44
|
+
expect(getDatasetsState).toHaveBeenCalledWith(
|
|
45
|
+
sanityInstance,
|
|
46
|
+
expect.objectContaining({projectId: 'test'}),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('lets an explicit projectId override the ambient resource', () => {
|
|
51
|
+
renderHook(() => useDatasets({projectId: 'explicit-project'}))
|
|
52
|
+
expect(getDatasetsState).toHaveBeenCalledWith(
|
|
53
|
+
sanityInstance,
|
|
54
|
+
expect.objectContaining({projectId: 'explicit-project'}),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves the projectId from an explicit resource when the config has none', () => {
|
|
59
|
+
renderHook(() => useDatasets(), {
|
|
60
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
61
|
+
<ResourceProvider
|
|
62
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
63
|
+
fallback={null}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</ResourceProvider>
|
|
67
|
+
),
|
|
68
|
+
})
|
|
69
|
+
expect(getDatasetsState).toHaveBeenCalledWith(
|
|
70
|
+
sanityInstance,
|
|
71
|
+
expect.objectContaining({projectId: 'resource-project'}),
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('resolves a dataset-less projectId config for project-scoped use', () => {
|
|
76
|
+
renderHook(() => useDatasets(), {
|
|
77
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
78
|
+
<ResourceProvider projectId="config-project" fallback={null}>
|
|
79
|
+
{children}
|
|
80
|
+
</ResourceProvider>
|
|
81
|
+
),
|
|
82
|
+
})
|
|
83
|
+
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
84
|
+
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
85
|
+
expect(getDatasetsState).toHaveBeenCalledWith(
|
|
86
|
+
sanityInstance,
|
|
87
|
+
expect.objectContaining({projectId: 'config-project'}),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
|
|
92
|
+
// An instance with no project/dataset config and no ambient
|
|
93
|
+
// resource, then a projectId-only ResourceProvider.
|
|
94
|
+
const emptyInstance = createSanityInstance({})
|
|
95
|
+
renderHook(() => useDatasets(), {
|
|
96
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
97
|
+
<SanityInstanceContext.Provider value={emptyInstance}>
|
|
98
|
+
<ResourceProvider projectId="bare-project" fallback={null}>
|
|
99
|
+
{children}
|
|
100
|
+
</ResourceProvider>
|
|
101
|
+
</SanityInstanceContext.Provider>
|
|
102
|
+
),
|
|
103
|
+
})
|
|
104
|
+
expect(getDatasetsState).toHaveBeenCalledWith(
|
|
105
|
+
emptyInstance,
|
|
106
|
+
expect.objectContaining({projectId: 'bare-project'}),
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('suspends via resolveDatasets until dataset data is available', () => {
|
|
111
|
+
vi.mocked(getDatasetsState).mockReturnValue(stateSource(undefined))
|
|
112
|
+
vi.mocked(resolveDatasets).mockReturnValue(new Promise(() => {}))
|
|
113
|
+
renderHook(() => useDatasets())
|
|
114
|
+
expect(resolveDatasets).toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -6,9 +6,9 @@ import {
|
|
|
6
6
|
type SanityInstance,
|
|
7
7
|
type StateSource,
|
|
8
8
|
} from '@sanity/sdk'
|
|
9
|
-
import {identity} from 'rxjs'
|
|
10
9
|
|
|
11
10
|
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
11
|
+
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
12
12
|
|
|
13
13
|
type UseDatasets = {
|
|
14
14
|
/**
|
|
@@ -16,6 +16,8 @@ type UseDatasets = {
|
|
|
16
16
|
* Returns metadata for each dataset the current user has access to.
|
|
17
17
|
*
|
|
18
18
|
* @category Datasets
|
|
19
|
+
* @param options - Optional project/resource to read datasets for. Defaults to
|
|
20
|
+
* the resource named in `ResourceProvider`/`SDKProvider`.
|
|
19
21
|
* @returns The metadata for your the datasets
|
|
20
22
|
*
|
|
21
23
|
* @example
|
|
@@ -31,15 +33,17 @@ type UseDatasets = {
|
|
|
31
33
|
* )
|
|
32
34
|
* ```
|
|
33
35
|
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* The `projectId` is resolved in order from:
|
|
38
|
+
* 1. an explicit `projectId` option
|
|
39
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
40
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
41
|
+
* 4. `instance.config`.
|
|
34
42
|
*/
|
|
35
|
-
(): DatasetsResponse
|
|
43
|
+
(options?: ProjectHandle): DatasetsResponse
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
* @public
|
|
40
|
-
* @function
|
|
41
|
-
*/
|
|
42
|
-
export const useDatasets: UseDatasets = createStateSourceHook({
|
|
46
|
+
const useDatasetsBase = createStateSourceHook({
|
|
43
47
|
getState: getDatasetsState as (
|
|
44
48
|
instance: SanityInstance,
|
|
45
49
|
projectHandle?: ProjectHandle,
|
|
@@ -48,5 +52,13 @@ export const useDatasets: UseDatasets = createStateSourceHook({
|
|
|
48
52
|
// remove `undefined` since we're suspending when that is the case
|
|
49
53
|
getDatasetsState(instance, projectHandle).getCurrent() === undefined,
|
|
50
54
|
suspender: resolveDatasets,
|
|
51
|
-
getConfig: identity as (projectHandle?: ProjectHandle) => ProjectHandle,
|
|
52
55
|
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @public
|
|
59
|
+
* @function
|
|
60
|
+
*/
|
|
61
|
+
export const useDatasets: UseDatasets = (options) => {
|
|
62
|
+
const projectId = useResolvedProjectId(options)
|
|
63
|
+
return useDatasetsBase(projectId ? {...options, projectId} : options)
|
|
64
|
+
}
|
|
@@ -4,7 +4,7 @@ import {type SanityDocument} from 'groq'
|
|
|
4
4
|
import {type ResourceHandle} from '../../config/handles'
|
|
5
5
|
import {useApplyActions} from '../helpers/useApplyActions'
|
|
6
6
|
// this import is used in an `{@link useEditDocument}`
|
|
7
|
-
// eslint-disable-next-line import/consistent-type-specifier-style
|
|
7
|
+
// eslint-disable-next-line import-x/consistent-type-specifier-style
|
|
8
8
|
import type {useEditDocument} from './useEditDocument'
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -7,9 +7,9 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
|
7
7
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
8
8
|
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
9
9
|
// used in an `{@link useDocumentProjection}` and `{@link useQuery}`
|
|
10
|
-
// eslint-disable-next-line import/consistent-type-specifier-style
|
|
10
|
+
// eslint-disable-next-line import-x/consistent-type-specifier-style
|
|
11
11
|
import type {useDocumentProjection} from '../projection/useDocumentProjection'
|
|
12
|
-
// eslint-disable-next-line import/consistent-type-specifier-style
|
|
12
|
+
// eslint-disable-next-line import-x/consistent-type-specifier-style
|
|
13
13
|
import type {useQuery} from '../query/useQuery'
|
|
14
14
|
|
|
15
15
|
const useDocumentValue = createStateSourceHook({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {type DocumentResource} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {type ReactNode} from 'react'
|
|
4
|
+
import {describe, expect, it} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {ResourceContext} from '../../context/DefaultResourceContext'
|
|
7
|
+
import {ProjectContext} from '../../context/ProjectContext'
|
|
8
|
+
import {useResolvedProjectId} from './useResolvedProjectId'
|
|
9
|
+
|
|
10
|
+
const datasetResource: DocumentResource = {projectId: 'resource-project', dataset: 'production'}
|
|
11
|
+
|
|
12
|
+
describe('useResolvedProjectId', () => {
|
|
13
|
+
it('prefers an explicit projectId on the options', () => {
|
|
14
|
+
const {result} = renderHook(() => useResolvedProjectId({projectId: 'option-project'}), {
|
|
15
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
16
|
+
<ResourceContext.Provider value={datasetResource}>
|
|
17
|
+
<ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
|
|
18
|
+
</ResourceContext.Provider>
|
|
19
|
+
),
|
|
20
|
+
})
|
|
21
|
+
expect(result.current).toBe('option-project')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to the ambient project scope (ProjectContext) over the resource', () => {
|
|
25
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
26
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
27
|
+
<ResourceContext.Provider value={datasetResource}>
|
|
28
|
+
<ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
|
|
29
|
+
</ResourceContext.Provider>
|
|
30
|
+
),
|
|
31
|
+
})
|
|
32
|
+
expect(result.current).toBe('context-project')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('falls back to the resolved resource projectId', () => {
|
|
36
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
37
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
38
|
+
<ResourceContext.Provider value={datasetResource}>{children}</ResourceContext.Provider>
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
expect(result.current).toBe('resource-project')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('ignores a non-dataset resource (e.g. media library)', () => {
|
|
45
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
46
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
47
|
+
<ResourceContext.Provider value={{mediaLibraryId: 'ml-id'}}>
|
|
48
|
+
{children}
|
|
49
|
+
</ResourceContext.Provider>
|
|
50
|
+
),
|
|
51
|
+
})
|
|
52
|
+
expect(result.current).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns undefined when nothing resolves', () => {
|
|
56
|
+
const {result} = renderHook(() => useResolvedProjectId())
|
|
57
|
+
expect(result.current).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {type DocumentResource, isDatasetResource} from '@sanity/sdk'
|
|
2
|
+
import {useContext} from 'react'
|
|
3
|
+
|
|
4
|
+
import {ProjectContext} from '../../context/ProjectContext'
|
|
5
|
+
import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the effective `projectId` for project-scoped hooks (`useProject`,
|
|
9
|
+
* `useDatasets`, `useUsers`).
|
|
10
|
+
*
|
|
11
|
+
* Precedence:
|
|
12
|
+
* 1. an explicit `projectId` on the options
|
|
13
|
+
* 2. the ambient project scope (`ProjectContext`, e.g. a dataset-less
|
|
14
|
+
* `<ResourceProvider projectId="…">`)
|
|
15
|
+
* 3. the resolved resource's projectId (`ResourceProvider`/`SDKProvider`)
|
|
16
|
+
*
|
|
17
|
+
* Returns `undefined` when none apply, letting callers fall back to core's
|
|
18
|
+
* `instance.config.projectId`.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function useResolvedProjectId(options?: {
|
|
23
|
+
projectId?: string
|
|
24
|
+
dataset?: string
|
|
25
|
+
resource?: DocumentResource
|
|
26
|
+
resourceName?: string
|
|
27
|
+
}): string | undefined {
|
|
28
|
+
const {resource} = useNormalizedResourceOptions(options ?? {})
|
|
29
|
+
const contextProjectId = useContext(ProjectContext)
|
|
30
|
+
return (
|
|
31
|
+
options?.projectId ??
|
|
32
|
+
contextProjectId ??
|
|
33
|
+
(resource && isDatasetResource(resource) ? resource.projectId : undefined)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSanityInstance,
|
|
3
|
+
getProjectState,
|
|
4
|
+
type Project,
|
|
5
|
+
resolveProject,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {type ReactNode} from 'react'
|
|
9
|
+
import {type Observable} from 'rxjs'
|
|
10
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {renderHook} from '../../../test/test-utils'
|
|
13
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
14
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
15
|
+
import {useProject} from './useProject'
|
|
16
|
+
|
|
17
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
+
return {...original, getProjectState: vi.fn(), resolveProject: vi.fn()}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const stateSource = (current: Project | undefined): StateSource<Project | undefined> =>
|
|
23
|
+
({
|
|
24
|
+
getCurrent: vi.fn(() => current),
|
|
25
|
+
subscribe: vi.fn(),
|
|
26
|
+
get observable(): Observable<unknown> {
|
|
27
|
+
throw new Error('Not implemented')
|
|
28
|
+
},
|
|
29
|
+
}) as unknown as StateSource<Project | undefined>
|
|
30
|
+
|
|
31
|
+
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
32
|
+
|
|
33
|
+
describe('useProject', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.clearAllMocks()
|
|
36
|
+
vi.mocked(getProjectState).mockReturnValue(stateSource({id: 'p'} as unknown as Project))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('resolves the projectId from the instance config resource', () => {
|
|
40
|
+
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
41
|
+
renderHook(() => useProject())
|
|
42
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
43
|
+
sanityInstance,
|
|
44
|
+
expect.objectContaining({projectId: 'test'}),
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('lets an explicit projectId override the ambient resource', () => {
|
|
49
|
+
renderHook(() => useProject({projectId: 'explicit-project'}))
|
|
50
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
51
|
+
sanityInstance,
|
|
52
|
+
expect.objectContaining({projectId: 'explicit-project'}),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('resolves the projectId from an explicit resource when the config has none', () => {
|
|
57
|
+
renderHook(() => useProject(), {
|
|
58
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
59
|
+
<ResourceProvider
|
|
60
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
61
|
+
fallback={null}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</ResourceProvider>
|
|
65
|
+
),
|
|
66
|
+
})
|
|
67
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
68
|
+
sanityInstance,
|
|
69
|
+
expect.objectContaining({projectId: 'resource-project'}),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('resolves a dataset-less projectId config for project-scoped use', () => {
|
|
74
|
+
renderHook(() => useProject(), {
|
|
75
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
76
|
+
<ResourceProvider projectId="config-project" fallback={null}>
|
|
77
|
+
{children}
|
|
78
|
+
</ResourceProvider>
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
82
|
+
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
83
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
84
|
+
sanityInstance,
|
|
85
|
+
expect.objectContaining({projectId: 'config-project'}),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
|
|
90
|
+
// An instance with no project/dataset config and no ambient
|
|
91
|
+
// resource, then a projectId-only ResourceProvider.
|
|
92
|
+
const emptyInstance = createSanityInstance({})
|
|
93
|
+
renderHook(() => useProject(), {
|
|
94
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
95
|
+
<SanityInstanceContext.Provider value={emptyInstance}>
|
|
96
|
+
<ResourceProvider projectId="bare-project" fallback={null}>
|
|
97
|
+
{children}
|
|
98
|
+
</ResourceProvider>
|
|
99
|
+
</SanityInstanceContext.Provider>
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
103
|
+
emptyInstance,
|
|
104
|
+
expect.objectContaining({projectId: 'bare-project'}),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('suspends via resolveProject until project data is available', () => {
|
|
109
|
+
vi.mocked(getProjectState).mockReturnValue(stateSource(undefined))
|
|
110
|
+
vi.mocked(resolveProject).mockReturnValue(new Promise(() => {}))
|
|
111
|
+
renderHook(() => useProject())
|
|
112
|
+
expect(resolveProject).toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
})
|