@sanity/sdk-react 2.10.0 → 2.11.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 (54) hide show
  1. package/dist/index.d.ts +257 -200
  2. package/dist/index.js +364 -253
  3. package/dist/index.js.map +1 -1
  4. package/package.json +6 -9
  5. package/src/_exports/index.ts +2 -0
  6. package/src/_exports/sdk-react.ts +4 -0
  7. package/src/components/SDKProvider.test.tsx +5 -12
  8. package/src/components/SDKProvider.tsx +26 -24
  9. package/src/config/handles.ts +55 -0
  10. package/src/constants.ts +5 -0
  11. package/src/context/DefaultResourceContext.ts +10 -0
  12. package/src/context/PerspectiveContext.ts +12 -0
  13. package/src/context/ResourceProvider.test.tsx +2 -2
  14. package/src/context/ResourceProvider.tsx +53 -49
  15. package/src/hooks/agent/agentActions.ts +55 -38
  16. package/src/hooks/context/useResource.test.tsx +32 -0
  17. package/src/hooks/context/useResource.ts +24 -0
  18. package/src/hooks/context/useSanityInstance.test.tsx +42 -111
  19. package/src/hooks/context/useSanityInstance.ts +28 -50
  20. package/src/hooks/dashboard/useDispatchIntent.test.ts +5 -1
  21. package/src/hooks/dashboard/useDispatchIntent.ts +3 -3
  22. package/src/hooks/dashboard/useManageFavorite.test.tsx +16 -12
  23. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +1 -5
  24. package/src/hooks/document/{useApplyDocumentActions.test.ts → useApplyDocumentActions.test.tsx} +42 -77
  25. package/src/hooks/document/useApplyDocumentActions.ts +28 -62
  26. package/src/hooks/document/useDocument.ts +3 -5
  27. package/src/hooks/document/useDocumentEvent.ts +4 -3
  28. package/src/hooks/document/useDocumentPermissions.test.tsx +58 -150
  29. package/src/hooks/document/useDocumentPermissions.ts +78 -55
  30. package/src/hooks/document/useEditDocument.test.tsx +25 -60
  31. package/src/hooks/document/useEditDocument.ts +1 -1
  32. package/src/hooks/documents/useDocuments.ts +13 -8
  33. package/src/hooks/helpers/createStateSourceHook.tsx +1 -2
  34. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +253 -0
  35. package/src/hooks/helpers/useNormalizedResourceOptions.ts +85 -47
  36. package/src/hooks/organizations/useOrganization.test-d.ts +53 -0
  37. package/src/hooks/organizations/useOrganization.test.ts +65 -0
  38. package/src/hooks/organizations/useOrganization.ts +40 -0
  39. package/src/hooks/organizations/useOrganizations.test-d.ts +55 -0
  40. package/src/hooks/organizations/useOrganizations.test.ts +85 -0
  41. package/src/hooks/organizations/useOrganizations.ts +45 -0
  42. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +23 -9
  43. package/src/hooks/presence/usePresence.ts +4 -11
  44. package/src/hooks/preview/useDocumentPreview.tsx +4 -7
  45. package/src/hooks/projection/useDocumentProjection.ts +5 -7
  46. package/src/hooks/projects/useProject.test-d.ts +49 -0
  47. package/src/hooks/projects/useProject.ts +33 -41
  48. package/src/hooks/projects/useProjects.test-d.ts +49 -0
  49. package/src/hooks/projects/useProjects.ts +17 -23
  50. package/src/hooks/query/useQuery.ts +1 -1
  51. package/src/hooks/releases/useActiveReleases.ts +6 -6
  52. package/src/hooks/releases/usePerspective.ts +7 -12
  53. package/src/hooks/users/useUser.ts +1 -1
  54. package/src/hooks/users/useUsers.ts +1 -1
@@ -7,10 +7,9 @@ import {
7
7
  type StateSource,
8
8
  } from '@sanity/sdk'
9
9
  import {type SanityDocument} from '@sanity/types'
10
- import {renderHook} from '@testing-library/react'
11
10
  import {beforeEach, describe, expect, it, vi} from 'vitest'
12
11
 
13
- import {ResourceProvider} from '../../context/ResourceProvider'
12
+ import {renderHook} from '../../../test/test-utils'
14
13
  import {useApplyDocumentActions} from './useApplyDocumentActions'
