@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.
- package/README.md +38 -67
- package/dist/index.d.ts +4811 -2
- package/dist/index.js +1069 -2
- package/dist/index.js.map +1 -1
- package/package.json +27 -58
- package/src/_exports/index.ts +66 -10
- package/src/components/Login/LoginLinks.test.tsx +4 -14
- package/src/components/Login/LoginLinks.tsx +16 -31
- package/src/components/SDKProvider.test.tsx +79 -0
- package/src/components/SDKProvider.tsx +42 -0
- package/src/components/SanityApp.test.tsx +156 -0
- package/src/components/SanityApp.tsx +90 -0
- package/src/components/auth/AuthBoundary.test.tsx +6 -19
- package/src/components/auth/AuthBoundary.tsx +20 -4
- package/src/components/auth/Login.test.tsx +2 -16
- package/src/components/auth/Login.tsx +11 -30
- package/src/components/auth/LoginCallback.test.tsx +5 -20
- package/src/components/auth/LoginCallback.tsx +9 -14
- package/src/components/auth/LoginError.test.tsx +2 -17
- package/src/components/auth/LoginError.tsx +11 -16
- package/src/components/auth/LoginFooter.test.tsx +2 -16
- package/src/components/auth/LoginFooter.tsx +8 -24
- package/src/components/auth/LoginLayout.test.tsx +2 -16
- package/src/components/auth/LoginLayout.tsx +8 -38
- package/src/components/auth/authTestHelpers.tsx +11 -0
- package/src/components/utils.ts +22 -0
- package/src/context/SanityInstanceContext.ts +4 -0
- package/src/{components/context → context}/SanityProvider.test.tsx +2 -2
- package/src/context/SanityProvider.tsx +50 -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 +29 -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/useLoginUrls.tsx +1 -0
- package/src/hooks/client/useClient.ts +9 -30
- package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
- package/src/hooks/comlink/useFrameConnection.ts +107 -0
- package/src/hooks/comlink/useManageFavorite.test.ts +111 -0
- package/src/hooks/comlink/useManageFavorite.ts +130 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +81 -0
- package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +106 -0
- package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
- package/src/hooks/comlink/useWindowConnection.ts +122 -0
- package/src/hooks/context/useSanityInstance.test.tsx +2 -2
- package/src/hooks/context/useSanityInstance.ts +24 -8
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +178 -0
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +123 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.test.tsx +278 -0
- package/src/hooks/dashboard/useStudioWorkspacesByResourceId.ts +92 -0
- package/src/hooks/datasets/useDatasets.ts +40 -0
- package/src/hooks/document/useApplyDocumentActions.test.ts +25 -0
- package/src/hooks/document/useApplyDocumentActions.ts +75 -0
- package/src/hooks/document/useDocument.test.ts +81 -0
- package/src/hooks/document/useDocument.ts +107 -0
- package/src/hooks/document/useDocumentEvent.test.ts +63 -0
- package/src/hooks/document/useDocumentEvent.ts +54 -0
- package/src/hooks/document/useDocumentPermissions.ts +84 -0
- package/src/hooks/document/useDocumentSyncStatus.test.ts +16 -0
- package/src/hooks/document/useDocumentSyncStatus.ts +33 -0
- package/src/hooks/document/useEditDocument.test.ts +179 -0
- package/src/hooks/document/useEditDocument.ts +195 -0
- package/src/hooks/documents/useDocuments.test.tsx +152 -0
- package/src/hooks/documents/useDocuments.ts +174 -0
- package/src/hooks/helpers/createCallbackHook.tsx +3 -2
- package/src/hooks/helpers/createStateSourceHook.test.tsx +66 -0
- package/src/hooks/helpers/createStateSourceHook.tsx +29 -10
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +259 -0
- package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +290 -0
- package/src/hooks/preview/usePreview.test.tsx +19 -10
- package/src/hooks/preview/usePreview.tsx +67 -13
- package/src/hooks/projection/useProjection.test.tsx +218 -0
- package/src/hooks/projection/useProjection.ts +147 -0
- package/src/hooks/projects/useProject.ts +48 -0
- package/src/hooks/projects/useProjects.ts +45 -0
- package/src/hooks/query/useQuery.test.tsx +188 -0
- package/src/hooks/query/useQuery.ts +103 -0
- package/src/hooks/users/useUsers.test.ts +163 -0
- package/src/hooks/users/useUsers.ts +107 -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 -257
- package/dist/components.js +0 -316
- package/dist/components.js.map +0 -1
- package/dist/hooks.d.ts +0 -187
- 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/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/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,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
|
+
}
|