@sanity/sdk-react 2.8.0 → 2.10.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/index.d.ts +232 -47
- package/dist/index.js +468 -263
- package/dist/index.js.map +1 -1
- package/package.json +8 -10
- package/src/_exports/sdk-react.ts +5 -0
- package/src/components/SDKProvider.tsx +36 -8
- package/src/components/SanityApp.tsx +3 -2
- package/src/components/auth/AuthBoundary.tsx +8 -1
- package/src/components/auth/DashboardAccessRequest.tsx +37 -0
- package/src/components/auth/LoginError.test.tsx +191 -5
- package/src/components/auth/LoginError.tsx +100 -56
- package/src/components/errors/ChunkLoadError.test.tsx +59 -0
- package/src/components/errors/ChunkLoadError.tsx +56 -0
- package/src/components/errors/chunkReloadStorage.ts +57 -0
- package/src/context/ResourceProvider.test.tsx +7 -1
- package/src/context/ResourceProvider.tsx +11 -4
- package/src/context/ResourcesContext.tsx +7 -0
- package/src/context/SDKStudioContext.ts +6 -0
- package/src/context/SanityInstanceProvider.test.tsx +100 -0
- package/src/context/SanityInstanceProvider.tsx +71 -0
- package/src/hooks/auth/useVerifyOrgProjects.tsx +13 -6
- package/src/hooks/dashboard/useDispatchIntent.test.ts +8 -6
- package/src/hooks/dashboard/useDispatchIntent.ts +6 -6
- package/src/hooks/dashboard/useWindowTitle.test.ts +213 -0
- package/src/hooks/dashboard/useWindowTitle.ts +112 -0
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +15 -15
- package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +13 -13
- package/src/hooks/document/useApplyDocumentActions.test.ts +113 -10
- package/src/hooks/document/useApplyDocumentActions.ts +99 -3
- package/src/hooks/document/useDocument.ts +22 -6
- package/src/hooks/document/useDocumentEvent.test.tsx +3 -3
- package/src/hooks/document/useDocumentEvent.ts +10 -3
- package/src/hooks/document/useDocumentPermissions.test.tsx +86 -2
- package/src/hooks/document/useDocumentPermissions.ts +22 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +13 -2
- package/src/hooks/document/useDocumentSyncStatus.ts +14 -5
- package/src/hooks/document/useEditDocument.ts +34 -8
- package/src/hooks/documents/useDocuments.ts +11 -6
- package/src/hooks/helpers/useNormalizedResourceOptions.ts +131 -0
- package/src/hooks/helpers/useTrackHookUsage.ts +37 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +11 -8
- package/src/hooks/presence/usePresence.test.tsx +56 -9
- package/src/hooks/presence/usePresence.ts +25 -4
- package/src/hooks/preview/useDocumentPreview.test.tsx +84 -193
- package/src/hooks/preview/useDocumentPreview.tsx +40 -55
- package/src/hooks/projection/useDocumentProjection.ts +8 -6
- package/src/hooks/query/useQuery.ts +12 -9
- package/src/hooks/releases/useActiveReleases.ts +32 -13
- package/src/hooks/releases/usePerspective.ts +26 -14
- package/src/hooks/users/useUser.ts +2 -0
- package/src/hooks/users/useUsers.ts +2 -0
- package/src/context/SourcesContext.tsx +0 -7
- package/src/hooks/helpers/useNormalizedSourceOptions.ts +0 -85
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {type DocumentResource} from '@sanity/sdk'
|
|
2
|
+
import {useContext} from 'react'
|
|
3
|
+
|
|
4
|
+
import {ResourcesContext} from '../../context/ResourcesContext'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Adds React hook support (resourceName resolution) to core types.
|
|
8
|
+
* This wrapper allows hooks to accept `resourceName` as a convenience,
|
|
9
|
+
* which is then resolved to a `DocumentResource` at the React layer.
|
|
10
|
+
* For now, we are trying to avoid resource name resolution in core --
|
|
11
|
+
* functions having resources explicitly passed will reduce complexity.
|
|
12
|
+
*
|
|
13
|
+
* @typeParam T - The core type to extend (must have optional `resource` field)
|
|
14
|
+
* @beta
|
|
15
|
+
*/
|
|
16
|
+
export type WithResourceNameSupport<T extends {resource?: DocumentResource}> = T & {
|
|
17
|
+
/**
|
|
18
|
+
* Optional name of a resource to resolve from context.
|
|
19
|
+
* If provided, will be resolved to a `DocumentResource` via `ResourcesContext`.
|
|
20
|
+
* @beta
|
|
21
|
+
*/
|
|
22
|
+
resourceName?: string
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Use `resourceName` instead.
|
|
25
|
+
* @beta
|
|
26
|
+
*/
|
|
27
|
+
sourceName?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure function that normalizes options by resolving `resourceName` to a `DocumentResource`
|
|
32
|
+
* using the provided resources map. Use this when options are only available at call time
|
|
33
|
+
* (e.g. inside a callback) and you cannot call the {@link useNormalizedResourceOptions} hook.
|
|
34
|
+
*
|
|
35
|
+
* @typeParam T - The options type (must include optional resource field)
|
|
36
|
+
* @param options - Options that may include `resourceName` and/or `resource`
|
|
37
|
+
* @param resources - Map of resource names to DocumentResource (e.g. from ResourcesContext)
|
|
38
|
+
* @returns Normalized options with `resourceName` removed and `resource` resolved
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeResourceOptions<
|
|
42
|
+
T extends {
|
|
43
|
+
resource?: DocumentResource
|
|
44
|
+
resourceName?: string
|
|
45
|
+
source?: DocumentResource
|
|
46
|
+
sourceName?: string
|
|
47
|
+
},
|
|
48
|
+
>(
|
|
49
|
+
options: T,
|
|
50
|
+
resources: Record<string, DocumentResource>,
|
|
51
|
+
): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
|
|
52
|
+
const {resourceName, sourceName, source, ...rest} = options
|
|
53
|
+
|
|
54
|
+
// Coalesce deprecated aliases to their canonical equivalents
|
|
55
|
+
const effectiveResourceName = resourceName ?? sourceName
|
|
56
|
+
const effectiveResource = options.resource ?? source
|
|
57
|
+
|
|
58
|
+
if (!effectiveResourceName && !effectiveResource) {
|
|
59
|
+
return rest as Omit<T, 'resourceName' | 'source' | 'sourceName'>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hasNameKey = Object.hasOwn(options, 'resourceName') || Object.hasOwn(options, 'sourceName')
|
|
63
|
+
const hasResourceKey = Object.hasOwn(options, 'resource') || Object.hasOwn(options, 'source')
|
|
64
|
+
|
|
65
|
+
if (hasNameKey && hasResourceKey) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Resource name ${JSON.stringify(effectiveResourceName)} and resource ${JSON.stringify(effectiveResource)} cannot be used together.`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let resolvedResource: DocumentResource | undefined
|
|
72
|
+
|
|
73
|
+
if (effectiveResource) {
|
|
74
|
+
resolvedResource = effectiveResource
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (effectiveResourceName && !Object.hasOwn(resources, effectiveResourceName)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`There's no resource named ${JSON.stringify(effectiveResourceName)} in context. Please use <ResourceProvider>.`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (effectiveResourceName && resources[effectiveResourceName]) {
|
|
84
|
+
resolvedResource = resources[effectiveResourceName]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...rest,
|
|
89
|
+
resource: resolvedResource,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalizes hook options by resolving `resourceName` to a `DocumentResource`.
|
|
95
|
+
* This hook ensures that options passed to core layer functions only contain
|
|
96
|
+
* `resource` (never `resourceName`), preventing duplicate cache keys and maintaining
|
|
97
|
+
* clean separation between React and core layers.
|
|
98
|
+
*
|
|
99
|
+
* @typeParam T - The options type (must include optional resource field)
|
|
100
|
+
* @param options - Hook options that may include `resourceName` and/or `resource`
|
|
101
|
+
* @returns Normalized options with `resourceName` removed and `resource` resolved
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Resolution priority:
|
|
105
|
+
* 1. If `resourceName` is provided, resolves it via `ResourcesContext` and uses that
|
|
106
|
+
* 2. Otherwise, uses the inline `resource` if provided
|
|
107
|
+
* 3. If neither is provided, returns options without a resource field
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```tsx
|
|
111
|
+
* function useQuery(options: WithResourceNameSupport<QueryOptions>) {
|
|
112
|
+
* const instance = useSanityInstance(options)
|
|
113
|
+
* const normalized = useNormalizedOptions(options)
|
|
114
|
+
* // normalized now has resource but never resourceName
|
|
115
|
+
* const queryKey = getQueryKey(normalized)
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* @beta
|
|
120
|
+
*/
|
|
121
|
+
export function useNormalizedResourceOptions<
|
|
122
|
+
T extends {
|
|
123
|
+
resource?: DocumentResource
|
|
124
|
+
resourceName?: string
|
|
125
|
+
source?: DocumentResource
|
|
126
|
+
sourceName?: string
|
|
127
|
+
},
|
|
128
|
+
>(options: T): Omit<T, 'resourceName' | 'source' | 'sourceName'> {
|
|
129
|
+
const resources = useContext(ResourcesContext)
|
|
130
|
+
return normalizeResourceOptions(options, resources)
|
|
131
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {trackHookMounted} from '@sanity/sdk/_internal'
|
|
3
|
+
import {useRef} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks the first usage of a named hook per SDK session.
|
|
9
|
+
* If the telemetry manager hasn't initialized yet, the hook
|
|
10
|
+
* name is buffered and flushed when it becomes available.
|
|
11
|
+
*
|
|
12
|
+
* Uses a ref to ensure the tracking call only happens once per
|
|
13
|
+
* component mount, avoiding repeated WeakMap lookups on re-renders.
|
|
14
|
+
*
|
|
15
|
+
* Call at the top of any public hook whose adoption we want to measure.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function useTrackHookUsage(hookName: string): void {
|
|
20
|
+
const instance = useSanityInstance()
|
|
21
|
+
const tracked = useRef<true | null>(null)
|
|
22
|
+
if (tracked.current === null) {
|
|
23
|
+
tracked.current = true
|
|
24
|
+
trackHookMounted(instance, hookName)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Non-hook variant for tracking hook usage when an instance is already
|
|
30
|
+
* available (avoids an extra `useSanityInstance` call in hooks that
|
|
31
|
+
* already have the instance).
|
|
32
|
+
*
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export function trackHookUsage(instance: SanityInstance, hookName: string): void {
|
|
36
|
+
trackHookMounted(instance, hookName)
|
|
37
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk'
|
|
2
2
|
import {type SortOrderingItem} from '@sanity/types'
|
|
3
|
-
import {
|
|
4
|
-
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
3
|
+
import {useCallback, useMemo, useState} from 'react'
|
|
5
4
|
|
|
6
5
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
6
|
+
import {useTrackHookUsage} from '../helpers/useTrackHookUsage'
|
|
7
7
|
import {useQuery} from '../query/useQuery'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -238,14 +238,16 @@ export function usePaginatedDocuments<
|
|
|
238
238
|
TDataset,
|
|
239
239
|
TProjectId
|
|
240
240
|
> {
|
|
241
|
+
useTrackHookUsage('usePaginatedDocuments')
|
|
241
242
|
const instance = useSanityInstance(options)
|
|
242
243
|
const [pageIndex, setPageIndex] = useState(0)
|
|
243
244
|
const key = JSON.stringify({filter, search, params, orderings, pageSize})
|
|
244
|
-
// Reset
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
// Reset pageIndex to 0 whenever any query parameter changes.
|
|
246
|
+
const [prevKey, setPrevKey] = useState(key)
|
|
247
|
+
if (prevKey !== key) {
|
|
248
|
+
setPrevKey(key)
|
|
247
249
|
setPageIndex(0)
|
|
248
|
-
}
|
|
250
|
+
}
|
|
249
251
|
|
|
250
252
|
const startIndex = pageIndex * pageSize
|
|
251
253
|
const endIndex = (pageIndex + 1) * pageSize
|
|
@@ -301,8 +303,9 @@ export function usePaginatedDocuments<
|
|
|
301
303
|
...params,
|
|
302
304
|
__types: documentTypes,
|
|
303
305
|
__handle: {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
+
projectId: options.projectId ?? instance.config.projectId,
|
|
307
|
+
dataset: options.dataset ?? instance.config.dataset,
|
|
308
|
+
perspective: options.perspective ?? instance.config.perspective,
|
|
306
309
|
},
|
|
307
310
|
},
|
|
308
311
|
})
|
|
@@ -6,14 +6,17 @@ import {describe, expect, it, vi} from 'vitest'
|
|
|
6
6
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
7
|
import {usePresence} from './usePresence'
|
|
8
8
|
|
|
9
|
-
vi.mock('@sanity/sdk', () =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import('@sanity/sdk')>()
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
getPresence: vi.fn(),
|
|
14
|
+
createSanityInstance: vi.fn(() => ({
|
|
15
|
+
isDisposed: vi.fn(() => false),
|
|
16
|
+
dispose: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
}
|
|
19
|
+
})
|
|
17
20
|
|
|
18
21
|
describe('usePresence', () => {
|
|
19
22
|
it('should return presence locations and update when the store changes', () => {
|
|
@@ -59,7 +62,10 @@ describe('usePresence', () => {
|
|
|
59
62
|
|
|
60
63
|
const {result, unmount} = renderHook(() => usePresence(), {
|
|
61
64
|
wrapper: ({children}) => (
|
|
62
|
-
<ResourceProvider
|
|
65
|
+
<ResourceProvider
|
|
66
|
+
resource={{projectId: 'test-project', dataset: 'test-dataset'}}
|
|
67
|
+
fallback={null}
|
|
68
|
+
>
|
|
63
69
|
{children}
|
|
64
70
|
</ResourceProvider>
|
|
65
71
|
),
|
|
@@ -80,4 +86,45 @@ describe('usePresence', () => {
|
|
|
80
86
|
expect(result.current.locations).toEqual(updatedLocations)
|
|
81
87
|
unmount()
|
|
82
88
|
})
|
|
89
|
+
|
|
90
|
+
it('should throw an error when used with a media library resource', () => {
|
|
91
|
+
expect(() => {
|
|
92
|
+
renderHook(() => usePresence({resource: {mediaLibraryId: 'ml123'}}), {
|
|
93
|
+
wrapper: ({children}) => (
|
|
94
|
+
<ResourceProvider
|
|
95
|
+
resource={{projectId: 'test-project', dataset: 'test-dataset'}}
|
|
96
|
+
fallback={null}
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
</ResourceProvider>
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
}).toThrow('usePresence() does not support media library resources')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should work with a dataset resource', () => {
|
|
106
|
+
const mockPresenceSource = {
|
|
107
|
+
getCurrent: vi.fn().mockReturnValue([]),
|
|
108
|
+
subscribe: vi.fn(() => () => {}),
|
|
109
|
+
observable: NEVER,
|
|
110
|
+
}
|
|
111
|
+
vi.mocked(getPresence).mockReturnValue(mockPresenceSource)
|
|
112
|
+
|
|
113
|
+
const {result, unmount} = renderHook(
|
|
114
|
+
() => usePresence({resource: {projectId: 'test-project', dataset: 'test-dataset'}}),
|
|
115
|
+
{
|
|
116
|
+
wrapper: ({children}) => (
|
|
117
|
+
<ResourceProvider
|
|
118
|
+
resource={{projectId: 'test-project', dataset: 'test-dataset'}}
|
|
119
|
+
fallback={null}
|
|
120
|
+
>
|
|
121
|
+
{children}
|
|
122
|
+
</ResourceProvider>
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(result.current.locations).toEqual([])
|
|
128
|
+
unmount()
|
|
129
|
+
})
|
|
83
130
|
})
|
|
@@ -1,17 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type DatasetHandle,
|
|
3
|
+
getPresence,
|
|
4
|
+
isMediaLibraryResource,
|
|
5
|
+
type UserPresence,
|
|
6
|
+
} from '@sanity/sdk'
|
|
2
7
|
import {useCallback, useMemo, useSyncExternalStore} from 'react'
|
|
3
8
|
|
|
4
9
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
10
|
+
import {
|
|
11
|
+
useNormalizedResourceOptions,
|
|
12
|
+
type WithResourceNameSupport,
|
|
13
|
+
} from '../helpers/useNormalizedResourceOptions'
|
|
14
|
+
import {trackHookUsage} from '../helpers/useTrackHookUsage'
|
|
5
15
|
|
|
6
16
|
/**
|
|
7
|
-
* A hook for subscribing to presence information for the current project.
|
|
17
|
+
* A hook for subscribing to presence information for the current project or Canvas.
|
|
8
18
|
* @public
|
|
9
19
|
*/
|
|
10
|
-
export function usePresence(): {
|
|
20
|
+
export function usePresence(options: WithResourceNameSupport<DatasetHandle> = {}): {
|
|
11
21
|
locations: UserPresence[]
|
|
12
22
|
} {
|
|
23
|
+
const normalizedOptions = useNormalizedResourceOptions(options)
|
|
24
|
+
if (normalizedOptions.resource && isMediaLibraryResource(normalizedOptions.resource)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'usePresence() does not support media library resources. Presence tracking requires a canvas or dataset resource.',
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
const sanityInstance = useSanityInstance()
|
|
14
|
-
|
|
31
|
+
trackHookUsage(sanityInstance, 'usePresence')
|
|
32
|
+
const source = useMemo(
|
|
33
|
+
() => getPresence(sanityInstance, normalizedOptions),
|
|
34
|
+
[sanityInstance, normalizedOptions],
|
|
35
|
+
)
|
|
15
36
|
const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
|
|
16
37
|
const locations = useSyncExternalStore(
|
|
17
38
|
subscribe,
|
|
@@ -1,233 +1,124 @@
|
|
|
1
|
-
import {type DocumentHandle,
|
|
2
|
-
import {
|
|
3
|
-
import {useRef} from 'react'
|
|
4
|
-
import {type Mock} from 'vitest'
|
|
1
|
+
import {type DocumentHandle, type PreviewQueryResult} from '@sanity/sdk'
|
|
2
|
+
import {beforeEach, describe, expect, test, vi} from 'vitest'
|
|
5
3
|
|
|
6
|
-
import {
|
|
4
|
+
import {render, renderHook, screen} from '../../../test/test-utils'
|
|
5
|
+
import {useDocumentProjection} from '../projection/useDocumentProjection'
|
|
7
6
|
import {useDocumentPreview} from './useDocumentPreview'
|
|
8
7
|
|
|
9
|
-
// Mock
|
|
10
|
-
|
|
11
|
-
let intersectionObserverCallback: (entries: IntersectionObserverEntry[]) => void
|
|
12
|
-
|
|
13
|
-
beforeAll(() => {
|
|
14
|
-
vi.stubGlobal(
|
|
15
|
-
'IntersectionObserver',
|
|
16
|
-
class {
|
|
17
|
-
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
|
|
18
|
-
intersectionObserverCallback = callback
|
|
19
|
-
mockIntersectionObserver(callback)
|
|
20
|
-
}
|
|
21
|
-
observe = vi.fn()
|
|
22
|
-
disconnect = vi.fn()
|
|
23
|
-
},
|
|
24
|
-
)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
// Mock the preview store
|
|
28
|
-
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
29
|
-
const actual = await importOriginal<typeof import('@sanity/sdk')>()
|
|
30
|
-
const getCurrent = vi.fn()
|
|
31
|
-
const subscribe = vi.fn()
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
...actual,
|
|
35
|
-
resolvePreview: vi.fn(),
|
|
36
|
-
getPreviewState: vi.fn().mockReturnValue({getCurrent, subscribe}),
|
|
37
|
-
}
|
|
38
|
-
})
|
|
8
|
+
// Mock useDocumentProjection since useDocumentPreview now uses it internally
|
|
9
|
+
vi.mock('../projection/useDocumentProjection')
|
|
39
10
|
|
|
40
11
|
const mockDocument: DocumentHandle = {
|
|
41
12
|
documentId: 'doc1',
|
|
42
13
|
documentType: 'exampleType',
|
|
43
14
|
}
|
|
44
15
|
|
|
45
|
-
function TestComponent(docHandle: DocumentHandle) {
|
|
46
|
-
const ref = useRef(null)
|
|
47
|
-
const {data, isPending} = useDocumentPreview({...docHandle, ref})
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<div ref={ref}>
|
|
51
|
-
<h1>{data?.title}</h1>
|
|
52
|
-
<p>{data?.subtitle}</p>
|
|
53
|
-
{isPending && <div>Pending...</div>}
|
|
54
|
-
</div>
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
16
|
describe('useDocumentPreview', () => {
|
|
59
|
-
let getCurrent: Mock
|
|
60
|
-
let subscribe: Mock
|
|
61
|
-
|
|
62
17
|
beforeEach(() => {
|
|
63
|
-
|
|
64
|
-
getCurrent = getPreviewState().getCurrent as Mock
|
|
65
|
-
// @ts-expect-error mock does not need param
|
|
66
|
-
subscribe = getPreviewState().subscribe as Mock
|
|
67
|
-
|
|
68
|
-
// Reset all mocks between tests
|
|
69
|
-
getCurrent.mockReset()
|
|
70
|
-
subscribe.mockReset()
|
|
71
|
-
mockIntersectionObserver.mockReset()
|
|
18
|
+
vi.clearAllMocks()
|
|
72
19
|
})
|
|
73
20
|
|
|
74
|
-
test('
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
render(
|
|
84
|
-
<ResourceProvider fallback={<div>Loading...</div>}>
|
|
85
|
-
<TestComponent {...mockDocument} />
|
|
86
|
-
</ResourceProvider>,
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
// Initially, element is not intersecting
|
|
90
|
-
expect(screen.getByText('Initial Title')).toBeInTheDocument()
|
|
91
|
-
expect(subscribe).not.toHaveBeenCalled()
|
|
92
|
-
|
|
93
|
-
// Simulate element becoming visible
|
|
94
|
-
await act(async () => {
|
|
95
|
-
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
// After element becomes visible, events subscription should be active
|
|
99
|
-
expect(subscribe).toHaveBeenCalled()
|
|
100
|
-
expect(eventsUnsubscribe).not.toHaveBeenCalled()
|
|
21
|
+
test('transforms projection result to preview format', () => {
|
|
22
|
+
const mockProjectionResult: PreviewQueryResult = {
|
|
23
|
+
_id: 'doc1',
|
|
24
|
+
_type: 'exampleType',
|
|
25
|
+
_updatedAt: '2024-01-01',
|
|
26
|
+
titleCandidates: {title: 'Test Title'},
|
|
27
|
+
subtitleCandidates: {description: 'Test Description'},
|
|
28
|
+
media: null,
|
|
29
|
+
}
|
|
101
30
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
31
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
32
|
+
data: mockProjectionResult,
|
|
33
|
+
isPending: false,
|
|
105
34
|
})
|
|
106
35
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
expect(
|
|
36
|
+
const {result} = renderHook(() => useDocumentPreview(mockDocument))
|
|
37
|
+
const {data, isPending} = result.current
|
|
38
|
+
expect(data.title).toBe('Test Title')
|
|
39
|
+
expect(data.subtitle).toBe('Test Description')
|
|
40
|
+
expect(isPending).toBe(false)
|
|
110
41
|
})
|
|
111
42
|
|
|
112
|
-
test
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
43
|
+
test('handles pending state', () => {
|
|
44
|
+
const mockProjectionResult: PreviewQueryResult = {
|
|
45
|
+
_id: 'doc1',
|
|
46
|
+
_type: 'exampleType',
|
|
47
|
+
_updatedAt: '2024-01-01',
|
|
48
|
+
titleCandidates: {title: 'Loading Title'},
|
|
49
|
+
subtitleCandidates: {},
|
|
118
50
|
media: null,
|
|
119
|
-
}
|
|
120
|
-
;(resolvePreview as Mock).mockReturnValueOnce(resolvePromise)
|
|
51
|
+
}
|
|
121
52
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return vi.fn()
|
|
53
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
54
|
+
data: mockProjectionResult,
|
|
55
|
+
isPending: true,
|
|
126
56
|
})
|
|
127
57
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
58
|
+
function TestComponent() {
|
|
59
|
+
const {data, isPending} = useDocumentPreview(mockDocument)
|
|
60
|
+
return (
|
|
61
|
+
<div>
|
|
62
|
+
<h1>{data.title}</h1>
|
|
63
|
+
{isPending && <div>Pending...</div>}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
133
67
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Simulate element becoming visible
|
|
137
|
-
await act(async () => {
|
|
138
|
-
intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
|
|
139
|
-
await resolvePromise
|
|
140
|
-
getCurrent.mockReturnValue({
|
|
141
|
-
data: {title: 'Resolved Title', subtitle: 'Resolved Subtitle'},
|
|
142
|
-
isPending: false,
|
|
143
|
-
})
|
|
144
|
-
subscriber?.()
|
|
145
|
-
})
|
|
68
|
+
render(<TestComponent />)
|
|
146
69
|
|
|
147
|
-
expect(screen.getByText('
|
|
148
|
-
expect(screen.getByText('
|
|
70
|
+
expect(screen.getByText('Loading Title')).toBeInTheDocument()
|
|
71
|
+
expect(screen.getByText('Pending...')).toBeInTheDocument()
|
|
149
72
|
})
|
|
150
73
|
|
|
151
|
-
test('
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
74
|
+
test('uses fallback title when no candidates exist', () => {
|
|
75
|
+
const mockProjectionResult: PreviewQueryResult = {
|
|
76
|
+
_id: 'doc1',
|
|
77
|
+
_type: 'article',
|
|
78
|
+
_updatedAt: '2024-01-01',
|
|
79
|
+
titleCandidates: {},
|
|
80
|
+
subtitleCandidates: {},
|
|
81
|
+
media: null,
|
|
82
|
+
}
|
|
156
83
|
|
|
157
|
-
|
|
158
|
-
data:
|
|
84
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
85
|
+
data: mockProjectionResult,
|
|
159
86
|
isPending: false,
|
|
160
87
|
})
|
|
161
|
-
subscribe.mockImplementation(() => vi.fn())
|
|
162
88
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
</ResourceProvider>,
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
expect(screen.getByText('Fallback Title')).toBeInTheDocument()
|
|
170
|
-
|
|
171
|
-
// Restore IntersectionObserver
|
|
172
|
-
window.IntersectionObserver = originalIntersectionObserver
|
|
89
|
+
const {result} = renderHook(() => useDocumentPreview(mockDocument))
|
|
90
|
+
const {data} = result.current
|
|
91
|
+
expect(data.title).toBe('article: doc1')
|
|
173
92
|
})
|
|
174
93
|
|
|
175
|
-
test('
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
function NoRefComponent(docHandle: DocumentHandle) {
|
|
184
|
-
const {data} = useDocumentPreview(docHandle) // No ref provided
|
|
185
|
-
return (
|
|
186
|
-
<div>
|
|
187
|
-
<h1>{data?.title}</h1>
|
|
188
|
-
<p>{data?.subtitle}</p>
|
|
189
|
-
</div>
|
|
190
|
-
)
|
|
94
|
+
test('passes ref to useDocumentProjection', () => {
|
|
95
|
+
const mockProjectionResult: PreviewQueryResult = {
|
|
96
|
+
_id: 'doc1',
|
|
97
|
+
_type: 'exampleType',
|
|
98
|
+
_updatedAt: '2024-01-01',
|
|
99
|
+
titleCandidates: {title: 'Title'},
|
|
100
|
+
subtitleCandidates: {},
|
|
101
|
+
media: null,
|
|
191
102
|
}
|
|
192
103
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
<NoRefComponent {...mockDocument} />
|
|
196
|
-
</ResourceProvider>,
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
// Should subscribe immediately without waiting for intersection
|
|
200
|
-
expect(subscribe).toHaveBeenCalled()
|
|
201
|
-
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
test('it subscribes immediately when ref.current is not an HTML element', async () => {
|
|
205
|
-
getCurrent.mockReturnValue({
|
|
206
|
-
data: {title: 'Title', subtitle: 'Subtitle'},
|
|
104
|
+
vi.mocked(useDocumentProjection).mockReturnValue({
|
|
105
|
+
data: mockProjectionResult,
|
|
207
106
|
isPending: false,
|
|
208
107
|
})
|
|
209
|
-
const eventsUnsubscribe = vi.fn()
|
|
210
|
-
subscribe.mockImplementation(() => eventsUnsubscribe)
|
|
211
|
-
|
|
212
|
-
function NonHtmlRefComponent(docHandle: DocumentHandle) {
|
|
213
|
-
const ref = useRef({}) // ref.current is not an HTML element
|
|
214
|
-
const {data} = useDocumentPreview({...docHandle, ref})
|
|
215
|
-
return (
|
|
216
|
-
<div>
|
|
217
|
-
<h1>{data?.title}</h1>
|
|
218
|
-
<p>{data?.subtitle}</p>
|
|
219
|
-
</div>
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
108
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
109
|
+
const ref = {current: null}
|
|
110
|
+
const {result} = renderHook(() => useDocumentPreview({...mockDocument, ref}))
|
|
111
|
+
const {data} = result.current
|
|
112
|
+
expect(data.title).toBe('Title')
|
|
113
|
+
|
|
114
|
+
// Verify useDocumentProjection was called with the ref and preview projection
|
|
115
|
+
expect(useDocumentProjection).toHaveBeenCalledWith(
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
documentId: 'doc1',
|
|
118
|
+
documentType: 'exampleType',
|
|
119
|
+
ref: ref,
|
|
120
|
+
projection: expect.any(String),
|
|
121
|
+
}),
|
|
227
122
|
)
|
|
228
|
-
|
|
229
|
-
// Should subscribe immediately without waiting for intersection
|
|
230
|
-
expect(subscribe).toHaveBeenCalled()
|
|
231
|
-
expect(screen.getByText('Title')).toBeInTheDocument()
|
|
232
123
|
})
|
|
233
124
|
})
|