@sanity/sdk-react 2.14.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -30
- package/dist/index.d.ts +512 -335
- package/dist/index.js +540 -332
- package/dist/index.js.map +1 -1
- package/package.json +37 -39
- package/src/_exports/sdk-react.ts +1 -0
- package/src/components/SDKProvider.test.tsx +135 -22
- package/src/components/SDKProvider.tsx +54 -17
- package/src/components/SanityApp.tsx +29 -0
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.tsx +11 -1
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +57 -1
- package/src/context/ResourceProvider.tsx +30 -9
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +20 -8
- package/src/hooks/document/useApplyDocumentActions.ts +1 -1
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.ts +2 -2
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/projects/useProject.test.tsx +114 -0
- package/src/hooks/projects/useProject.ts +17 -7
- package/src/hooks/users/useUsers.test.tsx +101 -2
- package/src/hooks/users/useUsers.ts +35 -3
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/datasets/useDatasets.test.ts +0 -80
- package/src/hooks/projects/useProject.test.ts +0 -80
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {createDocument} from '@sanity/sdk'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {renderHook} from '../../../test/test-utils'
|
|
5
|
+
import {useApplyDocumentActions} from './useApplyDocumentActions'
|
|
6
|
+
import {useCreateDocument} from './useCreateDocument'
|
|
7
|
+
|
|
8
|
+
vi.mock('./useApplyDocumentActions', () => ({
|
|
9
|
+
useApplyDocumentActions: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const typeHandle = {
|
|
13
|
+
documentType: 'book',
|
|
14
|
+
projectId: 'test',
|
|
15
|
+
dataset: 'test',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
describe('useCreateDocument hook', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('applies a createDocument action with a generated id and initial values', async () => {
|
|
28
|
+
vi.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000')
|
|
29
|
+
const apply = vi.fn().mockResolvedValue({transactionId: 'tx1'})
|
|
30
|
+
vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
|
|
31
|
+
|
|
32
|
+
const {result} = renderHook(() => useCreateDocument(typeHandle))
|
|
33
|
+
const handle = await result.current({title: 'New Book'})
|
|
34
|
+
|
|
35
|
+
expect(apply).toHaveBeenCalledWith(
|
|
36
|
+
createDocument(
|
|
37
|
+
{...typeHandle, documentId: '00000000-0000-0000-0000-000000000000'},
|
|
38
|
+
{
|
|
39
|
+
title: 'New Book',
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
expect(handle).toEqual({...typeHandle, documentId: '00000000-0000-0000-0000-000000000000'})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns a handle carrying the generated id', async () => {
|
|
47
|
+
vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-1111-1111-111111111111')
|
|
48
|
+
const apply = vi.fn().mockResolvedValue({transactionId: 'tx2'})
|
|
49
|
+
vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
|
|
50
|
+
|
|
51
|
+
const {result} = renderHook(() => useCreateDocument(typeHandle))
|
|
52
|
+
const handle = await result.current()
|
|
53
|
+
|
|
54
|
+
expect(handle.documentId).toBe('11111111-1111-1111-1111-111111111111')
|
|
55
|
+
expect(handle.documentType).toBe('book')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('uses the documentId supplied on the handle instead of generating one', async () => {
|
|
59
|
+
const apply = vi.fn().mockResolvedValue({transactionId: 'tx3'})
|
|
60
|
+
vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
|
|
61
|
+
|
|
62
|
+
const {result} = renderHook(() => useCreateDocument({...typeHandle, documentId: 'fixed-id'}))
|
|
63
|
+
const handle = await result.current()
|
|
64
|
+
|
|
65
|
+
expect(handle.documentId).toBe('fixed-id')
|
|
66
|
+
expect(apply).toHaveBeenCalledWith(
|
|
67
|
+
createDocument({...typeHandle, documentId: 'fixed-id'}, undefined),
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('uses a per-call documentId override over the handle id', async () => {
|
|
72
|
+
const apply = vi.fn().mockResolvedValue({transactionId: 'tx4'})
|
|
73
|
+
vi.mocked(useApplyDocumentActions).mockReturnValue(apply)
|
|
74
|
+
|
|
75
|
+
const {result} = renderHook(() => useCreateDocument({...typeHandle, documentId: 'handle-id'}))
|
|
76
|
+
const handle = await result.current({title: 'Override'}, {documentId: 'override-id'})
|
|
77
|
+
|
|
78
|
+
expect(handle.documentId).toBe('override-id')
|
|
79
|
+
expect(apply).toHaveBeenCalledWith(
|
|
80
|
+
createDocument({...typeHandle, documentId: 'override-id'}, {title: 'Override'}),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {createDocument} from '@sanity/sdk'
|
|
2
|
+
import {type SanityDocument} from 'groq'
|
|
3
|
+
|
|
4
|
+
import {type DocumentHandle, type DocumentTypeHandle} from '../../config/handles'
|
|
5
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
7
|
+
import {useApplyDocumentActions} from './useApplyDocumentActions'
|
|
8
|
+
|
|
9
|
+
type IgnoredKey = '_id' | '_type' | '_rev' | '_createdAt' | '_updatedAt'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Optional per-call overrides for {@link useCreateDocument}'s create function.
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export interface CreateDocumentOverrides {
|
|
16
|
+
/**
|
|
17
|
+
* Use this document ID instead of generating one. Overrides any `documentId`
|
|
18
|
+
* supplied on the handle passed to `useCreateDocument`.
|
|
19
|
+
*/
|
|
20
|
+
documentId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Overload 1: Typegen — infers the document shape from your schema.
|
|
24
|
+
/**
|
|
25
|
+
* @public
|
|
26
|
+
* Create a new document, relying on Typegen for the initial-value type.
|
|
27
|
+
*
|
|
28
|
+
* @param options - A document-type handle including `documentType`, an optional `documentId`, and optionally `projectId`/`dataset`/`perspective`.
|
|
29
|
+
* @returns A function that creates the document. It accepts optional initial field values and an optional `{documentId}` override,
|
|
30
|
+
* and resolves to the {@link DocumentHandle} of the created document (carrying the generated or supplied id).
|
|
31
|
+
*/
|
|
32
|
+
export function useCreateDocument<
|
|
33
|
+
TDocumentType extends string = string,
|
|
34
|
+
TDataset extends string = string,
|
|
35
|
+
TProjectId extends string = string,
|
|
36
|
+
>(
|
|
37
|
+
options: DocumentTypeHandle<TDocumentType, TDataset, TProjectId>,
|
|
38
|
+
): (
|
|
39
|
+
initialValue?: Partial<
|
|
40
|
+
Omit<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`>, IgnoredKey>
|
|
41
|
+
>,
|
|
42
|
+
overrides?: CreateDocumentOverrides,
|
|
43
|
+
) => Promise<DocumentHandle<TDocumentType, TDataset, TProjectId>>
|
|
44
|
+
|
|
45
|
+
// Overload 2: Explicit type `TData`.
|
|
46
|
+
/**
|
|
47
|
+
* @public
|
|
48
|
+
* Create a new document with an explicit type `TData`.
|
|
49
|
+
*
|
|
50
|
+
* @param options - A document-type handle including `documentType` and optionally `projectId`/`dataset`/`perspective`.
|
|
51
|
+
* @returns A function that creates the document. It accepts optional initial field values (typed against `TData`) and an
|
|
52
|
+
* optional `{documentId}` override, and resolves to the {@link DocumentHandle} of the created document.
|
|
53
|
+
*/
|
|
54
|
+
export function useCreateDocument<TData extends Record<string, unknown>>(
|
|
55
|
+
options: DocumentTypeHandle,
|
|
56
|
+
): (
|
|
57
|
+
initialValue?: Partial<Omit<TData, IgnoredKey>>,
|
|
58
|
+
overrides?: CreateDocumentOverrides,
|
|
59
|
+
) => Promise<DocumentHandle>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @public
|
|
63
|
+
* Provides a function to create a new document and returns its handle.
|
|
64
|
+
*
|
|
65
|
+
* @category Documents
|
|
66
|
+
* @remarks
|
|
67
|
+
* This is the create counterpart to {@link useEditDocument}. It wraps
|
|
68
|
+
* {@link useApplyDocumentActions} and the `createDocument` action for the common
|
|
69
|
+
* single-document case, so you don't have to assemble the action by hand.
|
|
70
|
+
*
|
|
71
|
+
* It handles the document ID for you: if you don't supply one (on the handle or
|
|
72
|
+
* via the per-call `{documentId}` override), a UUID is generated. Either way the
|
|
73
|
+
* returned {@link DocumentHandle} carries that id, ready to pass to
|
|
74
|
+
* {@link useDocument}, {@link useEditDocument}, or your router.
|
|
75
|
+
*
|
|
76
|
+
* Unlike {@link useEditDocument}, this hook does not read existing document state,
|
|
77
|
+
* so it never suspends.
|
|
78
|
+
*
|
|
79
|
+
* For atomic create-and-publish, or for creating several documents in a single
|
|
80
|
+
* transaction, use {@link useApplyDocumentActions} with the `createDocument` and
|
|
81
|
+
* `publishDocument` action creators directly.
|
|
82
|
+
*
|
|
83
|
+
* @example Create a document and navigate to it
|
|
84
|
+
* ```tsx
|
|
85
|
+
* import {useCreateDocument} from '@sanity/sdk-react'
|
|
86
|
+
* import {useNavigate} from 'react-router-dom'
|
|
87
|
+
*
|
|
88
|
+
* function CreateArticleButton() {
|
|
89
|
+
* const createArticle = useCreateDocument({documentType: 'article'})
|
|
90
|
+
* const navigate = useNavigate()
|
|
91
|
+
*
|
|
92
|
+
* const handleClick = async () => {
|
|
93
|
+
* const handle = await createArticle({title: 'New Article'})
|
|
94
|
+
* navigate(`/articles/${handle.documentId}`)
|
|
95
|
+
* }
|
|
96
|
+
*
|
|
97
|
+
* return <button onClick={handleClick}>Create Article</button>
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function useCreateDocument(
|
|
102
|
+
options: DocumentTypeHandle,
|
|
103
|
+
): (
|
|
104
|
+
initialValue?: Record<string, unknown>,
|
|
105
|
+
overrides?: CreateDocumentOverrides,
|
|
106
|
+
) => Promise<DocumentHandle> {
|
|
107
|
+
const instance = useSanityInstance()
|
|
108
|
+
trackHookUsage(instance, 'useCreateDocument')
|
|
109
|
+
const apply = useApplyDocumentActions()
|
|
110
|
+
|
|
111
|
+
return async (initialValue, overrides) => {
|
|
112
|
+
const documentId = overrides?.documentId ?? options.documentId ?? crypto.randomUUID()
|
|
113
|
+
const handle: DocumentHandle = {...options, documentId}
|
|
114
|
+
await apply(createDocument(handle, initialValue))
|
|
115
|
+
return handle
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -7,9 +7,9 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
|
7
7
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
8
8
|
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
9
9
|
// used in an `{@link useDocumentProjection}` and `{@link useQuery}`
|
|
10
|
-
// eslint-disable-next-line import/consistent-type-specifier-style
|
|
10
|
+
// eslint-disable-next-line import-x/consistent-type-specifier-style
|
|
11
11
|
import type {useDocumentProjection} from '../projection/useDocumentProjection'
|
|
12
|
-
// eslint-disable-next-line import/consistent-type-specifier-style
|
|
12
|
+
// eslint-disable-next-line import-x/consistent-type-specifier-style
|
|
13
13
|
import type {useQuery} from '../query/useQuery'
|
|
14
14
|
|
|
15
15
|
const useDocumentValue = createStateSourceHook({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {type DocumentResource} from '@sanity/sdk'
|
|
2
|
+
import {renderHook} from '@testing-library/react'
|
|
3
|
+
import {type ReactNode} from 'react'
|
|
4
|
+
import {describe, expect, it} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {ResourceContext} from '../../context/DefaultResourceContext'
|
|
7
|
+
import {ProjectContext} from '../../context/ProjectContext'
|
|
8
|
+
import {useResolvedProjectId} from './useResolvedProjectId'
|
|
9
|
+
|
|
10
|
+
const datasetResource: DocumentResource = {projectId: 'resource-project', dataset: 'production'}
|
|
11
|
+
|
|
12
|
+
describe('useResolvedProjectId', () => {
|
|
13
|
+
it('prefers an explicit projectId on the options', () => {
|
|
14
|
+
const {result} = renderHook(() => useResolvedProjectId({projectId: 'option-project'}), {
|
|
15
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
16
|
+
<ResourceContext.Provider value={datasetResource}>
|
|
17
|
+
<ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
|
|
18
|
+
</ResourceContext.Provider>
|
|
19
|
+
),
|
|
20
|
+
})
|
|
21
|
+
expect(result.current).toBe('option-project')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to the ambient project scope (ProjectContext) over the resource', () => {
|
|
25
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
26
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
27
|
+
<ResourceContext.Provider value={datasetResource}>
|
|
28
|
+
<ProjectContext.Provider value="context-project">{children}</ProjectContext.Provider>
|
|
29
|
+
</ResourceContext.Provider>
|
|
30
|
+
),
|
|
31
|
+
})
|
|
32
|
+
expect(result.current).toBe('context-project')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('falls back to the resolved resource projectId', () => {
|
|
36
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
37
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
38
|
+
<ResourceContext.Provider value={datasetResource}>{children}</ResourceContext.Provider>
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
expect(result.current).toBe('resource-project')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('ignores a non-dataset resource (e.g. media library)', () => {
|
|
45
|
+
const {result} = renderHook(() => useResolvedProjectId(), {
|
|
46
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
47
|
+
<ResourceContext.Provider value={{mediaLibraryId: 'ml-id'}}>
|
|
48
|
+
{children}
|
|
49
|
+
</ResourceContext.Provider>
|
|
50
|
+
),
|
|
51
|
+
})
|
|
52
|
+
expect(result.current).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns undefined when nothing resolves', () => {
|
|
56
|
+
const {result} = renderHook(() => useResolvedProjectId())
|
|
57
|
+
expect(result.current).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {type DocumentResource, isDatasetResource} from '@sanity/sdk'
|
|
2
|
+
import {useContext} from 'react'
|
|
3
|
+
|
|
4
|
+
import {ProjectContext} from '../../context/ProjectContext'
|
|
5
|
+
import {useNormalizedResourceOptions} from './useNormalizedResourceOptions'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the effective `projectId` for project-scoped hooks (`useProject`,
|
|
9
|
+
* `useDatasets`, `useUsers`).
|
|
10
|
+
*
|
|
11
|
+
* Precedence:
|
|
12
|
+
* 1. an explicit `projectId` on the options
|
|
13
|
+
* 2. the ambient project scope (`ProjectContext`, e.g. a dataset-less
|
|
14
|
+
* `<ResourceProvider projectId="…">`)
|
|
15
|
+
* 3. the resolved resource's projectId (`ResourceProvider`/`SDKProvider`)
|
|
16
|
+
*
|
|
17
|
+
* Returns `undefined` when none apply, letting callers fall back to core's
|
|
18
|
+
* `instance.config.projectId`.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function useResolvedProjectId(options?: {
|
|
23
|
+
projectId?: string
|
|
24
|
+
dataset?: string
|
|
25
|
+
resource?: DocumentResource
|
|
26
|
+
resourceName?: string
|
|
27
|
+
}): string | undefined {
|
|
28
|
+
const {resource} = useNormalizedResourceOptions(options ?? {})
|
|
29
|
+
const contextProjectId = useContext(ProjectContext)
|
|
30
|
+
return (
|
|
31
|
+
options?.projectId ??
|
|
32
|
+
contextProjectId ??
|
|
33
|
+
(resource && isDatasetResource(resource) ? resource.projectId : undefined)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSanityInstance,
|
|
3
|
+
getProjectState,
|
|
4
|
+
type Project,
|
|
5
|
+
resolveProject,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {type ReactNode} from 'react'
|
|
9
|
+
import {type Observable} from 'rxjs'
|
|
10
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {renderHook} from '../../../test/test-utils'
|
|
13
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
14
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
15
|
+
import {useProject} from './useProject'
|
|
16
|
+
|
|
17
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
+
return {...original, getProjectState: vi.fn(), resolveProject: vi.fn()}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const stateSource = (current: Project | undefined): StateSource<Project | undefined> =>
|
|
23
|
+
({
|
|
24
|
+
getCurrent: vi.fn(() => current),
|
|
25
|
+
subscribe: vi.fn(),
|
|
26
|
+
get observable(): Observable<unknown> {
|
|
27
|
+
throw new Error('Not implemented')
|
|
28
|
+
},
|
|
29
|
+
}) as unknown as StateSource<Project | undefined>
|
|
30
|
+
|
|
31
|
+
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
32
|
+
|
|
33
|
+
describe('useProject', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.clearAllMocks()
|
|
36
|
+
vi.mocked(getProjectState).mockReturnValue(stateSource({id: 'p'} as unknown as Project))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('resolves the projectId from the instance config resource', () => {
|
|
40
|
+
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
41
|
+
renderHook(() => useProject())
|
|
42
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
43
|
+
sanityInstance,
|
|
44
|
+
expect.objectContaining({projectId: 'test'}),
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('lets an explicit projectId override the ambient resource', () => {
|
|
49
|
+
renderHook(() => useProject({projectId: 'explicit-project'}))
|
|
50
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
51
|
+
sanityInstance,
|
|
52
|
+
expect.objectContaining({projectId: 'explicit-project'}),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('resolves the projectId from an explicit resource when the config has none', () => {
|
|
57
|
+
renderHook(() => useProject(), {
|
|
58
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
59
|
+
<ResourceProvider
|
|
60
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
61
|
+
fallback={null}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</ResourceProvider>
|
|
65
|
+
),
|
|
66
|
+
})
|
|
67
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
68
|
+
sanityInstance,
|
|
69
|
+
expect.objectContaining({projectId: 'resource-project'}),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('resolves a dataset-less projectId config for project-scoped use', () => {
|
|
74
|
+
renderHook(() => useProject(), {
|
|
75
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
76
|
+
<ResourceProvider projectId="config-project" fallback={null}>
|
|
77
|
+
{children}
|
|
78
|
+
</ResourceProvider>
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
82
|
+
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
83
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
84
|
+
sanityInstance,
|
|
85
|
+
expect.objectContaining({projectId: 'config-project'}),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
|
|
90
|
+
// An instance with no project/dataset config and no ambient
|
|
91
|
+
// resource, then a projectId-only ResourceProvider.
|
|
92
|
+
const emptyInstance = createSanityInstance({})
|
|
93
|
+
renderHook(() => useProject(), {
|
|
94
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
95
|
+
<SanityInstanceContext.Provider value={emptyInstance}>
|
|
96
|
+
<ResourceProvider projectId="bare-project" fallback={null}>
|
|
97
|
+
{children}
|
|
98
|
+
</ResourceProvider>
|
|
99
|
+
</SanityInstanceContext.Provider>
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
expect(getProjectState).toHaveBeenCalledWith(
|
|
103
|
+
emptyInstance,
|
|
104
|
+
expect.objectContaining({projectId: 'bare-project'}),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('suspends via resolveProject until project data is available', () => {
|
|
109
|
+
vi.mocked(getProjectState).mockReturnValue(stateSource(undefined))
|
|
110
|
+
vi.mocked(resolveProject).mockReturnValue(new Promise(() => {}))
|
|
111
|
+
renderHook(() => useProject())
|
|
112
|
+
expect(resolveProject).toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import {getProjectState, type Project, type ProjectOptions, resolveProject} from '@sanity/sdk'
|
|
2
|
-
import {identity} from 'rxjs'
|
|
3
2
|
|
|
4
3
|
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
4
|
+
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
5
|
+
|
|
6
|
+
const useProjectBase = createStateSourceHook({
|
|
7
|
+
getState: getProjectState,
|
|
8
|
+
shouldSuspend: (instance, ...params) =>
|
|
9
|
+
getProjectState(instance, ...params).getCurrent() === undefined,
|
|
10
|
+
suspender: resolveProject,
|
|
11
|
+
})
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Returns metadata for a given project.
|
|
@@ -29,15 +36,18 @@ import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
|
29
36
|
* const projectWithoutMembers = useProject({projectId, includeMembers: false})
|
|
30
37
|
* const projectWithoutFeatures = useProject({projectId, includeFeatures: false})
|
|
31
38
|
* ```
|
|
39
|
+
* @remarks
|
|
40
|
+
* The `projectId` is resolved in order from:
|
|
41
|
+
* 1. an explicit `projectId` option
|
|
42
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
43
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
44
|
+
* 4. `instance.config`.
|
|
32
45
|
* @public
|
|
33
46
|
* @function
|
|
34
47
|
*/
|
|
35
|
-
export const useProject =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
getProjectState(instance, ...params).getCurrent() === undefined,
|
|
39
|
-
suspender: resolveProject,
|
|
40
|
-
getConfig: identity,
|
|
48
|
+
export const useProject = ((options?: ProjectOptions<boolean, boolean>) => {
|
|
49
|
+
const projectId = useResolvedProjectId(options)
|
|
50
|
+
return useProjectBase(projectId ? {...options, projectId} : options)
|
|
41
51
|
}) as <IncludeMembers extends boolean = true, IncludeFeatures extends boolean = true>(
|
|
42
52
|
options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
|
|
43
53
|
) => Project<IncludeMembers, IncludeFeatures>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createSanityInstance,
|
|
2
3
|
getUsersState,
|
|
3
4
|
loadMoreUsers,
|
|
4
5
|
resolveUsers,
|
|
@@ -6,12 +7,14 @@ import {
|
|
|
6
7
|
type StateSource,
|
|
7
8
|
type UserProfile,
|
|
8
9
|
} from '@sanity/sdk'
|
|
9
|
-
import {act, fireEvent, render, screen} from '@testing-library/react'
|
|
10
|
-
import {useState} from 'react'
|
|
10
|
+
import {act, fireEvent, render, renderHook, screen} from '@testing-library/react'
|
|
11
|
+
import {type ReactNode, useState} from 'react'
|
|
11
12
|
import {type Observable, Subject} from 'rxjs'
|
|
12
13
|
import {describe, expect, it, vi} from 'vitest'
|
|
13
14
|
|
|
14
15
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
16
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
17
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
15
18
|
import {useUsers} from './useUsers'
|
|
16
19
|
|
|
17
20
|
// Mock the functions from '@sanity/sdk'
|
|
@@ -335,4 +338,100 @@ describe('useUsers', () => {
|
|
|
335
338
|
},
|
|
336
339
|
)
|
|
337
340
|
})
|
|
341
|
+
|
|
342
|
+
it('resolves the projectId from the default resource for a project-scoped query', () => {
|
|
343
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
344
|
+
getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
|
|
345
|
+
subscribe: vi.fn(),
|
|
346
|
+
get observable(): Observable<unknown> {
|
|
347
|
+
throw new Error('Not implemented')
|
|
348
|
+
},
|
|
349
|
+
} as unknown as StateSource<
|
|
350
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
351
|
+
>)
|
|
352
|
+
|
|
353
|
+
const {
|
|
354
|
+
result: {current: instance},
|
|
355
|
+
} = renderHook(
|
|
356
|
+
() => {
|
|
357
|
+
useUsers({resourceType: 'project'})
|
|
358
|
+
return useSanityInstance()
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
362
|
+
<ResourceProvider
|
|
363
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
364
|
+
fallback={null}
|
|
365
|
+
>
|
|
366
|
+
{children}
|
|
367
|
+
</ResourceProvider>
|
|
368
|
+
),
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
expect(getUsersState).toHaveBeenLastCalledWith(
|
|
373
|
+
instance,
|
|
374
|
+
expect.objectContaining({projectId: 'resource-project', resourceType: 'project'}),
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('does not inject a projectId for an organization-scoped query', () => {
|
|
379
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
380
|
+
getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
|
|
381
|
+
subscribe: vi.fn(),
|
|
382
|
+
get observable(): Observable<unknown> {
|
|
383
|
+
throw new Error('Not implemented')
|
|
384
|
+
},
|
|
385
|
+
} as unknown as StateSource<
|
|
386
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
387
|
+
>)
|
|
388
|
+
|
|
389
|
+
function TestComponent() {
|
|
390
|
+
useUsers({resourceType: 'organization', organizationId: 'test-org'})
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
render(
|
|
395
|
+
<ResourceProvider
|
|
396
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
397
|
+
fallback={null}
|
|
398
|
+
>
|
|
399
|
+
<TestComponent />
|
|
400
|
+
</ResourceProvider>,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
// Organization queries key off organizationId; the ambient project resource
|
|
404
|
+
// must not leak in as a projectId.
|
|
405
|
+
expect(vi.mocked(getUsersState).mock.lastCall?.[1]).not.toHaveProperty('projectId')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
|
|
409
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
410
|
+
getCurrent: vi.fn().mockReturnValue({data: mockUsers, hasMore: false, totalCount: 2}),
|
|
411
|
+
subscribe: vi.fn(),
|
|
412
|
+
get observable(): Observable<unknown> {
|
|
413
|
+
throw new Error('Not implemented')
|
|
414
|
+
},
|
|
415
|
+
} as unknown as StateSource<
|
|
416
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
417
|
+
>)
|
|
418
|
+
|
|
419
|
+
// An instance with no project/dataset config, then a
|
|
420
|
+
// projectId-only ResourceProvider.
|
|
421
|
+
const emptyInstance = createSanityInstance({})
|
|
422
|
+
renderHook(() => useUsers(), {
|
|
423
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
424
|
+
<SanityInstanceContext.Provider value={emptyInstance}>
|
|
425
|
+
<ResourceProvider projectId="bare-project" fallback={null}>
|
|
426
|
+
{children}
|
|
427
|
+
</ResourceProvider>
|
|
428
|
+
</SanityInstanceContext.Provider>
|
|
429
|
+
),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
expect(getUsersState).toHaveBeenLastCalledWith(
|
|
433
|
+
emptyInstance,
|
|
434
|
+
expect.objectContaining({projectId: 'bare-project'}),
|
|
435
|
+
)
|
|
436
|
+
})
|
|
338
437
|
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {useCallback, useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
|
|
11
11
|
|
|
12
12
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
13
|
+
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
13
14
|
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -36,6 +37,23 @@ export interface UsersResult {
|
|
|
36
37
|
loadMore: () => void
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Injects a resolved `projectId` into project-scoped users options. A missing
|
|
42
|
+
* `projectId` is a no-op, and organization-scoped queries (explicit
|
|
43
|
+
* `resourceType`/`organizationId`) or options that already carry a `projectId`
|
|
44
|
+
* are returned unchanged.
|
|
45
|
+
*/
|
|
46
|
+
function withResolvedProjectId(
|
|
47
|
+
options: GetUsersOptions | undefined,
|
|
48
|
+
projectId: string | undefined,
|
|
49
|
+
): GetUsersOptions | undefined {
|
|
50
|
+
if (!projectId) return options
|
|
51
|
+
if (!options) return {projectId}
|
|
52
|
+
const isOrgScoped = options.resourceType === 'organization' || !!options.organizationId
|
|
53
|
+
if (isOrgScoped || options.projectId) return options
|
|
54
|
+
return {...options, projectId}
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/**
|
|
40
58
|
*
|
|
41
59
|
* @public
|
|
@@ -67,6 +85,12 @@ export interface UsersResult {
|
|
|
67
85
|
* </div>
|
|
68
86
|
* )
|
|
69
87
|
* ```
|
|
88
|
+
* @remarks
|
|
89
|
+
* For project-scoped queries the `projectId` is resolved in order from:
|
|
90
|
+
* 1. an explicit `projectId` option
|
|
91
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
92
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
93
|
+
* 4. `instance.config`.
|
|
70
94
|
*/
|
|
71
95
|
export function useUsers(options?: GetUsersOptions): UsersResult {
|
|
72
96
|
const instance = useSanityInstance()
|
|
@@ -74,8 +98,16 @@ export function useUsers(options?: GetUsersOptions): UsersResult {
|
|
|
74
98
|
// Use React's useTransition to avoid UI jank when user options change
|
|
75
99
|
const [isPending, startTransition] = useTransition()
|
|
76
100
|
|
|
101
|
+
// Resolve the projectId from the ambient project/resource context so a
|
|
102
|
+
// project-scoped users request can pick it up rather than the top-level config.
|
|
103
|
+
const resolvedProjectId = useResolvedProjectId(options)
|
|
104
|
+
const effectiveOptions = useMemo(
|
|
105
|
+
() => withResolvedProjectId(options, resolvedProjectId),
|
|
106
|
+
[options, resolvedProjectId],
|
|
107
|
+
)
|
|
108
|
+
|
|
77
109
|
// Get the unique key for this users request and its options
|
|
78
|
-
const key = getUsersKey(instance,
|
|
110
|
+
const key = getUsersKey(instance, effectiveOptions)
|
|
79
111
|
// Use a deferred state to avoid immediate re-renders when the users request changes
|
|
80
112
|
const [deferredKey, setDeferredKey] = useState(key)
|
|
81
113
|
// Parse the deferred users key back into users options
|
|
@@ -115,8 +147,8 @@ export function useUsers(options?: GetUsersOptions): UsersResult {
|
|
|
115
147
|
const {data, hasMore} = useSyncExternalStore(subscribe, getCurrent)!
|
|
116
148
|
|
|
117
149
|
const loadMore = useCallback(() => {
|
|
118
|
-
loadMoreUsers(instance,
|
|
119
|
-
}, [instance,
|
|
150
|
+
loadMoreUsers(instance, effectiveOptions)
|
|
151
|
+
}, [instance, effectiveOptions])
|
|
120
152
|
|
|
121
153
|
return {data, hasMore, isPending, loadMore}
|
|
122
154
|
}
|