@sanity/sdk-react 0.0.0-alpha.9 → 0.0.0-rc.1

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 (94) hide show
  1. package/README.md +33 -126
  2. package/dist/index.d.ts +4742 -2
  3. package/dist/index.js +1054 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +19 -43
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +90 -0
  8. package/src/components/Login/LoginLinks.tsx +58 -0
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +104 -2
  12. package/src/components/SanityApp.tsx +54 -17
  13. package/src/components/auth/AuthBoundary.test.tsx +2 -2
  14. package/src/components/auth/AuthBoundary.tsx +13 -3
  15. package/src/components/auth/Login.test.tsx +1 -1
  16. package/src/components/auth/Login.tsx +11 -26
  17. package/src/components/auth/LoginCallback.tsx +4 -7
  18. package/src/components/auth/LoginError.tsx +12 -8
  19. package/src/components/auth/LoginFooter.tsx +13 -20
  20. package/src/components/auth/LoginLayout.tsx +8 -9
  21. package/src/components/auth/authTestHelpers.tsx +1 -8
  22. package/src/components/utils.ts +22 -0
  23. package/src/context/SanityInstanceContext.ts +4 -0
  24. package/src/context/SanityProvider.test.tsx +1 -1
  25. package/src/context/SanityProvider.tsx +10 -8
  26. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  27. package/src/hooks/auth/useAuthState.tsx +0 -2
  28. package/src/hooks/auth/useCurrentUser.tsx +26 -20
  29. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  30. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  31. package/src/hooks/client/useClient.ts +8 -30
  32. package/src/hooks/comlink/useFrameConnection.test.tsx +55 -10
  33. package/src/hooks/comlink/useFrameConnection.ts +39 -43
  34. package/src/hooks/comlink/useManageFavorite.test.ts +106 -0
  35. package/src/hooks/comlink/useManageFavorite.ts +101 -0
  36. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +77 -0
  37. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +79 -0
  38. package/src/hooks/comlink/useWindowConnection.test.ts +53 -12
  39. package/src/hooks/comlink/useWindowConnection.ts +69 -29
  40. package/src/hooks/context/useSanityInstance.test.tsx +1 -1
  41. package/src/hooks/context/useSanityInstance.ts +21 -5
  42. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +97 -0
  43. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +274 -0
  44. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +91 -0
  45. package/src/hooks/datasets/useDatasets.ts +37 -0
  46. package/src/hooks/document/useApplyActions.test.ts +5 -4
  47. package/src/hooks/document/useApplyActions.ts +55 -5
  48. package/src/hooks/document/useDocument.test.ts +2 -2
  49. package/src/hooks/document/useDocument.ts +90 -21
  50. package/src/hooks/document/useDocumentEvent.test.ts +13 -3
  51. package/src/hooks/document/useDocumentEvent.ts +36 -4
  52. package/src/hooks/document/useDocumentSyncStatus.test.ts +1 -1
  53. package/src/hooks/document/useDocumentSyncStatus.ts +26 -2
  54. package/src/hooks/document/useEditDocument.test.ts +55 -10
  55. package/src/hooks/document/useEditDocument.ts +159 -31
  56. package/src/hooks/document/usePermissions.ts +82 -0
  57. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  58. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  59. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  60. package/src/hooks/infiniteList/useInfiniteList.test.tsx +152 -0
  61. package/src/hooks/infiniteList/useInfiniteList.ts +174 -0
  62. package/src/hooks/paginatedList/usePaginatedList.test.tsx +259 -0
  63. package/src/hooks/paginatedList/usePaginatedList.ts +290 -0
  64. package/src/hooks/preview/usePreview.test.tsx +6 -6
  65. package/src/hooks/preview/usePreview.tsx +12 -9
  66. package/src/hooks/projection/useProjection.test.tsx +218 -0
  67. package/src/hooks/projection/useProjection.ts +147 -0
  68. package/src/hooks/projects/useProject.ts +45 -0
  69. package/src/hooks/projects/useProjects.ts +41 -0
  70. package/src/hooks/query/useQuery.test.tsx +188 -0
  71. package/src/hooks/query/useQuery.ts +103 -0
  72. package/src/hooks/users/useUsers.test.ts +163 -0
  73. package/src/hooks/users/useUsers.ts +107 -0
  74. package/src/utils/getEnv.ts +21 -0
  75. package/src/version.ts +8 -0
  76. package/dist/_chunks-es/context.js +0 -8
  77. package/dist/_chunks-es/context.js.map +0 -1
  78. package/dist/_chunks-es/useLogOut.js +0 -45
  79. package/dist/_chunks-es/useLogOut.js.map +0 -1
  80. package/dist/components.d.ts +0 -111
  81. package/dist/components.js +0 -153
  82. package/dist/components.js.map +0 -1
  83. package/dist/context.d.ts +0 -45
  84. package/dist/context.js +0 -5
  85. package/dist/context.js.map +0 -1
  86. package/dist/hooks.d.ts +0 -3532
  87. package/dist/hooks.js +0 -218
  88. package/dist/hooks.js.map +0 -1
  89. package/src/_exports/components.ts +0 -2
  90. package/src/_exports/context.ts +0 -2
  91. package/src/_exports/hooks.ts +0 -32
  92. package/src/hooks/client/useClient.test.tsx +0 -130
  93. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  94. package/src/hooks/documentCollection/useDocuments.ts +0 -135
