@sanity/sdk-react 3.11.0-next.20261029105031 → 3.11.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/README.md +13 -9
- package/dist/_exports/collaboration.d.ts +255 -0
- package/dist/_exports/collaboration.d.ts.map +1 -0
- package/dist/_exports/collaboration.js +244 -0
- package/dist/_exports/collaboration.js.map +1 -0
- package/dist/_exports/dashboard.js +2 -1
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +16 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -159
- package/dist/index.js.map +1 -1
- package/dist/useCommentList-CXi_IIwA.js +167 -0
- package/dist/useCommentList-CXi_IIwA.js.map +1 -0
- package/dist/useNormalizedResourceOptions-BKg7aOKA.d.ts +22 -0
- package/dist/useNormalizedResourceOptions-BKg7aOKA.d.ts.map +1 -0
- package/dist/useSanityInstance-C0a_Ou_p.js +10 -0
- package/dist/useSanityInstance-C0a_Ou_p.js.map +1 -0
- package/dist/{useStudioWorkspacesByProjectIdDataset-FRofq7KL.js → useStudioWorkspacesByProjectIdDataset-Cx-Q44d0.js} +4 -8
- package/dist/useStudioWorkspacesByProjectIdDataset-Cx-Q44d0.js.map +1 -0
- package/package.json +3 -2
- package/src/_exports/collaboration.ts +13 -0
- package/src/_exports/sdk-react.ts +6 -3
- package/src/hooks/comments/legacy/noCommentsError.ts +20 -0
- package/src/hooks/comments/legacy/useCommentActions.test.tsx +150 -0
- package/src/hooks/comments/legacy/useCommentActions.ts +116 -0
- package/src/hooks/comments/legacy/useCommentThreads.test.tsx +107 -0
- package/src/hooks/comments/legacy/useCommentThreads.ts +83 -0
- package/src/hooks/comments/legacy/useComments.test.tsx +242 -0
- package/src/hooks/comments/legacy/useComments.ts +68 -0
- package/src/hooks/comments/useCommentActions.test.tsx +33 -13
- package/src/hooks/comments/useCommentActions.ts +31 -11
- package/src/hooks/comments/useCommentList.ts +34 -19
- package/src/hooks/comments/useCommentThreads.test.tsx +207 -19
- package/src/hooks/comments/useCommentThreads.ts +33 -27
- package/src/hooks/comments/useComments.test.tsx +33 -126
- package/src/hooks/comments/useComments.ts +31 -12
- package/src/hooks/comments/useCommentsQuery.test.tsx +259 -0
- package/src/hooks/comments/useCommentsQuery.ts +105 -0
- package/src/hooks/document/useEditDocument.ts +0 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-FRofq7KL.js.map +0 -1
|
@@ -1,74 +1,80 @@
|
|
|
1
|
+
import {getDocumentCommentsOptionsKey, parseDocumentCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
1
2
|
import {
|
|
2
3
|
type CommentsOptions,
|
|
3
4
|
type CommentThread,
|
|
5
|
+
getCommentsErrorState,
|
|
4
6
|
getCommentThreadsState,
|
|
5
7
|
resolveCommentThreads,
|
|
6
|
-
} from '@sanity/sdk'
|
|
8
|
+
} from '@sanity/sdk/collaboration'
|
|
7
9
|
import {useMemo} from 'react'
|
|
8
10
|
|
|
9
11
|
import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
|
|
10
12
|
import {type CommentListSource, useCommentList} from './useCommentList'
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* @
|
|
15
|
+
* @beta
|
|
14
16
|
* @category Types
|
|
15
17
|
* @category Comments
|
|
16
18
|
*/
|
|
17
19
|
export interface UseCommentThreadsResult {
|
|
18
|
-
/**
|
|
20
|
+
/** Matching threads, newest first, each with its replies oldest first. */
|
|
19
21
|
threads: CommentThread[]
|
|
20
22
|
/** True while switching to a different document or filter. */
|
|
21
23
|
isPending: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Set when the threads have stopped following the server: the listener
|
|
26
|
+
* failed after they had loaded, so what you are reading is the list as it
|
|
27
|
+
* last stood rather than as it is. Clears when a listener comes back.
|
|
28
|
+
*/
|
|
29
|
+
error?: unknown
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
const SOURCE: CommentListSource<CommentThread[]> = {
|
|
32
|
+
const SOURCE: CommentListSource<CommentsOptions, CommentThread[]> = {
|
|
25
33
|
getState: getCommentThreadsState,
|
|
34
|
+
getErrorState: getCommentsErrorState,
|
|
26
35
|
resolve: resolveCommentThreads,
|
|
36
|
+
getKey: getDocumentCommentsOptionsKey,
|
|
37
|
+
parseKey: parseDocumentCommentsOptionsKey,
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
|
-
* Reads a document's
|
|
41
|
+
* Reads a document's comment threads and keeps them up to date.
|
|
31
42
|
*
|
|
32
|
-
* A thread
|
|
33
|
-
* from
|
|
34
|
-
*
|
|
43
|
+
* A thread carries its first comment plus its replies, and `status` and
|
|
44
|
+
* `fieldPath` come from that first comment, so filtering by either selects
|
|
45
|
+
* whole threads. Use {@link useComments} to read the same list flat, `scope` to
|
|
46
|
+
* say which of the document's comments to cover — by default it follows the
|
|
47
|
+
* perspective in view — and {@link useCommentsQuery} when the question is not
|
|
48
|
+
* "comments on this document".
|
|
35
49
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* your app needs to do the same.
|
|
50
|
+
* Suspends until the comments have loaded. Switching document or filter is a
|
|
51
|
+
* transition, so the previous list stays on screen and `isPending` goes true
|
|
52
|
+
* rather than the component suspending again.
|
|
40
53
|
*
|
|
41
54
|
* @category Comments
|
|
42
55
|
* @function
|
|
43
|
-
* @param options - The document to read, optionally narrowed by `fieldPath` or `
|
|
56
|
+
* @param options - The document to read, optionally narrowed by `fieldPath`, `status`, or `scope`
|
|
44
57
|
* @returns The matching threads, and whether a switch is in flight
|
|
45
58
|
*
|
|
46
|
-
* @example
|
|
59
|
+
* @example Count the open threads on a field
|
|
47
60
|
* ```tsx
|
|
48
|
-
* function
|
|
61
|
+
* function TitleCommentCount({documentId}: {documentId: string}) {
|
|
49
62
|
* const {threads} = useCommentThreads({
|
|
50
63
|
* documentId,
|
|
51
64
|
* documentType: 'article',
|
|
65
|
+
* fieldPath: 'title',
|
|
52
66
|
* status: 'open',
|
|
53
67
|
* })
|
|
54
68
|
*
|
|
55
|
-
* return
|
|
56
|
-
* <ul>
|
|
57
|
-
* {threads.map((thread) => (
|
|
58
|
-
* <li key={thread.threadId}>
|
|
59
|
-
* {thread.fieldPath || 'Document'} — {thread.commentsCount} comments
|
|
60
|
-
* </li>
|
|
61
|
-
* ))}
|
|
62
|
-
* </ul>
|
|
63
|
-
* )
|
|
69
|
+
* return <span>{threads.length}</span>
|
|
64
70
|
* }
|
|
65
71
|
* ```
|
|
66
72
|
*
|
|
67
|
-
* @
|
|
73
|
+
* @beta
|
|
68
74
|
*/
|
|
69
75
|
export function useCommentThreads(
|
|
70
76
|
options: WithResourceNameSupport<CommentsOptions>,
|
|
71
77
|
): UseCommentThreadsResult {
|
|
72
|
-
const {value, isPending} = useCommentList('useCommentThreads', options, SOURCE)
|
|
73
|
-
return useMemo(() => ({threads: value, isPending}), [isPending, value])
|
|
78
|
+
const {value, isPending, error} = useCommentList('useCommentThreads', options, SOURCE)
|
|
79
|
+
return useMemo(() => ({threads: value, isPending, error}), [error, isPending, value])
|
|
74
80
|
}
|
|
@@ -1,48 +1,38 @@
|
|
|
1
|
+
import {type StateSource} from '@sanity/sdk'
|
|
1
2
|
import {
|
|
2
3
|
type Comment,
|
|
3
4
|
type CommentsOptions,
|
|
5
|
+
getCommentsErrorState,
|
|
4
6
|
getCommentsState,
|
|
5
7
|
resolveComments,
|
|
6
|
-
|
|
7
|
-
} from '@sanity/sdk'
|
|
8
|
+
} from '@sanity/sdk/collaboration'
|
|
8
9
|
import {act, render, screen} from '@testing-library/react'
|
|
9
10
|
import {Suspense} from 'react'
|
|
10
11
|
import {type Observable, Subject} from 'rxjs'
|
|
11
12
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
12
13
|
|
|
13
14
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
14
|
-
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
15
15
|
import {useComments} from './useComments'
|
|
16
16
|
|
|
17
|
-
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
-
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
-
return {
|
|
17
|
+
vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
|
|
19
|
+
return {
|
|
20
|
+
...original,
|
|
21
|
+
getCommentsState: vi.fn(),
|
|
22
|
+
getCommentsErrorState: vi.fn(),
|
|
23
|
+
resolveComments: vi.fn(),
|
|
24
|
+
}
|
|
20
25
|
})
|
|
21
26
|
|
|
22
27
|
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
23
28
|
|
|
24
|
-
function comment(id: string)
|
|
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
|
-
}
|
|
29
|
+
function comment(id: string) {
|
|
30
|
+
return {id, fieldPath: ''} as Comment
|
|
37
31
|
}
|
|
38
32
|
|
|
39
33
|
/**
|
|
40
|
-
* Stands in for the store
|
|
41
|
-
*
|
|
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.
|
|
34
|
+
* Stands in for the store. `getCurrent` must hand back the same array every
|
|
35
|
+
* call for a given option set, or `useSyncExternalStore` renders in a loop.
|
|
46
36
|
*/
|
|
47
37
|
function mockSource(
|
|
48
38
|
getCurrent: (options: CommentsOptions) => Comment[] | undefined,
|
|
@@ -61,29 +51,21 @@ function mockSource(
|
|
|
61
51
|
},
|
|
62
52
|
}) as StateSource<Comment[] | undefined>,
|
|
63
53
|
)
|
|
54
|
+
vi.mocked(getCommentsErrorState).mockImplementation(
|
|
55
|
+
() =>
|
|
56
|
+
({
|
|
57
|
+
getCurrent: () => undefined,
|
|
58
|
+
subscribe: vi.fn(() => () => {}),
|
|
59
|
+
get observable(): Observable<unknown> {
|
|
60
|
+
throw new Error('Not implemented')
|
|
61
|
+
},
|
|
62
|
+
}) as StateSource<unknown>,
|
|
63
|
+
)
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/** Hoisted so the context value stays identical across renders. */
|
|
67
|
-
const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
|
|
68
|
-
|
|
69
66
|
function Wrapper({children}: {children: React.ReactNode}) {
|
|
70
67
|
return (
|
|
71
68
|
<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
69
|
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
88
70
|
</ResourceProvider>
|
|
89
71
|
)
|
|
@@ -137,12 +119,17 @@ describe('useComments', () => {
|
|
|
137
119
|
expect(screen.getByTestId('out').textContent).toBe('1')
|
|
138
120
|
})
|
|
139
121
|
|
|
140
|
-
it('passes the field path and
|
|
122
|
+
it('passes the field path, status, and scope through to the store', () => {
|
|
141
123
|
const loaded: Comment[] = []
|
|
142
124
|
mockSource(() => loaded)
|
|
143
125
|
|
|
144
126
|
function TestComponent() {
|
|
145
|
-
useComments({
|
|
127
|
+
useComments({
|
|
128
|
+
...HANDLE,
|
|
129
|
+
fieldPath: ['body', {_key: 'intro'}],
|
|
130
|
+
status: 'resolved',
|
|
131
|
+
scope: 'all',
|
|
132
|
+
})
|
|
146
133
|
return null
|
|
147
134
|
}
|
|
148
135
|
|
|
@@ -154,89 +141,9 @@ describe('useComments', () => {
|
|
|
154
141
|
documentId: 'doc-1',
|
|
155
142
|
fieldPath: 'body[_key=="intro"]',
|
|
156
143
|
status: 'resolved',
|
|
144
|
+
scope: 'all',
|
|
157
145
|
resource: {projectId: 'p', dataset: 'd'},
|
|
158
146
|
}),
|
|
159
147
|
)
|
|
160
148
|
})
|
|
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
149
|
})
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {getDocumentCommentsOptionsKey, parseDocumentCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
2
|
+
import {
|
|
3
|
+
type Comment,
|
|
4
|
+
type CommentsOptions,
|
|
5
|
+
getCommentsErrorState,
|
|
6
|
+
getCommentsState,
|
|
7
|
+
resolveComments,
|
|
8
|
+
} from '@sanity/sdk/collaboration'
|
|
2
9
|
import {useMemo} from 'react'
|
|
3
10
|
|
|
4
11
|
import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
|
|
5
12
|
import {type CommentListSource, useCommentList} from './useCommentList'
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
|
-
* @
|
|
15
|
+
* @beta
|
|
9
16
|
* @category Types
|
|
10
17
|
* @category Comments
|
|
11
18
|
*/
|
|
@@ -14,20 +21,32 @@ export interface UseCommentsResult {
|
|
|
14
21
|
comments: Comment[]
|
|
15
22
|
/** True while switching to a different document or filter. */
|
|
16
23
|
isPending: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Set when the comments have stopped following the server: the listener
|
|
26
|
+
* failed after they had loaded, so what you are reading is the list as it
|
|
27
|
+
* last stood rather than as it is. Clears when a listener comes back.
|
|
28
|
+
*/
|
|
29
|
+
error?: unknown
|
|
17
30
|
}
|
|
18
31
|
|
|
19
|
-
const SOURCE: CommentListSource<Comment[]> = {
|
|
32
|
+
const SOURCE: CommentListSource<CommentsOptions, Comment[]> = {
|
|
20
33
|
getState: getCommentsState,
|
|
34
|
+
getErrorState: getCommentsErrorState,
|
|
21
35
|
resolve: resolveComments,
|
|
36
|
+
getKey: getDocumentCommentsOptionsKey,
|
|
37
|
+
parseKey: parseDocumentCommentsOptionsKey,
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
/**
|
|
25
41
|
* Reads a document's comments and keeps them up to date.
|
|
26
42
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
43
|
+
* The list is flat, replies included, which suits a count or a feed. Reach for
|
|
44
|
+
* {@link useCommentThreads} to read the same comments grouped, where a reply
|
|
45
|
+
* sits under the comment it answers and filtering by `status` or `fieldPath`
|
|
46
|
+
* selects whole threads rather than individual comments. Both read the same
|
|
47
|
+
* document and share a listener, so using them side by side costs nothing
|
|
48
|
+
* extra. {@link useCommentsQuery} covers anything that is not one document's
|
|
49
|
+
* comments.
|
|
31
50
|
*
|
|
32
51
|
* Suspends until the comments have loaded. Switching document or filter is a
|
|
33
52
|
* transition, so the previous list stays on screen and `isPending` goes true
|
|
@@ -35,10 +54,10 @@ const SOURCE: CommentListSource<Comment[]> = {
|
|
|
35
54
|
*
|
|
36
55
|
* @category Comments
|
|
37
56
|
* @function
|
|
38
|
-
* @param options - The document to read, optionally narrowed by `fieldPath` or `
|
|
57
|
+
* @param options - The document to read, optionally narrowed by `fieldPath`, `status`, or `scope`
|
|
39
58
|
* @returns The matching comments, and whether a switch is in flight
|
|
40
59
|
*
|
|
41
|
-
* @example Count the open
|
|
60
|
+
* @example Count the open comments on a field
|
|
42
61
|
* ```tsx
|
|
43
62
|
* function TitleCommentCount({documentId}: {documentId: string}) {
|
|
44
63
|
* const {comments} = useComments({
|
|
@@ -52,9 +71,9 @@ const SOURCE: CommentListSource<Comment[]> = {
|
|
|
52
71
|
* }
|
|
53
72
|
* ```
|
|
54
73
|
*
|
|
55
|
-
* @
|
|
74
|
+
* @beta
|
|
56
75
|
*/
|
|
57
76
|
export function useComments(options: WithResourceNameSupport<CommentsOptions>): UseCommentsResult {
|
|
58
|
-
const {value, isPending} = useCommentList('useComments', options, SOURCE)
|
|
59
|
-
return useMemo(() => ({comments: value, isPending}), [isPending, value])
|
|
77
|
+
const {value, isPending, error} = useCommentList('useComments', options, SOURCE)
|
|
78
|
+
return useMemo(() => ({comments: value, isPending, error}), [error, isPending, value])
|
|
60
79
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {
|
|
3
|
+
type Comment,
|
|
4
|
+
type CommentsQueryOptions,
|
|
5
|
+
getCommentsQueryErrorState,
|
|
6
|
+
getCommentsQueryState,
|
|
7
|
+
resolveCommentsQuery,
|
|
8
|
+
} from '@sanity/sdk/collaboration'
|
|
9
|
+
import {act, render, screen} from '@testing-library/react'
|
|
10
|
+
import {Suspense} from 'react'
|
|
11
|
+
import {type Observable, Subject} from 'rxjs'
|
|
12
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
13
|
+
|
|
14
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
15
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
16
|
+
import {useCommentsQuery} from './useCommentsQuery'
|
|
17
|
+
|
|
18
|
+
vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
|
|
19
|
+
const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
|
|
20
|
+
return {
|
|
21
|
+
...original,
|
|
22
|
+
getCommentsQueryState: vi.fn(),
|
|
23
|
+
getCommentsQueryErrorState: vi.fn(),
|
|
24
|
+
resolveCommentsQuery: vi.fn(),
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function comment(id: string): Comment {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
32
|
+
authorId: 'user-1',
|
|
33
|
+
message: [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hello'}]}],
|
|
34
|
+
threadId: 'thread-1',
|
|
35
|
+
status: 'open',
|
|
36
|
+
documentId: 'doc-1',
|
|
37
|
+
sourceDocumentId: 'doc-1',
|
|
38
|
+
documentType: 'author',
|
|
39
|
+
fieldPath: 'title',
|
|
40
|
+
reactions: [],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** See the equivalent in `useCommentThreads.test.tsx` for why this is keyed. */
|
|
45
|
+
function mockSource(
|
|
46
|
+
getCurrent: (options: CommentsQueryOptions) => Comment[] | undefined,
|
|
47
|
+
changed$?: Subject<void>,
|
|
48
|
+
) {
|
|
49
|
+
vi.mocked(getCommentsQueryState).mockImplementation(
|
|
50
|
+
(_instance, options) =>
|
|
51
|
+
({
|
|
52
|
+
getCurrent: () => getCurrent(options),
|
|
53
|
+
subscribe: vi.fn((cb?: () => void) => {
|
|
54
|
+
const subscription = changed$?.subscribe(() => cb?.())
|
|
55
|
+
return () => subscription?.unsubscribe()
|
|
56
|
+
}),
|
|
57
|
+
get observable(): Observable<Comment[] | undefined> {
|
|
58
|
+
throw new Error('Not implemented')
|
|
59
|
+
},
|
|
60
|
+
}) as StateSource<Comment[] | undefined>,
|
|
61
|
+
)
|
|
62
|
+
mockErrorSource(() => undefined)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The companion source, live by default. Call again to override. */
|
|
66
|
+
function mockErrorSource(getCurrent: () => unknown, changed$?: Subject<void>) {
|
|
67
|
+
vi.mocked(getCommentsQueryErrorState).mockImplementation(
|
|
68
|
+
() =>
|
|
69
|
+
({
|
|
70
|
+
getCurrent,
|
|
71
|
+
subscribe: vi.fn((cb?: () => void) => {
|
|
72
|
+
const subscription = changed$?.subscribe(() => cb?.())
|
|
73
|
+
return () => subscription?.unsubscribe()
|
|
74
|
+
}),
|
|
75
|
+
get observable(): Observable<unknown> {
|
|
76
|
+
throw new Error('Not implemented')
|
|
77
|
+
},
|
|
78
|
+
}) as StateSource<unknown>,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function Wrapper({children}: {children: React.ReactNode}) {
|
|
83
|
+
return (
|
|
84
|
+
<ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
|
|
85
|
+
<ResourcesContext.Provider value={{other: {projectId: 'p2', dataset: 'd2'}}}>
|
|
86
|
+
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
87
|
+
</ResourcesContext.Provider>
|
|
88
|
+
</ResourceProvider>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('useCommentsQuery', () => {
|
|
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} = useCommentsQuery({filter: 'status == "open"'})
|
|
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(resolveCommentsQuery).mockReturnValue(
|
|
119
|
+
new Promise<Comment[]>((resolve) => {
|
|
120
|
+
settle = () => resolve(loaded)
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
function TestComponent() {
|
|
125
|
+
const {comments} = useCommentsQuery({filter: 'status == "open"'})
|
|
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 filter and its params through to the store', () => {
|
|
141
|
+
const loaded: Comment[] = []
|
|
142
|
+
mockSource(() => loaded)
|
|
143
|
+
|
|
144
|
+
function TestComponent() {
|
|
145
|
+
useCommentsQuery({filter: '_system.createdBy == $userId', params: {userId: 'user-1'}})
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
150
|
+
|
|
151
|
+
expect(getCommentsQueryState).toHaveBeenCalledWith(
|
|
152
|
+
expect.anything(),
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
filter: '_system.createdBy == $userId',
|
|
155
|
+
params: {userId: 'user-1'},
|
|
156
|
+
resource: {projectId: 'p', dataset: 'd'},
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('resolves a named resource from context', () => {
|
|
162
|
+
const loaded: Comment[] = []
|
|
163
|
+
mockSource(() => loaded)
|
|
164
|
+
|
|
165
|
+
function TestComponent() {
|
|
166
|
+
useCommentsQuery({filter: 'status == "open"', resourceName: 'other'})
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
171
|
+
|
|
172
|
+
expect(getCommentsQueryState).toHaveBeenCalledWith(
|
|
173
|
+
expect.anything(),
|
|
174
|
+
expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('re-reads when a param changes rather than only when the filter does', () => {
|
|
179
|
+
// The filter is a template and the params are what it means, so keying on
|
|
180
|
+
// the filter alone would leave a user's list showing another user's
|
|
181
|
+
// comments.
|
|
182
|
+
const loaded: Comment[] = []
|
|
183
|
+
mockSource(() => loaded)
|
|
184
|
+
|
|
185
|
+
function TestComponent({userId}: {userId: string}) {
|
|
186
|
+
useCommentsQuery({filter: '_system.createdBy == $userId', params: {userId}})
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const {rerender} = render(<TestComponent userId="user-1" />, {wrapper: Wrapper})
|
|
191
|
+
rerender(<TestComponent userId="user-2" />)
|
|
192
|
+
|
|
193
|
+
expect(getCommentsQueryState).toHaveBeenCalledWith(
|
|
194
|
+
expect.anything(),
|
|
195
|
+
expect.objectContaining({params: {userId: 'user-2'}}),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('keeps the previous list on screen while a different filter loads', async () => {
|
|
200
|
+
const open = [comment('a')]
|
|
201
|
+
const resolved = [comment('b'), comment('c')]
|
|
202
|
+
const byFilter: Record<string, Comment[] | undefined> = {'status == "open"': open}
|
|
203
|
+
mockSource((options) => byFilter[options.filter])
|
|
204
|
+
|
|
205
|
+
let settle: () => void = () => {}
|
|
206
|
+
vi.mocked(resolveCommentsQuery).mockReturnValue(
|
|
207
|
+
new Promise<Comment[]>((resolve) => {
|
|
208
|
+
settle = () => resolve(resolved)
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
function TestComponent({filter}: {filter: string}) {
|
|
213
|
+
const {comments, isPending} = useCommentsQuery({filter})
|
|
214
|
+
return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const {rerender} = render(<TestComponent filter='status == "open"' />, {wrapper: Wrapper})
|
|
218
|
+
expect(screen.getByTestId('out').textContent).toBe('1 idle')
|
|
219
|
+
|
|
220
|
+
await act(async () => {
|
|
221
|
+
rerender(<TestComponent filter='status == "resolved"' />)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
|
|
225
|
+
expect(screen.getByTestId('out').textContent).toBe('1 pending')
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
byFilter['status == "resolved"'] = resolved
|
|
229
|
+
settle()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('reports a listener that failed after the comments had loaded', async () => {
|
|
236
|
+
const loaded = [comment('a')]
|
|
237
|
+
mockSource(() => loaded)
|
|
238
|
+
const failure: {current: unknown} = {current: undefined}
|
|
239
|
+
const changed$ = new Subject<void>()
|
|
240
|
+
mockErrorSource(() => failure.current, changed$)
|
|
241
|
+
|
|
242
|
+
function TestComponent() {
|
|
243
|
+
const {comments, error} = useCommentsQuery({filter: 'status == "open"'})
|
|
244
|
+
return (
|
|
245
|
+
<div data-testid="out">{`${comments.length} ${error instanceof Error ? error.message : 'live'}`}</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
250
|
+
expect(screen.getByTestId('out').textContent).toBe('1 live')
|
|
251
|
+
|
|
252
|
+
await act(async () => {
|
|
253
|
+
failure.current = new Error('connection failed')
|
|
254
|
+
changed$.next()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
expect(screen.getByTestId('out').textContent).toBe('1 connection failed')
|
|
258
|
+
})
|
|
259
|
+
})
|