@sanity/sdk-react 3.4.0-rc.0 → 3.4.0
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 +112 -34
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +219 -114
- 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-Bjwi4cLk.js} +60 -3
- package/dist/useStudioWorkspacesByProjectIdDataset-Bjwi4cLk.js.map +1 -0
- package/package.json +9 -9
- package/src/_exports/dashboard.test-d.ts +3 -0
- package/src/_exports/dashboard.ts +11 -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/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/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,10 +1,13 @@
|
|
|
1
1
|
import {type PathChangeMessage} from '@sanity/message-protocol'
|
|
2
|
+
import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
|
|
3
|
+
import {type MessageBusHost, type NavigationLocation, type ValueOf} 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 {act, renderHook as renderHookWithInstance} from '../../../test/test-utils'
|
|
5
8
|
import {useNavigate} from './useNavigate'
|
|
6
9
|
|
|
7
|
-
const
|
|
10
|
+
const mockSendMessage = vi.fn()
|
|
8
11
|
let mockMessageHandler: ((data: PathChangeMessage['data']) => void) | undefined
|
|
9
12
|
|
|
10
13
|
vi.mock('../comlink/useWindowConnection', () => {
|
|
@@ -12,11 +15,11 @@ vi.mock('../comlink/useWindowConnection', () => {
|
|
|
12
15
|
useWindowConnection: ({
|
|
13
16
|
onMessage,
|
|
14
17
|
}: {
|
|
15
|
-
onMessage
|
|
18
|
+
onMessage?: Record<string, (data: PathChangeMessage['data']) => void>
|
|
16
19
|
}) => {
|
|
17
|
-
mockMessageHandler = onMessage['dashboard/v1/history/change-path']
|
|
20
|
+
mockMessageHandler = onMessage?.['dashboard/v1/history/change-path']
|
|
18
21
|
return {
|
|
19
|
-
|
|
22
|
+
sendMessage: mockSendMessage,
|
|
20
23
|
}
|
|
21
24
|
},
|
|
22
25
|
}
|
|
@@ -41,4 +44,388 @@ describe('useNavigate', () => {
|
|
|
41
44
|
|
|
42
45
|
expect(mockNavigateFn).toHaveBeenCalledWith(mockNavigationData)
|
|
43
46
|
})
|
|
47
|
+
|
|
48
|
+
it('reports an in-app navigation over the Comlink update-url message', () => {
|
|
49
|
+
const {result} = renderHook(() => useNavigate(mockNavigateFn))
|
|
50
|
+
|
|
51
|
+
result.current({path: 'documents/abc'})
|
|
52
|
+
|
|
53
|
+
// jsdom's origin is fixed at http://localhost:3000 in this test env.
|
|
54
|
+
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
55
|
+
'dashboard/v1/bridge/listeners/history/update-url',
|
|
56
|
+
{url: 'http://localhost:3000/documents/abc'},
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('useNavigate (message bus)', () => {
|
|
62
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
63
|
+
let host: MessageBusHost
|
|
64
|
+
// The single navigation.location.update responder for the describe: it records every payload
|
|
65
|
+
// and replies with whatever `updateReply` currently holds, so a test can force a not-ok reply
|
|
66
|
+
// without registering a second competing responder.
|
|
67
|
+
let updates: {url: string; history?: string}[]
|
|
68
|
+
let updateReply: {ok: true} | {ok: false; reason: 'not-navigable' | 'interrupted' | 'failed'}
|
|
69
|
+
|
|
70
|
+
const emitLocation = (value: NavigationLocation | null) =>
|
|
71
|
+
host.connections.subscribe((client) =>
|
|
72
|
+
client.emit('navigation.location', value as ValueOf<'navigation.location'>),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const publishBasePath = () =>
|
|
76
|
+
host.connections.subscribe((client) =>
|
|
77
|
+
client.emit('applications.base-path', {ok: true, value: '/applications/app'}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
updates = []
|
|
82
|
+
updateReply = {ok: true}
|
|
83
|
+
vi.stubGlobal('__SANITY_APP_ID__', 'app')
|
|
84
|
+
host = installMessageBus({appId: 'dashboard'})
|
|
85
|
+
host.subscribe('navigation.location.update', (message) => {
|
|
86
|
+
updates.push(message.payload)
|
|
87
|
+
message.reply(updateReply)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
resetMessageBus()
|
|
93
|
+
delete (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
|
|
94
|
+
vi.unstubAllGlobals()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('does not fire on the initial replayed location', () => {
|
|
98
|
+
publishBasePath()
|
|
99
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
100
|
+
|
|
101
|
+
const navigateFn = vi.fn()
|
|
102
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
103
|
+
|
|
104
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it.each([['push'], ['replace']] as const)(
|
|
108
|
+
'fires once for a host-mediated %s request to this app',
|
|
109
|
+
(navigationType) => {
|
|
110
|
+
publishBasePath()
|
|
111
|
+
emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
|
|
112
|
+
|
|
113
|
+
const navigateFn = vi.fn()
|
|
114
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
115
|
+
|
|
116
|
+
const to = {appId: 'app', path: 'documents/abc'}
|
|
117
|
+
act(() => {
|
|
118
|
+
emitLocation({appId: 'dashboard', path: 'dashboard', transition: {navigationType, to}})
|
|
119
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType, to}})
|
|
120
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
124
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/abc', type: navigationType})
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
it('ignores a transition targeting another app', () => {
|
|
129
|
+
publishBasePath()
|
|
130
|
+
emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
|
|
131
|
+
|
|
132
|
+
const navigateFn = vi.fn()
|
|
133
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
134
|
+
|
|
135
|
+
const to = {appId: 'other', path: 'other/route'}
|
|
136
|
+
act(() => {
|
|
137
|
+
emitLocation({
|
|
138
|
+
appId: 'dashboard',
|
|
139
|
+
path: 'dashboard',
|
|
140
|
+
transition: {navigationType: 'push', to},
|
|
141
|
+
})
|
|
142
|
+
emitLocation({appId: 'other', path: 'other/route', transition: {navigationType: 'push', to}})
|
|
143
|
+
emitLocation({appId: 'other', path: 'other/route', transition: null})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('ignores the app’s own in-app change with no prior transition', () => {
|
|
150
|
+
publishBasePath()
|
|
151
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
152
|
+
|
|
153
|
+
const navigateFn = vi.fn()
|
|
154
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('emits navigation.location.update with the joined path and history for an outbound report', async () => {
|
|
164
|
+
publishBasePath()
|
|
165
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
166
|
+
|
|
167
|
+
const navigateFn = vi.fn()
|
|
168
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
169
|
+
|
|
170
|
+
await act(async () => {
|
|
171
|
+
result.current({path: 'documents/def', type: 'replace'})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'replace'}])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('defaults the outbound history to push when no type is given', async () => {
|
|
178
|
+
publishBasePath()
|
|
179
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
180
|
+
|
|
181
|
+
const {result} = renderHookWithInstance(() => useNavigate(vi.fn()))
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
result.current({path: 'documents/def'})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'push'}])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('joins a trailing-slash base and a leading-slash path into a single separator', async () => {
|
|
191
|
+
host.connections.subscribe((client) =>
|
|
192
|
+
client.emit('applications.base-path', {ok: true, value: '/applications/app/'}),
|
|
193
|
+
)
|
|
194
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
195
|
+
|
|
196
|
+
const {result} = renderHookWithInstance(() => useNavigate(vi.fn()))
|
|
197
|
+
|
|
198
|
+
await act(async () => {
|
|
199
|
+
result.current({path: '/documents/def'})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(updates).toEqual([{url: '/applications/app/documents/def', history: 'push'}])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not echo the app’s own outbound report but still fires host-initiated commits', () => {
|
|
206
|
+
publishBasePath()
|
|
207
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
208
|
+
|
|
209
|
+
const navigateFn = vi.fn()
|
|
210
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
211
|
+
|
|
212
|
+
const own = {appId: 'app', path: 'documents/def'}
|
|
213
|
+
act(() => {
|
|
214
|
+
result.current({path: 'documents/def'})
|
|
215
|
+
emitLocation({
|
|
216
|
+
appId: 'app',
|
|
217
|
+
path: 'documents/abc',
|
|
218
|
+
transition: {navigationType: 'push', to: own},
|
|
219
|
+
})
|
|
220
|
+
emitLocation({
|
|
221
|
+
appId: 'app',
|
|
222
|
+
path: 'documents/def',
|
|
223
|
+
transition: {navigationType: 'push', to: own},
|
|
224
|
+
})
|
|
225
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
229
|
+
|
|
230
|
+
const hostTo = {appId: 'app', path: 'documents/ghi'}
|
|
231
|
+
act(() => {
|
|
232
|
+
emitLocation({
|
|
233
|
+
appId: 'app',
|
|
234
|
+
path: 'documents/def',
|
|
235
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
236
|
+
})
|
|
237
|
+
emitLocation({
|
|
238
|
+
appId: 'app',
|
|
239
|
+
path: 'documents/ghi',
|
|
240
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
241
|
+
})
|
|
242
|
+
emitLocation({appId: 'app', path: 'documents/ghi', transition: null})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
246
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/ghi', type: 'push'})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('fires a later host-mediated commit to the same path as a suppressed own report', () => {
|
|
250
|
+
publishBasePath()
|
|
251
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
252
|
+
|
|
253
|
+
const navigateFn = vi.fn()
|
|
254
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
255
|
+
|
|
256
|
+
const own = {appId: 'app', path: 'documents/def'}
|
|
257
|
+
act(() => {
|
|
258
|
+
result.current({path: 'documents/def'})
|
|
259
|
+
emitLocation({
|
|
260
|
+
appId: 'app',
|
|
261
|
+
path: 'documents/abc',
|
|
262
|
+
transition: {navigationType: 'push', to: own},
|
|
263
|
+
})
|
|
264
|
+
emitLocation({
|
|
265
|
+
appId: 'app',
|
|
266
|
+
path: 'documents/def',
|
|
267
|
+
transition: {navigationType: 'push', to: own},
|
|
268
|
+
})
|
|
269
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
273
|
+
|
|
274
|
+
const hostTo = {appId: 'app', path: 'documents/def'}
|
|
275
|
+
act(() => {
|
|
276
|
+
emitLocation({
|
|
277
|
+
appId: 'app',
|
|
278
|
+
path: 'documents/def',
|
|
279
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
280
|
+
})
|
|
281
|
+
emitLocation({
|
|
282
|
+
appId: 'app',
|
|
283
|
+
path: 'documents/def',
|
|
284
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
285
|
+
})
|
|
286
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
290
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('fires a host commit to a path whose own report was rejected by the host', async () => {
|
|
294
|
+
publishBasePath()
|
|
295
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
296
|
+
updateReply = {ok: false, reason: 'not-navigable'}
|
|
297
|
+
|
|
298
|
+
const navigateFn = vi.fn()
|
|
299
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
300
|
+
|
|
301
|
+
await act(async () => {
|
|
302
|
+
result.current({path: 'documents/def'})
|
|
303
|
+
await Promise.resolve()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const hostTo = {appId: 'app', path: 'documents/def'}
|
|
307
|
+
act(() => {
|
|
308
|
+
emitLocation({
|
|
309
|
+
appId: 'app',
|
|
310
|
+
path: 'documents/abc',
|
|
311
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
312
|
+
})
|
|
313
|
+
emitLocation({
|
|
314
|
+
appId: 'app',
|
|
315
|
+
path: 'documents/def',
|
|
316
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
317
|
+
})
|
|
318
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
322
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('ignores a commit whose prior transition target path does not match', () => {
|
|
326
|
+
publishBasePath()
|
|
327
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
328
|
+
|
|
329
|
+
const navigateFn = vi.fn()
|
|
330
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
331
|
+
|
|
332
|
+
const to = {appId: 'app', path: 'a'}
|
|
333
|
+
act(() => {
|
|
334
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType: 'push', to}})
|
|
335
|
+
emitLocation({appId: 'app', path: 'b', transition: null})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('suppresses the echo of an own report made with a leading slash', () => {
|
|
342
|
+
publishBasePath()
|
|
343
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
344
|
+
|
|
345
|
+
const navigateFn = vi.fn()
|
|
346
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
347
|
+
|
|
348
|
+
const own = {appId: 'app', path: 'documents/def'}
|
|
349
|
+
act(() => {
|
|
350
|
+
result.current({path: '/documents/def'})
|
|
351
|
+
emitLocation({
|
|
352
|
+
appId: 'app',
|
|
353
|
+
path: 'documents/abc',
|
|
354
|
+
transition: {navigationType: 'push', to: own},
|
|
355
|
+
})
|
|
356
|
+
emitLocation({
|
|
357
|
+
appId: 'app',
|
|
358
|
+
path: 'documents/def',
|
|
359
|
+
transition: {navigationType: 'push', to: own},
|
|
360
|
+
})
|
|
361
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
expect(navigateFn).not.toHaveBeenCalled()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('fires host-mediated commits while the base path is unpublished', () => {
|
|
368
|
+
emitLocation({appId: 'dashboard', path: 'dashboard', transition: null})
|
|
369
|
+
|
|
370
|
+
const navigateFn = vi.fn()
|
|
371
|
+
renderHookWithInstance(() => useNavigate(navigateFn))
|
|
372
|
+
|
|
373
|
+
const to = {appId: 'app', path: 'documents/abc'}
|
|
374
|
+
act(() => {
|
|
375
|
+
emitLocation({
|
|
376
|
+
appId: 'dashboard',
|
|
377
|
+
path: 'dashboard',
|
|
378
|
+
transition: {navigationType: 'push', to},
|
|
379
|
+
})
|
|
380
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: {navigationType: 'push', to}})
|
|
381
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
385
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/abc', type: 'push'})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('drops an outbound report when the base path is not ok and still fires a later host commit to that path', async () => {
|
|
389
|
+
host.connections.subscribe((client) => client.emit('applications.base-path', {ok: false}))
|
|
390
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
391
|
+
|
|
392
|
+
const navigateFn = vi.fn()
|
|
393
|
+
const {result} = renderHookWithInstance(() => useNavigate(navigateFn))
|
|
394
|
+
|
|
395
|
+
await act(async () => {
|
|
396
|
+
result.current({path: 'documents/def'})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
expect(updates).toEqual([])
|
|
400
|
+
|
|
401
|
+
const hostTo = {appId: 'app', path: 'documents/def'}
|
|
402
|
+
act(() => {
|
|
403
|
+
emitLocation({
|
|
404
|
+
appId: 'app',
|
|
405
|
+
path: 'documents/abc',
|
|
406
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
407
|
+
})
|
|
408
|
+
emitLocation({
|
|
409
|
+
appId: 'app',
|
|
410
|
+
path: 'documents/def',
|
|
411
|
+
transition: {navigationType: 'push', to: hostTo},
|
|
412
|
+
})
|
|
413
|
+
emitLocation({appId: 'app', path: 'documents/def', transition: null})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
expect(navigateFn).toHaveBeenCalledTimes(1)
|
|
417
|
+
expect(navigateFn).toHaveBeenCalledWith({path: 'documents/def', type: 'push'})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('returns a referentially stable function across re-renders', () => {
|
|
421
|
+
publishBasePath()
|
|
422
|
+
emitLocation({appId: 'app', path: 'documents/abc', transition: null})
|
|
423
|
+
|
|
424
|
+
const {result, rerender} = renderHookWithInstance(() => useNavigate(vi.fn()))
|
|
425
|
+
const first = result.current
|
|
426
|
+
|
|
427
|
+
rerender()
|
|
428
|
+
|
|
429
|
+
expect(result.current).toBe(first)
|
|
430
|
+
})
|
|
44
431
|
})
|
|
@@ -1,52 +1,99 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable react-compiler/react-compiler -- the transport branch in `useNavigate` is a deliberate rules-of-hooks exception; the compiler refuses files that disable it */
|
|
2
|
+
import {
|
|
3
|
+
type Bridge,
|
|
4
|
+
type PathChangeMessage,
|
|
5
|
+
SDK_CHANNEL_NAME,
|
|
6
|
+
SDK_NODE_NAME,
|
|
7
|
+
} from '@sanity/message-protocol'
|
|
8
|
+
import {isDashboardEnvironment, requireDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
9
|
+
import {type NavigationLocation, TopicError} from '@sanity/sdk/dashboard'
|
|
10
|
+
import {useCallback, useEffect, useEffectEvent, useRef} from 'react'
|
|
11
|
+
import {filter, map, pairwise} from 'rxjs'
|
|
2
12
|
|
|
3
13
|
import {useWindowConnection} from '../comlink/useWindowConnection'
|
|
14
|
+
import {useSanityInstance} from '../context/useSanityInstance'
|
|
15
|
+
|
|
16
|
+
type UpdateURLMessage = Bridge.Listeners.History.UpdateURLMessage
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A navigation the Dashboard asks the app to perform, or that the app reports to the Dashboard.
|
|
20
|
+
* `path` is relative to the app's own route, with no leading slash (`'documents/abc'`), and may
|
|
21
|
+
* carry a query string and hash.
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export type DashboardNavigation = PathChangeMessage['data']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reports an in-app navigation to the Dashboard so the browser URL and the Dashboard's own
|
|
28
|
+
* router follow the app. `type` defaults to `'push'` and only applies in the federated runtime;
|
|
29
|
+
* see {@link useNavigate}.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export type NavigateToDashboardPath = (options: {path: string; type?: 'push' | 'replace'}) => void
|
|
4
33
|
|
|
5
34
|
/**
|
|
6
35
|
* @public
|
|
7
36
|
*
|
|
8
37
|
* A helper hook designed to be injected into routing components for apps within the Dashboard.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
38
|
+
* It keeps the app's router and the Dashboard in two-way sync: `navigateFn` receives the
|
|
39
|
+
* navigations the Dashboard asks the app to perform, and the returned function reports the app's
|
|
40
|
+
* own in-app navigations back to the Dashboard so the browser URL and the Dashboard's router
|
|
41
|
+
* follow along.
|
|
11
42
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* there needs to be some way for the dashboard to signal to your application to reroute to where that document was favorited.
|
|
43
|
+
* A navigation the app reports is not echoed back through `navigateFn`. The returned function is
|
|
44
|
+
* referentially stable.
|
|
15
45
|
*
|
|
16
|
-
*
|
|
46
|
+
* The two Dashboard runtimes differ:
|
|
47
|
+
* - In an iframe (Comlink), inbound `type` may be `'push'`, `'replace'` or `'pop'`. Reporting is
|
|
48
|
+
* optional because the bridge already forwards the iframe's own `pushState`; a reported `type`
|
|
49
|
+
* is ignored and the host applies the URL as a `replace`.
|
|
50
|
+
* - In a federated app (message bus), inbound `type` is `'push'` or `'replace'`; `'pop'` is never
|
|
51
|
+
* sent. Reporting is required because the app's router does not reach the host, and `type` is
|
|
52
|
+
* honoured. Until the Dashboard publishes the app's base path, reports are dropped with a
|
|
53
|
+
* console warning.
|
|
17
54
|
*
|
|
18
55
|
* @param navigateFn - Function to handle navigation; should accept:
|
|
19
56
|
* - `path`: a string, which will be a relative path (for example, 'my-route')
|
|
20
57
|
* - `type`: 'push', 'replace', or 'pop', which will be the type of navigation to perform
|
|
58
|
+
* @returns A function the app calls to report its own in-app navigations to the Dashboard.
|
|
21
59
|
*
|
|
22
60
|
* @example
|
|
23
61
|
* ```tsx
|
|
24
62
|
* import {useNavigate} from '@sanity/sdk-react/dashboard'
|
|
25
|
-
* import {
|
|
26
|
-
* import {
|
|
63
|
+
* import {useEffect} from 'react'
|
|
64
|
+
* import {useLocation, useNavigate as useRouterNavigate} from 'react-router'
|
|
27
65
|
*
|
|
28
|
-
* function
|
|
29
|
-
* const
|
|
30
|
-
*
|
|
31
|
-
*
|
|
66
|
+
* function DashboardNavigationSync() {
|
|
67
|
+
* const routerNavigate = useRouterNavigate()
|
|
68
|
+
* const {pathname, search, hash} = useLocation()
|
|
69
|
+
* const navigate = useNavigate(({path, type}) => {
|
|
70
|
+
* routerNavigate(path, {replace: type === 'replace'})
|
|
32
71
|
* })
|
|
72
|
+
* useEffect(() => {
|
|
73
|
+
* navigate({path: `${pathname.slice(1)}${search}${hash}`})
|
|
74
|
+
* }, [navigate, pathname, search, hash])
|
|
33
75
|
* return null
|
|
34
76
|
* }
|
|
35
|
-
*
|
|
36
|
-
* // Wrap the component with Suspense since the hook may suspend
|
|
37
|
-
* function MyApp() {
|
|
38
|
-
* return (
|
|
39
|
-
* <BrowserRouter>
|
|
40
|
-
* <Suspense>
|
|
41
|
-
* <DashboardNavigationHandler />
|
|
42
|
-
* </Suspense>
|
|
43
|
-
* </BrowserRouter>
|
|
44
|
-
* )
|
|
45
|
-
* }
|
|
46
77
|
* ```
|
|
47
78
|
*/
|
|
48
|
-
export function useNavigate(
|
|
49
|
-
|
|
79
|
+
export function useNavigate(
|
|
80
|
+
navigateFn: (options: DashboardNavigation) => void,
|
|
81
|
+
): NavigateToDashboardPath {
|
|
82
|
+
// The branch is stable: the transport is fixed for the page lifetime, so one set of hooks
|
|
83
|
+
// always runs and the other never does.
|
|
84
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
85
|
+
if (isDashboardEnvironment()) return useBusNavigate(navigateFn)
|
|
86
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- transport is fixed for the page lifetime
|
|
87
|
+
return useComlinkNavigate(navigateFn)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function useComlinkNavigate(
|
|
91
|
+
navigateFn: (options: DashboardNavigation) => void,
|
|
92
|
+
): NavigateToDashboardPath {
|
|
93
|
+
const {sendMessage} = useWindowConnection<
|
|
94
|
+
UpdateURLMessage | PathChangeMessage,
|
|
95
|
+
PathChangeMessage
|
|
96
|
+
>({
|
|
50
97
|
name: SDK_NODE_NAME,
|
|
51
98
|
connectTo: SDK_CHANNEL_NAME,
|
|
52
99
|
onMessage: {
|
|
@@ -55,4 +102,100 @@ export function useNavigate(navigateFn: (options: PathChangeMessage['data']) =>
|
|
|
55
102
|
},
|
|
56
103
|
},
|
|
57
104
|
})
|
|
105
|
+
|
|
106
|
+
return useCallback<NavigateToDashboardPath>(
|
|
107
|
+
({path}) =>
|
|
108
|
+
sendMessage('dashboard/v1/bridge/listeners/history/update-url', {
|
|
109
|
+
url: new URL(path, window.location.origin).href,
|
|
110
|
+
}),
|
|
111
|
+
[sendMessage],
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function joinPath(base: string, path: string): string {
|
|
116
|
+
const root = base.replace(/\/$/, '')
|
|
117
|
+
return path ? `${root}/${path}` : root
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function useBusNavigate(
|
|
121
|
+
navigateFn: (options: DashboardNavigation) => void,
|
|
122
|
+
): NavigateToDashboardPath {
|
|
123
|
+
const instance = useSanityInstance()
|
|
124
|
+
const bus = requireDashboardMessageBus(instance, 'navigate')
|
|
125
|
+
const navigate = useEffectEvent(navigateFn)
|
|
126
|
+
// The path of this app's latest outbound request, used to suppress the echo of our own commit.
|
|
127
|
+
const ownRequest = useRef<string | null>(null)
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
// bus.subscribe, not useTopic: the rule needs the previous emission, and React coalesces
|
|
131
|
+
// store notifications that land in one task, so a snapshot + ref could miss the
|
|
132
|
+
// `{to, transition}` -> `{to, transition: null}` pair. The observable delivers every emission.
|
|
133
|
+
const sub = bus
|
|
134
|
+
.subscribe('navigation.location')
|
|
135
|
+
.pipe(
|
|
136
|
+
pairwise(),
|
|
137
|
+
filter(
|
|
138
|
+
(
|
|
139
|
+
pair,
|
|
140
|
+
): pair is [
|
|
141
|
+
NavigationLocation & {transition: NonNullable<NavigationLocation['transition']>},
|
|
142
|
+
NavigationLocation & {transition: null},
|
|
143
|
+
] => {
|
|
144
|
+
const [prev, curr] = pair
|
|
145
|
+
return (
|
|
146
|
+
curr?.transition === null &&
|
|
147
|
+
curr.appId === bus.appId &&
|
|
148
|
+
prev?.transition?.to.appId === bus.appId &&
|
|
149
|
+
prev.transition.to.path === curr.path
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
filter(([, curr]) => {
|
|
154
|
+
// Loop suppression: any own-app host-mediated commit consumes the pending request, so
|
|
155
|
+
// a request that lands elsewhere (or never lands) can't strand its path and drop a
|
|
156
|
+
// later host navigation. Only the commit for our exact path is our own echo to drop.
|
|
157
|
+
const suppress = curr.path === ownRequest.current
|
|
158
|
+
ownRequest.current = null
|
|
159
|
+
return !suppress
|
|
160
|
+
}),
|
|
161
|
+
map(([prev, curr]) => ({path: curr.path, type: prev.transition.navigationType})),
|
|
162
|
+
)
|
|
163
|
+
.subscribe((change) => navigate(change))
|
|
164
|
+
return () => sub.unsubscribe()
|
|
165
|
+
}, [bus])
|
|
166
|
+
|
|
167
|
+
return useCallback<NavigateToDashboardPath>(
|
|
168
|
+
({path, type = 'push'}) => {
|
|
169
|
+
// The host commits paths without a leading slash; store the same form so the echo matches.
|
|
170
|
+
const own = path.replace(/^\//, '')
|
|
171
|
+
ownRequest.current = own
|
|
172
|
+
// A request that never landed must drop its suppression, or a later host navigation to the
|
|
173
|
+
// same path would be dropped. Best-effort: never throws.
|
|
174
|
+
const clearIfStale = () => {
|
|
175
|
+
if (ownRequest.current === own) ownRequest.current = null
|
|
176
|
+
}
|
|
177
|
+
// The base path is read per call, not in render, so a host that has not published it only
|
|
178
|
+
// loses outbound reporting; the inbound subscription above still installs.
|
|
179
|
+
bus
|
|
180
|
+
.query('applications.base-path')
|
|
181
|
+
.then((base) => {
|
|
182
|
+
if (!base.ok) throw new TopicError('applications.base-path')
|
|
183
|
+
return bus.emit('navigation.location.update', {
|
|
184
|
+
url: joinPath(base.value, own),
|
|
185
|
+
history: type,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
.then(
|
|
189
|
+
(reply) => {
|
|
190
|
+
if (!reply.ok) clearIfStale()
|
|
191
|
+
},
|
|
192
|
+
(error) => {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.warn('Failed to report navigation to the Dashboard', error)
|
|
195
|
+
clearIfStale()
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
},
|
|
199
|
+
[bus],
|
|
200
|
+
)
|
|
58
201
|
}
|