@sanity/sdk-react 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +143 -16
- package/dist/index.js +151 -27
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/_exports/sdk-react.ts +5 -0
- package/src/hooks/dashboard/useWindowTitle.test.ts +31 -0
- package/src/hooks/dashboard/useWindowTitle.ts +4 -2
- package/src/hooks/presence/usePresence.ts +14 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -44,42 +44,42 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@sanity/client": "^7.23.
|
|
47
|
+
"@sanity/client": "^7.23.2",
|
|
48
48
|
"@sanity/message-protocol": "^0.23.0",
|
|
49
|
-
"@sanity/types": "^6.
|
|
49
|
+
"@sanity/types": "^6.5.0",
|
|
50
50
|
"groq": "3.88.1-typegen-experimental.0",
|
|
51
51
|
"react-compiler-runtime": "^1.0.0",
|
|
52
52
|
"react-error-boundary": "^6.1.2",
|
|
53
53
|
"rxjs": "^7.8.2",
|
|
54
|
-
"@sanity/sdk": "2.
|
|
54
|
+
"@sanity/sdk": "2.19.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@sanity/browserslist-config": "^1.0.5",
|
|
58
58
|
"@sanity/comlink": "^4.0.1",
|
|
59
|
-
"@sanity/pkg-utils": "^10.
|
|
59
|
+
"@sanity/pkg-utils": "^10.9.2",
|
|
60
60
|
"@testing-library/jest-dom": "^6.9.1",
|
|
61
61
|
"@testing-library/react": "^16.3.2",
|
|
62
|
-
"@types/node": "^24.
|
|
62
|
+
"@types/node": "^24.13.3",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"@vitejs/plugin-react": "^6.0.3",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.10",
|
|
67
67
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
68
68
|
"eslint": "^10.6.0",
|
|
69
|
-
"groq-js": "^
|
|
69
|
+
"groq-js": "^2.0.0",
|
|
70
70
|
"jsdom": "^29.1.1",
|
|
71
71
|
"oxfmt": "^0.58.0",
|
|
72
72
|
"react": "^19.2.7",
|
|
73
73
|
"react-dom": "^19.2.7",
|
|
74
74
|
"rollup-plugin-visualizer": "^7.0.1",
|
|
75
75
|
"typescript": "^6.0.3",
|
|
76
|
-
"vite": "^8.1.
|
|
76
|
+
"vite": "^8.1.5",
|
|
77
77
|
"vitest": "^4.1.10",
|
|
78
|
-
"@repo/config-test": "0.0.1",
|
|
79
78
|
"@repo/config-eslint": "0.0.0",
|
|
80
|
-
"@repo/package.
|
|
79
|
+
"@repo/package.bundle": "3.82.0",
|
|
80
|
+
"@repo/config-test": "0.0.1",
|
|
81
81
|
"@repo/tsconfig": "0.0.1",
|
|
82
|
-
"@repo/package.
|
|
82
|
+
"@repo/package.config": "0.0.1"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -78,6 +78,11 @@ export {
|
|
|
78
78
|
usePaginatedDocuments,
|
|
79
79
|
} from '../hooks/paginatedDocuments/usePaginatedDocuments'
|
|
80
80
|
export {usePresence} from '../hooks/presence/usePresence'
|
|
81
|
+
export {
|
|
82
|
+
usePresenceForDocument,
|
|
83
|
+
type UsePresenceForDocumentOptions,
|
|
84
|
+
} from '../hooks/presence/usePresenceForDocument'
|
|
85
|
+
export {useReportPresence, type UseReportPresenceOptions} from '../hooks/presence/useReportPresence'
|
|
81
86
|
export {
|
|
82
87
|
useDocumentPreview,
|
|
83
88
|
type useDocumentPreviewOptions,
|
|
@@ -101,6 +101,37 @@ describe('useWindowTitle', () => {
|
|
|
101
101
|
})
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
it('should fall back to the iframe title when the dashboard resource has no title', async () => {
|
|
105
|
+
document.title = 'Local App'
|
|
106
|
+
|
|
107
|
+
const mockFetch = vi.fn().mockResolvedValue(
|
|
108
|
+
createContextResponse({
|
|
109
|
+
type: 'application',
|
|
110
|
+
title: '',
|
|
111
|
+
manifest: null,
|
|
112
|
+
activeDeployment: null,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
vi.mocked(useWindowConnection).mockReturnValue({
|
|
116
|
+
fetch: mockFetch,
|
|
117
|
+
sendMessage: vi.fn(),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const {rerender} = renderHook(({viewTitle}) => useWindowTitle(viewTitle), {
|
|
121
|
+
initialProps: {viewTitle: 'Teams'},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(document.title).toBe('Teams | Local App')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
rerender({viewTitle: 'Week 10'})
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(document.title).toBe('Week 10 | Local App')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
104
135
|
it('should prepend view title when provided', async () => {
|
|
105
136
|
const mockFetch = vi.fn().mockResolvedValue(
|
|
106
137
|
createContextResponse({
|
|
@@ -23,7 +23,7 @@ interface ContextResponse {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
26
|
-
return resource.manifest?.title
|
|
26
|
+
return resource.manifest?.title || resource.activeDeployment?.manifest?.title || resource.title
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -81,7 +81,9 @@ export function useWindowTitle(viewTitle?: string): void {
|
|
|
81
81
|
async function fetchAppTitle(signal: AbortSignal) {
|
|
82
82
|
try {
|
|
83
83
|
const data = await fetch<ContextResponse>('dashboard/v1/context', undefined, {signal})
|
|
84
|
-
|
|
84
|
+
// Local development resources do not have a registered title or deployment manifest.
|
|
85
|
+
// In that case, use the title rendered into the iframe HTML by the Sanity CLI.
|
|
86
|
+
const title = resolveAppTitle(data.context.resource) || document.title
|
|
85
87
|
if (title) {
|
|
86
88
|
setAppTitle(title)
|
|
87
89
|
}
|
|
@@ -7,7 +7,20 @@ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOpti
|
|
|
7
7
|
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Every participant in the current project and dataset, or Canvas.
|
|
11
|
+
*
|
|
12
|
+
* Reading presence never announces anything. Call `useReportPresence` to make the
|
|
13
|
+
* current user visible to others, including to the Studio, which shares the same
|
|
14
|
+
* presence room.
|
|
15
|
+
*
|
|
16
|
+
* This returns everyone in the whole resource and leaves the filtering to you.
|
|
17
|
+
* Prefer `usePresenceForDocument` when you care about one document: it scopes and
|
|
18
|
+
* flattens the result for rendering. Note that participants are counted by session,
|
|
19
|
+
* so one person in two tabs appears twice.
|
|
20
|
+
*
|
|
21
|
+
* Presence is scoped to a single project and dataset. It is not a list of everyone
|
|
22
|
+
* signed in to your organization.
|
|
23
|
+
*
|
|
11
24
|
* @public
|
|
12
25
|
*/
|
|
13
26
|
export function usePresence(options: ResourceHandle = {}): {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {type DocumentPresence, getDocumentPresence} from '@sanity/sdk'
|
|
2
|
+
import {act, renderHook} from '@testing-library/react'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
|
+
import {usePresenceForDocument} from './usePresenceForDocument'
|
|
7
|
+
|
|
8
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal<typeof import('@sanity/sdk')>()
|
|
10
|
+
return {...actual, getDocumentPresence: vi.fn()}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const entry = (sessionId: string): DocumentPresence =>
|
|
14
|
+
({
|
|
15
|
+
sessionId,
|
|
16
|
+
documentId: 'movie-1',
|
|
17
|
+
path: ['title'],
|
|
18
|
+
lastActiveAt: '2026-07-30T12:00:00Z',
|
|
19
|
+
user: {sanityUserId: 'u1', profile: {id: 'u1'}, memberships: []},
|
|
20
|
+
}) as unknown as DocumentPresence
|
|
21
|
+
|
|
22
|
+
/** A minimal StateSource stand-in whose value can be pushed. */
|
|
23
|
+
function createSource(initial: DocumentPresence[]) {
|
|
24
|
+
let current = initial
|
|
25
|
+
const listeners = new Set<() => void>()
|
|
26
|
+
return {
|
|
27
|
+
source: {
|
|
28
|
+
getCurrent: () => current,
|
|
29
|
+
subscribe: (cb: () => void) => {
|
|
30
|
+
listeners.add(cb)
|
|
31
|
+
return () => listeners.delete(cb)
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
push: (next: DocumentPresence[]) => {
|
|
35
|
+
current = next
|
|
36
|
+
listeners.forEach((cb) => cb())
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
42
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
43
|
+
{children}
|
|
44
|
+
</ResourceProvider>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
describe('usePresenceForDocument', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Without this, `mock.calls[0]` is whatever the previous test did.
|
|
50
|
+
vi.clearAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns presence and updates when the store changes', () => {
|
|
54
|
+
const {source, push} = createSource([entry('s1')])
|
|
55
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
56
|
+
|
|
57
|
+
const {result} = renderHook(
|
|
58
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
|
|
59
|
+
{wrapper},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(result.current.presence).toHaveLength(1)
|
|
63
|
+
|
|
64
|
+
act(() => push([entry('s1'), entry('s2')]))
|
|
65
|
+
expect(result.current.presence).toHaveLength(2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('passes the document, path, and excludeVersions through', () => {
|
|
69
|
+
const {source} = createSource([])
|
|
70
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
71
|
+
|
|
72
|
+
renderHook(
|
|
73
|
+
() =>
|
|
74
|
+
usePresenceForDocument({
|
|
75
|
+
documentId: 'drafts.movie-1',
|
|
76
|
+
documentType: 'movie',
|
|
77
|
+
path: ['cast', {_key: 'm1'}],
|
|
78
|
+
excludeVersions: true,
|
|
79
|
+
}),
|
|
80
|
+
{wrapper},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// Already a draft id, so resolution leaves it alone.
|
|
84
|
+
expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
|
|
85
|
+
documentId: 'drafts.movie-1',
|
|
86
|
+
path: ['cast', {_key: 'm1'}],
|
|
87
|
+
excludeVersions: true,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reads the same id useReportPresence writes, or reads never match writes', () => {
|
|
92
|
+
const {source} = createSource([])
|
|
93
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
94
|
+
|
|
95
|
+
// Forwarded unresolved, exactly as `useReportPresence` forwards it, so core
|
|
96
|
+
// resolves both the same way. If these diverged, field-level presence would
|
|
97
|
+
// silently never match.
|
|
98
|
+
renderHook(
|
|
99
|
+
() =>
|
|
100
|
+
usePresenceForDocument({
|
|
101
|
+
documentId: 'movie-1',
|
|
102
|
+
documentType: 'movie',
|
|
103
|
+
perspective: 'published',
|
|
104
|
+
}),
|
|
105
|
+
{wrapper},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
|
|
109
|
+
documentId: 'movie-1',
|
|
110
|
+
perspective: 'published',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not rebuild the source when a caller passes a fresh path each render', () => {
|
|
115
|
+
const {source} = createSource([])
|
|
116
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
117
|
+
|
|
118
|
+
const {rerender} = renderHook(
|
|
119
|
+
// A new array identity every render, which is what real callers write.
|
|
120
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie', path: ['title']}),
|
|
121
|
+
{wrapper},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
rerender()
|
|
125
|
+
rerender()
|
|
126
|
+
|
|
127
|
+
expect(vi.mocked(getDocumentPresence)).toHaveBeenCalledTimes(1)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns an empty array rather than undefined before anything arrives', () => {
|
|
131
|
+
const {source} = createSource(undefined as unknown as DocumentPresence[])
|
|
132
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
133
|
+
|
|
134
|
+
const {result} = renderHook(
|
|
135
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
|
|
136
|
+
{wrapper},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(result.current.presence).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -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
|
+
})
|