@sanity/sdk-react 0.0.0-alpha.2 → 0.0.0-alpha.21

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 (116) hide show
  1. package/README.md +38 -67
  2. package/dist/index.d.ts +4811 -2
  3. package/dist/index.js +1069 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +27 -58
  6. package/src/_exports/index.ts +66 -10
  7. package/src/components/Login/LoginLinks.test.tsx +4 -14
  8. package/src/components/Login/LoginLinks.tsx +16 -31
  9. package/src/components/SDKProvider.test.tsx +79 -0
  10. package/src/components/SDKProvider.tsx +42 -0
  11. package/src/components/SanityApp.test.tsx +156 -0
  12. package/src/components/SanityApp.tsx +90 -0
  13. package/src/components/auth/AuthBoundary.test.tsx +6 -19
  14. package/src/components/auth/AuthBoundary.tsx +20 -4
  15. package/src/components/auth/Login.test.tsx +2 -16
  16. package/src/components/auth/Login.tsx +11 -30
  17. package/src/components/auth/LoginCallback.test.tsx +5 -20
  18. package/src/components/auth/LoginCallback.tsx +9 -14
  19. package/src/components/auth/LoginError.test.tsx +2 -17
  20. package/src/components/auth/LoginError.tsx +11 -16
  21. package/src/components/auth/LoginFooter.test.tsx +2 -16
  22. package/src/components/auth/LoginFooter.tsx +8 -24
  23. package/src/components/auth/LoginLayout.test.tsx +2 -16
  24. package/src/components/auth/LoginLayout.tsx +8 -38
  25. package/src/components/auth/authTestHelpers.tsx +11 -0
  26. package/src/components/utils.ts +22 -0
  27. package/src/context/SanityInstanceContext.ts +4 -0
  28. package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
  29. package/src/context/SanityProvider.tsx +50 -0
  30. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  31. package/src/hooks/auth/useAuthState.tsx +4 -5
  32. package/src/hooks/auth/useAuthToken.tsx +1 -1
  33. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  34. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  35. package/src/hooks/auth/useDashboardOrganizationId.tsx +29 -0
  36. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  37. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  38. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  39. package/src/hooks/auth/useLogOut.tsx +1 -1
  40. package/src/hooks/auth/useLoginUrls.tsx +1 -0
  41. package/src/hooks/client/useClient.ts +9 -30
  42. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  43. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  44. package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
  45. package/src/hooks/comlink/useManageFavorite.ts +130 -0
  46. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
  47. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
  48. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  49. package/src/hooks/comlink/useWindowConnection.ts +122 -0
  50. package/src/hooks/context/useSanityInstance.test.tsx +2 -2
  51. package/src/hooks/context/useSanityInstance.ts +24 -8
  52. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
  53. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
  54. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
  55. package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
  56. package/src/hooks/datasets/useDatasets.ts +40 -0
  57. package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
  58. package/src/hooks/document/useApplyDocumentActions.ts +75 -0
  59. package/src/hooks/document/useDocument.test.ts +81 -0
  60. package/src/hooks/document/useDocument.ts +107 -0
  61. package/src/hooks/document/useDocumentEvent.test.ts +63 -0
  62. package/src/hooks/document/useDocumentEvent.ts +54 -0
  63. package/src/hooks/document/useDocumentPermissions.ts +84 -0
  64. package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
  65. package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
  66. package/src/hooks/document/useEditDocument.test.ts +179 -0
  67. package/src/hooks/document/useEditDocument.ts +195 -0
  68. package/src/hooks/documents/useDocuments.test.tsx +152 -0
  69. package/src/hooks/documents/useDocuments.ts +174 -0
  70. package/src/hooks/helpers/createCallbackHook.tsx +3 -2
  71. package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
  72. package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
  73. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
  74. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
  75. package/src/hooks/preview/usePreview.test.tsx +19 -10
  76. package/src/hooks/preview/usePreview.tsx +67 -13
  77. package/src/hooks/projection/useProjection.test.tsx +218 -0
  78. package/src/hooks/projection/useProjection.ts +147 -0
  79. package/src/hooks/projects/useProject.ts +48 -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 +103 -0
  83. package/src/hooks/users/useUsers.test.ts +163 -0
  84. package/src/hooks/users/useUsers.ts +107 -0
  85. package/src/utils/getEnv.ts +21 -0
  86. package/src/version.ts +8 -0
  87. package/src/vite-env.d.ts +10 -0
  88. package/dist/_chunks-es/useLogOut.js +0 -44
  89. package/dist/_chunks-es/useLogOut.js.map +0 -1
  90. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  91. package/dist/components.d.ts +0 -257
  92. package/dist/components.js +0 -316
  93. package/dist/components.js.map +0 -1
  94. package/dist/hooks.d.ts +0 -187
  95. package/dist/hooks.js +0 -81
  96. package/dist/hooks.js.map +0 -1
  97. package/src/_exports/components.ts +0 -13
  98. package/src/_exports/hooks.ts +0 -9
  99. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  100. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  101. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  102. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  103. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  104. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  105. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  106. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  107. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  108. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  109. package/src/components/context/SanityProvider.tsx +0 -42
  110. package/src/css/css.config.js +0 -220
  111. package/src/css/paramour.css +0 -2347
  112. package/src/css/styles.css +0 -11
  113. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  114. package/src/hooks/client/useClient.test.tsx +0 -130
  115. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  116. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -0,0 +1,178 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {type DocumentHandle} from '@sanity/sdk'
