@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.
- package/README.md +6 -100
- package/dist/index.d.ts +2390 -2
- package/dist/index.js +1119 -2
- package/dist/index.js.map +1 -1
- package/package.json +35 -49
- package/src/_exports/index.ts +2 -10
- package/src/_exports/sdk-react.ts +73 -0
- package/src/components/SDKProvider.test.tsx +103 -0
- package/src/components/SDKProvider.tsx +52 -0
- package/src/components/SanityApp.test.tsx +244 -0
- package/src/components/SanityApp.tsx +106 -0
- package/src/components/auth/AuthBoundary.test.tsx +204 -29
- package/src/components/auth/AuthBoundary.tsx +96 -19
- package/src/components/auth/ConfigurationError.ts +22 -0
- package/src/components/auth/LoginCallback.test.tsx +22 -24
- package/src/components/auth/LoginCallback.tsx +6 -16
- package/src/components/auth/LoginError.test.tsx +11 -18
- package/src/components/auth/LoginError.tsx +43 -25
- package/src/components/utils.ts +22 -0
- package/src/context/ResourceProvider.test.tsx +157 -0
- package/src/context/ResourceProvider.tsx +111 -0
- package/src/context/SanityInstanceContext.ts +4 -0
- package/src/hooks/_synchronous-groq-js.mjs +4 -0
- package/src/hooks/auth/useAuthState.tsx +4 -5
- package/src/hooks/auth/useAuthToken.tsx +1 -1
- package/src/hooks/auth/useCurrentUser.tsx +28 -4
- package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
- package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
- package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
- package/src/hooks/auth/useLogOut.test.tsx +2 -2
- package/src/hooks/auth/useLogOut.tsx +1 -1
- package/src/hooks/auth/useLoginUrl.tsx +14 -0
- package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
- package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
- package/src/hooks/client/useClient.ts +13 -33
- package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
- package/src/hooks/comlink/useFrameConnection.ts +107 -0
- package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
- package/src/hooks/comlink/useManageFavorite.ts +210 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
- package/src/hooks/comlink/useWindowConnection.ts +123 -0
- package/src/hooks/context/useSanityInstance.test.tsx +157 -15
- package/src/hooks/context/useSanityInstance.ts +68 -11
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
- package/src/hooks/datasets/useDatasets.test.ts +80 -0
- package/src/hooks/datasets/useDatasets.ts +52 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
- package/src/hooks/document/useApplyDocumentActions.ts +124 -0
- package/src/hooks/document/useDocument.test.ts +118 -0
- package/src/hooks/document/useDocument.ts +212 -0
- package/src/hooks/document/useDocumentEvent.test.ts +62 -0
- package/src/hooks/document/useDocumentEvent.ts +94 -0
- package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
- package/src/hooks/document/useDocumentPermissions.ts +131 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
- package/src/hooks/document/useEditDocument.test.ts +196 -0
- package/src/hooks/document/useEditDocument.ts +314 -0
- package/src/hooks/documents/useDocuments.test.tsx +179 -0
- package/src/hooks/documents/useDocuments.ts +300 -0
- package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
- package/src/hooks/helpers/createCallbackHook.tsx +1 -1
- package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
- package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
- package/src/hooks/preview/usePreview.test.tsx +85 -17
- package/src/hooks/preview/usePreview.tsx +81 -22
- package/src/hooks/projection/useProjection.test.tsx +283 -0
- package/src/hooks/projection/useProjection.ts +232 -0
- package/src/hooks/projects/useProject.test.ts +80 -0
- package/src/hooks/projects/useProject.ts +51 -0
- package/src/hooks/projects/useProjects.test.ts +77 -0
- package/src/hooks/projects/useProjects.ts +45 -0
- package/src/hooks/query/useQuery.test.tsx +188 -0
- package/src/hooks/query/useQuery.ts +193 -0
- package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
- package/src/hooks/releases/useActiveReleases.ts +39 -0
- package/src/hooks/releases/usePerspective.test.tsx +120 -0
- package/src/hooks/releases/usePerspective.ts +49 -0
- package/src/hooks/users/useUsers.test.tsx +330 -0
- package/src/hooks/users/useUsers.ts +120 -0
- package/src/utils/getEnv.ts +21 -0
- package/src/version.ts +8 -0
- package/src/vite-env.d.ts +10 -0
- package/dist/_chunks-es/useLogOut.js +0 -44
- package/dist/_chunks-es/useLogOut.js.map +0 -1
- package/dist/assets/bundle-CcAyERuZ.css +0 -11
- package/dist/components.d.ts +0 -259
- package/dist/components.js +0 -301
- package/dist/components.js.map +0 -1
- package/dist/hooks.d.ts +0 -186
- package/dist/hooks.js +0 -81
- package/dist/hooks.js.map +0 -1
- package/src/_exports/components.ts +0 -13
- package/src/_exports/hooks.ts +0 -9
- package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
- package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
- package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
- package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
- package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
- package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
- package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
- package/src/components/Login/LoginLinks.test.tsx +0 -100
- package/src/components/Login/LoginLinks.tsx +0 -73
- package/src/components/auth/Login.test.tsx +0 -41
- package/src/components/auth/Login.tsx +0 -45
- package/src/components/auth/LoginFooter.test.tsx +0 -29
- package/src/components/auth/LoginFooter.tsx +0 -65
- package/src/components/auth/LoginLayout.test.tsx +0 -33
- package/src/components/auth/LoginLayout.tsx +0 -81
- package/src/components/context/SanityProvider.test.tsx +0 -25
- package/src/components/context/SanityProvider.tsx +0 -42
- package/src/css/css.config.js +0 -220
- package/src/css/paramour.css +0 -2347
- package/src/css/styles.css +0 -11
- package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
- package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
- package/src/hooks/auth/useLoginUrls.tsx +0 -51
- package/src/hooks/client/useClient.test.tsx +0 -130
- package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
- 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
|
+
}
|