@sanity/sdk-react 2.8.0 → 2.9.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 (35) hide show
  1. package/dist/index.d.ts +144 -25
  2. package/dist/index.js +216 -122
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -7
  5. package/src/_exports/sdk-react.ts +1 -0
  6. package/src/components/SanityApp.tsx +1 -0
  7. package/src/context/ResourceProvider.test.tsx +7 -1
  8. package/src/context/ResourceProvider.tsx +6 -0
  9. package/src/context/SDKStudioContext.ts +6 -0
  10. package/src/hooks/dashboard/useDispatchIntent.test.ts +2 -0
  11. package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
  12. package/src/hooks/dashboard/useWindowTitle.ts +112 -0
  13. package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
  14. package/src/hooks/document/useApplyDocumentActions.ts +99 -3
  15. package/src/hooks/document/useDocument.ts +22 -6
  16. package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
  17. package/src/hooks/document/useDocumentEvent.ts +10 -3
  18. package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
  19. package/src/hooks/document/useDocumentPermissions.ts +22 -0
  20. package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
  21. package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
  22. package/src/hooks/document/useEditDocument.ts +34 -8
  23. package/src/hooks/documents/useDocuments.ts +2 -0
  24. package/src/hooks/helpers/useNormalizedSourceOptions.ts +50 -28
  25. package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
  26. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -0
  27. package/src/hooks/presence/usePresence.ts +2 -0
  28. package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
  29. package/src/hooks/preview/useDocumentPreview.tsx +39 -55
  30. package/src/hooks/projection/useDocumentProjection.ts +2 -0
  31. package/src/hooks/query/useQuery.ts +2 -0
  32. package/src/hooks/releases/useActiveReleases.ts +32 -13
  33. package/src/hooks/releases/usePerspective.ts +26 -14
  34. package/src/hooks/users/useUser.ts +2 -0
  35. package/src/hooks/users/useUsers.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -52,7 +52,7 @@
52
52
  "react-compiler-runtime": "19.1.0-rc.2",
53
53
  "react-error-boundary": "^5.0.0",
54
54
  "rxjs": "^7.8.2",
55
- "@sanity/sdk": "2.8.0"
55
+ "@sanity/sdk": "2.9.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@sanity/browserslist-config": "^1.0.5",
@@ -75,13 +75,13 @@
75
75
  "react-dom": "^19.2.1",
76
76
  "rollup-plugin-visualizer": "^5.14.0",
77
77
  "typescript": "^5.8.3",
78
- "vite": "^6.3.4",
78
+ "vite": "^7.0.0",
79
79
  "vitest": "^3.2.4",
80
- "@repo/package.bundle": "3.82.0",
81
- "@repo/config-eslint": "0.0.0",
82
- "@repo/config-test": "0.0.1",
80
+ "@repo/tsconfig": "0.0.1",
83
81
  "@repo/package.config": "0.0.1",
84
- "@repo/tsconfig": "0.0.1"
82
+ "@repo/config-test": "0.0.1",
83
+ "@repo/package.bundle": "3.82.0",
84
+ "@repo/config-eslint": "0.0.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "^18.0.0 || ^19.0.0",
@@ -50,6 +50,7 @@ export {
50
50
  } from '../hooks/dashboard/useNavigateToStudioDocument'
51
51
  export {useRecordDocumentHistoryEvent} from '../hooks/dashboard/useRecordDocumentHistoryEvent'
52
52
  export {useStudioWorkspacesByProjectIdDataset} from '../hooks/dashboard/useStudioWorkspacesByProjectIdDataset'
53
+ export {useWindowTitle} from '../hooks/dashboard/useWindowTitle'
53
54
  export {useDatasets} from '../hooks/datasets/useDatasets'
54
55
  export {useApplyDocumentActions} from '../hooks/document/useApplyDocumentActions'
55
56
  export {useDocument} from '../hooks/document/useDocument'
@@ -37,6 +37,7 @@ function deriveConfigFromWorkspace(workspace: StudioWorkspaceHandle): SanityConf
37
37
  projectId: workspace.projectId,
38
38
  dataset: workspace.dataset,