@@ -1,5 +1,6 @@
1
+ import {type MessageData, type Node, type Status} from '@sanity/comlink'
1
2
  import {type FrameMessage, getOrCreateNode, releaseNode, type WindowMessage} from '@sanity/sdk'
2
- import {useCallback, useEffect, useMemo} from 'react'
3
+ import {useCallback, useEffect, useRef} from 'react'
3
4
 
4
5
  import {useSanityInstance} from '../context/useSanityInstance'
5
6
 
@@ -17,6 +18,7 @@ export interface UseWindowConnectionOptions<TMessage extends FrameMessage> {
17
18
  name: string
18
19
  connectTo: string
19
20
  onMessage?: Record<TMessage['type'], WindowMessageHandler<TMessage>>
21
+ onStatus?: (status: Status) => void
20
22
  }
21
23
 
22
24
  /**
@@ -27,56 +29,94 @@ export interface WindowConnection<TMessage extends WindowMessage> {
27
29
  type: TType,
28
30
  data?: Extract<TMessage, {type: TType}>['data'],
29
31
  ) => void
32
+ fetch: <TResponse>(
33
+ type: string,
34
+ data?: MessageData,
35
+ options?: {
36
+ signal?: AbortSignal
37
+ suppressWarnings?: boolean
38
+ responseTimeout?: number
39
+ },
40
+ ) => Promise<TResponse>
30
41
  }
31
42
 
32
43
  /**
33
44
  * @internal
45
+ * Hook to wrap a Comlink node in a React hook.
46
+ * Our store functionality takes care of the lifecycle of the node,
47
+ * as well as sharing a single node between invocations if they share the same name.
48
+ *
49
+ * Generally not to be used directly, but to be used as a dependency of
50
+ * Comlink-powered hooks like `useManageFavorite`.
34
51
  */
35
52
  export function useWindowConnection<
36
53
  TWindowMessage extends WindowMessage,
37
54
  TFrameMessage extends FrameMessage,
