@sanity/sdk-react 2.5.0 → 2.7.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 +164 -19
- package/dist/index.d.ts +571 -26
- package/dist/index.js +149 -78
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/_exports/sdk-react.ts +2 -0
- package/src/components/SDKProvider.tsx +8 -3
- package/src/components/SanityApp.test.tsx +72 -2
- package/src/components/SanityApp.tsx +53 -10
- package/src/components/auth/AuthBoundary.tsx +5 -5
- package/src/context/ComlinkTokenRefresh.test.tsx +2 -2
- package/src/context/ComlinkTokenRefresh.tsx +3 -2
- package/src/context/SDKStudioContext.test.tsx +126 -0
- package/src/context/SDKStudioContext.ts +65 -0
- package/src/context/SourcesContext.tsx +7 -0
- package/src/context/renderSanityApp.test.tsx +355 -0
- package/src/context/renderSanityApp.tsx +48 -0
- package/src/hooks/agent/agentActions.ts +436 -21
- package/src/hooks/dashboard/useDispatchIntent.test.ts +26 -20
- package/src/hooks/dashboard/useDispatchIntent.ts +10 -11
- package/src/hooks/dashboard/utils/{getResourceIdFromDocumentHandle.test.ts → useResourceIdFromDocumentHandle.test.ts} +33 -60
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +46 -0
- package/src/hooks/document/useEditDocument.ts +3 -0
- package/src/hooks/documents/useDocuments.ts +3 -2
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +85 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +1 -0
- package/src/hooks/projection/useDocumentProjection.ts +15 -4
- package/src/hooks/query/useQuery.ts +30 -11
- package/src/hooks/dashboard/types.ts +0 -12
- package/src/hooks/dashboard/utils/getResourceIdFromDocumentHandle.ts +0 -53
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {type SanityConfig} from '@sanity/sdk'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {SanityApp} from '../components/SanityApp'
|
|
5
|
+
import {renderSanityApp} from './renderSanityApp'
|
|
6
|
+
|
|
7
|
+
// Hoist the mock functions
|
|
8
|
+
const mockRender = vi.hoisted(() => vi.fn())
|
|
9
|
+
const mockUnmount = vi.hoisted(() => vi.fn())
|
|
10
|
+
const mockCreateRoot = vi.hoisted(() =>
|
|
11
|
+
vi.fn(() => ({
|
|
12
|
+
render: mockRender,
|
|
13
|
+
unmount: mockUnmount,
|
|
14
|
+
})),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// Mock the SanityApp component
|
|
18
|
+
vi.mock('../components/SanityApp', () => ({
|
|
19
|
+
SanityApp: vi.fn(({children}) => <div data-testid="sanity-app">{children}</div>),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// Mock react-dom/client
|
|
23
|
+
vi.mock('react-dom/client', () => ({
|
|
24
|
+
createRoot: mockCreateRoot,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
describe('renderSanityApp', () => {
|
|
28
|
+
let rootElement: HTMLElement | null
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks()
|
|
32
|
+
mockRender.mockClear()
|
|
33
|
+
mockUnmount.mockClear()
|
|
34
|
+
mockCreateRoot.mockClear()
|
|
35
|
+
rootElement = document.createElement('div')
|
|
36
|
+
document.body.appendChild(rootElement)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (rootElement && rootElement.parentNode) {
|
|
41
|
+
document.body.removeChild(rootElement)
|
|
42
|
+
}
|
|
43
|
+
rootElement = null
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws error when rootElement is null', () => {
|
|
47
|
+
const namedSources = {
|
|
48
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(() => renderSanityApp(null, namedSources, {}, <div>Test</div>)).toThrowError(
|
|
52
|
+
'Missing root element to mount application into',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('creates root with the provided element', () => {
|
|
57
|
+
const namedSources = {
|
|
58
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
62
|
+
|
|
63
|
+
expect(mockCreateRoot).toHaveBeenCalledWith(rootElement)
|
|
64
|
+
expect(mockCreateRoot).toHaveBeenCalledTimes(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('converts namedSources object to array of configs', () => {
|
|
68
|
+
const namedSources = {
|
|
69
|
+
main: {projectId: 'project-1', dataset: 'production'},
|
|
70
|
+
secondary: {projectId: 'project-2', dataset: 'staging'},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
74
|
+
|
|
75
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
76
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
77
|
+
expect(renderCall).toBeDefined()
|
|
78
|
+
|
|
79
|
+
// The renderCall is the SanityApp component directly when not using StrictMode
|
|
80
|
+
expect(renderCall.type).toBe(SanityApp)
|
|
81
|
+
expect(renderCall.props.config).toEqual([
|
|
82
|
+
{projectId: 'project-1', dataset: 'production'},
|
|
83
|
+
{projectId: 'project-2', dataset: 'staging'},
|
|
84
|
+
])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders without StrictMode when reactStrictMode is false', () => {
|
|
88
|
+
const namedSources = {
|
|
89
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
90
|
+
}
|
|
91
|
+
const children = <div>Test Children</div>
|
|
92
|
+
|
|
93
|
+
renderSanityApp(rootElement, namedSources, {reactStrictMode: false}, children)
|
|
94
|
+
|
|
95
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
96
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
97
|
+
|
|
98
|
+
// Should not have StrictMode wrapper
|
|
99
|
+
expect(renderCall.type).toBe(SanityApp)
|
|
100
|
+
expect(renderCall.props.children).toEqual(children)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders without StrictMode by default', () => {
|
|
104
|
+
const namedSources = {
|
|
105
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
106
|
+
}
|
|
107
|
+
const children = <div>Test Children</div>
|
|
108
|
+
|
|
109
|
+
renderSanityApp(rootElement, namedSources, {}, children)
|
|
110
|
+
|
|
111
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
112
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
113
|
+
|
|
114
|
+
// Should not have StrictMode wrapper when not specified
|
|
115
|
+
expect(renderCall.type).toBe(SanityApp)
|
|
116
|
+
expect(renderCall.props.children).toEqual(children)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('renders with StrictMode when reactStrictMode is true', () => {
|
|
120
|
+
const namedSources = {
|
|
121
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
122
|
+
}
|
|
123
|
+
const children = <div>Test Children</div>
|
|
124
|
+
|
|
125
|
+
renderSanityApp(rootElement, namedSources, {reactStrictMode: true}, children)
|
|
126
|
+
|
|
127
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
128
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
129
|
+
|
|
130
|
+
// Should have StrictMode wrapper (StrictMode is a Symbol in React 18)
|
|
131
|
+
expect(renderCall.type).toBeDefined()
|
|
132
|
+
expect(renderCall.type.toString()).toContain('Symbol')
|
|
133
|
+
const strictModeChild = renderCall.props.children
|
|
134
|
+
expect(strictModeChild.type).toBe(SanityApp)
|
|
135
|
+
expect(strictModeChild.props.children).toEqual(children)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('passes loading fallback to SanityApp', () => {
|
|
139
|
+
const namedSources = {
|
|
140
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
144
|
+
|
|
145
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
146
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
147
|
+
const sanityAppElement = renderCall
|
|
148
|
+
|
|
149
|
+
expect(sanityAppElement.type).toBe(SanityApp)
|
|
150
|
+
expect(sanityAppElement.props.fallback).toEqual(<div>Loading...</div>)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns an unmount function', () => {
|
|
154
|
+
const namedSources = {
|
|
155
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const unmount = renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
159
|
+
|
|
160
|
+
expect(typeof unmount).toBe('function')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('calls root.unmount when unmount function is invoked', () => {
|
|
164
|
+
const namedSources = {
|
|
165
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const unmount = renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
169
|
+
|
|
170
|
+
expect(mockUnmount).not.toHaveBeenCalled()
|
|
171
|
+
unmount()
|
|
172
|
+
expect(mockUnmount).toHaveBeenCalledTimes(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('handles empty namedSources object', () => {
|
|
176
|
+
const namedSources = {}
|
|
177
|
+
|
|
178
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
179
|
+
|
|
180
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
181
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
182
|
+
const sanityAppElement = renderCall
|
|
183
|
+
|
|
184
|
+
expect(sanityAppElement.type).toBe(SanityApp)
|
|
185
|
+
expect(sanityAppElement.props.config).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('handles single namedSource', () => {
|
|
189
|
+
const namedSources = {
|
|
190
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
194
|
+
|
|
195
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
196
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
197
|
+
const sanityAppElement = renderCall
|
|
198
|
+
|
|
199
|
+
expect(sanityAppElement.type).toBe(SanityApp)
|
|
200
|
+
expect(sanityAppElement.props.config).toEqual([
|
|
201
|
+
{projectId: 'test-project', dataset: 'production'},
|
|
202
|
+
])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('handles multiple namedSources', () => {
|
|
206
|
+
const namedSources = {
|
|
207
|
+
main: {projectId: 'project-1', dataset: 'production'},
|
|
208
|
+
blog: {projectId: 'project-2', dataset: 'staging'},
|
|
209
|
+
ecommerce: {projectId: 'project-3', dataset: 'development'},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
213
|
+
|
|
214
|
+
expect(mockRender).toHaveBeenCalledTimes(1)
|
|
215
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
216
|
+
const sanityAppElement = renderCall
|
|
217
|
+
|
|
218
|
+
expect(sanityAppElement.type).toBe(SanityApp)
|
|
219
|
+
expect(sanityAppElement.props.config).toHaveLength(3)
|
|
220
|
+
expect(sanityAppElement.props.config).toEqual([
|
|
221
|
+
{projectId: 'project-1', dataset: 'production'},
|
|
222
|
+
{projectId: 'project-2', dataset: 'staging'},
|
|
223
|
+
{projectId: 'project-3', dataset: 'development'},
|
|
224
|
+
])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('preserves order of namedSources in config array', () => {
|
|
228
|
+
const namedSources = {
|
|
229
|
+
z: {projectId: 'project-z', dataset: 'z-dataset'},
|
|
230
|
+
a: {projectId: 'project-a', dataset: 'a-dataset'},
|
|
231
|
+
m: {projectId: 'project-m', dataset: 'm-dataset'},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
renderSanityApp(rootElement, namedSources, {}, <div>Test</div>)
|
|
235
|
+
|
|
236
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
237
|
+
const sanityAppElement = renderCall
|
|
238
|
+
|
|
239
|
+
// Object.values preserves insertion order in modern JS
|
|
240
|
+
expect(sanityAppElement.props.config).toEqual([
|
|
241
|
+
{projectId: 'project-z', dataset: 'z-dataset'},
|
|
242
|
+
{projectId: 'project-a', dataset: 'a-dataset'},
|
|
243
|
+
{projectId: 'project-m', dataset: 'm-dataset'},
|
|
244
|
+
])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('passes children to SanityApp', () => {
|
|
248
|
+
const namedSources = {
|
|
249
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
250
|
+
}
|
|
251
|
+
const children = (
|
|
252
|
+
<div>
|
|
253
|
+
<h1>Test App</h1>
|
|
254
|
+
<p>Content</p>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
renderSanityApp(rootElement, namedSources, {}, children)
|
|
259
|
+
|
|
260
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
261
|
+
const sanityAppElement = renderCall
|
|
262
|
+
|
|
263
|
+
expect(sanityAppElement.props.children).toEqual(children)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('works with different types of children', () => {
|
|
267
|
+
const namedSources = {
|
|
268
|
+
main: {projectId: 'test-project', dataset: 'production'},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Test with string children
|
|
272
|
+
renderSanityApp(rootElement, namedSources, {}, 'String child')
|
|
273
|
+
|
|
274
|
+
let renderCall = mockRender.mock.calls[0][0]
|
|
275
|
+
let sanityAppElement = renderCall
|
|
276
|
+
|
|
277
|
+
expect(sanityAppElement.props.children).toBe('String child')
|
|
278
|
+
|
|
279
|
+
// Test with null children
|
|
280
|
+
mockRender.mockClear()
|
|
281
|
+
renderSanityApp(rootElement, namedSources, {}, null)
|
|
282
|
+
|
|
283
|
+
renderCall = mockRender.mock.calls[0][0]
|
|
284
|
+
sanityAppElement = renderCall
|
|
285
|
+
|
|
286
|
+
expect(sanityAppElement.props.children).toBe(null)
|
|
287
|
+
|
|
288
|
+
// Test with array of children
|
|
289
|
+
mockRender.mockClear()
|
|
290
|
+
const arrayChildren = [<div key="1">Child 1</div>, <div key="2">Child 2</div>]
|
|
291
|
+
renderSanityApp(rootElement, namedSources, {}, arrayChildren)
|
|
292
|
+
|
|
293
|
+
renderCall = mockRender.mock.calls[0][0]
|
|
294
|
+
sanityAppElement = renderCall
|
|
295
|
+
|
|
296
|
+
expect(sanityAppElement.props.children).toEqual(arrayChildren)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('integrates with StrictMode and passes all props correctly', () => {
|
|
300
|
+
const namedSources = {
|
|
301
|
+
main: {
|
|
302
|
+
projectId: 'test-project',
|
|
303
|
+
dataset: 'production',
|
|
304
|
+
apiVersion: '2023-01-01',
|
|
305
|
+
} as SanityConfig,
|
|
306
|
+
secondary: {
|
|
307
|
+
projectId: 'test-project-2',
|
|
308
|
+
dataset: 'staging',
|
|
309
|
+
} as SanityConfig,
|
|
310
|
+
}
|
|
311
|
+
const children = <div>App Content</div>
|
|
312
|
+
|
|
313
|
+
renderSanityApp(rootElement, namedSources, {reactStrictMode: true}, children)
|
|
314
|
+
|
|
315
|
+
const renderCall = mockRender.mock.calls[0][0]
|
|
316
|
+
|
|
317
|
+
// Verify StrictMode wrapper (StrictMode is a Symbol in React 18)
|
|
318
|
+
expect(renderCall.type).toBeDefined()
|
|
319
|
+
expect(renderCall.type.toString()).toContain('Symbol')
|
|
320
|
+
|
|
321
|
+
// Verify SanityApp is inside StrictMode
|
|
322
|
+
const strictModeChild = renderCall.props.children
|
|
323
|
+
expect(strictModeChild.type).toBe(SanityApp)
|
|
324
|
+
|
|
325
|
+
// Verify all props are passed correctly
|
|
326
|
+
expect(strictModeChild.props.config).toEqual([
|
|
327
|
+
{projectId: 'test-project', dataset: 'production', apiVersion: '2023-01-01'},
|
|
328
|
+
{projectId: 'test-project-2', dataset: 'staging'},
|
|
329
|
+
])
|
|
330
|
+
expect(strictModeChild.props.fallback).toEqual(<div>Loading...</div>)
|
|
331
|
+
expect(strictModeChild.props.children).toEqual(children)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('can be called multiple times with different roots', () => {
|
|
335
|
+
const rootElement2 = document.createElement('div')
|
|
336
|
+
document.body.appendChild(rootElement2)
|
|
337
|
+
|
|
338
|
+
const namedSources1 = {main: {projectId: 'project-1', dataset: 'production'}}
|
|
339
|
+
const namedSources2 = {main: {projectId: 'project-2', dataset: 'staging'}}
|
|
340
|
+
|
|
341
|
+
const unmount1 = renderSanityApp(rootElement, namedSources1, {}, <div>App 1</div>)
|
|
342
|
+
const unmount2 = renderSanityApp(rootElement2, namedSources2, {}, <div>App 2</div>)
|
|
343
|
+
|
|
344
|
+
expect(mockCreateRoot).toHaveBeenCalledTimes(2)
|
|
345
|
+
expect(mockCreateRoot).toHaveBeenNthCalledWith(1, rootElement)
|
|
346
|
+
expect(mockCreateRoot).toHaveBeenNthCalledWith(2, rootElement2)
|
|
347
|
+
|
|
348
|
+
unmount1()
|
|
349
|
+
unmount2()
|
|
350
|
+
|
|
351
|
+
expect(mockUnmount).toHaveBeenCalledTimes(2)
|
|
352
|
+
|
|
353
|
+
document.body.removeChild(rootElement2)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {type SanityConfig} from '@sanity/sdk'
|
|
2
|
+
import {StrictMode} from 'react'
|
|
3
|
+
import {createRoot} from 'react-dom/client'
|
|
4
|
+
|
|
5
|
+
import {SanityApp} from '../components/SanityApp'
|
|
6
|
+
|
|
7
|
+
interface RenderSanitySDKAppOptions {
|
|
8
|
+
reactStrictMode?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** In-flight CLI PR is using named sources since it's aspirational.
|
|
12
|
+
* We can transform the shape in this function until it's finalized.
|
|
13
|
+
*/
|
|
14
|
+
interface NamedSources {
|
|
15
|
+
[key: string]: SanityConfig
|
|
16
|
+
}
|
|
17
|
+
/** @internal */
|
|
18
|
+
export function renderSanityApp(
|
|
19
|
+
rootElement: HTMLElement | null,
|
|
20
|
+
namedSources: NamedSources,
|
|
21
|
+
options: RenderSanitySDKAppOptions,
|
|
22
|
+
children: React.ReactNode,
|
|
23
|
+
): () => void {
|
|
24
|
+
if (!rootElement) {
|
|
25
|
+
throw new Error('Missing root element to mount application into')
|
|
26
|
+
}
|
|
27
|
+
const {reactStrictMode = false} = options
|
|
28
|
+
|
|
29
|
+
const root = createRoot(rootElement)
|
|
30
|
+
const config = Object.values(namedSources)
|
|
31
|
+
|
|
32
|
+
root.render(
|
|
33
|
+
reactStrictMode ? (
|
|
34
|
+
<StrictMode>
|
|
35
|
+
{/* TODO: think about a loading component we want to be "universal" */}
|
|
36
|
+
<SanityApp config={config} fallback={<div>Loading...</div>}>
|
|
37
|
+
{children}
|
|
38
|
+
</SanityApp>
|
|
39
|
+
</StrictMode>
|
|
40
|
+
) : (
|
|
41
|
+
<SanityApp config={config} fallback={<div>Loading...</div>}>
|
|
42
|
+
{children}
|
|
43
|
+
</SanityApp>
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return () => root.unmount()
|
|
48
|
+
}
|