@sanity/sdk-react 0.0.0-alpha.3 → 0.0.0-alpha.30

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 (131) hide show
  1. package/README.md +6 -100
  2. package/dist/index.d.ts +2390 -2
  3. package/dist/index.js +1119 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +35 -49
  6. package/src/_exports/index.ts +2 -10
  7. package/src/_exports/sdk-react.ts +73 -0
  8. package/src/components/SDKProvider.test.tsx +103 -0
  9. package/src/components/SDKProvider.tsx +52 -0
  10. package/src/components/SanityApp.test.tsx +244 -0
  11. package/src/components/SanityApp.tsx +106 -0
  12. package/src/components/auth/AuthBoundary.test.tsx +204 -29
  13. package/src/components/auth/AuthBoundary.tsx +96 -19
  14. package/src/components/auth/ConfigurationError.ts +22 -0
  15. package/src/components/auth/LoginCallback.test.tsx +22 -24
  16. package/src/components/auth/LoginCallback.tsx +6 -16
  17. package/src/components/auth/LoginError.test.tsx +11 -18
  18. package/src/components/auth/LoginError.tsx +43 -25
  19. package/src/components/utils.ts +22 -0
  20. package/src/context/ResourceProvider.test.tsx +157 -0
  21. package/src/context/ResourceProvider.tsx +111 -0
  22. package/src/context/SanityInstanceContext.ts +4 -0
  23. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  24. package/src/hooks/auth/useAuthState.tsx +4 -5
  25. package/src/hooks/auth/useAuthToken.tsx +1 -1
  26. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  27. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  28. package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
  29. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  30. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  31. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  32. package/src/hooks/auth/useLogOut.tsx +1 -1
  33. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  34. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  35. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  36. package/src/hooks/client/useClient.ts +13 -33
  37. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  38. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  39. package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
  40. package/src/hooks/comlink/useManageFavorite.ts +210 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
  42. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
  43. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  44. package/src/hooks/comlink/useWindowConnection.ts +123 -0
  45. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  46. package/src/hooks/context/useSanityInstance.ts +68 -11
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
  48. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
  50. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
  51. package/src/hooks/datasets/useDatasets.test.ts +80 -0
  52. package/src/hooks/datasets/useDatasets.ts +52 -0
  53. package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
  54. package/src/hooks/document/useApplyDocumentActions.ts +124 -0
  55. package/src/hooks/document/useDocument.test.ts +118 -0
  56. package/src/hooks/document/useDocument.ts +212 -0
  57. package/src/hooks/document/useDocumentEvent.test.ts +62 -0
  58. package/src/hooks/document/useDocumentEvent.ts +94 -0
  59. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  60. package/src/hooks/document/useDocumentPermissions.ts +131 -0
  61. package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
  62. package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
  63. package/src/hooks/document/useEditDocument.test.ts +196 -0
  64. package/src/hooks/document/useEditDocument.ts +314 -0
  65. package/src/hooks/documents/useDocuments.test.tsx +179 -0
  66. package/src/hooks/documents/useDocuments.ts +300 -0
  67. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  68. package/src/hooks/helpers/createCallbackHook.tsx +1 -1
  69. package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
  70. package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
  71. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
  72. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
  73. package/src/hooks/preview/usePreview.test.tsx +85 -17
  74. package/src/hooks/preview/usePreview.tsx +81 -22
  75. package/src/hooks/projection/useProjection.test.tsx +283 -0
  76. package/src/hooks/projection/useProjection.ts +232 -0
  77. package/src/hooks/projects/useProject.test.ts +80 -0
  78. package/src/hooks/projects/useProject.ts +51 -0
  79. package/src/hooks/projects/useProjects.test.ts +77 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +193 -0
  83. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  84. package/src/hooks/releases/useActiveReleases.ts +39 -0
  85. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  86. package/src/hooks/releases/usePerspective.ts +49 -0
  87. package/src/hooks/users/useUsers.test.tsx +330 -0
  88. package/src/hooks/users/useUsers.ts +120 -0
  89. package/src/utils/getEnv.ts +21 -0
  90. package/src/version.ts +8 -0
  91. package/src/vite-env.d.ts +10 -0
  92. package/dist/_chunks-es/useLogOut.js +0 -44
  93. package/dist/_chunks-es/useLogOut.js.map +0 -1
  94. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  95. package/dist/components.d.ts +0 -259
  96. package/dist/components.js +0 -301
  97. package/dist/components.js.map +0 -1
  98. package/dist/hooks.d.ts +0 -186
  99. package/dist/hooks.js +0 -81
  100. package/dist/hooks.js.map +0 -1
  101. package/src/_exports/components.ts +0 -13
  102. package/src/_exports/hooks.ts +0 -9
  103. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  104. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  105. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  106. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  107. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  108. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  109. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  110. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  111. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  112. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  113. package/src/components/Login/LoginLinks.test.tsx +0 -100
  114. package/src/components/Login/LoginLinks.tsx +0 -73
  115. package/src/components/auth/Login.test.tsx +0 -41
  116. package/src/components/auth/Login.tsx +0 -45
  117. package/src/components/auth/LoginFooter.test.tsx +0 -29
  118. package/src/components/auth/LoginFooter.tsx +0 -65
  119. package/src/components/auth/LoginLayout.test.tsx +0 -33
  120. package/src/components/auth/LoginLayout.tsx +0 -81
  121. package/src/components/context/SanityProvider.test.tsx +0 -25
  122. package/src/components/context/SanityProvider.tsx +0 -42
  123. package/src/css/css.config.js +0 -220
  124. package/src/css/paramour.css +0 -2347
  125. package/src/css/styles.css +0 -11
  126. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  127. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  128. package/src/hooks/auth/useLoginUrls.tsx +0 -51
  129. package/src/hooks/client/useClient.test.tsx +0 -130
  130. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  131. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -0,0 +1,107 @@
