@sanity/sdk-react 3.0.0-rc.0 → 3.0.0-rc.2
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 +103 -155
- package/dist/index.d.ts +2488 -2019
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4435 -1529
- package/dist/index.js.map +1 -1
- package/package.json +55 -59
- package/src/_exports/index.ts +2 -4
- package/src/_exports/sdk-react.ts +30 -18
- package/src/components/SDKProvider.test.tsx +173 -32
- package/src/components/SDKProvider.tsx +87 -28
- package/src/components/SanityApp.test.tsx +68 -12
- package/src/components/SanityApp.tsx +93 -87
- package/src/components/auth/AuthBoundary.recovery.test.tsx +86 -0
- package/src/components/auth/AuthBoundary.test.tsx +7 -7
- package/src/components/auth/AuthBoundary.tsx +29 -6
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +104 -61
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/CorsErrorComponent.tsx +2 -2
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/config/handles.ts +29 -27
- package/src/constants.ts +5 -0
- package/src/context/ComlinkTokenRefresh.test.tsx +10 -27
- package/src/context/ComlinkTokenRefresh.tsx +3 -5
- package/src/context/DefaultResourceContext.ts +3 -3
- package/src/context/OrganizationResourcesProvider.test.tsx +189 -0
- package/src/context/OrganizationResourcesProvider.tsx +111 -0
- package/src/context/PerspectiveContext.ts +3 -3
- package/src/context/ProjectContext.ts +15 -0
- package/src/context/ResourceProvider.test.tsx +93 -90
- package/src/context/ResourceProvider.tsx +62 -93
- package/src/context/SDKStudioContext.test.tsx +28 -33
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
- package/src/context/WorkbenchTokenRefresh.tsx +61 -0
- package/src/context/renderSanityApp.test.tsx +151 -49
- package/src/context/renderSanityApp.tsx +12 -8
- package/src/context/workbenchToken.ts +63 -0
- package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
- package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
- package/src/hooks/access/useCheckPermissions.ts +24 -0
- package/src/hooks/agent/agentActions.test.tsx +2 -2
- package/src/hooks/agent/agentActions.ts +52 -72
- package/src/hooks/agent/useAgentResourceContext.ts +1 -1
- package/src/hooks/applications/useApplication.test-d.ts +30 -0
- package/src/hooks/applications/useApplication.ts +22 -0
- package/src/hooks/applications/useApplications.test-d.ts +31 -0
- package/src/hooks/applications/useApplications.ts +25 -0
- package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
- package/src/hooks/applications/useDeleteApplication.ts +11 -0
- package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
- package/src/hooks/applications/useUpdateApplication.ts +11 -0
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +2 -8
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +8 -32
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/client/useClient.test.tsx +1 -4
- package/src/hooks/client/useClient.ts +1 -0
- package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
- package/src/hooks/comlink/useFrameConnection.ts +1 -1
- package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
- package/src/hooks/comlink/useWindowConnection.ts +2 -3
- 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/context/useResource.test.tsx +32 -0
- package/src/hooks/context/useResource.ts +24 -0
- package/src/hooks/context/useSanityInstance.test.tsx +18 -1
- package/src/hooks/context/useSanityInstance.ts +6 -6
- package/src/hooks/dashboard/useManageFavorite.test.tsx +110 -46
- package/src/hooks/dashboard/useManageFavorite.ts +11 -17
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +5 -7
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -6
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +0 -2
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -2
- package/src/hooks/dashboard/useWindowTitle.test.ts +244 -0
- package/src/hooks/dashboard/useWindowTitle.ts +114 -0
- package/src/hooks/datasets/useDatasets.test.tsx +116 -0
- package/src/hooks/datasets/useDatasets.ts +33 -13
- package/src/hooks/document/useApplyDocumentActions.test.tsx +48 -0
- package/src/hooks/document/useApplyDocumentActions.ts +66 -48
- package/src/hooks/document/useCreateDocument.test.tsx +83 -0
- package/src/hooks/document/useCreateDocument.ts +117 -0
- package/src/hooks/document/useDocument.test.tsx +37 -8
- package/src/hooks/document/useDocument.ts +131 -66
- package/src/hooks/document/useDocumentEvent.test.tsx +16 -4
- package/src/hooks/document/useDocumentEvent.ts +16 -10
- package/src/hooks/document/useDocumentPermissions.test.tsx +8 -7
- package/src/hooks/document/useDocumentPermissions.ts +71 -39
- package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +13 -9
- package/src/hooks/document/useEditDocument.test.tsx +21 -14
- package/src/hooks/document/useEditDocument.ts +163 -17
- package/src/hooks/documents/useDocuments.test.tsx +64 -44
- package/src/hooks/documents/useDocuments.ts +31 -20
- package/src/hooks/helpers/createCallbackHook.test.tsx +13 -19
- package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
- package/src/hooks/helpers/createFetcherHook.ts +69 -0
- package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
- package/src/hooks/helpers/createMutationHook.tsx +93 -0
- package/src/hooks/helpers/createStateSourceHook.test.tsx +10 -10
- package/src/hooks/helpers/createStateSourceHook.tsx +2 -1
- package/src/hooks/{document/useApplyDocumentActions.test.ts → helpers/useApplyActions.test.tsx} +92 -53
- package/src/hooks/helpers/useApplyActions.ts +68 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +226 -0
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +79 -48
- package/src/hooks/helpers/useResolvedProjectId.test.tsx +59 -0
- package/src/hooks/helpers/useResolvedProjectId.ts +35 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/installations/useInstallation.test-d.ts +19 -0
- package/src/hooks/installations/useInstallation.ts +22 -0
- package/src/hooks/installations/useInstallations.test-d.ts +26 -0
- package/src/hooks/installations/useInstallations.ts +25 -0
- package/src/hooks/organizations/useOrganization.test-d.ts +60 -0
- package/src/hooks/organizations/useOrganization.test.ts +63 -0
- package/src/hooks/organizations/useOrganization.ts +34 -0
- package/src/hooks/organizations/useOrganizations.test-d.ts +68 -0
- package/src/hooks/organizations/useOrganizations.test.ts +63 -0
- package/src/hooks/organizations/useOrganizations.ts +39 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +34 -27
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +35 -22
- package/src/hooks/presence/usePresence.test.tsx +0 -15
- package/src/hooks/presence/usePresence.ts +22 -20
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
- package/src/hooks/preview/useDocumentPreview.test.tsx +0 -1
- package/src/hooks/preview/useDocumentPreview.tsx +12 -14
- package/src/hooks/projection/useDocumentProjection.test.tsx +78 -9
- package/src/hooks/projection/useDocumentProjection.ts +81 -2
- package/src/hooks/projects/useProject.test-d.ts +56 -0
- package/src/hooks/projects/useProject.test.tsx +120 -0
- package/src/hooks/projects/useProject.ts +42 -43
- package/src/hooks/projects/useProjects.test-d.ts +60 -0
- package/src/hooks/projects/useProjects.test.ts +45 -98
- package/src/hooks/projects/useProjects.ts +17 -32
- package/src/hooks/query/useQuery.test.tsx +6 -5
- package/src/hooks/query/useQuery.ts +94 -17
- package/src/hooks/releases/useActiveReleases.test.tsx +13 -7
- package/src/hooks/releases/useActiveReleases.ts +28 -24
- package/src/hooks/releases/useAllReleases.test.tsx +93 -0
- package/src/hooks/releases/useAllReleases.ts +62 -0
- package/src/hooks/releases/useApplyReleaseActions.test.tsx +66 -0
- package/src/hooks/releases/useApplyReleaseActions.ts +82 -0
- package/src/hooks/releases/usePerspective.test.tsx +18 -10
- package/src/hooks/releases/usePerspective.ts +13 -17
- package/src/hooks/users/useUser.test.tsx +3 -9
- package/src/hooks/users/useUser.ts +4 -8
- package/src/hooks/users/useUsers.test.tsx +103 -7
- package/src/hooks/users/useUsers.ts +38 -5
- package/src/utils/resolveOrgResources.test.ts +111 -0
- package/src/utils/resolveOrgResources.ts +69 -0
- package/src/hooks/context/useDefaultResource.test.tsx +0 -25
- package/src/hooks/context/useDefaultResource.ts +0 -30
- package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -254
- package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -107
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -39
- package/src/hooks/datasets/useDatasets.test.ts +0 -66
- package/src/hooks/helpers/useNormalizedResourceOptions.test.ts +0 -65
- package/src/hooks/projects/useProject.test.ts +0 -79
|
@@ -27,13 +27,20 @@ export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): s
|
|
|
27
27
|
const instance = useSanityInstance()
|
|
28
28
|
const [error, setError] = useState<string | null>(null)
|
|
29
29
|
|
|
30
|
+
const isInactive = disabled || !projectIds || projectIds.length === 0
|
|
31
|
+
|
|
32
|
+
// Reset stale errors when verification turns off so the next activation
|
|
33
|
+
// doesn't briefly leak the previous result.
|
|
34
|
+
const [prevInactive, setPrevInactive] = useState(isInactive)
|
|
35
|
+
if (prevInactive !== isInactive) {
|
|
36
|
+
setPrevInactive(isInactive)
|
|
37
|
+
if (isInactive) setError(null)
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
useEffect(() => {
|
|
31
|
-
if (
|
|
32
|
-
if (error !== null) setError(null)
|
|
33
|
-
return
|
|
34
|
-
}
|
|
41
|
+
if (isInactive) return
|
|
35
42
|
|
|
36
|
-
const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds)
|
|
43
|
+
const verificationObservable$ = observeOrganizationVerificationState(instance, projectIds!)
|
|
37
44
|
|
|
38
45
|
const subscription = verificationObservable$.subscribe((result: OrgVerificationResult) => {
|
|
39
46
|
setError(result.error)
|
|
@@ -42,7 +49,7 @@ export function useVerifyOrgProjects(disabled = false, projectIds?: string[]): s
|
|
|
42
49
|
return () => {
|
|
43
50
|
subscription.unsubscribe()
|
|
44
51
|
}
|
|
45
|
-
}, [instance,
|
|
52
|
+
}, [instance, isInactive, projectIds])
|
|
46
53
|
|
|
47
54
|
return error
|
|
48
55
|
}
|
|
@@ -6,10 +6,7 @@ import {useClient} from './useClient'
|
|
|
6
6
|
|
|
7
7
|
describe('useClient', () => {
|
|
8
8
|
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
9
|
-
<ResourceProvider
|
|
10
|
-
resource={{projectId: 'test-project', dataset: 'test-dataset'}}
|
|
11
|
-
fallback={null}
|
|
12
|
-
>
|
|
9
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
13
10
|
{children}
|
|
14
11
|
</ResourceProvider>
|
|
15
12
|
)
|
|
@@ -4,7 +4,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
|
4
4
|
import {act, renderHook} from '../../../test/test-utils'
|
|
5
5
|
import {useFrameConnection} from './useFrameConnection'
|
|
6
6
|
|
|
7
|
-
vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
7
|
+
vi.mock(import('@sanity/sdk/comlink'), async (importOriginal) => {
|
|
8
8
|
const actual = await importOriginal()
|
|
9
9
|
return {
|
|
10
10
|
...actual,
|
|
@@ -14,7 +14,8 @@ vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
|
14
14
|
}
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
const {getOrCreateChannel, getOrCreateController, releaseChannel} =
|
|
17
|
+
const {getOrCreateChannel, getOrCreateController, releaseChannel} =
|
|
18
|
+
await import('@sanity/sdk/comlink')
|
|
18
19
|
|
|
19
20
|
interface TestControllerMessage {
|
|
20
21
|
type: 'TEST_MESSAGE'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {type Message, type Node} from '@sanity/comlink'
|
|
2
|
-
import {
|
|
2
|
+
import {type StateSource} from '@sanity/sdk'
|
|
3
|
+
import {getNodeState, type NodeState} from '@sanity/sdk/comlink'
|
|
3
4
|
import {screen} from '@testing-library/react'
|
|
4
5
|
import {Suspense} from 'react'
|
|
5
6
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
@@ -7,8 +8,8 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
|
7
8
|
import {render, renderHook} from '../../../test/test-utils'
|
|
8
9
|
import {useWindowConnection} from './useWindowConnection'
|
|
9
10
|
|
|
10
|
-
vi.mock('@sanity/sdk', async () => {
|
|
11
|
-
const actual = await vi.importActual('@sanity/sdk')
|
|
11
|
+
vi.mock('@sanity/sdk/comlink', async () => {
|
|
12
|
+
const actual = await vi.importActual('@sanity/sdk/comlink')
|
|
12
13
|
return {
|
|
13
14
|
...actual,
|
|
14
15
|
getNodeState: vi.fn(),
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import {type MessageData, type NodeInput} from '@sanity/comlink'
|
|
2
|
+
import {type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
2
3
|
import {
|
|
3
4
|
type FrameMessage,
|
|
4
5
|
getNodeState,
|
|
5
6
|
type NodeState,
|
|
6
|
-
type SanityInstance,
|
|
7
|
-
type StateSource,
|
|
8
7
|
type WindowMessage,
|
|
9
|
-
} from '@sanity/sdk'
|
|
8
|
+
} from '@sanity/sdk/comlink'
|
|
10
9
|
import {useCallback, useEffect, useRef} from 'react'
|
|
11
10
|
import {filter, firstValueFrom} from 'rxjs'
|
|
12
11
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createComment,
|
|
3
|
+
removeComment,
|
|
4
|
+
replyToComment,
|
|
5
|
+
setCommentStatus,
|
|
6
|
+
updateComment,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {renderHook} from '@testing-library/react'
|
|
9
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
10
|
+
|
|
11
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
12
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
13
|
+
import {useCommentActions} from './useCommentActions'
|
|
14
|
+
|
|
15
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
17
|
+
return {
|
|
18
|
+
...original,
|
|
19
|
+
createComment: vi.fn(),
|
|
20
|
+
replyToComment: vi.fn(),
|
|
21
|
+
updateComment: vi.fn(),
|
|
22
|
+
setCommentStatus: vi.fn(),
|
|
23
|
+
removeComment: vi.fn(),
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
28
|
+
|
|
29
|
+
/** Creates name a field, since a comment with no path is refused. */
|
|
30
|
+
const CREATE = {...HANDLE, fieldPath: 'name'}
|
|
31
|
+
|
|
32
|
+
// Hoisted: an inline object here would be a new value on every render, and the
|
|
33
|
+
// callbacks are memoised against it.
|
|
34
|
+
const RESOURCES = {other: {projectId: 'p2', dataset: 'd2'}}
|
|
35
|
+
|
|
36
|
+
/** Hoisted for the same reason as `RESOURCES`. */
|
|
37
|
+
const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
|
|
38
|
+
|
|
39
|
+
function Wrapper({children}: {children: React.ReactNode}) {
|
|
40
|
+
return (
|
|
41
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
42
|
+
<ResourcesContext.Provider value={RESOURCES}>{children}</ResourcesContext.Provider>
|
|
43
|
+
</ResourceProvider>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function PerspectiveWrapper({children}: {children: React.ReactNode}) {
|
|
48
|
+
return (
|
|
49
|
+
<ResourceProvider projectId="p" dataset="d" perspective={RELEASE_PERSPECTIVE} fallback={null}>
|
|
50
|
+
<ResourcesContext.Provider value={RESOURCES}>{children}</ResourcesContext.Provider>
|
|
51
|
+
</ResourceProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setup(wrapper: typeof Wrapper = Wrapper) {
|
|
56
|
+
return renderHook(() => useCommentActions(), {wrapper})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('useCommentActions', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.resetAllMocks()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps the callbacks stable across renders', () => {
|
|
65
|
+
const {result, rerender} = setup()
|
|
66
|
+
const first = result.current
|
|
67
|
+
|
|
68
|
+
rerender()
|
|
69
|
+
|
|
70
|
+
expect(result.current).toBe(first)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('forwards each action to the store', () => {
|
|
74
|
+
const {result} = setup()
|
|
75
|
+
|
|
76
|
+
result.current.createComment({...CREATE, message: null})
|
|
77
|
+
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: null})
|
|
78
|
+
result.current.updateComment({commentId: 'c1', message: null})
|
|
79
|
+
result.current.setCommentStatus({commentId: 'c1', status: 'resolved'})
|
|
80
|
+
result.current.removeComment({commentId: 'c1'})
|
|
81
|
+
|
|
82
|
+
expect(createComment).toHaveBeenCalledOnce()
|
|
83
|
+
expect(replyToComment).toHaveBeenCalledOnce()
|
|
84
|
+
expect(updateComment).toHaveBeenCalledOnce()
|
|
85
|
+
expect(setCommentStatus).toHaveBeenCalledOnce()
|
|
86
|
+
expect(removeComment).toHaveBeenCalledOnce()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fills in the resource from context', () => {
|
|
90
|
+
const {result} = setup()
|
|
91
|
+
|
|
92
|
+
result.current.createComment({...CREATE, message: null})
|
|
93
|
+
|
|
94
|
+
expect(createComment).toHaveBeenCalledWith(
|
|
95
|
+
expect.anything(),
|
|
96
|
+
expect.objectContaining({resource: {projectId: 'p', dataset: 'd'}}),
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('lets a call choose a different named resource', () => {
|
|
101
|
+
// Resolution happens per call, so one set of callbacks serves several
|
|
102
|
+
// resources.
|
|
103
|
+
const {result} = setup()
|
|
104
|
+
|
|
105
|
+
result.current.removeComment({commentId: 'c1', resourceName: 'other'})
|
|
106
|
+
|
|
107
|
+
expect(removeComment).toHaveBeenCalledWith(
|
|
108
|
+
expect.anything(),
|
|
109
|
+
expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('rejects a call naming a resource that is not in context', () => {
|
|
114
|
+
const {result} = setup()
|
|
115
|
+
|
|
116
|
+
expect(() => result.current.removeComment({commentId: 'c1', resourceName: 'missing'})).toThrow(
|
|
117
|
+
/no resource named "missing"/,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('fills in the perspective from context', () => {
|
|
122
|
+
// Core turns a release perspective into `target.documentVersionId`, so
|
|
123
|
+
// losing it here files the comment against the wrong release and nothing
|
|
124
|
+
// reports an error.
|
|
125
|
+
const {result} = setup(PerspectiveWrapper)
|
|
126
|
+
|
|
127
|
+
result.current.createComment({...CREATE, message: null})
|
|
128
|
+
result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: null})
|
|
129
|
+
|
|
130
|
+
expect(createComment).toHaveBeenCalledWith(
|
|
131
|
+
expect.anything(),
|
|
132
|
+
expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
|
|
133
|
+
)
|
|
134
|
+
expect(replyToComment).toHaveBeenCalledWith(
|
|
135
|
+
expect.anything(),
|
|
136
|
+
expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('lets a call override the perspective from context', () => {
|
|
141
|
+
const {result} = setup(PerspectiveWrapper)
|
|
142
|
+
|
|
143
|
+
result.current.createComment({...CREATE, message: null, perspective: 'published'})
|
|
144
|
+
|
|
145
|
+
expect(createComment).toHaveBeenCalledWith(
|
|
146
|
+
expect.anything(),
|
|
147
|
+
expect.objectContaining({perspective: 'published'}),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Comment,
|
|
3
|
+
createComment,
|
|
4
|
+
type CreateCommentOptions,
|
|
5
|
+
type DocumentResource,
|
|
6
|
+
type PerspectiveHandle,
|
|
7
|
+
removeComment,
|
|
8
|
+
type RemoveCommentOptions,
|
|
9
|
+
replyToComment,
|
|
10
|
+
type ReplyToCommentOptions,
|
|
11
|
+
setCommentStatus,
|
|
12
|
+
type SetCommentStatusOptions,
|
|
13
|
+
updateComment,
|
|
14
|
+
type UpdateCommentOptions,
|
|
15
|
+
} from '@sanity/sdk'
|
|
16
|
+
import {useContext, useMemo} from 'react'
|
|
17
|
+
|
|
18
|
+
import {PerspectiveContext} from '../../context/PerspectiveContext'
|
|
19
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
20
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
21
|
+
import {
|
|
22
|
+
normalizeResourceOptions,
|
|
23
|
+
useEffectiveContextResource,
|
|
24
|
+
type WithResourceNameSupport,
|
|
25
|
+
} from '../helpers/useNormalizedResourceOptions'
|
|
26
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @public
|
|
30
|
+
* @category Types
|
|
31
|
+
*/
|
|
32
|
+
export interface CommentActions {
|
|
33
|
+
/** Starts a thread on a document, or on one of its fields. */
|
|
34
|
+
createComment: (options: WithResourceNameSupport<CreateCommentOptions>) => Promise<Comment>
|
|
35
|
+
/** Adds a reply to an existing thread. */
|
|
36
|
+
replyToComment: (options: WithResourceNameSupport<ReplyToCommentOptions>) => Promise<Comment>
|
|
37
|
+
/** Rewrites a comment's message. */
|
|
38
|
+
updateComment: (options: WithResourceNameSupport<UpdateCommentOptions>) => Promise<void>
|
|
39
|
+
/** Resolves or reopens a thread. Pass the thread's first comment. */
|
|
40
|
+
setCommentStatus: (options: WithResourceNameSupport<SetCommentStatusOptions>) => Promise<void>
|
|
41
|
+
/** Deletes a comment, and its replies when it starts a thread. */
|
|
42
|
+
removeComment: (options: WithResourceNameSupport<RemoveCommentOptions>) => Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Resolvable = {
|
|
46
|
+
resource?: DocumentResource
|
|
47
|
+
resourceName?: string
|
|
48
|
+
projectId?: string
|
|
49
|
+
dataset?: string
|
|
50
|
+
perspective?: PerspectiveHandle['perspective']
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the five comment write actions, bound to the current instance.
|
|
55
|
+
*
|
|
56
|
+
* Each writes optimistically: the change shows immediately and is rolled back
|
|
57
|
+
* if the server rejects it, so an app can render straight from
|
|
58
|
+
* {@link useComments} without tracking pending state itself.
|
|
59
|
+
*
|
|
60
|
+
* Creating is the exception, and `replyToComment` counts as creating. A comment
|
|
61
|
+
* that fails to post stays on screen carrying `_state.createError` rather than
|
|
62
|
+
* disappearing, so passing the same `commentId` again retries it instead of
|
|
63
|
+
* losing what someone typed.
|
|
64
|
+
*
|
|
65
|
+
* The resource is resolved when an action is called rather than when the hook
|
|
66
|
+
* runs, so one set of callbacks works across several resources. Pass `resource`
|
|
67
|
+
* or `resourceName` per call to choose; otherwise the surrounding
|
|
68
|
+
* `ResourceProvider` decides.
|
|
69
|
+
*
|
|
70
|
+
* @category Comments
|
|
71
|
+
* @function
|
|
72
|
+
* @returns The five write actions
|
|
73
|
+
*
|
|
74
|
+
* @example Resolve a thread
|
|
75
|
+
* ```tsx
|
|
76
|
+
* function ResolveButton({commentId}: {commentId: string}) {
|
|
77
|
+
* const {setCommentStatus} = useCommentActions()
|
|
78
|
+
*
|
|
79
|
+
* return (
|
|
80
|
+
* <button onClick={() => setCommentStatus({commentId, status: 'resolved'})}>
|
|
81
|
+
* Resolve
|
|
82
|
+
* </button>
|
|
83
|
+
* )
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
export function useCommentActions(): CommentActions {
|
|
90
|
+
const instance = useSanityInstance()
|
|
91
|
+
trackHookUsage(instance, 'useCommentActions')
|
|
92
|
+
|
|
93
|
+
const resources = useContext(ResourcesContext)
|
|
94
|
+
const contextResource = useEffectiveContextResource()
|
|
95
|
+
const contextPerspective = useContext(PerspectiveContext)
|
|
96
|
+
|
|
97
|
+
return useMemo(() => {
|
|
98
|
+
const resolve = <T extends Resolvable>(options: T) =>
|
|
99
|
+
normalizeResourceOptions(options, resources, contextResource, contextPerspective)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
createComment: (options) => createComment(instance, resolve(options)),
|
|
103
|
+
replyToComment: (options) => replyToComment(instance, resolve(options)),
|
|
104
|
+
updateComment: (options) => updateComment(instance, resolve(options)),
|
|
105
|
+
setCommentStatus: (options) => setCommentStatus(instance, resolve(options)),
|
|
106
|
+
removeComment: (options) => removeComment(instance, resolve(options)),
|
|
107
|
+
}
|
|
108
|
+
}, [contextPerspective, contextResource, instance, resources])
|
|
109
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {type CommentsOptions, type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
|
|
3
|
+
import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
import {
|
|
7
|
+
useNormalizedResourceOptions,
|
|
8
|
+
type WithResourceNameSupport,
|
|
9
|
+
} from '../helpers/useNormalizedResourceOptions'
|
|
10
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
11
|
+
|
|
12
|
+
/** The pair of core functions backing one read hook. */
|
|
13
|
+
export interface CommentListSource<T> {
|
|
14
|
+
getState: (instance: SanityInstance, options: CommentsOptions) => StateSource<T | undefined>
|
|
15
|
+
resolve: (
|
|
16
|
+
instance: SanityInstance,
|
|
17
|
+
options: CommentsOptions & {signal?: AbortSignal},
|
|
18
|
+
) => Promise<T>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shared body of {@link useComments} and {@link useCommentThreads}.
|
|
23
|
+
*
|
|
24
|
+
* Suspends until the first snapshot arrives. Changing documents or filters
|
|
25
|
+
* happens in a transition, so the list already on screen stays put and
|
|
26
|
+
* `isPending` reports the swap instead of the component suspending again. The
|
|
27
|
+
* previous read is aborted, which drops its listener when nothing else is
|
|
28
|
+
* reading it.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export function useCommentList<T>(
|
|
33
|
+
hookName: string,
|
|
34
|
+
options: WithResourceNameSupport<CommentsOptions>,
|
|
35
|
+
{getState, resolve}: CommentListSource<T>,
|
|
36
|
+
): {value: T; isPending: boolean} {
|
|
37
|
+
const instance = useSanityInstance()
|
|
38
|
+
trackHookUsage(instance, hookName)
|
|
39
|
+
|
|
40
|
+
const normalized = useNormalizedResourceOptions(options)
|
|
41
|
+
const [isPending, startTransition] = useTransition()
|
|
42
|
+
|
|
43
|
+
const key = getCommentsOptionsKey(normalized)
|
|
44
|
+
// Held one render behind `key`, so the swap can happen inside a transition.
|
|
45
|
+
const [deferredKey, setDeferredKey] = useState(key)
|
|
46
|
+
const abortRef = useRef<AbortController>(new AbortController())
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (key === deferredKey) return
|
|
50
|
+
|
|
51
|
+
startTransition(() => {
|
|
52
|
+
if (!abortRef.current.signal.aborted) {
|
|
53
|
+
abortRef.current.abort()
|
|
54
|
+
abortRef.current = new AbortController()
|
|
55
|
+
}
|
|
56
|
+
setDeferredKey(key)
|
|
57
|
+
})
|
|
58
|
+
}, [deferredKey, key])
|
|
59
|
+
|
|
60
|
+
const deferred = useMemo(() => parseCommentsOptionsKey(deferredKey), [deferredKey])
|
|
61
|
+
const {getCurrent, subscribe} = useMemo(
|
|
62
|
+
() => getState(instance, deferred),
|
|
63
|
+
[deferred, getState, instance],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (getCurrent() === undefined) {
|
|
67
|
+
// Reading the ref mid-render is safe here: React runs no effects for a
|
|
68
|
+
// render that suspends, so the signal captured now cannot be swapped
|
|
69
|
+
// underneath this pass.
|
|
70
|
+
const currentSignal = abortRef.current.signal
|
|
71
|
+
|
|
72
|
+
// eslint-disable-next-line react-hooks/refs -- intentional during a suspended render; see above
|
|
73
|
+
throw resolve(instance, {...deferred, signal: currentSignal})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Not memoised: both callers destructure this immediately and memoise their
|
|
77
|
+
// own result object, so a stable identity here would never be observed.
|
|
78
|
+
return {value: useSyncExternalStore(subscribe, getCurrent) as T, isPending}
|
|
79
|
+
}
|
|
@@ -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,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommentsOptions,
|
|
3
|
+
type CommentThread,
|
|
4
|
+
getCommentThreadsState,
|
|
5
|
+
resolveCommentThreads,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {useMemo} from 'react'
|
|
8
|
+
|
|
9
|
+
import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
|
|
10
|
+
import {type CommentListSource, useCommentList} from './useCommentList'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @public
|
|
14
|
+
* @category Types
|
|
15
|
+
*/
|
|
16
|
+
export interface UseCommentThreadsResult {
|
|
17
|
+
/** Newest thread first, each with its replies oldest first. */
|
|
18
|
+
threads: CommentThread[]
|
|
19
|
+
/** True while switching to a different document or filter. */
|
|
20
|
+
isPending: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SOURCE: CommentListSource<CommentThread[]> = {
|
|
24
|
+
getState: getCommentThreadsState,
|
|
25
|
+
resolve: resolveCommentThreads,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads a document's comments grouped into threads.
|
|
30
|
+
*
|
|
31
|
+
* A thread is one comment plus its replies. Its `status` and `fieldPath` come
|
|
32
|
+
* from the first comment, so filtering by either selects whole threads rather
|
|
33
|
+
* than stray replies.
|
|
34
|
+
*
|
|
35
|
+
* Unlike the Studio, every thread is returned. The Studio hides threads whose
|
|
36
|
+
* field has left the schema or is hidden by a conditional, which it can do
|
|
37
|
+
* because it has the schema to check against. Inspect `fieldPath` yourself if
|
|
38
|
+
* your app needs to do the same.
|
|
39
|
+
*
|
|
40
|
+
* @category Comments
|
|
41
|
+
* @function
|
|
42
|
+
* @param options - The document to read, optionally narrowed by `fieldPath` or `status`
|
|
43
|
+
* @returns The matching threads, and whether a switch is in flight
|
|
44
|
+
*
|
|
45
|
+
* @example Render the open threads on a document
|
|
46
|
+
* ```tsx
|
|
47
|
+
* function Threads({documentId}: {documentId: string}) {
|
|
48
|
+
* const {threads} = useCommentThreads({
|
|
49
|
+
* documentId,
|
|
50
|
+
* documentType: 'article',
|
|
51
|
+
* status: 'open',
|
|
52
|
+
* })
|
|
53
|
+
*
|
|
54
|
+
* return (
|
|
55
|
+
* <ul>
|
|
56
|
+
* {threads.map((thread) => (
|
|
57
|
+
* <li key={thread.threadId}>
|
|
58
|
+
* {thread.fieldPath || 'Document'} — {thread.commentsCount} comments
|
|
59
|
+
* </li>
|
|
60
|
+
* ))}
|
|
61
|
+
* </ul>
|
|
62
|
+
* )
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
export function useCommentThreads(
|
|
69
|
+
options: WithResourceNameSupport<CommentsOptions>,
|
|
70
|
+
): UseCommentThreadsResult {
|
|
71
|
+
const {value, isPending} = useCommentList('useCommentThreads', options, SOURCE)
|
|
72
|
+
return useMemo(() => ({threads: value, isPending}), [isPending, value])
|
|
73
|
+
}
|