@sanity/sdk-react 3.4.0-rc.0 → 3.5.0-next.20260922182803
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/dist/_exports/dashboard.d.ts +151 -45
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +267 -125
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +21 -141
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -147
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js → useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js} +61 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DL6_9jgU.js.map +1 -0
- package/package.json +9 -9
- package/src/_exports/dashboard.test-d.ts +3 -0
- package/src/_exports/dashboard.ts +13 -2
- package/src/_exports/sdk-react.ts +0 -3
- package/src/components/auth/AuthBoundary.test.tsx +2 -81
- package/src/components/auth/AuthBoundary.tsx +3 -17
- package/src/components/auth/LoginCallback.test.tsx +7 -46
- package/src/components/auth/LoginCallback.tsx +4 -22
- package/src/hooks/dashboard/useAgentResourceContext.test.tsx +67 -2
- package/src/hooks/dashboard/useAgentResourceContext.ts +35 -6
- package/src/hooks/dashboard/useApplicationContext.test.tsx +95 -0
- package/src/hooks/dashboard/useApplicationContext.ts +47 -0
- package/src/hooks/dashboard/useApplications.test.tsx +165 -7
- package/src/hooks/dashboard/useApplications.ts +112 -28
- package/src/hooks/dashboard/useCapabilities.test.tsx +84 -0
- package/src/hooks/dashboard/useCapabilities.ts +27 -0
- package/src/hooks/dashboard/useNavigate.test.ts +392 -5
- package/src/hooks/dashboard/useNavigate.ts +169 -26
- package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +116 -9
- package/src/hooks/dashboard/useNavigateToStudioDocument.ts +110 -39
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.test.ts +99 -1
- package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +73 -2
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +23 -1
- package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +2 -1
- package/dist/useStudioWorkspacesByProjectIdDataset-B5A5kBH9.js.map +0 -1
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +0 -16
- package/src/hooks/auth/useHandleOAuthCallback.tsx +0 -49
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +0 -16
- package/src/hooks/auth/useOAuthAuthorize.tsx +0 -28
- package/src/hooks/auth/useOAuthTokens.test.tsx +0 -240
- package/src/hooks/auth/useOAuthTokens.tsx +0 -95
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {type DocumentHandle} from '@sanity/sdk'
|
|
2
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
3
|
+
import {type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
2
4
|
import {renderHook} from '@testing-library/react'
|
|
3
|
-
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
6
|
|
|
7
|
+
import {renderHook as renderHookWithInstance} from '../../../test/test-utils'
|
|
5
8
|
import {useNavigateToStudioDocument} from './useNavigateToStudioDocument'
|
|
6
9
|
|
|
7
10
|
// Mock dependencies
|
|
@@ -54,14 +57,6 @@ describe('useNavigateToStudioDocument', () => {
|
|
|
54
57
|
}
|
|
55
58
|
})
|
|
56
59
|
|
|
57
|
-
it('returns a function and connection status', () => {
|
|
58
|
-
const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
|
|
59
|
-
|
|
60
|
-
expect(result.current).toEqual({
|
|
61
|
-
navigateToStudioDocument: expect.any(Function),
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
|
|
65
60
|
it('sends correct navigation message when called', () => {
|
|
66
61
|
const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
|
|
67
62
|
|
|
@@ -205,3 +200,115 @@ describe('useNavigateToStudioDocument', () => {
|
|
|
205
200
|
consoleSpy.mockRestore()
|
|
206
201
|
})
|
|
207
202
|
})
|
|
203
|
+
|
|
204
|
+
describe('useNavigateToStudioDocument (message bus)', () => {
|
|
205
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
206
|
+
let host: MessageBusHost
|
|
207
|
+
let updates: {url: string}[]
|
|
208
|
+
let reply: {ok: true} | {ok: false; reason: 'not-navigable' | 'interrupted' | 'failed'}
|
|
209
|
+
|
|
210
|
+
const mockDocumentHandle: DocumentHandle = {
|
|
211
|
+
documentId: 'doc123',
|
|
212
|
+
documentType: 'article',
|
|
213
|
+
projectId: 'project1',
|
|
214
|
+
dataset: 'dataset1',
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const mockWorkspace = {
|
|
218
|
+
id: 'workspace123',
|
|
219
|
+
name: 'production',
|
|
220
|
+
title: 'Production',
|
|
221
|
+
basePath: '/production',
|
|
222
|
+
projectId: 'project1',
|
|
223
|
+
dataset: 'dataset1',
|
|
224
|
+
type: 'studio',
|
|
225
|
+
userApplicationId: 'studio-app',
|
|
226
|
+
url: 'https://test.sanity.studio',
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
// Reset shared mocks so `mockSendMessage` assertions don't depend on test order.
|
|
231
|
+
vi.resetAllMocks()
|
|
232
|
+
updates = []
|
|
233
|
+
reply = {ok: true}
|
|
234
|
+
mockWorkspacesByProjectIdAndDataset = {
|
|
235
|
+
'project1:dataset1': [mockWorkspace],
|
|
236
|
+
}
|
|
237
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
238
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
239
|
+
host.subscribe('navigation.location.update', (message) => {
|
|
240
|
+
updates.push(message.payload)
|
|
241
|
+
message.reply(reply)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
afterEach(() => {
|
|
246
|
+
resetMessageBus()
|
|
247
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
248
|
+
vi.unstubAllGlobals()
|
|
249
|
+
vi.restoreAllMocks()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('emits navigation.location.update with the studio edit intent url', () => {
|
|
253
|
+
const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
|
|
254
|
+
|
|
255
|
+
result.current.navigateToStudioDocument()
|
|
256
|
+
|
|
257
|
+
expect(mockSendMessage).not.toHaveBeenCalled()
|
|
258
|
+
expect(updates).toEqual([
|
|
259
|
+
{url: '/studios/studio-app/production/intent/edit/id=doc123;type=article/'},
|
|
260
|
+
])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('selects the workspace matching the preferred studio url', () => {
|
|
264
|
+
const preferredUrl = 'https://preferred.sanity.studio'
|
|
265
|
+
const mockWorkspace2 = {
|
|
266
|
+
...mockWorkspace,
|
|
267
|
+
id: 'workspace2',
|
|
268
|
+
name: 'staging',
|
|
269
|
+
userApplicationId: 'preferred-app',
|
|
270
|
+
url: preferredUrl,
|
|
271
|
+
}
|
|
272
|
+
mockWorkspacesByProjectIdAndDataset = {
|
|
273
|
+
'project1:dataset1': [mockWorkspace, mockWorkspace2],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const {result} = renderHookWithInstance(() =>
|
|
277
|
+
useNavigateToStudioDocument(mockDocumentHandle, preferredUrl),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
result.current.navigateToStudioDocument()
|
|
281
|
+
|
|
282
|
+
expect(updates).toEqual([
|
|
283
|
+
{url: '/studios/preferred-app/staging/intent/edit/id=doc123;type=article/'},
|
|
284
|
+
])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('warns and does not emit when no workspace is found', () => {
|
|
288
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
289
|
+
mockWorkspacesByProjectIdAndDataset = {}
|
|
290
|
+
|
|
291
|
+
const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
|
|
292
|
+
result.current.navigateToStudioDocument()
|
|
293
|
+
|
|
294
|
+
expect(updates).toEqual([])
|
|
295
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
296
|
+
consoleSpy.mockRestore()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('warns when the host rejects the navigation', async () => {
|
|
300
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
301
|
+
reply = {ok: false, reason: 'not-navigable'}
|
|
302
|
+
|
|
303
|
+
const {result} = renderHookWithInstance(() => useNavigateToStudioDocument(mockDocumentHandle))
|
|
304
|
+
result.current.navigateToStudioDocument()
|
|
305
|
+
|
|
306
|
+
await vi.waitFor(() =>
|
|
307
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
308
|
+
'Failed to navigate to studio document',
|
|
309
|
+
expect.objectContaining({ok: false, reason: 'not-navigable'}),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
consoleSpy.mockRestore()
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
/* eslint-disable react-compiler/react-compiler -- the transport branch in `useNavigateToStudioDocument` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
|
|
1
2
|
import {type Bridge, SDK_CHANNEL_NAME, SDK_NODE_NAME} from '@sanity/message-protocol'
|
|
2
3
|
import {type DocumentHandle} from '@sanity/sdk'
|
|
4
|
+
import {isDashboardEnvironment} from '@sanity/sdk/_internal'
|
|
3
5
|
import {useCallback} from 'react'
|
|
4
6
|
|
|
7
|
+
import {urlFor} from '../../dashboard/urlFor'
|
|
5
8
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
9
|
+
import {useEmit} from './useEmit'
|
|
6
10
|
import {
|
|
7
11
|
type DashboardResource,
|
|
8
12
|
useStudioWorkspacesByProjectIdDataset,
|
|
@@ -16,6 +20,61 @@ export interface NavigateToStudioResult {
|
|
|
16
20
|
navigateToStudioDocument: () => void
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
function useComlinkStudioNavigator(
|
|
24
|
+
documentHandle: DocumentHandle,
|
|
25
|
+
): (workspace: DashboardResource) => void {
|
|
26
|
+
const {sendMessage} = useWindowConnection<Bridge.Navigation.NavigateToResourceMessage, never>({
|
|
27
|
+
name: SDK_NODE_NAME,
|
|
28
|
+
connectTo: SDK_CHANNEL_NAME,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return useCallback(
|
|
32
|
+
(workspace: DashboardResource) => {
|
|
33
|
+
const message: Bridge.Navigation.NavigateToResourceMessage = {
|
|
34
|
+
type: 'dashboard/v1/bridge/navigate-to-resource',
|
|
35
|
+
data: {
|
|
36
|
+
resourceId: workspace.id,
|
|
37
|
+
resourceType: 'studio',
|
|
38
|
+
path: `/intent/edit/id=${documentHandle.documentId};type=${documentHandle.documentType}`,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
sendMessage(message.type, message.data)
|
|
42
|
+
},
|
|
43
|
+
[documentHandle, sendMessage],
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function useBusStudioNavigator(
|
|
48
|
+
documentHandle: DocumentHandle,
|
|
49
|
+
): (workspace: DashboardResource) => void {
|
|
50
|
+
const navigate = useEmit('navigation.location.update')
|
|
51
|
+
|
|
52
|
+
return useCallback(
|
|
53
|
+
(workspace: DashboardResource) => {
|
|
54
|
+
const url = urlFor
|
|
55
|
+
.studios(workspace.userApplicationId)
|
|
56
|
+
.workspace(workspace.name)
|
|
57
|
+
.intent('edit', {id: documentHandle.documentId, type: documentHandle.documentType})
|
|
58
|
+
.url()
|
|
59
|
+
navigate({url}).then(
|
|
60
|
+
(reply) => {
|
|
61
|
+
if (reply.ok === false) {
|
|
62
|
+
// eslint-disable-next-line no-console
|
|
63
|
+
console.warn('Failed to navigate to studio document', reply)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
// The reply can reject (no responder, timeout, aborted); navigation is best-effort, so
|
|
67
|
+
// warn instead of throwing.
|
|
68
|
+
(error) => {
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.warn('Failed to navigate to studio document', error)
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
[documentHandle, navigate],
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
19
78
|
/**
|
|
20
79
|
* @public
|
|
21
80
|
*
|
|
@@ -24,6 +83,9 @@ export interface NavigateToStudioResult {
|
|
|
24
83
|
* Uses the `projectId` and `dataset` properties of the {@link DocumentHandle} you provide to resolve the correct Studio.
|
|
25
84
|
* This will only work if you have deployed a studio with a workspace with this `projectId` / `dataset` combination.
|
|
26
85
|
*
|
|
86
|
+
* Works in both Dashboard runtimes: it sends the navigation over the Comlink connection in the
|
|
87
|
+
* iframe runtime and over the message bus in the federated runtime.
|
|
88
|
+
*
|
|
27
89
|
* @remarks If you write your own Document Handle to pass to this hook (as opposed to a Document Handle generated by another hook),
|
|
28
90
|
* it must include values for `documentId`, `documentType`, `projectId`, and `dataset`.
|
|
29
91
|
*
|
|
@@ -66,10 +128,13 @@ export function useNavigateToStudioDocument(
|
|
|
66
128
|
preferredStudioUrl?: string,
|
|
67
129
|
): NavigateToStudioResult {
|
|
68
130
|
const {workspacesByProjectIdAndDataset} = useStudioWorkspacesByProjectIdDataset()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
// The branch is stable: the transport is fixed for the page lifetime, so one navigator hook
|
|
132
|
+
// always runs and the other never does.
|
|
133
|
+
const sendToWorkspace = isDashboardEnvironment()
|
|
134
|
+
? // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
135
|
+
useBusStudioNavigator(documentHandle)
|
|
136
|
+
: // eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
137
|
+
useComlinkStudioNavigator(documentHandle)
|
|
73
138
|
|
|
74
139
|
const navigateToStudioDocument = useCallback(() => {
|
|
75
140
|
const {projectId, dataset} = documentHandle
|
|
@@ -80,30 +145,11 @@ export function useNavigateToStudioDocument(
|
|
|
80
145
|
return
|
|
81
146
|
}
|
|
82
147
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const allWorkspaces = [
|
|
89
|
-
...(workspacesByProjectIdAndDataset[`${projectId}:${dataset}`] || []),
|
|
90
|
-
...(workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'] || []),
|
|
91
|
-
]
|
|
92
|
-
workspace = allWorkspaces.find((w) => w.url === preferredStudioUrl)
|
|
93
|
-
} else {
|
|
94
|
-
const workspaces = workspacesByProjectIdAndDataset[`${projectId}:${dataset}`]
|
|
95
|
-
if (workspaces?.length > 1) {
|
|
96
|
-
// eslint-disable-next-line no-console
|
|
97
|
-
console.warn(
|
|
98
|
-
'Multiple workspaces found for document and no preferred studio url',
|
|
99
|
-
documentHandle,
|
|
100
|
-
)
|
|
101
|
-
// eslint-disable-next-line no-console
|
|
102
|
-
console.warn('Using the first one', workspaces[0])
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
workspace = workspaces?.[0]
|
|
106
|
-
}
|
|
148
|
+
const workspace = resolveWorkspace(
|
|
149
|
+
documentHandle,
|
|
150
|
+
workspacesByProjectIdAndDataset,
|
|
151
|
+
preferredStudioUrl,
|
|
152
|
+
)
|
|
107
153
|
|
|
108
154
|
if (!workspace) {
|
|
109
155
|
// eslint-disable-next-line no-console
|
|
@@ -113,19 +159,44 @@ export function useNavigateToStudioDocument(
|
|
|
113
159
|
return
|
|
114
160
|
}
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
data: {
|
|
119
|
-
resourceId: workspace.id,
|
|
120
|
-
resourceType: 'studio',
|
|
121
|
-
path: `/intent/edit/id=${documentHandle.documentId};type=${documentHandle.documentType}`,
|
|
122
|
-
},
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
sendMessage(message.type, message.data)
|
|
126
|
-
}, [documentHandle, workspacesByProjectIdAndDataset, sendMessage, preferredStudioUrl])
|
|
162
|
+
sendToWorkspace(workspace)
|
|
163
|
+
}, [documentHandle, workspacesByProjectIdAndDataset, preferredStudioUrl, sendToWorkspace])
|
|
127
164
|
|
|
128
165
|
return {
|
|
129
166
|
navigateToStudioDocument,
|
|
130
167
|
}
|
|
131
168
|
}
|
|
169
|
+
|
|
170
|
+
type WorkspacesByProjectIdDataset = ReturnType<
|
|
171
|
+
typeof useStudioWorkspacesByProjectIdDataset
|
|
172
|
+
>['workspacesByProjectIdAndDataset']
|
|
173
|
+
|
|
174
|
+
function resolveWorkspace(
|
|
175
|
+
documentHandle: DocumentHandle,
|
|
176
|
+
workspacesByProjectIdAndDataset: WorkspacesByProjectIdDataset,
|
|
177
|
+
preferredStudioUrl?: string,
|
|
178
|
+
): DashboardResource | undefined {
|
|
179
|
+
const {projectId, dataset} = documentHandle
|
|
180
|
+
const key = `${projectId}:${dataset}` as const
|
|
181
|
+
|
|
182
|
+
if (preferredStudioUrl) {
|
|
183
|
+
// Include workspaces without projectId/dataset in case their manifest has not loaded yet.
|
|
184
|
+
const allWorkspaces = [
|
|
185
|
+
...(workspacesByProjectIdAndDataset[key] || []),
|
|
186
|
+
...(workspacesByProjectIdAndDataset['NO_PROJECT_ID:NO_DATASET'] || []),
|
|
187
|
+
]
|
|
188
|
+
return allWorkspaces.find((w) => w.url === preferredStudioUrl)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const workspaces = workspacesByProjectIdAndDataset[key]
|
|
192
|
+
if (workspaces && workspaces.length > 1) {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.warn(
|
|
195
|
+
'Multiple workspaces found for document and no preferred studio url',
|
|
196
|
+
documentHandle,
|
|
197
|
+
)
|
|
198
|
+
// eslint-disable-next-line no-console
|
|
199
|
+
console.warn('Using the first one', workspaces[0])
|
|
200
|
+
}
|
|
201
|
+
return workspaces?.[0]
|
|
202
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
2
|
+
import {type ApplicationActivity, type MessageBusHost} from '@sanity/sdk/dashboard'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
2
4
|
|
|
3
5
|
import {renderHook} from '../../../test/test-utils'
|
|
4
6
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
@@ -69,3 +71,99 @@ describe('useRecordDocumentHistoryEvent', () => {
|
|
|
69
71
|
)
|
|
70
72
|
})
|
|
71
73
|
})
|
|
74
|
+
|
|
75
|
+
describe('useRecordDocumentHistoryEvent (message bus)', () => {
|
|
76
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
77
|
+
let host: MessageBusHost
|
|
78
|
+
|
|
79
|
+
const documentHandle = {
|
|
80
|
+
documentId: 'mock-id',
|
|
81
|
+
documentType: 'mock-type',
|
|
82
|
+
resourceType: 'studio' as const,
|
|
83
|
+
resourceId: 'mock-resource-id',
|
|
84
|
+
schemaName: 'production',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const emitCapabilities = (value: {history?: boolean}) =>
|
|
88
|
+
host.connections.subscribe((client) => client.emit('applications.capabilities', value))
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
92
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
93
|
+
emitCapabilities({history: true})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
resetMessageBus()
|
|
98
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
99
|
+
vi.unstubAllGlobals()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it.each(['viewed', 'edited', 'created', 'deleted'] as const)(
|
|
103
|
+
'emits a %s document activity event, mapping studios to their dataset resource',
|
|
104
|
+
(eventType) => {
|
|
105
|
+
const activity: ApplicationActivity[] = []
|
|
106
|
+
host.subscribe('applications.activity', (message) => activity.push(message.payload))
|
|
107
|
+
|
|
108
|
+
const {result} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
|
|
109
|
+
result.current.recordEvent(eventType)
|
|
110
|
+
|
|
111
|
+
expect(useWindowConnection).not.toHaveBeenCalled()
|
|
112
|
+
expect(activity).toEqual([
|
|
113
|
+
{
|
|
114
|
+
kind: 'document',
|
|
115
|
+
eventType,
|
|
116
|
+
document: {
|
|
117
|
+
id: 'mock-id',
|
|
118
|
+
type: 'mock-type',
|
|
119
|
+
resource: {id: 'mock-resource-id', type: 'dataset', schemaName: 'production'},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
it('passes a non-studio resource type through unmapped', () => {
|
|
127
|
+
const activity: ApplicationActivity[] = []
|
|
128
|
+
host.subscribe('applications.activity', (message) => activity.push(message.payload))
|
|
129
|
+
|
|
130
|
+
const {result} = renderHook(() =>
|
|
131
|
+
useRecordDocumentHistoryEvent({...documentHandle, resourceType: 'media-library'}),
|
|
132
|
+
)
|
|
133
|
+
result.current.recordEvent('viewed')
|
|
134
|
+
|
|
135
|
+
expect(activity[0].document.resource.type).toBe('media-library')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it.each([{history: false}, {}])(
|
|
139
|
+
'does not emit when the host does not provide the history capability (%o)',
|
|
140
|
+
(capabilities) => {
|
|
141
|
+
emitCapabilities(capabilities)
|
|
142
|
+
const activity: ApplicationActivity[] = []
|
|
143
|
+
host.subscribe('applications.activity', (message) => activity.push(message.payload))
|
|
144
|
+
|
|
145
|
+
const {result} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
|
|
146
|
+
result.current.recordEvent('viewed')
|
|
147
|
+
|
|
148
|
+
expect(activity).toEqual([])
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
it('throws when resourceId is missing', () => {
|
|
153
|
+
expect(() =>
|
|
154
|
+
renderHook(() => useRecordDocumentHistoryEvent({...documentHandle, resourceId: undefined})),
|
|
155
|
+
).toThrow('resourceId is required to record document history under the message bus')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('keeps recordEvent referentially stable when capabilities re-publish unchanged', () => {
|
|
159
|
+
const {result, rerender} = renderHook(() => useRecordDocumentHistoryEvent(documentHandle))
|
|
160
|
+
const first = result.current.recordEvent
|
|
161
|
+
|
|
162
|
+
// Re-publishing the same value must not churn the callback: a dependency on the whole
|
|
163
|
+
// capabilities object rather than `capabilities.history` would fail here.
|
|
164
|
+
emitCapabilities({history: true})
|
|
165
|
+
rerender()
|
|
166
|
+
|
|
167
|
+
expect(result.current.recordEvent).toBe(first)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable react-compiler/react-compiler -- the transport branch in `useRecordDocumentHistoryEvent` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
|
|
1
2
|
import {
|
|
2
3
|
type CanvasResource,
|
|
3
4
|
type Events,
|
|
@@ -7,10 +8,13 @@ import {
|
|
|
7
8
|
type StudioResource,
|
|
8
9
|
} from '@sanity/message-protocol'
|
|
9
10
|
import {type DocumentHandle} from '@sanity/sdk'
|
|
11
|
+
import {isDashboardEnvironment} from '@sanity/sdk/_internal'
|
|
10
12
|
import {type FrameMessage} from '@sanity/sdk/comlink'
|
|
11
13
|
import {useCallback} from 'react'
|
|
12
14
|
|
|
13
15
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
16
|
+
import {useEmit} from './useEmit'
|
|
17
|
+
import {useTopic} from './useTopic'
|
|
14
18
|
|
|
15
19
|
interface DocumentInteractionHistory {
|
|
16
20
|
recordEvent: (eventType: 'viewed' | 'edited' | 'created' | 'deleted') => void
|
|
@@ -21,6 +25,11 @@ interface DocumentInteractionHistory {
|
|
|
21
25
|
*/
|
|
22
26
|
interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
|
|
23
27
|
resourceType: StudioResource['type'] | MediaResource['type'] | CanvasResource['type']
|
|
28
|
+
/**
|
|
29
|
+
* The resource the document lives in. Optional for studios over Comlink, where the iframe
|
|
30
|
+
* host fills it from context; always required under the message bus, which has no such
|
|
31
|
+
* context and throws when it is missing.
|
|
32
|
+
*/
|
|
24
33
|
resourceId?: string
|
|
25
34
|
/**
|
|
26
35
|
* The name of the schema collection this document belongs to.
|
|
@@ -31,8 +40,20 @@ interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
|
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* @internal
|
|
34
|
-
* Hook for
|
|
43
|
+
* Hook for recording document interaction history in a Dashboard application.
|
|
35
44
|
* This hook provides functionality to record document interactions.
|
|
45
|
+
*
|
|
46
|
+
* It works in both Dashboard runtimes and picks the transport for the current one:
|
|
47
|
+
*
|
|
48
|
+
* | Runtime | Transport | `resourceId` | Suspends until |
|
|
49
|
+
* | --- | --- | --- | --- |
|
|
50
|
+
* | iframe | Comlink | optional for studios | the node connects |
|
|
51
|
+
* | federated | message bus | required | capabilities publish |
|
|
52
|
+
*
|
|
53
|
+
* Under the message bus the hook always suspends until the host publishes its capabilities;
|
|
54
|
+
* only once they resolve does `recordEvent` run, and it then no-ops on every call when the
|
|
55
|
+
* host does not provide the `history` capability. There is no synchronous no-op path.
|
|
56
|
+
*
|
|
36
57
|
* @category History
|
|
37
58
|
* @param documentHandle - The document handle containing document ID and type, like `{_id: '123', _type: 'book'}`
|
|
38
59
|
* @returns An object containing:
|
|
@@ -70,7 +91,18 @@ interface UseRecordDocumentHistoryEventProps extends DocumentHandle {
|
|
|
70
91
|
* }
|
|
71
92
|
* ```
|
|
72
93
|
*/
|
|
73
|
-
export function useRecordDocumentHistoryEvent(
|
|
94
|
+
export function useRecordDocumentHistoryEvent(
|
|
95
|
+
props: UseRecordDocumentHistoryEventProps,
|
|
96
|
+
): DocumentInteractionHistory {
|
|
97
|
+
// The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
|
|
98
|
+
// always runs and the other never does.
|
|
99
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
100
|
+
if (isDashboardEnvironment()) return useBusRecordDocumentHistoryEvent(props)
|
|
101
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
102
|
+
return useComlinkRecordDocumentHistoryEvent(props)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function useComlinkRecordDocumentHistoryEvent({
|
|
74
106
|
documentId,
|
|
75
107
|
documentType,
|
|
76
108
|
resourceType,
|
|
@@ -119,3 +151,42 @@ export function useRecordDocumentHistoryEvent({
|
|
|
119
151
|
recordEvent,
|
|
120
152
|
}
|
|
121
153
|
}
|
|
154
|
+
|
|
155
|
+
function useBusRecordDocumentHistoryEvent({
|
|
156
|
+
documentId,
|
|
157
|
+
documentType,
|
|
158
|
+
resourceType,
|
|
159
|
+
resourceId,
|
|
160
|
+
schemaName,
|
|
161
|
+
}: UseRecordDocumentHistoryEventProps): DocumentInteractionHistory {
|
|
162
|
+
const emitActivity = useEmit('applications.activity')
|
|
163
|
+
const capabilities = useTopic('applications.capabilities')
|
|
164
|
+
|
|
165
|
+
// The bus host has no iframe context to fill `projectId.dataset` from, so the resource must
|
|
166
|
+
// be addressed explicitly.
|
|
167
|
+
if (!resourceId) {
|
|
168
|
+
throw new Error('resourceId is required to record document history under the message bus')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A studio is addressed by its dataset resource over the bus; the workspace name rides along
|
|
172
|
+
// in `schemaName`.
|
|
173
|
+
const type = resourceType === 'studio' ? 'dataset' : resourceType
|
|
174
|
+
|
|
175
|
+
const recordEvent = useCallback(
|
|
176
|
+
(eventType: 'viewed' | 'edited' | 'created' | 'deleted') => {
|
|
177
|
+
if (!capabilities.history) return
|
|
178
|
+
emitActivity({
|
|
179
|
+
kind: 'document',
|
|
180
|
+
eventType,
|
|
181
|
+
document: {
|
|
182
|
+
id: documentId,
|
|
183
|
+
type: documentType,
|
|
184
|
+
resource: {id: resourceId, type, schemaName},
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
[capabilities.history, documentId, documentType, emitActivity, resourceId, schemaName, type],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return {recordEvent}
|
|
192
|
+
}
|
|
@@ -305,7 +305,22 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
|
|
|
305
305
|
})
|
|
306
306
|
|
|
307
307
|
it('maps studio workspaces to resources keyed by projectId:dataset', () => {
|
|
308
|
-
|
|
308
|
+
// An installation (no `type`) shares the topic; the hook must tolerate it and map only studios.
|
|
309
|
+
const installation = {id: 'installation-1', applicationId: 'app-remote-1'}
|
|
310
|
+
// A dev-server studio is still a studio, addressed by its dev server.
|
|
311
|
+
const localStudio = {
|
|
312
|
+
...studio,
|
|
313
|
+
id: 'studio-local',
|
|
314
|
+
slug: null,
|
|
315
|
+
externalUrl: 'http://localhost:3333',
|
|
316
|
+
local: {host: 'localhost', port: 3333},
|
|
317
|
+
activeDeployment: {
|
|
318
|
+
...deployment,
|
|
319
|
+
applicationId: 'studio-local',
|
|
320
|
+
workspaces: [{...workspace, id: 'workspace-local', projectId: 'project3'}],
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
emitApplications([studio, coreApp, installation, localStudio])
|
|
309
324
|
|
|
310
325
|
const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
|
|
311
326
|
|
|
@@ -327,6 +342,13 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
|
|
|
327
342
|
expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
|
|
328
343
|
],
|
|
329
344
|
'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
|
|
345
|
+
'project3:dataset1': [
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
id: 'workspace-local',
|
|
348
|
+
userApplicationId: 'studio-local',
|
|
349
|
+
url: 'http://localhost:3333',
|
|
350
|
+
}),
|
|
351
|
+
],
|
|
330
352
|
})
|
|
331
353
|
})
|
|
332
354
|
|
|
@@ -79,7 +79,8 @@ export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult
|
|
|
79
79
|
// The legacy Comlink protocol models studios at the workspace level, so each workspace of a
|
|
80
80
|
// studio's active deployment becomes one resource, addressed by the studio's origin.
|
|
81
81
|
function toResources(application: DashboardApplications[number]): DashboardResource[] {
|
|
82
|
-
|
|
82
|
+
// Installations join the topic union without a `type`; only studios yield workspace resources.
|
|
83
|
+
if (!('type' in application) || application.type !== 'studio') return []
|
|
83
84
|
const url = getApplicationOrigin(application) ?? ''
|
|
84
85
|
return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
|
|
85
86
|
id: workspace.id,
|