@sanity/sdk-react 0.0.0-alpha.21 → 0.0.0-alpha.23
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 +502 -3460
- package/dist/index.js +400 -465
- package/dist/index.js.map +1 -1
- package/package.json +17 -15
- package/src/_exports/index.ts +4 -5
- package/src/components/SDKProvider.test.tsx +78 -54
- package/src/components/SDKProvider.tsx +31 -26
- package/src/components/SanityApp.test.tsx +121 -15
- package/src/components/SanityApp.tsx +26 -15
- package/src/components/auth/AuthBoundary.test.tsx +32 -14
- package/src/components/auth/AuthBoundary.tsx +53 -23
- package/src/components/auth/LoginCallback.test.tsx +19 -6
- package/src/components/auth/LoginCallback.tsx +2 -11
- package/src/components/auth/LoginError.test.tsx +12 -4
- package/src/components/auth/LoginError.tsx +13 -21
- package/src/components/auth/LoginFooter.test.tsx +7 -3
- package/src/context/ResourceProvider.test.tsx +157 -0
- package/src/context/ResourceProvider.tsx +111 -0
- package/src/context/SanityInstanceContext.ts +1 -1
- package/src/hooks/auth/useLoginUrl.tsx +14 -0
- package/src/hooks/client/useClient.ts +2 -1
- package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
- package/src/hooks/comlink/useManageFavorite.ts +37 -13
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
- package/src/hooks/context/useSanityInstance.test.tsx +157 -15
- package/src/hooks/context/useSanityInstance.ts +66 -26
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
- package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
- package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
- package/src/hooks/datasets/useDatasets.ts +15 -4
- package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
- package/src/hooks/document/useApplyDocumentActions.ts +6 -31
- package/src/hooks/document/useDocument.test.ts +2 -2
- package/src/hooks/document/useDocument.ts +40 -19
- package/src/hooks/document/useDocumentEvent.test.ts +2 -3
- package/src/hooks/document/useDocumentEvent.ts +7 -11
- package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
- package/src/hooks/document/useDocumentPermissions.ts +31 -23
- package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
- package/src/hooks/document/useEditDocument.test.ts +2 -3
- package/src/hooks/document/useEditDocument.ts +43 -29
- package/src/hooks/documents/useDocuments.test.tsx +30 -3
- package/src/hooks/documents/useDocuments.ts +20 -7
- package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
- package/src/hooks/helpers/createCallbackHook.tsx +2 -3
- package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
- package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
- package/src/hooks/preview/usePreview.test.tsx +66 -7
- package/src/hooks/preview/usePreview.tsx +17 -12
- package/src/hooks/projection/useProjection.test.tsx +68 -3
- package/src/hooks/projection/useProjection.ts +21 -24
- package/src/hooks/projects/useProject.ts +7 -4
- package/src/hooks/query/useQuery.ts +32 -14
- package/src/hooks/users/useUsers.test.tsx +330 -0
- package/src/hooks/users/useUsers.ts +65 -52
- package/src/components/Login/LoginLinks.test.tsx +0 -90
- package/src/components/Login/LoginLinks.tsx +0 -58
- package/src/components/auth/Login.test.tsx +0 -27
- package/src/components/auth/Login.tsx +0 -39
- package/src/components/auth/LoginLayout.test.tsx +0 -19
- package/src/components/auth/LoginLayout.tsx +0 -69
- package/src/components/auth/authTestHelpers.tsx +0 -11
- package/src/context/SanityProvider.test.tsx +0 -25
- package/src/context/SanityProvider.tsx +0 -50
- package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
- package/src/hooks/auth/useLoginUrls.tsx +0 -52
- package/src/hooks/users/useUsers.test.ts +0 -163
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
resolveProjection,
|
|
5
5
|
type ValidProjection,
|
|
6
6
|
} from '@sanity/sdk'
|
|
7
|
-
import {useCallback,
|
|
7
|
+
import {useCallback, useSyncExternalStore} from 'react'
|
|
8
8
|
import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
|
|
9
9
|
|
|
10
10
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
@@ -13,18 +13,17 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
13
13
|
* @public
|
|
14
14
|
* @category Types
|
|
15
15
|
*/
|
|
16
|
-
export interface UseProjectionOptions {
|
|
17
|
-
document: DocumentHandle
|
|
18
|
-
projection: ValidProjection
|
|
16
|
+
export interface UseProjectionOptions extends DocumentHandle {
|
|
19
17
|
ref?: React.RefObject<unknown>
|
|
18
|
+
projection: ValidProjection
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* @public
|
|
24
23
|
* @category Types
|
|
25
24
|
*/
|
|
26
|
-
export interface UseProjectionResults<
|
|
27
|
-
data:
|
|
25
|
+
export interface UseProjectionResults<TData extends object> {
|
|
26
|
+
data: TData
|
|
28
27
|
isPending: boolean
|
|
29
28
|
}
|
|
30
29
|
|
|
@@ -87,24 +86,26 @@ export interface UseProjectionResults<TResult extends object> {
|
|
|
87
86
|
* )
|
|
88
87
|
* ```
|
|
89
88
|
*/
|
|
90
|
-
export function useProjection<
|
|
91
|
-
document: {_id, _type},
|
|
92
|
-
projection,
|
|
89
|
+
export function useProjection<TData extends object>({
|
|
93
90
|
ref,
|
|
94
|
-
|
|
91
|
+
projection,
|
|
92
|
+
...docHandle
|
|
93
|
+
}: UseProjectionOptions): UseProjectionResults<TData> {
|
|
95
94
|
const instance = useSanityInstance()
|
|
95
|
+
const stateSource = getProjectionState<TData>(instance, {...docHandle, projection})
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
97
|
+
if (stateSource.getCurrent().data === null) {
|
|
98
|
+
throw resolveProjection(instance, {...docHandle, projection})
|
|
99
|
+
}
|
|
101
100
|
|
|
102
101
|
// Create subscribe function for useSyncExternalStore
|
|
103
102
|
const subscribe = useCallback(
|
|
104
103
|
(onStoreChanged: () => void) => {
|
|
105
104
|
const subscription = new Observable<boolean>((observer) => {
|
|
106
|
-
//
|
|
105
|
+
// For environments that don't have an intersection observer (e.g. server-side),
|
|
106
|
+
// we pass true to always subscribe since we can't detect visibility
|
|
107
107
|
if (typeof IntersectionObserver === 'undefined' || typeof HTMLElement === 'undefined') {
|
|
108
|
+
observer.next(true)
|
|
108
109
|
return
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -114,6 +115,10 @@ export function useProjection<TResult extends object>({
|
|
|
114
115
|
)
|
|
115
116
|
if (ref?.current && ref.current instanceof HTMLElement) {
|
|
116
117
|
intersectionObserver.observe(ref.current)
|
|
118
|
+
} else {
|
|
119
|
+
// If no ref is provided or ref.current isn't an HTML element,
|
|
120
|
+
// pass true to always subscribe since we can't track visibility
|
|
121
|
+
observer.next(true)
|
|
117
122
|
}
|
|
118
123
|
return () => intersectionObserver.disconnect()
|
|
119
124
|
})
|
|
@@ -135,13 +140,5 @@ export function useProjection<TResult extends object>({
|
|
|
135
140
|
[stateSource, ref],
|
|
136
141
|
)
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
const getSnapshot = useCallback(() => {
|
|
140
|
-
const currentState = stateSource.getCurrent()
|
|
141
|
-
if (currentState.data === null)
|
|
142
|
-
throw resolveProjection(instance, {document: {_id, _type}, projection})
|
|
143
|
-
return currentState as UseProjectionResults<TResult>
|
|
144
|
-
}, [_id, _type, projection, instance, stateSource])
|
|
145
|
-
|
|
146
|
-
return useSyncExternalStore(subscribe, getSnapshot)
|
|
143
|
+
return useSyncExternalStore(subscribe, stateSource.getCurrent) as UseProjectionResults<TData>
|
|
147
144
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getProjectState,
|
|
3
|
+
type ProjectHandle,
|
|
3
4
|
resolveProject,
|
|
4
5
|
type SanityInstance,
|
|
5
6
|
type SanityProject,
|
|
6
7
|
type StateSource,
|
|
7
8
|
} from '@sanity/sdk'
|
|
9
|
+
import {identity} from 'rxjs'
|
|
8
10
|
|
|
9
11
|
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
10
12
|
|
|
@@ -29,7 +31,7 @@ type UseProject = {
|
|
|
29
31
|
* }
|
|
30
32
|
* ```
|
|
31
33
|
*/
|
|
32
|
-
(
|
|
34
|
+
(projectHandle?: ProjectHandle): SanityProject
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -40,9 +42,10 @@ export const useProject: UseProject = createStateSourceHook({
|
|
|
40
42
|
// remove `undefined` since we're suspending when that is the case
|
|
41
43
|
getState: getProjectState as (
|
|
42
44
|
instance: SanityInstance,
|
|
43
|
-
|
|
45
|
+
projectHandle?: ProjectHandle,
|
|
44
46
|
) => StateSource<SanityProject>,
|
|
45
|
-
shouldSuspend: (instance,
|
|
46
|
-
getProjectState(instance,
|
|
47
|
+
shouldSuspend: (instance, projectHandle) =>
|
|
48
|
+
getProjectState(instance, projectHandle).getCurrent() === undefined,
|
|
47
49
|
suspender: resolveProject,
|
|
50
|
+
getConfig: identity,
|
|
48
51
|
})
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type QueryOptions,
|
|
6
6
|
resolveQuery,
|
|
7
7
|
} from '@sanity/sdk'
|
|
8
|
-
import {useEffect, useMemo, useState, useSyncExternalStore, useTransition} from 'react'
|
|
8
|
+
import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
|
|
9
9
|
|
|
10
10
|
import {useSanityInstance} from '../context/useSanityInstance'
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
13
13
|
* Executes GROQ queries against a Sanity dataset.
|
|
14
14
|
*
|
|
15
15
|
* This hook provides a convenient way to fetch and subscribe to real-time updates
|
|
16
|
-
* for your Sanity content. Changes made to the dataset
|
|
16
|
+
* for your Sanity content. Changes made to the dataset's content will trigger
|
|
17
17
|
* automatic updates.
|
|
18
18
|
*
|
|
19
19
|
* @remarks
|
|
@@ -23,7 +23,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
23
23
|
* @beta
|
|
24
24
|
* @category GROQ
|
|
25
25
|
* @param query - GROQ query string to execute
|
|
26
|
-
* @param options - Optional configuration for the query
|
|
26
|
+
* @param options - Optional configuration for the query, including projectId and dataset
|
|
27
27
|
* @returns Object containing the query result and a pending state flag
|
|
28
28
|
*
|
|
29
29
|
* @example Basic usage
|
|
@@ -39,6 +39,15 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
39
39
|
* })
|
|
40
40
|
* ```
|
|
41
41
|
*
|
|
42
|
+
* @example Query from a specific project/dataset
|
|
43
|
+
* ```tsx
|
|
44
|
+
* // Specify which project and dataset to query
|
|
45
|
+
* const {data} = useQuery<Movie[]>('*[_type == "movie"]', {
|
|
46
|
+
* projectId: 'abc123',
|
|
47
|
+
* dataset: 'production'
|
|
48
|
+
* })
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
42
51
|
* @example With a loading state for transitions
|
|
43
52
|
* ```tsx
|
|
44
53
|
* const {data, isPending} = useQuery<Movie[]>('*[_type == "movie"]')
|
|
@@ -54,7 +63,8 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
54
63
|
*
|
|
55
64
|
*/
|
|
56
65
|
export function useQuery<T>(query: string, options?: QueryOptions): {data: T; isPending: boolean} {
|
|
57
|
-
const instance = useSanityInstance(options
|
|
66
|
+
const instance = useSanityInstance(options)
|
|
67
|
+
|
|
58
68
|
// Use React's useTransition to avoid UI jank when queries change
|
|
59
69
|
const [isPending, startTransition] = useTransition()
|
|
60
70
|
|
|
@@ -66,7 +76,7 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
|
|
|
66
76
|
const deferred = useMemo(() => parseQueryKey(deferredQueryKey), [deferredQueryKey])
|
|
67
77
|
|
|
68
78
|
// Create an AbortController to cancel in-flight requests when needed
|
|
69
|
-
const
|
|
79
|
+
const ref = useRef<AbortController>(new AbortController())
|
|
70
80
|
|
|
71
81
|
// When the query or options change, start a transition to update the query
|
|
72
82
|
useEffect(() => {
|
|
@@ -74,14 +84,14 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
|
|
|
74
84
|
|
|
75
85
|
startTransition(() => {
|
|
76
86
|
// Abort any in-flight requests for the previous query
|
|
77
|
-
if (ref && !ref.signal.aborted) {
|
|
78
|
-
ref.abort()
|
|
79
|
-
|
|
87
|
+
if (ref && !ref.current.signal.aborted) {
|
|
88
|
+
ref.current.abort()
|
|
89
|
+
ref.current = new AbortController()
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
setDeferredQueryKey(queryKey)
|
|
83
93
|
})
|
|
84
|
-
}, [deferredQueryKey, queryKey
|
|
94
|
+
}, [deferredQueryKey, queryKey])
|
|
85
95
|
|
|
86
96
|
// Get the state source for this query from the query store
|
|
87
97
|
const {getCurrent, subscribe} = useMemo(
|
|
@@ -89,15 +99,23 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
|
|
|
89
99
|
[instance, deferred],
|
|
90
100
|
)
|
|
91
101
|
|
|
92
|
-
// If data isn't available yet, suspend rendering
|
|
93
|
-
// This is the React Suspense integration - throwing a promise
|
|
94
|
-
// will cause React to show the nearest Suspense fallback
|
|
102
|
+
// If data isn't available yet, suspend rendering
|
|
95
103
|
if (getCurrent() === undefined) {
|
|
96
|
-
|
|
104
|
+
// Normally, reading from a mutable ref during render can be risky in concurrent mode.
|
|
105
|
+
// However, it is safe here because:
|
|
106
|
+
// 1. React guarantees that while the component is suspended (via throwing a promise),
|
|
107
|
+
// no effects or state updates occur during that render pass.
|
|
108
|
+
// 2. We immediately capture the current abort signal in a local variable (currentSignal).
|
|
109
|
+
// 3. Even if a background render updates ref.current (for example, due to a query change),
|
|
110
|
+
// the captured signal remains unchanged for this suspended render.
|
|
111
|
+
// Thus, the promise thrown here uses a stable abort signal, ensuring correct behavior.
|
|
112
|
+
const currentSignal = ref.current.signal
|
|
113
|
+
// eslint-disable-next-line react-compiler/react-compiler
|
|
114
|
+
throw resolveQuery(instance, deferred.query, {...deferred.options, signal: currentSignal})
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
// Subscribe to updates and get the current data
|
|
100
118
|
// useSyncExternalStore ensures the component re-renders when the data changes
|
|
101
119
|
const data = useSyncExternalStore(subscribe, getCurrent) as T
|
|
102
|
-
return {data, isPending}
|
|
120
|
+
return useMemo(() => ({data, isPending}), [data, isPending])
|
|
103
121
|
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getUsersState,
|
|
3
|
+
loadMoreUsers,
|
|
4
|
+
resolveUsers,
|
|
5
|
+
type SanityUser,
|
|
6
|
+
type StateSource,
|
|
7
|
+
type UserProfile,
|
|
8
|
+
} from '@sanity/sdk'
|
|
9
|
+
import {act, fireEvent, render, screen} from '@testing-library/react'
|
|
10
|
+
import {Suspense, useState} from 'react'
|
|
11
|
+
import {type Observable, Subject} from 'rxjs'
|
|
12
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
13
|
+
|
|
14
|
+
import {useUsers} from './useUsers'
|
|
15
|
+
|
|
16
|
+
// Mock the functions from '@sanity/sdk'
|
|
17
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
18
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
19
|
+
return {
|
|
20
|
+
...original,
|
|
21
|
+
getUsersState: vi.fn(),
|
|
22
|
+
resolveUsers: vi.fn(),
|
|
23
|
+
loadMoreUsers: vi.fn(),
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Mock the Sanity instance hook to return a dummy instance
|
|
28
|
+
vi.mock('../context/useSanityInstance', () => ({
|
|
29
|
+
useSanityInstance: vi.fn().mockReturnValue({config: {projectId: 'p'}}),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
describe('useUsers', () => {
|
|
33
|
+
// Create mock user profiles with all required fields
|
|
34
|
+
const mockUserProfile1: UserProfile = {
|
|
35
|
+
id: 'profile1',
|
|
36
|
+
displayName: 'User One',
|
|
37
|
+
email: 'user1@example.com',
|
|
38
|
+
imageUrl: 'https://example.com/user1.jpg',
|
|
39
|
+
provider: 'google',
|
|
40
|
+
createdAt: '2023-01-01T00:00:00Z',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const mockUserProfile2: UserProfile = {
|
|
44
|
+
id: 'profile2',
|
|
45
|
+
displayName: 'User Two',
|
|
46
|
+
email: 'user2@example.com',
|
|
47
|
+
imageUrl: 'https://example.com/user2.jpg',
|
|
48
|
+
provider: 'github',
|
|
49
|
+
createdAt: '2023-01-02T00:00:00Z',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const mockUsers: SanityUser[] = [
|
|
53
|
+
{
|
|
54
|
+
sanityUserId: 'user1',
|
|
55
|
+
profile: mockUserProfile1,
|
|
56
|
+
memberships: [],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
sanityUserId: 'user2',
|
|
60
|
+
profile: mockUserProfile2,
|
|
61
|
+
memberships: [],
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
it('should render users data immediately when available', () => {
|
|
66
|
+
const getCurrent = vi.fn().mockReturnValue({
|
|
67
|
+
data: mockUsers,
|
|
68
|
+
hasMore: false,
|
|
69
|
+
totalCount: 2,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Type assertion to fix the StateSource type issue
|
|
73
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
74
|
+
getCurrent,
|
|
75
|
+
subscribe: vi.fn(),
|
|
76
|
+
get observable(): Observable<unknown> {
|
|
77
|
+
throw new Error('Not implemented')
|
|
78
|
+
},
|
|
79
|
+
} as unknown as StateSource<
|
|
80
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
81
|
+
>)
|
|
82
|
+
|
|
83
|
+
function TestComponent() {
|
|
84
|
+
const {data, hasMore, isPending} = useUsers({
|
|
85
|
+
resourceType: 'organization',
|
|
86
|
+
organizationId: 'test-org',
|
|
87
|
+
batchSize: 10,
|
|
88
|
+
})
|
|
89
|
+
return (
|
|
90
|
+
<div data-testid="output">
|
|
91
|
+
{data.length} users - {hasMore ? 'has more' : 'no more'} -{' '}
|
|
92
|
+
{isPending ? 'pending' : 'not pending'}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
render(<TestComponent />)
|
|
98
|
+
|
|
99
|
+
// Verify that the output contains the data and that isPending is false
|
|
100
|
+
expect(screen.getByTestId('output').textContent).toContain('2 users')
|
|
101
|
+
expect(screen.getByTestId('output').textContent).toContain('no more')
|
|
102
|
+
expect(screen.getByTestId('output').textContent).toContain('not pending')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should suspend rendering until users data is resolved via Suspense', async () => {
|
|
106
|
+
const ref = {
|
|
107
|
+
current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
|
|
108
|
+
}
|
|
109
|
+
const getCurrent = vi.fn(() => ref.current)
|
|
110
|
+
const storeChanged$ = new Subject<void>()
|
|
111
|
+
|
|
112
|
+
// Type assertion to fix the StateSource type issue
|
|
113
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
114
|
+
getCurrent,
|
|
115
|
+
subscribe: vi.fn((cb) => {
|
|
116
|
+
const subscription = storeChanged$.subscribe(cb)
|
|
117
|
+
return () => subscription.unsubscribe()
|
|
118
|
+
}),
|
|
119
|
+
get observable(): Observable<unknown> {
|
|
120
|
+
throw new Error('Not implemented')
|
|
121
|
+
},
|
|
122
|
+
} as unknown as StateSource<
|
|
123
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
124
|
+
>)
|
|
125
|
+
|
|
126
|
+
// Create a controllable promise to simulate the users resolution
|
|
127
|
+
let resolvePromise: () => void
|
|
128
|
+
// Mock resolveUsers to return our fake promise
|
|
129
|
+
vi.mocked(resolveUsers).mockReturnValue(
|
|
130
|
+
new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
|
|
131
|
+
resolvePromise = () => {
|
|
132
|
+
ref.current = {
|
|
133
|
+
data: mockUsers,
|
|
134
|
+
hasMore: true,
|
|
135
|
+
totalCount: 2,
|
|
136
|
+
}
|
|
137
|
+
storeChanged$.next()
|
|
138
|
+
resolve(ref.current)
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
function TestComponent() {
|
|
144
|
+
const {data} = useUsers({
|
|
145
|
+
resourceType: 'organization',
|
|
146
|
+
organizationId: 'test-org',
|
|
147
|
+
batchSize: 10,
|
|
148
|
+
})
|
|
149
|
+
return (
|
|
150
|
+
<div data-testid="output">{data.map((user) => user.profile.displayName).join(', ')}</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render(
|
|
155
|
+
<Suspense fallback={<div data-testid="fallback">Loading...</div>}>
|
|
156
|
+
<TestComponent />
|
|
157
|
+
</Suspense>,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// Initially, since storeValue is undefined, the component should suspend and fallback is shown
|
|
161
|
+
expect(screen.getByTestId('fallback')).toBeInTheDocument()
|
|
162
|
+
|
|
163
|
+
// Now simulate that data becomes available
|
|
164
|
+
await act(async () => {
|
|
165
|
+
resolvePromise()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(screen.getByTestId('output').textContent).toContain('User One, User Two')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should display transition pending state during options change', async () => {
|
|
172
|
+
const ref = {
|
|
173
|
+
current: undefined as {data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined,
|
|
174
|
+
}
|
|
175
|
+
const getCurrent = vi.fn(() => ref.current)
|
|
176
|
+
const storeChanged$ = new Subject<void>()
|
|
177
|
+
|
|
178
|
+
// Use a more specific type for the mock implementation
|
|
179
|
+
vi.mocked(getUsersState).mockImplementation((_instance, options) => {
|
|
180
|
+
if (options?.organizationId === 'org1') {
|
|
181
|
+
return {
|
|
182
|
+
getCurrent: vi.fn().mockReturnValue({
|
|
183
|
+
data: [mockUsers[0]],
|
|
184
|
+
hasMore: false,
|
|
185
|
+
totalCount: 1,
|
|
186
|
+
}),
|
|
187
|
+
subscribe: vi.fn(),
|
|
188
|
+
get observable(): Observable<unknown> {
|
|
189
|
+
throw new Error('Not implemented')
|
|
190
|
+
},
|
|
191
|
+
} as unknown as StateSource<
|
|
192
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
193
|
+
>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
getCurrent,
|
|
198
|
+
subscribe: vi.fn((cb) => {
|
|
199
|
+
const subscription = storeChanged$.subscribe(cb)
|
|
200
|
+
return () => subscription.unsubscribe()
|
|
201
|
+
}),
|
|
202
|
+
get observable(): Observable<unknown> {
|
|
203
|
+
throw new Error('Not implemented')
|
|
204
|
+
},
|
|
205
|
+
} as unknown as StateSource<
|
|
206
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
207
|
+
>
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Create a controllable promise to simulate the users resolution
|
|
211
|
+
let resolvePromise: () => void
|
|
212
|
+
// Mock resolveUsers to return our fake promise
|
|
213
|
+
vi.mocked(resolveUsers).mockReturnValue(
|
|
214
|
+
new Promise<{data: SanityUser[]; totalCount: number; hasMore: boolean}>((resolve) => {
|
|
215
|
+
resolvePromise = () => {
|
|
216
|
+
ref.current = {
|
|
217
|
+
data: [mockUsers[1]],
|
|
218
|
+
hasMore: true,
|
|
219
|
+
totalCount: 1,
|
|
220
|
+
}
|
|
221
|
+
storeChanged$.next()
|
|
222
|
+
resolve(ref.current)
|
|
223
|
+
}
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
function WrapperComponent() {
|
|
228
|
+
const [organizationId, setOrganizationId] = useState('org1')
|
|
229
|
+
const {data, hasMore, isPending} = useUsers({
|
|
230
|
+
resourceType: 'organization',
|
|
231
|
+
organizationId,
|
|
232
|
+
batchSize: 10,
|
|
233
|
+
})
|
|
234
|
+
return (
|
|
235
|
+
<div>
|
|
236
|
+
<div data-testid="output">
|
|
237
|
+
{data.map((user) => user.profile.displayName).join(', ')} -
|
|
238
|
+
{hasMore ? 'has more' : 'no more'} -{isPending ? 'pending' : 'not pending'}
|
|
239
|
+
</div>
|
|
240
|
+
<button data-testid="button" onClick={() => setOrganizationId('org2')}>
|
|
241
|
+
Change Organization
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
render(<WrapperComponent />)
|
|
248
|
+
|
|
249
|
+
// Initially, should show data for org1 and not pending
|
|
250
|
+
expect(screen.getByTestId('output').textContent).toContain('User One')
|
|
251
|
+
expect(screen.getByTestId('output').textContent).toContain('no more')
|
|
252
|
+
expect(screen.getByTestId('output').textContent).toContain('not pending')
|
|
253
|
+
|
|
254
|
+
// Trigger organization change to "org2"
|
|
255
|
+
act(() => {
|
|
256
|
+
screen.getByTestId('button').click()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Immediately after clicking, deferredKey is still for org1,
|
|
260
|
+
// so the hook returns data from the previous org ('User One') but isPending should now be true.
|
|
261
|
+
expect(screen.getByTestId('output').textContent).toContain('User One')
|
|
262
|
+
expect(screen.getByTestId('output').textContent).toContain('pending')
|
|
263
|
+
|
|
264
|
+
// Simulate the completion of the transition.
|
|
265
|
+
await act(async () => {
|
|
266
|
+
resolvePromise()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Now, the component should render with the new deferred options and display new data.
|
|
270
|
+
expect(screen.getByTestId('output').textContent).toContain('User Two')
|
|
271
|
+
expect(screen.getByTestId('output').textContent).toContain('has more')
|
|
272
|
+
expect(screen.getByTestId('output').textContent).toContain('not pending')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should call loadMoreUsers when loadMore is called', () => {
|
|
276
|
+
const getCurrent = vi.fn().mockReturnValue({
|
|
277
|
+
data: mockUsers,
|
|
278
|
+
hasMore: true,
|
|
279
|
+
totalCount: 2,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Type assertion to fix the StateSource type issue
|
|
283
|
+
vi.mocked(getUsersState).mockReturnValue({
|
|
284
|
+
getCurrent,
|
|
285
|
+
subscribe: vi.fn(),
|
|
286
|
+
get observable(): Observable<unknown> {
|
|
287
|
+
throw new Error('Not implemented')
|
|
288
|
+
},
|
|
289
|
+
} as unknown as StateSource<
|
|
290
|
+
{data: SanityUser[]; totalCount: number; hasMore: boolean} | undefined
|
|
291
|
+
>)
|
|
292
|
+
|
|
293
|
+
function TestComponent() {
|
|
294
|
+
const {data, hasMore, loadMore} = useUsers({
|
|
295
|
+
resourceType: 'organization',
|
|
296
|
+
organizationId: 'test-org',
|
|
297
|
+
batchSize: 10,
|
|
298
|
+
})
|
|
299
|
+
return (
|
|
300
|
+
<div>
|
|
301
|
+
<div data-testid="output">
|
|
302
|
+
{data.length} users - {hasMore ? 'has more' : 'no more'}
|
|
303
|
+
</div>
|
|
304
|
+
<button data-testid="load-more" onClick={loadMore}>
|
|
305
|
+
Load More
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
render(<TestComponent />)
|
|
312
|
+
|
|
313
|
+
// Verify initial state
|
|
314
|
+
expect(screen.getByTestId('output').textContent).toContain('2 users')
|
|
315
|
+
expect(screen.getByTestId('output').textContent).toContain('has more')
|
|
316
|
+
|
|
317
|
+
// Click the load more button
|
|
318
|
+
fireEvent.click(screen.getByTestId('load-more'))
|
|
319
|
+
|
|
320
|
+
// Verify that loadMoreUsers was called with the correct arguments
|
|
321
|
+
expect(loadMoreUsers).toHaveBeenCalledWith(
|
|
322
|
+
expect.objectContaining({config: {projectId: 'p'}}),
|
|
323
|
+
{
|
|
324
|
+
resourceType: 'organization',
|
|
325
|
+
organizationId: 'test-org',
|
|
326
|
+
batchSize: 10,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
})
|