@sanity/sdk-react 2.15.0 → 2.16.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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@sanity/client": "^7.23.
|
|
47
|
+
"@sanity/client": "^7.23.1",
|
|
48
48
|
"@sanity/message-protocol": "^0.23.0",
|
|
49
|
-
"@sanity/types": "^6.
|
|
49
|
+
"@sanity/types": "^6.2.0",
|
|
50
50
|
"groq": "3.88.1-typegen-experimental.0",
|
|
51
|
-
"react-compiler-runtime": "
|
|
51
|
+
"react-compiler-runtime": "^1.0.0",
|
|
52
52
|
"react-error-boundary": "^6.1.2",
|
|
53
53
|
"rxjs": "^7.8.2",
|
|
54
|
-
"@sanity/sdk": "2.
|
|
54
|
+
"@sanity/sdk": "2.16.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
@@ -62,24 +62,24 @@
|
|
|
62
62
|
"@types/node": "^24.12.4",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
|
-
"@vitejs/plugin-react": "^
|
|
66
|
-
"@vitest/coverage-v8": "^4.1.
|
|
67
|
-
"babel-plugin-react-compiler": "
|
|
68
|
-
"eslint": "^
|
|
65
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
66
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
67
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
68
|
+
"eslint": "^10.6.0",
|
|
69
69
|
"groq-js": "^1.30.2",
|
|
70
70
|
"jsdom": "^29.1.1",
|
|
71
|
-
"oxfmt": "^0.
|
|
71
|
+
"oxfmt": "^0.58.0",
|
|
72
72
|
"react": "^19.2.7",
|
|
73
73
|
"react-dom": "^19.2.7",
|
|
74
74
|
"rollup-plugin-visualizer": "^7.0.1",
|
|
75
|
-
"typescript": "^
|
|
76
|
-
"vite": "^
|
|
77
|
-
"vitest": "^4.1.
|
|
78
|
-
"@repo/package.bundle": "3.82.0",
|
|
79
|
-
"@repo/config-eslint": "0.0.0",
|
|
75
|
+
"typescript": "^6.0.3",
|
|
76
|
+
"vite": "^8.1.4",
|
|
77
|
+
"vitest": "^4.1.10",
|
|
80
78
|
"@repo/config-test": "0.0.1",
|
|
79
|
+
"@repo/tsconfig": "0.0.1",
|
|
81
80
|
"@repo/package.config": "0.0.1",
|
|
82
|
-
"@repo/
|
|
81
|
+
"@repo/package.bundle": "3.82.0",
|
|
82
|
+
"@repo/config-eslint": "0.0.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -1,41 +1,67 @@
|
|
|
1
|
+
import {createSanityInstance, type SanityConfig} from '@sanity/sdk'
|
|
1
2
|
import {render} from '@testing-library/react'
|
|
2
3
|
import React from 'react'
|
|
3
|
-
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
5
|
|
|
6
|
+
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
7
|
+
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
5
8
|
import {SDKProvider} from './SDKProvider'
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
const instance = createSanityInstance()
|
|
11
|
+
|
|
12
|
+
// Returns the same object on every call so the module-level WeakMap cache works across renders.
|
|
13
|
+
vi.mock('../hooks/context/useSanityInstance', () => {
|
|
14
|
+
return {useSanityInstance: () => instance}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
vi.mock('../hooks/auth/useDashboardOrganizationId', () => ({
|
|
18
|
+
useDashboardOrganizationId: vi.fn(),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
vi.mock('../utils/resolveOrgResources', () => ({
|
|
22
|
+
resolveOrgResources: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
8
25
|
vi.mock('../context/ResourceProvider', () => ({
|
|
9
26
|
ResourceProvider: ({
|
|
10
27
|
children,
|
|
11
|
-
|
|
28
|
+
// exclude fallback: it's a real ResourceProvider prop but not part of config
|
|
29
|
+
fallback: _fallback,
|
|
30
|
+
resource,
|
|
31
|
+
...config
|
|
12
32
|
}: {
|
|
13
33
|
children: React.ReactNode
|
|
14
|
-
|
|
15
|
-
dataset?: string
|
|
16
|
-
}) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{children}
|
|
26
|
-
</div>
|
|
27
|
-
)
|
|
28
|
-
},
|
|
34
|
+
fallback?: React.ReactNode
|
|
35
|
+
resource?: {projectId?: string; dataset?: string}
|
|
36
|
+
}) => (
|
|
37
|
+
<div
|
|
38
|
+
data-testid="resource-provider"
|
|
39
|
+
data-config={JSON.stringify(config)}
|
|
40
|
+
data-resource={JSON.stringify(resource ?? null)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
29
45
|
}))
|
|
30
46
|
|
|
31
|
-
// Mock AuthBoundary
|
|
32
47
|
vi.mock('./auth/AuthBoundary', () => ({
|
|
33
|
-
AuthBoundary: ({children}: {children: React.ReactNode}) =>
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
AuthBoundary: ({children, projectIds}: {children: React.ReactNode; projectIds?: string[]}) => (
|
|
49
|
+
<div data-testid="auth-boundary" data-project-ids={JSON.stringify(projectIds ?? [])}>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
36
53
|
}))
|
|
37
54
|
|
|
55
|
+
const mockResolveOrgResources = vi.mocked(resolveOrgResources)
|
|
56
|
+
const mockUseDashboardOrganizationId = vi.mocked(useDashboardOrganizationId)
|
|
57
|
+
|
|
38
58
|
describe('SDKProvider', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks()
|
|
61
|
+
mockResolveOrgResources.mockResolvedValue({})
|
|
62
|
+
mockUseDashboardOrganizationId.mockReturnValue(undefined)
|
|
63
|
+
})
|
|
64
|
+
|
|
39
65
|
it('renders single ResourceProvider with AuthBoundary for a single config', () => {
|
|
40
66
|
const config = {
|
|
41
67
|
projectId: 'test-project',
|
|
@@ -93,4 +119,91 @@ describe('SDKProvider', () => {
|
|
|
93
119
|
dataset: 'production',
|
|
94
120
|
})
|
|
95
121
|
})
|
|
122
|
+
|
|
123
|
+
it('preserves non-resource config fields (e.g. studio auth, perspective) on the root provider', () => {
|
|
124
|
+
const tokenSource = {subscribe: () => ({unsubscribe: () => {}})}
|
|
125
|
+
const config: SanityConfig = {
|
|
126
|
+
projectId: 'test-project',
|
|
127
|
+
dataset: 'production',
|
|
128
|
+
perspective: 'published',
|
|
129
|
+
studio: {authenticated: true, auth: {token: tokenSource}},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const {getAllByTestId} = render(
|
|
133
|
+
<SDKProvider config={config} fallback={<div>Loading...</div>}>
|
|
134
|
+
<div>Child Content</div>
|
|
135
|
+
</SDKProvider>,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const providers = getAllByTestId('resource-provider')
|
|
139
|
+
// The full first config is spread onto the root ResourceProvider so the
|
|
140
|
+
// root SanityInstance retains auth/perspective/etc. (The token source is a
|
|
141
|
+
// function and doesn't survive JSON serialization, so assert on the
|
|
142
|
+
// serializable fields and that the studio config object is carried through.)
|
|
143
|
+
const spreadConfig = JSON.parse(providers[0].getAttribute('data-config') || '{}')
|
|
144
|
+
expect(spreadConfig).toMatchObject({
|
|
145
|
+
projectId: 'test-project',
|
|
146
|
+
dataset: 'production',
|
|
147
|
+
perspective: 'published',
|
|
148
|
+
studio: {authenticated: true},
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('synthesizes the default resource from the first config when none is explicit', () => {
|
|
153
|
+
const config = {projectId: 'test-project', dataset: 'production'}
|
|
154
|
+
|
|
155
|
+
const {getAllByTestId} = render(
|
|
156
|
+
<SDKProvider config={config} fallback={<div>Loading...</div>}>
|
|
157
|
+
<div>Child Content</div>
|
|
158
|
+
</SDKProvider>,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const providers = getAllByTestId('resource-provider')
|
|
162
|
+
expect(JSON.parse(providers[0].getAttribute('data-resource') || 'null')).toEqual({
|
|
163
|
+
projectId: 'test-project',
|
|
164
|
+
dataset: 'production',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('uses an explicitly provided "default" resource over the config-derived one', () => {
|
|
169
|
+
const config = {projectId: 'config-project', dataset: 'production'}
|
|
170
|
+
const resources = {default: {projectId: 'explicit-project', dataset: 'staging'}}
|
|
171
|
+
|
|
172
|
+
const {getAllByTestId} = render(
|
|
173
|
+
<SDKProvider config={config} resources={resources} fallback={<div>Loading...</div>}>
|
|
174
|
+
<div>Child Content</div>
|
|
175
|
+
</SDKProvider>,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const providers = getAllByTestId('resource-provider')
|
|
179
|
+
// Config fields still flow through for instance creation…
|
|
180
|
+
expect(JSON.parse(providers[0].getAttribute('data-config') || '{}')).toEqual(config)
|
|
181
|
+
// …but the explicit default resource wins as the resource context value.
|
|
182
|
+
expect(JSON.parse(providers[0].getAttribute('data-resource') || 'null')).toEqual(
|
|
183
|
+
resources.default,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('collects deduped projectIds from both config and explicit resources', () => {
|
|
188
|
+
const configs = [
|
|
189
|
+
{projectId: 'project-1', dataset: 'production'},
|
|
190
|
+
{projectId: 'project-2', dataset: 'production'},
|
|
191
|
+
]
|
|
192
|
+
const resources = {
|
|
193
|
+
// duplicates project-1 (should be deduped)
|
|
194
|
+
default: {projectId: 'project-1', dataset: 'production'},
|
|
195
|
+
other: {projectId: 'project-3', dataset: 'production'},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const {getByTestId} = render(
|
|
199
|
+
<SDKProvider config={configs} resources={resources} fallback={<div>Loading...</div>}>
|
|
200
|
+
<div>Child Content</div>
|
|
201
|
+
</SDKProvider>,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const projectIds = JSON.parse(
|
|
205
|
+
getByTestId('auth-boundary').getAttribute('data-project-ids') || '[]',
|
|
206
|
+
)
|
|
207
|
+
expect([...projectIds].sort()).toEqual(['project-1', 'project-2', 'project-3'])
|
|
208
|
+
})
|
|
96
209
|
})
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type DocumentResource,
|
|
3
|
+
isDatasetResource,
|
|
4
|
+
isImportError,
|
|
5
|
+
type SanityConfig,
|
|
6
|
+
} from '@sanity/sdk'
|
|
2
7
|
import {type ReactElement, type ReactNode, useEffect, useMemo} from 'react'
|
|
3
8
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
4
9
|
|
|
5
10
|
import {DEFAULT_RESOURCE_NAME} from '../constants'
|
|
11
|
+
import {OrganizationResourcesProvider} from '../context/OrganizationResourcesProvider'
|
|
6
12
|
import {ResourceProvider} from '../context/ResourceProvider'
|
|
7
|
-
import {ResourcesContext} from '../context/ResourcesContext'
|
|
8
13
|
import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'
|
|
9
14
|
import {ChunkLoadError} from './errors/ChunkLoadError'
|
|
10
15
|
import {clearChunkReloadFlag} from './errors/chunkReloadStorage'
|
|
@@ -17,6 +22,8 @@ export interface SDKProviderProps extends AuthBoundaryProps {
|
|
|
17
22
|
config: SanityConfig | SanityConfig[]
|
|
18
23
|
fallback: ReactNode
|
|
19
24
|
resources?: Record<string, DocumentResource>
|
|
25
|
+
/** When set, automatically fetches and registers the organization's media library and canvas as named resources. */
|
|
26
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -40,38 +47,68 @@ export function SDKProvider({
|
|
|
40
47
|
children,
|
|
41
48
|
config,
|
|
42
49
|
fallback,
|
|
50
|
+
inferMediaLibraryAndCanvas,
|
|
43
51
|
...props
|
|
44
52
|
}: SDKProviderProps): ReactElement {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
// For legacy config support, extract the first config object as a default resource,
|
|
54
|
+
// matching the legacy behavior nesting ResourceProviders with the first config closest to users' application code.
|
|
55
|
+
// This should be removed when we remove legacy config support.
|
|
56
|
+
const defaultConfig = Array.isArray(config) ? config[0] : config
|
|
57
|
+
const defaultProjectId = defaultConfig?.projectId
|
|
58
|
+
const defaultDataset = defaultConfig?.dataset
|
|
48
59
|
|
|
49
|
-
// Extract static fields so the memo below doesn't take a reference dependency
|
|
50
|
-
// on `config` — inline config objects change identity on every render.
|
|
51
|
-
const singleConfig = Array.isArray(config) ? null : config
|
|
52
|
-
const defaultProjectId = singleConfig?.projectId
|
|
53
|
-
const defaultDataset = singleConfig?.dataset
|
|
54
|
-
|
|
55
|
-
// For a single config, synthesize a 'default' resource from its projectId/dataset
|
|
56
|
-
// so that hooks can resolve it via resourceName: 'default' or fall back to it
|
|
57
|
-
// automatically when no resource info is provided.
|
|
58
60
|
const resourcesValue = useMemo(() => {
|
|
59
61
|
const explicit = props.resources ?? {}
|
|
60
62
|
if (defaultProjectId && defaultDataset && !Object.hasOwn(explicit, DEFAULT_RESOURCE_NAME)) {
|
|
61
63
|
return {
|
|
62
|
-
[DEFAULT_RESOURCE_NAME]: {
|
|
64
|
+
[DEFAULT_RESOURCE_NAME]: {
|
|
65
|
+
projectId: defaultProjectId,
|
|
66
|
+
dataset: defaultDataset,
|
|
67
|
+
},
|
|
63
68
|
...explicit,
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
return explicit
|
|
67
72
|
}, [defaultProjectId, defaultDataset, props.resources])
|
|
68
73
|
|
|
74
|
+
// Collect all projectIds from both resources and config, deduped so
|
|
75
|
+
// AuthBoundary doesn't verify the same project more than once.
|
|
76
|
+
const projectIds = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
Array.from(
|
|
79
|
+
new Set(
|
|
80
|
+
[
|
|
81
|
+
...Object.values(resourcesValue)
|
|
82
|
+
.filter(isDatasetResource)
|
|
83
|
+
.map((resource) => resource.projectId),
|
|
84
|
+
...(Array.isArray(config) ? config : [config]).map((configObj) => configObj.projectId),
|
|
85
|
+
].filter((id): id is string => !!id),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
[resourcesValue, config],
|
|
89
|
+
)
|
|
90
|
+
|
|
69
91
|
return (
|
|
70
92
|
<ErrorBoundary FallbackComponent={ChunkAwareFallback}>
|
|
71
93
|
<ResetChunkReloadFlagOnMount />
|
|
72
|
-
|
|
94
|
+
{/* Spread the first config so SanityInstance can use the auth,
|
|
95
|
+
perspective, and other config fields; `resource` lets an explicitly
|
|
96
|
+
provided `default` resource override the config-derived resource. */}
|
|
97
|
+
<ResourceProvider
|
|
98
|
+
{...defaultConfig}
|
|
99
|
+
resource={resourcesValue[DEFAULT_RESOURCE_NAME]}
|
|
100
|
+
fallback={fallback}
|
|
101
|
+
>
|
|
73
102
|
<AuthBoundary {...props} projectIds={projectIds}>
|
|
74
|
-
|
|
103
|
+
{/* OrganizationResourcesProvider wraps ResourcesContext.
|
|
104
|
+
It merges explicit resources with lazily inferred org resources (media
|
|
105
|
+
library, canvas) and provides the combined map to the subtree. */}
|
|
106
|
+
<OrganizationResourcesProvider
|
|
107
|
+
resources={resourcesValue}
|
|
108
|
+
inferMediaLibraryAndCanvas={inferMediaLibraryAndCanvas}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</OrganizationResourcesProvider>
|
|
75
112
|
</AuthBoundary>
|
|
76
113
|
</ResourceProvider>
|
|
77
114
|
</ErrorBoundary>
|
|
@@ -23,6 +23,35 @@ export interface SanityAppProps {
|
|
|
23
23
|
children: React.ReactNode
|
|
24
24
|
/* Fallback content to show when child components are suspending. Same as the `fallback` prop for React Suspense. */
|
|
25
25
|
fallback: React.ReactNode
|
|
26
|
+
/**
|
|
27
|
+
* Set this to automatically fetch and register the organization's media library and canvas as named resources.
|
|
28
|
+
* These resources will be available to hooks as `media-library` and `canvas`.
|
|
29
|
+
*
|
|
30
|
+
* The SDK App must be running in the organization's Dashboard to use this feature.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
*
|
|
35
|
+
* const MyApp = () => {
|
|
36
|
+
* // should "just work" because of inferMediaLibraryAndCanvas.
|
|
37
|
+
* const {data: assets} = useDocuments({
|
|
38
|
+
* documentType: 'sanity.asset',
|
|
39
|
+
* resourceName: 'media-library',
|
|
40
|
+
* })
|
|
41
|
+
* return (
|
|
42
|
+
* <div>
|
|
43
|
+
* {assets.map((asset) => (
|
|
44
|
+
* <div key={asset._id}>{asset.originalFilename}</div>
|
|
45
|
+
* ))}
|
|
46
|
+
* </div>
|
|
47
|
+
* )
|
|
48
|
+
* }
|
|
49
|
+
* <SanityApp inferMediaLibraryAndCanvas>
|
|
50
|
+
* <MyApp />
|
|
51
|
+
* </SanityApp>
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
const REDIRECT_URL = 'https://sanity.io/welcome'
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {createSanityInstance, type DocumentResource} from '@sanity/sdk'
|
|
2
|
+
import {act, render} from '@testing-library/react'
|
|
3
|
+
import {type ReactNode, Suspense, useContext, useEffect} from 'react'
|
|
4
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
7
|
+
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
8
|
+
import {OrganizationResourcesProvider} from './OrganizationResourcesProvider'
|
|
9
|
+
import {ResourcesContext} from './ResourcesContext'
|
|
10
|
+
|
|
11
|
+
function promiseWithResolvers<T>(): {
|
|
12
|
+
promise: Promise<T>
|
|
13
|
+
resolve: (value: T) => void
|
|
14
|
+
} {
|
|
15
|
+
let resolve!: (value: T) => void
|
|
16
|
+
const promise = new Promise<T>((res) => {
|
|
17
|
+
resolve = res
|
|
18
|
+
})
|
|
19
|
+
return {promise, resolve}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reset to a fresh instance per test so the module-level WeakMap cache (keyed by
|
|
23
|
+
// instance) starts empty. `useSanityInstance` returns the same object across
|
|
24
|
+
// renders within a test, which is what the cache relies on.
|
|
25
|
+
let instance = createSanityInstance()
|
|
26
|
+
|
|
27
|
+
vi.mock('../hooks/context/useSanityInstance', () => {
|
|
28
|
+
return {useSanityInstance: () => instance}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
vi.mock('../hooks/auth/useDashboardOrganizationId', () => ({
|
|
32
|
+
useDashboardOrganizationId: vi.fn(),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
vi.mock('../utils/resolveOrgResources', () => ({
|
|
36
|
+
resolveOrgResources: vi.fn(),
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
const mockResolveOrgResources = vi.mocked(resolveOrgResources)
|
|
40
|
+
const mockUseDashboardOrganizationId = vi.mocked(useDashboardOrganizationId)
|
|
41
|
+
|
|
42
|
+
describe('OrganizationResourcesProvider', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks()
|
|
45
|
+
instance = createSanityInstance()
|
|
46
|
+
mockResolveOrgResources.mockResolvedValue({})
|
|
47
|
+
mockUseDashboardOrganizationId.mockReturnValue(undefined)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Captures ResourcesContext via useEffect (side-effect territory, not render)
|
|
51
|
+
// to satisfy the react-compiler purity rule.
|
|
52
|
+
function makeCapture() {
|
|
53
|
+
const deferred = promiseWithResolvers<Record<string, DocumentResource>>()
|
|
54
|
+
|
|
55
|
+
const CaptureContexts = () => {
|
|
56
|
+
const resources = useContext(ResourcesContext)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
deferred.resolve(resources)
|
|
59
|
+
}, [resources])
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {CaptureContexts, capturedResources: deferred.promise}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderProvider(
|
|
67
|
+
children: ReactNode,
|
|
68
|
+
options?: {
|
|
69
|
+
resources?: Record<string, DocumentResource>
|
|
70
|
+
inferMediaLibraryAndCanvas?: boolean
|
|
71
|
+
},
|
|
72
|
+
) {
|
|
73
|
+
return render(
|
|
74
|
+
<Suspense fallback={<div>loading</div>}>
|
|
75
|
+
<OrganizationResourcesProvider
|
|
76
|
+
resources={options?.resources}
|
|
77
|
+
inferMediaLibraryAndCanvas={options?.inferMediaLibraryAndCanvas}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</OrganizationResourcesProvider>
|
|
81
|
+
</Suspense>,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('does not suspend when inferMediaLibraryAndCanvas is not set', async () => {
|
|
86
|
+
const {CaptureContexts, capturedResources} = makeCapture()
|
|
87
|
+
renderProvider(<CaptureContexts />)
|
|
88
|
+
const resources = await capturedResources
|
|
89
|
+
expect(Object.keys(resources)).toHaveLength(0)
|
|
90
|
+
expect(mockResolveOrgResources).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('suspends children while org resources are being fetched, then renders with resolved resources', async () => {
|
|
94
|
+
mockUseDashboardOrganizationId.mockReturnValue('org-suspend-test')
|
|
95
|
+
const {promise, resolve} = promiseWithResolvers<void>()
|
|
96
|
+
mockResolveOrgResources.mockReturnValue(
|
|
97
|
+
promise.then(() => ({
|
|
98
|
+
mediaLibrary: {mediaLibraryId: 'ml-id'},
|
|
99
|
+
canvas: {canvasId: 'canvas-id'},
|
|
100
|
+
})) as ReturnType<typeof mockResolveOrgResources>,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const {CaptureContexts, capturedResources} = makeCapture()
|
|
104
|
+
await act(async () => {
|
|
105
|
+
renderProvider(<CaptureContexts />, {inferMediaLibraryAndCanvas: true})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
resolve()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const resources = await capturedResources
|
|
113
|
+
expect(resources['media-library']).toEqual({mediaLibraryId: 'ml-id'})
|
|
114
|
+
expect(resources['canvas']).toEqual({canvasId: 'canvas-id'})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders without inferred entries and logs a warning when resolveOrgResources rejects', async () => {
|
|
118
|
+
mockUseDashboardOrganizationId.mockReturnValue('org-warn-test')
|
|
119
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
120
|
+
mockResolveOrgResources.mockRejectedValue(new Error('fetch failed'))
|
|
121
|
+
|
|
122
|
+
const {CaptureContexts, capturedResources} = makeCapture()
|
|
123
|
+
await act(async () => {
|
|
124
|
+
renderProvider(<CaptureContexts />, {inferMediaLibraryAndCanvas: true})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const resources = await capturedResources
|
|
128
|
+
expect(resources['media-library']).toBeUndefined()
|
|
129
|
+
expect(resources['canvas']).toBeUndefined()
|
|
130
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
131
|
+
'[sanity/sdk] Failed to infer org resources:',
|
|
132
|
+
expect.any(Error),
|
|
133
|
+
)
|
|
134
|
+
consoleSpy.mockRestore()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('caches Promise references across providers for the same instance', () => {
|
|
138
|
+
mockUseDashboardOrganizationId.mockReturnValue('org-cache-test')
|
|
139
|
+
render(
|
|
140
|
+
<Suspense fallback={null}>
|
|
141
|
+
<OrganizationResourcesProvider inferMediaLibraryAndCanvas>
|
|
142
|
+
{null}
|
|
143
|
+
</OrganizationResourcesProvider>
|
|
144
|
+
</Suspense>,
|
|
145
|
+
)
|
|
146
|
+
render(
|
|
147
|
+
<Suspense fallback={null}>
|
|
148
|
+
<OrganizationResourcesProvider inferMediaLibraryAndCanvas>
|
|
149
|
+
{null}
|
|
150
|
+
</OrganizationResourcesProvider>
|
|
151
|
+
</Suspense>,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// resolveOrgResources should only have been called once across both providers.
|
|
155
|
+
expect(mockResolveOrgResources).toHaveBeenCalledTimes(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('explicit resources are passed through to ResourcesContext', async () => {
|
|
159
|
+
const explicitResources = {
|
|
160
|
+
default: {projectId: 'p1', dataset: 'prod'},
|
|
161
|
+
secondary: {projectId: 'p2', dataset: 'staging'},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const {CaptureContexts, capturedResources} = makeCapture()
|
|
165
|
+
renderProvider(<CaptureContexts />, {resources: explicitResources})
|
|
166
|
+
|
|
167
|
+
expect(await capturedResources).toEqual(explicitResources)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('explicit resources take precedence over inferred ones', async () => {
|
|
171
|
+
mockUseDashboardOrganizationId.mockReturnValue('org-override-test')
|
|
172
|
+
mockResolveOrgResources.mockResolvedValue({
|
|
173
|
+
mediaLibrary: {mediaLibraryId: 'inferred-ml'},
|
|
174
|
+
canvas: {canvasId: 'inferred-canvas'},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const explicitResources = {'media-library': {mediaLibraryId: 'explicit-ml'}}
|
|
178
|
+
const {CaptureContexts, capturedResources} = makeCapture()
|
|
179
|
+
await act(async () => {
|
|
180
|
+
renderProvider(<CaptureContexts />, {
|
|
181
|
+
resources: explicitResources,
|
|
182
|
+
inferMediaLibraryAndCanvas: true,
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const resources = await capturedResources
|
|
187
|
+
expect(resources['media-library']).toEqual({mediaLibraryId: 'explicit-ml'})
|
|
188
|
+
})
|
|
189
|
+
})
|