@sanity/sdk-react 2.18.0 → 2.20.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 +2290 -2584
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4166 -1878
- package/dist/index.js.map +1 -1
- package/package.json +18 -18
- package/src/_exports/sdk-react.ts +8 -0
- package/src/context/ResourceProvider.test.tsx +21 -0
- package/src/context/ResourceProvider.tsx +4 -1
- 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/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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Comment,
|
|
3
|
+
type CommentsOptions,
|
|
4
|
+
getCommentsState,
|
|
5
|
+
resolveComments,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {act, render, screen} from '@testing-library/react'
|
|
9
|
+
import {Suspense} from 'react'
|
|
10
|
+
import {type Observable, Subject} from 'rxjs'
|
|
11
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
12
|
+
|
|
13
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
14
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
15
|
+
import {useComments} from './useComments'
|
|
16
|
+
|
|
17
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
+
return {...original, getCommentsState: vi.fn(), resolveComments: vi.fn()}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
23
|
+
|
|
24
|
+
function comment(id: string): Comment {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
28
|
+
authorId: 'user-1',
|
|
29
|
+
message: null,
|
|
30
|
+
threadId: 'thread-1',
|
|
31
|
+
status: 'open',
|
|
32
|
+
documentId: 'doc-1',
|
|
33
|
+
documentType: 'author',
|
|
34
|
+
fieldPath: '',
|
|
35
|
+
reactions: [],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stands in for the store, one source per option set so a test can hold one
|
|
41
|
+
* document loaded and another not.
|
|
42
|
+
*
|
|
43
|
+
* `getCurrent` must hand back the same array every call for a given option set.
|
|
44
|
+
* Returning a fresh one sends `useSyncExternalStore` into a render loop, which
|
|
45
|
+
* is why the store memoises its selectors.
|
|
46
|
+
*/
|
|
47
|
+
function mockSource(
|
|
48
|
+
getCurrent: (options: CommentsOptions) => Comment[] | undefined,
|
|
49
|
+
changed$?: Subject<void>,
|
|
50
|
+
) {
|
|
51
|
+
vi.mocked(getCommentsState).mockImplementation(
|
|
52
|
+
(_instance, options) =>
|
|
53
|
+
({
|
|
54
|
+
getCurrent: () => getCurrent(options),
|
|
55
|
+
subscribe: vi.fn((cb?: () => void) => {
|
|
56
|
+
const subscription = changed$?.subscribe(() => cb?.())
|
|
57
|
+
return () => subscription?.unsubscribe()
|
|
58
|
+
}),
|
|
59
|
+
get observable(): Observable<Comment[] | undefined> {
|
|
60
|
+
throw new Error('Not implemented')
|
|
61
|
+
},
|
|
62
|
+
}) as StateSource<Comment[] | undefined>,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Hoisted so the context value stays identical across renders. */
|
|
67
|
+
const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
|
|
68
|
+
|
|
69
|
+
function Wrapper({children}: {children: React.ReactNode}) {
|
|
70
|
+
return (
|
|
71
|
+
<ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
|
|
72
|
+
<ResourcesContext.Provider value={{other: {projectId: 'p2', dataset: 'd2'}}}>
|
|
73
|
+
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
74
|
+
</ResourcesContext.Provider>
|
|
75
|
+
</ResourceProvider>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function PerspectiveWrapper({children}: {children: React.ReactNode}) {
|
|
80
|
+
return (
|
|
81
|
+
<ResourceProvider
|
|
82
|
+
projectId="p"
|
|
83
|
+
dataset="d"
|
|
84
|
+
perspective={RELEASE_PERSPECTIVE}
|
|
85
|
+
fallback={<p>Loading…</p>}
|
|
86
|
+
>
|
|
87
|
+
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
88
|
+
</ResourceProvider>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('useComments', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.resetAllMocks()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('renders the comments once they are loaded', () => {
|
|
98
|
+
const loaded = [comment('a'), comment('b')]
|
|
99
|
+
mockSource(() => loaded)
|
|
100
|
+
|
|
101
|
+
function TestComponent() {
|
|
102
|
+
const {comments, isPending} = useComments(HANDLE)
|
|
103
|
+
return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
107
|
+
|
|
108
|
+
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('suspends until the first snapshot arrives', async () => {
|
|
112
|
+
const loaded = [comment('a')]
|
|
113
|
+
const ref: {current: Comment[] | undefined} = {current: undefined}
|
|
114
|
+
const changed$ = new Subject<void>()
|
|
115
|
+
mockSource(() => ref.current, changed$)
|
|
116
|
+
|
|
117
|
+
let settle: () => void = () => {}
|
|
118
|
+
vi.mocked(resolveComments).mockReturnValue(
|
|
119
|
+
new Promise<Comment[]>((resolve) => {
|
|
120
|
+
settle = () => resolve(loaded)
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
function TestComponent() {
|
|
125
|
+
const {comments} = useComments(HANDLE)
|
|
126
|
+
return <div data-testid="out">{comments.length}</div>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
130
|
+
expect(screen.getByTestId('suspended')).toBeInTheDocument()
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
ref.current = loaded
|
|
134
|
+
settle()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(screen.getByTestId('out').textContent).toBe('1')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('passes the field path and status through to the store', () => {
|
|
141
|
+
const loaded: Comment[] = []
|
|
142
|
+
mockSource(() => loaded)
|
|
143
|
+
|
|
144
|
+
function TestComponent() {
|
|
145
|
+
useComments({...HANDLE, fieldPath: ['body', {_key: 'intro'}], status: 'resolved'})
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
150
|
+
|
|
151
|
+
expect(getCommentsState).toHaveBeenCalledWith(
|
|
152
|
+
expect.anything(),
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
documentId: 'doc-1',
|
|
155
|
+
fieldPath: 'body[_key=="intro"]',
|
|
156
|
+
status: 'resolved',
|
|
157
|
+
resource: {projectId: 'p', dataset: 'd'},
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('resolves a named resource from context', () => {
|
|
163
|
+
const loaded: Comment[] = []
|
|
164
|
+
mockSource(() => loaded)
|
|
165
|
+
|
|
166
|
+
function TestComponent() {
|
|
167
|
+
useComments({...HANDLE, resourceName: 'other'})
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
172
|
+
|
|
173
|
+
expect(getCommentsState).toHaveBeenCalledWith(
|
|
174
|
+
expect.anything(),
|
|
175
|
+
expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
|
|
176
|
+
)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('fills in the perspective from context', () => {
|
|
180
|
+
// Core turns a release perspective into `target.documentVersionId` and into
|
|
181
|
+
// the key the list is stored under, so dropping it here would read and
|
|
182
|
+
// write the wrong release with nothing to show that anything went wrong.
|
|
183
|
+
const loaded: Comment[] = []
|
|
184
|
+
mockSource(() => loaded)
|
|
185
|
+
|
|
186
|
+
function TestComponent() {
|
|
187
|
+
useComments(HANDLE)
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
render(<TestComponent />, {wrapper: PerspectiveWrapper})
|
|
192
|
+
|
|
193
|
+
expect(getCommentsState).toHaveBeenCalledWith(
|
|
194
|
+
expect.anything(),
|
|
195
|
+
expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('keeps the previous list on screen while a different document loads', async () => {
|
|
200
|
+
const first = [comment('a')]
|
|
201
|
+
const second = [comment('b'), comment('c')]
|
|
202
|
+
// Only `doc-1` is loaded, so switching to `doc-2` has to suspend.
|
|
203
|
+
const byDocument: Record<string, Comment[] | undefined> = {'doc-1': first}
|
|
204
|
+
mockSource((options) => byDocument[options.documentId])
|
|
205
|
+
|
|
206
|
+
let settle: () => void = () => {}
|
|
207
|
+
vi.mocked(resolveComments).mockReturnValue(
|
|
208
|
+
new Promise<Comment[]>((resolve) => {
|
|
209
|
+
settle = () => resolve(second)
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
function TestComponent({documentId}: {documentId: string}) {
|
|
214
|
+
const {comments, isPending} = useComments({...HANDLE, documentId})
|
|
215
|
+
return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const {rerender} = render(<TestComponent documentId="doc-1" />, {wrapper: Wrapper})
|
|
219
|
+
expect(screen.getByTestId('out').textContent).toBe('1 idle')
|
|
220
|
+
|
|
221
|
+
await act(async () => {
|
|
222
|
+
rerender(<TestComponent documentId="doc-2" />)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// The swap happens inside a transition, so the render that suspends is
|
|
226
|
+
// thrown away rather than falling back: `doc-1` stays on screen and
|
|
227
|
+
// `isPending` is what reports the switch.
|
|
228
|
+
expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
|
|
229
|
+
expect(screen.getByTestId('out').textContent).toBe('1 pending')
|
|
230
|
+
expect(getCommentsState).toHaveBeenCalledWith(
|
|
231
|
+
expect.anything(),
|
|
232
|
+
expect.objectContaining({documentId: 'doc-2'}),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
await act(async () => {
|
|
236
|
+
byDocument['doc-2'] = second
|
|
237
|
+
settle()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {type Comment, type CommentsOptions, getCommentsState, resolveComments} from '@sanity/sdk'
|
|
2
|
+
import {useMemo} from 'react'
|
|
3
|
+
|
|
4
|
+
import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
|
|
5
|
+
import {type CommentListSource, useCommentList} from './useCommentList'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @public
|
|
9
|
+
* @category Types
|
|
10
|
+
*/
|
|
11
|
+
export interface UseCommentsResult {
|
|
12
|
+
/** Every matching comment, newest first, replies included. */
|
|
13
|
+
comments: Comment[]
|
|
14
|
+
/** True while switching to a different document or filter. */
|
|
15
|
+
isPending: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SOURCE: CommentListSource<Comment[]> = {
|
|
19
|
+
getState: getCommentsState,
|
|
20
|
+
resolve: resolveComments,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads a document's comments and keeps them up to date.
|
|
25
|
+
*
|
|
26
|
+
* Comments are shared with the Studio: they live in the project's comments
|
|
27
|
+
* dataset, so a thread started here shows up there and the other way round. The
|
|
28
|
+
* list is flat, replies included; reach for {@link useCommentThreads} to read it
|
|
29
|
+
* grouped.
|
|
30
|
+
*
|
|
31
|
+
* Suspends until the comments have loaded. Switching document or filter is a
|
|
32
|
+
* transition, so the previous list stays on screen and `isPending` goes true
|
|
33
|
+
* rather than the component suspending again.
|
|
34
|
+
*
|
|
35
|
+
* @category Comments
|
|
36
|
+
* @function
|
|
37
|
+
* @param options - The document to read, optionally narrowed by `fieldPath` or `status`
|
|
38
|
+
* @returns The matching comments, and whether a switch is in flight
|
|
39
|
+
*
|
|
40
|
+
* @example Count the open threads on a field
|
|
41
|
+
* ```tsx
|
|
42
|
+
* function TitleCommentCount({documentId}: {documentId: string}) {
|
|
43
|
+
* const {comments} = useComments({
|
|
44
|
+
* documentId,
|
|
45
|
+
* documentType: 'article',
|
|
46
|
+
* fieldPath: 'title',
|
|
47
|
+
* status: 'open',
|
|
48
|
+
* })
|
|
49
|
+
*
|
|
50
|
+
* return <span>{comments.length}</span>
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export function useComments(options: WithResourceNameSupport<CommentsOptions>): UseCommentsResult {
|
|
57
|
+
const {value, isPending} = useCommentList('useComments', options, SOURCE)
|
|
58
|
+
return useMemo(() => ({comments: value, isPending}), [isPending, value])
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|