39
39
  studio: {
40
+ authenticated: workspace.authenticated,
40
41
  auth: workspace.auth.token ? {token: workspace.auth.token} : undefined,
41
42
  },
42
43
  }
@@ -1,7 +1,7 @@
1
1
  import {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
- import {describe, expect, it} from 'vitest'
4
+ import {describe, expect, it, vi} from 'vitest'
5
5
 
6
6
  import {ResourceProvider} from './ResourceProvider'
7
7
  import {SanityInstanceContext} from './SanityInstanceContext'
@@ -37,6 +37,7 @@ describe('ResourceProvider', () => {
37
37
  })
38
38
 
39
39
  it('shows fallback during loading', async () => {
40
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
40
41
  const {promise, resolve} = promiseWithResolvers()
41
42
  function SuspendingChild(): React.ReactNode {
42
43
  throw promise
@@ -52,6 +53,8 @@ describe('ResourceProvider', () => {
52
53
  act(() => {
53
54
  resolve()
54
55
  })
56
+ await new Promise((r) => setTimeout(r, 0))
57
+ consoleSpy.mockRestore()
55
58
  })
56
59
 
57
60
  it('creates root instance when no parent context exists', async () => {
@@ -141,6 +144,7 @@ describe('ResourceProvider', () => {
141
144
  })
142
145
 
143
146
  it('uses default fallback when none provided', async () => {
147
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
144
148
  const {promise, resolve} = promiseWithResolvers()
145
149
  function SuspendingChild(): React.ReactNode {
146
150
  throw promise
@@ -157,5 +161,7 @@ describe('ResourceProvider', () => {
157
161
  act(() => {
158
162
  resolve()
159
163
  })
164
+ await new Promise((r) => setTimeout(r, 0))
165
+ consoleSpy.mockRestore()
160
166
  })
161
167
  })
@@ -1,4 +1,5 @@
1
1
  import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
+ import {initTelemetry} from '@sanity/sdk/_internal'
2
3
  import {Suspense, useContext, useEffect, useMemo, useRef} from 'react'
3
4
 
4
5
  import {SanityInstanceContext} from './SanityInstanceContext'
@@ -78,6 +79,11 @@ export function ResourceProvider({
78
79
  [config, parent],
79
80
  )
80
81
 
82
+ const projectId = config.projectId ?? ''
83
+ useMemo(() => {
84
+ if (projectId && !parent) initTelemetry(instance, projectId)
85
+ }, [instance, projectId, parent])
86
+
81
87
  // Ref to hold the scheduled disposal timer.
82
88
  const disposal = useRef<{
83
89
  instance: SanityInstance
@@ -13,6 +13,12 @@ export interface StudioWorkspaceHandle {
13
13
  projectId: string
14
14
  /** The dataset name for this workspace. */
15
15
  dataset: string
16
+ /**
17
+ * Whether the Studio has determined the user is authenticated.
18
+ * When `true` and the token source emits `null`, the SDK infers
19
+ * cookie-based auth is in use and skips the logged-out state.
20
+ */
21
+ authenticated?: boolean
16
22
  /** Authentication state for this workspace. */
17
23
  auth: {
18
24
  /**
@@ -37,11 +37,13 @@ describe('useDispatchIntent', () => {
37
37
  })
38
38
 
39
39
  it('should throw error when neither action nor intentId is provided', () => {
40
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
40
41
  const {result} = renderHook(() => useDispatchIntent({documentHandle: mockDocumentHandle}))
41
42
 
42
43
  expect(() => result.current.dispatchIntent()).toThrow(
43
44
  'useDispatchIntent: Either `action` or `intentId` must be provided.',
44
45
  )
46
+ consoleErrorSpy.mockRestore()
45
47
  })
46
48
 
47
49
  it('should handle errors gracefully', () => {
@@ -0,0 +1,213 @@
1
+ import {renderHook, waitFor} from '@testing-library/react'
2
+ import {afterEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {useWindowConnection} from '../comlink/useWindowConnection'
5
+ import {useWindowTitle} from './useWindowTitle'
6
+
7
+ vi.mock('../comlink/useWindowConnection', () => ({
8
+ useWindowConnection: vi.fn(),
9
+ }))
10
+
11
+ function createContextResponse(resource: Record<string, unknown>) {
12
+ return {context: {resource}}
13
+ }
14
+
15
+ describe('useWindowTitle', () => {
16
+ afterEach(() => {
17
+ vi.clearAllMocks()
18
+ document.title = ''
19
+ })
20
+
21
+ it('should set the document title to the manifest title', async () => {
22
+ const mockFetch = vi.fn().mockResolvedValue(
23
+ createContextResponse({
24
+ type: 'application',
25
+ title: 'sdk-movie-list',
26
+ manifest: {title: 'Movie List App'},
27
+ }),
28
+ )
29
+ vi.mocked(useWindowConnection).mockReturnValue({
30
+ fetch: mockFetch,
31
+ sendMessage: vi.fn(),
32
+ })
33
+
34
+ renderHook(() => useWindowTitle())
35
+
36
+ await waitFor(() => {
37
+ expect(document.title).toBe('Movie List App')
38
+ })
39
+ })
40
+
41
+ it('should prefer manifest title over system title', async () => {
42
+ const mockFetch = vi.fn().mockResolvedValue(
43
+ createContextResponse({
44
+ type: 'application',
45
+ title: 'sdk-movie-list',
46
+ manifest: {title: 'Movie List App'},
47
+ activeDeployment: {manifest: {title: 'Deployed Title'}},
48
+ }),
49
+ )
50
+ vi.mocked(useWindowConnection).mockReturnValue({
51
+ fetch: mockFetch,
52
+ sendMessage: vi.fn(),
53
+ })
54
+
55
+ renderHook(() => useWindowTitle())
56
+
57
+ await waitFor(() => {
58
+ expect(document.title).toBe('Movie List App')
59
+ })
60
+ })
61
+
62
+ it('should fall back to activeDeployment manifest title', async () => {
63
+ const mockFetch = vi.fn().mockResolvedValue(
64
+ createContextResponse({
65
+ type: 'application',
66
+ title: 'sdk-movie-list',
67
+ manifest: null,
68
+ activeDeployment: {manifest: {title: 'Deployed Title'}},
69
+ }),
70
+ )
71
+ vi.mocked(useWindowConnection).mockReturnValue({
72
+ fetch: mockFetch,
73
+ sendMessage: vi.fn(),
74
+ })
75
+
76
+ renderHook(() => useWindowTitle())
77
+
78
+ await waitFor(() => {
79
+ expect(document.title).toBe('Deployed Title')
80
+ })
81
+ })
82
+
83
+ it('should fall back to system title when no manifest title exists', async () => {
84
+ const mockFetch = vi.fn().mockResolvedValue(
85
+ createContextResponse({
86
+ type: 'application',
87
+ title: 'sdk-movie-list',
88
+ manifest: null,
89
+ activeDeployment: null,
90
+ }),
91
+ )
92
+ vi.mocked(useWindowConnection).mockReturnValue({
93
+ fetch: mockFetch,
94
+ sendMessage: vi.fn(),
95
+ })
96
+
97
+ renderHook(() => useWindowTitle())
98
+
99
+ await waitFor(() => {
100
+ expect(document.title).toBe('sdk-movie-list')
101
+ })
102
+ })
103
+
104
+ it('should prepend view title when provided', async () => {
105
+ const mockFetch = vi.fn().mockResolvedValue(
106
+ createContextResponse({
107
+ type: 'application',
108
+ title: 'sdk-movie-list',
109
+ manifest: {title: 'Movie List App'},
110
+ }),
111
+ )
112
+ vi.mocked(useWindowConnection).mockReturnValue({
113
+ fetch: mockFetch,
114
+ sendMessage: vi.fn(),
115
+ })
116
+
117
+ renderHook(() => useWindowTitle('Movies'))
118
+
119
+ await waitFor(() => {
120
+ expect(document.title).toBe('Movies | Movie List App')
121
+ })
122
+ })
123
+
124
+ it('should update when view title changes', async () => {
125
+ const mockFetch = vi.fn().mockResolvedValue(
126
+ createContextResponse({
127
+ type: 'application',
128
+ title: 'sdk-movie-list',
129
+ manifest: {title: 'Movie List App'},
130
+ }),
131
+ )
132
+ vi.mocked(useWindowConnection).mockReturnValue({
133
+ fetch: mockFetch,
134
+ sendMessage: vi.fn(),
135
+ })
136
+
137
+ const {rerender} = renderHook(({viewTitle}) => useWindowTitle(viewTitle), {
138
+ initialProps: {viewTitle: 'Movies'},
139
+ })
140
+
141
+ await waitFor(() => {
142
+ expect(document.title).toBe('Movies | Movie List App')
143
+ })
144
+
145
+ rerender({viewTitle: 'Movie Details'})
146
+
147
+ await waitFor(() => {
148
+ expect(document.title).toBe('Movie Details | Movie List App')
149
+ })
150
+ })
151
+
152
+ it('should restore the previous title on unmount', async () => {
153
+ document.title = 'Previous Title'
154
+
155
+ const mockFetch = vi.fn().mockResolvedValue(
156
+ createContextResponse({
157
+ type: 'application',
158
+ title: 'sdk-movie-list',
159
+ manifest: {title: 'Movie List App'},
160
+ }),
161
+ )
162
+ vi.mocked(useWindowConnection).mockReturnValue({
163
+ fetch: mockFetch,
164
+ sendMessage: vi.fn(),
165
+ })
166
+
167
+ const {unmount} = renderHook(() => useWindowTitle('Movies'))
168
+
169
+ await waitFor(() => {
170
+ expect(document.title).toBe('Movies | Movie List App')
171
+ })
172
+
173
+ unmount()
174
+
175
+ expect(document.title).toBe('Previous Title')
176
+ })
177
+
178
+ it('should handle AbortError silently', async () => {
179
+ const abortError = new Error('Aborted')
180
+ abortError.name = 'AbortError'
181
+ const mockFetch = vi.fn().mockRejectedValue(abortError)
182
+ vi.mocked(useWindowConnection).mockReturnValue({
183
+ fetch: mockFetch,
184
+ sendMessage: vi.fn(),
185
+ })
186
+
187
+ renderHook(() => useWindowTitle())
188
+
189
+ await waitFor(() => {
190
+ expect(document.title).toBe('')
191
+ })
192
+ })
193
+
194
+ it('should log non-abort fetch errors', async () => {
195
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
196
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Connection failed'))
197
+ vi.mocked(useWindowConnection).mockReturnValue({
198
+ fetch: mockFetch,
199
+ sendMessage: vi.fn(),
200
+ })
201
+
202
+ renderHook(() => useWindowTitle('Movies'))
203
+
204
+ await waitFor(() => {
205
+ expect(consoleSpy).toHaveBeenCalledWith(
206
+ 'Failed to fetch app title from dashboard context:',
207
+ expect.any(Error),
208
+ )
209
+ })
210
+
211
+ consoleSpy.mockRestore()
212
+ })
213
+ })
@@ -0,0 +1,112 @@
1
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
2
+ import {useEffect, useState} from 'react'
3
+
4
+ import {useWindowConnection} from '../comlink/useWindowConnection'
5
+
6
+ interface ContextResource {
7
+ type: string
8
+ title?: string
9
+ manifest?: {
10
+ title?: string
11
+ } | null
12
+ activeDeployment?: {
13
+ manifest?: {
14
+ title?: string
15
+ } | null
16
+ } | null
17
+ }
18
+
19
+ interface ContextResponse {
20
+ context: {
21
+ resource: ContextResource
22
+ }
23
+ }
24
+
25
+ function resolveAppTitle(resource: ContextResource): string | undefined {
26
+ return resource.manifest?.title ?? resource.activeDeployment?.manifest?.title ?? resource.title
27
+ }
28
+
29
+ /**
30
+ * Sets the browser's document title, automatically including the app's name
31
+ * from the manifest.
32
+ *
33
+ * This follows the same convention as Sanity Studio workspaces, where the
34
+ * workspace name is always present in the title:
35
+ *
36
+ * - With a view title: `<viewTitle> | <appTitle>`
37
+ * - Without a view title: `<appTitle>`
38
+ *
39
+ * The Sanity dashboard appends `| Sanity` to produce the final browser tab title.
40
+ *
41
+ * @param viewTitle - An optional view-specific title to prepend to the app title.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * import {useWindowTitle} from '@sanity/sdk-react'
46
+ *
47
+ * function MoviesList() {
48
+ * useWindowTitle('Movies')
49
+ * return <div>...</div>
50
+ * }
51
+ *
52
+ * // Browser tab: "Movies | My App | Sanity"
53
+ * ```
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * // Call without arguments to show just the app title
58
+ * function AppRoot() {
59
+ * useWindowTitle()
60
+ * return <Outlet />
61
+ * }
62
+ *
63
+ * // Browser tab: "My App | Sanity"
64
+ * ```
65
+ *
66
+ * @public
67
+ */
68
+ export function useWindowTitle(viewTitle?: string): void {
69
+ const [appTitle, setAppTitle] = useState<string | null>(null)
70
+
71
+ const {fetch} = useWindowConnection({
72
+ name: SDK_NODE_NAME,
73
+ connectTo: SDK_CHANNEL_NAME,
74
+ })
75
+
76
+ useEffect(() => {
77
+ if (!fetch) return
78
+
79
+ const controller = new AbortController()
80
+
81
+ async function fetchAppTitle(signal: AbortSignal) {
82
+ try {
83
+ const data = await fetch<ContextResponse>('dashboard/v1/context', undefined, {signal})
84
+ const title = resolveAppTitle(data.context.resource)
85
+ if (title) {
86
+ setAppTitle(title)
87
+ }
88
+ } catch (err: unknown) {
89
+ if (err instanceof Error && err.name === 'AbortError') return
90
+ // eslint-disable-next-line no-console
91
+ console.error('Failed to fetch app title from dashboard context:', err)
92
+ }
93
+ }
94
+
95
+ fetchAppTitle(controller.signal)
96
+
97
+ return () => {
98
+ controller.abort()
99
+ }
100
+ }, [fetch])
101
+
102
+ useEffect(() => {
103
+ if (!appTitle) return
104
+
105
+ const previous = document.title
106
+ document.title = viewTitle ? `${viewTitle} | ${appTitle}` : appTitle
107
+
108
+ return () => {
109
+ document.title = previous
110
+ }
111
+ }, [viewTitle, appTitle])
112
+ }
@@ -1,6 +1,7 @@
1
1
  import {applyDocumentActions, type SanityInstance} from '@sanity/sdk'
2
2
  import {describe, it} from 'vitest'
3
3
 
4
+ import {renderHook} from '../../../test/test-utils'
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
5
6
  import {useApplyDocumentActions} from './useApplyDocumentActions'
6
7
 
@@ -31,8 +32,8 @@ describe('useApplyDocumentActions', () => {
31
32
  })
32
33
 
33
34
  it('uses the SanityInstance', async () => {
34
- const apply = useApplyDocumentActions()
35
- apply({
35
+ const {result} = renderHook(() => useApplyDocumentActions())
36
+ result.current({
36
37
  type: 'document.edit',
37
38
  documentType: 'post',
38
39
  documentId: 'abc',
@@ -50,8 +51,8 @@ describe('useApplyDocumentActions', () => {
50
51
  })
51
52
 
52
53
  it('uses SanityInstance.match when projectId is overrideen', async () => {
53
- const apply = useApplyDocumentActions()
54
- apply({
54
+ const {result} = renderHook(() => useApplyDocumentActions())
55
+ result.current({
55
56
  type: 'document.edit',
56
57
  documentType: 'post',
57
58
  documentId: 'abc',
@@ -73,8 +74,8 @@ describe('useApplyDocumentActions', () => {
73
74
  })
74
75
 
75
76
  it('uses SanityInstance when dataset is overrideen', async () => {
76
- const apply = useApplyDocumentActions()
77
- apply({
77
+ const {result} = renderHook(() => useApplyDocumentActions())
78
+ result.current({
78
79
  type: 'document.edit',
79
80
  documentType: 'post',
80
81
  documentId: 'abc',
@@ -96,8 +97,8 @@ describe('useApplyDocumentActions', () => {
96
97
  })
97
98
 
98
99
  it('uses SanityInstance.amcth when projectId and dataset is overrideen', async () => {
99
- const apply = useApplyDocumentActions()
100
- apply({
100
+ const {result} = renderHook(() => useApplyDocumentActions())
101
+ result.current({
101
102
  type: 'document.edit',
102
103
  documentType: 'post',
103
104
  documentId: 'abc',
@@ -121,9 +122,9 @@ describe('useApplyDocumentActions', () => {
121
122
  })
122
123
 
123
124
  it("throws if SanityInstance.match doesn't find anything", async () => {
124
- const apply = useApplyDocumentActions()
125
+ const {result} = renderHook(() => useApplyDocumentActions())
125
126
  expect(() => {
126
- apply({
127
+ result.current({
127
128
  type: 'document.edit',
128
129
  documentType: 'post',
129
130
  documentId: 'abc',
@@ -132,4 +133,106 @@ describe('useApplyDocumentActions', () => {
132
133
  })
133
134
  }).toThrow()
134
135
  })
136
+
137
+ it('throws when actions have mismatched project IDs', async () => {
138
+ const {result} = renderHook(() => useApplyDocumentActions())
139
+ expect(() => {
140
+ result.current([
141
+ {
142
+ type: 'document.edit',
143
+ documentType: 'post',
144
+ documentId: 'abc',
145
+ projectId: 'p123',
146
+ },
147
+ {
148
+ type: 'document.edit',
149
+ documentType: 'post',
150
+ documentId: 'def',
151
+ projectId: 'p456',
152
+ },
153
+ ])
154
+ }).toThrow(/Mismatched project IDs found in actions/)
155
+ })
156
+
157
+ it('throws when actions have mismatched datasets', async () => {
158
+ const {result} = renderHook(() => useApplyDocumentActions())
159
+ expect(() => {
160
+ result.current([
161
+ {
162
+ type: 'document.edit',
163
+ documentType: 'post',
164
+ documentId: 'abc',
165
+ projectId: 'p',
166
+ dataset: 'd1',
167
+ },
168
+ {
169
+ type: 'document.edit',
170
+ documentType: 'post',
171
+ documentId: 'def',
172
+ projectId: 'p',
173
+ dataset: 'd2',
174
+ },
175
+ ])
176
+ }).toThrow(/Mismatched datasets found in actions/)
177
+ })
178
+
179
+ it('throws when actions have mismatched sources', async () => {
180
+ const {result} = renderHook(() => useApplyDocumentActions())
181
+ expect(() => {
182
+ result.current([
183
+ {
184
+ type: 'document.edit',
185
+ documentType: 'post',
186
+ documentId: 'abc',
187
+ source: {projectId: 'p', dataset: 'd1'},
188
+ },
189
+ {
190
+ type: 'document.edit',
191
+ documentType: 'post',
192
+ documentId: 'def',
193
+ source: {projectId: 'p', dataset: 'd2'},
194
+ },
195
+ ])
196
+ }).toThrow(/Mismatched sources found in actions/)
197
+ })
198
+
199
+ it('throws when mixing projectId and source (projectId first)', async () => {
200
+ const {result} = renderHook(() => useApplyDocumentActions())
201
+ expect(() => {
202
+ result.current([
203
+ {
204
+ type: 'document.edit',
205
+ documentType: 'post',
206
+ documentId: 'abc',
207
+ projectId: 'p',
208
+ },
209
+ {
210
+ type: 'document.edit',
211
+ documentType: 'post',
212
+ documentId: 'def',
213
+ source: {projectId: 'p', dataset: 'd'},
214
+ },
215
+ ])
216
+ }).toThrow(/Mismatches between projectId\/dataset options and source/)
217
+ })
218
+
219
+ it('throws when mixing source and projectId (source first)', async () => {
220
+ const {result} = renderHook(() => useApplyDocumentActions())
221
+ expect(() => {
222
+ result.current([
223
+ {
224
+ type: 'document.edit',
225
+ documentType: 'post',
226
+ documentId: 'abc',
227
+ source: {projectId: 'p', dataset: 'd'},
228
+ },
229
+ {
230
+ type: 'document.edit',
231
+ documentType: 'post',
232
+ documentId: 'def',
233
+ projectId: 'p',
234
+ },
235
+ ])
236
+ }).toThrow(/Mismatches between projectId\/dataset options and source/)
237
+ })
135
238
  })