@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,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