15
14
  import {useEditDocument} from './useEditDocument'
16
15
 
@@ -42,6 +41,11 @@ const docHandle = createDocumentHandle({
42
41
  documentType: 'book',
43
42
  })
44
43
 
44
+ const normalizedDoc = {
45
+ ...docHandle,
46
+ resource: {projectId: 'test', dataset: 'test'},
47
+ }
48
+
45
49
  // Define a single generic TestDocument type
46
50
  interface Book extends SanityDocument {
47
51
  _type: 'book'
@@ -76,16 +80,10 @@ describe('useEditDocument hook', () => {
76
80
  const apply = vi.fn().mockResolvedValue({transactionId: 'tx1'})
77
81
  vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
78
82
 
79
- const {result} = renderHook(() => useEditDocument<string>({...docHandle, path: 'foo'}), {
80
- wrapper: ({children}) => (
81
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
82
- {children}
83
- </ResourceProvider>
84
- ),
85
- })
83
+ const {result} = renderHook(() => useEditDocument<string>({...docHandle, path: 'foo'}))
86
84
  const promise = result.current('newValue')
87
- expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'newValue'}})
88
- expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'newValue'}}))
85
+ expect(editDocument).toHaveBeenCalledWith(normalizedDoc, {set: {foo: 'newValue'}})
86
+ expect(apply).toHaveBeenCalledWith(editDocument(normalizedDoc, {set: {foo: 'newValue'}}))
89
87
  const actionsResult = await promise
90
88
  expect(actionsResult).toEqual({transactionId: 'tx1'})
91
89
  })
@@ -103,15 +101,9 @@ describe('useEditDocument hook', () => {
103
101
  const apply = vi.fn().mockResolvedValue({transactionId: 'tx2'})
104
102
  vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
105
103
 
106
- const {result} = renderHook(() => useEditDocument(docHandle), {
107
- wrapper: ({children}) => (
108
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
109
- {children}
110
- </ResourceProvider>
111
- ),
112
- })
104
+ const {result} = renderHook(() => useEditDocument(docHandle))
113
105
  const promise = result.current({...doc, foo: 'baz', extra: 'old', _id: 'doc1'})
114
- expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
106
+ expect(apply).toHaveBeenCalledWith([editDocument(normalizedDoc, {set: {foo: 'baz'}})])
115
107
  const actionsResult = await promise
116
108
  expect(actionsResult).toEqual({transactionId: 'tx2'})
117
109
  })
@@ -127,16 +119,10 @@ describe('useEditDocument hook', () => {
127
119
  const apply = vi.fn().mockResolvedValue({transactionId: 'tx3'})
128
120
  vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
129
121
 
130
- const {result} = renderHook(() => useEditDocument<string>({...docHandle, path: 'foo'}), {
131
- wrapper: ({children}) => (
132
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
133
- {children}
134
- </ResourceProvider>
135
- ),
136
- })
122
+ const {result} = renderHook(() => useEditDocument<string>({...docHandle, path: 'foo'}))
137
123
  const promise = result.current((prev: unknown) => `${prev}Updated`) // 'bar' becomes 'barUpdated'
138
- expect(editDocument).toHaveBeenCalledWith(docHandle, {set: {foo: 'barUpdated'}})
139
- expect(apply).toHaveBeenCalledWith(editDocument(docHandle, {set: {foo: 'barUpdated'}}))
124
+ expect(editDocument).toHaveBeenCalledWith(normalizedDoc, {set: {foo: 'barUpdated'}})
125
+ expect(apply).toHaveBeenCalledWith(editDocument(normalizedDoc, {set: {foo: 'barUpdated'}}))
140
126
  const actionsResult = await promise
141
127
  expect(actionsResult).toEqual({transactionId: 'tx3'})
142
128
  })
