@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.
Files changed (38) hide show
  1. package/dist/_exports/dashboard.d.ts +209 -0
  2. package/dist/_exports/dashboard.d.ts.map +1 -0
  3. package/dist/_exports/dashboard.js +278 -0
  4. package/dist/_exports/dashboard.js.map +1 -0
  5. package/dist/index.d.ts +86 -261
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +134 -532
  8. package/dist/index.js.map +1 -1
  9. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js +177 -0
  10. package/dist/useStudioWorkspacesByProjectIdDataset-I4S3CuR5.js.map +1 -0
  11. package/package.json +9 -8
  12. package/src/_exports/dashboard.ts +11 -0
  13. package/src/_exports/sdk-react.ts +2 -12
  14. package/src/components/SDKProvider.test.tsx +5 -5
  15. package/src/components/auth/AuthBoundary.tsx +5 -5
  16. package/src/context/{WorkbenchTokenRefresh.test.tsx → DashboardTokenRefresh.test.tsx} +26 -26
  17. package/src/context/{WorkbenchTokenRefresh.tsx → DashboardTokenRefresh.tsx} +14 -14
  18. package/src/context/OrganizationResourcesProvider.test.tsx +9 -9
  19. package/src/context/OrganizationResourcesProvider.tsx +2 -2
  20. package/src/context/{workbenchToken.ts → dashboardToken.ts} +13 -13
  21. package/src/hooks/comlink/useWindowConnection.ts +1 -1
  22. package/src/hooks/{agent → dashboard}/useAgentResourceContext.ts +1 -1
  23. package/src/hooks/dashboard/useFavorite.test.tsx +101 -0
  24. package/src/hooks/dashboard/useFavorite.ts +34 -0
  25. package/src/hooks/dashboard/useFavoriteContext.ts +61 -0
  26. package/src/hooks/dashboard/{useDashboardNavigate.test.ts → useNavigate.test.ts} +3 -3
  27. package/src/hooks/dashboard/{useDashboardNavigate.ts → useNavigate.ts} +5 -5
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +2 -1
  29. package/src/hooks/{auth/useDashboardOrganizationId.test.tsx → dashboard/useOrganizationId.test.tsx} +4 -4
  30. package/src/hooks/{auth/useDashboardOrganizationId.tsx → dashboard/useOrganizationId.tsx} +2 -2
  31. package/src/hooks/dashboard/useUpdateFavorite.test.tsx +146 -0
  32. package/src/hooks/dashboard/useUpdateFavorite.ts +74 -0
  33. package/src/hooks/dashboard/useWindowTitle.ts +1 -1
  34. package/src/hooks/datasets/useDatasets.test.tsx +29 -22
  35. package/src/hooks/datasets/useDatasets.ts +31 -53
  36. package/src/hooks/dashboard/useManageFavorite.test.tsx +0 -379
  37. package/src/hooks/dashboard/useManageFavorite.ts +0 -173
  38. /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 = useDashboardOrganizationId()
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 workbench host installs its shared message bus on this well-known global
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 workbench.
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 isWorkbenchEnvironment(): boolean {
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 workbench "OS", tracking the OS auth
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 workbench, so the
30
- * caller uses its normal auth flow. Inside the workbench, subscribes to 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 observeWorkbenchToken(): Observable<string | null> | undefined {
39
- if (!isWorkbenchEnvironment()) return undefined
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 workbench "OS" to reissue the session token, e.g. after its current
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 observeWorkbenchToken}. No-op outside
52
- * the workbench.
51
+ * the `auth.token` subscription in {@link observeDashboardToken}. No-op outside
52
+ * the dashboard.
53
53
  *
54
54
  * @internal
55
55
  */
56
- export function refreshWorkbenchToken(): void {
57
- if (!isWorkbenchEnvironment()) return
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 `useManageFavorite`.
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 {useDashboardNavigate} from './useDashboardNavigate'
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('useDashboardNavigate', () => {
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(() => useDashboardNavigate(mockNavigateFn))
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 {useDashboardNavigate} from '@sanity/sdk-react'
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 = useNavigate()
30
- * useDashboardNavigate(({path, type}) => {
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 useDashboardNavigate(
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 {useNavigateToStudioDocument, type DocumentHandle} from '@sanity/sdk-react'
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
  *
@@ -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 {useDashboardOrganizationId} from './useDashboardOrganizationId'
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('useDashboardOrganizationId', () => {
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(() => useDashboardOrganizationId(), {
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(() => useDashboardOrganizationId(), {
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 = useDashboardOrganizationId()
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 useDashboardOrganizationId(): string | undefined {
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')