1
+ import {type ChannelInstance, type Controller, type Status} from '@sanity/comlink'
2
+ import {
3
+ type FrameMessage,
4
+ getOrCreateChannel,
5
+ getOrCreateController,
6
+ releaseChannel,
7
+ type WindowMessage,
8
+ } from '@sanity/sdk'
9
+ import {useCallback, useEffect, useRef} from 'react'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+
13
+ /**
14
+ * @internal
15
+ */
16
+ export type FrameMessageHandler<TWindowMessage extends WindowMessage> = (
17
+ event: TWindowMessage['data'],
18
+ ) => TWindowMessage['response'] | Promise<TWindowMessage['response']>
19
+
20
+ /**
21
+ * @internal
22
+ */
23
+ export interface UseFrameConnectionOptions<TWindowMessage extends WindowMessage> {
24
+ name: string
25
+ connectTo: string
26
+ targetOrigin: string
27
+ onMessage?: {
28
+ [K in TWindowMessage['type']]: (data: Extract<TWindowMessage, {type: K}>['data']) => void
29
+ }
30
+ heartbeat?: boolean
31
+ onStatus?: (status: Status) => void
32
+ }
33
+
34
+ /**
35
+ * @internal
36
+ */
37
+ export interface FrameConnection<TFrameMessage extends FrameMessage> {
38
+ connect: (frameWindow: Window) => () => void // Return cleanup function
39
+ sendMessage: <T extends TFrameMessage['type']>(
40
+ ...params: Extract<TFrameMessage, {type: T}>['data'] extends undefined
41
+ ? [type: T]
42
+ : [type: T, data: Extract<TFrameMessage, {type: T}>['data']]
43
+ ) => void
44
+ }
45
+
46
+ /**
47
+ * @internal
48
+ */
49
+ export function useFrameConnection<
50
+ TFrameMessage extends FrameMessage,
51
+ TWindowMessage extends WindowMessage,
52
+ >(options: UseFrameConnectionOptions<TWindowMessage>): FrameConnection<TFrameMessage> {
53
+ const {onMessage, targetOrigin, name, connectTo, heartbeat, onStatus} = options
54
+ const instance = useSanityInstance()
55
+ const controllerRef = useRef<Controller | null>(null)
56
+ const channelRef = useRef<ChannelInstance<TFrameMessage, TWindowMessage> | null>(null)
57
+
58
+ useEffect(() => {
59
+ const controller = getOrCreateController(instance, targetOrigin)
60
+ const channel = getOrCreateChannel(instance, {name, connectTo, heartbeat})
61
+ controllerRef.current = controller
62
+ channelRef.current = channel
63
+
64
+ channel.onStatus((event) => {
65
+ onStatus?.(event.status)
66
+ })
67
+
68
+ const messageUnsubscribers: Array<() => void> = []
69
+
70
+ if (onMessage) {
71
+ Object.entries(onMessage).forEach(([type, handler]) => {
72
+ const unsubscribe = channel.on(type, handler as FrameMessageHandler<TWindowMessage>)
73
+ messageUnsubscribers.push(unsubscribe)
74
+ })
75
+ }
76
+
77
+ return () => {
78
+ // Clean up all subscriptions and stop controller/channel
79
+ messageUnsubscribers.forEach((unsub) => unsub())
80
+ releaseChannel(instance, name)
81
+ channelRef.current = null
82
+ controllerRef.current = null
83
+ }
84
+ }, [targetOrigin, name, connectTo, heartbeat, onMessage, instance, onStatus])
85
+
86
+ const connect = useCallback((frameWindow: Window) => {
87
+ const removeTarget = controllerRef.current?.addTarget(frameWindow)
88
+ return () => {
89
+ removeTarget?.()
90
+ }
91
+ }, [])
92
+
93
+ const sendMessage = useCallback(
94
+ <T extends TFrameMessage['type']>(
95
+ type: T,
96
+ data?: Extract<TFrameMessage, {type: T}>['data'],
97
+ ) => {
98
+ channelRef.current?.post(type, data)
99
+ },
100
+ [],
101
+ )
102
+
103
+ return {
104
+ connect,
105
+ sendMessage,
106
+ }
107
+ }
@@ -0,0 +1,368 @@
1
+ import {type Message, type Node, type Status} from '@sanity/comlink'
2
+ import {
3
+ type FavoriteStatusResponse,
4
+ getFavoritesState,
5
+ getOrCreateNode,
6
+ resolveFavoritesState,
7
+ type SanityInstance,
8
+ } from '@sanity/sdk'
9
+ import {BehaviorSubject} from 'rxjs'
10
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {act, renderHook} from '../../../test/test-utils'
13
+ import {useSanityInstance} from '../context/useSanityInstance'
14
+ import {useManageFavorite} from './useManageFavorite'
15
+
16
+ vi.mock(import('@sanity/sdk'), async (importOriginal) => {
17
+ const actual = await importOriginal()
18
+ return {
19
+ ...actual,
20
+ getOrCreateNode: vi.fn(),
21
+ releaseNode: vi.fn(),
22
+ getFavoritesState: vi.fn(),
23
+ resolveFavoritesState: vi.fn(),
24
+ }
25
+ })
26
+
27
+ vi.mock('../context/useSanityInstance')
28
+
29
+ describe('useManageFavorite', () => {
30
+ let node: Node<Message, Message>
31
+ let statusCallback: ((status: Status) => void) | null = null
32
+ let favoriteStatusSubject: BehaviorSubject<FavoriteStatusResponse>
33
+
34
+ const mockDocumentHandle = {
35
+ documentId: 'mock-id',
36
+ documentType: 'mock-type',
37
+ resourceType: 'studio' as const,
38
+ }
39
+
40
+ function createMockNode() {
41
+ return {
42
+ on: vi.fn(() => () => {}),
43
+ fetch: vi.fn().mockImplementation(() => Promise.resolve({success: true})),
44
+ stop: vi.fn(),
45
+ onStatus: vi.fn((callback) => {
46
+ statusCallback = callback
47
+ return () => {}
48
+ }),
49
+ } as unknown as Node<Message, Message>
50
+ }
51
+
52
+ beforeEach(() => {
53
+ statusCallback = null
54
+ favoriteStatusSubject = new BehaviorSubject<FavoriteStatusResponse>({isFavorited: false})
55
+ node = createMockNode()
56
+ vi.mocked(getOrCreateNode).mockReturnValue(node)
57
+
58
+ // Mock getFavoritesState
59
+ vi.mocked(getFavoritesState).mockImplementation(() => ({
60
+ subscribe: (callback?: () => void) => {
61
+ if (!callback) return () => {}
62
+
63
+ const subscription = favoriteStatusSubject.subscribe(() => callback())
64
+ callback() // Initial call
65
+ return () => subscription.unsubscribe()
66
+ },
67
+ getCurrent: () => favoriteStatusSubject.getValue(),
68
+ observable: favoriteStatusSubject.asObservable(),
69
+ }))
70
+
71
+ // Mock resolveFavoritesState
72
+ vi.mocked(resolveFavoritesState).mockImplementation(async () => {
73
+ const newValue = {isFavorited: !favoriteStatusSubject.getValue().isFavorited}
74
+ favoriteStatusSubject.next(newValue)
75
+ return newValue
76
+ })
77
+
78
+ // Default mock for useSanityInstance
79
+ vi.mocked(useSanityInstance).mockReturnValue({
80
+ config: {
81
+ projectId: 'test',
82
+ dataset: 'test',
83
+ },
84
+ } as unknown as SanityInstance)
85
+ })
86
+
87
+ afterEach(() => {
88
+ favoriteStatusSubject.complete()
89
+ vi.clearAllMocks()
90
+ })
91
+
92
+ it('should initialize with default states', () => {
93
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
94
+
95
+ expect(result.current.isFavorited).toBe(false)
96
+ expect(result.current.isConnected).toBe(false)
97
+ })
98
+
99
+ it('should handle favorite action and update state', async () => {
100
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
101
+
102
+ expect(result.current.isFavorited).toBe(false)
103
+
104
+ // Simulate connection first
105
+ act(() => {
106
+ statusCallback?.('connected')
107
+ })
108
+
109
+ await act(async () => {
110
+ await result.current.favorite()
111
+ })
112
+
113
+ expect(node.fetch).toHaveBeenCalledWith(
114
+ 'dashboard/v1/events/favorite/mutate',
115
+ {
116
+ document: {
117
+ id: 'mock-id',
118
+ type: 'mock-type',
119
+ resource: {
120
+ id: 'test.test',
121
+ type: 'studio',
122
+ },
123
+ },
124
+ eventType: 'added',
125
+ },
126
+ // empty options object (from useWindowConnection)
127
+ {},
128
+ )
129
+ expect(resolveFavoritesState).toHaveBeenCalled()
130
+ expect(result.current.isFavorited).toBe(true)
131
+ })
132
+
133
+ it('should handle unfavorite action and update state', async () => {
134
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
135
+
136
+ // Set initial state to favorited
137
+ await act(async () => {
138
+ favoriteStatusSubject.next({isFavorited: true})
139
+ })
140
+
141
+ expect(result.current.isFavorited).toBe(true)
142
+
143
+ // Simulate connection first
144
+ act(() => {
145
+ statusCallback?.('connected')
146
+ })
147
+
148
+ await act(async () => {
149
+ await result.current.unfavorite()
150
+ })
151
+
152
+ expect(node.fetch).toHaveBeenCalledWith(
153
+ 'dashboard/v1/events/favorite/mutate',
154
+ {
155
+ document: {
156
+ id: 'mock-id',
157
+ type: 'mock-type',
158
+ resource: {
159
+ id: 'test.test',
160
+ type: 'studio',
161
+ },
162
+ },
163
+ eventType: 'removed',
164
+ },
165
+ {},
166
+ )
167
+ expect(resolveFavoritesState).toHaveBeenCalled()
168
+ expect(result.current.isFavorited).toBe(false)
169
+ })
170
+
171
+ it('should not update state if favorite action fails', async () => {
172
+ vi.mocked(node.fetch).mockImplementationOnce(() => Promise.resolve({success: false}))
173
+
174
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
175
+
176
+ expect(result.current.isFavorited).toBe(false)
177
+
178
+ await act(async () => {
179
+ await result.current.favorite()
180
+ })
181
+
182
+ expect(resolveFavoritesState).not.toHaveBeenCalled()
183
+ expect(result.current.isFavorited).toBe(false)
184
+ })
185
+
186
+ it('should throw error during favorite/unfavorite actions', async () => {
187
+ const errorMessage = 'Failed to update favorite status'
188
+
189
+ vi.mocked(node.fetch).mockImplementation(() => {
190
+ throw new Error(errorMessage)
191
+ })
192
+
193
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
194
+
195
+ await act(async () => {
196
+ statusCallback?.('connected')
197
+ })
198
+
199
+ await act(async () => {
200
+ await expect(result.current.favorite()).rejects.toThrow(errorMessage)
201
+ })
202
+
203
+ expect(resolveFavoritesState).not.toHaveBeenCalled()
204
+ expect(result.current.isFavorited).toBe(false)
205
+
206
+ await act(async () => {
207
+ await expect(result.current.unfavorite()).rejects.toThrow(errorMessage)
208
+ })
209
+
210
+ expect(resolveFavoritesState).not.toHaveBeenCalled()
211
+ })
212
+
213
+ it('should update connection status', () => {
214
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
215
+
216
+ expect(result.current.isConnected).toBe(false)
217
+
218
+ act(() => {
219
+ statusCallback?.('connected')
220
+ })
221
+
222
+ expect(result.current.isConnected).toBe(true)
223
+ })
224
+
225
+ it('should throw error when studio resource is missing projectId or dataset', () => {
226
+ // Mock the Sanity instance to not have projectId or dataset
227
+ vi.mocked(useSanityInstance).mockReturnValue({
228
+ config: {
229
+ projectId: undefined,
230
+ dataset: undefined,
231
+ },
232
+ } as unknown as SanityInstance)
233
+
234
+ const mockDocumentHandleWithoutProjectId = {
235
+ documentId: 'mock-id',
236
+ documentType: 'mock-type',
237
+ resourceType: 'studio' as const,
238
+ }
239
+
240
+ expect(() => renderHook(() => useManageFavorite(mockDocumentHandleWithoutProjectId))).toThrow(
241
+ 'projectId and dataset are required for studio resources',
242
+ )
243
+ })
244
+
245
+ it('should throw error when resourceId is missing for non-studio resources', () => {
246
+ const mockMediaDocumentHandle = {
247
+ documentId: 'mock-id',
248
+ documentType: 'mock-type',
249
+ resourceType: 'media-library' as const,
250
+ resourceId: undefined,
251
+ }
252
+
253
+ expect(() => renderHook(() => useManageFavorite(mockMediaDocumentHandle))).toThrow(
254
+ 'resourceId is required for media-library and canvas resources',
255
+ )
256
+ })
257
+
258
+ it('should handle favorites service timeout gracefully', async () => {
259
+ // Mock both state functions for timeout scenario
260
+ vi.mocked(getFavoritesState).mockImplementationOnce(() => ({
261
+ subscribe: () => () => {},
262
+ getCurrent: () => undefined, // This will trigger the resolveFavoritesState call
263
+ observable: favoriteStatusSubject.asObservable(),
264
+ }))
265
+
266
+ vi.mocked(resolveFavoritesState).mockImplementationOnce(() => {
267
+ throw new Error('Favorites service connection timeout')
268
+ })
269
+
270
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
271
+
272
+ // Should return fallback state instead of suspending
273
+ expect(result.current).toEqual({
274
+ favorite: expect.any(Function),
275
+ unfavorite: expect.any(Function),
276
+ isFavorited: false,
277
+ isConnected: false,
278
+ })
279
+
280
+ // Favorite and unfavorite actions should be a no-op
281
+ await act(async () => {
282
+ await result.current.favorite()
283
+ })
284
+
285
+ expect(node.fetch).not.toHaveBeenCalled()
286
+
287
+ await act(async () => {
288
+ await result.current.unfavorite()
289
+ })
290
+
291
+ expect(node.fetch).not.toHaveBeenCalled()
292
+ })
293
+
294
+ it('should still throw non-timeout errors for suspension', async () => {
295
+ vi.mocked(getFavoritesState).mockImplementation(() => ({
296
+ subscribe: () => () => {},
297
+ getCurrent: () => undefined, // This will trigger the resolveFavoritesState call
298
+ observable: favoriteStatusSubject.asObservable(),
299
+ }))
300
+
301
+ // Mock resolveFavoritesState to throw
302
+ const error = new Error('Some other error')
303
+ vi.mocked(resolveFavoritesState).mockImplementation(() => {
304
+ throw error
305
+ })
306
+
307
+ expect(() => {
308
+ renderHook(() => useManageFavorite(mockDocumentHandle))
309
+ }).toThrow(error)
310
+ })
311
+
312
+ it('should not call fetch if connection is not established', async () => {
313
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandle))
314
+
315
+ // Ensure connection is not established
316
+ expect(result.current.isConnected).toBe(false)
317
+
318
+ // Try to favorite
319
+ await act(async () => {
320
+ await result.current.favorite()
321
+ })
322
+
323
+ // Fetch should not have been called due to the new status check
324
+ expect(node.fetch).not.toHaveBeenCalled()
325
+
326
+ // Try to unfavorite
327
+ await act(async () => {
328
+ await result.current.unfavorite()
329
+ })
330
+
331
+ // Fetch should still not have been called
332
+ expect(node.fetch).not.toHaveBeenCalled()
333
+ })
334
+
335
+ it('should include schemaName in payload when provided', async () => {
336
+ const mockDocumentHandleWithSchema = {
337
+ ...mockDocumentHandle,
338
+ schemaName: 'testSchema',
339
+ }
340
+ const {result} = renderHook(() => useManageFavorite(mockDocumentHandleWithSchema))
341
+
342
+ // Simulate connection first
343
+ act(() => {
344
+ statusCallback?.('connected')
345
+ })
346
+
347
+ await act(async () => {
348
+ await result.current.favorite()
349
+ })
350
+
351
+ expect(node.fetch).toHaveBeenCalledWith(
352
+ 'dashboard/v1/events/favorite/mutate',
353
+ {
354
+ document: {
355
+ id: 'mock-id',
356
+ type: 'mock-type',
357
+ resource: {
358
+ id: 'test.test',
359
+ type: 'studio',
360
+ schemaName: 'testSchema', // <-- Expect schemaName here
361
+ },
362
+ },
363
+ eventType: 'added',
364
+ },
365
+ {},
366
+ )
367
+ })
368
+ })
@@ -0,0 +1,210 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {
3
+ type CanvasResource,
4
+ type Events,
5
+ type MediaResource,
6
+ SDK_CHANNEL_NAME,
7
+ SDK_NODE_NAME,
8
+ type StudioResource,
9
+ } from '@sanity/message-protocol'
10
+ import {
11
+ type DocumentHandle,
12
+ type FavoriteStatusResponse,
13
+ type FrameMessage,
14
+ getFavoritesState,
15
+ resolveFavoritesState,
16
+ } from '@sanity/sdk'
17
+ import {useCallback, useMemo, useState, useSyncExternalStore} from 'react'
18
+
19
+ import {useSanityInstance} from '../context/useSanityInstance'
20
+ import {useWindowConnection} from './useWindowConnection'
21
+
22
+ interface ManageFavorite extends FavoriteStatusResponse {
23
+ favorite: () => Promise<void>
24
+ unfavorite: () => Promise<void>
25
+ isFavorited: boolean
26
+ isConnected: boolean
27
+ }
28
+
29
+ interface UseManageFavoriteProps extends DocumentHandle {
30
+ resourceId?: string
31
+ resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
32
+ /**
33
+ * The name of the schema collection this document belongs to.
34
+ * Typically is the name of the workspace when used in the context of a studio.
35
+ */
36
+ schemaName?: string
37
+ }
38
+
39
+ /**
40
+ * @internal
41
+ *
42
+ * This hook provides functionality to add and remove documents from favorites,
43
+ * and tracks the current favorite status of the document.
44
+ * @param documentHandle - The document handle containing document ID and type, like `{_id: '123', _type: 'book'}`
45
+ * @returns An object containing:
46
+ * - `favorite` - Function to add document to favorites
47
+ * - `unfavorite` - Function to remove document from favorites
48
+ * - `isFavorited` - Boolean indicating if document is currently favorited
49
+ * - `isConnected` - Boolean indicating if connection to Dashboard UI is established
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * function FavoriteButton(props: DocumentActionProps) {
54
+ * const {documentId, documentType} = props
55
+ * const {favorite, unfavorite, isFavorited, isConnected} = useManageFavorite({
56
+ * documentId,
57
+ * documentType
58
+ * })
59
+ *
60
+ * return (
61
+ * <Button
62
+ * disabled={!isConnected}
63
+ * onClick={() => isFavorited ? unfavorite() : favorite()}
64
+ * text={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
65
+ * />
66
+ * )
67
+ * }
68
+ *
69
+ * // Wrap the component with Suspense since the hook may suspend
70
+ * function MyDocumentAction(props: DocumentActionProps) {
71
+ * return (
72
+ * <Suspense
73
+ * fallback={
74
+ * <Button
75
+ * text="Loading..."
76
+ * disabled
77
+ * />
78
+ * }
79
+ * >
80
+ * <FavoriteButton {...props} />
81
+ * </Suspense>
82
+ * )
83
+ * }
84
+ * ```
85
+ */
86
+ export function useManageFavorite({
87
+ documentId,
88
+ documentType,
89
+ projectId: paramProjectId,
90
+ dataset: paramDataset,
91
+ resourceId: paramResourceId,
92
+ resourceType,
93
+ schemaName,
94
+ }: UseManageFavoriteProps): ManageFavorite {
95
+ const [status, setStatus] = useState<Status>('idle')
96
+ const {fetch} = useWindowConnection<Events.FavoriteMessage, FrameMessage>({
97
+ name: SDK_NODE_NAME,
98
+ connectTo: SDK_CHANNEL_NAME,
99
+ onStatus: setStatus,
100
+ })
101
+ const instance = useSanityInstance()
102
+ const {config} = instance
103
+ const instanceProjectId = config?.projectId
104
+ const instanceDataset = config?.dataset
105
+ const projectId = paramProjectId ?? instanceProjectId
106
+ const dataset = paramDataset ?? instanceDataset
107
+
108
+ if (resourceType === 'studio' && (!projectId || !dataset)) {
109
+ throw new Error('projectId and dataset are required for studio resources')
110
+ }
111
+ // Compute the final resourceId
112
+ const resourceId =
113
+ resourceType === 'studio' && !paramResourceId ? `${projectId}.${dataset}` : paramResourceId
114
+
115
+ if (!resourceId) {
116
+ throw new Error('resourceId is required for media-library and canvas resources')
117
+ }
118
+
119
+ // used for favoriteStore functions like getFavoritesState and resolveFavoritesState
120
+ const context = useMemo(
121
+ () => ({
122
+ documentId,
123
+ documentType,
124
+ resourceId,
125
+ resourceType,
126
+ schemaName,
127
+ }),
128
+ [documentId, documentType, resourceId, resourceType, schemaName],
129
+ )
130
+
131
+ // Get favorite status using StateSource
132
+ const favoriteState = getFavoritesState(instance, context)
133
+ const state = useSyncExternalStore(favoriteState.subscribe, favoriteState.getCurrent)
134
+
135
+ const isFavorited = state?.isFavorited ?? false
136
+
137
+ const handleFavoriteAction = useCallback(
138
+ async (action: 'added' | 'removed') => {
139
+ if (status !== 'connected' || !fetch || !documentId || !documentType || !resourceType) return
140
+
141
+ try {
142
+ const payload = {
143
+ eventType: action,
144
+ document: {
145
+ id: documentId,
146
+ type: documentType,
147
+ resource: {
148
+ ...{
149
+ id: resourceId,
150
+ type: resourceType,
151
+ },
152
+ ...(schemaName ? {schemaName} : {}),
153
+ },
154
+ },
155
+ }
156
+
157
+ const res = await fetch<{success: boolean}>('dashboard/v1/events/favorite/mutate', payload)
158
+ if (res.success) {
159
+ // Force a re-fetch of the favorite status after successful mutation
160
+ await resolveFavoritesState(instance, context)
161
+ }
162
+ } catch (err) {
163
+ // eslint-disable-next-line no-console
164
+ console.error(`Failed to ${action === 'added' ? 'favorite' : 'unfavorite'} document:`, err)
165
+ throw err
166
+ }
167
+ },
168
+ [
169
+ fetch,
170
+ documentId,
171
+ documentType,
172
+ resourceId,
173
+ resourceType,
174
+ schemaName,
175
+ instance,
176
+ context,
177
+ status,
178
+ ],
179
+ )
180
+
181
+ const favorite = useCallback(() => handleFavoriteAction('added'), [handleFavoriteAction])
182
+ const unfavorite = useCallback(() => handleFavoriteAction('removed'), [handleFavoriteAction])
183
+
184
+ // if state is undefined, we should suspend
185
+ if (!state) {
186
+ try {
187
+ const promise = resolveFavoritesState(instance, context)
188
+ throw promise
189
+ } catch (err) {
190
+ // If we get a timeout error, return a fallback state instead of suspending
191
+ if (err instanceof Error && err.message === 'Favorites service connection timeout') {
192
+ return {
193
+ favorite: async () => {},
194
+ unfavorite: async () => {},
195
+ isFavorited: false,
196
+ isConnected: false,
197
+ }
198
+ }
199
+ // For other errors, continue with suspension
200
+ throw err
201
+ }
202
+ }
203
+
204
+ return {
205
+ favorite,
206
+ unfavorite,
207
+ isFavorited,
208
+ isConnected: status === 'connected',
209
+ }
210
+ }