@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.1
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 +1564 -682
- package/dist/index.js +1118 -612
- package/dist/index.js.map +1 -1
- package/package.json +51 -54
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +26 -17
- 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 +94 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +26 -8
- package/src/components/auth/AuthBoundary.tsx +28 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +103 -59
- 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/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 +72 -90
- package/src/context/ResourceProvider.tsx +59 -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 +1 -1
- package/src/hooks/agent/agentActions.ts +51 -71
- 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/useCreateUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useCreateUserApplication.ts +11 -0
- package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -0
- package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
- package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
- package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
- package/src/hooks/applications/useUserApplication.ts +14 -0
- package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
- package/src/hooks/applications/useUserApplications.ts +14 -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/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +72 -3
- package/src/hooks/context/useSanityInstance.ts +30 -8
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +10 -16
- 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 +1 -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 +36 -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 +253 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +92 -50
- 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 +40 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +9 -20
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +10 -8
- 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 +19 -26
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +91 -9
- 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 +2 -0
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +37 -3
- 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,10 +1,18 @@
|
|
|
1
|
-
import {
|
|
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
|
-
import {
|
|
4
|
-
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
9
|
+
import {useCallback, useMemo, useState} from 'react'
|
|
5
10
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
11
|
+
import {
|
|
12
|
+
useNormalizedResourceOptions,
|
|
13
|
+
type WithResourceNameSupport,
|
|
14
|
+
} from '../helpers/useNormalizedResourceOptions'
|
|
15
|
+
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
8
16
|
import {useQuery} from '../query/useQuery'
|
|
9
17
|
|
|
10
18
|
/**
|
|
@@ -17,10 +25,9 @@ export interface PaginatedDocumentsOptions<
|
|
|
17
25
|
TDocumentType extends string = string,
|
|
18
26
|
TDataset extends string = string,
|
|
19
27
|
TProjectId extends string = string,
|
|
20
|
-
>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Pick<QueryOptions<TDocumentType, TDataset, TProjectId>, 'params'> {
|
|
28
|
+
> extends WithResourceNameSupport<
|
|
29
|
+
Omit<QueryOptions<TDocumentType, TDataset, TProjectId>, 'query'>
|
|
30
|
+
> {
|
|
24
31
|
documentType?: TDocumentType | TDocumentType[]
|
|
25
32
|
/**
|
|
26
33
|
* GROQ filter expression to apply to the query
|
|
@@ -141,16 +148,18 @@ export interface PaginatedDocumentsResponse<
|
|
|
141
148
|
* @returns An object containing the list of document handles, pagination details, and functions to navigate between pages
|
|
142
149
|
*
|
|
143
150
|
* @remarks
|
|
144
|
-
* - The returned document handles include
|
|
151
|
+
* - The returned document handles include projectId and dataset information from the current Sanity instance
|
|
145
152
|
* - This makes them ready to use with document operations and other document hooks
|
|
146
|
-
* - The hook automatically uses the correct Sanity instance based on the
|
|
153
|
+
* - The hook automatically uses the correct Sanity instance based on the projectId and dataset in the options
|
|
147
154
|
*
|
|
148
155
|
* @example Paginated list of documents with navigation
|
|
149
156
|
* ```tsx
|
|
150
157
|
* import {
|
|
151
158
|
* usePaginatedDocuments,
|
|
159
|
+
* createDatasetHandle,
|
|
160
|
+
* type DatasetHandle,
|
|
152
161
|
* type DocumentHandle,
|
|
153
|
-
* type
|
|
162
|
+
* type SortOrderingItem,
|
|
154
163
|
* useDocumentProjection
|
|
155
164
|
* } from '@sanity/sdk-react'
|
|
156
165
|
* import {Suspense} from 'react'
|
|
@@ -173,10 +182,10 @@ export interface PaginatedDocumentsResponse<
|
|
|
173
182
|
* // Define props for the list component
|
|
174
183
|
* interface PaginatedDocumentListProps {
|
|
175
184
|
* documentType: string
|
|
176
|
-
*
|
|
185
|
+
* dataset?: DatasetHandle
|
|
177
186
|
* }
|
|
178
187
|
*
|
|
179
|
-
* function PaginatedDocumentList({documentType,
|
|
188
|
+
* function PaginatedDocumentList({documentType, dataset}: PaginatedDocumentListProps) {
|
|
180
189
|
* const {
|
|
181
190
|
* data,
|
|
182
191
|
* isPending,
|
|
@@ -187,7 +196,7 @@ export interface PaginatedDocumentsResponse<
|
|
|
187
196
|
* hasNextPage,
|
|
188
197
|
* hasPreviousPage
|
|
189
198
|
* } = usePaginatedDocuments({
|
|
190
|
-
*
|
|
199
|
+
* ...dataset,
|
|
191
200
|
* documentType,
|
|
192
201
|
* pageSize: 10,
|
|
193
202
|
* orderings: [{field: '_createdAt', direction: 'desc'}],
|
|
@@ -219,7 +228,8 @@ export interface PaginatedDocumentsResponse<
|
|
|
219
228
|
* }
|
|
220
229
|
*
|
|
221
230
|
* // Usage:
|
|
222
|
-
* //
|
|
231
|
+
* // const myDatasetHandle = createDatasetHandle({ projectId: 'p1', dataset: 'production' })
|
|
232
|
+
* // <PaginatedDocumentList dataset={myDatasetHandle} documentType="post" />
|
|
223
233
|
* ```
|
|
224
234
|
*/
|
|
225
235
|
export function usePaginatedDocuments<
|
|
@@ -239,14 +249,16 @@ export function usePaginatedDocuments<
|
|
|
239
249
|
TDataset,
|
|
240
250
|
TProjectId
|
|
241
251
|
> {
|
|
252
|
+
useTrackHookUsage('usePaginatedDocuments')
|
|
242
253
|
const options = useNormalizedResourceOptions(rawOptions)
|
|
243
254
|
const [pageIndex, setPageIndex] = useState(0)
|
|
244
255
|
const key = JSON.stringify({filter, search, params, orderings, pageSize, ...options})
|
|
245
|
-
// Reset
|
|
246
|
-
|
|
247
|
-
|
|
256
|
+
// Reset pageIndex to 0 whenever any query parameter changes.
|
|
257
|
+
const [prevKey, setPrevKey] = useState(key)
|
|
258
|
+
if (prevKey !== key) {
|
|
259
|
+
setPrevKey(key)
|
|
248
260
|
setPageIndex(0)
|
|
249
|
-
}
|
|
261
|
+
}
|
|
250
262
|
|
|
251
263
|
const startIndex = pageIndex * pageSize
|
|
252
264
|
const endIndex = (pageIndex + 1) * pageSize
|
|
@@ -301,8 +313,14 @@ export function usePaginatedDocuments<
|
|
|
301
313
|
params: {
|
|
302
314
|
...params,
|
|
303
315
|
__types: documentTypes,
|
|
304
|
-
|
|
305
|
-
|
|
316
|
+
__handle: {
|
|
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']),
|
|
323
|
+
},
|
|
306
324
|
},
|
|
307
325
|
})
|
|
308
326
|
|
|
@@ -102,21 +102,6 @@ describe('usePresence', () => {
|
|
|
102
102
|
}).toThrow('usePresence() does not support media library resources')
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
-
it('should throw an error when used with a canvas resource', () => {
|
|
106
|
-
expect(() => {
|
|
107
|
-
renderHook(() => usePresence({resource: {canvasId: 'canvas123'}}), {
|
|
108
|
-
wrapper: ({children}) => (
|
|
109
|
-
<ResourceProvider
|
|
110
|
-
resource={{projectId: 'test-project', dataset: 'test-dataset'}}
|
|
111
|
-
fallback={null}
|
|
112
|
-
>
|
|
113
|
-
{children}
|
|
114
|
-
</ResourceProvider>
|
|
115
|
-
),
|
|
116
|
-
})
|
|
117
|
-
}).toThrow('usePresence() does not support canvas resources')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
105
|
it('should work with a dataset resource', () => {
|
|
121
106
|
const mockPresenceSource = {
|
|
122
107
|
getCurrent: vi.fn().mockReturnValue([]),
|
|
@@ -1,38 +1,27 @@
|
|
|
1
|
-
import {getPresence,
|
|
1
|
+
import {getPresence, isMediaLibraryResource, type UserPresence} from '@sanity/sdk'
|
|
2
2
|
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
3
3
|
|
|
4
4
|
import {type ResourceHandle} from '../../config/handles'
|
|
5
5
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
6
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
7
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* A hook for subscribing to presence information for the current project.
|
|
10
|
+
* A hook for subscribing to presence information for the current project or Canvas.
|
|
10
11
|
* @public
|
|
11
12
|
*/
|
|
12
13
|
export function usePresence(options: ResourceHandle = {}): {
|
|
13
14
|
locations: UserPresence[]
|
|
14
15
|
} {
|
|
15
16
|
const normalizedOptions = useNormalizedResourceOptions(options)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (normalizedOptions.resource) {
|
|
21
|
-
if (isMediaLibraryResource(normalizedOptions.resource)) {
|
|
22
|
-
throw new Error(
|
|
23
|
-
'usePresence() does not support media library resources. Presence tracking requires a dataset resource. ' +
|
|
24
|
-
'Either remove the resourceName parameter or use a dataset resource instead.',
|
|
25
|
-
)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (isCanvasResource(normalizedOptions.resource)) {
|
|
29
|
-
throw new Error(
|
|
30
|
-
'usePresence() does not support canvas resources. Presence tracking requires a dataset resource. ' +
|
|
31
|
-
'Either remove the resourceName parameter or use a dataset resource instead.',
|
|
32
|
-
)
|
|
33
|
-
}
|
|
17
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
20
|
+
)
|
|
34
21
|
}
|
|
35
22
|
|
|
23
|
+
const sanityInstance = useSanityInstance()
|
|
24
|
+
trackHookUsage(sanityInstance, 'usePresence')
|
|
36
25
|
const source = useMemo(
|
|
37
26
|
() => getPresence(sanityInstance, normalizedOptions),
|
|
38
27
|
[sanityInstance, normalizedOptions],
|
|
@@ -9,6 +9,7 @@ import {useMemo} from 'react'
|
|
|
9
9
|
import {type DocumentHandle} from '../../config/handles'
|
|
10
10
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
11
11
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
12
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
12
13
|
import {useDocumentProjection} from '../projection/useDocumentProjection'
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -60,9 +61,9 @@ export interface useDocumentPreviewResults {
|
|
|
60
61
|
*
|
|
61
62
|
* @example Combining with useDocuments to render a collection of document previews
|
|
62
63
|
* ```
|
|
63
|
-
* // PreviewComponent.
|
|
64
|
-
* export default function PreviewComponent(
|
|
65
|
-
* const { data: { title, subtitle, media }, isPending } = useDocumentPreview(
|
|
64
|
+
* // PreviewComponent.jsx
|
|
65
|
+
* export default function PreviewComponent({ document }) {
|
|
66
|
+
* const { data: { title, subtitle, media }, isPending } = useDocumentPreview({ document })
|
|
66
67
|
* return (
|
|
67
68
|
* <article style={{ opacity: isPending ? 0.5 : 1}}>
|
|
68
69
|
* {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
|
|
@@ -72,16 +73,16 @@ export interface useDocumentPreviewResults {
|
|
|
72
73
|
* )
|
|
73
74
|
* }
|
|
74
75
|
*
|
|
75
|
-
* // DocumentList.
|
|
76
|
-
* const { data } = useDocuments({
|
|
76
|
+
* // DocumentList.jsx
|
|
77
|
+
* const { data } = useDocuments({ filter: '_type == "movie"' })
|
|
77
78
|
* return (
|
|
78
79
|
* <div>
|
|
79
80
|
* <h1>Movies</h1>
|
|
80
81
|
* <ul>
|
|
81
82
|
* {data.map(movie => (
|
|
82
|
-
* <li key={movie.
|
|
83
|
+
* <li key={movie._id}>
|
|
83
84
|
* <Suspense fallback='Loading…'>
|
|
84
|
-
* <PreviewComponent {
|
|
85
|
+
* <PreviewComponent document={movie} />
|
|
85
86
|
* </Suspense>
|
|
86
87
|
* </li>
|
|
87
88
|
* ))}
|
|
@@ -95,6 +96,7 @@ export function useDocumentPreview({
|
|
|
95
96
|
...docHandle
|
|
96
97
|
}: useDocumentPreviewOptions): useDocumentPreviewResults {
|
|
97
98
|
const instance = useSanityInstance()
|
|
99
|
+
trackHookUsage(instance, 'useDocumentPreview')
|
|
98
100
|
const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
|
|
99
101
|
|
|
100
102
|
// Use the projection hook with the fixed preview projection
|
|
@@ -108,7 +110,7 @@ export function useDocumentPreview({
|
|
|
108
110
|
// Keep this non-null assumption aligned with useDocumentPreviewResults.data.
|
|
109
111
|
const previewValue = useMemo(
|
|
110
112
|
() =>
|
|
111
|
-
transformProjectionToPreview(instance,
|
|
113
|
+
transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.resource),
|
|
112
114
|
[projectionResult.data, instance, normalizedDocHandle.resource],
|
|
113
115
|
)
|
|
114
116
|
|
|
@@ -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
|
+
})
|