@sanity/sdk-react 3.0.0-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/dashboard.d.ts +209 -0
- package/dist/_exports/dashboard.d.ts.map +1 -0
- package/dist/_exports/dashboard.js +278 -0
- package/dist/_exports/dashboard.js.map +1 -0
- package/dist/index.d.ts +2186 -2774
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3953 -1951
- package/dist/index.js.map +1 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +177 -0
- package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +1 -0
- package/package.json +19 -19
- package/src/_exports/dashboard.ts +11 -0
- package/src/_exports/sdk-react.ts +11 -18
- package/src/components/SDKProvider.test.tsx +5 -5
- package/src/components/SanityApp.tsx +2 -3
- package/src/components/auth/AuthBoundary.test.tsx +1 -19
- package/src/components/auth/AuthBoundary.tsx +7 -6
- package/src/components/auth/LoginError.tsx +2 -3
- package/src/context/ComlinkTokenRefresh.tsx +3 -5
- package/src/context/{WorkbenchTokenRefresh.test.tsx → DashboardTokenRefresh.test.tsx} +26 -26
- package/src/context/{WorkbenchTokenRefresh.tsx → DashboardTokenRefresh.tsx} +14 -14
- package/src/context/OrganizationResourcesProvider.test.tsx +9 -9
- package/src/context/OrganizationResourcesProvider.tsx +2 -2
- package/src/context/ResourceProvider.test.tsx +21 -0
- package/src/context/ResourceProvider.tsx +4 -1
- package/src/context/{workbenchToken.ts → dashboardToken.ts} +13 -13
- package/src/hooks/agent/agentActions.test.tsx +1 -1
- package/src/hooks/agent/agentActions.ts +1 -1
- 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 +3 -4
- 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/useSanityInstance.test.tsx +2 -54
- package/src/hooks/context/useSanityInstance.ts +2 -24
- package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +2 -2
- package/src/hooks/dashboard/useFavorite.test.tsx +101 -0
- package/src/hooks/dashboard/useFavorite.ts +34 -0
- package/src/hooks/dashboard/useFavoriteContext.ts +61 -0
- package/src/hooks/dashboard/{useDashboardNavigate.test.ts → useNavigate.test.ts} +3 -3
- package/src/hooks/dashboard/{useDashboardNavigate.ts → useNavigate.ts} +5 -5
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -1
- package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx} +4 -4
- package/src/hooks/{auth/useDashboardOrganizationId.tsx → dashboard/useOrganizationId.tsx} +2 -2
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +146 -0
- package/src/hooks/dashboard/useUpdateFavorite.ts +74 -0
- package/src/hooks/dashboard/useWindowTitle.ts +1 -1
- package/src/hooks/datasets/useDatasets.test.tsx +29 -22
- package/src/hooks/datasets/useDatasets.ts +31 -53
- package/src/hooks/documents/useDocuments.ts +2 -7
- package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +0 -27
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +14 -25
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -7
- package/src/hooks/presence/usePresence.ts +14 -1
- package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
- package/src/hooks/presence/usePresenceForDocument.ts +104 -0
- package/src/hooks/presence/useReportPresence.test.tsx +202 -0
- package/src/hooks/presence/useReportPresence.ts +170 -0
- package/src/hooks/preview/useDocumentPreview.tsx +2 -6
- package/src/hooks/projects/useProjects.ts +0 -8
- package/src/hooks/query/useQuery.ts +3 -8
- package/src/hooks/releases/useActiveReleases.ts +2 -2
- package/src/hooks/releases/useAllReleases.ts +2 -2
- package/src/hooks/users/useUser.ts +2 -8
- package/src/hooks/users/useUsers.ts +1 -2
- package/src/hooks/applications/useCreateUserApplication.test-d.ts +0 -14
- package/src/hooks/applications/useCreateUserApplication.ts +0 -11
- package/src/hooks/applications/useDeleteUserApplication.test-d.ts +0 -14
- package/src/hooks/applications/useDeleteUserApplication.ts +0 -11
- package/src/hooks/applications/useUpdateUserApplication.test-d.ts +0 -14
- package/src/hooks/applications/useUpdateUserApplication.ts +0 -11
- package/src/hooks/applications/useUserApplication.test-d.ts +0 -11
- package/src/hooks/applications/useUserApplication.ts +0 -14
- package/src/hooks/applications/useUserApplications.test-d.ts +0 -11
- package/src/hooks/applications/useUserApplications.ts +0 -14
- package/src/hooks/dashboard/useManageFavorite.test.tsx +0 -379
- package/src/hooks/dashboard/useManageFavorite.ts +0 -173
- /package/src/hooks/{agent → dashboard}/useAgentResourceContext.test.tsx +0 -0
|
@@ -42,7 +42,7 @@ function resolveAppTitle(resource: ContextResource): string | undefined {
|
|
|
42
42
|
*
|
|
43
43
|
* @example
|
|
44
44
|
* ```tsx
|
|
45
|
-
* import {useWindowTitle} from '@sanity/sdk-react'
|
|
45
|
+
* import {useWindowTitle} from '@sanity/sdk-react/dashboard'
|
|
46
46
|
*
|
|
47
47
|
* function MoviesList() {
|
|
48
48
|
* useWindowTitle('Movies')
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
getDatasetsState,
|
|
5
|
-
resolveDatasets,
|
|
6
|
-
type StateSource,
|
|
7
|
-
} from '@sanity/sdk'
|
|
2
|
+
import {createSanityInstance, datasets, type StateSource} from '@sanity/sdk'
|
|
3
|
+
import {type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
8
4
|
import {type ReactNode} from 'react'
|
|
9
5
|
import {type Observable} from 'rxjs'
|
|
10
6
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
@@ -16,32 +12,43 @@ import {useDatasets} from './useDatasets'
|
|
|
16
12
|
|
|
17
13
|
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
14
|
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
-
return {...original,
|
|
15
|
+
return {...original, datasets: {getState: vi.fn(), resolveState: vi.fn()}}
|
|
20
16
|
})
|
|
21
17
|
|
|
22
18
|
const stateSource = (
|
|
23
19
|
current: DatasetsResponse | undefined,
|
|
24
|
-
): StateSource<DatasetsResponse
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
): StateSource<FetcherSnapshot<DatasetsResponse>> => {
|
|
21
|
+
// Cache the snapshot: useSyncExternalStore requires a referentially stable current value.
|
|
22
|
+
const snapshot = current
|
|
23
|
+
? {status: 'success', data: current, error: undefined, isFetching: false, dataUpdatedAt: 1}
|
|
24
|
+
: {
|
|
25
|
+
status: 'pending',
|
|
26
|
+
data: undefined,
|
|
27
|
+
error: undefined,
|
|
28
|
+
isFetching: true,
|
|
29
|
+
dataUpdatedAt: undefined,
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
getCurrent: vi.fn(() => snapshot),
|
|
33
|
+
subscribe: vi.fn(() => () => {}),
|
|
28
34
|
get observable(): Observable<unknown> {
|
|
29
35
|
throw new Error('Not implemented')
|
|
30
36
|
},
|
|
31
|
-
}
|
|
37
|
+
} as unknown as StateSource<FetcherSnapshot<DatasetsResponse>>
|
|
38
|
+
}
|
|
32
39
|
|
|
33
40
|
const sanityInstance = expect.objectContaining({config: expect.any(Object)})
|
|
34
41
|
|
|
35
42
|
describe('useDatasets', () => {
|
|
36
43
|
beforeEach(() => {
|
|
37
44
|
vi.clearAllMocks()
|
|
38
|
-
vi.mocked(
|
|
45
|
+
vi.mocked(datasets.getState).mockReturnValue(stateSource([] as unknown as DatasetsResponse))
|
|
39
46
|
})
|
|
40
47
|
|
|
41
48
|
it('resolves the projectId from the instance config resource', () => {
|
|
42
49
|
// test-utils wraps with ResourceProvider projectId="test" dataset="test".
|
|
43
50
|
renderHook(() => useDatasets())
|
|
44
|
-
expect(
|
|
51
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
45
52
|
sanityInstance,
|
|
46
53
|
expect.objectContaining({projectId: 'test'}),
|
|
47
54
|
)
|
|
@@ -49,7 +56,7 @@ describe('useDatasets', () => {
|
|
|
49
56
|
|
|
50
57
|
it('lets an explicit projectId override the ambient resource', () => {
|
|
51
58
|
renderHook(() => useDatasets({projectId: 'explicit-project'}))
|
|
52
|
-
expect(
|
|
59
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
53
60
|
sanityInstance,
|
|
54
61
|
expect.objectContaining({projectId: 'explicit-project'}),
|
|
55
62
|
)
|
|
@@ -66,7 +73,7 @@ describe('useDatasets', () => {
|
|
|
66
73
|
</ResourceProvider>
|
|
67
74
|
),
|
|
68
75
|
})
|
|
69
|
-
expect(
|
|
76
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
70
77
|
sanityInstance,
|
|
71
78
|
expect.objectContaining({projectId: 'resource-project'}),
|
|
72
79
|
)
|
|
@@ -82,7 +89,7 @@ describe('useDatasets', () => {
|
|
|
82
89
|
})
|
|
83
90
|
// A dataset-less config can't form a DatasetResource; the projectId is carried
|
|
84
91
|
// via ProjectContext and injected so project-scoped reads still resolve it.
|
|
85
|
-
expect(
|
|
92
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
86
93
|
sanityInstance,
|
|
87
94
|
expect.objectContaining({projectId: 'config-project'}),
|
|
88
95
|
)
|
|
@@ -101,16 +108,16 @@ describe('useDatasets', () => {
|
|
|
101
108
|
</SanityInstanceContext.Provider>
|
|
102
109
|
),
|
|
103
110
|
})
|
|
104
|
-
expect(
|
|
111
|
+
expect(datasets.getState).toHaveBeenCalledWith(
|
|
105
112
|
emptyInstance,
|
|
106
113
|
expect.objectContaining({projectId: 'bare-project'}),
|
|
107
114
|
)
|
|
108
115
|
})
|
|
109
116
|
|
|
110
|
-
it('suspends via
|
|
111
|
-
vi.mocked(
|
|
112
|
-
vi.mocked(
|
|
117
|
+
it('suspends via the datasets fetcher until dataset data is available', () => {
|
|
118
|
+
vi.mocked(datasets.getState).mockReturnValue(stateSource(undefined))
|
|
119
|
+
vi.mocked(datasets.resolveState).mockReturnValue(new Promise(() => {}))
|
|
113
120
|
renderHook(() => useDatasets())
|
|
114
|
-
expect(
|
|
121
|
+
expect(datasets.resolveState).toHaveBeenCalled()
|
|
115
122
|
})
|
|
116
123
|
})
|
|
@@ -1,64 +1,42 @@
|
|
|
1
1
|
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
-
import {
|
|
3
|
-
getDatasetsState,
|
|
4
|
-
type ProjectHandle,
|
|
5
|
-
resolveDatasets,
|
|
6
|
-
type SanityInstance,
|
|
7
|
-
type StateSource,
|
|
8
|
-
} from '@sanity/sdk'
|
|
2
|
+
import {datasets, type ProjectHandle} from '@sanity/sdk'
|
|
9
3
|
|
|
10
|
-
import {
|
|
4
|
+
import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
|
|
11
5
|
import {useResolvedProjectId} from '../helpers/useResolvedProjectId'
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
* Returns metadata for each dataset the current user has access to.
|
|
17
|
-
*
|
|
18
|
-
* @category Datasets
|
|
19
|
-
* @param options - Optional project/resource to read datasets for. Defaults to
|
|
20
|
-
* the resource named in `ResourceProvider`/`SDKProvider`.
|
|
21
|
-
* @returns The metadata for your the datasets
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* ```tsx
|
|
25
|
-
* const datasets = useDatasets()
|
|
26
|
-
*
|
|
27
|
-
* return (
|
|
28
|
-
* <select>
|
|
29
|
-
* {datasets.map((dataset) => (
|
|
30
|
-
* <option key={dataset.name}>{dataset.name}</option>
|
|
31
|
-
* ))}
|
|
32
|
-
* </select>
|
|
33
|
-
* )
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* @remarks
|
|
37
|
-
* The `projectId` is resolved in order from:
|
|
38
|
-
* 1. an explicit `projectId` option
|
|
39
|
-
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
40
|
-
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
41
|
-
* 4. `instance.config`.
|
|
42
|
-
*/
|
|
43
|
-
(options?: ProjectHandle): DatasetsResponse
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const useDatasetsBase = createStateSourceHook({
|
|
47
|
-
getState: getDatasetsState as (
|
|
48
|
-
instance: SanityInstance,
|
|
49
|
-
projectHandle?: ProjectHandle,
|
|
50
|
-
) => StateSource<DatasetsResponse>,
|
|
51
|
-
shouldSuspend: (instance, projectHandle?: ProjectHandle) =>
|
|
52
|
-
// remove `undefined` since we're suspending when that is the case
|
|
53
|
-
getDatasetsState(instance, projectHandle).getCurrent() === undefined,
|
|
54
|
-
suspender: resolveDatasets,
|
|
55
|
-
})
|
|
7
|
+
const useDatasetsBase = createFetcherHook(datasets)
|
|
56
8
|
|
|
57
9
|
/**
|
|
10
|
+
* Returns metadata for each dataset the current user has access to.
|
|
11
|
+
*
|
|
12
|
+
* @category Datasets
|
|
13
|
+
* @param options - Optional project/resource to read datasets for. Defaults to
|
|
14
|
+
* the resource named in `ResourceProvider`/`SDKProvider`.
|
|
15
|
+
* @returns A {@link FetcherHookResult} whose `data` is the metadata for the
|
|
16
|
+
* datasets.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const {data: datasets} = useDatasets()
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <select>
|
|
24
|
+
* {datasets.map((dataset) => (
|
|
25
|
+
* <option key={dataset.name}>{dataset.name}</option>
|
|
26
|
+
* ))}
|
|
27
|
+
* </select>
|
|
28
|
+
* )
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The `projectId` is resolved in order from:
|
|
33
|
+
* 1. an explicit `projectId` option
|
|
34
|
+
* 2. A legacy ProjectContext (e.g. a `<ResourceProvider projectId="…">` with no dataset), then
|
|
35
|
+
* 3. The active resource (`ResourceProvider`/`SDKProvider`)
|
|
36
|
+
* 4. `instance.config`.
|
|
58
37
|
* @public
|
|
59
|
-
* @function
|
|
60
38
|
*/
|
|
61
|
-
export
|
|
39
|
+
export function useDatasets(options?: ProjectHandle): FetcherHookResult<DatasetsResponse> {
|
|
62
40
|
const projectId = useResolvedProjectId(options)
|
|
63
41
|
return useDatasetsBase(projectId ? {...options, projectId} : options)
|
|
64
42
|
}
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type DocumentHandle,
|
|
4
|
-
isDatasetResource,
|
|
5
|
-
type QueryOptions,
|
|
6
|
-
} from '@sanity/sdk'
|
|
7
|
-
import {pickProperties} from '@sanity/sdk/_internal'
|
|
1
|
+
import {type DocumentHandle, isDatasetResource, type QueryOptions} from '@sanity/sdk'
|
|
2
|
+
import {createGroqSearchFilter, pickProperties} from '@sanity/sdk/_internal'
|
|
8
3
|
import {type SortOrderingItem} from '@sanity/types'
|
|
9
4
|
import {useCallback, useMemo, useState} from 'react'
|
|
10
5
|
|
|
@@ -91,21 +91,6 @@ describe('useNormalizedResourceOptions', () => {
|
|
|
91
91
|
),
|
|
92
92
|
).toThrow()
|
|
93
93
|
})
|
|
94
|
-
|
|
95
|
-
it('resolves deprecated `source` as `resource`', () => {
|
|
96
|
-
const {result} = renderHook(() =>
|
|
97
|
-
useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
|
|
98
|
-
)
|
|
99
|
-
expect(result.current.resource).toEqual({projectId: 'src', dataset: 'src-ds'})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('resolves deprecated `sourceName` as `resourceName`', () => {
|
|
103
|
-
const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
|
|
104
|
-
expect(result.current.resource).toEqual({
|
|
105
|
-
projectId: 'resource-project-id',
|
|
106
|
-
dataset: 'resource-dataset',
|
|
107
|
-
})
|
|
108
|
-
})
|
|
109
94
|
})
|
|
110
95
|
|
|
111
96
|
describe('tier 2 — bare projectId/dataset in options', () => {
|
|
@@ -232,18 +217,6 @@ describe('useNormalizedResourceOptions', () => {
|
|
|
232
217
|
expect(result.current).not.toHaveProperty('dataset')
|
|
233
218
|
})
|
|
234
219
|
|
|
235
|
-
it('strips deprecated source from the result', () => {
|
|
236
|
-
const {result} = renderHook(() =>
|
|
237
|
-
useNormalizedResourceOptions({source: {projectId: 'src', dataset: 'src-ds'}}),
|
|
238
|
-
)
|
|
239
|
-
expect(result.current).not.toHaveProperty('source')
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
it('strips deprecated sourceName from the result', () => {
|
|
243
|
-
const {result} = renderHook(() => useNormalizedResourceOptions({sourceName: 'dataset'}))
|
|
244
|
-
expect(result.current).not.toHaveProperty('sourceName')
|
|
245
|
-
})
|
|
246
|
-
|
|
247
220
|
it('preserves unrelated fields', () => {
|
|
248
221
|
const opts: DocumentHandle = {documentId: 'doc-1', documentType: 'article'}
|
|
249
222
|
const {result} = renderHook(() => useNormalizedResourceOptions(opts))
|
|
@@ -6,7 +6,7 @@ import {PerspectiveContext} from '../../context/PerspectiveContext'
|
|
|
6
6
|
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
7
7
|
import {SanityInstanceContext} from '../../context/SanityInstanceContext'
|
|
8
8
|
|
|
9
|
-
type NormalizedResourceFields = 'resourceName' | '
|
|
9
|
+
type NormalizedResourceFields = 'resourceName' | 'projectId' | 'dataset'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Adds React hook support (resourceName resolution) to core types.
|
|
@@ -24,11 +24,6 @@ export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T
|
|
|
24
24
|
* @beta
|
|
25
25
|
*/
|
|
26
26
|
resourceName?: string
|
|
27
|
-
/**
|
|
28
|
-
* @deprecated Use `resourceName` instead.
|
|
29
|
-
* @beta
|
|
30
|
-
*/
|
|
31
|
-
sourceName?: string
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
/**
|
|
@@ -48,8 +43,6 @@ export function normalizeResourceOptions<
|
|
|
48
43
|
T extends {
|
|
49
44
|
resource?: DocumentResource
|
|
50
45
|
resourceName?: string
|
|
51
|
-
source?: DocumentResource
|
|
52
|
-
sourceName?: string
|
|
53
46
|
projectId?: string
|
|
54
47
|
dataset?: string
|
|
55
48
|
perspective?: unknown
|
|
@@ -60,30 +53,27 @@ export function normalizeResourceOptions<
|
|
|
60
53
|
contextResource?: DocumentResource,
|
|
61
54
|
contextPerspective?: PerspectiveHandle['perspective'],
|
|
62
55
|
): Omit<T, NormalizedResourceFields> {
|
|
63
|
-
const {resourceName,
|
|
64
|
-
|
|
65
|
-
// Coalesce deprecated aliases to their canonical equivalents
|
|
66
|
-
const effectiveResourceName = resourceName ?? sourceName
|
|
67
|
-
const effectiveResource = options.resource ?? source
|
|
56
|
+
const {resourceName, projectId, dataset, ...rest} = options
|
|
57
|
+
const resource = options.resource
|
|
68
58
|
|
|
69
|
-
if (
|
|
59
|
+
if (resourceName && resource) {
|
|
70
60
|
throw new Error(
|
|
71
|
-
`Resource name ${JSON.stringify(
|
|
61
|
+
`Resource name ${JSON.stringify(resourceName)} and resource ${JSON.stringify(resource)} cannot be used together.`,
|
|
72
62
|
)
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
let resolvedResource: DocumentResource | undefined
|
|
76
66
|
|
|
77
67
|
// Tier (a): explicit resource object or resourceName lookup
|
|
78
|
-
if (
|
|
79
|
-
resolvedResource =
|
|
80
|
-
} else if (
|
|
81
|
-
if (!Object.hasOwn(resources,
|
|
68
|
+
if (resource) {
|
|
69
|
+
resolvedResource = resource
|
|
70
|
+
} else if (resourceName) {
|
|
71
|
+
if (!Object.hasOwn(resources, resourceName)) {
|
|
82
72
|
throw new Error(
|
|
83
|
-
`There's no resource named ${JSON.stringify(
|
|
73
|
+
`There's no resource named ${JSON.stringify(resourceName)} in context. Please use <ResourceProvider>.`,
|
|
84
74
|
)
|
|
85
75
|
}
|
|
86
|
-
resolvedResource = resources[
|
|
76
|
+
resolvedResource = resources[resourceName]
|
|
87
77
|
}
|
|
88
78
|
|
|
89
79
|
// Tier (b): projectId or dataset in options → synthesize a resource
|
|
@@ -121,13 +111,14 @@ export function normalizeResourceOptions<
|
|
|
121
111
|
export function useEffectiveContextResource(): DocumentResource | undefined {
|
|
122
112
|
const contextResource = useContext(ResourceContext)
|
|
123
113
|
const instance = useContext(SanityInstanceContext)
|
|
124
|
-
const {projectId, dataset} = instance?.config ?? {}
|
|
114
|
+
const {projectId, dataset, resource: configResource} = instance?.config ?? {}
|
|
125
115
|
|
|
126
116
|
return useMemo(() => {
|
|
127
117
|
if (contextResource) return contextResource
|
|
118
|
+
if (configResource) return configResource
|
|
128
119
|
if (projectId && dataset) return {projectId, dataset}
|
|
129
120
|
return undefined
|
|
130
|
-
}, [contextResource, projectId, dataset])
|
|
121
|
+
}, [contextResource, configResource, projectId, dataset])
|
|
131
122
|
}
|
|
132
123
|
|
|
133
124
|
/**
|
|
@@ -149,8 +140,6 @@ export function useNormalizedResourceOptions<
|
|
|
149
140
|
T extends {
|
|
150
141
|
resource?: DocumentResource
|
|
151
142
|
resourceName?: string
|
|
152
|
-
source?: DocumentResource
|
|
153
|
-
sourceName?: string
|
|
154
143
|
projectId?: string
|
|
155
144
|
dataset?: string
|
|
156
145
|
perspective?: PerspectiveHandle['perspective']
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type DocumentHandle,
|
|
4
|
-
isDatasetResource,
|
|
5
|
-
type QueryOptions,
|
|
6
|
-
} from '@sanity/sdk'
|
|
7
|
-
import {pickProperties} from '@sanity/sdk/_internal'
|
|
1
|
+
import {type DocumentHandle, isDatasetResource, type QueryOptions} from '@sanity/sdk'
|
|
2
|
+
import {createGroqSearchFilter, pickProperties} from '@sanity/sdk/_internal'
|
|
8
3
|
import {type SortOrderingItem} from '@sanity/types'
|
|
9
4
|
import {useCallback, useMemo, useState} from 'react'
|
|
10
5
|
|
|
@@ -7,7 +7,20 @@ import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOpti
|
|
|
7
7
|
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Every participant in the current project and dataset, or Canvas.
|
|
11
|
+
*
|
|
12
|
+
* Reading presence never announces anything. Call `useReportPresence` to make the
|
|
13
|
+
* current user visible to others, including to the Studio, which shares the same
|
|
14
|
+
* presence room.
|
|
15
|
+
*
|
|
16
|
+
* This returns everyone in the whole resource and leaves the filtering to you.
|
|
17
|
+
* Prefer `usePresenceForDocument` when you care about one document: it scopes and
|
|
18
|
+
* flattens the result for rendering. Note that participants are counted by session,
|
|
19
|
+
* so one person in two tabs appears twice.
|
|
20
|
+
*
|
|
21
|
+
* Presence is scoped to a single project and dataset. It is not a list of everyone
|
|
22
|
+
* signed in to your organization.
|
|
23
|
+
*
|
|
11
24
|
* @public
|
|
12
25
|
*/
|
|
13
26
|
export function usePresence(options: ResourceHandle = {}): {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {type DocumentPresence, getDocumentPresence} from '@sanity/sdk'
|
|
2
|
+
import {act, renderHook} from '@testing-library/react'
|
|
3
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
6
|
+
import {usePresenceForDocument} from './usePresenceForDocument'
|
|
7
|
+
|
|
8
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal<typeof import('@sanity/sdk')>()
|
|
10
|
+
return {...actual, getDocumentPresence: vi.fn()}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const entry = (sessionId: string): DocumentPresence =>
|
|
14
|
+
({
|
|
15
|
+
sessionId,
|
|
16
|
+
documentId: 'movie-1',
|
|
17
|
+
path: ['title'],
|
|
18
|
+
lastActiveAt: '2026-07-30T12:00:00Z',
|
|
19
|
+
user: {sanityUserId: 'u1', profile: {id: 'u1'}, memberships: []},
|
|
20
|
+
}) as unknown as DocumentPresence
|
|
21
|
+
|
|
22
|
+
/** A minimal StateSource stand-in whose value can be pushed. */
|
|
23
|
+
function createSource(initial: DocumentPresence[]) {
|
|
24
|
+
let current = initial
|
|
25
|
+
const listeners = new Set<() => void>()
|
|
26
|
+
return {
|
|
27
|
+
source: {
|
|
28
|
+
getCurrent: () => current,
|
|
29
|
+
subscribe: (cb: () => void) => {
|
|
30
|
+
listeners.add(cb)
|
|
31
|
+
return () => listeners.delete(cb)
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
push: (next: DocumentPresence[]) => {
|
|
35
|
+
current = next
|
|
36
|
+
listeners.forEach((cb) => cb())
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
42
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
43
|
+
{children}
|
|
44
|
+
</ResourceProvider>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
describe('usePresenceForDocument', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Without this, `mock.calls[0]` is whatever the previous test did.
|
|
50
|
+
vi.clearAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns presence and updates when the store changes', () => {
|
|
54
|
+
const {source, push} = createSource([entry('s1')])
|
|
55
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
56
|
+
|
|
57
|
+
const {result} = renderHook(
|
|
58
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
|
|
59
|
+
{wrapper},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(result.current.presence).toHaveLength(1)
|
|
63
|
+
|
|
64
|
+
act(() => push([entry('s1'), entry('s2')]))
|
|
65
|
+
expect(result.current.presence).toHaveLength(2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('passes the document, path, and excludeVersions through', () => {
|
|
69
|
+
const {source} = createSource([])
|
|
70
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
71
|
+
|
|
72
|
+
renderHook(
|
|
73
|
+
() =>
|
|
74
|
+
usePresenceForDocument({
|
|
75
|
+
documentId: 'drafts.movie-1',
|
|
76
|
+
documentType: 'movie',
|
|
77
|
+
path: ['cast', {_key: 'm1'}],
|
|
78
|
+
excludeVersions: true,
|
|
79
|
+
}),
|
|
80
|
+
{wrapper},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// Already a draft id, so resolution leaves it alone.
|
|
84
|
+
expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
|
|
85
|
+
documentId: 'drafts.movie-1',
|
|
86
|
+
path: ['cast', {_key: 'm1'}],
|
|
87
|
+
excludeVersions: true,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reads the same id useReportPresence writes, or reads never match writes', () => {
|
|
92
|
+
const {source} = createSource([])
|
|
93
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
94
|
+
|
|
95
|
+
// Forwarded unresolved, exactly as `useReportPresence` forwards it, so core
|
|
96
|
+
// resolves both the same way. If these diverged, field-level presence would
|
|
97
|
+
// silently never match.
|
|
98
|
+
renderHook(
|
|
99
|
+
() =>
|
|
100
|
+
usePresenceForDocument({
|
|
101
|
+
documentId: 'movie-1',
|
|
102
|
+
documentType: 'movie',
|
|
103
|
+
perspective: 'published',
|
|
104
|
+
}),
|
|
105
|
+
{wrapper},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(vi.mocked(getDocumentPresence).mock.calls[0][1]).toMatchObject({
|
|
109
|
+
documentId: 'movie-1',
|
|
110
|
+
perspective: 'published',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not rebuild the source when a caller passes a fresh path each render', () => {
|
|
115
|
+
const {source} = createSource([])
|
|
116
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
117
|
+
|
|
118
|
+
const {rerender} = renderHook(
|
|
119
|
+
// A new array identity every render, which is what real callers write.
|
|
120
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie', path: ['title']}),
|
|
121
|
+
{wrapper},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
rerender()
|
|
125
|
+
rerender()
|
|
126
|
+
|
|
127
|
+
expect(vi.mocked(getDocumentPresence)).toHaveBeenCalledTimes(1)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns an empty array rather than undefined before anything arrives', () => {
|
|
131
|
+
const {source} = createSource(undefined as unknown as DocumentPresence[])
|
|
132
|
+
vi.mocked(getDocumentPresence).mockReturnValue(source as never)
|
|
133
|
+
|
|
134
|
+
const {result} = renderHook(
|
|
135
|
+
() => usePresenceForDocument({documentId: 'movie-1', documentType: 'movie'}),
|
|
136
|
+
{wrapper},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(result.current.presence).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {type DocumentPresence, getDocumentPresence, isMediaLibraryResource} from '@sanity/sdk'
|
|
2
|
+
import {type Path} from '@sanity/types'
|
|
3
|
+
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
4
|
+
|
|
5
|
+
import {type DocumentHandle} from '../../config/handles'
|
|
6
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
7
|
+
import {useNormalizedResourceOptions} from '../helpers/useNormalizedResourceOptions'
|
|
8
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
9
|
+
|
|
10
|
+
/** @beta */
|
|
11
|
+
export interface UsePresenceForDocumentOptions extends DocumentHandle {
|
|
12
|
+
/**
|
|
13
|
+
* Narrows to participants at or below this field path, which is what a field
|
|
14
|
+
* indicator wants. Omit it for everyone in the document.
|
|
15
|
+
*/
|
|
16
|
+
path?: Path
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* By default a draft, its published version, and any release versions count as
|
|
20
|
+
* the same document, which is what document lists want. Set this to compare ids
|
|
21
|
+
* exactly, so that a draft and a release version do not bleed into each other.
|
|
22
|
+
*/
|
|
23
|
+
excludeVersions?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Who else is in a document, flattened to one entry per participant per location
|
|
28
|
+
* so it can be rendered straight against a field.
|
|
29
|
+
*
|
|
30
|
+
* Reading presence never announces anything. Use `useReportPresence` to make the
|
|
31
|
+
* current user visible to others.
|
|
32
|
+
*
|
|
33
|
+
* Prefer this over `usePresence` when you care about one document: `usePresence`
|
|
34
|
+
* returns every participant in the whole project and dataset, leaving the filtering
|
|
35
|
+
* to you.
|
|
36
|
+
*
|
|
37
|
+
* Resolves the document through its perspective exactly as `useReportPresence` does,
|
|
38
|
+
* so reads match writes. Participants are counted by session, so one person in two
|
|
39
|
+
* tabs appears twice.
|
|
40
|
+
*
|
|
41
|
+
* @example Avatars on a document
|
|
42
|
+
* ```tsx
|
|
43
|
+
* const {presence} = usePresenceForDocument({documentId, documentType})
|
|
44
|
+
* return presence.map((p) => <Avatar key={p.sessionId} user={p.user} />)
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example Avatars on a single field
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const {presence} = usePresenceForDocument({
|
|
50
|
+
* documentId,
|
|
51
|
+
* documentType,
|
|
52
|
+
* path: ['title'],
|
|
53
|
+
* excludeVersions: true,
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @beta
|
|
58
|
+
*/
|
|
59
|
+
export function usePresenceForDocument(options: UsePresenceForDocumentOptions): {
|
|
60
|
+
presence: DocumentPresence[]
|
|
61
|
+
} {
|
|
62
|
+
const {path, excludeVersions, ...handle} = options
|
|
63
|
+
|
|
64
|
+
const normalizedOptions = useNormalizedResourceOptions(handle)
|
|
65
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'usePresenceForDocument() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sanityInstance = useSanityInstance()
|
|
72
|
+
trackHookUsage(sanityInstance, 'usePresenceForDocument')
|
|
73
|
+
|
|
74
|
+
const {resource, perspective} = normalizedOptions
|
|
75
|
+
const {documentId, liveEdit} = options
|
|
76
|
+
|
|
77
|
+
// Serialized so a caller passing a fresh `path` array each render does not
|
|
78
|
+
// rebuild the state source every render.
|
|
79
|
+
const pathKey = useMemo(() => JSON.stringify(path ?? null), [path])
|
|
80
|
+
|
|
81
|
+
const source = useMemo(
|
|
82
|
+
() =>
|
|
83
|
+
getDocumentPresence(sanityInstance, {
|
|
84
|
+
...(resource ? {resource} : {}),
|
|
85
|
+
documentId,
|
|
86
|
+
// Forwarded, not resolved here, so this matches what `useReportPresence`
|
|
87
|
+
// sends. Core owns turning a perspective into a specific document id.
|
|
88
|
+
...(perspective ? {perspective} : {}),
|
|
89
|
+
...(liveEdit ? {liveEdit} : {}),
|
|
90
|
+
...(pathKey === 'null' ? {} : {path: JSON.parse(pathKey) as Path}),
|
|
91
|
+
...(excludeVersions === undefined ? {} : {excludeVersions}),
|
|
92
|
+
}),
|
|
93
|
+
[sanityInstance, resource, documentId, perspective, liveEdit, pathKey, excludeVersions],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
|
|
97
|
+
const presence = useSyncExternalStore(
|
|
98
|
+
subscribe,
|
|
99
|
+
() => source.getCurrent(),
|
|
100
|
+
() => source.getCurrent(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return {presence: presence || []}
|
|
104
|
+
}
|