@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
@@ -1,14 +1,18 @@
1
- import {type DocumentResource} from '@sanity/sdk'
2
- import {useContext} from 'react'
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
2
+ import {useContext, useMemo} from 'react'
3
3
 
4
+ import {ResourceContext} from '../../context/DefaultResourceContext'
5
+ import {PerspectiveContext} from '../../context/PerspectiveContext'
4
6
  import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
8
+
9
+ type NormalizedResourceFields = 'resourceName' | 'source' | 'sourceName' | 'projectId' | 'dataset'
5
10
 
6
11
  /**
7
12
  * Adds React hook support (resourceName resolution) to core types.
8
- * This wrapper allows hooks to accept `resourceName` as a convenience,
9
- * which is then resolved to a `DocumentResource` at the React layer.
10
- * For now, we are trying to avoid resource name resolution in core --
11
- * functions having resources explicitly passed will reduce complexity.
13
+ * Prefer using the React-layer handle types (ResourceHandle, DocumentHandle)
14
+ * from `@sanity/sdk-react` this wrapper is kept for cases where overloads
15
+ * don't fit (e.g. non-handle options objects).
12
16
  *
13
17
  * @typeParam T - The core type to extend (must have optional `resource` field)
14
18
  * @beta
@@ -35,6 +39,8 @@ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T
35
39
  * @typeParam T - The options type (must include optional resource field)
36
40
  * @param options - Options that may include `resourceName` and/or `resource`
37
41
  * @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
42
+ * @param contextResource - Resource from context (from ResourceContext)
43
+ * @param contextPerspective - Perspective from context (from PerspectiveContext)
38
44
  * @returns Normalized options with `resourceName` removed and `resource` resolved
39
45
  * @internal
40
46
  */
@@ -44,25 +50,23 @@ export function normalizeResourceOptions<
44
50
  resourceName?: string
45
51
  source?: DocumentResource
46
52
  sourceName?: string
53
+ projectId?: string
54
+ dataset?: string
55
+ perspective?: unknown
47
56
  },