@@ -153,15 +139,9 @@ describe('useEditDocument hook', () => {
153
139
  const apply = vi.fn().mockResolvedValue({transactionId: 'tx4'})
154
140
  vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
155
141
 
156
- const {result} = renderHook(() => useEditDocument(docHandle), {
157
- wrapper: ({children}) => (
158
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
159
- {children}
160
- </ResourceProvider>
161
- ),
162
- })
163
- const promise = result.current((prevDoc) => ({...prevDoc, foo: 'baz'}))
164
- expect(apply).toHaveBeenCalledWith([editDocument(docHandle, {set: {foo: 'baz'}})])
142
+ const {result} = renderHook(() => useEditDocument(docHandle))
143
+ const promise = result.current((prevDoc: Book) => ({...prevDoc, foo: 'baz'}))
144
+ expect(apply).toHaveBeenCalledWith([editDocument(normalizedDoc, {set: {foo: 'baz'}})])
165
145
  const actionsResult = await promise
166
146
  expect(actionsResult).toEqual({transactionId: 'tx4'})
167
147
  })
@@ -177,13 +157,7 @@ describe('useEditDocument hook', () => {
177
157
  const fakeApply = vi.fn()
178
158
  vi.mocked(useApplyDocumentActions).mockReturnValue(fakeApply)
179
159
 
180
- const {result} = renderHook(() => useEditDocument(docHandle), {
181
- wrapper: ({children}) => (
182
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
183
- {children}
184
- </ResourceProvider>
185
- ),
186
- })
160
+ const {result} = renderHook(() => useEditDocument(docHandle))
187
161
  expect(() => result.current('notAnObject' as unknown as Book)).toThrowError(
188
162
  'No path was provided to `useEditDocument` and the value provided was not a document object.',
189
163
  )
@@ -203,22 +177,13 @@ describe('useEditDocument hook', () => {
203
177
  vi.mocked(resolveDocument).mockReturnValue(resolveDocPromise)
204
178
 
205
179
  // Render the hook and capture the thrown promise.
206
- const {result} = renderHook(
207
- () => {
208
- try {
209
- return useEditDocument(docHandle)
210
- } catch (e) {
211
- return e
212
- }
213
- },
214
- {
215
- wrapper: ({children}) => (
216
- <ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
217
- {children}
218
- </ResourceProvider>
219
- ),
220
- },
221
- )
180
+ const {result} = renderHook(() => {
181
+ try {
182
+ return useEditDocument(docHandle)
183
+ } catch (e) {
184
+ return e
185
+ }
186
+ })
222
187
 
223
188
  // When the document is not ready, the hook throws the promise from resolveDocument.
224
189
  expect(result.current).toBe(resolveDocPromise)
@@ -286,7 +286,7 @@ export function useEditDocument({
286
286
  path,
287
287
  ...doc
288
288
  }: DocumentOptions<string | undefined>): (updater: Updater<unknown>) => Promise<ActionsResult> {
289
- const instance = useSanityInstance(doc)
289
+ const instance = useSanityInstance()
290
290
  trackHookUsage(instance, 'useEditDocument')
291
291
  const normalizedDoc = useNormalizedResourceOptions(doc)
292
292
 
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  createGroqSearchFilter,
3
- type DatasetHandle,
4
3
  type DocumentHandle,
4
+ isDatasetResource,
5
5
  type QueryOptions,
6
6
  } from '@sanity/sdk'
7
+ import {pickProperties} from '@sanity/sdk/_internal'
7
8
  import {type SortOrderingItem} from '@sanity/types'
8
9
  import {useCallback, useMemo, useState} from 'react'
9
10
 
10
- import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {type ResourceHandle} from '../../config/handles'
12
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
11
13
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
12
14
  import {useQuery} from '../query/useQuery'
13
15
 
@@ -24,7 +26,7 @@ export interface DocumentsOptions<
24
26
  TDataset extends string = string,
25
27
  TProjectId extends string = string,
26
28
  >
27
- extends DatasetHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
29
+ extends ResourceHandle<TDataset, TProjectId>, Pick<QueryOptions, 'perspective' | 'params'> {
28
30
  /**
29
31
  * Filter documents by their `_type`. Can be a single type or an array of types.
30
32
  */
@@ -201,14 +203,14 @@ export function useDocuments<
201
203
  filter,
202
204
  orderings,
203
205
  documentType,
204
- ...options
206
+ ...rawOptions
205
207
  }: DocumentsOptions<TDocumentType, TDataset, TProjectId>): DocumentsResponse<
206
208
  TDocumentType,
207
209
  TDataset,
208
210
  TProjectId
209
211
  > {
210
212
  useTrackHookUsage('useDocuments')
211
- const instance = useSanityInstance(options)
213
+ const options = useNormalizedResourceOptions(rawOptions)
212
214
  const [limit, setLimit] = useState(batchSize)
213
215
  const documentTypes = useMemo(
214
216
  () =>
@@ -284,9 +286,12 @@ export function useDocuments<
284
286
  ...params,
285
287
  // these are passed back to the user as part of each document handle
286
288
  __handle: {
287
- projectId: options.projectId ?? instance.config.projectId,
288
- dataset: options.dataset ?? instance.config.dataset,
289
- perspective: options.perspective ?? instance.config.perspective,
289
+ // keep projectId/dataset for backward compat until v4; resource is added
290
+ // intentionally so that hook consumers can resolve the correct resource
291
+ ...(options.resource && isDatasetResource(options.resource)
292
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
293
+ : {}),
294
+ ...pickProperties(options, ['perspective', 'resource']),
290
295
  },
291
296
  __types: documentTypes,
292
297
  },
@@ -19,11 +19,10 @@ export function createStateSourceHook<TParams extends unknown[], TState>(
19
19
  options: StateSourceFactory<TParams, TState> | CreateStateSourceHookOptions<TParams, TState>,
20
20
  ): (...params: TParams) => TState {
21
21
  const getState = typeof options === 'function' ? options : options.getState
22
- const getConfig = 'getConfig' in options ? options.getConfig : undefined
23
22
  const suspense = 'shouldSuspend' in options && 'suspender' in options ? options : undefined
24
23
 
25
24
  function useHook(...params: TParams) {
26
- const instance = useSanityInstance(getConfig?.(...params))
25
+ const instance = useSanityInstance()
27
26
 
28
27
  if (suspense?.suspender && suspense?.shouldSuspend?.(instance, ...params)) {
29
28
  throw suspense.suspender(instance, ...params)
@@ -0,0 +1,253 @@
1
+ import {createSanityInstance, type DocumentHandle} from '@sanity/sdk'
2
+ import {type ReactNode} from 'react'
3
+ import {describe, expect, it} from 'vitest'
4
+
5
+ import {renderHook, resources} from '../../../test/test-utils'
6
+ import {ResourceProvider} from '../../context/ResourceProvider'
7
+ import {ResourcesContext} from '../../context/ResourcesContext'
8
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
9
+ import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
10
+
11
+ // Wrapper that sets ResourceContext via the `resource` prop (tier 3).
12
+ // Includes ResourcesContext so resourceName resolution also works in these tests.
13
+ function ResourceContextWrapper({
14
+ children,
15
+ resource,
16
+ }: {
17
+ children: ReactNode
18
+ resource: {projectId: string; dataset: string}
19
+ }) {
20
+ return (
21
+ <ResourceProvider resource={resource} fallback={null}>
22
+ <ResourcesContext.Provider value={resources}>{children}</ResourcesContext.Provider>
23
+ </ResourceProvider>
24
+ )
25
+ }
26
+
27
+ // Wrapper that provides an instance with no projectId/dataset and no ResourceContext (tier 5).
28
+ const bareInstance = createSanityInstance({})
29
+ function NoResourceWrapper({children}: {children: ReactNode}) {
30
+ return (
31
+ <SanityInstanceContext.Provider value={bareInstance}>{children}</SanityInstanceContext.Provider>
32
+ )
33
+ }
34
+
35
+ describe('useNormalizedResourceOptions', () => {
36
+ describe('tier 1 — explicit options', () => {
37
+ it('uses an explicit dataset resource object', () => {
38
+ const {result} = renderHook(() =>
39
+ useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
40
+ )
41
+ expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
42
+ })
43
+
44
+ it('uses an explicit media-library resource object', () => {
45
+ const {result} = renderHook(() =>
46
+ useNormalizedResourceOptions({resource: {mediaLibraryId: 'ml-123'}}),
47
+ )
48
+ expect(result.current.resource).toEqual({mediaLibraryId: 'ml-123'})
49
+ })
50
+
51
+ it('uses an explicit canvas resource object', () => {
52
+ const {result} = renderHook(() =>
53
+ useNormalizedResourceOptions({resource: {canvasId: 'canvas-123'}}),
54
+ )
55
+ expect(result.current.resource).toEqual({canvasId: 'canvas-123'})
56
+ })
57
+
58
+ it('resolves resourceName to a named dataset resource', () => {
59
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
60
+ expect(result.current.resource).toEqual({
61
+ projectId: 'resource-project-id',
62
+ dataset: 'resource-dataset',
63
+ })
64
+ })
65
+
66
+ it('resolves resourceName to a named media-library resource', () => {
67
+ const {result} = renderHook(() =>
68
+ useNormalizedResourceOptions({resourceName: 'media-library'}),
69
+ )
70
+ expect(result.current.resource).toEqual({mediaLibraryId: 'media-library-id'})
71
+ })
72
+
73
+ it('resolves resourceName to a named canvas resource', () => {
74
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'canvas'}))
75
+ expect(result.current.resource).toEqual({canvasId: 'canvas-id'})
76
+ })
77
+
78
+ it('throws when resourceName is not registered', () => {
79
+ expect(() =>
80
+ renderHook(() => useNormalizedResourceOptions({resourceName: 'unknown'})),
81
+ ).toThrow(/no resource named/i)
82
+ })
83
+
84
+ it('throws when both resource and resourceName are provided', () => {
85
+ expect(() =>
86
+ renderHook(() =>
87
+ useNormalizedResourceOptions({
88
+ resource: {projectId: 'p', dataset: 'd'},
89
+ resourceName: 'dataset',
90
+ }),
91
+ ),
92
+ ).toThrow()
93
+ })
94
+
95
+ it('resolves deprecated `source` as `resource`', () => {
96
+ const {result} = renderHook(() =>
97
+ useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
98
+ )
99
+ expect(result.current.resource).toEqual({projectId: 'src', dataset: 'src-ds'})
100
+ })
101
+
102
+ it('resolves deprecated `sourceName` as `resourceName`', () => {
103
+ const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
104
+ expect(result.current.resource).toEqual({
105
+ projectId: 'resource-project-id',
106
+ dataset: 'resource-dataset',
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('tier 2 — bare projectId/dataset in options', () => {
112
+ it('synthesizes a resource from projectId + dataset', () => {
113
+ const {result} = renderHook(() =>
114
+ useNormalizedResourceOptions({projectId: 'opt', dataset: 'opt-ds'}),
115
+ )
116
+ expect(result.current.resource).toEqual({projectId: 'opt', dataset: 'opt-ds'})
117
+ })
118
+
119
+ it('falls through to context when only projectId is provided (no dataset)', () => {
120
+ // Only projectId is not enough to synthesize — should fall back to context resource
121
+ const {result} = renderHook(() => useNormalizedResourceOptions({projectId: 'opt'}))
122
+ // Default test-utils: ResourceProvider projectId="test" dataset="test" → tier-3 via config synthesis
123
+ expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
124
+ })
125
+ })
126
+
127
+ describe('tier 3 — ResourceContext', () => {
128
+ it('uses ResourceContext set via ResourceProvider `resource` prop', () => {
129
+ const contextResource = {projectId: 'ctx-project', dataset: 'ctx-dataset'}
130
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
131
+ wrapper: ({children}) => (
132
+ <ResourceContextWrapper resource={contextResource}>{children}</ResourceContextWrapper>
133
+ ),
134
+ })
135
+ expect(result.current.resource).toEqual(contextResource)
136
+ })
137
+
138
+ it('uses ResourceContext synthesized from ResourceProvider projectId/dataset', () => {
139
+ // ResourceProvider with projectId/dataset (no explicit resource prop) synthesizes ResourceContext
140
+ const {result} = renderHook(() => useNormalizedResourceOptions({}))
141
+ // Default test-utils: ResourceProvider projectId="test" dataset="test"
142
+ expect(result.current.resource).toEqual({projectId: 'test', dataset: 'test'})
143
+ })
144
+
145
+ it('explicit resource in options takes precedence over ResourceContext', () => {
146
+ const {result} = renderHook(
147
+ () =>
148
+ useNormalizedResourceOptions({resource: {projectId: 'explicit', dataset: 'explicit-ds'}}),
149
+ {
150
+ wrapper: ({children}) => (
151
+ <ResourceContextWrapper resource={{projectId: 'ctx-project', dataset: 'ctx-dataset'}}>
152
+ {children}
153
+ </ResourceContextWrapper>
154
+ ),
155
+ },
156
+ )
157
+ expect(result.current.resource).toEqual({projectId: 'explicit', dataset: 'explicit-ds'})
158
+ })
159
+ })
160
+
161
+ describe('tier 4 — SanityInstance config fallback', () => {
162
+ it('falls back to instance projectId/dataset when ResourceContext is not set', () => {
163
+ // Bare SanityInstanceContext with config — no ResourceProvider, so no ResourceContext
164
+ const instanceWithConfig = createSanityInstance({projectId: 'inst', dataset: 'inst-ds'})
165
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
166
+ wrapper: ({children}) => (
167
+ <SanityInstanceContext.Provider value={instanceWithConfig}>
168
+ {children}
169
+ </SanityInstanceContext.Provider>
170
+ ),
171
+ })
172
+ expect(result.current.resource).toEqual({projectId: 'inst', dataset: 'inst-ds'})
173
+ })
174
+ })
175
+
176
+ describe('tier 5 — no resource available', () => {
177
+ it('returns no resource when neither options, context, nor instance config provide one', () => {
178
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
179
+ wrapper: NoResourceWrapper,
180
+ })
181
+ expect(result.current).not.toHaveProperty('resource')
182
+ })
183
+ })
184
+
185
+ describe('perspective resolution', () => {
186
+ it('uses explicit perspective from options', () => {
187
+ const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}))
188
+ expect(result.current.perspective).toBe('published')
189
+ })
190
+
191
+ it('falls back to PerspectiveContext when no perspective in options', () => {
192
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
193
+ wrapper: ({children}) => (
194
+ <ResourceProvider perspective="previewDrafts" fallback={null}>
195
+ {children}
196
+ </ResourceProvider>
197
+ ),
198
+ })
199
+ expect(result.current.perspective).toBe('previewDrafts')
200
+ })
201
+
202
+ it('explicit perspective overrides PerspectiveContext', () => {
203
+ const {result} = renderHook(() => useNormalizedResourceOptions({perspective: 'published'}), {
204
+ wrapper: ({children}) => (
205
+ <ResourceProvider perspective="previewDrafts" fallback={null}>
206
+ {children}
207
+ </ResourceProvider>
208
+ ),
209
+ })
210
+ expect(result.current.perspective).toBe('published')
211
+ })
212
+
213
+ it('omits perspective from result when not set', () => {
214
+ const {result} = renderHook(() => useNormalizedResourceOptions({}), {
215
+ wrapper: NoResourceWrapper,
216
+ })
217
+ expect(result.current).not.toHaveProperty('perspective')
218
+ })
219
+ })
220
+
221
+ describe('field stripping', () => {
222
+ it('strips resourceName from the result', () => {
223
+ const {result} = renderHook(() => useNormalizedResourceOptions({resourceName: 'dataset'}))
224
+ expect(result.current).not.toHaveProperty('resourceName')
225
+ })
226
+
227
+ it('strips projectId and dataset from the result when synthesized into resource', () => {
228
+ const {result} = renderHook(() =>
229
+ useNormalizedResourceOptions({projectId: 'p', dataset: 'd'}),
230
+ )
231
+ expect(result.current).not.toHaveProperty('projectId')
232
+ expect(result.current).not.toHaveProperty('dataset')
233
+ })
234
+
235
+ it('strips deprecated source from the result', () => {
236
+ const {result} = renderHook(() =>
237
+ useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
238
+ )
239
+ expect(result.current).not.toHaveProperty('source')
240
+ })
241
+
242
+ it('strips deprecated sourceName from the result', () => {
243
+ const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
244
+ expect(result.current).not.toHaveProperty('sourceName')
245
+ })
246
+
247
+ it('preserves unrelated fields', () => {
248
+ const opts: DocumentHandle = {documentId: 'doc-1', documentType: 'article'}
249
+ const {result} = renderHook(() => useNormalizedResourceOptions(opts))
250
+ expect(result.current).toMatchObject({documentId: 'doc-1', documentType: 'article'})
251
+ })
252
+ })
253
+ })