@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {type DocumentPresence, getDocumentPresence, isMediaLibraryResource} from '@sanity/sdk'
|
|
2
|
+
import {type Path} from '@sanity/types'
|
|
3
|
+
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
4
|
+
|
|
5
|
+
import {type DocumentHandle} from '../../config/handles'
|
|
6
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
7
|
+
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
8
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
9
|
+
|
|
10
|
+
/** @beta */
|
|
11
|
+
export interface UsePresenceForDocumentOptions extends DocumentHandle {
|
|
12
|
+
/**
|
|
13
|
+
* Narrows to participants at or below this field path, which is what a field
|
|
14
|
+
* indicator wants. Omit it for everyone in the document.
|
|
15
|
+
*/
|
|
16
|
+
path?: Path
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* By default a draft, its published version, and any release versions count as
|
|
20
|
+
* the same document, which is what document lists want. Set this to compare ids
|
|
21
|
+
* exactly, so that a draft and a release version do not bleed into each other.
|
|
22
|
+
*/
|
|
23
|
+
excludeVersions?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Who else is in a document, flattened to one entry per participant per location
|
|
28
|
+
* so it can be rendered straight against a field.
|
|
29
|
+
*
|
|
30
|
+
* Reading presence never announces anything. Use `useReportPresence` to make the
|
|
31
|
+
* current user visible to others.
|
|
32
|
+
*
|
|
33
|
+
* Prefer this over `usePresence` when you care about one document: `usePresence`
|
|
34
|
+
* returns every participant in the whole project and dataset, leaving the filtering
|
|
35
|
+
* to you.
|
|
36
|
+
*
|
|
37
|
+
* Resolves the document through its perspective exactly as `useReportPresence` does,
|
|
38
|
+
* so reads match writes. Participants are counted by session, so one person in two
|
|
39
|
+
* tabs appears twice.
|
|
40
|
+
*
|
|
41
|
+
* @example Avatars on a document
|
|
42
|
+
* ```tsx
|
|
43
|
+
* const {presence} = usePresenceForDocument({documentId, documentType})
|
|
44
|
+
* return presence.map((p) => <Avatar key={p.sessionId} user={p.user} />)
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example Avatars on a single field
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const {presence} = usePresenceForDocument({
|
|
50
|
+
* documentId,
|
|
51
|
+
* documentType,
|
|
52
|
+
* path: ['title'],
|
|
53
|
+
* excludeVersions: true,
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @beta
|
|
58
|
+
*/
|
|
59
|
+
export function usePresenceForDocument(options: UsePresenceForDocumentOptions): {
|
|
60
|
+
presence: DocumentPresence[]
|
|
61
|
+
} {
|
|
62
|
+
const {path, excludeVersions, ...handle} = options
|
|
63
|
+
|
|
64
|
+
const normalizedOptions = useNormalizedResourceOptions(handle)
|
|
65
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'usePresenceForDocument() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sanityInstance = useSanityInstance()
|
|
72
|
+
trackHookUsage(sanityInstance, 'usePresenceForDocument')
|
|
73
|
+
|
|
74
|
+
const {resource, perspective} = normalizedOptions
|
|
75
|
+
const {documentId, liveEdit} = options
|
|
76
|
+
|
|
77
|
+
// Serialized so a caller passing a fresh `path` array each render does not
|
|
78
|
+
// rebuild the state source every render.
|
|
79
|
+
const pathKey = useMemo(() => JSON.stringify(path ?? null), [path])
|
|
80
|
+
|
|
81
|
+
const source = useMemo(
|
|
82
|
+
() =>
|
|
83
|
+
getDocumentPresence(sanityInstance, {
|
|
84
|
+
...(resource ? {resource} : {}),
|
|
85
|
+
documentId,
|
|
86
|
+
// Forwarded, not resolved here, so this matches what `useReportPresence`
|
|
87
|
+
// sends. Core owns turning a perspective into a specific document id.
|
|
88
|
+
...(perspective ? {perspective} : {}),
|
|
89
|
+
...(liveEdit ? {liveEdit} : {}),
|
|
90
|
+
...(pathKey === 'null' ? {} : {path: JSON.parse(pathKey) as Path}),
|
|
91
|
+
...(excludeVersions === undefined ? {} : {excludeVersions}),
|
|
92
|
+
}),
|
|
93
|
+
[sanityInstance, resource, documentId, perspective, liveEdit, pathKey, excludeVersions],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
|
|
97
|
+
const presence = useSyncExternalStore(
|
|
98
|
+
subscribe,
|
|
99
|
+
() => source.getCurrent(),
|
|
100
|
+
() => source.getCurrent(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return {presence: presence || []}
|
|
104
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {reportPresence} from '@sanity/sdk'
|
|
2
|
+
import {act, renderHook} from '@testing-library/react'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
|
+
import {useReportPresence} from './useReportPresence'
|
|
7
|
+
|
|
8
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal<typeof import('@sanity/sdk')>()
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
reportPresence: vi.fn(),
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const reported = () => vi.mocked(reportPresence).mock.calls.map(([, params]) => params.locations)
|
|
17
|
+
|
|
18
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
19
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
20
|
+
{children}
|
|
21
|
+
</ResourceProvider>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
describe('useReportPresence', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
vi.useFakeTimers()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.useRealTimers()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('reports the document with no path or selection keys when neither was given', () => {
|
|
35
|
+
renderHook(() => useReportPresence({documentId: 'doc-1', documentType: 'movie'}), {wrapper})
|
|
36
|
+
|
|
37
|
+
// Keys absent rather than set to `undefined`. The id stays unresolved here:
|
|
38
|
+
// core turns a perspective into a specific document id, so the read and write
|
|
39
|
+
// sides cannot drift apart.
|
|
40
|
+
expect(reported()).toEqual([[{documentId: 'doc-1'}]])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('forwards an explicit perspective for core to resolve', () => {
|
|
44
|
+
renderHook(
|
|
45
|
+
() =>
|
|
46
|
+
useReportPresence({
|
|
47
|
+
documentId: 'doc-1',
|
|
48
|
+
documentType: 'movie',
|
|
49
|
+
perspective: {releaseName: 'autumn'},
|
|
50
|
+
}),
|
|
51
|
+
{wrapper},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
expect(reported()[0][0]).toMatchObject({
|
|
55
|
+
documentId: 'doc-1',
|
|
56
|
+
perspective: {releaseName: 'autumn'},
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('forwards liveEdit, which core resolves to the published document', () => {
|
|
61
|
+
renderHook(
|
|
62
|
+
() => useReportPresence({documentId: 'doc-1', documentType: 'movie', liveEdit: true}),
|
|
63
|
+
{wrapper},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(reported()[0][0]).toMatchObject({documentId: 'doc-1', liveEdit: true})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('picks up an ambient perspective from the provider', () => {
|
|
70
|
+
// The whole point of resolving after normalization: a perspective set once on
|
|
71
|
+
// `ResourceProvider` has to reach presence without every call site passing it.
|
|
72
|
+
const ambient = ({children}: {children: React.ReactNode}) => (
|
|
73
|
+
<ResourceProvider projectId="p" dataset="d" perspective="published" fallback={null}>
|
|
74
|
+
{children}
|
|
75
|
+
</ResourceProvider>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
renderHook(() => useReportPresence({documentId: 'doc-1', documentType: 'movie'}), {
|
|
79
|
+
wrapper: ambient,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(reported()[0][0]).toMatchObject({documentId: 'doc-1', perspective: 'published'})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('announces a field path', () => {
|
|
86
|
+
renderHook(
|
|
87
|
+
() => useReportPresence({documentId: 'doc-1', documentType: 'movie', path: ['title']}),
|
|
88
|
+
{wrapper},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(reported()[0][0].path).toEqual(['title'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('carries keyed segments and a selection', () => {
|
|
95
|
+
const path = ['body', {_key: 'b1'}, 'children', {_key: 's1'}, 'text']
|
|
96
|
+
const selection = {
|
|
97
|
+
anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 1},
|
|
98
|
+
focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 4},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
renderHook(
|
|
102
|
+
() => useReportPresence({documentId: 'doc-1', documentType: 'movie', path, selection}),
|
|
103
|
+
{wrapper},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(reported()[0][0].path).toEqual(path)
|
|
107
|
+
expect(reported()[0][0].selection).toEqual(selection)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not re-announce when a caller passes fresh literals each render', () => {
|
|
111
|
+
const {rerender} = renderHook(
|
|
112
|
+
() =>
|
|
113
|
+
useReportPresence({
|
|
114
|
+
documentId: 'doc-1',
|
|
115
|
+
documentType: 'movie',
|
|
116
|
+
// A new array identity on every render, which is what real callers do.
|
|
117
|
+
path: ['title'],
|
|
118
|
+
}),
|
|
119
|
+
{wrapper},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
rerender()
|
|
123
|
+
rerender()
|
|
124
|
+
|
|
125
|
+
expect(reported()).toHaveLength(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('throttles a burst and announces the final position', () => {
|
|
129
|
+
const {rerender} = renderHook(
|
|
130
|
+
({path}: {path: string[]}) =>
|
|
131
|
+
useReportPresence({documentId: 'doc-1', documentType: 'movie', path}),
|
|
132
|
+
{wrapper, initialProps: {path: ['a']}},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(reported()).toHaveLength(1)
|
|
136
|
+
|
|
137
|
+
rerender({path: ['b']})
|
|
138
|
+
rerender({path: ['c']})
|
|
139
|
+
expect(reported()).toHaveLength(1)
|
|
140
|
+
|
|
141
|
+
// Trailing edge, so the position the user actually settled on is the one sent.
|
|
142
|
+
act(() => {
|
|
143
|
+
vi.advanceTimersByTime(1000)
|
|
144
|
+
})
|
|
145
|
+
expect(reported()).toHaveLength(2)
|
|
146
|
+
expect(reported()[1][0].path).toEqual(['c'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('announces selections more often than field focus', () => {
|
|
150
|
+
const selectionAt = (offset: number) => ({
|
|
151
|
+
anchor: {path: [{_key: 'b1'}], offset},
|
|
152
|
+
focus: {path: [{_key: 'b1'}], offset},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const {rerender} = renderHook(
|
|
156
|
+
({offset}: {offset: number}) =>
|
|
157
|
+
useReportPresence({
|
|
158
|
+
documentId: 'doc-1',
|
|
159
|
+
documentType: 'movie',
|
|
160
|
+
path: ['body'],
|
|
161
|
+
selection: selectionAt(offset),
|
|
162
|
+
}),
|
|
163
|
+
{wrapper, initialProps: {offset: 1}},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(reported()).toHaveLength(1)
|
|
167
|
+
rerender({offset: 2})
|
|
168
|
+
|
|
169
|
+
// Would still be pending at the 1000ms focus interval. A caret that lags a
|
|
170
|
+
// full second reads as broken, so selections use 250ms.
|
|
171
|
+
act(() => {
|
|
172
|
+
vi.advanceTimersByTime(250)
|
|
173
|
+
})
|
|
174
|
+
expect(reported()).toHaveLength(2)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('clears its location on unmount, staying present but nowhere', () => {
|
|
178
|
+
const {unmount} = renderHook(
|
|
179
|
+
() => useReportPresence({documentId: 'doc-1', documentType: 'movie'}),
|
|
180
|
+
{wrapper},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
unmount()
|
|
184
|
+
|
|
185
|
+
expect(reported().at(-1)).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('does not clear on every location change, only on unmount', () => {
|
|
189
|
+
const {rerender} = renderHook(
|
|
190
|
+
({path}: {path: string[]}) =>
|
|
191
|
+
useReportPresence({documentId: 'doc-1', documentType: 'movie', path}),
|
|
192
|
+
{wrapper, initialProps: {path: ['a']}},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
rerender({path: ['b']})
|
|
196
|
+
act(() => {
|
|
197
|
+
vi.advanceTimersByTime(1000)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(reported().every((locations) => locations.length === 1)).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMediaLibraryResource,
|
|
3
|
+
type PresenceSelection,
|
|
4
|
+
reportPresence,
|
|
5
|
+
type ReportPresenceOptions,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {type Path} from '@sanity/types'
|
|
8
|
+
import {useEffect, useMemo, useRef} from 'react'
|
|
9
|
+
|
|
10
|
+
import {type DocumentHandle} from '../../config/handles'
|
|
11
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
12
|
+
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
13
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Field focus moves at human speed, so a second between announcements is plenty.
|
|
17
|
+
*/
|
|
18
|
+
const FOCUS_THROTTLE_MS = 1000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A caret that trails a second behind reads as broken rather than as latency, so
|
|
22
|
+
* reporting a `selection` announces more often. The Studio uses a flat 1000ms for
|
|
23
|
+
* both, which is why its remote carets feel sluggish.
|
|
24
|
+
*/
|
|
25
|
+
const SELECTION_THROTTLE_MS = 250
|
|
26
|
+
|
|
27
|
+
/** @beta */
|
|
28
|
+
export interface UseReportPresenceOptions extends DocumentHandle {
|
|
29
|
+
/**
|
|
30
|
+
* The focused field path. Omit it for document-level presence. Keyed and numeric
|
|
31
|
+
* segments are supported, so array items and Portable Text spans can be
|
|
32
|
+
* addressed.
|
|
33
|
+
*/
|
|
34
|
+
path?: Path
|
|
35
|
+
|
|
36
|
+
/** The Portable Text caret, when the focused field is a Portable Text field. */
|
|
37
|
+
selection?: PresenceSelection
|
|
38
|
+
|
|
39
|
+
/** Overrides the throttle interval. Mainly useful in tests. */
|
|
40
|
+
throttleMs?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Announces that the current user is in a document, so that other clients in the
|
|
45
|
+
* same project and dataset can show them.
|
|
46
|
+
*
|
|
47
|
+
* Writing presence is opt-in. Reading it with `usePresenceForDocument` or
|
|
48
|
+
* `usePresence` never announces anything, and this hook is the only thing that
|
|
49
|
+
* makes an app visible to others. That includes the Studio, which shares the same
|
|
50
|
+
* presence room and will show these users in its navbar and field indicators.
|
|
51
|
+
*
|
|
52
|
+
* Announcements are throttled, collapsed over a short window, and then repeated
|
|
53
|
+
* every 30 seconds while the user is idle. That repeat is what tells peers the
|
|
54
|
+
* session is still alive, so the intended usage is to mount this hook for as long
|
|
55
|
+
* as the user is in the document. On unmount the location is cleared, leaving the
|
|
56
|
+
* user present in the app but not in any particular document.
|
|
57
|
+
*
|
|
58
|
+
* The perspective decides which specific document is reported: the draft under
|
|
59
|
+
* `drafts`, the published document under `published`, a version under a release.
|
|
60
|
+
* It is taken from `ResourceProvider` unless you pass one on the handle. That
|
|
61
|
+
* matters for interoperability, because the Studio's field indicators compare the
|
|
62
|
+
* exact id its form is on, so a mismatch shows your user at document level while
|
|
63
|
+
* never lighting up a field.
|
|
64
|
+
*
|
|
65
|
+
* Presence is scoped to a single project and dataset. It is not a list of everyone
|
|
66
|
+
* signed in to your organization.
|
|
67
|
+
*
|
|
68
|
+
* @example Document-level presence
|
|
69
|
+
* ```tsx
|
|
70
|
+
* function DocumentEditor({documentId, documentType}: DocumentHandle) {
|
|
71
|
+
* useReportPresence({documentId, documentType})
|
|
72
|
+
* return <Editor />
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @example Presence in a release version
|
|
77
|
+
* ```tsx
|
|
78
|
+
* // The document id stays plain; the perspective selects the version.
|
|
79
|
+
* useReportPresence({documentId, documentType, perspective: {releaseName: 'autumn'}})
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example Field-level presence
|
|
83
|
+
* ```tsx
|
|
84
|
+
* function TitleField({documentId, documentType}: DocumentHandle) {
|
|
85
|
+
* const [focused, setFocused] = useState(false)
|
|
86
|
+
* useReportPresence({documentId, documentType, path: focused ? ['title'] : undefined})
|
|
87
|
+
* return <input onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @beta
|
|
92
|
+
*/
|
|
93
|
+
export function useReportPresence(options: UseReportPresenceOptions): void {
|
|
94
|
+
const {path, selection, throttleMs, ...handle} = options
|
|
95
|
+
|
|
96
|
+
const normalizedOptions = useNormalizedResourceOptions(handle)
|
|
97
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'useReportPresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sanityInstance = useSanityInstance()
|
|
104
|
+
trackHookUsage(sanityInstance, 'useReportPresence')
|
|
105
|
+
|
|
106
|
+
const {resource, perspective} = normalizedOptions
|
|
107
|
+
const {documentId, liveEdit} = options
|
|
108
|
+
|
|
109
|
+
const interval = throttleMs ?? (selection ? SELECTION_THROTTLE_MS : FOCUS_THROTTLE_MS)
|
|
110
|
+
|
|
111
|
+
// Compared by value, because callers write `path={['title']}` inline and a fresh
|
|
112
|
+
// array identity every render must not mean a fresh announcement every render.
|
|
113
|
+
const locationKey = useMemo(
|
|
114
|
+
() =>
|
|
115
|
+
JSON.stringify([documentId, path ?? [], selection ?? null, perspective ?? null, liveEdit]),
|
|
116
|
+
[documentId, path, selection, perspective, liveEdit],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Rebuilt from the key rather than from the props, so the effect below can
|
|
120
|
+
// depend on it honestly instead of suppressing the exhaustive-deps rule.
|
|
121
|
+
const location = useMemo<ReportPresenceOptions>(() => {
|
|
122
|
+
const [id, parsedPath, parsedSelection, parsedPerspective, parsedLiveEdit] = JSON.parse(
|
|
123
|
+
locationKey,
|
|
124
|
+
) as [string, Path, PresenceSelection, ReportPresenceOptions['perspective'] | null, boolean?]
|
|
125
|
+
return {
|
|
126
|
+
documentId: id,
|
|
127
|
+
// Forwarded rather than resolved here: core turns the perspective into the
|
|
128
|
+
// specific document id, so the read and write sides cannot drift apart.
|
|
129
|
+
...(parsedPerspective ? {perspective: parsedPerspective} : {}),
|
|
130
|
+
...(parsedLiveEdit ? {liveEdit: parsedLiveEdit} : {}),
|
|
131
|
+
...(parsedPath.length > 0 ? {path: parsedPath} : {}),
|
|
132
|
+
...(parsedSelection ? {selection: parsedSelection} : {}),
|
|
133
|
+
}
|
|
134
|
+
}, [locationKey])
|
|
135
|
+
|
|
136
|
+
const lastSentAt = useRef(0)
|
|
137
|
+
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
// Captured in this effect's closure rather than read from a ref, so nothing is
|
|
141
|
+
// written during render. A newer location re-runs the effect, which clears the
|
|
142
|
+
// pending timeout below and schedules the newer value instead.
|
|
143
|
+
const send = () => {
|
|
144
|
+
lastSentAt.current = Date.now()
|
|
145
|
+
reportPresence(sanityInstance, {
|
|
146
|
+
...(resource ? {resource} : {}),
|
|
147
|
+
locations: [location],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const elapsed = Date.now() - lastSentAt.current
|
|
152
|
+
if (elapsed >= interval) {
|
|
153
|
+
send()
|
|
154
|
+
} else {
|
|
155
|
+
// Trailing edge, so the position the user settled on is the one announced.
|
|
156
|
+
clearTimeout(pending.current)
|
|
157
|
+
pending.current = setTimeout(send, interval - elapsed)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return () => clearTimeout(pending.current)
|
|
161
|
+
}, [location, interval, sanityInstance, resource])
|
|
162
|
+
|
|
163
|
+
// Kept separate from the throttled effect so it runs on unmount only, rather
|
|
164
|
+
// than every time the reported location changes.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
return () => {
|
|
167
|
+
reportPresence(sanityInstance, {...(resource ? {resource} : {}), locations: []})
|
|
168
|
+
}
|
|
169
|
+
}, [sanityInstance, resource])
|
|
170
|
+
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type PreviewQueryResult,
|
|
4
|
-
type PreviewValue,
|
|
5
|
-
transformProjectionToPreview,
|
|
6
|
-
} from '@sanity/sdk'
|
|
1
|
+
import {type PreviewQueryResult, type PreviewValue} from '@sanity/sdk'
|
|
2
|
+
import {PREVIEW_PROJECTION, transformProjectionToPreview} from '@sanity/sdk/_internal'
|
|
7
3
|
import {useMemo} from 'react'
|
|
8
4
|
|
|
9
5
|
import {type DocumentHandle} from '../../config/handles'
|
|
10
6
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
11
7
|
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
8
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
12
9
|
import {useDocumentProjection} from '../projection/useDocumentProjection'
|
|
13
10
|
|
|
14
11
|
/**
|
|
@@ -60,9 +57,9 @@ export interface useDocumentPreviewResults {
|
|
|
60
57
|
*
|
|
61
58
|
* @example Combining with useDocuments to render a collection of document previews
|
|
62
59
|
* ```
|
|
63
|
-
* // PreviewComponent.
|
|
64
|
-
* export default function PreviewComponent(
|
|
65
|
-
* const { data: { title, subtitle, media }, isPending } = useDocumentPreview(
|
|
60
|
+
* // PreviewComponent.jsx
|
|
61
|
+
* export default function PreviewComponent({ document }) {
|
|
62
|
+
* const { data: { title, subtitle, media }, isPending } = useDocumentPreview({ document })
|
|
66
63
|
* return (
|
|
67
64
|
* <article style={{ opacity: isPending ? 0.5 : 1}}>
|
|
68
65
|
* {media?.type === 'image-asset' ? <img src={media.url} alt='' /> : ''}
|
|
@@ -72,16 +69,16 @@ export interface useDocumentPreviewResults {
|
|
|
72
69
|
* )
|
|
73
70
|
* }
|
|
74
71
|
*
|
|
75
|
-
* // DocumentList.
|
|
76
|
-
* const { data } = useDocuments({
|
|
72
|
+
* // DocumentList.jsx
|
|
73
|
+
* const { data } = useDocuments({ filter: '_type == "movie"' })
|
|
77
74
|
* return (
|
|
78
75
|
* <div>
|
|
79
76
|
* <h1>Movies</h1>
|
|
80
77
|
* <ul>
|
|
81
78
|
* {data.map(movie => (
|
|
82
|
-
* <li key={movie.
|
|
79
|
+
* <li key={movie._id}>
|
|
83
80
|
* <Suspense fallback='Loading…'>
|
|
84
|
-
* <PreviewComponent {
|
|
81
|
+
* <PreviewComponent document={movie} />
|
|
85
82
|
* </Suspense>
|
|
86
83
|
* </li>
|
|
87
84
|
* ))}
|
|
@@ -95,6 +92,7 @@ export function useDocumentPreview({
|
|
|
95
92
|
...docHandle
|
|
96
93
|
}: useDocumentPreviewOptions): useDocumentPreviewResults {
|
|
97
94
|
const instance = useSanityInstance()
|
|
95
|
+
trackHookUsage(instance, 'useDocumentPreview')
|
|
98
96
|
const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
|
|
99
97
|
|
|
100
98
|
// Use the projection hook with the fixed preview projection
|
|
@@ -108,7 +106,7 @@ export function useDocumentPreview({
|
|
|
108
106
|
// Keep this non-null assumption aligned with useDocumentPreviewResults.data.
|
|
109
107
|
const previewValue = useMemo(
|
|
110
108
|
() =>
|
|
111
|
-
transformProjectionToPreview(instance,
|
|
109
|
+
transformProjectionToPreview(instance, projectionResult.data, normalizedDocHandle.resource),
|
|
112
110
|
[projectionResult.data, instance, normalizedDocHandle.resource],
|
|
113
111
|
)
|
|
114
112
|
|