38
- >(options: UseWindowConnectionOptions<TFrameMessage>): WindowConnection<TWindowMessage> {
39
- const {name, onMessage, connectTo} = options
55
+ >({
56
+ name,
57
+ connectTo,
58
+ onMessage,
59
+ onStatus,
60
+ }: UseWindowConnectionOptions<TFrameMessage>): WindowConnection<TWindowMessage> {
61
+ const nodeRef = useRef<Node<TWindowMessage, TFrameMessage> | null>(null)
62
+ const messageUnsubscribers = useRef<(() => void)[]>([])
40
63
  const instance = useSanityInstance()
41
64
 
42
- const node = useMemo(
43
- () => getOrCreateNode(instance, {name, connectTo}),
44
- [instance, name, connectTo],
45
- )
46
-
47
65
  useEffect(() => {
48
- if (!onMessage) return
49
-
50
- const unsubscribers: Array<() => void> = []
66
+ // the type cast is unfortunate, but the generic type of the node is not known here.
67
+ // We know that the node is a WindowMessage node, but not the generic types.
68
+ const node = getOrCreateNode(instance, {
69
+ name,
70
+ connectTo,
71
+ }) as unknown as Node<TWindowMessage, TFrameMessage>
72
+ nodeRef.current = node
51
73
 
52
- Object.entries(onMessage).forEach(([type, handler]) => {
53
- const unsubscribe = node.on(type, handler as WindowMessageHandler<TFrameMessage>)
54
- unsubscribers.push(unsubscribe)
74
+ const statusUnsubscribe = node.onStatus((eventStatus) => {
75
+ onStatus?.(eventStatus)
55
76
  })
56
77
 
78
+ if (onMessage) {
79
+ Object.entries(onMessage).forEach(([type, handler]) => {
80
+ const messageUnsubscribe = node.on(type, handler as WindowMessageHandler<TFrameMessage>)
81
+ messageUnsubscribers.current.push(messageUnsubscribe)
82
+ })
83
+ }
84
+
57
85
  return () => {
58
- unsubscribers.forEach((unsub) => unsub())
86
+ statusUnsubscribe()
87
+ messageUnsubscribers.current.forEach((unsubscribe) => unsubscribe())
88
+ messageUnsubscribers.current = []
89
+ releaseNode(instance, name)
90
+ nodeRef.current = null
59
91
  }
60
- }, [node, onMessage])
92
+ }, [instance, name, connectTo, onMessage, onStatus])
61
93
 
62
94
  const sendMessage = useCallback(
63
- <TType extends WindowMessage['type']>(
64
- type: TType,
65
- data?: Extract<WindowMessage, {type: TType}>['data'],
66
- ) => {
67
- node?.post(type, data)
95
+ (type: TWindowMessage['type'], data?: Extract<TWindowMessage, {type: typeof type}>['data']) => {
96
+ if (!nodeRef.current) {
97
+ throw new Error('Cannot send message before connection is established')
98
+ }
99
+ nodeRef.current.post(type, data)
68
100
  },
69
- [node],
101
+ [],
70
102
  )
71
103
 
72
- // cleanup node on unmount
73
- useEffect(() => {
74
- return () => {
75
- releaseNode(instance, name)
76
- }
77
- }, [instance, name])
78
-
104
+ const fetch = useCallback(
105
+ <TResponse>(
106
+ type: string,
107
+ data?: MessageData,
108
+ fetchOptions?: {
109
+ responseTimeout?: number
110
+ signal?: AbortSignal
111
+ suppressWarnings?: boolean
112
+ },
113
+ ): Promise<TResponse> => {
114
+ return nodeRef.current?.fetch(type, data, fetchOptions ?? {}) as Promise<TResponse>
115
+ },
116
+ [],
117
+ )
79
118
  return {
80
119
  sendMessage,
120
+ fetch,
81
121
  }
82
122
  }
@@ -11,7 +11,7 @@ describe('useSanityInstance', () => {
11
11
 
12
12
  it('returns sanity instance when used within provider', () => {
13
13
  const wrapper = ({children}: {children: React.ReactNode}) => (
14
- <SanityProvider sanityInstance={sanityInstance}>{children}</SanityProvider>
14
+ <SanityProvider sanityInstances={[sanityInstance]}>{children}</SanityProvider>
15
15
  )
16
16
 
17
17
  const {result} = renderHook(() => useSanityInstance(), {wrapper})
@@ -1,23 +1,39 @@
1
1
  import {type SanityInstance} from '@sanity/sdk'
2
2
  import {useContext} from 'react'
3
3
 
4
- import {SanityInstanceContext} from '../../context/SanityProvider'
4
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
5
5
 
6
6
  /**
7
7
  * `useSanityInstance` returns the current Sanity instance from the application context.
8
8
  * This must be called from within a `SanityProvider` component.
9
- * @public
9
+ * @internal
10
+ *
11
+ * @param resourceId - The resourceId of the Sanity instance to return (optional)
10
12
  * @returns The current Sanity instance
11
13
  * @example
12
14
  * ```tsx
13
- * const instance = useSanityInstance()
15
+ * const instance = useSanityInstance('abc123.production')
14
16
  * ```
15
17
  */
16
- export const useSanityInstance = (): SanityInstance => {
18
+ export const useSanityInstance = (resourceId?: string): SanityInstance => {
17
19
  const sanityInstance = useContext(SanityInstanceContext)
18
20
  if (!sanityInstance) {
19
21
  throw new Error('useSanityInstance must be called from within the SanityProvider')
20
22
  }
23
+ if (sanityInstance.length === 0) {
24
+ throw new Error('No Sanity instances found')
25
+ }
26
+ if (sanityInstance.length === 1 || !resourceId) {
27
+ return sanityInstance[0]
28
+ }
21
29
 
22
- return sanityInstance
30
+ if (!resourceId) {
31
+ throw new Error('resourceId is required when there are multiple Sanity instances')
32
+ }
33
+
34
+ const instance = sanityInstance.find((inst) => inst.identity.resourceId === resourceId)
35
+ if (!instance) {
36
+ throw new Error(`Sanity instance with resourceId ${resourceId} not found`)
37
+ }
38
+ return instance
23
39
  }
@@ -0,0 +1,97 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {type DocumentHandle} from '@sanity/sdk'
3
+ import {useCallback, useState} from 'react'
4
+
5
+ import {useWindowConnection} from '../comlink/useWindowConnection'
6
+ import {useStudioWorkspacesByResourceId} from './useStudioWorkspacesByResourceId'
7
+
8
+ interface NavigateToResourceMessage {
9
+ type: 'core/v1/bridge/navigate-to-resource'
10
+ data: {
11
+ /**
12
+ * Resource ID
13
+ */
14
+ resourceId: string
15
+ /**
16
+ * Resource type
17
+ * @example 'application' | 'studio'
18
+ */
19
+ resourceType: string
20
+ /**
21
+ * Path within the resource to navigate to.
22
+ */
23
+ path?: string
24
+ }
25
+ }
26
+
27
+ interface NavigateToStudioResult {
28
+ navigateToStudioDocument: () => void
29
+ isConnected: boolean
30
+ }
31
+
32
+ /**
33
+ * @public
34
+ * Hook that provides a function to navigate to a studio document.
35
+ * @param documentHandle - The document handle containing document ID, type, and resource ID
36
+ * @returns An object containing:
37
+ * - navigateToStudioDocument - Function that when called will navigate to the studio document
38
+ * - isConnected - Boolean indicating if connection to Core UI is established
39
+ */
40
+ export function useNavigateToStudioDocument(
41
+ documentHandle: DocumentHandle,
42
+ ): NavigateToStudioResult {
43
+ const {workspacesByResourceId, isConnected: workspacesConnected} =
44
+ useStudioWorkspacesByResourceId()
45
+ const [status, setStatus] = useState<Status>('idle')
46
+ const {sendMessage} = useWindowConnection<NavigateToResourceMessage, never>({
47
+ name: 'core/nodes/sdk',
48
+ connectTo: 'core/channels/sdk',
49
+ onStatus: setStatus,
50
+ })
51
+
52
+ const navigateToStudioDocument = useCallback(() => {
53
+ if (!workspacesConnected || status !== 'connected' || !documentHandle.resourceId) {
54
+ return
55
+ }
56
+
57
+ // Extract projectId and dataset from the resourceId (current format: document:projectId.dataset:documentId)
58
+ const [, projectAndDataset] = documentHandle.resourceId.split(':')
59
+ const [projectId, dataset] = projectAndDataset.split('.')
60
+ if (!projectId || !dataset) {
61
+ return
62
+ }
63
+
64
+ // Find the workspace for this document
65
+ const workspaces = workspacesByResourceId[`${projectId}:${dataset}`]
66
+ if (!workspaces?.length) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn('No workspace found for document', documentHandle.resourceId)
69
+ return
70
+ }
71
+
72
+ if (workspaces.length > 1) {
73
+ // eslint-disable-next-line no-console
74
+ console.warn('Multiple workspaces found for document', documentHandle.resourceId)
75
+ // eslint-disable-next-line no-console
76
+ console.warn('Using the first one', workspaces[0])
77
+ }
78
+
79
+ const workspace = workspaces[0]
80
+
81
+ const message: NavigateToResourceMessage = {
82
+ type: 'core/v1/bridge/navigate-to-resource',
83
+ data: {
84
+ resourceId: workspace._ref,
85
+ resourceType: 'studio',
86
+ path: `/intent/edit/id=${documentHandle._id};type=${documentHandle._type}`,
87
+ },
88
+ }
89
+
90
+ sendMessage(message.type, message.data)
91
+ }, [documentHandle, workspacesConnected, status, sendMessage, workspacesByResourceId])
92
+
93
+ return {
94
+ navigateToStudioDocument,
95
+ isConnected: workspacesConnected && status === 'connected',
96
+ }
97
+ }
@@ -0,0 +1,274 @@
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 {useStudioWorkspacesByResourceId} from './useStudioWorkspacesByResourceId'
7
+
8
+ vi.mock('../comlink/useWindowConnection', () => ({
9
+ useWindowConnection: vi.fn(),
10
+ }))
11
+
12
+ const mockWorkspaceData = {
13
+ context: {
14
+ availableResources: [
15
+ {
16
+ projectId: 'project1',
17
+ workspaces: [
18
+ {
19
+ name: 'workspace1',
20
+ title: 'Workspace 1',
21
+ basePath: '/workspace1',
22
+ dataset: 'dataset1',
23
+ userApplicationId: 'user1',
24
+ url: 'https://test.sanity.studio',
25
+ _ref: 'user1-workspace1',
26
+ },
27
+ {
28
+ name: 'workspace2',
29
+ title: 'Workspace 2',
30
+ basePath: '/workspace2',
31
+ dataset: 'dataset1',
32
+ userApplicationId: 'user1',
33
+ url: 'https://test.sanity.studio',
34
+ _ref: 'user1-workspace2',
35
+ },
36
+ ],
37
+ },
38
+ {
39
+ projectId: 'project2',
40
+ workspaces: [
41
+ {
42
+ name: 'workspace3',
43
+ title: 'Workspace 3',
44
+ basePath: '/workspace3',
45
+ dataset: 'dataset2',
46
+ userApplicationId: 'user2',
47
+ url: 'https://test.sanity.studio',
48
+ _ref: 'user2-workspace3',
49
+ },
50
+ ],
51
+ },
52
+ {
53
+ // Project without workspaces
54
+ projectId: 'project3',
55
+ workspaces: [],
56
+ },
57
+ ],
58
+ },
59
+ }
60
+
61
+ describe('useStudioWorkspacesByResourceId', () => {
62
+ it('should return empty workspaces and connected=false when not connected', async () => {
63
+ // Create a mock that captures the onStatus callback
64
+ let capturedOnStatus: ((status: Status) => void) | undefined
65
+
66
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
67
+ capturedOnStatus = onStatus
68
+
69
+ return {
70
+ fetch: undefined,
71
+ sendMessage: vi.fn(),
72
+ } as unknown as WindowConnection<Message>
73
+ })
74
+
75
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
76
+
77
+ // Call onStatus with 'idle' to simulate not connected
78
+ if (capturedOnStatus) capturedOnStatus('idle')
79
+
80
+ expect(result.current).toEqual({
81
+ workspacesByResourceId: {},
82
+ error: null,
83
+ isConnected: false,
84
+ })
85
+ })
86
+
87
+ it('should process workspaces into lookup by projectId:dataset', async () => {
88
+ const mockFetch = vi.fn().mockResolvedValue(mockWorkspaceData)
89
+ let capturedOnStatus: ((status: Status) => void) | undefined
90
+
91
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
92
+ capturedOnStatus = onStatus
93
+
94
+ return {
95
+ fetch: mockFetch,
96
+ sendMessage: vi.fn(),
97
+ } as unknown as WindowConnection<Message>
98
+ })
99
+
100
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
101
+
102
+ // Call onStatus with 'connected' to simulate connected state
103
+ if (capturedOnStatus) capturedOnStatus('connected')
104
+
105
+ await waitFor(() => {
106
+ expect(result.current.workspacesByResourceId).toEqual({
107
+ 'project1:dataset1': [
108
+ {
109
+ name: 'workspace1',
110
+ title: 'Workspace 1',
111
+ basePath: '/workspace1',
112
+ dataset: 'dataset1',
113
+ userApplicationId: 'user1',
114
+ url: 'https://test.sanity.studio',
115
+ _ref: 'user1-workspace1',
116
+ },
117
+ {
118
+ name: 'workspace2',
119
+ title: 'Workspace 2',
120
+ basePath: '/workspace2',
121
+ dataset: 'dataset1',
122
+ userApplicationId: 'user1',
123
+ url: 'https://test.sanity.studio',
124
+ _ref: 'user1-workspace2',
125
+ },
126
+ ],
127
+ 'project2:dataset2': [
128
+ {
129
+ name: 'workspace3',
130
+ title: 'Workspace 3',
131
+ basePath: '/workspace3',
132
+ dataset: 'dataset2',
133
+ userApplicationId: 'user2',
134
+ url: 'https://test.sanity.studio',
135
+ _ref: 'user2-workspace3',
136
+ },
137
+ ],
138
+ })
139
+ expect(result.current.error).toBeNull()
140
+ expect(result.current.isConnected).toBe(true)
141
+ })
142
+
143
+ expect(mockFetch).toHaveBeenCalledWith('core/v1/bridge/context', undefined, expect.any(Object))
144
+ })
145
+
146
+ it('should handle fetch errors', async () => {
147
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
148
+ let capturedOnStatus: ((status: Status) => void) | undefined
149
+
150
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
151
+ capturedOnStatus = onStatus
152
+
153
+ return {
154
+ fetch: mockFetch,
155
+ sendMessage: vi.fn(),
156
+ } as unknown as WindowConnection<Message>
157
+ })
158
+
159
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
160
+
161
+ // Call onStatus with 'connected' to simulate connected state
162
+ if (capturedOnStatus) capturedOnStatus('connected')
163
+
164
+ await waitFor(() => {
165
+ expect(result.current.workspacesByResourceId).toEqual({})
166
+ expect(result.current.error).toBe('Failed to fetch workspaces')
167
+ expect(result.current.isConnected).toBe(true)
168
+ })
169
+ })
170
+
171
+ it('should handle AbortError silently', async () => {
172
+ const abortError = new Error('Aborted')
173
+ abortError.name = 'AbortError'
174
+ const mockFetch = vi.fn().mockRejectedValue(abortError)
175
+ let capturedOnStatus: ((status: Status) => void) | undefined
176
+
177
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
178
+ capturedOnStatus = onStatus
179
+
180
+ return {
181
+ fetch: mockFetch,
182
+ sendMessage: vi.fn(),
183
+ } as unknown as WindowConnection<Message>
184
+ })
185
+
186
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
187
+
188
+ // Call onStatus with 'connected' to simulate connected state
189
+ if (capturedOnStatus) capturedOnStatus('connected')
190
+
191
+ await waitFor(() => {
192
+ expect(result.current.workspacesByResourceId).toEqual({})
193
+ expect(result.current.error).toBeNull()
194
+ expect(result.current.isConnected).toBe(true)
195
+ })
196
+ })
197
+
198
+ it('should handle projects without workspaces', async () => {
199
+ const mockFetch = vi.fn().mockResolvedValue({
200
+ context: {
201
+ availableResources: [
202
+ {
203
+ projectId: 'project1',
204
+ workspaces: [],
205
+ },
206
+ ],
207
+ },
208
+ })
209
+ let capturedOnStatus: ((status: Status) => void) | undefined
210
+
211
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
212
+ capturedOnStatus = onStatus
213
+
214
+ return {
215
+ fetch: mockFetch,
216
+ sendMessage: vi.fn(),
217
+ } as unknown as WindowConnection<Message>
218
+ })
219
+
220
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
221
+
222
+ // Call onStatus with 'connected' to simulate connected state
223
+ if (capturedOnStatus) capturedOnStatus('connected')
224
+
225
+ await waitFor(() => {
226
+ expect(result.current.workspacesByResourceId).toEqual({})
227
+ expect(result.current.error).toBeNull()
228
+ expect(result.current.isConnected).toBe(true)
229
+ })
230
+ })
231
+
232
+ it('should handle projects without projectId', async () => {
233
+ const mockFetch = vi.fn().mockResolvedValue({
234
+ context: {
235
+ availableResources: [
236
+ {
237
+ workspaces: [
238
+ {
239
+ name: 'workspace1',
240
+ title: 'Workspace 1',
241
+ basePath: '/workspace1',
242
+ dataset: 'dataset1',
243
+ userApplicationId: 'user1',
244
+ url: 'https://test.sanity.studio',
245
+ _ref: 'user1-workspace1',
246
+ },
247
+ ],
248
+ },
249
+ ],
250
+ },
251
+ })
252
+ let capturedOnStatus: ((status: Status) => void) | undefined
253
+
254
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
255
+ capturedOnStatus = onStatus
256
+
257
+ return {
258
+ fetch: mockFetch,
259
+ sendMessage: vi.fn(),
260
+ } as unknown as WindowConnection<Message>
261
+ })
262
+
263
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
264
+
265
+ // Call onStatus with 'connected' to simulate connected state
266
+ if (capturedOnStatus) capturedOnStatus('connected')
267
+
268
+ await waitFor(() => {
269
+ expect(result.current.workspacesByResourceId).toEqual({})
270
+ expect(result.current.error).toBeNull()
271
+ expect(result.current.isConnected).toBe(true)
272
+ })
273
+ })
274
+ })
@@ -0,0 +1,91 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {useEffect, useState} from 'react'
3
+
4
+ import {useWindowConnection} from '../comlink/useWindowConnection'
5
+
6
+ interface Workspace {
7
+ name: string
8
+ title: string
9
+ basePath: string
10
+ dataset: string
11
+ userApplicationId: string
12
+ url: string
13
+ _ref: string
14
+ }
15
+
16
+ interface WorkspacesByResourceId {
17
+ [key: string]: Workspace[] // key format: `${projectId}:${dataset}`
18
+ }
19
+
20
+ interface StudioWorkspacesResult {
21
+ workspacesByResourceId: WorkspacesByResourceId
22
+ error: string | null
23
+ isConnected: boolean
24
+ }
25
+
26
+ /**
27
+ * Hook that fetches studio workspaces and organizes them by projectId:dataset
28
+ * @internal
29
+ */
30
+ export function useStudioWorkspacesByResourceId(): StudioWorkspacesResult {
31
+ const [workspacesByResourceId, setWorkspacesByResourceId] = useState<WorkspacesByResourceId>({})
32
+ const [status, setStatus] = useState<Status>('idle')
33
+ const [error, setError] = useState<string | null>(null)
34
+
35
+ const {fetch} = useWindowConnection({
36
+ name: 'core/nodes/sdk',
37
+ connectTo: 'core/channels/sdk',
38
+ onStatus: setStatus,
39
+ })
40
+
41
+ // Once computed, this should probably be in a store and poll for changes
42
+ // However, our stores are currently being refactored
43
+ useEffect(() => {
44
+ if (!fetch || status !== 'connected') return
45
+
46
+ async function fetchWorkspaces(signal: AbortSignal) {
47
+ try {
48
+ const data = await fetch<{
49
+ context: {availableResources: Array<{projectId: string; workspaces: Workspace[]}>}
50
+ }>('core/v1/bridge/context', undefined, {signal})
51
+
52
+ const workspaceMap: WorkspacesByResourceId = {}
53
+
54
+ data.context.availableResources.forEach((resource) => {
55
+ if (!resource.projectId || !resource.workspaces?.length) return
56
+
57
+ resource.workspaces.forEach((workspace) => {
58
+ const key = `${resource.projectId}:${workspace.dataset}`
59
+ if (!workspaceMap[key]) {
60
+ workspaceMap[key] = []
61
+ }
62
+ workspaceMap[key].push(workspace)
63
+ })
64
+ })
65
+
66
+ setWorkspacesByResourceId(workspaceMap)
67
+ setError(null)
68
+ } catch (err: unknown) {
69
+ if (err instanceof Error) {
70
+ if (err.name === 'AbortError') {
71
+ return
72
+ }
73
+ setError('Failed to fetch workspaces')
74
+ }
75
+ }
76
+ }
77
+
78
+ const controller = new AbortController()
79
+ fetchWorkspaces(controller.signal)
80
+
81
+ return () => {
82
+ controller.abort()
83
+ }
84
+ }, [fetch, status])
85
+
86
+ return {
87
+ workspacesByResourceId,
88
+ error,
89
+ isConnected: status === 'connected',
90
+ }
91
+ }
@@ -0,0 +1,37 @@
1
+ import {type DatasetsResponse} from '@sanity/client'
2
+ import {getDatasetsState, resolveDatasets, type SanityInstance, type StateSource} from '@sanity/sdk'
3
+
4
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
5
+
6
+ type UseDatasets = {
7
+ /**
8
+ *
9
+ * Returns metadata for each dataset in your organization.
10
+ *
11
+ * @category Datasets
12
+ * @returns The metadata for your organization's datasets
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * const datasets = useDatasets()
17
+ *
18
+ * return (
19
+ * <select>
20
+ * {datasets.map((dataset) => (
21
+ * <option key={dataset.name}>{dataset.name}</option>
22
+ * ))}
23
+ * </select>
24
+ * )
25
+ * ```
26
+ *
27
+ */
28
+ (): DatasetsResponse
29
+ }
30
+
31
+ /** @public */
32
+ export const useDatasets: UseDatasets = createStateSourceHook({
33
+ // remove `undefined` since we're suspending when that is the case
34
+ getState: getDatasetsState as (instance: SanityInstance) => StateSource<DatasetsResponse>,
35
+ shouldSuspend: (instance) => getDatasetsState(instance).getCurrent() === undefined,
36
+ suspender: resolveDatasets,
37
+ })