@sanity/sdk-react 3.0.0-rc.2 → 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 +86 -261
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +134 -532
- 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 +9 -8
- package/src/_exports/dashboard.ts +11 -0
- package/src/_exports/sdk-react.ts +2 -12
- package/src/components/SDKProvider.test.tsx +5 -5
- package/src/components/auth/AuthBoundary.tsx +5 -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/{workbenchToken.ts → dashboardToken.ts} +13 -13
- package/src/hooks/comlink/useWindowConnection.ts +1 -1
- package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +1 -1
- 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/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/dashboard/useManageFavorite.test.tsx +0 -379
- package/src/hooks/dashboard/useManageFavorite.ts +0 -173
- /package/src/hooks/{agent → dashboard}/useAgentResourceContext.test.tsx +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {type DocumentResource, type SanityInstance} from '@sanity/sdk'
|
|
2
2
|
import {type ReactElement, type ReactNode, use, useMemo} from 'react'
|
|
3
3
|
|
|
4
|
-
import {useDashboardOrganizationId} from '../hooks/auth/useDashboardOrganizationId'
|
|
5
4
|
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
5
|
+
import {useOrganizationId} from '../hooks/dashboard/useOrganizationId'
|
|
6
6
|
import {resolveOrgResources} from '../utils/resolveOrgResources'
|
|
7
7
|
import {ResourcesContext} from './ResourcesContext'
|
|
8
8
|
|
|
@@ -91,7 +91,7 @@ export function OrganizationResourcesProvider({
|
|
|
91
91
|
children: ReactNode
|
|
92
92
|
}): ReactElement {
|
|
93
93
|
const instance = useSanityInstance()
|
|
94
|
-
const orgId =
|
|
94
|
+
const orgId = useOrganizationId()
|
|
95
95
|
|
|
96
96
|
if (!inferMediaLibraryAndCanvas || !orgId) {
|
|
97
97
|
return (
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {from, type Observable, of} from 'rxjs'
|
|
2
2
|
import {catchError, switchMap} from 'rxjs/operators'
|
|
3
3
|
|
|
4
|
-
// The
|
|
4
|
+
// The dashboard host installs its shared message bus on this well-known global
|
|
5
5
|
// symbol before it loads federated remotes. It must match the key used by
|
|
6
6
|
// `@sanity/workbench` (`Symbol.for('sanity.os.bus')`).
|
|
7
7
|
const OS_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Whether this app is running as a federated remote inside the
|
|
10
|
+
* Whether this app is running as a federated remote inside the dashboard.
|
|
11
11
|
*
|
|
12
12
|
* Federation shares the host's realm, so the installed bus is visible on
|
|
13
13
|
* `globalThis`. This is `false` in a standalone app, where we must never import
|
|
@@ -18,16 +18,16 @@ const OS_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
|
18
18
|
*
|
|
19
19
|
* @internal
|
|
20
20
|
*/
|
|
21
|
-
export function
|
|
21
|
+
export function isDashboardEnvironment(): boolean {
|
|
22
22
|
return typeof globalThis === 'object' && OS_BUS_KEY in globalThis
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Observes the session token issued by the
|
|
26
|
+
* Observes the session token issued by the dashboard "OS", tracking the OS auth
|
|
27
27
|
* state over time.
|
|
28
28
|
*
|
|
29
|
-
* Returns `undefined` when the app is not embedded in the
|
|
30
|
-
* caller uses its normal auth flow. Inside the
|
|
29
|
+
* Returns `undefined` when the app is not embedded in the dashboard, so the
|
|
30
|
+
* caller uses its normal auth flow. Inside the dashboard, subscribes to the
|
|
31
31
|
* `auth.token` state topic, emitting the current token — or `null` when the OS
|
|
32
32
|
* is signed out — and re-emitting as the OS auth state changes, so sign-in/out
|
|
33
33
|
* propagates instead of being captured once. Any bus error is treated as "no
|
|
@@ -35,8 +35,8 @@ export function isWorkbenchEnvironment(): boolean {
|
|
|
35
35
|
*
|
|
36
36
|
* @internal
|
|
37
37
|
*/
|
|
38
|
-
export function
|
|
39
|
-
if (!
|
|
38
|
+
export function observeDashboardToken(): Observable<string | null> | undefined {
|
|
39
|
+
if (!isDashboardEnvironment()) return undefined
|
|
40
40
|
|
|
41
41
|
return from(import('@sanity/workbench')).pipe(
|
|
42
42
|
switchMap(({os}) => os.subscribe('auth.token')),
|
|
@@ -46,15 +46,15 @@ export function observeWorkbenchToken(): Observable<string | null> | undefined {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Asks the
|
|
49
|
+
* Asks the dashboard "OS" to reissue the session token, e.g. after its current
|
|
50
50
|
* one was rejected with a 401. Fire-and-forget: the reissued token arrives via
|
|
51
|
-
* the `auth.token` subscription in {@link
|
|
52
|
-
* the
|
|
51
|
+
* the `auth.token` subscription in {@link observeDashboardToken}. No-op outside
|
|
52
|
+
* the dashboard.
|
|
53
53
|
*
|
|
54
54
|
* @internal
|
|
55
55
|
*/
|
|
56
|
-
export function
|
|
57
|
-
if (!
|
|
56
|
+
export function refreshDashboardToken(): void {
|
|
57
|
+
if (!isDashboardEnvironment()) return
|
|
58
58
|
|
|
59
59
|
void import('@sanity/workbench').then(
|
|
60
60
|
({os}) => os.emit('auth.token.refresh', undefined),
|
|
@@ -66,7 +66,7 @@ const useNodeState = createStateSourceHook({
|
|
|
66
66
|
* as well as sharing a single node between invocations if they share the same name.
|
|
67
67
|
*
|
|
68
68
|
* Generally not to be used directly, but to be used as a dependency of
|
|
69
|
-
* Comlink-powered hooks like `
|
|
69
|
+
* Comlink-powered hooks like `useStudioWorkspacesByProjectIdDataset`.
|
|
70
70
|
*/
|
|
71
71
|
export function useWindowConnection<
|
|
72
72
|
TWindowMessage extends WindowMessage,
|
|
@@ -36,7 +36,7 @@ export interface AgentResourceContextOptions {
|
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```tsx
|
|
39
|
-
* import {useAgentResourceContext} from '@sanity/sdk-react'
|
|
39
|
+
* import {useAgentResourceContext} from '@sanity/sdk-react/dashboard'
|
|
40
40
|
*
|
|
41
41
|
* function MyComponent() {
|
|
42
42
|
* const documentId = 'my-document-id'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {favorites, type FavoriteStatusResponse, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {type FetcherSnapshot} from '@sanity/sdk/_internal'
|
|
3
|
+
import {renderHook} from '@testing-library/react'
|
|
4
|
+
import {type ReactNode} from 'react'
|
|
5
|
+
import {BehaviorSubject} from 'rxjs'
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
7
|
+
|
|
8
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
9
|
+
import {useFavorite} from './useFavorite'
|
|
10
|
+
|
|
11
|
+
vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal()
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
favorites: {
|
|
16
|
+
getState: vi.fn(),
|
|
17
|
+
resolveState: vi.fn(),
|
|
18
|
+
refetch: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
} as unknown as typeof actual
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('useFavorite', () => {
|
|
24
|
+
let subject: BehaviorSubject<FavoriteStatusResponse>
|
|
25
|
+
|
|
26
|
+
const handle = {
|
|
27
|
+
documentId: 'mock-id',
|
|
28
|
+
documentType: 'mock-type',
|
|
29
|
+
resourceType: 'studio' as const,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const toSnapshot = (value: FavoriteStatusResponse): FetcherSnapshot<FavoriteStatusResponse> => ({
|
|
33
|
+
status: 'success',
|
|
34
|
+
data: value,
|
|
35
|
+
error: undefined,
|
|
36
|
+
isFetching: false,
|
|
37
|
+
dataUpdatedAt: 1,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const wrapper = ({children}: {children: ReactNode}) => (
|
|
41
|
+
<ResourceProvider projectId="test" dataset="test" fallback={null}>
|
|
42
|
+
{children}
|
|
43
|
+
</ResourceProvider>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
subject = new BehaviorSubject<FavoriteStatusResponse>({isFavorited: false})
|
|
48
|
+
vi.mocked(favorites.getState).mockImplementation(
|
|
49
|
+
() =>
|
|
50
|
+
({
|
|
51
|
+
subscribe: (callback?: () => void) => {
|
|
52
|
+
if (!callback) return () => {}
|
|
53
|
+
const subscription = subject.subscribe(() => callback())
|
|
54
|
+
callback()
|
|
55
|
+
return () => subscription.unsubscribe()
|
|
56
|
+
},
|
|
57
|
+
getCurrent: () => toSnapshot(subject.getValue()),
|
|
58
|
+
observable: subject.asObservable(),
|
|
59
|
+
}) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
subject.complete()
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns false when the document is not favorited', () => {
|
|
69
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
70
|
+
expect(result.current).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('reflects the favorited status from the store', () => {
|
|
74
|
+
subject.next({isFavorited: true})
|
|
75
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
76
|
+
expect(result.current).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('suspends until the favorite status is available', () => {
|
|
80
|
+
const pending: FetcherSnapshot<FavoriteStatusResponse> = {
|
|
81
|
+
status: 'pending',
|
|
82
|
+
data: undefined,
|
|
83
|
+
error: undefined,
|
|
84
|
+
isFetching: true,
|
|
85
|
+
dataUpdatedAt: undefined,
|
|
86
|
+
}
|
|
87
|
+
vi.mocked(favorites.getState).mockImplementation(
|
|
88
|
+
() =>
|
|
89
|
+
({
|
|
90
|
+
subscribe: () => () => {},
|
|
91
|
+
getCurrent: () => pending,
|
|
92
|
+
observable: subject.asObservable(),
|
|
93
|
+
}) as unknown as StateSource<FetcherSnapshot<FavoriteStatusResponse>>,
|
|
94
|
+
)
|
|
95
|
+
vi.mocked(favorites.resolveState).mockReturnValue(new Promise(() => {}))
|
|
96
|
+
const {result} = renderHook(() => useFavorite(handle), {wrapper})
|
|
97
|
+
// Suspended on the initial fetch — the ResourceProvider fallback renders instead.
|
|
98
|
+
expect(result.current).toBeNull()
|
|
99
|
+
expect(favorites.resolveState).toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {favorites} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {createFetcherHook} from '../helpers/createFetcherHook'
|
|
4
|
+
import {useFavoriteContext, type UseFavoriteProps} from './useFavoriteContext'
|
|
5
|
+
|
|
6
|
+
const useFavoriteStatus = createFetcherHook(favorites)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @internal
|
|
10
|
+
*
|
|
11
|
+
* Reads whether a document is currently favorited. The write-side counterpart is
|
|
12
|
+
* {@link useUpdateFavorite}.
|
|
13
|
+
*
|
|
14
|
+
* The hook suspends until the first favorite status resolves, so wrap the
|
|
15
|
+
* component in a `<Suspense>` boundary.
|
|
16
|
+
*
|
|
17
|
+
* @param props - The document handle plus the resource it lives in.
|
|
18
|
+
* @returns `true` when the document is favorited, otherwise `false`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* function FavoriteLabel(props: DocumentActionProps) {
|
|
23
|
+
* const {documentId, documentType} = props
|
|
24
|
+
* const isFavorited = useFavorite({documentId, documentType, resourceType: 'studio'})
|
|
25
|
+
*
|
|
26
|
+
* return <span>{isFavorited ? 'Favorited' : 'Not favorited'}</span>
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function useFavorite(props: UseFavoriteProps): boolean {
|
|
31
|
+
const context = useFavoriteContext(props)
|
|
32
|
+
const {data} = useFavoriteStatus(context)
|
|
33
|
+
return data.isFavorited
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanvasResource,
|
|
3
|
+
type MediaResource,
|
|
4
|
+
type StudioResource,
|
|
5
|
+
} from '@sanity/message-protocol'
|
|
6
|
+
import {type DocumentHandle, type FavoriteDocumentContext} from '@sanity/sdk'
|
|
7
|
+
import {useMemo} from 'react'
|
|
8
|
+
|
|
9
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props shared by {@link useFavorite} and {@link useUpdateFavorite}: a document
|
|
13
|
+
* handle plus the resource it lives in.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export interface UseFavoriteProps extends DocumentHandle {
|
|
18
|
+
resourceId?: string
|
|
19
|
+
resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
|
|
20
|
+
/**
|
|
21
|
+
* The name of the schema collection this document belongs to.
|
|
22
|
+
* Typically is the name of the workspace when used in the context of a studio.
|
|
23
|
+
*/
|
|
24
|
+
schemaName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves {@link UseFavoriteProps} into the {@link FavoriteDocumentContext}
|
|
29
|
+
* shared by the favorites fetcher and mutation, defaulting a studio `resourceId`
|
|
30
|
+
* from the instance's project and dataset.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function useFavoriteContext({
|
|
35
|
+
documentId,
|
|
36
|
+
documentType,
|
|
37
|
+
projectId: paramProjectId,
|
|
38
|
+
dataset: paramDataset,
|
|
39
|
+
resourceId: paramResourceId,
|
|
40
|
+
resourceType,
|
|
41
|
+
schemaName,
|
|
42
|
+
}: UseFavoriteProps): FavoriteDocumentContext {
|
|
43
|
+
const {config} = useSanityInstance()
|
|
44
|
+
const projectId = paramProjectId ?? config?.projectId
|
|
45
|
+
const dataset = paramDataset ?? config?.dataset
|
|
46
|
+
|
|
47
|
+
if (resourceType === 'studio' && (!projectId || !dataset)) {
|
|
48
|
+
throw new Error('projectId and dataset are required for studio resources')
|
|
49
|
+
}
|
|
50
|
+
const resourceId =
|
|
51
|
+
resourceType === 'studio' && !paramResourceId ? `${projectId}.${dataset}` : paramResourceId
|
|
52
|
+
|
|
53
|
+
if (!resourceId) {
|
|
54
|
+
throw new Error('resourceId is required for media-library and canvas resources')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return useMemo(
|
|
58
|
+
() => ({documentId, documentType, resourceId, resourceType, schemaName}),
|
|
59
|
+
[documentId, documentType, resourceId, resourceType, schemaName],
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -2,7 +2,7 @@ import {type PathChangeMessage} from '@sanity/message-protocol'
|
|
|
2
2
|
import {renderHook} from '@testing-library/react'
|
|
3
3
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {useNavigate} from './useNavigate'
|
|
6
6
|
|
|
7
7
|
const mockOnMessage = vi.fn()
|
|
8
8
|
let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
|
|
@@ -22,7 +22,7 @@ vi.mock('../comlink/useWindowConnection', () => {
|
|
|
22
22
|
}
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
describe('
|
|
25
|
+
describe('useNavigate', () => {
|
|
26
26
|
const mockNavigateFn = vi.fn()
|
|
27
27
|
|
|
28
28
|
beforeEach(() => {
|
|
@@ -31,7 +31,7 @@ describe('useDashboardNavigate', () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
it('calls navigate function with correct data when message is received', () => {
|
|
34
|
-
renderHook(() =>
|
|
34
|
+
renderHook(() => useNavigate(mockNavigateFn))
|
|
35
35
|
|
|
36
36
|
const mockNavigationData = {
|
|
37
37
|
path: '/test-path',
|
|
@@ -21,13 +21,13 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
|
21
21
|
*
|
|
22
22
|
* @example
|
|
23
23
|
* ```tsx
|
|
24
|
-
* import {
|
|
25
|
-
* import {BrowserRouter, useNavigate} from 'react-router'
|
|
24
|
+
* import {useNavigate} from '@sanity/sdk-react/dashboard'
|
|
25
|
+
* import {BrowserRouter, useNavigate as useRouterNavigate} from 'react-router'
|
|
26
26
|
* import {Suspense} from 'react'
|
|
27
27
|
*
|
|
28
28
|
* function DashboardNavigationHandler() {
|
|
29
|
-
* const navigate =
|
|
30
|
-
*
|
|
29
|
+
* const navigate = useRouterNavigate()
|
|
30
|
+
* useNavigate(({path, type}) => {
|
|
31
31
|
* navigate(path, {replace: type === 'replace'})
|
|
32
32
|
* })
|
|
33
33
|
* return null
|
|
@@ -45,7 +45,7 @@ import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
|
45
45
|
* }
|
|
46
46
|
* ```
|
|
47
47
|
*/
|
|
48
|
-
export function
|
|
48
|
+
export function useNavigate(
|
|
49
49
|
navigateFn: (options: PathChangeMessage['data']) => void,
|
|
50
50
|
): void {
|
|
51
51
|
useWindowConnection<PathChangeMessage, never>({
|
|
@@ -36,7 +36,8 @@ export interface NavigateToStudioResult {
|
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```tsx
|
|
39
|
-
* import {
|
|
39
|
+
* import {type DocumentHandle} from '@sanity/sdk-react'
|
|
40
|
+
* import {useNavigateToStudioDocument} from '@sanity/sdk-react/dashboard'
|
|
40
41
|
* import {Button} from '@sanity/ui'
|
|
41
42
|
* import {Suspense} from 'react'
|
|
42
43
|
*
|
package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx}
RENAMED
|
@@ -4,14 +4,14 @@ import {throwError} from 'rxjs'
|
|
|
4
4
|
import {describe, expect, it, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
7
|
-
import {
|
|
7
|
+
import {useOrganizationId} from './useOrganizationId'
|
|
8
8
|
|
|
9
9
|
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
10
10
|
const actual = await importOriginal()
|
|
11
11
|
return {...(actual || {}), getDashboardOrganizationId: vi.fn()}
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
-
describe('
|
|
14
|
+
describe('useOrganizationId', () => {
|
|
15
15
|
it('should return undefined when no organization ID is set', () => {
|
|
16
16
|
const subscribe = vi.fn()
|
|
17
17
|
vi.mocked(getDashboardOrganizationId).mockReturnValue({
|
|
@@ -20,7 +20,7 @@ describe('useDashboardOrganizationId', () => {
|
|
|
20
20
|
observable: throwError(() => new Error('Unexpected usage of observable')),
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
const {result} = renderHook(() =>
|
|
23
|
+
const {result} = renderHook(() => useOrganizationId(), {
|
|
24
24
|
wrapper: ({children}) => (
|
|
25
25
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
26
26
|
{children}
|
|
@@ -39,7 +39,7 @@ describe('useDashboardOrganizationId', () => {
|
|
|
39
39
|
observable: throwError(() => new Error('Unexpected usage of observable')),
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
const {result} = renderHook(() =>
|
|
42
|
+
const {result} = renderHook(() => useOrganizationId(), {
|
|
43
43
|
wrapper: ({children}) => (
|
|
44
44
|
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
45
45
|
{children}
|
|
@@ -11,7 +11,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
11
11
|
* @example
|
|
12
12
|
* ```tsx
|
|
13
13
|
* function DashboardComponent() {
|
|
14
|
-
* const orgId =
|
|
14
|
+
* const orgId = useOrganizationId()
|
|
15
15
|
*
|
|
16
16
|
* if (!orgId) return null
|
|
17
17
|
*
|
|
@@ -22,7 +22,7 @@ import {useSanityInstance} from '../context/useSanityInstance'
|
|
|
22
22
|
* @category Dashboard
|
|
23
23
|
* @returns The dashboard organization ID (string | undefined)
|
|
24
24
|
*/
|
|
25
|
-
export function
|
|
25
|
+
export function useOrganizationId(): string | undefined {
|
|
26
26
|
const instance = useSanityInstance()
|
|
27
27
|
const {subscribe, getCurrent} = useMemo(() => getDashboardOrganizationId(instance), [instance])
|
|
28
28
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {type FavoriteStatusResponse, setFavorite} from '@sanity/sdk'
|
|
2
|
+
import {type MutationResult} from '@sanity/sdk/_internal'
|
|
3
|
+
import {act, renderHook, waitFor} from '@testing-library/react'
|
|
4
|
+
import {type ReactNode} from 'react'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
8
|
+
import {useUpdateFavorite} from './useUpdateFavorite'
|
|
9
|
+
|
|
10
|
+
vi.mock(import('@sanity/sdk'), async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal()
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
setFavorite: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type SetFavorite = typeof setFavorite
|
|
19
|
+
|
|
20
|
+
describe('useUpdateFavorite', () => {
|
|
21
|
+
const mockSetFavorite = setFavorite as Mock
|
|
22
|
+
|
|
23
|
+
const handle = {
|
|
24
|
+
documentId: 'mock-id',
|
|
25
|
+
documentType: 'mock-type',
|
|
26
|
+
resourceType: 'studio' as const,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const makeWrapper = (projectId?: string, dataset?: string) => {
|
|
30
|
+
return function Wrapper({children}: {children: ReactNode}) {
|
|
31
|
+
return (
|
|
32
|
+
<ResourceProvider projectId={projectId} dataset={dataset} fallback={null}>
|
|
33
|
+
{children}
|
|
34
|
+
</ResourceProvider>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const wrapper = makeWrapper('test', 'test')
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockSetFavorite.mockImplementation((async (_instance, input) => ({
|
|
42
|
+
data: {isFavorited: input.isFavorited},
|
|
43
|
+
invalidated: Promise.resolve(),
|
|
44
|
+
})) as SetFavorite)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.clearAllMocks()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('sends the added event with the resolved studio resourceId', async () => {
|
|
52
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
53
|
+
|
|
54
|
+
await act(async () => {
|
|
55
|
+
await result.current.favorite()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
59
|
+
expect.anything(),
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
documentId: 'mock-id',
|
|
62
|
+
documentType: 'mock-type',
|
|
63
|
+
resourceId: 'test.test',
|
|
64
|
+
resourceType: 'studio',
|
|
65
|
+
isFavorited: true,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('sends the removed event when unfavoriting', async () => {
|
|
71
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await result.current.unfavorite()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
78
|
+
expect.anything(),
|
|
79
|
+
expect.objectContaining({isFavorited: false}),
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('passes schemaName through when provided', async () => {
|
|
84
|
+
const {result} = renderHook(() => useUpdateFavorite({...handle, schemaName: 'testSchema'}), {
|
|
85
|
+
wrapper,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
await result.current.favorite()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(setFavorite).toHaveBeenCalledWith(
|
|
93
|
+
expect.anything(),
|
|
94
|
+
expect.objectContaining({schemaName: 'testSchema', isFavorited: true}),
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('tracks isPending across the mutation lifecycle', async () => {
|
|
99
|
+
let resolveMutation!: (value: MutationResult<FavoriteStatusResponse>) => void
|
|
100
|
+
mockSetFavorite.mockReturnValue(
|
|
101
|
+
new Promise<MutationResult<FavoriteStatusResponse>>((resolve) => {
|
|
102
|
+
resolveMutation = resolve
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
107
|
+
expect(result.current.isPending).toBe(false)
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
void result.current.favorite()
|
|
111
|
+
})
|
|
112
|
+
expect(result.current.isPending).toBe(true)
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
resolveMutation({data: {isFavorited: true}, invalidated: Promise.resolve()})
|
|
116
|
+
})
|
|
117
|
+
expect(result.current.isPending).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('surfaces mutation failures on error', async () => {
|
|
121
|
+
mockSetFavorite.mockRejectedValue(new Error('mutate failed'))
|
|
122
|
+
|
|
123
|
+
const {result} = renderHook(() => useUpdateFavorite(handle), {wrapper})
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await expect(result.current.favorite()).rejects.toThrow('mutate failed')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws when a studio resource is missing projectId or dataset', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
renderHook(() => useUpdateFavorite(handle), {wrapper: makeWrapper(undefined, undefined)}),
|
|
135
|
+
).toThrow('projectId and dataset are required for studio resources')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('throws when resourceId is missing for non-studio resources', () => {
|
|
139
|
+
expect(() =>
|
|
140
|
+
renderHook(
|
|
141
|
+
() => useUpdateFavorite({...handle, resourceType: 'media-library', resourceId: undefined}),
|
|
142
|
+
{wrapper},
|
|
143
|
+
),
|
|
144
|
+
).toThrow('resourceId is required for media-library and canvas resources')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {type FavoriteStatusResponse, setFavorite} from '@sanity/sdk'
|
|
2
|
+
import {useCallback} from 'react'
|
|
3
|
+
|
|
4
|
+
import {createMutationHook} from '../helpers/createMutationHook'
|
|
5
|
+
import {useFavoriteContext, type UseFavoriteProps} from './useFavoriteContext'
|
|
6
|
+
|
|
7
|
+
const useSetFavorite = createMutationHook(setFavorite)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The value returned by {@link useUpdateFavorite}.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export interface UpdateFavorite {
|
|
15
|
+
/** Adds the document to favorites. */
|
|
16
|
+
favorite: () => Promise<FavoriteStatusResponse>
|
|
17
|
+
/** Removes the document from favorites. */
|
|
18
|
+
unfavorite: () => Promise<FavoriteStatusResponse>
|
|
19
|
+
/** A favorite or unfavorite mutation is currently in flight. */
|
|
20
|
+
isPending: boolean
|
|
21
|
+
/** The most recent failure; cleared by the next call or `reset`. */
|
|
22
|
+
error: unknown
|
|
23
|
+
/** Clears error and pending state back to idle. */
|
|
24
|
+
reset: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @internal
|
|
29
|
+
*
|
|
30
|
+
* Adds or removes a document from favorites. The read-side counterpart is
|
|
31
|
+
* {@link useFavorite}, which reflects the change once the mutation settles.
|
|
32
|
+
*
|
|
33
|
+
* Unlike {@link useFavorite}, this hook does not suspend.
|
|
34
|
+
*
|
|
35
|
+
* @param props - The document handle plus the resource it lives in.
|
|
36
|
+
* @returns `favorite`/`unfavorite` actions and the `{isPending, error, reset}`
|
|
37
|
+
* mutation state.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* function FavoriteButton(props: DocumentActionProps) {
|
|
42
|
+
* const {documentId, documentType} = props
|
|
43
|
+
* const handle = {documentId, documentType, resourceType: 'studio'} as const
|
|
44
|
+
* const isFavorited = useFavorite(handle)
|
|
45
|
+
* const {favorite, unfavorite, isPending} = useUpdateFavorite(handle)
|
|
46
|
+
*
|
|
47
|
+
* return (
|
|
48
|
+
* <Button
|
|
49
|
+
* disabled={isPending}
|
|
50
|
+
* onClick={() => (isFavorited ? unfavorite() : favorite())}
|
|
51
|
+
* text={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
|
|
52
|
+
* />
|
|
53
|
+
* )
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* // Wrap the component with Suspense since useFavorite suspends
|
|
57
|
+
* function MyDocumentAction(props: DocumentActionProps) {
|
|
58
|
+
* return (
|
|
59
|
+
* <Suspense fallback={<Button text="Loading..." disabled />}>
|
|
60
|
+
* <FavoriteButton {...props} />
|
|
61
|
+
* </Suspense>
|
|
62
|
+
* )
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function useUpdateFavorite(props: UseFavoriteProps): UpdateFavorite {
|
|
67
|
+
const context = useFavoriteContext(props)
|
|
68
|
+
const {mutate, isPending, error, reset} = useSetFavorite()
|
|
69
|
+
|
|
70
|
+
const favorite = useCallback(() => mutate({...context, isFavorited: true}), [mutate, context])
|
|
71
|
+
const unfavorite = useCallback(() => mutate({...context, isFavorited: false}), [mutate, context])
|
|
72
|
+
|
|
73
|
+
return {favorite, unfavorite, isPending, error, reset}
|
|
74
|
+
}
|
|
@@ -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')
|