@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2
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 +103 -155
- package/dist/index.d.ts +2488 -2019
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4435 -1529
- package/dist/index.js.map +1 -1
- package/package.json +55 -59
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +30 -18
- package/src/components/SDKProvider.test.tsx +173 -32
- package/src/components/SDKProvider.tsx +87 -28
- package/src/components/SanityApp.test.tsx +68 -12
- package/src/components/SanityApp.tsx +93 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +7 -7
- package/src/components/auth/AuthBoundary.tsx +29 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +104 -61
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/CorsErrorComponent.tsx +2 -2
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +29 -27
- package/src/constants.ts +5 -0
- package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
- package/src/context/ComlinkTokenRefresh.tsx +3 -5
- package/src/context/DefaultResourceContext.ts +3 -3
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/PerspectiveContext.ts +3 -3
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +93 -90
- package/src/context/ResourceProvider.tsx +62 -93
- package/src/context/SDKStudioContext.test.tsx +28 -33
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
- package/src/context/WorkbenchTokenRefresh.tsx +61 -0
- package/src/context/renderSanityApp.test.tsx +151 -49
- package/src/context/renderSanityApp.tsx +12 -8
- package/src/context/workbenchToken.ts +63 -0
- package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
- package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
- package/src/hooks/access/useCheckPermissions.ts +24 -0
- package/src/hooks/agent/agentActions.test.tsx +2 -2
- package/src/hooks/agent/agentActions.ts +52 -72
- package/src/hooks/agent/useAgentResourceContext.ts +1 -1
- package/src/hooks/applications/useApplication.test-d.ts +30 -0
- package/src/hooks/applications/useApplication.ts +22 -0
- package/src/hooks/applications/useApplications.test-d.ts +31 -0
- package/src/hooks/applications/useApplications.ts +25 -0
- package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -0
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/client/useClient.test.tsx +1 -4
- package/src/hooks/client/useClient.ts +1 -0
- package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
- package/src/hooks/comlink/useFrameConnection.ts +1 -1
- package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
- package/src/hooks/comlink/useWindowConnection.ts +2 -3
- package/src/hooks/comments/useCommentActions.test.tsx +150 -0
- package/src/hooks/comments/useCommentActions.ts +109 -0
- package/src/hooks/comments/useCommentList.ts +79 -0
- package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
- package/src/hooks/comments/useCommentThreads.ts +73 -0
- package/src/hooks/comments/useComments.test.tsx +242 -0
- package/src/hooks/comments/useComments.ts +59 -0
- package/src/hooks/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +18 -1
- package/src/hooks/context/useSanityInstance.ts +6 -6
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +11 -17
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
- package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
- package/src/hooks/dashboard/useWindowTitle.ts +114 -0
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +33 -13
- package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
- package/src/hooks/document/useApplyDocumentActions.ts +66 -48
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.test.tsx +37 -8
- package/src/hooks/document/useDocument.ts +131 -66
- package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
- package/src/hooks/document/useDocumentEvent.ts +16 -10
- package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
- package/src/hooks/document/useDocumentPermissions.ts +71 -39
- package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
- package/src/hooks/document/useEditDocument.test.tsx +21 -14
- package/src/hooks/document/useEditDocument.ts +163 -17
- package/src/hooks/documents/useDocuments.test.tsx +64 -44
- package/src/hooks/documents/useDocuments.ts +31 -20
- package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
- package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
- package/src/hooks/helpers/createFetcherHook.ts +69 -0
- package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
- package/src/hooks/helpers/createMutationHook.tsx +93 -0
- package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
- package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
- package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
- package/src/hooks/helpers/useApplyActions.ts +68 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/installations/useInstallation.test-d.ts +19 -0
- package/src/hooks/installations/useInstallation.ts +22 -0
- package/src/hooks/installations/useInstallations.test-d.ts +26 -0
- package/src/hooks/installations/useInstallations.ts +25 -0
- package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
- package/src/hooks/organizations/useOrganization.test.ts +63 -0
- package/src/hooks/organizations/useOrganization.ts +34 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
- package/src/hooks/organizations/useOrganizations.test.ts +63 -0
- package/src/hooks/organizations/useOrganizations.ts +39 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +22 -20
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +12 -14
- package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
- package/src/hooks/projection/useDocumentProjection.ts +81 -2
- package/src/hooks/projects/useProject.test-d.ts +56 -0
- package/src/hooks/projects/useProject.test.tsx +120 -0
- package/src/hooks/projects/useProject.ts +42 -43
- package/src/hooks/projects/useProjects.test-d.ts +60 -0
- package/src/hooks/projects/useProjects.test.ts +45 -98
- package/src/hooks/projects/useProjects.ts +17 -32
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +94 -17
- package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
- package/src/hooks/releases/useActiveReleases.ts +28 -24
- package/src/hooks/releases/useAllReleases.test.tsx +93 -0
- package/src/hooks/releases/useAllReleases.ts +62 -0
- package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
- package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
- package/src/hooks/releases/usePerspective.test.tsx +18 -10
- package/src/hooks/releases/usePerspective.ts +13 -17
- package/src/hooks/users/useUser.test.tsx +3 -9
- package/src/hooks/users/useUser.ts +4 -8
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +38 -5
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/context/useDefaultResource.test.tsx +0 -25
- package/src/hooks/context/useDefaultResource.ts +0 -30
- package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
- package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
- package/src/hooks/datasets/useDatasets.test.ts +0 -66
- package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
- package/src/hooks/projects/useProject.test.ts +0 -79
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
|
|
2
|
+
import {act, render, screen} from '@testing-library/react'
|
|
2
3
|
import {useRef} from 'react'
|
|
3
4
|
import {type Mock} from 'vitest'
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
7
|
import {useDocumentProjection} from './useDocumentProjection'
|
|
7
8
|
|
|
8
9
|
// Mock IntersectionObserver
|
|
@@ -39,7 +40,6 @@ vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
|
39
40
|
const mockDocument: DocumentHandle = {
|
|
40
41
|
documentId: 'doc1',
|
|
41
42
|
documentType: 'exampleType',
|
|
42
|
-
resource: {projectId: 'p', dataset: 'd'},
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
interface ProjectionResult {
|
|
@@ -85,7 +85,11 @@ describe('useDocumentProjection', () => {
|
|
|
85
85
|
const eventsUnsubscribe = vi.fn()
|
|
86
86
|
subscribe.mockImplementation(() => eventsUnsubscribe)
|
|
87
87
|
|
|
88
|
-
render(
|
|
88
|
+
render(
|
|
89
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
90
|
+
<TestComponent document={mockDocument} projection="{name, description}" />
|
|
91
|
+
</ResourceProvider>,
|
|
92
|
+
)
|
|
89
93
|
|
|
90
94
|
// Initially, element is not intersecting
|
|
91
95
|
expect(screen.getByText('Initial Title')).toBeInTheDocument()
|
|
@@ -110,6 +114,47 @@ describe('useDocumentProjection', () => {
|
|
|
110
114
|
expect(eventsUnsubscribe).toHaveBeenCalled()
|
|
111
115
|
})
|
|
112
116
|
|
|
117
|
+
test('it re-reads the current snapshot when the element becomes visible again after an off-screen update', async () => {
|
|
118
|
+
// The store notifies subscribers only on changes *after* subscribe (it skips the
|
|
119
|
+
// value current at subscribe time), so the mocked subscribe never invokes its
|
|
120
|
+
// callback — matching real behavior. The hook must therefore re-read getCurrent()
|
|
121
|
+
// itself whenever the visibility gate (re)opens, otherwise a value that updated
|
|
122
|
+
// while the element was off-screen stays frozen on screen.
|
|
123
|
+
getCurrent.mockReturnValue({
|
|
124
|
+
data: {title: 'Initial Title', description: 'Initial Description'},
|
|
125
|
+
isPending: false,
|
|
126
|
+
})
|
|
127
|
+
const eventsUnsubscribe = vi.fn()
|
|
128
|
+
subscribe.mockImplementation(() => eventsUnsubscribe)
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
132
|
+
<TestComponent document={mockDocument} projection="{name, description}" />
|
|
133
|
+
</ResourceProvider>,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Become visible, then hidden — mirrors a card being scrolled out of view.
|
|
137
|
+
await act(async () => {
|
|
138
|
+
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
139
|
+
})
|
|
140
|
+
await act(async () => {
|
|
141
|
+
intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// The underlying document changes while the element is off-screen.
|
|
145
|
+
getCurrent.mockReturnValue({
|
|
146
|
+
data: {title: 'Updated Title', description: 'Updated Description'},
|
|
147
|
+
isPending: false,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Becoming visible again must surface the value that changed while hidden.
|
|
151
|
+
await act(async () => {
|
|
152
|
+
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(screen.getByText('Updated Title')).toBeInTheDocument()
|
|
156
|
+
})
|
|
157
|
+
|
|
113
158
|
test('it suspends and resolves data when element becomes visible', async () => {
|
|
114
159
|
// Mock the initial state to trigger suspense
|
|
115
160
|
getCurrent.mockReturnValueOnce({
|
|
@@ -131,7 +176,11 @@ describe('useDocumentProjection', () => {
|
|
|
131
176
|
// Setup subscription that does nothing (we'll manually trigger updates)
|
|
132
177
|
subscribe.mockReturnValue(() => {})
|
|
133
178
|
|
|
134
|
-
render(
|
|
179
|
+
render(
|
|
180
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
181
|
+
<TestComponent document={mockDocument} projection="{title, description}" />
|
|
182
|
+
</ResourceProvider>,
|
|
183
|
+
)
|
|
135
184
|
|
|
136
185
|
await act(async () => {
|
|
137
186
|
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
@@ -154,7 +203,11 @@ describe('useDocumentProjection', () => {
|
|
|
154
203
|
})
|
|
155
204
|
subscribe.mockImplementation(() => vi.fn())
|
|
156
205
|
|
|
157
|
-
render(
|
|
206
|
+
render(
|
|
207
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
208
|
+
<TestComponent document={mockDocument} projection="{title, description}" />
|
|
209
|
+
</ResourceProvider>,
|
|
210
|
+
)
|
|
158
211
|
|
|
159
212
|
expect(screen.getByText('Fallback Title')).toBeInTheDocument()
|
|
160
213
|
|
|
@@ -170,7 +223,11 @@ describe('useDocumentProjection', () => {
|
|
|
170
223
|
const eventsUnsubscribe = vi.fn()
|
|
171
224
|
subscribe.mockImplementation(() => eventsUnsubscribe)
|
|
172
225
|
|
|
173
|
-
const {rerender} = render(
|
|
226
|
+
const {rerender} = render(
|
|
227
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
228
|
+
<TestComponent document={mockDocument} projection="{title}" />
|
|
229
|
+
</ResourceProvider>,
|
|
230
|
+
)
|
|
174
231
|
|
|
175
232
|
// Change projection
|
|
176
233
|
getCurrent.mockReturnValue({
|
|
@@ -178,7 +235,11 @@ describe('useDocumentProjection', () => {
|
|
|
178
235
|
isPending: false,
|
|
179
236
|
})
|
|
180
237
|
|
|
181
|
-
rerender(
|
|
238
|
+
rerender(
|
|
239
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
240
|
+
<TestComponent document={mockDocument} projection="{title, description}" />
|
|
241
|
+
</ResourceProvider>,
|
|
242
|
+
)
|
|
182
243
|
|
|
183
244
|
expect(screen.getByText('Updated Title')).toBeInTheDocument()
|
|
184
245
|
expect(screen.getByText('Added Description')).toBeInTheDocument()
|
|
@@ -202,7 +263,11 @@ describe('useDocumentProjection', () => {
|
|
|
202
263
|
)
|
|
203
264
|
}
|
|
204
265
|
|
|
205
|
-
render(
|
|
266
|
+
render(
|
|
267
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
268
|
+
<NoRefComponent {...mockDocument} projection="{title, description}" />
|
|
269
|
+
</ResourceProvider>,
|
|
270
|
+
)
|
|
206
271
|
|
|
207
272
|
// Should subscribe immediately without waiting for intersection
|
|
208
273
|
expect(subscribe).toHaveBeenCalled()
|
|
@@ -231,7 +296,11 @@ describe('useDocumentProjection', () => {
|
|
|
231
296
|
)
|
|
232
297
|
}
|
|
233
298
|
|
|
234
|
-
render(
|
|
299
|
+
render(
|
|
300
|
+
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
301
|
+
<NonHtmlRefComponent {...mockDocument} projection="{title, description}" />
|
|
302
|
+
</ResourceProvider>,
|
|
303
|
+
)
|
|
235
304
|
|
|
236
305
|
// Should subscribe immediately without waiting for intersection
|
|
237
306
|
expect(subscribe).toHaveBeenCalled()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {getProjectionState, resolveProjection} from '@sanity/sdk'
|
|
2
|
+
import {type SanityProjectionResult} from 'groq'
|
|
2
3
|
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
3
4
|
import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
|
|
4
5
|
|
|
5
6
|
import {type DocumentHandle} from '../../config/handles'
|
|
6
7
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
7
8
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
9
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* @public
|
|
@@ -45,13 +47,83 @@ export interface useDocumentProjectionResults<TData> {
|
|
|
45
47
|
*
|
|
46
48
|
* @category Documents
|
|
47
49
|
* @remarks
|
|
48
|
-
* This hook
|
|
50
|
+
* This hook has multiple signatures allowing for fine-grained control over type inference:
|
|
51
|
+
* - Using Typegen: Infers the return type based on the `documentType`, `dataset`, `projectId`, and `projection`.
|
|
52
|
+
* - Using explicit type parameter: Allows specifying a custom return type `TData`.
|
|
49
53
|
*
|
|
50
54
|
* @param options - An object containing the `DocumentHandle` properties (`documentId`, `documentType`, etc.), the `projection` string, optional `params`, and an optional `ref`.
|
|
51
55
|
* @returns An object containing the projection results (`data`) and a boolean indicating whether the resolution is pending (`isPending`). Note: Suspense handles initial loading states; `data` being `undefined` after initial loading means the document doesn't exist or the projection yielded no result.
|
|
52
56
|
*/
|
|
53
57
|
|
|
54
|
-
// Overload 1:
|
|
58
|
+
// Overload 1: Relies on Typegen
|
|
59
|
+
/**
|
|
60
|
+
* @public
|
|
61
|
+
* Fetch a projection, relying on Typegen for the return type based on the handle and projection.
|
|
62
|
+
*
|
|
63
|
+
* @category Documents
|
|
64
|
+
* @param options - Options including the document handle properties (`documentId`, `documentType`, etc.) and the `projection`.
|
|
65
|
+
* @returns The projected data, typed based on Typegen.
|
|
66
|
+
*
|
|
67
|
+
* @example Using Typegen for a book preview
|
|
68
|
+
* ```tsx
|
|
69
|
+
* // ProjectionComponent.tsx
|
|
70
|
+
* import {useDocumentProjection, type DocumentHandle} from '@sanity/sdk-react'
|
|
71
|
+
* import {useRef} from 'react'
|
|
72
|
+
* import {defineProjection} from 'groq'
|
|
73
|
+
*
|
|
74
|
+
* // Define props using DocumentHandle with the specific document type
|
|
75
|
+
* type ProjectionComponentProps = {
|
|
76
|
+
* doc: DocumentHandle<'book'> // Typegen knows 'book'
|
|
77
|
+
* }
|
|
78
|
+
*
|
|
79
|
+
* // This is required for typegen to generate the correct return type
|
|
80
|
+
* const myProjection = defineProjection(`{
|
|
81
|
+
* title,
|
|
82
|
+
* 'coverImage': cover.asset->url,
|
|
83
|
+
* 'authors': array::join(authors[]->{'name': firstName + ' ' + lastName}.name, ', ')
|
|
84
|
+
* }`)
|
|
85
|
+
*
|
|
86
|
+
* export default function ProjectionComponent({ doc }: ProjectionComponentProps) {
|
|
87
|
+
* const ref = useRef(null) // Optional ref to track viewport intersection for lazy loading
|
|
88
|
+
*
|
|
89
|
+
* // Spread the doc handle into the options
|
|
90
|
+
* // Typegen infers the return type based on 'book' and the projection
|
|
91
|
+
* const { data } = useDocumentProjection({
|
|
92
|
+
* ...doc, // Pass the handle properties
|
|
93
|
+
* ref,
|
|
94
|
+
* projection: myProjection,
|
|
95
|
+
* })
|
|
96
|
+
*
|
|
97
|
+
* // Suspense handles initial load, check for data existence after
|
|
98
|
+
* return (
|
|
99
|
+
* <article ref={ref}>
|
|
100
|
+
* <h2>{data.title ?? 'Untitled'}</h2>
|
|
101
|
+
* {data.coverImage && <img src={data.coverImage} alt={data.title} />}
|
|
102
|
+
* <p>{data.authors ?? 'Unknown authors'}</p>
|
|
103
|
+
* </article>
|
|
104
|
+
* )
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* // Usage:
|
|
108
|
+
* // import {createDocumentHandle} from '@sanity/sdk-react'
|
|
109
|
+
* // const myDocHandle = createDocumentHandle({ documentId: 'book123', documentType: 'book' })
|
|
110
|
+
* // <Suspense fallback='Loading preview...'>
|
|
111
|
+
* // <ProjectionComponent doc={myDocHandle} />
|
|
112
|
+
* // </Suspense>
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function useDocumentProjection<
|
|
116
|
+
TProjection extends string = string,
|
|
117
|
+
TDocumentType extends string = string,
|
|
118
|
+
TDataset extends string = string,
|
|
119
|
+
TProjectId extends string = string,
|
|
120
|
+
>(
|
|
121
|
+
options: useDocumentProjectionOptions<TProjection, TDocumentType, TDataset, TProjectId>,
|
|
122
|
+
): useDocumentProjectionResults<
|
|
123
|
+
SanityProjectionResult<TProjection, TDocumentType, `${TProjectId}.${TDataset}`>
|
|
124
|
+
>
|
|
125
|
+
|
|
126
|
+
// Overload 2: Explicit type provided
|
|
55
127
|
/**
|
|
56
128
|
* @public
|
|
57
129
|
* Fetch a projection with an explicitly defined return type `TData`.
|
|
@@ -108,6 +180,7 @@ export function useDocumentProjection<TData extends object>({
|
|
|
108
180
|
...docHandle
|
|
109
181
|
}: useDocumentProjectionOptions): useDocumentProjectionResults<TData> {
|
|
110
182
|
const instance = useSanityInstance()
|
|
183
|
+
trackHookUsage(instance, 'useDocumentProjection')
|
|
111
184
|
|
|
112
185
|
// Normalize projection string to handle template literals with whitespace
|
|
113
186
|
// This ensures that the same projection content produces the same state source
|
|
@@ -162,6 +235,12 @@ export function useDocumentProjection<TData extends object>({
|
|
|
162
235
|
switchMap((isVisible) =>
|
|
163
236
|
isVisible
|
|
164
237
|
? new Observable<void>((obs) => {
|
|
238
|
+
// `stateSource.subscribe` skips the value current at subscribe time;
|
|
239
|
+
// (intentionally -- we only want new events)
|
|
240
|
+
// but in this case the store might have updated while the element was off-screen;
|
|
241
|
+
// so we fire an immediate notification here to make useSyncExternalStore
|
|
242
|
+
// re-read getCurrent() and pick up the fresh value.
|
|
243
|
+
obs.next()
|
|
165
244
|
return stateSource.subscribe(() => obs.next())
|
|
166
245
|
})
|
|
167
246
|
: EMPTY,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {type Project, type ProjectMember} from '@sanity/sdk'
|
|
2
|
+
import {expectTypeOf, test} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useProject} from './useProject'
|
|
5
|
+
|
|
6
|
+
test('useProject — no args: members and features both included by default', () => {
|
|
7
|
+
expectTypeOf(useProject().data).toEqualTypeOf<Project<true, true>>()
|
|
8
|
+
type Result = ReturnType<typeof useProject<true, true>>
|
|
9
|
+
expectTypeOf<Result['data']['members']>().toEqualTypeOf<ProjectMember[]>()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('useProject — returns the FetcherHookResult envelope', () => {
|
|
13
|
+
const result = useProject()
|
|
14
|
+
expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
|
|
15
|
+
expectTypeOf(result.error).toEqualTypeOf<unknown>()
|
|
16
|
+
expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Project<true, true>>>()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('useProject — includeMembers: false drops members from the type', () => {
|
|
20
|
+
expectTypeOf(useProject({includeMembers: false}).data).toEqualTypeOf<Project<false, true>>()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('useProject — includeFeatures: false drops features from the type', () => {
|
|
24
|
+
expectTypeOf(useProject({includeFeatures: false}).data).toEqualTypeOf<Project<true, false>>()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('useProject — both flags true → both arrays present', () => {
|
|
28
|
+
expectTypeOf(useProject({includeMembers: true, includeFeatures: true}).data).toEqualTypeOf<
|
|
29
|
+
Project<true, true>
|
|
30
|
+
>()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('useProject — both flags false → bare base shape', () => {
|
|
34
|
+
expectTypeOf(useProject({includeMembers: false, includeFeatures: false}).data).toEqualTypeOf<
|
|
35
|
+
Project<false, false>
|
|
36
|
+
>()
|
|
37
|
+
type Result = ReturnType<typeof useProject<false, false>>
|
|
38
|
+
expectTypeOf<Result['data']['id']>().toEqualTypeOf<string>()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('useProject — rejects non-boolean flag values', () => {
|
|
42
|
+
// @ts-expect-error — includeMembers must be a boolean
|
|
43
|
+
void useProject({includeMembers: 'yes'})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('useProject — projectId alone does not change the data shape', () => {
|
|
47
|
+
expectTypeOf(useProject({projectId: 'p'}).data).toEqualTypeOf<Project<true, true>>()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('useProject — non-literal boolean flag makes members optional', () => {
|
|
51
|
+
const includeMembers = false as boolean
|
|
52
|
+
expectTypeOf(useProject({includeMembers}).data).toEqualTypeOf<Project<boolean, true>>()
|
|
53
|
+
type Result = ReturnType<typeof useProject<boolean, true>>
|
|
54
|
+
expectTypeOf<Result['data']['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
|
|
55
|
+
expectTypeOf<Pick<Result['data'], 'members'>>().toEqualTypeOf<{members?: ProjectMember[]}>()
|
|
56
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {createSanityInstance, type Project, project, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
3
|
+
import {type ReactNode} from 'react'
|
|
4
|
+
import {type Observable} from 'rxjs'
|
|
5
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {renderHook} from '../../../test/test-utils'
|
|
8
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
9
|
+
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
10
|
+
import {useProject} from './useProject'
|
|
11
|
+
|
|
12
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
13
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
14
|
+
return {...original, project: {getState: vi.fn(), resolveState: vi.fn()}}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const stateSource = (current: Project | undefined): StateSource<FetcherSnapshot<Project>> => {
|
|
18
|
+
// Cache the snapshot: useSyncExternalStore requires a referentially stable current value.
|
|
19
|
+
const snapshot = current
|
|
20
|
+
? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
|
|
21
|
+
: {
|
|
22
|
+
status: 'pending',
|
|
23
|
+
data: undefined,
|
|
24
|
+
error: undefined,
|
|
25
|
+
isFetching: true,
|
|
26
|
+
dataUpdatedAt: undefined,
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
getCurrent: vi.fn(() => snapshot),
|
|
30
|
+
subscribe: vi.fn(() => () => {}),
|
|
31
|
+
get observable(): Observable<unknown> {
|
|
32
|
+
throw new Error('Not implemented')
|
|
33
|
+
},
|
|
34
|
+
} as unknown as StateSource<FetcherSnapshot<Project>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
38
|
+
|
|
39
|
+
describe('useProject', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks()
|
|
42
|
+
vi.mocked(project.getState).mockReturnValue(stateSource({id: 'p'} as unknown as Project))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resolves the projectId from the instance config resource', () => {
|
|
46
|
+
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
47
|
+
renderHook(() => useProject())
|
|
48
|
+
expect(project.getState).toHaveBeenCalledWith(
|
|
49
|
+
sanityInstance,
|
|
50
|
+
expect.objectContaining({projectId: 'test'}),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('lets an explicit projectId override the ambient resource', () => {
|
|
55
|
+
renderHook(() => useProject({projectId: 'explicit-project'}))
|
|
56
|
+
expect(project.getState).toHaveBeenCalledWith(
|
|
57
|
+
sanityInstance,
|
|
58
|
+
expect.objectContaining({projectId: 'explicit-project'}),
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('resolves the projectId from an explicit resource when the config has none', () => {
|
|
63
|
+
renderHook(() => useProject(), {
|
|
64
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
65
|
+
<ResourceProvider
|
|
66
|
+
resource={{projectId: 'resource-project', dataset: 'production'}}
|
|
67
|
+
fallback={null}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</ResourceProvider>
|
|
71
|
+
),
|
|
72
|
+
})
|
|
73
|
+
expect(project.getState).toHaveBeenCalledWith(
|
|
74
|
+
sanityInstance,
|
|
75
|
+
expect.objectContaining({projectId: 'resource-project'}),
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('resolves a dataset-less projectId config for project-scoped use', () => {
|
|
80
|
+
renderHook(() => useProject(), {
|
|
81
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
82
|
+
<ResourceProvider projectId="config-project" fallback={null}>
|
|
83
|
+
{children}
|
|
84
|
+
</ResourceProvider>
|
|
85
|
+
),
|
|
86
|
+
})
|
|
87
|
+
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
88
|
+
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
89
|
+
expect(project.getState).toHaveBeenCalledWith(
|
|
90
|
+
sanityInstance,
|
|
91
|
+
expect.objectContaining({projectId: 'config-project'}),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('resolves a bare projectId from a ResourceProvider when the parent instance has no config', () => {
|
|
96
|
+
// An instance with no project/dataset config and no ambient
|
|
97
|
+
// resource, then a projectId-only ResourceProvider.
|
|
98
|
+
const emptyInstance = createSanityInstance({})
|
|
99
|
+
renderHook(() => useProject(), {
|
|
100
|
+
wrapper: ({children}: {children: ReactNode}) => (
|
|
101
|
+
<SanityInstanceContext.Provider value={emptyInstance}>
|
|
102
|
+
<ResourceProvider projectId="bare-project" fallback={null}>
|
|
103
|
+
{children}
|
|
104
|
+
</ResourceProvider>
|
|
105
|
+
</SanityInstanceContext.Provider>
|
|
106
|
+
),
|
|
107
|
+
})
|
|
108
|
+
expect(project.getState).toHaveBeenCalledWith(
|
|
109
|
+
emptyInstance,
|
|
110
|
+
expect.objectContaining({projectId: 'bare-project'}),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('suspends via the project fetcher until project data is available', () => {
|
|
115
|
+
vi.mocked(project.getState).mockReturnValue(stateSource(undefined))
|
|
116
|
+
vi.mocked(project.resolveState).mockReturnValue(new Promise(() => {}))
|
|
117
|
+
renderHook(() => useProject())
|
|
118
|
+
expect(project.resolveState).toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -1,50 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getProjectState,
|
|
3
|
-
type ProjectHandle,
|
|
4
|
-
resolveProject,
|
|
5
|
-
type SanityInstance,
|
|
6
|
-
type SanityProject,
|
|
7
|
-
type StateSource,
|
|
8
|
-
} from '@sanity/sdk'
|
|
1
|
+
import {type Project, project, type ProjectOptions} from '@sanity/sdk'
|
|
9
2
|
|
|
10
|
-
import {
|
|
3
|
+
import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
|
|
4
|
+
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
11
5
|
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
* Returns metadata for a given project
|
|
16
|
-
*
|
|
17
|
-
* @category Projects
|
|
18
|
-
* @param projectHandle - An optional project handle identifying which project to retrieve metadata for
|
|
19
|
-
* @returns The metadata for the project
|
|
20
|
-
* @example
|
|
21
|
-
* ```tsx
|
|
22
|
-
* function ProjectMetadata({ projectId }: { projectId: string }) {
|
|
23
|
-
* const project = useProject({ projectId })
|
|
24
|
-
*
|
|
25
|
-
* return (
|
|
26
|
-
* <figure style={{ backgroundColor: project.metadata.color || 'lavender'}}>
|
|
27
|
-
* <h1>{project.displayName}</h1>
|
|
28
|
-
* </figure>
|
|
29
|
-
* )
|
|
30
|
-
* }
|
|
31
|
-
* ```
|
|
32
|
-
*/
|
|
33
|
-
(projectHandle?: ProjectHandle): SanityProject
|
|
34
|
-
}
|
|
6
|
+
const useProjectBase = createFetcherHook(project)
|
|
35
7
|
|
|
36
8
|
/**
|
|
9
|
+
* Returns metadata for a given project.
|
|
10
|
+
*
|
|
11
|
+
* @category Projects
|
|
12
|
+
* @param options - Configuration options
|
|
13
|
+
* @returns A {@link FetcherHookResult} whose `data` is the metadata for the
|
|
14
|
+
* project. `members` is included only when `includeMembers: true`; `features`
|
|
15
|
+
* is included unless `includeFeatures: false`.
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* function ProjectMetadata({projectId}: {projectId: string}) {
|
|
19
|
+
* const {data: project} = useProject({projectId})
|
|
20
|
+
*
|
|
21
|
+
* return (
|
|
22
|
+
* <figure style={{backgroundColor: project.metadata.color || 'lavender'}}>
|
|
23
|
+
* <h1>{project.displayName}</h1>
|
|
24
|
+
* </figure>
|
|
25
|
+
* )
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const {data: projectWithMembersAndFeatures} = useProject({projectId})
|
|
31
|
+
* const {data: projectWithMembers} = useProject({projectId, includeMembers: true})
|
|
32
|
+
* const {data: projectWithoutMembers} = useProject({projectId, includeMembers: false})
|
|
33
|
+
* const {data: projectWithoutFeatures} = useProject({projectId, includeFeatures: false})
|
|
34
|
+
* ```
|
|
35
|
+
* @remarks
|
|
36
|
+
* The `projectId` is resolved in order from:
|
|
37
|
+
* 1. an explicit `projectId` option
|
|
38
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
39
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
40
|
+
* 4. `instance.config`.
|
|
37
41
|
* @public
|
|
38
42
|
* @function
|
|
39
43
|
*/
|
|
40
|
-
export const useProject
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
shouldSuspend: (instance: SanityInstance, projectHandle?: ProjectHandle) =>
|
|
47
|
-
getProjectState(instance, projectHandle as ProjectHandle).getCurrent() === undefined,
|
|
48
|
-
suspender: (instance: SanityInstance, projectHandle?: ProjectHandle) =>
|
|
49
|
-
resolveProject(instance, projectHandle as ProjectHandle),
|
|
50
|
-
}) as UseProject
|
|
44
|
+
export const useProject = ((options?: ProjectOptions<boolean, boolean>) => {
|
|
45
|
+
const projectId = useResolvedProjectId(options)
|
|
46
|
+
return useProjectBase(projectId ? {...options, projectId} : options)
|
|
47
|
+
}) as <IncludeMembers extends boolean = true, IncludeFeatures extends boolean = true>(
|
|
48
|
+
options?: ProjectOptions<IncludeMembers, IncludeFeatures>,
|
|
49
|
+
) => FetcherHookResult<Project<IncludeMembers, IncludeFeatures>>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {type Project, type ProjectMember} from '@sanity/sdk'
|
|
2
|
+
import {expectTypeOf, test} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useProjects} from './useProjects'
|
|
5
|
+
|
|
6
|
+
test('useProjects — no args: features included, members omitted', () => {
|
|
7
|
+
expectTypeOf(useProjects().data).toEqualTypeOf<Project<false, true>[]>()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('useProjects — returns the FetcherHookResult envelope', () => {
|
|
11
|
+
const result = useProjects()
|
|
12
|
+
expectTypeOf(result.isFetching).toEqualTypeOf<boolean>()
|
|
13
|
+
expectTypeOf(result.error).toEqualTypeOf<unknown>()
|
|
14
|
+
expectTypeOf(result.refetch).toEqualTypeOf<() => Promise<Project<false, true>[]>>()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('useProjects — includeMembers: true adds members to the type', () => {
|
|
18
|
+
expectTypeOf(useProjects({includeMembers: true}).data).toEqualTypeOf<Project<true, true>[]>()
|
|
19
|
+
type Result = ReturnType<typeof useProjects<true, true>>
|
|
20
|
+
expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<ProjectMember[]>()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('useProjects — includeFeatures: false drops features from the type', () => {
|
|
24
|
+
expectTypeOf(useProjects({includeFeatures: false}).data).toEqualTypeOf<Project<false, false>[]>()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('useProjects — both flags true → both arrays present', () => {
|
|
28
|
+
expectTypeOf(useProjects({includeMembers: true, includeFeatures: true}).data).toEqualTypeOf<
|
|
29
|
+
Project<true, true>[]
|
|
30
|
+
>()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('useProjects — both flags false → bare base shape', () => {
|
|
34
|
+
expectTypeOf(useProjects({includeMembers: false, includeFeatures: false}).data).toEqualTypeOf<
|
|
35
|
+
Project<false, false>[]
|
|
36
|
+
>()
|
|
37
|
+
type Result = ReturnType<typeof useProjects<false, false>>
|
|
38
|
+
expectTypeOf<Result['data'][number]['id']>().toEqualTypeOf<string>()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('useProjects — rejects non-boolean flag values', () => {
|
|
42
|
+
// @ts-expect-error — includeMembers must be a boolean
|
|
43
|
+
void useProjects({includeMembers: 'yes'})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('useProjects — organizationId alone does not change the data shape', () => {
|
|
47
|
+
expectTypeOf(useProjects({organizationId: 'org_123'}).data).toEqualTypeOf<
|
|
48
|
+
Project<false, true>[]
|
|
49
|
+
>()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('useProjects — non-literal boolean flag makes members optional', () => {
|
|
53
|
+
const includeMembers = false as boolean
|
|
54
|
+
expectTypeOf(useProjects({includeMembers}).data).toEqualTypeOf<Project<boolean, true>[]>()
|
|
55
|
+
type Result = ReturnType<typeof useProjects<boolean, true>>
|
|
56
|
+
expectTypeOf<Result['data'][number]['members']>().toEqualTypeOf<ProjectMember[] | undefined>()
|
|
57
|
+
expectTypeOf<Pick<Result['data'][number], 'members'>>().toEqualTypeOf<{
|
|
58
|
+
members?: ProjectMember[]
|
|
59
|
+
}>()
|
|
60
|
+
})
|