3
+ import {act, renderHook} from '@testing-library/react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {useNavigateToStudioDocument} from './useNavigateToStudioDocument'
7
+
8
+ // Mock dependencies
9
+ const mockSendMessage = vi.fn()
10
+ const mockFetch = vi.fn()
11
+ let mockWorkspacesByResourceId = {}
12
+ let mockWorkspacesIsConnected = true
13
+ let mockStatusCallback: ((status: Status) => void) | null = null
14
+
15
+ vi.mock('../comlink/useWindowConnection', () => {
16
+ return {
17
+ useWindowConnection: ({onStatus}: {onStatus?: (status: Status) => void}) => {
18
+ mockStatusCallback = onStatus || null
19
+ return {
20
+ sendMessage: mockSendMessage,
21
+ fetch: mockFetch,
22
+ }
23
+ },
24
+ }
25
+ })
26
+
27
+ vi.mock('./useStudioWorkspacesByResourceId', () => {
28
+ return {
29
+ useStudioWorkspacesByResourceId: () => ({
30
+ workspacesByResourceId: mockWorkspacesByResourceId,
31
+ error: null,
32
+ isConnected: mockWorkspacesIsConnected,
33
+ }),
34
+ }
35
+ })
36
+
37
+ describe('useNavigateToStudioDocument', () => {
38
+ const mockDocumentHandle: DocumentHandle = {
39
+ _id: 'doc123',
40
+ _type: 'article',
41
+ resourceId: 'document:project1.dataset1:doc123',
42
+ }
43
+
44
+ const mockWorkspace = {
45
+ name: 'workspace1',
46
+ title: 'Workspace 1',
47
+ basePath: '/workspace1',
48
+ dataset: 'dataset1',
49
+ userApplicationId: 'user1',
50
+ url: 'https://test.sanity.studio',
51
+ _ref: 'workspace123',
52
+ }
53
+
54
+ beforeEach(() => {
55
+ vi.resetAllMocks()
56
+ mockWorkspacesByResourceId = {
57
+ 'project1:dataset1': [mockWorkspace],
58
+ }
59
+ mockWorkspacesIsConnected = true
60
+ mockStatusCallback = null
61
+ })
62
+
63
+ it('returns a function and connection status', () => {
64
+ const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
65
+
66
+ // Initially not connected
67
+ expect(result.current.isConnected).toBe(false)
68
+
69
+ // Simulate connection
70
+ act(() => {
71
+ mockStatusCallback?.('connected')
72
+ })
73
+
74
+ expect(result.current).toEqual({
75
+ navigateToStudioDocument: expect.any(Function),
76
+ isConnected: true,
77
+ })
78
+ })
79
+
80
+ it('sends correct navigation message when called', () => {
81
+ const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
82
+
83
+ // Simulate connection
84
+ act(() => {
85
+ mockStatusCallback?.('connected')
86
+ })
87
+
88
+ result.current.navigateToStudioDocument()
89
+
90
+ expect(mockSendMessage).toHaveBeenCalledWith('dashboard/v1/bridge/navigate-to-resource', {
91
+ resourceId: 'workspace123',
92
+ resourceType: 'studio',
93
+ path: '/intent/edit/id=doc123;type=article',
94
+ })
95
+ })
96
+
97
+ it('does not send message when not connected', () => {
98
+ mockWorkspacesByResourceId = {}
99
+ mockWorkspacesIsConnected = false
100
+
101
+ const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
102
+
103
+ // Simulate connection
104
+ act(() => {
105
+ mockStatusCallback?.('connected')
106
+ })
107
+
108
+ result.current.navigateToStudioDocument()
109
+
110
+ expect(mockSendMessage).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('does not send message when no workspace is found', () => {
114
+ mockWorkspacesByResourceId = {}
115
+ mockWorkspacesIsConnected = true
116
+
117
+ const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
118
+
119
+ // Simulate connection
120
+ act(() => {
121
+ mockStatusCallback?.('connected')
122
+ })
123
+
124
+ result.current.navigateToStudioDocument()
125
+
126
+ expect(mockSendMessage).not.toHaveBeenCalled()
127
+ })
128
+
129
+ it('handles invalid resourceId format', () => {
130
+ const invalidDocHandle: DocumentHandle = {
131
+ _id: 'doc123',
132
+ _type: 'article',
133
+ resourceId: 'document:project1.invalid:doc123' as `document:${string}.${string}:${string}`,
134
+ }
135
+
136
+ const {result} = renderHook(() => useNavigateToStudioDocument(invalidDocHandle))
137
+
138
+ // Simulate connection
139
+ act(() => {
140
+ mockStatusCallback?.('connected')
141
+ })
142
+
143
+ result.current.navigateToStudioDocument()
144
+
145
+ expect(mockSendMessage).not.toHaveBeenCalled()
146
+ })
147
+
148
+ it('warns when multiple workspaces are found', () => {
149
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
150
+ const mockWorkspace2 = {...mockWorkspace, _ref: 'workspace2'}
151
+
152
+ mockWorkspacesByResourceId = {
153
+ 'project1:dataset1': [mockWorkspace, mockWorkspace2],
154
+ }
155
+
156
+ const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
157
+
158
+ // Simulate connection
159
+ act(() => {
160
+ mockStatusCallback?.('connected')
161
+ })
162
+
163
+ result.current.navigateToStudioDocument()
164
+
165
+ expect(consoleSpy).toHaveBeenCalledWith(
166
+ 'Multiple workspaces found for document',
167
+ mockDocumentHandle.resourceId,
168
+ )
169
+ expect(mockSendMessage).toHaveBeenCalledWith(
170
+ 'dashboard/v1/bridge/navigate-to-resource',
171
+ expect.objectContaining({
172
+ resourceId: mockWorkspace._ref,
173
+ }),
174
+ )
175
+
176
+ consoleSpy.mockRestore()
177
+ })
178
+ })
@@ -0,0 +1,123 @@
1
+ import {type Status} from '@sanity/comlink'
2
+ import {SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
3
+ import {type DocumentHandle} from '@sanity/sdk'
4
+ import {useCallback, useState} from 'react'
5
+
6
+ import {useWindowConnection} from '../comlink/useWindowConnection'
7
+ import {useStudioWorkspacesByResourceId} from './useStudioWorkspacesByResourceId'
8
+
9
+ interface NavigateToResourceMessage {
10
+ type: 'dashboard/v1/bridge/navigate-to-resource'
11
+ data: {
12
+ /**
13
+ * Resource ID
14
+ */
15
+ resourceId: string
16
+ /**
17
+ * Resource type
18
+ * @example 'application' | 'studio'
19
+ */
20
+ resourceType: string
21
+ /**
22
+ * Path within the resource to navigate to.
23
+ */
24
+ path?: string
25
+ }
26
+ }
27
+
28
+ interface NavigateToStudioResult {
29
+ navigateToStudioDocument: () => void
30
+ isConnected: boolean
31
+ }
32
+
33
+ /**
34
+ * @public
35
+ * Hook that provides a function to navigate to a studio document.
36
+ * Currently, requires a document handle with a resourceId.
37
+ * That resourceId is currently formatted like: `document:projectId.dataset:documentId`
38
+ * If the hook you used to retrieve the document handle doesn't provide a resourceId like this,
39
+ * you can construct it according to the above format with the document handle's _id.
40
+ *
41
+ * This will only work if you have deployed a studio with a workspace
42
+ * with this projectId / dataset combination.
43
+ * It may be able to take a custom URL in the future.
44
+ *
45
+ * This will likely change in the future.
46
+ * @param documentHandle - The document handle containing document ID, type, and resource ID
47
+ * @returns An object containing:
48
+ * - navigateToStudioDocument - Function that when called will navigate to the studio document
49
+ * - isConnected - Boolean indicating if connection to Dashboard is established
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import {navigateToStudioDocument, type DocumentHandle} from '@sanity/sdk'
54
+ *
55
+ * function MyComponent({documentHandle}: {documentHandle: DocumentHandle}) {
56
+ * const {navigateToStudioDocument, isConnected} = useNavigateToStudioDocument(documentHandle)
57
+ *
58
+ * return (
59
+ * <button onClick={navigateToStudioDocument} disabled={!isConnected}>
60
+ * Navigate to Studio Document
61
+ * </button>
62
+ * )
63
+ * }
64
+ * ```
65
+ */
66
+ export function useNavigateToStudioDocument(
67
+ documentHandle: DocumentHandle,
68
+ ): NavigateToStudioResult {
69
+ const {workspacesByResourceId, isConnected: workspacesConnected} =
70
+ useStudioWorkspacesByResourceId()
71
+ const [status, setStatus] = useState<Status>('idle')
72
+ const {sendMessage} = useWindowConnection<NavigateToResourceMessage, never>({
73
+ name: SDK_NODE_NAME,
74
+ connectTo: SDK_CHANNEL_NAME,
75
+ onStatus: setStatus,
76
+ })
77
+
78
+ const navigateToStudioDocument = useCallback(() => {
79
+ if (!workspacesConnected || status !== 'connected' || !documentHandle.resourceId) {
80
+ return
81
+ }
82
+
83
+ // Extract projectId and dataset from the resourceId (current format: document:projectId.dataset:documentId)
84
+ const [, projectAndDataset] = documentHandle.resourceId.split(':')
85
+ const [projectId, dataset] = projectAndDataset.split('.')
86
+ if (!projectId || !dataset) {
87
+ return
88
+ }
89
+
90
+ // Find the workspace for this document
91
+ const workspaces = workspacesByResourceId[`${projectId}:${dataset}`]
92
+ if (!workspaces?.length) {
93
+ // eslint-disable-next-line no-console
94
+ console.warn('No workspace found for document', documentHandle.resourceId)
95
+ return
96
+ }
97
+
98
+ if (workspaces.length > 1) {
99
+ // eslint-disable-next-line no-console
100
+ console.warn('Multiple workspaces found for document', documentHandle.resourceId)
101
+ // eslint-disable-next-line no-console
102
+ console.warn('Using the first one', workspaces[0])
103
+ }
104
+
105
+ const workspace = workspaces[0]
106
+
107
+ const message: NavigateToResourceMessage = {
108
+ type: 'dashboard/v1/bridge/navigate-to-resource',
109
+ data: {
110
+ resourceId: workspace._ref,
111
+ resourceType: 'studio',
112
+ path: `/intent/edit/id=${documentHandle._id};type=${documentHandle._type}`,
113
+ },
114
+ }
115
+
116
+ sendMessage(message.type, message.data)
117
+ }, [documentHandle, workspacesConnected, status, sendMessage, workspacesByResourceId])
118
+
119
+ return {
120
+ navigateToStudioDocument,
121
+ isConnected: workspacesConnected && status === 'connected',
122
+ }
123
+ }
@@ -0,0 +1,278 @@
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(
144
+ 'dashboard/v1/bridge/context',
145
+ undefined,
146
+ expect.any(Object),
147
+ )
148
+ })
149
+
150
+ it('should handle fetch errors', async () => {
151
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
152
+ let capturedOnStatus: ((status: Status) => void) | undefined
153
+
154
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
155
+ capturedOnStatus = onStatus
156
+
157
+ return {
158
+ fetch: mockFetch,
159
+ sendMessage: vi.fn(),
160
+ } as unknown as WindowConnection<Message>
161
+ })
162
+
163
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
164
+
165
+ // Call onStatus with 'connected' to simulate connected state
166
+ if (capturedOnStatus) capturedOnStatus('connected')
167
+
168
+ await waitFor(() => {
169
+ expect(result.current.workspacesByResourceId).toEqual({})
170
+ expect(result.current.error).toBe('Failed to fetch workspaces')
171
+ expect(result.current.isConnected).toBe(true)
172
+ })
173
+ })
174
+
175
+ it('should handle AbortError silently', async () => {
176
+ const abortError = new Error('Aborted')
177
+ abortError.name = 'AbortError'
178
+ const mockFetch = vi.fn().mockRejectedValue(abortError)
179
+ let capturedOnStatus: ((status: Status) => void) | undefined
180
+
181
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
182
+ capturedOnStatus = onStatus
183
+
184
+ return {
185
+ fetch: mockFetch,
186
+ sendMessage: vi.fn(),
187
+ } as unknown as WindowConnection<Message>
188
+ })
189
+
190
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
191
+
192
+ // Call onStatus with 'connected' to simulate connected state
193
+ if (capturedOnStatus) capturedOnStatus('connected')
194
+
195
+ await waitFor(() => {
196
+ expect(result.current.workspacesByResourceId).toEqual({})
197
+ expect(result.current.error).toBeNull()
198
+ expect(result.current.isConnected).toBe(true)
199
+ })
200
+ })
201
+
202
+ it('should handle projects without workspaces', async () => {
203
+ const mockFetch = vi.fn().mockResolvedValue({
204
+ context: {
205
+ availableResources: [
206
+ {
207
+ projectId: 'project1',
208
+ workspaces: [],
209
+ },
210
+ ],
211
+ },
212
+ })
213
+ let capturedOnStatus: ((status: Status) => void) | undefined
214
+
215
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
216
+ capturedOnStatus = onStatus
217
+
218
+ return {
219
+ fetch: mockFetch,
220
+ sendMessage: vi.fn(),
221
+ } as unknown as WindowConnection<Message>
222
+ })
223
+
224
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
225
+
226
+ // Call onStatus with 'connected' to simulate connected state
227
+ if (capturedOnStatus) capturedOnStatus('connected')
228
+
229
+ await waitFor(() => {
230
+ expect(result.current.workspacesByResourceId).toEqual({})
231
+ expect(result.current.error).toBeNull()
232
+ expect(result.current.isConnected).toBe(true)
233
+ })
234
+ })
235
+
236
+ it('should handle projects without projectId', async () => {
237
+ const mockFetch = vi.fn().mockResolvedValue({
238
+ context: {
239
+ availableResources: [
240
+ {
241
+ workspaces: [
242
+ {
243
+ name: 'workspace1',
244
+ title: 'Workspace 1',
245
+ basePath: '/workspace1',
246
+ dataset: 'dataset1',
247
+ userApplicationId: 'user1',
248
+ url: 'https://test.sanity.studio',
249
+ _ref: 'user1-workspace1',
250
+ },
251
+ ],
252
+ },
253
+ ],
254
+ },
255
+ })
256
+ let capturedOnStatus: ((status: Status) => void) | undefined
257
+
258
+ vi.mocked(useWindowConnection).mockImplementation(({onStatus}) => {
259
+ capturedOnStatus = onStatus
260
+
261
+ return {
262
+ fetch: mockFetch,
263
+ sendMessage: vi.fn(),
264
+ } as unknown as WindowConnection<Message>
265
+ })
266
+
267
+ const {result} = renderHook(() => useStudioWorkspacesByResourceId())
268
+
269
+ // Call onStatus with 'connected' to simulate connected state
270
+ if (capturedOnStatus) capturedOnStatus('connected')
271
+
272
+ await waitFor(() => {
273
+ expect(result.current.workspacesByResourceId).toEqual({})
274
+ expect(result.current.error).toBeNull()
275
+ expect(result.current.isConnected).toBe(true)
276
+ })
277
+ })
278
+ })
@@ -0,0 +1,92 @@
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
+ interface Workspace {
8
+ name: string
9
+ title: string
10
+ basePath: string
11
+ dataset: string
12
+ userApplicationId: string
13
+ url: string
14
+ _ref: string
15
+ }
16
+
17
+ interface WorkspacesByResourceId {
18
+ [key: string]: Workspace[] // key format: `${projectId}:${dataset}`
19
+ }
20
+
21
+ interface StudioWorkspacesResult {
22
+ workspacesByResourceId: WorkspacesByResourceId
23
+ error: string | null
24
+ isConnected: boolean
25
+ }
26
+
27
+ /**
28
+ * Hook that fetches studio workspaces and organizes them by projectId:dataset
29
+ * @internal
30
+ */
31
+ export function useStudioWorkspacesByResourceId(): StudioWorkspacesResult {
32
+ const [workspacesByResourceId, setWorkspacesByResourceId] = useState<WorkspacesByResourceId>({})
33
+ const [status, setStatus] = useState<Status>('idle')
34
+ const [error, setError] = useState<string | null>(null)
35
+
36
+ const {fetch} = useWindowConnection({
37
+ name: SDK_NODE_NAME,
38
+ connectTo: SDK_CHANNEL_NAME,
39
+ onStatus: setStatus,
40
+ })
41
+
42
+ // Once computed, this should probably be in a store and poll for changes
43
+ // However, our stores are currently being refactored
44
+ useEffect(() => {
45
+ if (!fetch || status !== 'connected') return
46
+
47
+ async function fetchWorkspaces(signal: AbortSignal) {
48
+ try {
49
+ const data = await fetch<{
50
+ context: {availableResources: Array<{projectId: string; workspaces: Workspace[]}>}
51
+ }>('dashboard/v1/bridge/context', undefined, {signal})
52
+
53
+ const workspaceMap: WorkspacesByResourceId = {}
54
+
55
+ data.context.availableResources.forEach((resource) => {
56
+ if (!resource.projectId || !resource.workspaces?.length) return
57
+
58
+ resource.workspaces.forEach((workspace) => {
59
+ const key = `${resource.projectId}:${workspace.dataset}`
60
+ if (!workspaceMap[key]) {
61
+ workspaceMap[key] = []
62
+ }
63
+ workspaceMap[key].push(workspace)
64
+ })
65
+ })
66
+
67
+ setWorkspacesByResourceId(workspaceMap)
68
+ setError(null)
69
+ } catch (err: unknown) {
70
+ if (err instanceof Error) {
71
+ if (err.name === 'AbortError') {
72
+ return
73
+ }
74
+ setError('Failed to fetch workspaces')
75
+ }
76
+ }
77
+ }
78
+
79
+ const controller = new AbortController()
80
+ fetchWorkspaces(controller.signal)
81
+
82
+ return () => {
83
+ controller.abort()
84
+ }
85
+ }, [fetch, status])
86
+
87
+ return {
88
+ workspacesByResourceId,
89
+ error,
90
+ isConnected: status === 'connected',
91
+ }
92
+ }