@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,291 @@
|
|
|
1
|
+
import {type Message, type Status} from '@sanity/comlink'
|
|
2
|
+
import {renderHook, waitFor} from '@testing-library/react'
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {useWindowConnection, type WindowConnection} from '../comlink/useWindowConnection'
|
|
6
|
+
import {useStudioWorkspacesByProjectIdDataset} from './useStudioWorkspacesByProjectIdDataset'
|
|
7
|
+
|
|
8
|
+
vi.mock('../comlink/useWindowConnection', () => ({
|
|
9
|
+
useWindowConnection: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const mockWorkspaceData = {
|
|
13
|
+
context: {
|
|
14
|
+
availableResources: [
|
|
15
|
+
{
|
|
16
|
+
id: 'user1-workspace1',
|
|
17
|
+
projectId: 'project1',
|
|
18
|
+
dataset: 'dataset1',
|
|
19
|
+
name: 'workspace1',
|
|
20
|
+
title: 'Workspace 1',
|
|
21
|
+
basePath: '/workspace1',
|
|
22
|
+
userApplicationId: 'user1',
|
|
23
|
+
url: 'https://test1.sanity.studio',
|
|
24
|
+
type: 'studio',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'user1-workspace2',
|
|
28
|
+
projectId: 'project1',
|
|
29
|
+
dataset: 'dataset1',
|
|
30
|
+
name: 'workspace2',
|
|
31
|
+
title: 'Workspace 2',
|
|
32
|
+
basePath: '/workspace2',
|
|
33
|
+
userApplicationId: 'user1',
|
|
34
|
+
url: 'https://test2.sanity.studio',
|
|
35
|
+
type: 'studio',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'user2-workspace3',
|
|
39
|
+
projectId: 'project2',
|
|
40
|
+
dataset: 'dataset2',
|
|
41
|
+
name: 'workspace3',
|
|
42
|
+
title: 'Workspace 3',
|
|
43
|
+
basePath: '/workspace3',
|
|
44
|
+
userApplicationId: 'user2',
|
|
45
|
+
url: 'https://test3.sanity.studio',
|
|
46
|
+
type: 'studio',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('useStudioWorkspacesByResourceId', () => {
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.clearAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should return empty workspaces and connected=false when not connected', async () => {
|
|
58
|
+
// Create a mock that captures the onStatus callback
|
|
59
|
+
let capturedOnStatus: ((status: Status) => void) | undefined
|
|
60
|
+
|
|
61
|
+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
|
|
62
|
+
capturedOnStatus = onStatus
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
fetch: undefined,
|
|
66
|
+
sendMessage: vi.fn(),
|
|
67
|
+
} as unknown as WindowConnection<Message>
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const {result} = renderHook(() => useStudioWorkspacesByProjectIdDataset())
|
|
71
|
+
|
|
72
|
+
// Call onStatus with 'idle' to simulate not connected
|
|
73
|
+
if (capturedOnStatus) capturedOnStatus('idle')
|
|
74
|
+
|
|
75
|
+
expect(result.current).toEqual({
|
|
76
|
+
workspacesByProjectIdAndDataset: {},
|
|
77
|
+
error: null,
|
|
78
|
+
isConnected: false,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should process workspaces into lookup by projectId:dataset', async () => {
|
|
83
|
+
const mockFetch = vi.fn().mockResolvedValue(mockWorkspaceData)
|
|
84
|
+
let capturedOnStatus: ((status: Status) => void) | undefined
|
|
85
|
+
|
|
86
|
+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
|
|
87
|
+
capturedOnStatus = onStatus
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
fetch: mockFetch,
|
|
91
|
+
sendMessage: vi.fn(),
|
|
92
|
+
} as unknown as WindowConnection<Message>
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const {result} = renderHook(() => useStudioWorkspacesByProjectIdDataset())
|
|
96
|
+
|
|
97
|
+
// Call onStatus with 'connected' to simulate connected state
|
|
98
|
+
if (capturedOnStatus) capturedOnStatus('connected')
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(result.current.workspacesByProjectIdAndDataset).toEqual({
|
|
102
|
+
'project1:dataset1': [
|
|
103
|
+
{
|
|
104
|
+
id: 'user1-workspace1',
|
|
105
|
+
projectId: 'project1',
|
|
106
|
+
dataset: 'dataset1',
|
|
107
|
+
name: 'workspace1',
|
|
108
|
+
title: 'Workspace 1',
|
|
109
|
+
basePath: '/workspace1',
|
|
110
|
+
userApplicationId: 'user1',
|
|
111
|
+
url: 'https://test1.sanity.studio',
|
|
112
|
+
type: 'studio',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'user1-workspace2',
|
|
116
|
+
projectId: 'project1',
|
|
117
|
+
dataset: 'dataset1',
|
|
118
|
+
name: 'workspace2',
|
|
119
|
+
title: 'Workspace 2',
|
|
120
|
+
basePath: '/workspace2',
|
|
121
|
+
userApplicationId: 'user1',
|
|
122
|
+
url: 'https://test2.sanity.studio',
|
|
123
|
+
type: 'studio',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
'project2:dataset2': [
|
|
127
|
+
{
|
|
128
|
+
id: 'user2-workspace3',
|
|
129
|
+
projectId: 'project2',
|
|
130
|
+
dataset: 'dataset2',
|
|
131
|
+
name: 'workspace3',
|
|
132
|
+
title: 'Workspace 3',
|
|
133
|
+
basePath: '/workspace3',
|
|
134
|
+
userApplicationId: 'user2',
|
|
135
|
+
url: 'https://test3.sanity.studio',
|
|
136
|
+
type: 'studio',
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
})
|
|
140
|
+
expect(result.current.error).toBeNull()
|
|
141
|
+
expect(result.current.isConnected).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
145
|
+
'dashboard/v1/bridge/context',
|
|
146
|
+
undefined,
|
|
147
|
+
expect.any(Object),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should handle fetch errors', async () => {
|
|
152
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
|
|
153
|
+
let capturedOnStatus: ((status: Status) => void) | undefined
|
|
154
|
+
|
|
155
|
+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
|
|
156
|
+
capturedOnStatus = onStatus
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
fetch: mockFetch,
|
|
160
|
+
sendMessage: vi.fn(),
|
|
161
|
+
} as unknown as WindowConnection<Message>
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const {result} = renderHook(() => useStudioWorkspacesByProjectIdDataset())
|
|
165
|
+
|
|
166
|
+
// Call onStatus with 'connected' to simulate connected state
|
|
167
|
+
if (capturedOnStatus) capturedOnStatus('connected')
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(result.current.workspacesByProjectIdAndDataset).toEqual({})
|
|
171
|
+
expect(result.current.error).toBe('Failed to fetch workspaces')
|
|
172
|
+
expect(result.current.isConnected).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should handle AbortError silently', async () => {
|
|
177
|
+
const abortError = new Error('Aborted')
|
|
178
|
+
abortError.name = 'AbortError'
|
|
179
|
+
const mockFetch = vi.fn().mockRejectedValue(abortError)
|
|
180
|
+
let capturedOnStatus: ((status: Status) => void) | undefined
|
|
181
|
+
|
|
182
|
+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
|
|
183
|
+
capturedOnStatus = onStatus
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
fetch: mockFetch,
|
|
187
|
+
sendMessage: vi.fn(),
|
|
188
|
+
} as unknown as WindowConnection<Message>
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const {result} = renderHook(() => useStudioWorkspacesByProjectIdDataset())
|
|
192
|
+
|
|
193
|
+
// Call onStatus with 'connected' to simulate connected state
|
|
194
|
+
if (capturedOnStatus) capturedOnStatus('connected')
|
|
195
|
+
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(result.current.workspacesByProjectIdAndDataset).toEqual({})
|
|
198
|
+
expect(result.current.error).toBeNull()
|
|
199
|
+
expect(result.current.isConnected).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should filter non-studio resources and handle resources without projectId/dataset', async () => {
|
|
204
|
+
const mockDataWithMixedResources = {
|
|
205
|
+
context: {
|
|
206
|
+
availableResources: [
|
|
207
|
+
{
|
|
208
|
+
id: 'studio1',
|
|
209
|
+
projectId: 'project1',
|
|
210
|
+
dataset: 'dataset1',
|
|
211
|
+
name: 'workspace1',
|
|
212
|
+
title: 'Workspace 1',
|
|
213
|
+
basePath: '/workspace1',
|
|
214
|
+
userApplicationId: 'user1',
|
|
215
|
+
url: 'https://test1.sanity.studio',
|
|
216
|
+
type: 'studio',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'non-studio',
|
|
220
|
+
projectId: 'project2',
|
|
221
|
+
dataset: 'dataset2',
|
|
222
|
+
name: 'non-studio',
|
|
223
|
+
title: 'Non Studio Resource',
|
|
224
|
+
basePath: '/non-studio',
|
|
225
|
+
userApplicationId: 'user2',
|
|
226
|
+
url: 'https://test2.sanity.studio',
|
|
227
|
+
type: 'other',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'studio-no-project',
|
|
231
|
+
name: 'incomplete-workspace',
|
|
232
|
+
title: 'Incomplete Workspace',
|
|
233
|
+
basePath: '/incomplete',
|
|
234
|
+
userApplicationId: 'user3',
|
|
235
|
+
url: 'https://test3.sanity.studio',
|
|
236
|
+
type: 'studio',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'studio-no-dataset',
|
|
240
|
+
projectId: 'project3',
|
|
241
|
+
name: 'no-dataset-workspace',
|
|
242
|
+
title: 'No Dataset Workspace',
|
|
243
|
+
basePath: '/no-dataset',
|
|
244
|
+
userApplicationId: 'user4',
|
|
245
|
+
url: 'https://test4.sanity.studio',
|
|
246
|
+
type: 'studio',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const mockFetch = vi.fn().mockResolvedValue(mockDataWithMixedResources)
|
|
253
|
+
let capturedOnStatus: ((status: Status) => void) | undefined
|
|
254
|
+
|
|
255
|
+
vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
|
|
256
|
+
capturedOnStatus = onStatus
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
fetch: mockFetch,
|
|
260
|
+
sendMessage: vi.fn(),
|
|
261
|
+
} as unknown as WindowConnection<Message>
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const {result} = renderHook(() => useStudioWorkspacesByProjectIdDataset())
|
|
265
|
+
|
|
266
|
+
// Call onStatus with 'connected' to simulate connected state
|
|
267
|
+
if (capturedOnStatus) capturedOnStatus('connected')
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
// Should only include the studio resource with valid projectId and dataset
|
|
271
|
+
expect(result.current.workspacesByProjectIdAndDataset['project1:dataset1']).toHaveLength(1)
|
|
272
|
+
expect(result.current.workspacesByProjectIdAndDataset['project1:dataset1'][0].id).toBe(
|
|
273
|
+
'studio1',
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Should not include the non-studio resource
|
|
277
|
+
expect(result.current.workspacesByProjectIdAndDataset['project2:dataset2']).toBeUndefined()
|
|
278
|
+
|
|
279
|
+
// Should group resources without projectId or dataset under NO_PROJECT_ID:NO_DATASET
|
|
280
|
+
expect(
|
|
281
|
+
result.current.workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'],
|
|
282
|
+
).toHaveLength(2)
|
|
283
|
+
expect(
|
|
284
|
+
result.current.workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'].map((r) => r.id),
|
|
285
|
+
).toEqual(['studio-no-project', 'studio-no-dataset'])
|
|
286
|
+
|
|
287
|
+
expect(result.current.error).toBeNull()
|
|
288
|
+
expect(result.current.isConnected).toBe(true)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {type Status} from '@sanity/comlink'
|
|
2
|
+
import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
3
|
+
import {useEffect, useState} from 'react'
|
|
4
|
+
|
|
5
|
+
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
6
|
+
|
|
7
|
+
export interface DashboardResource {
|
|
8
|
+
id: string
|
|
9
|
+
name: string
|
|
10
|
+
title: string
|
|
11
|
+
basePath: string
|
|
12
|
+
projectId: string
|
|
13
|
+
dataset: string
|
|
14
|
+
type: string
|
|
15
|
+
userApplicationId: string
|
|
16
|
+
url: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WorkspacesByProjectIdDataset {
|
|
20
|
+
[key: `${string}:${string}`]: DashboardResource[] // key format: `${projectId}:${dataset}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StudioWorkspacesResult {
|
|
24
|
+
workspacesByProjectIdAndDataset: WorkspacesByProjectIdDataset
|
|
25
|
+
error: string | null
|
|
26
|
+
isConnected: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hook that fetches studio workspaces and organizes them by projectId:dataset
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult {
|
|
34
|
+
const [workspacesByProjectIdAndDataset, setWorkspacesByProjectIdAndDataset] =
|
|
35
|
+
useState<WorkspacesByProjectIdDataset>({})
|
|
36
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
37
|
+
const [error, setError] = useState<string | null>(null)
|
|
38
|
+
|
|
39
|
+
const {fetch} = useWindowConnection({
|
|
40
|
+
name: SDK_NODE_NAME,
|
|
41
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
42
|
+
onStatus: setStatus,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Once computed, this should probably be in a store and poll for changes
|
|
46
|
+
// However, our stores are currently being refactored
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!fetch || status !== 'connected') return
|
|
49
|
+
|
|
50
|
+
async function fetchWorkspaces(signal: AbortSignal) {
|
|
51
|
+
try {
|
|
52
|
+
const data = await fetch<{
|
|
53
|
+
context: {availableResources: Array<DashboardResource>}
|
|
54
|
+
}>('dashboard/v1/bridge/context', undefined, {signal})
|
|
55
|
+
|
|
56
|
+
const workspaceMap: WorkspacesByProjectIdDataset = {}
|
|
57
|
+
const noProjectIdAndDataset: DashboardResource[] = []
|
|
58
|
+
|
|
59
|
+
data.context.availableResources.forEach((resource) => {
|
|
60
|
+
if (resource.type !== 'studio') return
|
|
61
|
+
if (!resource.projectId || !resource.dataset) {
|
|
62
|
+
noProjectIdAndDataset.push(resource)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const key = `${resource.projectId}:${resource.dataset}` as const
|
|
66
|
+
if (!workspaceMap[key]) {
|
|
67
|
+
workspaceMap[key] = []
|
|
68
|
+
}
|
|
69
|
+
workspaceMap[key].push(resource)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (noProjectIdAndDataset.length > 0) {
|
|
73
|
+
workspaceMap['NO_PROJECT_ID:NO_DATASET'] = noProjectIdAndDataset
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setWorkspacesByProjectIdAndDataset(workspaceMap)
|
|
77
|
+
setError(null)
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
if (err instanceof Error) {
|
|
80
|
+
if (err.name === 'AbortError') {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
setError('Failed to fetch workspaces')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController()
|
|
89
|
+
fetchWorkspaces(controller.signal)
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
controller.abort()
|
|
93
|
+
}
|
|
94
|
+
}, [fetch, status])
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
workspacesByProjectIdAndDataset,
|
|
98
|
+
error,
|
|
99
|
+
isConnected: status === 'connected',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {getDatasetsState, type ProjectHandle, type SanityInstance} from '@sanity/sdk'
|
|
2
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
vi.mock('@sanity/sdk', () => ({
|
|
8
|
+
getDatasetsState: vi.fn(() => ({
|
|
9
|
+
getCurrent: vi.fn(() => undefined), // Mocking getCurrent to satisfy the call within shouldSuspend
|
|
10
|
+
})),
|
|
11
|
+
resolveDatasets: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
vi.mock('../helpers/createStateSourceHook', () => ({
|
|
14
|
+
createStateSourceHook: vi.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
describe('useDatasets', () => {
|
|
18
|
+
// Use beforeEach to reset modules and ensure mocks are fresh for each test
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.resetModules()
|
|
21
|
+
// Re-mock dependencies for each test after resetModules
|
|
22
|
+
vi.mock('@sanity/sdk', () => ({
|
|
23
|
+
getDatasetsState: vi.fn(() => ({
|
|
24
|
+
getCurrent: vi.fn(() => undefined),
|
|
25
|
+
})),
|
|
26
|
+
resolveDatasets: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
vi.mock('../helpers/createStateSourceHook', () => ({
|
|
29
|
+
createStateSourceHook: vi.fn(),
|
|
30
|
+
}))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should call createStateSourceHook with correct arguments on import', async () => {
|
|
34
|
+
// Dynamically import the hook *after* mocks are set up and modules reset
|
|
35
|
+
await import('./useDatasets')
|
|
36
|
+
|
|
37
|
+
// Check if createStateSourceHook was called during the module evaluation (import)
|
|
38
|
+
expect(createStateSourceHook).toHaveBeenCalled()
|
|
39
|
+
expect(createStateSourceHook).toHaveBeenCalledWith(
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
getState: expect.any(Function),
|
|
42
|
+
shouldSuspend: expect.any(Function),
|
|
43
|
+
suspender: expect.any(Function), // Actual function reference doesn't matter here as it's mocked
|
|
44
|
+
getConfig: expect.any(Function), // Actual function reference doesn't matter here
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('shouldSuspend should call getDatasetsState and getCurrent', async () => {
|
|
50
|
+
// Dynamically import the hook *after* mocks are set up and modules reset
|
|
51
|
+
await import('./useDatasets')
|
|
52
|
+
|
|
53
|
+
// Get the arguments passed to createStateSourceHook
|
|
54
|
+
// Need to ensure createStateSourceHook mock is correctly typed for access
|
|
55
|
+
const mockCreateStateSourceHook = createStateSourceHook as ReturnType<typeof vi.fn>
|
|
56
|
+
expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0)
|
|
57
|
+
const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0]
|
|
58
|
+
const shouldSuspend = createStateSourceHookArgs.shouldSuspend
|
|
59
|
+
|
|
60
|
+
// Mock instance and projectHandle for the test call
|
|
61
|
+
const mockInstance = {} as SanityInstance // Use specific type
|
|
62
|
+
const mockProjectHandle = {} as ProjectHandle // Use specific type
|
|
63
|
+
|
|
64
|
+
// Call the shouldSuspend function
|
|
65
|
+
const result = shouldSuspend(mockInstance, mockProjectHandle)
|
|
66
|
+
|
|
67
|
+
// Assert that getDatasetsState was called with the correct arguments
|
|
68
|
+
// Need to ensure getDatasetsState mock is correctly typed for access
|
|
69
|
+
const mockGetDatasetsState = getDatasetsState as ReturnType<typeof vi.fn>
|
|
70
|
+
expect(mockGetDatasetsState).toHaveBeenCalledWith(mockInstance, mockProjectHandle)
|
|
71
|
+
|
|
72
|
+
// Assert that getCurrent was called on the result of getDatasetsState
|
|
73
|
+
expect(mockGetDatasetsState.mock.results.length).toBeGreaterThan(0)
|
|
74
|
+
const getDatasetsStateMockResult = mockGetDatasetsState.mock.results[0].value
|
|
75
|
+
expect(getDatasetsStateMockResult.getCurrent).toHaveBeenCalled()
|
|
76
|
+
|
|
77
|
+
// Assert the result of shouldSuspend based on the mocked getCurrent value
|
|
78
|
+
expect(result).toBe(true) // Since getCurrent is mocked to return undefined
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {type DatasetsResponse} from '@sanity/client'
|
|
2
|
+
import {
|
|
3
|
+
getDatasetsState,
|
|
4
|
+
type ProjectHandle,
|
|
5
|
+
resolveDatasets,
|
|
6
|
+
type SanityInstance,
|
|
7
|
+
type StateSource,
|
|
8
|
+
} from '@sanity/sdk'
|
|
9
|
+
import {identity} from 'rxjs'
|
|
10
|
+
|
|
11
|
+
import {createStateSourceHook} from '../helpers/createStateSourceHook'
|
|
12
|
+
|
|
13
|
+
type UseDatasets = {
|
|
14
|
+
/**
|
|
15
|
+
*
|
|
16
|
+
* Returns metadata for each dataset the current user has access to.
|
|
17
|
+
*
|
|
18
|
+
* @category Datasets
|
|
19
|
+
* @returns The metadata for your the datasets
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const datasets = useDatasets()
|
|
24
|
+
*
|
|
25
|
+
* return (
|
|
26
|
+
* <select>
|
|
27
|
+
* {datasets.map((dataset) => (
|
|
28
|
+
* <option key={dataset.name}>{dataset.name}</option>
|
|
29
|
+
* ))}
|
|
30
|
+
* </select>
|
|
31
|
+
* )
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
*/
|
|
35
|
+
(): DatasetsResponse
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @public
|
|
40
|
+
* @function
|
|
41
|
+
*/
|
|
42
|
+
export const useDatasets: UseDatasets = createStateSourceHook({
|
|
43
|
+
getState: getDatasetsState as (
|
|
44
|
+
instance: SanityInstance,
|
|
45
|
+
projectHandle?: ProjectHandle,
|
|
46
|
+
) => StateSource<DatasetsResponse>,
|
|
47
|
+
shouldSuspend: (instance, projectHandle?: ProjectHandle) =>
|
|
48
|
+
// remove `undefined` since we're suspending when that is the case
|
|
49
|
+
getDatasetsState(instance, projectHandle).getCurrent() === undefined,
|
|
50
|
+
suspender: resolveDatasets,
|
|
51
|
+
getConfig: identity as (projectHandle?: ProjectHandle) => ProjectHandle,
|
|
52
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {applyDocumentActions} from '@sanity/sdk'
|
|
2
|
+
import {describe, it} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
5
|
+
|
|
6
|
+
vi.mock('../helpers/createCallbackHook', () => ({
|
|
7
|
+
createCallbackHook: vi.fn((cb) => () => cb),
|
|
8
|
+
}))
|
|
9
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
10
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
11
|
+
return {...original, applyDocumentActions: vi.fn()}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('useApplyDocumentActions', () => {
|
|
15
|
+
it('calls `createCallbackHook` with `applyActions`', async () => {
|
|
16
|
+
expect(createCallbackHook).not.toHaveBeenCalled()
|
|
17
|
+
await import('./useApplyDocumentActions')
|
|
18
|
+
expect(createCallbackHook).toHaveBeenCalledWith(applyDocumentActions)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActionsResult,
|
|
3
|
+
applyDocumentActions,
|
|
4
|
+
type ApplyDocumentActionsOptions,
|
|
5
|
+
type DocumentAction,
|
|
6
|
+
} from '@sanity/sdk'
|
|
7
|
+
import {type SanityDocumentResult} from 'groq'
|
|
8
|
+
|
|
9
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
10
|
+
// this import is used in an `{@link useEditDocument}`
|
|
11
|
+
// eslint-disable-next-line unused-imports/no-unused-imports, import/consistent-type-specifier-style
|
|
12
|
+
import type {useEditDocument} from './useEditDocument'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @beta
|
|
16
|
+
*/
|
|
17
|
+
interface UseApplyDocumentActions {
|
|
18
|
+
(): <
|
|
19
|
+
TDocumentType extends string = string,
|
|
20
|
+
TDataset extends string = string,
|
|
21
|
+
TProjectId extends string = string,
|
|
22
|
+
>(
|
|
23
|
+
action:
|
|
24
|
+
| DocumentAction<TDocumentType, TDataset, TProjectId>
|
|
25
|
+
| DocumentAction<TDocumentType, TDataset, TProjectId>[],
|
|
26
|
+
options?: ApplyDocumentActionsOptions,
|
|
27
|
+
) => Promise<ActionsResult<SanityDocumentResult<TDocumentType, TDataset, TProjectId>>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @beta
|
|
32
|
+
*
|
|
33
|
+
* Provides a stable callback function for applying one or more document actions.
|
|
34
|
+
*
|
|
35
|
+
* This hook wraps the core `applyDocumentActions` functionality from `@sanity/sdk`,
|
|
36
|
+
* integrating it with the React component lifecycle and {@link SanityInstance}.
|
|
37
|
+
* It allows you to apply actions generated by functions like `createDocument`,
|
|
38
|
+
* `editDocument`, `deleteDocument`, `publishDocument`, `unpublishDocument`,
|
|
39
|
+
* and `discardDocument` to documents.
|
|
40
|
+
*
|
|
41
|
+
* Features:
|
|
42
|
+
* - Applies one or multiple `DocumentAction` objects.
|
|
43
|
+
* - Supports optimistic updates: Local state reflects changes immediately.
|
|
44
|
+
* - Handles batching: Multiple actions passed together are sent as a single atomic transaction.
|
|
45
|
+
* - Integrates with the collaborative editing engine for conflict resolution and state synchronization.
|
|
46
|
+
*
|
|
47
|
+
* @category Documents
|
|
48
|
+
* @returns A stable callback function. When called with a single `DocumentAction` or an array of `DocumentAction`s,
|
|
49
|
+
* it returns a promise that resolves to an {@link ActionsResult}. The `ActionsResult` contains information about the
|
|
50
|
+
* outcome, including optimistic results if applicable.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* This hook is a fundamental part of interacting with document state programmatically.
|
|
54
|
+
* It operates within the same unified pipeline as other document hooks like `useDocument` (for reading state)
|
|
55
|
+
* and {@link useEditDocument} (a higher-level hook specifically for edits).
|
|
56
|
+
*
|
|
57
|
+
* When multiple actions are provided in a single call, they are guaranteed to be submitted
|
|
58
|
+
* as a single transaction to Content Lake. This ensures atomicity for related operations (e.g., creating and publishing a document).
|
|
59
|
+
*
|
|
60
|
+
* @function
|
|
61
|
+
*
|
|
62
|
+
* @example Publish or unpublish a document
|
|
63
|
+
* ```tsx
|
|
64
|
+
* import {
|
|
65
|
+
* publishDocument,
|
|
66
|
+
* unpublishDocument,
|
|
67
|
+
* useApplyDocumentActions,
|
|
68
|
+
* type DocumentHandle
|
|
69
|
+
* } from '@sanity/sdk-react'
|
|
70
|
+
*
|
|
71
|
+
* // Define props using the DocumentHandle type
|
|
72
|
+
* interface PublishControlsProps {
|
|
73
|
+
* doc: DocumentHandle
|
|
74
|
+
* }
|
|
75
|
+
*
|
|
76
|
+
* function PublishControls({doc}: PublishControlsProps) {
|
|
77
|
+
* const apply = useApplyDocumentActions()
|
|
78
|
+
*
|
|
79
|
+
* const handlePublish = () => apply(publishDocument(doc))
|
|
80
|
+
* const handleUnpublish = () => apply(unpublishDocument(doc))
|
|
81
|
+
*
|
|
82
|
+
* return (
|
|
83
|
+
* <>
|
|
84
|
+
* <button onClick={handlePublish}>Publish</button>
|
|
85
|
+
* <button onClick={handleUnpublish}>Unpublish</button>
|
|
86
|
+
* </>
|
|
87
|
+
* )
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* @example Create and publish a new document
|
|
92
|
+
* ```tsx
|
|
93
|
+
* import {
|
|
94
|
+
* createDocument,
|
|
95
|
+
* publishDocument,
|
|
96
|
+
* createDocumentHandle,
|
|
97
|
+
* useApplyDocumentActions
|
|
98
|
+
* } from '@sanity/sdk-react'
|
|
99
|
+
*
|
|
100
|
+
* function CreateAndPublishButton({documentType}: {documentType: string}) {
|
|
101
|
+
* const apply = useApplyDocumentActions()
|
|
102
|
+
*
|
|
103
|
+
* const handleCreateAndPublish = () => {
|
|
104
|
+
* // Create a new handle inside the handler
|
|
105
|
+
* const newDocHandle = createDocumentHandle({ documentId: crypto.randomUUID(), documentType })
|
|
106
|
+
*
|
|
107
|
+
* // Apply multiple actions for the new handle as a single transaction
|
|
108
|
+
* apply([
|
|
109
|
+
* createDocument(newDocHandle),
|
|
110
|
+
* publishDocument(newDocHandle),
|
|
111
|
+
* ])
|
|
112
|
+
* }
|
|
113
|
+
*
|
|
114
|
+
* return (
|
|
115
|
+
* <button onClick={handleCreateAndPublish}>
|
|
116
|
+
* I'm feeling lucky
|
|
117
|
+
* </button>
|
|
118
|
+
* )
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export const useApplyDocumentActions = createCallbackHook(
|
|
123
|
+
applyDocumentActions,
|
|
124
|
+
) as UseApplyDocumentActions
|