@blueprint-ts/core 1.2.0 → 2.0.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/CHANGELOG.md +8 -0
- package/docs/vue/requests/route-resource-binding.md +193 -25
- package/package.json +1 -1
- package/src/vue/router/routeResourceBinding/RouteResourceBoundView.ts +145 -0
- package/src/vue/router/routeResourceBinding/defineRoute.ts +29 -1
- package/src/vue/router/routeResourceBinding/index.ts +2 -1
- package/src/vue/router/routeResourceBinding/installRouteInjection.ts +86 -15
- package/src/vue/router/routeResourceBinding/types.ts +6 -1
- package/src/vue/router/routeResourceBinding/useRouteResource.ts +17 -7
- package/tests/vue/router/routeResourceBinding/RouteResourceBoundView.test.ts +344 -0
- package/tests/vue/router/routeResourceBinding/installRouteInjection.test.ts +450 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { installRouteInjection } from '../../../../src/vue/router/routeResourceBinding/installRouteInjection'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flush all pending microtasks / promises.
|
|
6
|
+
*/
|
|
7
|
+
function flushPromises() {
|
|
8
|
+
return new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal stub for vue-router's Router – we only need `beforeResolve`.
|
|
13
|
+
*/
|
|
14
|
+
function createMockRouter() {
|
|
15
|
+
const guards: Array<(to: any) => Promise<void>> = []
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
beforeResolve(guard: (to: any) => Promise<void>) {
|
|
19
|
+
guards.push(guard)
|
|
20
|
+
},
|
|
21
|
+
async simulateNavigation(to: any) {
|
|
22
|
+
for (const guard of guards) {
|
|
23
|
+
await guard(to)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createRoute(params: Record<string, string>, matched: any[]) {
|
|
30
|
+
return {
|
|
31
|
+
params,
|
|
32
|
+
matched,
|
|
33
|
+
meta: {} as Record<string, any>
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('installRouteInjection', () => {
|
|
38
|
+
let mockRouter: ReturnType<typeof createMockRouter>
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockRouter = createMockRouter()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('resolves injected props and stores them in _injectedProps', async () => {
|
|
45
|
+
installRouteInjection(mockRouter as any)
|
|
46
|
+
|
|
47
|
+
const resolveFn = vi.fn().mockResolvedValue('resolved-invoice')
|
|
48
|
+
|
|
49
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
50
|
+
{
|
|
51
|
+
path: ':invoiceId',
|
|
52
|
+
meta: {
|
|
53
|
+
inject: {
|
|
54
|
+
invoice: {
|
|
55
|
+
from: 'invoiceId',
|
|
56
|
+
resolve: () => ({ resolve: resolveFn })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
await mockRouter.simulateNavigation(to)
|
|
64
|
+
await flushPromises()
|
|
65
|
+
|
|
66
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
67
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reuses cached value when navigating to a child route with the same param', async () => {
|
|
71
|
+
installRouteInjection(mockRouter as any)
|
|
72
|
+
|
|
73
|
+
const resolveFn = vi.fn().mockResolvedValue('resolved-invoice')
|
|
74
|
+
const parentRecord = {
|
|
75
|
+
path: ':invoiceId',
|
|
76
|
+
meta: {
|
|
77
|
+
inject: {
|
|
78
|
+
invoice: {
|
|
79
|
+
from: 'invoiceId',
|
|
80
|
+
resolve: () => ({ resolve: resolveFn })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// First navigation: parent route
|
|
87
|
+
const to1 = createRoute({ invoiceId: '123' }, [parentRecord])
|
|
88
|
+
await mockRouter.simulateNavigation(to1)
|
|
89
|
+
await flushPromises()
|
|
90
|
+
|
|
91
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(to1.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
|
|
93
|
+
|
|
94
|
+
// Second navigation: parent + child route, same invoiceId
|
|
95
|
+
const to2 = createRoute({ invoiceId: '123' }, [
|
|
96
|
+
parentRecord,
|
|
97
|
+
{ path: 'details', meta: {} }
|
|
98
|
+
])
|
|
99
|
+
await mockRouter.simulateNavigation(to2)
|
|
100
|
+
await flushPromises()
|
|
101
|
+
|
|
102
|
+
// resolve should NOT be called again – cached value reused
|
|
103
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(to2.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('re-resolves when param value changes', async () => {
|
|
108
|
+
installRouteInjection(mockRouter as any)
|
|
109
|
+
|
|
110
|
+
const resolveFn = vi.fn()
|
|
111
|
+
.mockResolvedValueOnce('invoice-A')
|
|
112
|
+
.mockResolvedValueOnce('invoice-B')
|
|
113
|
+
|
|
114
|
+
const makeParent = () => ({
|
|
115
|
+
path: ':invoiceId',
|
|
116
|
+
meta: {
|
|
117
|
+
inject: {
|
|
118
|
+
invoice: {
|
|
119
|
+
from: 'invoiceId',
|
|
120
|
+
resolve: () => ({ resolve: resolveFn })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Navigate with invoiceId=1
|
|
127
|
+
const to1 = createRoute({ invoiceId: '1' }, [makeParent()])
|
|
128
|
+
await mockRouter.simulateNavigation(to1)
|
|
129
|
+
await flushPromises()
|
|
130
|
+
|
|
131
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(to1.meta._injectedProps).toEqual({ invoice: 'invoice-A' })
|
|
133
|
+
|
|
134
|
+
// Navigate with invoiceId=2 – must re-resolve
|
|
135
|
+
const to2 = createRoute({ invoiceId: '2' }, [makeParent()])
|
|
136
|
+
await mockRouter.simulateNavigation(to2)
|
|
137
|
+
await flushPromises()
|
|
138
|
+
|
|
139
|
+
expect(resolveFn).toHaveBeenCalledTimes(2)
|
|
140
|
+
expect(to2.meta._injectedProps).toEqual({ invoice: 'invoice-B' })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('refresh() bypasses cache and fetches fresh data', async () => {
|
|
144
|
+
installRouteInjection(mockRouter as any)
|
|
145
|
+
|
|
146
|
+
const resolveFn = vi.fn()
|
|
147
|
+
.mockResolvedValueOnce('old-data')
|
|
148
|
+
.mockResolvedValueOnce('fresh-data')
|
|
149
|
+
|
|
150
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
151
|
+
{
|
|
152
|
+
path: ':invoiceId',
|
|
153
|
+
meta: {
|
|
154
|
+
inject: {
|
|
155
|
+
invoice: {
|
|
156
|
+
from: 'invoiceId',
|
|
157
|
+
resolve: () => ({ resolve: resolveFn })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
await mockRouter.simulateNavigation(to)
|
|
165
|
+
await flushPromises()
|
|
166
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'old-data' })
|
|
167
|
+
|
|
168
|
+
// Manually refresh
|
|
169
|
+
await to.meta.refresh!('invoice')
|
|
170
|
+
expect(resolveFn).toHaveBeenCalledTimes(2)
|
|
171
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'fresh-data' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('evicts cache entries when navigating away from a route', async () => {
|
|
175
|
+
installRouteInjection(mockRouter as any)
|
|
176
|
+
|
|
177
|
+
const invoiceResolve = vi.fn().mockResolvedValue('invoice-data')
|
|
178
|
+
const productResolve = vi.fn().mockResolvedValue('product-data')
|
|
179
|
+
|
|
180
|
+
// Navigate to invoice route
|
|
181
|
+
const to1 = createRoute({ invoiceId: '1' }, [
|
|
182
|
+
{
|
|
183
|
+
path: ':invoiceId',
|
|
184
|
+
meta: {
|
|
185
|
+
inject: {
|
|
186
|
+
invoice: {
|
|
187
|
+
from: 'invoiceId',
|
|
188
|
+
resolve: () => ({ resolve: invoiceResolve })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
])
|
|
194
|
+
await mockRouter.simulateNavigation(to1)
|
|
195
|
+
await flushPromises()
|
|
196
|
+
expect(invoiceResolve).toHaveBeenCalledTimes(1)
|
|
197
|
+
|
|
198
|
+
// Navigate to a completely different route (product)
|
|
199
|
+
const to2 = createRoute({ productId: '5' }, [
|
|
200
|
+
{
|
|
201
|
+
path: ':productId',
|
|
202
|
+
meta: {
|
|
203
|
+
inject: {
|
|
204
|
+
product: {
|
|
205
|
+
from: 'productId',
|
|
206
|
+
resolve: () => ({ resolve: productResolve })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
])
|
|
212
|
+
await mockRouter.simulateNavigation(to2)
|
|
213
|
+
await flushPromises()
|
|
214
|
+
|
|
215
|
+
// Now navigate back to invoice with the same id – should re-resolve (cache was evicted)
|
|
216
|
+
const to3 = createRoute({ invoiceId: '1' }, [
|
|
217
|
+
{
|
|
218
|
+
path: ':invoiceId',
|
|
219
|
+
meta: {
|
|
220
|
+
inject: {
|
|
221
|
+
invoice: {
|
|
222
|
+
from: 'invoiceId',
|
|
223
|
+
resolve: () => ({ resolve: invoiceResolve })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
])
|
|
229
|
+
await mockRouter.simulateNavigation(to3)
|
|
230
|
+
await flushPromises()
|
|
231
|
+
expect(invoiceResolve).toHaveBeenCalledTimes(2)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('child routes inherit parent injected props without re-resolving across multiple children', async () => {
|
|
235
|
+
installRouteInjection(mockRouter as any)
|
|
236
|
+
|
|
237
|
+
const resolveFn = vi.fn().mockResolvedValue('invoice-data')
|
|
238
|
+
const parentRecord = {
|
|
239
|
+
path: ':invoiceId',
|
|
240
|
+
meta: {
|
|
241
|
+
inject: {
|
|
242
|
+
invoice: {
|
|
243
|
+
from: 'invoiceId',
|
|
244
|
+
resolve: () => ({ resolve: resolveFn })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Navigate to parent
|
|
251
|
+
await mockRouter.simulateNavigation(createRoute({ invoiceId: '42' }, [parentRecord]))
|
|
252
|
+
await flushPromises()
|
|
253
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
254
|
+
|
|
255
|
+
// Navigate to child A
|
|
256
|
+
const toChildA = createRoute({ invoiceId: '42' }, [parentRecord, { path: 'edit', meta: {} }])
|
|
257
|
+
await mockRouter.simulateNavigation(toChildA)
|
|
258
|
+
await flushPromises()
|
|
259
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
260
|
+
expect(toChildA.meta._injectedProps).toEqual({ invoice: 'invoice-data' })
|
|
261
|
+
|
|
262
|
+
// Navigate to child B
|
|
263
|
+
const toChildB = createRoute({ invoiceId: '42' }, [parentRecord, { path: 'payments', meta: {} }])
|
|
264
|
+
await mockRouter.simulateNavigation(toChildB)
|
|
265
|
+
await flushPromises()
|
|
266
|
+
expect(resolveFn).toHaveBeenCalledTimes(1)
|
|
267
|
+
expect(toChildB.meta._injectedProps).toEqual({ invoice: 'invoice-data' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('sets loading state to true while resolving and false after', async () => {
|
|
271
|
+
installRouteInjection(mockRouter as any)
|
|
272
|
+
|
|
273
|
+
let resolvePromise: (value: string) => void
|
|
274
|
+
const resolveFn = vi.fn().mockImplementation(() => {
|
|
275
|
+
return new Promise<string>((resolve) => {
|
|
276
|
+
resolvePromise = resolve
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
281
|
+
{
|
|
282
|
+
path: ':invoiceId',
|
|
283
|
+
meta: {
|
|
284
|
+
inject: {
|
|
285
|
+
invoice: {
|
|
286
|
+
from: 'invoiceId',
|
|
287
|
+
resolve: () => ({ resolve: resolveFn })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
await mockRouter.simulateNavigation(to)
|
|
295
|
+
|
|
296
|
+
// Loading should be true while the resolver is pending
|
|
297
|
+
const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
298
|
+
expect(state.invoice.loading).toBe(true)
|
|
299
|
+
expect(state.invoice.error).toBeNull()
|
|
300
|
+
|
|
301
|
+
// Resolve the promise
|
|
302
|
+
resolvePromise!('resolved-invoice')
|
|
303
|
+
await flushPromises()
|
|
304
|
+
|
|
305
|
+
// Loading should be false after resolution
|
|
306
|
+
expect(state.invoice.loading).toBe(false)
|
|
307
|
+
expect(state.invoice.error).toBeNull()
|
|
308
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('sets error state when resolver fails', async () => {
|
|
312
|
+
installRouteInjection(mockRouter as any)
|
|
313
|
+
|
|
314
|
+
const resolveFn = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
315
|
+
|
|
316
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
317
|
+
{
|
|
318
|
+
path: ':invoiceId',
|
|
319
|
+
meta: {
|
|
320
|
+
inject: {
|
|
321
|
+
invoice: {
|
|
322
|
+
from: 'invoiceId',
|
|
323
|
+
resolve: () => ({ resolve: resolveFn })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
])
|
|
329
|
+
|
|
330
|
+
await mockRouter.simulateNavigation(to)
|
|
331
|
+
await flushPromises()
|
|
332
|
+
|
|
333
|
+
const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
334
|
+
expect(state.invoice.loading).toBe(false)
|
|
335
|
+
expect(state.invoice.error).toBeInstanceOf(Error)
|
|
336
|
+
expect(state.invoice.error!.message).toBe('Network error')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('navigation is non-blocking (guard returns before resolvers complete)', async () => {
|
|
340
|
+
installRouteInjection(mockRouter as any)
|
|
341
|
+
|
|
342
|
+
let resolvePromise: (value: string) => void
|
|
343
|
+
const resolveFn = vi.fn().mockImplementation(() => {
|
|
344
|
+
return new Promise<string>((resolve) => {
|
|
345
|
+
resolvePromise = resolve
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
350
|
+
{
|
|
351
|
+
path: ':invoiceId',
|
|
352
|
+
meta: {
|
|
353
|
+
inject: {
|
|
354
|
+
invoice: {
|
|
355
|
+
from: 'invoiceId',
|
|
356
|
+
resolve: () => ({ resolve: resolveFn })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
])
|
|
362
|
+
|
|
363
|
+
// simulateNavigation should return even though the resolver hasn't completed
|
|
364
|
+
await mockRouter.simulateNavigation(to)
|
|
365
|
+
|
|
366
|
+
// Props should NOT be set yet (resolver still pending)
|
|
367
|
+
expect((to.meta._injectedProps as any).invoice).toBeUndefined()
|
|
368
|
+
|
|
369
|
+
// Now resolve
|
|
370
|
+
resolvePromise!('resolved-invoice')
|
|
371
|
+
await flushPromises()
|
|
372
|
+
|
|
373
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'resolved-invoice' })
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('refresh() with silent option does not set loading state', async () => {
|
|
377
|
+
installRouteInjection(mockRouter as any)
|
|
378
|
+
|
|
379
|
+
const resolveFn = vi.fn()
|
|
380
|
+
.mockResolvedValueOnce('old-data')
|
|
381
|
+
.mockResolvedValueOnce('fresh-data')
|
|
382
|
+
|
|
383
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
384
|
+
{
|
|
385
|
+
path: ':invoiceId',
|
|
386
|
+
meta: {
|
|
387
|
+
inject: {
|
|
388
|
+
invoice: {
|
|
389
|
+
from: 'invoiceId',
|
|
390
|
+
resolve: () => ({ resolve: resolveFn })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
])
|
|
396
|
+
|
|
397
|
+
await mockRouter.simulateNavigation(to)
|
|
398
|
+
await flushPromises()
|
|
399
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'old-data' })
|
|
400
|
+
|
|
401
|
+
const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
402
|
+
|
|
403
|
+
// Track loading state changes during silent refresh
|
|
404
|
+
let loadingWasTrue = false
|
|
405
|
+
const checkLoading = setInterval(() => {
|
|
406
|
+
if (state.invoice.loading) loadingWasTrue = true
|
|
407
|
+
}, 0)
|
|
408
|
+
|
|
409
|
+
await to.meta.refresh!('invoice', { silent: true })
|
|
410
|
+
|
|
411
|
+
clearInterval(checkLoading)
|
|
412
|
+
expect(loadingWasTrue).toBe(false)
|
|
413
|
+
expect(state.invoice.loading).toBe(false)
|
|
414
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'fresh-data' })
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('refresh() resets loading and error state', async () => {
|
|
418
|
+
installRouteInjection(mockRouter as any)
|
|
419
|
+
|
|
420
|
+
const resolveFn = vi.fn()
|
|
421
|
+
.mockRejectedValueOnce(new Error('First fail'))
|
|
422
|
+
.mockResolvedValueOnce('success-data')
|
|
423
|
+
|
|
424
|
+
const to = createRoute({ invoiceId: '123' }, [
|
|
425
|
+
{
|
|
426
|
+
path: ':invoiceId',
|
|
427
|
+
meta: {
|
|
428
|
+
inject: {
|
|
429
|
+
invoice: {
|
|
430
|
+
from: 'invoiceId',
|
|
431
|
+
resolve: () => ({ resolve: resolveFn })
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
])
|
|
437
|
+
|
|
438
|
+
await mockRouter.simulateNavigation(to)
|
|
439
|
+
await flushPromises()
|
|
440
|
+
|
|
441
|
+
const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
442
|
+
expect(state.invoice.error).toBeInstanceOf(Error)
|
|
443
|
+
|
|
444
|
+
// Refresh should clear the error and resolve successfully
|
|
445
|
+
await to.meta.refresh!('invoice')
|
|
446
|
+
expect(state.invoice.loading).toBe(false)
|
|
447
|
+
expect(state.invoice.error).toBeNull()
|
|
448
|
+
expect(to.meta._injectedProps).toEqual({ invoice: 'success-data' })
|
|
449
|
+
})
|
|
450
|
+
})
|