@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication -- deprecated add-on dataset comments, kept alongside hooks/comments/ until removal in the next major
|
|
2
|
+
import {
|
|
3
|
+
type Comment,
|
|
4
|
+
createComment,
|
|
5
|
+
type CreateCommentOptions,
|
|
6
|
+
type DocumentResource,
|
|
7
|
+
type PerspectiveHandle,
|
|
8
|
+
removeComment,
|
|
9
|
+
type RemoveCommentOptions,
|
|
10
|
+
replyToComment,
|
|
11
|
+
type ReplyToCommentOptions,
|
|
12
|
+
setCommentStatus,
|
|
13
|
+
type SetCommentStatusOptions,
|
|
14
|
+
updateComment,
|
|
15
|
+
type UpdateCommentOptions,
|
|
16
|
+
} from '@sanity/sdk'
|
|
17
|
+
import {useContext, useMemo} from 'react'
|
|
18
|
+
|
|
19
|
+
import {PerspectiveContext} from '../../../context/PerspectiveContext'
|
|
20
|
+
import {ResourcesContext} from '../../../context/ResourcesContext'
|
|
21
|
+
import {useSanityInstance} from '../../context/useSanityInstance'
|
|
22
|
+
import {
|
|
23
|
+
normalizeResourceOptions,
|
|
24
|
+
useEffectiveContextResource,
|
|
25
|
+
type WithResourceNameSupport,
|
|
26
|
+
} from '../../helpers/useNormalizedResourceOptions'
|
|
27
|
+
import {trackHookUsage} from '../../helpers/useTrackHookUsage'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @public
|
|
31
|
+
* @category Types
|
|
32
|
+
* @deprecated The add-on dataset write actions. See `CommentActions` in
|
|
33
|
+
* `@sanity/sdk-react/collaboration`, which adds reactions and re-anchoring.
|
|
34
|
+
*/
|
|
35
|
+
export interface CommentActions {
|
|
36
|
+
/** Starts a thread on a document, or on one of its fields. */
|
|
37
|
+
createComment: (options: WithResourceNameSupport<CreateCommentOptions>) => Promise<Comment>
|
|
38
|
+
/** Adds a reply to an existing thread. */
|
|
39
|
+
replyToComment: (options: WithResourceNameSupport<ReplyToCommentOptions>) => Promise<Comment>
|
|
40
|
+
/** Rewrites a comment's message. */
|
|
41
|
+
updateComment: (options: WithResourceNameSupport<UpdateCommentOptions>) => Promise<void>
|
|
42
|
+
/** Resolves or reopens a thread. Pass the thread's first comment. */
|
|
43
|
+
setCommentStatus: (options: WithResourceNameSupport<SetCommentStatusOptions>) => Promise<void>
|
|
44
|
+
/** Deletes a comment, and its replies when it starts a thread. */
|
|
45
|
+
removeComment: (options: WithResourceNameSupport<RemoveCommentOptions>) => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Resolvable = {
|
|
49
|
+
resource?: DocumentResource
|
|
50
|
+
resourceName?: string
|
|
51
|
+
projectId?: string
|
|
52
|
+
dataset?: string
|
|
53
|
+
perspective?: PerspectiveHandle['perspective']
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns the five comment write actions, bound to the current instance.
|
|
58
|
+
*
|
|
59
|
+
* Each writes optimistically: the change shows immediately and is rolled back
|
|
60
|
+
* if the server rejects it, so an app can render straight from
|
|
61
|
+
* {@link useComments} without tracking pending state itself.
|
|
62
|
+
*
|
|
63
|
+
* Creating is the exception, and `replyToComment` counts as creating. A comment
|
|
64
|
+
* that fails to post stays on screen carrying `_state.createError` rather than
|
|
65
|
+
* disappearing, so passing the same `commentId` again retries it instead of
|
|
66
|
+
* losing what someone typed.
|
|
67
|
+
*
|
|
68
|
+
* The resource is resolved when an action is called rather than when the hook
|
|
69
|
+
* runs, so one set of callbacks works across several resources. Pass `resource`
|
|
70
|
+
* or `resourceName` per call to choose; otherwise the surrounding
|
|
71
|
+
* `ResourceProvider` decides.
|
|
72
|
+
*
|
|
73
|
+
* @category Comments
|
|
74
|
+
* @function
|
|
75
|
+
* @returns The five write actions
|
|
76
|
+
*
|
|
77
|
+
* @example Resolve a thread
|
|
78
|
+
* ```tsx
|
|
79
|
+
* function ResolveButton({commentId}: {commentId: string}) {
|
|
80
|
+
* const {setCommentStatus} = useCommentActions()
|
|
81
|
+
*
|
|
82
|
+
* return (
|
|
83
|
+
* <button onClick={() => setCommentStatus({commentId, status: 'resolved'})}>
|
|
84
|
+
* Resolve
|
|
85
|
+
* </button>
|
|
86
|
+
* )
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* @public
|
|
91
|
+
* @deprecated Comments have moved to the organization collaboration API. Use
|
|
92
|
+
* `useCommentActions` from `@sanity/sdk-react/collaboration`, which writes inline anchors as an
|
|
93
|
+
* `anchor` of offsets rather than a pre-built `selection`. This add-on dataset hook is
|
|
94
|
+
* removed in the next major.
|
|
95
|
+
*/
|
|
96
|
+
export function useCommentActions(): CommentActions {
|
|
97
|
+
const instance = useSanityInstance()
|
|
98
|
+
trackHookUsage(instance, 'useCommentActions')
|
|
99
|
+
|
|
100
|
+
const resources = useContext(ResourcesContext)
|
|
101
|
+
const contextResource = useEffectiveContextResource()
|
|
102
|
+
const contextPerspective = useContext(PerspectiveContext)
|
|
103
|
+
|
|
104
|
+
return useMemo(() => {
|
|
105
|
+
const resolve = <T extends Resolvable>(options: T) =>
|
|
106
|
+
normalizeResourceOptions(options, resources, contextResource, contextPerspective)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
createComment: (options) => createComment(instance, resolve(options)),
|
|
110
|
+
replyToComment: (options) => replyToComment(instance, resolve(options)),
|
|
111
|
+
updateComment: (options) => updateComment(instance, resolve(options)),
|
|
112
|
+
setCommentStatus: (options) => setCommentStatus(instance, resolve(options)),
|
|
113
|
+
removeComment: (options) => removeComment(instance, resolve(options)),
|
|
114
|
+
}
|
|
115
|
+
}, [contextPerspective, contextResource, instance, resources])
|
|
116
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommentThread,
|
|
3
|
+
getCommentThreadsState,
|
|
4
|
+
resolveCommentThreads,
|
|
5
|
+
type StateSource,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {act, render, screen} from '@testing-library/react'
|
|
8
|
+
import {Suspense} from 'react'
|
|
9
|
+
import {type Observable} from 'rxjs'
|
|
10
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {ResourceProvider} from '../../../context/ResourceProvider'
|
|
13
|
+
import {useCommentThreads} from './useCommentThreads'
|
|
14
|
+
|
|
15
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
17
|
+
return {...original, getCommentThreadsState: vi.fn(), resolveCommentThreads: vi.fn()}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
21
|
+
|
|
22
|
+
function thread(threadId: string) {
|
|
23
|
+
return {threadId, commentsCount: 2, fieldPath: ''} as CommentThread
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockSource(getCurrent: () => CommentThread[] | undefined) {
|
|
27
|
+
vi.mocked(getCommentThreadsState).mockReturnValue({
|
|
28
|
+
getCurrent,
|
|
29
|
+
subscribe: vi.fn(() => () => {}),
|
|
30
|
+
get observable(): Observable<CommentThread[] | undefined> {
|
|
31
|
+
throw new Error('Not implemented')
|
|
32
|
+
},
|
|
33
|
+
} as StateSource<CommentThread[] | undefined>)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Wrapper({children}: {children: React.ReactNode}) {
|
|
37
|
+
return (
|
|
38
|
+
<ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
|
|
39
|
+
<Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
|
|
40
|
+
</ResourceProvider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('useCommentThreads', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.resetAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders the threads once they are loaded', () => {
|
|
50
|
+
const loaded = [thread('t1'), thread('t2')]
|
|
51
|
+
mockSource(() => loaded)
|
|
52
|
+
|
|
53
|
+
function TestComponent() {
|
|
54
|
+
const {threads, isPending} = useCommentThreads(HANDLE)
|
|
55
|
+
return <div data-testid="out">{`${threads.length} ${isPending ? 'pending' : 'idle'}`}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
59
|
+
|
|
60
|
+
expect(screen.getByTestId('out').textContent).toBe('2 idle')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('suspends until the threads are available', async () => {
|
|
64
|
+
const loaded = [thread('t1')]
|
|
65
|
+
const ref: {current: CommentThread[] | undefined} = {current: undefined}
|
|
66
|
+
mockSource(() => ref.current)
|
|
67
|
+
|
|
68
|
+
let settle: () => void = () => {}
|
|
69
|
+
vi.mocked(resolveCommentThreads).mockReturnValue(
|
|
70
|
+
new Promise<CommentThread[]>((resolve) => {
|
|
71
|
+
settle = () => resolve(loaded)
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
function TestComponent() {
|
|
76
|
+
const {threads} = useCommentThreads(HANDLE)
|
|
77
|
+
return <div data-testid="out">{threads.length}</div>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
81
|
+
expect(screen.getByTestId('suspended')).toBeInTheDocument()
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
ref.current = loaded
|
|
85
|
+
settle()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(screen.getByTestId('out').textContent).toBe('1')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('narrows to one field when asked', () => {
|
|
92
|
+
const loaded: CommentThread[] = []
|
|
93
|
+
mockSource(() => loaded)
|
|
94
|
+
|
|
95
|
+
function TestComponent() {
|
|
96
|
+
useCommentThreads({...HANDLE, fieldPath: 'title'})
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render(<TestComponent />, {wrapper: Wrapper})
|
|
101
|
+
|
|
102
|
+
expect(getCommentThreadsState).toHaveBeenCalledWith(
|
|
103
|
+
expect.anything(),
|
|
104
|
+
expect.objectContaining({fieldPath: 'title'}),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommentsOptions,
|
|
3
|
+
type CommentThread,
|
|
4
|
+
getCommentThreadsState,
|
|
5
|
+
resolveCommentThreads,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
8
|
+
import {useMemo} from 'react'
|
|
9
|
+
|
|
10
|
+
import {type WithResourceNameSupport} from '../../helpers/useNormalizedResourceOptions'
|
|
11
|
+
import {type CommentListSource, useCommentList} from '../useCommentList'
|
|
12
|
+
import {getNoCommentsErrorState} from './noCommentsError'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @public
|
|
16
|
+
* @category Types
|
|
17
|
+
* @deprecated See the `UseCommentThreadsResult` exported from
|
|
18
|
+
* `@sanity/sdk-react/collaboration`.
|
|
19
|
+
*/
|
|
20
|
+
export interface UseCommentThreadsResult {
|
|
21
|
+
/** Newest thread first, each with its replies oldest first. */
|
|
22
|
+
threads: CommentThread[]
|
|
23
|
+
/** True while switching to a different document or filter. */
|
|
24
|
+
isPending: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SOURCE: CommentListSource<CommentsOptions, CommentThread[]> = {
|
|
28
|
+
getState: getCommentThreadsState,
|
|
29
|
+
getErrorState: getNoCommentsErrorState,
|
|
30
|
+
resolve: resolveCommentThreads,
|
|
31
|
+
getKey: getCommentsOptionsKey,
|
|
32
|
+
parseKey: parseCommentsOptionsKey,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads a document's comments grouped into threads.
|
|
37
|
+
*
|
|
38
|
+
* A thread is one comment plus its replies. Its `status` and `fieldPath` come
|
|
39
|
+
* from the first comment, so filtering by either selects whole threads rather
|
|
40
|
+
* than stray replies.
|
|
41
|
+
*
|
|
42
|
+
* Unlike the Studio, every thread is returned. The Studio hides threads whose
|
|
43
|
+
* field has left the schema or is hidden by a conditional, which it can do
|
|
44
|
+
* because it has the schema to check against. Inspect `fieldPath` yourself if
|
|
45
|
+
* your app needs to do the same.
|
|
46
|
+
*
|
|
47
|
+
* @category Comments
|
|
48
|
+
* @function
|
|
49
|
+
* @param options - The document to read, optionally narrowed by `fieldPath` or `status`
|
|
50
|
+
* @returns The matching threads, and whether a switch is in flight
|
|
51
|
+
*
|
|
52
|
+
* @example Render the open threads on a document
|
|
53
|
+
* ```tsx
|
|
54
|
+
* function Threads({documentId}: {documentId: string}) {
|
|
55
|
+
* const {threads} = useCommentThreads({
|
|
56
|
+
* documentId,
|
|
57
|
+
* documentType: 'article',
|
|
58
|
+
* status: 'open',
|
|
59
|
+
* })
|
|
60
|
+
*
|
|
61
|
+
* return (
|
|
62
|
+
* <ul>
|
|
63
|
+
* {threads.map((thread) => (
|
|
64
|
+
* <li key={thread.threadId}>
|
|
65
|
+
* {thread.fieldPath || 'Document'} — {thread.commentsCount} comments
|
|
66
|
+
* </li>
|
|
67
|
+
* ))}
|
|
68
|
+
* </ul>
|
|
69
|
+
* )
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @public
|
|
74
|
+
* @deprecated Comments have moved to the organization collaboration API. Import
|
|
75
|
+
* `useCommentThreads` from `@sanity/sdk-react/collaboration` instead; it takes the same options
|
|
76
|
+
* and returns the same thread shape. This add-on dataset hook is removed in the next major.
|
|
77
|
+
*/
|
|
78
|
+
export function useCommentThreads(
|
|
79
|
+
options: WithResourceNameSupport<CommentsOptions>,
|
|
80
|
+
): UseCommentThreadsResult {
|
|
81
|
+
const {value, isPending} = useCommentList('useCommentThreads', options, SOURCE)
|
|
82
|
+
return useMemo(() => ({threads: value, isPending}), [isPending, value])
|
|
83
|
+
}
|
|
@@ -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,68 @@
|
|
|
1
|
+
import {type Comment, type CommentsOptions, getCommentsState, resolveComments} from '@sanity/sdk'
|
|
2
|
+
import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
3
|
+
import {useMemo} from 'react'
|
|
4
|
+
|
|
5
|
+
import {type WithResourceNameSupport} from '../../helpers/useNormalizedResourceOptions'
|
|
6
|
+
import {type CommentListSource, useCommentList} from '../useCommentList'
|
|
7
|
+
import {getNoCommentsErrorState} from './noCommentsError'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @public
|
|
11
|
+
* @category Types
|
|
12
|
+
* @deprecated See the `UseCommentsResult` exported from `@sanity/sdk-react/collaboration`.
|
|
13
|
+
*/
|
|
14
|
+
export interface UseCommentsResult {
|
|
15
|
+
/** Every matching comment, newest first, replies included. */
|
|
16
|
+
comments: Comment[]
|
|
17
|
+
/** True while switching to a different document or filter. */
|
|
18
|
+
isPending: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SOURCE: CommentListSource<CommentsOptions, Comment[]> = {
|
|
22
|
+
getState: getCommentsState,
|
|
23
|
+
getErrorState: getNoCommentsErrorState,
|
|
24
|
+
resolve: resolveComments,
|
|
25
|
+
getKey: getCommentsOptionsKey,
|
|
26
|
+
parseKey: parseCommentsOptionsKey,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads a document's comments and keeps them up to date.
|
|
31
|
+
*
|
|
32
|
+
* Comments are shared with the Studio: they live in the project's comments
|
|
33
|
+
* dataset, so a thread started here shows up there and the other way round. The
|
|
34
|
+
* list is flat, replies included; reach for {@link useCommentThreads} to read it
|
|
35
|
+
* grouped.
|
|
36
|
+
*
|
|
37
|
+
* Suspends until the comments have loaded. Switching document or filter is a
|
|
38
|
+
* transition, so the previous list stays on screen and `isPending` goes true
|
|
39
|
+
* rather than the component suspending again.
|
|
40
|
+
*
|
|
41
|
+
* @category Comments
|
|
42
|
+
* @function
|
|
43
|
+
* @param options - The document to read, optionally narrowed by `fieldPath` or `status`
|
|
44
|
+
* @returns The matching comments, and whether a switch is in flight
|
|
45
|
+
*
|
|
46
|
+
* @example Count the open threads on a field
|
|
47
|
+
* ```tsx
|
|
48
|
+
* function TitleCommentCount({documentId}: {documentId: string}) {
|
|
49
|
+
* const {comments} = useComments({
|
|
50
|
+
* documentId,
|
|
51
|
+
* documentType: 'article',
|
|
52
|
+
* fieldPath: 'title',
|
|
53
|
+
* status: 'open',
|
|
54
|
+
* })
|
|
55
|
+
*
|
|
56
|
+
* return <span>{comments.length}</span>
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @public
|
|
61
|
+
* @deprecated Comments have moved to the organization collaboration API. Import `useComments`
|
|
62
|
+
* from `@sanity/sdk-react/collaboration` instead; it takes the same options and returns the
|
|
63
|
+
* same flat list. This add-on dataset hook is removed in the next major.
|
|
64
|
+
*/
|
|
65
|
+
export function useComments(options: WithResourceNameSupport<CommentsOptions>): UseCommentsResult {
|
|
66
|
+
const {value, isPending} = useCommentList('useComments', options, SOURCE)
|
|
67
|
+
return useMemo(() => ({comments: value, isPending}), [isPending, value])
|
|
68
|
+
}
|