@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,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
addReaction,
|
|
2
3
|
createComment,
|
|
3
4
|
removeComment,
|
|
5
|
+
removeReaction,
|
|
4
6
|
replyToComment,
|
|
5
7
|
setCommentStatus,
|
|
6
8
|
updateComment,
|
|
7
|
-
|
|
9
|
+
updateCommentAnchor,
|
|
10
|
+
} from '@sanity/sdk/collaboration'
|
|
8
11
|
import {renderHook} from '@testing-library/react'
|
|
9
12
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
10
13
|
|
|
@@ -12,15 +15,18 @@ import {ResourceProvider} from '../../context/ResourceProvider'
|
|
|
12
15
|
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
13
16
|
import {useCommentActions} from './useCommentActions'
|
|
14
17
|
|
|
15
|
-
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
-
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
18
|
+
vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
|
|
19
|
+
const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
|
|
17
20
|
return {
|
|
18
21
|
...original,
|
|
19
22
|
createComment: vi.fn(),
|
|
20
23
|
replyToComment: vi.fn(),
|
|
21
24
|
updateComment: vi.fn(),
|
|
25
|
+
updateCommentAnchor: vi.fn(),
|
|
22
26
|
setCommentStatus: vi.fn(),
|
|
23
27
|
removeComment: vi.fn(),
|
|
28
|
+
addReaction: vi.fn(),
|
|
29
|
+
removeReaction: vi.fn(),
|
|
24
30
|
}
|
|
25
31
|
})
|
|
26
32
|
|
|
@@ -29,6 +35,14 @@ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
|
29
35
|
/** Creates name a field, since a comment with no path is refused. */
|
|
30
36
|
const CREATE = {...HANDLE, fieldPath: 'name'}
|
|
31
37
|
|
|
38
|
+
const MESSAGE = [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hi'}]}]
|
|
39
|
+
|
|
40
|
+
const ANCHOR = {
|
|
41
|
+
type: 'portable-text',
|
|
42
|
+
start: {_key: 'b1', offset: 0},
|
|
43
|
+
end: {_key: 'b1', offset: 5},
|
|
44
|
+
} as const
|
|
45
|
+
|
|
32
46
|
// Hoisted: an inline object here would be a new value on every render, and the
|
|
33
47
|
// callbacks are memoised against it.
|
|
34
48
|
const RESOURCES = {other: {projectId: 'p2', dataset: 'd2'}}
|
|
@@ -73,23 +87,29 @@ describe('useCommentActions', () => {
|
|
|
73
87
|
it('forwards each action to the store', () => {
|
|
74
88
|
const {result} = setup()
|
|
75
89
|
|
|
76
|
-
result.current.createComment({...CREATE, message:
|
|
77
|
-
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message:
|
|
78
|
-
result.current.updateComment({commentId: 'c1', message:
|
|
90
|
+
result.current.createComment({...CREATE, message: MESSAGE})
|
|
91
|
+
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: MESSAGE})
|
|
92
|
+
result.current.updateComment({commentId: 'c1', message: MESSAGE})
|
|
93
|
+
result.current.updateCommentAnchor({commentId: 'c1', anchor: ANCHOR})
|
|
79
94
|
result.current.setCommentStatus({commentId: 'c1', status: 'resolved'})
|
|
80
95
|
result.current.removeComment({commentId: 'c1'})
|
|
96
|
+
result.current.addReaction({commentId: 'c1', shortName: ':+1:'})
|
|
97
|
+
result.current.removeReaction({commentId: 'c1', shortName: ':+1:'})
|
|
81
98
|
|
|
82
99
|
expect(createComment).toHaveBeenCalledOnce()
|
|
83
100
|
expect(replyToComment).toHaveBeenCalledOnce()
|
|
84
101
|
expect(updateComment).toHaveBeenCalledOnce()
|
|
102
|
+
expect(updateCommentAnchor).toHaveBeenCalledOnce()
|
|
85
103
|
expect(setCommentStatus).toHaveBeenCalledOnce()
|
|
86
104
|
expect(removeComment).toHaveBeenCalledOnce()
|
|
105
|
+
expect(addReaction).toHaveBeenCalledOnce()
|
|
106
|
+
expect(removeReaction).toHaveBeenCalledOnce()
|
|
87
107
|
})
|
|
88
108
|
|
|
89
109
|
it('fills in the resource from context', () => {
|
|
90
110
|
const {result} = setup()
|
|
91
111
|
|
|
92
|
-
result.current.createComment({...CREATE, message:
|
|
112
|
+
result.current.createComment({...CREATE, message: MESSAGE})
|
|
93
113
|
|
|
94
114
|
expect(createComment).toHaveBeenCalledWith(
|
|
95
115
|
expect.anything(),
|
|
@@ -119,13 +139,13 @@ describe('useCommentActions', () => {
|
|
|
119
139
|
})
|
|
120
140
|
|
|
121
141
|
it('fills in the perspective from context', () => {
|
|
122
|
-
// Core turns a release perspective into
|
|
123
|
-
// losing it here files the comment against the wrong
|
|
124
|
-
// reports an error.
|
|
142
|
+
// Core turns a release perspective into the source document id the comment
|
|
143
|
+
// is written against, so losing it here files the comment against the wrong
|
|
144
|
+
// release and nothing reports an error.
|
|
125
145
|
const {result} = setup(PerspectiveWrapper)
|
|
126
146
|
|
|
127
|
-
result.current.createComment({...CREATE, message:
|
|
128
|
-
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message:
|
|
147
|
+
result.current.createComment({...CREATE, message: MESSAGE})
|
|
148
|
+
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: MESSAGE})
|
|
129
149
|
|
|
130
150
|
expect(createComment).toHaveBeenCalledWith(
|
|
131
151
|
expect.anything(),
|
|
@@ -140,7 +160,7 @@ describe('useCommentActions', () => {
|
|
|
140
160
|
it('lets a call override the perspective from context', () => {
|
|
141
161
|
const {result} = setup(PerspectiveWrapper)
|
|
142
162
|
|
|
143
|
-
result.current.createComment({...CREATE, message:
|
|
163
|
+
result.current.createComment({...CREATE, message: MESSAGE, perspective: 'published'})
|
|
144
164
|
|
|
145
165
|
expect(createComment).toHaveBeenCalledWith(
|
|
146
166
|
expect.anything(),
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
+
import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
|
|
1
2
|
import {
|
|
3
|
+
addReaction,
|
|
2
4
|
type Comment,
|
|
3
5
|
createComment,
|
|
4
6
|
type CreateCommentOptions,
|
|
5
|
-
type
|
|
6
|
-
type PerspectiveHandle,
|
|
7
|
+
type ReactionOptions,
|
|
7
8
|
removeComment,
|
|
8
9
|
type RemoveCommentOptions,
|
|
10
|
+
removeReaction,
|
|
9
11
|
replyToComment,
|
|
10
12
|
type ReplyToCommentOptions,
|
|
11
13
|
setCommentStatus,
|
|
12
14
|
type SetCommentStatusOptions,
|
|
13
15
|
updateComment,
|
|
16
|
+
updateCommentAnchor,
|
|
17
|
+
type UpdateCommentAnchorOptions,
|
|
14
18
|
type UpdateCommentOptions,
|
|
15
|
-
} from '@sanity/sdk'
|
|
19
|
+
} from '@sanity/sdk/collaboration'
|
|
16
20
|
import {useContext, useMemo} from 'react'
|
|
17
21
|
|
|
18
22
|
import {PerspectiveContext} from '../../context/PerspectiveContext'
|
|
@@ -26,21 +30,31 @@ import {
|
|
|
26
30
|
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
|
-
* @
|
|
33
|
+
* @beta
|
|
30
34
|
* @category Types
|
|
31
35
|
* @category Comments
|
|
32
36
|
*/
|
|
33
37
|
export interface CommentActions {
|
|
34
|
-
/** Starts a thread on
|
|
38
|
+
/** Starts a thread on one of a document's fields. */
|
|
35
39
|
createComment: (options: WithResourceNameSupport<CreateCommentOptions>) => Promise<Comment>
|
|
36
40
|
/** Adds a reply to an existing thread. */
|
|
37
41
|
replyToComment: (options: WithResourceNameSupport<ReplyToCommentOptions>) => Promise<Comment>
|
|
38
42
|
/** Rewrites a comment's message. */
|
|
39
43
|
updateComment: (options: WithResourceNameSupport<UpdateCommentOptions>) => Promise<void>
|
|
44
|
+
/**
|
|
45
|
+
* Re-anchors a comment to a different run of text, without marking it edited.
|
|
46
|
+
*/
|
|
47
|
+
updateCommentAnchor: (
|
|
48
|
+
options: WithResourceNameSupport<UpdateCommentAnchorOptions>,
|
|
49
|
+
) => Promise<void>
|
|
40
50
|
/** Resolves or reopens a thread. Pass the thread's first comment. */
|
|
41
51
|
setCommentStatus: (options: WithResourceNameSupport<SetCommentStatusOptions>) => Promise<void>
|
|
42
52
|
/** Deletes a comment, and its replies when it starts a thread. */
|
|
43
53
|
removeComment: (options: WithResourceNameSupport<RemoveCommentOptions>) => Promise<void>
|
|
54
|
+
/** Adds the current user's reaction to a comment. */
|
|
55
|
+
addReaction: (options: WithResourceNameSupport<ReactionOptions>) => Promise<void>
|
|
56
|
+
/** Removes the current user's reaction from a comment. */
|
|
57
|
+
removeReaction: (options: WithResourceNameSupport<ReactionOptions>) => Promise<void>
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
type Resolvable = {
|
|
@@ -52,25 +66,28 @@ type Resolvable = {
|
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
/**
|
|
55
|
-
* Returns the
|
|
69
|
+
* Returns the comment write actions, bound to the current instance.
|
|
56
70
|
*
|
|
57
71
|
* Each writes optimistically: the change shows immediately and is rolled back
|
|
58
72
|
* if the server rejects it, so an app can render straight from
|
|
59
|
-
* {@link
|
|
73
|
+
* {@link useCommentThreads} without tracking pending state itself.
|
|
60
74
|
*
|
|
61
75
|
* Creating is the exception, and `replyToComment` counts as creating. A comment
|
|
62
|
-
* that fails to post stays on screen carrying `
|
|
76
|
+
* that fails to post stays on screen carrying `state.createError` rather than
|
|
63
77
|
* disappearing, so passing the same `commentId` again retries it instead of
|
|
64
78
|
* losing what someone typed.
|
|
65
79
|
*
|
|
66
80
|
* The resource is resolved when an action is called rather than when the hook
|
|
67
81
|
* runs, so one set of callbacks works across several resources. Pass `resource`
|
|
68
82
|
* or `resourceName` per call to choose; otherwise the surrounding
|
|
69
|
-
* `ResourceProvider` decides.
|
|
83
|
+
* `ResourceProvider` decides. The organization the comments live in is
|
|
84
|
+
* resolved per call as well: the one the Dashboard opened the app in, else
|
|
85
|
+
* `organizationId` on the Sanity config, else the organization owning the
|
|
86
|
+
* resource's project. It cannot be passed on the call.
|
|
70
87
|
*
|
|
71
88
|
* @category Comments
|
|
72
89
|
* @function
|
|
73
|
-
* @returns The
|
|
90
|
+
* @returns The write actions
|
|
74
91
|
*
|
|
75
92
|
* @example Resolve a thread
|
|
76
93
|
* ```tsx
|
|
@@ -85,7 +102,7 @@ type Resolvable = {
|
|
|
85
102
|
* }
|
|
86
103
|
* ```
|
|
87
104
|
*
|
|
88
|
-
* @
|
|
105
|
+
* @beta
|
|
89
106
|
*/
|
|
90
107
|
export function useCommentActions(): CommentActions {
|
|
91
108
|
const instance = useSanityInstance()
|
|
@@ -103,8 +120,11 @@ export function useCommentActions(): CommentActions {
|
|
|
103
120
|
createComment: (options) => createComment(instance, resolve(options)),
|
|
104
121
|
replyToComment: (options) => replyToComment(instance, resolve(options)),
|
|
105
122
|
updateComment: (options) => updateComment(instance, resolve(options)),
|
|
123
|
+
updateCommentAnchor: (options) => updateCommentAnchor(instance, resolve(options)),
|
|
106
124
|
setCommentStatus: (options) => setCommentStatus(instance, resolve(options)),
|
|
107
125
|
removeComment: (options) => removeComment(instance, resolve(options)),
|
|
126
|
+
addReaction: (options) => addReaction(instance, resolve(options)),
|
|
127
|
+
removeReaction: (options) => removeReaction(instance, resolve(options)),
|
|
108
128
|
}
|
|
109
129
|
}, [contextPerspective, contextResource, instance, resources])
|
|
110
130
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {type
|
|
2
|
-
import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
1
|
+
import {type DocumentResource, type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
3
2
|
import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
|
|
4
3
|
|
|
5
4
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
@@ -9,17 +8,22 @@ import {
|
|
|
9
8
|
} from '../helpers/useNormalizedResourceOptions'
|
|
10
9
|
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
11
10
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The core functions backing one read hook, plus the option-key pair that keeps
|
|
13
|
+
* its state source steady across renders.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export interface CommentListSource<TOptions, TValue> {
|
|
18
|
+
getState: (instance: SanityInstance, options: TOptions) => StateSource<TValue | undefined>
|
|
19
|
+
getErrorState: (instance: SanityInstance, options: TOptions) => StateSource<unknown>
|
|
20
|
+
resolve: (instance: SanityInstance, options: TOptions & {signal?: AbortSignal}) => Promise<TValue>
|
|
21
|
+
getKey: (options: TOptions) => string
|
|
22
|
+
parseKey: (key: string) => TOptions
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
* Shared body of
|
|
26
|
+
* Shared body of the comment read hooks.
|
|
23
27
|
*
|
|
24
28
|
* Suspends until the first snapshot arrives. Changing documents or filters
|
|
25
29
|
* happens in a transition, so the list already on screen stays put and
|
|
@@ -27,20 +31,23 @@ export interface CommentListSource<T> {
|
|
|
27
31
|
* previous read is aborted, which drops its listener when nothing else is
|
|
28
32
|
* reading it.
|
|
29
33
|
*
|
|
34
|
+
* A listener that fails after the list has loaded is reported through `error`
|
|
35
|
+
* rather than thrown: the list stays on screen, frozen as it last stood.
|
|
36
|
+
*
|
|
30
37
|
* @internal
|
|
31
38
|
*/
|
|
32
|
-
export function useCommentList<
|
|
39
|
+
export function useCommentList<TOptions extends {resource?: DocumentResource}, TValue>(
|
|
33
40
|
hookName: string,
|
|
34
|
-
options: WithResourceNameSupport<
|
|
35
|
-
{getState, resolve}: CommentListSource<
|
|
36
|
-
): {value:
|
|
41
|
+
options: WithResourceNameSupport<TOptions>,
|
|
42
|
+
{getState, getErrorState, resolve, getKey, parseKey}: CommentListSource<TOptions, TValue>,
|
|
43
|
+
): {value: TValue; isPending: boolean; error: unknown} {
|
|
37
44
|
const instance = useSanityInstance()
|
|
38
45
|
trackHookUsage(instance, hookName)
|
|
39
46
|
|
|
40
|
-
const normalized = useNormalizedResourceOptions(options)
|
|
47
|
+
const normalized = useNormalizedResourceOptions(options) as TOptions
|
|
41
48
|
const [isPending, startTransition] = useTransition()
|
|
42
49
|
|
|
43
|
-
const key =
|
|
50
|
+
const key = getKey(normalized)
|
|
44
51
|
// Held one render behind `key`, so the swap can happen inside a transition.
|
|
45
52
|
const [deferredKey, setDeferredKey] = useState(key)
|
|
46
53
|
const abortRef = useRef<AbortController>(new AbortController())
|
|
@@ -57,11 +64,15 @@ export function useCommentList<T>(
|
|
|
57
64
|
})
|
|
58
65
|
}, [deferredKey, key])
|
|
59
66
|
|
|
60
|
-
const deferred = useMemo(() =>
|
|
67
|
+
const deferred = useMemo(() => parseKey(deferredKey), [deferredKey, parseKey])
|
|
61
68
|
const {getCurrent, subscribe} = useMemo(
|
|
62
69
|
() => getState(instance, deferred),
|
|
63
70
|
[deferred, getState, instance],
|
|
64
71
|
)
|
|
72
|
+
const errorSource = useMemo(
|
|
73
|
+
() => getErrorState(instance, deferred),
|
|
74
|
+
[deferred, getErrorState, instance],
|
|
75
|
+
)
|
|
65
76
|
|
|
66
77
|
if (getCurrent() === undefined) {
|
|
67
78
|
// Reading the ref mid-render is safe here: React runs no effects for a
|
|
@@ -73,7 +84,11 @@ export function useCommentList<T>(
|
|
|
73
84
|
throw resolve(instance, {...deferred, signal: currentSignal})
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
// Not memoised:
|
|
87
|
+
// Not memoised: every caller destructures this immediately and memoises its
|
|
77
88
|
// own result object, so a stable identity here would never be observed.
|
|
78
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
value: useSyncExternalStore(subscribe, getCurrent) as TValue,
|
|
91
|
+
isPending,
|
|
92
|
+
error: useSyncExternalStore(errorSource.subscribe, errorSource.getCurrent),
|
|
93
|
+
}
|
|
79
94
|
}
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import {type StateSource} from '@sanity/sdk'
|
|
1
2
|
import {
|
|
3
|
+
type CommentsOptions,
|
|
2
4
|
type CommentThread,
|
|
5
|
+
getCommentsErrorState,
|
|
3
6
|
getCommentThreadsState,
|
|
4
7
|
resolveCommentThreads,
|
|
5
|
-
|
|
6
|
-
} from '@sanity/sdk'
|
|
8
|
+
} from '@sanity/sdk/collaboration'
|
|
7
9
|
import {act, render, screen} from '@testing-library/react'
|
|
8
10
|
import {Suspense} from 'react'
|
|
9
|
-
import {type Observable} from 'rxjs'
|
|
11
|
+
import {type Observable, Subject} from 'rxjs'
|
|
10
12
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
13
|
|
|
12
14
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
15
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
13
16
|
import {useCommentThreads} from './useCommentThreads'
|
|
14
17
|
|
|
15
|
-
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
-
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
17
|
-
return {
|
|
18
|
+
vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
|
|
19
|
+
const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
|
|
20
|
+
return {
|
|
21
|
+
...original,
|
|
22
|
+
getCommentThreadsState: vi.fn(),
|
|
23
|
+
getCommentsErrorState: vi.fn(),
|
|
24
|
+
resolveCommentThreads: vi.fn(),
|
|
25
|
+
}
|
|
18
26
|
})
|
|
19
27
|
|
|
20
28
|
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
@@ -23,19 +31,72 @@ function thread(threadId: string) {
|
|
|
23
31
|
return {threadId, commentsCount: 2, fieldPath: ''} as CommentThread
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Stands in for the store, one source per option set so a test can hold one
|
|
36
|
+
* document loaded and another not.
|
|
37
|
+
*
|
|
38
|
+
* `getCurrent` must hand back the same array every call for a given option set.
|
|
39
|
+
* Returning a fresh one sends `useSyncExternalStore` into a render loop, which
|
|
40
|
+
* is why the store memoises its selectors.
|
|
41
|
+
*/
|
|
42
|
+
function mockSource(
|
|
43
|
+
getCurrent: (options: CommentsOptions) => CommentThread[] | undefined,
|
|
44
|
+
changed$?: Subject<void>,
|
|
45
|
+
) {
|
|
46
|
+
vi.mocked(getCommentThreadsState).mockImplementation(
|
|
47
|
+
(_instance, options) =>
|
|
48
|
+
({
|
|
49
|
+
getCurrent: () => getCurrent(options),
|
|
50
|
+
subscribe: vi.fn((cb?: () => void) => {
|
|
51
|
+
const subscription = changed$?.subscribe(() => cb?.())
|
|
52
|
+
return () => subscription?.unsubscribe()
|
|
53
|
+
}),
|
|
54
|
+
get observable(): Observable<CommentThread[] | undefined> {
|
|
55
|
+
throw new Error('Not implemented')
|
|
56
|
+
},
|
|
57
|
+
}) as StateSource<CommentThread[] | undefined>,
|
|
58
|
+
)
|
|
59
|
+
mockErrorSource(() => undefined)
|
|
34
60
|
}
|
|
35
61
|
|
|
62
|
+
/** The companion source, live by default. Call again to override. */
|
|
63
|
+
function mockErrorSource(getCurrent: () => unknown, changed$?: Subject<void>) {
|
|
64
|
+
vi.mocked(getCommentsErrorState).mockImplementation(
|
|
65
|
+
() =>
|
|
66
|
+
({
|
|
67
|
+
getCurrent,
|
|
68
|
+
subscribe: vi.fn((cb?: () => void) => {
|
|
69
|
+
const subscription = changed$?.subscribe(() => cb?.())
|
|
70
|
+
return () => subscription?.unsubscribe()
|
|
71
|
+
}),
|
|
72
|
+
get observable(): Observable<unknown> {
|
|
73
|
+
throw new Error('Not implemented')
|
|
74
|
+
},
|
|
75
|
+
}) as StateSource<unknown>,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hoisted so the context value stays identical across renders. */
|
|
80
|
+
const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
|
|
81
|
+
|
|
36
82
|
function Wrapper({children}: {children: React.ReactNode}) {
|
|
37
83
|
return (
|
|
38
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
|
+
function PerspectiveWrapper({children}: {children: React.ReactNode}) {
|
|
93
|
+
return (
|
|
94
|
+
<ResourceProvider
|
|
95
|
+
projectId="p"
|
|
96
|
+
dataset="d"
|
|
97
|
+
perspective={RELEASE_PERSPECTIVE}
|
|
98
|
+
fallback={<p>Loading…</p>}
|
|
99
|
+
>
|
|
39
100
|
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
40
101
|
</ResourceProvider>
|
|
41
102
|
)
|
|
@@ -60,10 +121,11 @@ describe('useCommentThreads', () => {
|
|
|
60
121
|
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
61
122
|
})
|
|
62
123
|
|
|
63
|
-
it('suspends until the
|
|
124
|
+
it('suspends until the first snapshot arrives', async () => {
|
|
64
125
|
const loaded = [thread('t1')]
|
|
65
126
|
const ref: {current: CommentThread[] | undefined} = {current: undefined}
|
|
66
|
-
|
|
127
|
+
const changed$ = new Subject<void>()
|
|
128
|
+
mockSource(() => ref.current, changed$)
|
|
67
129
|
|
|
68
130
|
let settle: () => void = () => {}
|
|
69
131
|
vi.mocked(resolveCommentThreads).mockReturnValue(
|
|
@@ -88,12 +150,40 @@ describe('useCommentThreads', () => {
|
|
|
88
150
|
expect(screen.getByTestId('out').textContent).toBe('1')
|
|
89
151
|
})
|
|
90
152
|
|
|
91
|
-
it('
|
|
153
|
+
it('passes the field path, status, and scope through to the store', () => {
|
|
154
|
+
const loaded: CommentThread[] = []
|
|
155
|
+
mockSource(() => loaded)
|
|
156
|
+
|
|
157
|
+
function TestComponent() {
|
|
158
|
+
useCommentThreads({
|
|
159
|
+
...HANDLE,
|
|
160
|
+
fieldPath: ['body', {_key: 'intro'}],
|
|
161
|
+
status: 'resolved',
|
|
162
|
+
scope: 'all',
|
|
163
|
+
})
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
168
|
+
|
|
169
|
+
expect(getCommentThreadsState).toHaveBeenCalledWith(
|
|
170
|
+
expect.anything(),
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
documentId: 'doc-1',
|
|
173
|
+
fieldPath: 'body[_key=="intro"]',
|
|
174
|
+
status: 'resolved',
|
|
175
|
+
scope: 'all',
|
|
176
|
+
resource: {projectId: 'p', dataset: 'd'},
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('resolves a named resource from context', () => {
|
|
92
182
|
const loaded: CommentThread[] = []
|
|
93
183
|
mockSource(() => loaded)
|
|
94
184
|
|
|
95
185
|
function TestComponent() {
|
|
96
|
-
useCommentThreads({...HANDLE,
|
|
186
|
+
useCommentThreads({...HANDLE, resourceName: 'other'})
|
|
97
187
|
return null
|
|
98
188
|
}
|
|
99
189
|
|
|
@@ -101,7 +191,105 @@ describe('useCommentThreads', () => {
|
|
|
101
191
|
|
|
102
192
|
expect(getCommentThreadsState).toHaveBeenCalledWith(
|
|
103
193
|
expect.anything(),
|
|
104
|
-
expect.objectContaining({
|
|
194
|
+
expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('fills in the perspective from context', () => {
|
|
199
|
+
// The perspective decides which comments the default `scope` covers, so
|
|
200
|
+
// dropping it here would quietly read a release's comments as if they were
|
|
201
|
+
// the draft's.
|
|
202
|
+
const loaded: CommentThread[] = []
|
|
203
|
+
mockSource(() => loaded)
|
|
204
|
+
|
|
205
|
+
function TestComponent() {
|
|
206
|
+
useCommentThreads(HANDLE)
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
render(<TestComponent />, {wrapper: PerspectiveWrapper})
|
|
211
|
+
|
|
212
|
+
expect(getCommentThreadsState).toHaveBeenCalledWith(
|
|
213
|
+
expect.anything(),
|
|
214
|
+
expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('keeps the previous list on screen while a different document loads', async () => {
|
|
219
|
+
const first = [thread('t1')]
|
|
220
|
+
const second = [thread('t2'), thread('t3')]
|
|
221
|
+
// Only `doc-1` is loaded, so switching to `doc-2` has to suspend.
|
|
222
|
+
const byDocument: Record<string, CommentThread[] | undefined> = {'doc-1': first}
|
|
223
|
+
mockSource((options) => byDocument[options.documentId])
|
|
224
|
+
|
|
225
|
+
let settle: () => void = () => {}
|
|
226
|
+
vi.mocked(resolveCommentThreads).mockReturnValue(
|
|
227
|
+
new Promise<CommentThread[]>((resolve) => {
|
|
228
|
+
settle = () => resolve(second)
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
function TestComponent({documentId}: {documentId: string}) {
|
|
233
|
+
const {threads, isPending} = useCommentThreads({...HANDLE, documentId})
|
|
234
|
+
return <div data-testid="out">{`${threads.length} ${isPending ? 'pending' : 'idle'}`}</div>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const {rerender} = render(<TestComponent documentId="doc-1" />, {wrapper: Wrapper})
|
|
238
|
+
expect(screen.getByTestId('out').textContent).toBe('1 idle')
|
|
239
|
+
|
|
240
|
+
await act(async () => {
|
|
241
|
+
rerender(<TestComponent documentId="doc-2" />)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// The swap happens inside a transition, so the render that suspends is
|
|
245
|
+
// thrown away rather than falling back: `doc-1` stays on screen and
|
|
246
|
+
// `isPending` is what reports the switch.
|
|
247
|
+
expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
|
|
248
|
+
expect(screen.getByTestId('out').textContent).toBe('1 pending')
|
|
249
|
+
expect(getCommentThreadsState).toHaveBeenCalledWith(
|
|
250
|
+
expect.anything(),
|
|
251
|
+
expect.objectContaining({documentId: 'doc-2'}),
|
|
105
252
|
)
|
|
253
|
+
|
|
254
|
+
await act(async () => {
|
|
255
|
+
byDocument['doc-2'] = second
|
|
256
|
+
settle()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('reports a listener that failed after the threads had loaded', async () => {
|
|
263
|
+
const loaded = [thread('t1')]
|
|
264
|
+
mockSource(() => loaded)
|
|
265
|
+
const failure: {current: unknown} = {current: undefined}
|
|
266
|
+
const changed$ = new Subject<void>()
|
|
267
|
+
mockErrorSource(() => failure.current, changed$)
|
|
268
|
+
|
|
269
|
+
function TestComponent() {
|
|
270
|
+
const {threads, error} = useCommentThreads(HANDLE)
|
|
271
|
+
return (
|
|
272
|
+
<div data-testid="out">{`${threads.length} ${error instanceof Error ? error.message : 'live'}`}</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
277
|
+
expect(screen.getByTestId('out').textContent).toBe('1 live')
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
failure.current = new Error('connection failed')
|
|
281
|
+
changed$.next()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// The threads are still on screen; only their claim to being current is
|
|
285
|
+
// withdrawn.
|
|
286
|
+
expect(screen.getByTestId('out').textContent).toBe('1 connection failed')
|
|
287
|
+
|
|
288
|
+
await act(async () => {
|
|
289
|
+
failure.current = undefined
|
|
290
|
+
changed$.next()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
expect(screen.getByTestId('out').textContent).toBe('1 live')
|
|
106
294
|
})
|
|
107
295
|
})
|