@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.
Files changed (30) hide show
  1. package/README.md +164 -19
  2. package/dist/index.d.ts +571 -26
  3. package/dist/index.js +149 -78
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -7
  6. package/src/_exports/sdk-react.ts +2 -0
  7. package/src/components/SDKProvider.tsx +8 -3
  8. package/src/components/SanityApp.test.tsx +72 -2
  9. package/src/components/SanityApp.tsx +53 -10
  10. package/src/components/auth/AuthBoundary.tsx +5 -5
  11. package/src/context/ComlinkTokenRefresh.test.tsx +2 -2
  12. package/src/context/ComlinkTokenRefresh.tsx +3 -2
  13. package/src/context/SDKStudioContext.test.tsx +126 -0
  14. package/src/context/SDKStudioContext.ts +65 -0
  15. package/src/context/SourcesContext.tsx +7 -0
  16. package/src/context/renderSanityApp.test.tsx +355 -0
  17. package/src/context/renderSanityApp.tsx +48 -0
  18. package/src/hooks/agent/agentActions.ts +436 -21
  19. package/src/hooks/dashboard/useDispatchIntent.test.ts +26 -20
  20. package/src/hooks/dashboard/useDispatchIntent.ts +10 -11
  21. package/src/hooks/dashboard/utils/{getResourceIdFromDocumentHandle.test.ts → useResourceIdFromDocumentHandle.test.ts} +33 -60
  22. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +46 -0
  23. package/src/hooks/document/useEditDocument.ts +3 -0
  24. package/src/hooks/documents/useDocuments.ts +3 -2
  25. package/src/hooks/helpers/useNormalizedSourceOptions.ts +85 -0
  26. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +1 -0
  27. package/src/hooks/projection/useDocumentProjection.ts +15 -4
  28. package/src/hooks/query/useQuery.ts +30 -11
  29. package/src/hooks/dashboard/types.ts +0 -12
  30. 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
+ }