48
57
  >(
49
58
  options: T,
50
59
  resources: Record<string, DocumentResource>,
51
- ): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
52
- const {resourceName, sourceName, source, ...rest} = options
60
+ contextResource?: DocumentResource,
61
+ contextPerspective?: PerspectiveHandle['perspective'],
62
+ ): Omit<T, NormalizedResourceFields> {
63
+ const {resourceName, sourceName, source, projectId, dataset, ...rest} = options
53
64
 
54
65
  // Coalesce deprecated aliases to their canonical equivalents
55
66
  const effectiveResourceName = resourceName ?? sourceName
56
67
  const effectiveResource = options.resource ?? source
57
68
 
58
- if (!effectiveResourceName && !effectiveResource) {
59
- return rest as Omit<T, 'resourceName' | 'source' | 'sourceName'>
60
- }
61
-
62
- const hasNameKey = Object.hasOwn(options, 'resourceName') || Object.hasOwn(options, 'sourceName')
63
- const hasResourceKey = Object.hasOwn(options, 'resource') || Object.hasOwn(options, 'source')
64
-
65
- if (hasNameKey && hasResourceKey) {
69
+ if (effectiveResourceName && effectiveResource) {
66
70
  throw new Error(
67
71
  `Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
68
72
  )
@@ -70,53 +74,76 @@ export function normalizeResourceOptions<
70
74
 
71
75
  let resolvedResource: DocumentResource | undefined
72
76
 
77
+ // Tier (a): explicit resource object or resourceName lookup
73
78
  if (effectiveResource) {
74
79
  resolvedResource = effectiveResource
80
+ } else if (effectiveResourceName) {
81
+ if (!Object.hasOwn(resources, effectiveResourceName)) {
82
+ throw new Error(
83
+ `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
84
+ )
85
+ }
86
+ resolvedResource = resources[effectiveResourceName]
75
87
  }
76
88
 
77
- if (effectiveResourceName && !Object.hasOwn(resources, effectiveResourceName)) {
78
- throw new Error(
79
- `There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
80
- )
89
+ // Tier (b): projectId or dataset in options → synthesize a resource
90
+ if (!resolvedResource && projectId && dataset) {
91
+ resolvedResource = {
92
+ projectId,
93
+ dataset,
94
+ }
81
95
  }
82
96
 
83
- if (effectiveResourceName && resources[effectiveResourceName]) {
84
- resolvedResource = resources[effectiveResourceName]
97
+ // Tier (c): fall back to whatever ResourceContext provides
98
+ if (!resolvedResource) {
99
+ resolvedResource = contextResource
85
100
  }
86
101
 
102
+ // Inject perspective from context when not explicitly provided in options
103
+ const resolvedPerspective = Object.hasOwn(options, 'perspective')
104
+ ? options.perspective
105
+ : contextPerspective
106
+
87
107
  return {
88
108
  ...rest,
89
- resource: resolvedResource,
109
+ ...(resolvedResource !== undefined && {resource: resolvedResource}),
110
+ ...(resolvedPerspective !== undefined && {perspective: resolvedPerspective}),
90
111
  }
91
112
  }
92
113
 
93
114
  /**
94
- * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
95
- * This hook ensures that options passed to core layer functions only contain
96
- * `resource` (never `resourceName`), preventing duplicate cache keys and maintaining
97
- * clean separation between React and core layers.
115
+ * Returns the effective context resource: the `ResourceContext` value if set,
116
+ * otherwise a resource synthesized from the current `SanityInstance` config
117
+ * (tier-d fallback — returns `undefined` for studio-style configs with no project).
98
118
  *
99
- * @typeParam T - The options type (must include optional resource field)
100
- * @param options - Hook options that may include `resourceName` and/or `resource`
101
- * @returns Normalized options with `resourceName` removed and `resource` resolved
119
+ * @internal
120
+ */
121
+ export function useEffectiveContextResource(): DocumentResource | undefined {
122
+ const contextResource = useContext(ResourceContext)
123
+ const instance = useContext(SanityInstanceContext)
124
+ const {projectId, dataset} = instance?.config ?? {}
125
+
126
+ return useMemo(() => {
127
+ if (contextResource) return contextResource
128
+ if (projectId && dataset) return {projectId, dataset}
129
+ return undefined
130
+ }, [contextResource, projectId, dataset])
131
+ }
132
+
133
+ /**
134
+ * Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
102
135
  *
103
- * @remarks
104
- * Resolution priority:
105
- * 1. If `resourceName` is provided, resolves it via `ResourcesContext` and uses that
106
- * 2. Otherwise, uses the inline `resource` if provided
107
- * 3. If neither is provided, returns options without a resource field
136
+ * Resolution priority for resource:
137
+ * 1. Explicit `resource` or `resourceName` in options
138
+ * 2. Bare `projectId`/`dataset` pair in options synthesized into a resource
139
+ * 3. `ResourceContext` value (set by `ResourceProvider` / `SDKProvider`)
140
+ * 4. Current `SanityInstance` config falls back to `undefined` for studio configs
108
141
  *
109
- * @example
110
- * ```tsx
111
- * function useQuery(options: WithResourceNameSupport<QueryOptions>) {
112
- * const instance = useSanityInstance(options)
113
- * const normalized = useNormalizedOptions(options)
114
- * // normalized now has resource but never resourceName
115
- * const queryKey = getQueryKey(normalized)
116
- * }
117
- * ```
142
+ * Resolution priority for perspective:
143
+ * 1. Explicit `perspective` in options
144
+ * 2. `PerspectiveContext` value (set by `ResourceProvider`)
118
145
  *
119
- * @beta
146
+ * @internal
120
147
  */
121
148
  export function useNormalizedResourceOptions<
122
149
  T extends {
@@ -124,8 +151,19 @@ export function useNormalizedResourceOptions<
124
151
  resourceName?: string
125
152
  source?: DocumentResource
126
153
  sourceName?: string
154
+ projectId?: string
155
+ dataset?: string
156
+ perspective?: PerspectiveHandle['perspective']
127
157
  },
128
- >(options: T): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
158
+ >(
159
+ options: T,
160
+ ): Omit<T, NormalizedResourceFields> & {
161
+ resource?: DocumentResource
162
+ perspective?: PerspectiveHandle['perspective']
163
+ } {
129
164
  const resources = useContext(ResourcesContext)
130
- return normalizeResourceOptions(options, resources)
165
+ const effectiveContextResource = useEffectiveContextResource()
166
+ const contextPerspective = useContext(PerspectiveContext)
167
+
168
+ return normalizeResourceOptions(options, resources, effectiveContextResource, contextPerspective)
131
169
  }
@@ -0,0 +1,53 @@
1
+ import {type Organization, type OrganizationMember} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useOrganization} from './useOrganization'
5
+
6
+ test('useOrganization — no flags: members and features both omitted', () => {
7
+ expectTypeOf(useOrganization({organizationId: 'org_1'})).toEqualTypeOf<
8
+ Organization<false, false>
9
+ >()
10
+ })
11
+
12
+ test('useOrganization — includeMembers: true adds members to the type', () => {
13
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers: true})).toEqualTypeOf<
14
+ Organization<true, false>
15
+ >()
16
+ type Result = ReturnType<typeof useOrganization<true, false>>
17
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[]>()
18
+ })
19
+
20
+ test('useOrganization — includeFeatures: true adds features to the type', () => {
21
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeFeatures: true})).toEqualTypeOf<
22
+ Organization<false, true>
23
+ >()
24
+ })
25
+
26
+ test('useOrganization — both flags true → both arrays present', () => {
27
+ expectTypeOf(
28
+ useOrganization({organizationId: 'org_1', includeMembers: true, includeFeatures: true}),
29
+ ).toEqualTypeOf<Organization<true, true>>()
30
+ })
31
+
32
+ test('useOrganization — both flags false → bare base shape', () => {
33
+ expectTypeOf(
34
+ useOrganization({organizationId: 'org_1', includeMembers: false, includeFeatures: false}),
35
+ ).toEqualTypeOf<Organization<false, false>>()
36
+ type Result = ReturnType<typeof useOrganization<false, false>>
37
+ expectTypeOf<Result['id']>().toEqualTypeOf<string>()
38
+ })
39
+
40
+ test('useOrganization — rejects non-boolean flag values', () => {
41
+ // @ts-expect-error — includeMembers must be a boolean
42
+ void useOrganization({organizationId: 'org_1', includeMembers: 'yes'})
43
+ })
44
+
45
+ test('useOrganization — non-literal boolean flag makes members optional', () => {
46
+ const includeMembers = false as boolean
47
+ expectTypeOf(useOrganization({organizationId: 'org_1', includeMembers})).toEqualTypeOf<
48
+ Organization<boolean, false>
49
+ >()
50
+ type Result = ReturnType<typeof useOrganization<boolean, false>>
51
+ expectTypeOf<Result['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
52
+ expectTypeOf<Pick<Result, 'members'>>().toEqualTypeOf<{members?: OrganizationMember[]}>()
53
+ })
@@ -0,0 +1,65 @@
1
+ import {getOrganizationState, type OrganizationOptions, type SanityInstance} from '@sanity/sdk'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
+
6
+ vi.mock('@sanity/sdk', () => ({
7
+ getOrganizationState: vi.fn(() => ({
8
+ getCurrent: vi.fn(() => undefined),
9
+ })),
10
+ resolveOrganization: vi.fn(),
11
+ }))
12
+ vi.mock('../helpers/createStateSourceHook', () => ({
13
+ createStateSourceHook: vi.fn(),
14
+ }))
15
+
16
+ describe('useOrganization', () => {
17
+ beforeEach(() => {
18
+ vi.resetModules()
19
+ vi.mock('@sanity/sdk', () => ({
20
+ getOrganizationState: vi.fn(() => ({
21
+ getCurrent: vi.fn(() => undefined),
22
+ })),
23
+ resolveOrganization: vi.fn(),
24
+ }))
25
+ vi.mock('../helpers/createStateSourceHook', () => ({
26
+ createStateSourceHook: vi.fn(),
27
+ }))
28
+ })
29
+
30
+ it('should call createStateSourceHook with correct arguments on import', async () => {
31
+ await import('./useOrganization')
32
+
33
+ expect(createStateSourceHook).toHaveBeenCalled()
34
+ expect(createStateSourceHook).toHaveBeenCalledWith(
35
+ expect.objectContaining({
36
+ getState: expect.any(Function),
37
+ shouldSuspend: expect.any(Function),
38
+ suspender: expect.any(Function),
39
+ }),
40
+ )
41
+ })
42
+
43
+ it('shouldSuspend should call getOrganizationState and getCurrent', async () => {
44
+ await import('./useOrganization')
45
+
46
+ const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
47
+ expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0)
48
+ const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
49
+ const shouldSuspend = createStateSourceHookArgs.shouldSuspend
50
+
51
+ const mockInstance = {} as SanityInstance
52
+ const mockOptions: OrganizationOptions = {organizationId: 'org_1'}
53
+
54
+ const result = shouldSuspend(mockInstance, mockOptions)
55
+
56
+ const mockGetOrganizationState = getOrganizationState as ReturnType<typeof vi.fn>
57
+ expect(mockGetOrganizationState).toHaveBeenCalledWith(mockInstance, mockOptions)
58
+
59
+ expect(mockGetOrganizationState.mock.results.length).toBeGreaterThan(0)
60
+ const getOrganizationStateMockResult = mockGetOrganizationState.mock.results[0].value
61
+ expect(getOrganizationStateMockResult.getCurrent).toHaveBeenCalled()
62
+
63
+ expect(result).toBe(true)
64
+ })
65
+ })
@@ -0,0 +1,40 @@
1
+ import {
2
+ getOrganizationState,
3
+ type Organization,
4
+ type OrganizationOptions,
5
+ resolveOrganization,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
9
+
10
+ /**
11
+ * Returns metadata for a given organisation.
12
+ *
13
+ * @category Organizations
14
+ * @param options - Configuration options
15
+ * @returns The metadata for the organisation. `members` is included only when
16
+ * `includeMembers: true`; `features` is included only when `includeFeatures: true`.
17
+ * @example
18
+ * ```tsx
19
+ * function OrganizationName({organizationId}: {organizationId: string}) {
20
+ * const organization = useOrganization({organizationId})
21
+ *
22
+ * return <h1>{organization.name}</h1>
23
+ * }
24
+ * ```
25
+ * @example
26
+ * ```tsx
27
+ * const organizationWithMembers = useOrganization({organizationId, includeMembers: true})
28
+ * const organizationWithFeatures = useOrganization({organizationId, includeFeatures: true})
29
+ * ```
30
+ * @public
31
+ * @function
32
+ */
33
+ export const useOrganization = createStateSourceHook({
34
+ getState: getOrganizationState,
35
+ shouldSuspend: (instance, ...params) =>
36
+ getOrganizationState(instance, ...params).getCurrent() === undefined,
37
+ suspender: resolveOrganization,
38
+ }) as <IncludeMembers extends boolean = false, IncludeFeatures extends boolean = false>(
39
+ options: OrganizationOptions<IncludeMembers, IncludeFeatures>,
40
+ ) => Organization<IncludeMembers, IncludeFeatures>
@@ -0,0 +1,55 @@
1
+ import {type OrganizationMember, type Organizations} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {useOrganizations} from './useOrganizations'
5
+
6
+ test('useOrganizations — no args: members and features both omitted', () => {
7
+ expectTypeOf(useOrganizations()).toEqualTypeOf<Organizations<false, false>>()
8
+ })
9
+
10
+ test('useOrganizations — includeMembers: true adds members to the type', () => {
11
+ expectTypeOf(useOrganizations({includeMembers: true})).toEqualTypeOf<Organizations<true, false>>()
12
+ type Result = ReturnType<typeof useOrganizations<true, false>>
13
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<OrganizationMember[]>()
14
+ })
15
+
16
+ test('useOrganizations — includeFeatures: true adds features to the type', () => {
17
+ expectTypeOf(useOrganizations({includeFeatures: true})).toEqualTypeOf<
18
+ Organizations<false, true>
19
+ >()
20
+ })
21
+
22
+ test('useOrganizations — both flags true → both arrays present', () => {
23
+ expectTypeOf(useOrganizations({includeMembers: true, includeFeatures: true})).toEqualTypeOf<
24
+ Organizations<true, true>
25
+ >()
26
+ })
27
+
28
+ test('useOrganizations — both flags false → bare base shape', () => {
29
+ expectTypeOf(useOrganizations({includeMembers: false, includeFeatures: false})).toEqualTypeOf<
30
+ Organizations<false, false>
31
+ >()
32
+ type Result = ReturnType<typeof useOrganizations<false, false>>
33
+ expectTypeOf<Result[number]['id']>().toEqualTypeOf<string>()
34
+ })
35
+
36
+ test('useOrganizations — rejects non-boolean flag values', () => {
37
+ // @ts-expect-error — includeMembers must be a boolean
38
+ void useOrganizations({includeMembers: 'yes'})
39
+ })
40
+
41
+ test('useOrganizations — includeImplicitMemberships does not change the data shape', () => {
42
+ expectTypeOf(useOrganizations({includeImplicitMemberships: true})).toEqualTypeOf<
43
+ Organizations<false, false>
44
+ >()
45
+ })
46
+
47
+ test('useOrganizations — non-literal boolean flag makes members optional', () => {
48
+ const includeMembers = false as boolean
49
+ expectTypeOf(useOrganizations({includeMembers})).toEqualTypeOf<Organizations<boolean, false>>()
50
+ type Result = ReturnType<typeof useOrganizations<boolean, false>>
51
+ expectTypeOf<Result[number]['members']>().toEqualTypeOf<OrganizationMember[] | undefined>()
52
+ expectTypeOf<Pick<Result[number], 'members'>>().toEqualTypeOf<{
53
+ members?: OrganizationMember[]
54
+ }>()
55
+ })
@@ -0,0 +1,85 @@
1
+ import {getOrganizationsState, type SanityInstance} from '@sanity/sdk'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
+
6
+ vi.mock('@sanity/sdk', () => ({
7
+ getOrganizationsState: vi.fn(() => ({
8
+ getCurrent: vi.fn(() => undefined),
9
+ })),
10
+ resolveOrganizations: vi.fn(),
11
+ }))
12
+ vi.mock('../helpers/createStateSourceHook', () => ({
13
+ createStateSourceHook: vi.fn(),
14
+ }))
15
+
16
+ describe('useOrganizations', () => {
17
+ beforeEach(() => {
18
+ vi.resetModules()
19
+ vi.mock('@sanity/sdk', () => ({
20
+ getOrganizationsState: vi.fn(() => ({
21
+ getCurrent: vi.fn(() => undefined),
22
+ })),
23
+ resolveOrganizations: vi.fn(),
24
+ }))
25
+ vi.mock('../helpers/createStateSourceHook', () => ({
26
+ createStateSourceHook: vi.fn(),
27
+ }))
28
+ })
29
+
30
+ it('should call createStateSourceHook with correct arguments on import', async () => {
31
+ await import('./useOrganizations')
32
+
33
+ expect(createStateSourceHook).toHaveBeenCalled()
34
+ expect(createStateSourceHook).toHaveBeenCalledWith(
35
+ expect.objectContaining({
36
+ getState: expect.any(Function),
37
+ shouldSuspend: expect.any(Function),
38
+ suspender: expect.any(Function),
39
+ }),
40
+ )
41
+ })
42
+
43
+ it('shouldSuspend should call getOrganizationsState and getCurrent', async () => {
44
+ await import('./useOrganizations')
45
+
46
+ const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
47
+ expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0)
48
+ const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
49
+ const shouldSuspend = createStateSourceHookArgs.shouldSuspend
50
+
51
+ const mockInstance = {} as SanityInstance
52
+
53
+ const result = shouldSuspend(mockInstance, undefined)
54
+
55
+ const mockGetOrganizationsState = getOrganizationsState as ReturnType<typeof vi.fn>
56
+ expect(mockGetOrganizationsState).toHaveBeenCalledWith(mockInstance, undefined)
57
+
58
+ expect(mockGetOrganizationsState.mock.results.length).toBeGreaterThan(0)
59
+ const getOrganizationsStateMockResult = mockGetOrganizationsState.mock.results[0].value
60
+ expect(getOrganizationsStateMockResult.getCurrent).toHaveBeenCalled()
61
+
62
+ expect(result).toBe(true)
63
+ })
64
+
65
+ it('should handle different parameter combinations in shouldSuspend', async () => {
66
+ await import('./useOrganizations')
67
+
68
+ const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
69
+ const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
70
+ const shouldSuspend = createStateSourceHookArgs.shouldSuspend
71
+
72
+ const mockInstance = {} as SanityInstance
73
+
74
+ expect(() => shouldSuspend(mockInstance, undefined)).not.toThrow()
75
+ expect(() => shouldSuspend(mockInstance, {includeMembers: true})).not.toThrow()
76
+ expect(() => shouldSuspend(mockInstance, {includeFeatures: true})).not.toThrow()
77
+ expect(() =>
78
+ shouldSuspend(mockInstance, {
79
+ includeMembers: true,
80
+ includeFeatures: true,
81
+ includeImplicitMemberships: true,
82
+ }),
83
+ ).not.toThrow()
84
+ })
85
+ })
@@ -0,0 +1,45 @@
1
+ import {
2
+ getOrganizationsState,
3
+ type Organizations,
4
+ type OrganizationsOptions,
5
+ resolveOrganizations,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
9
+
10
+ /**
11
+ * Returns metadata for each organisation the current user has access to.
12
+ *
13
+ * @category Organizations
14
+ * @param options - Configuration options
15
+ * @returns An array of organisation metadata. `members` is included only when
16
+ * `includeMembers: true`; `features` is included only when `includeFeatures: true`.
17
+ * @example
18
+ * ```tsx
19
+ * const organizations = useOrganizations()
20
+ *
21
+ * return (
22
+ * <select>
23
+ * {organizations.map((organization) => (
24
+ * <option key={organization.id}>{organization.name}</option>
25
+ * ))}
26
+ * </select>
27
+ * )
28
+ * ```
29
+ * @example
30
+ * ```tsx
31
+ * const organizationsWithMembers = useOrganizations({includeMembers: true})
32
+ * const organizationsWithFeatures = useOrganizations({includeFeatures: true})
33
+ * const organizationsIncludingImplicit = useOrganizations({includeImplicitMemberships: true})
34
+ * ```
35
+ * @public
36
+ * @function
37
+ */
38
+ export const useOrganizations = createStateSourceHook({
39
+ getState: getOrganizationsState,
40
+ shouldSuspend: (instance, ...params) =>
41
+ getOrganizationsState(instance, ...params).getCurrent() === undefined,
42
+ suspender: resolveOrganizations,
43
+ }) as <IncludeMembers extends boolean = false, IncludeFeatures extends boolean = false>(
44
+ options?: OrganizationsOptions<IncludeMembers, IncludeFeatures>,
45
+ ) => Organizations<IncludeMembers, IncludeFeatures>
@@ -1,8 +1,17 @@
1
- import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
1
+ import {
2
+ createGroqSearchFilter,
3
+ type DocumentHandle,
4
+ isDatasetResource,
5
+ type QueryOptions,
6
+ } from '@sanity/sdk'
7
+ import {pickProperties} from '@sanity/sdk/_internal'
2
8
  import {type SortOrderingItem} from '@sanity/types'
3
9
  import {useCallback, useMemo, useState} from 'react'
4
10
 
5
- import {useSanityInstance} from '../context/useSanityInstance'
11
+ import {
12
+ useNormalizedResourceOptions,
13
+ type WithResourceNameSupport,
14
+ } from '../helpers/useNormalizedResourceOptions'
6
15
  import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
7
16
  import {useQuery} from '../query/useQuery'
8
17
 
@@ -16,7 +25,9 @@ export interface PaginatedDocumentsOptions<
16
25
  TDocumentType extends string = string,
17
26
  TDataset extends string = string,
18
27
  TProjectId extends string = string,
19
- > extends Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'> {
28
+ > extends WithResourceNameSupport<
29
+ Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'>
30
+ > {
20
31
  documentType?: TDocumentType | TDocumentType[]
21
32
  /**
22
33
  * GROQ filter expression to apply to the query
@@ -232,16 +243,16 @@ export function usePaginatedDocuments<
232
243
  params = {},
233
244
  orderings,
234
245
  search,
235
- ...options
246
+ ...rawOptions
236
247
  }: PaginatedDocumentsOptions<TDocumentType, TDataset, TProjectId>): PaginatedDocumentsResponse<
237
248
  TDocumentType,
238
249
  TDataset,
239
250
  TProjectId
240
251
  > {
241
252
  useTrackHookUsage('usePaginatedDocuments')
242
- const instance = useSanityInstance(options)
253
+ const options = useNormalizedResourceOptions(rawOptions)
243
254
  const [pageIndex, setPageIndex] = useState(0)
244
- const key = JSON.stringify({filter, search, params, orderings, pageSize})
255
+ const key = JSON.stringify({filter, search, params, orderings, pageSize, ...options})
245
256
  // Reset pageIndex to 0 whenever any query parameter changes.
246
257
  const [prevKey, setPrevKey] = useState(key)
247
258
  if (prevKey !== key) {
@@ -303,9 +314,12 @@ export function usePaginatedDocuments<
303
314
  ...params,
304
315
  __types: documentTypes,
305
316
  __handle: {
306
- projectId: options.projectId ?? instance.config.projectId,
307
- dataset: options.dataset ?? instance.config.dataset,
308
- perspective: options.perspective ?? instance.config.perspective,
317
+ // keep projectId/dataset for backward compat until v4; resource is added
318
+ // intentionally so that hook consumers can resolve the correct resource
319
+ ...(options.resource && isDatasetResource(options.resource)
320
+ ? pickProperties(options.resource, ['projectId', 'dataset'])
321
+ : {}),
322
+ ...pickProperties(options, ['perspective', 'resource']),
309
323
  },
310
324
  },
311
325
  })
@@ -1,23 +1,16 @@
1
- import {
2
- type DatasetHandle,
3
- getPresence,
4
- isMediaLibraryResource,
5
- type UserPresence,
6
- } from '@sanity/sdk'
1
+ import {getPresence, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
7
2
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
8
3
 
4
+ import {type ResourceHandle} from '../../config/handles'
9
5
  import {useSanityInstance} from '../context/useSanityInstance'
10
- import {
11
- useNormalizedResourceOptions,
12
- type WithResourceNameSupport,
13
- } from '../helpers/useNormalizedResourceOptions'
6
+ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
14
7
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
15
8
 
16
9
  /**
17
10
  * A hook for subscribing to presence information for the current project or Canvas.
18
11
  * @public
19
12
  */
20
- export function usePresence(options: WithResourceNameSupport<DatasetHandle> = {}): {
13
+ export function usePresence(options: ResourceHandle = {}): {
21
14
  locations: UserPresence[]
22
15
  } {
23
16
  const normalizedOptions = useNormalizedResourceOptions(options)