@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
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
1
2
|
import { useRoute } from 'vue-router'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
type InjectionState = Record<string, { loading: boolean; error: Error | null }>
|
|
5
|
+
|
|
6
|
+
export function useRouteResource(propName: string) {
|
|
4
7
|
const route = useRoute()
|
|
5
8
|
|
|
6
|
-
const refresh = async (
|
|
7
|
-
return await route.meta.refresh?.(propName)
|
|
9
|
+
const refresh = async (options?: { silent?: boolean }) => {
|
|
10
|
+
return await route.meta.refresh?.(propName, options)
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const isLoading: ComputedRef<boolean> = computed(() => {
|
|
14
|
+
const state = route.meta._injectionState as InjectionState | undefined
|
|
15
|
+
return state?.[propName]?.loading ?? false
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const error: ComputedRef<Error | null> = computed(() => {
|
|
19
|
+
const state = route.meta._injectionState as InjectionState | undefined
|
|
20
|
+
return state?.[propName]?.error ?? null
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return { refresh, isLoading, error }
|
|
14
24
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { defineComponent, h, markRaw, reactive, type VNode } from 'vue'
|
|
3
|
+
import { RouteResourceBoundView } from '../../../../src/vue/router/routeResourceBinding/RouteResourceBoundView'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* We mock vue-router's useRoute and RouterView.
|
|
7
|
+
*
|
|
8
|
+
* Since vi.mock is hoisted above all other code, the factory must be
|
|
9
|
+
* self-contained — it cannot reference variables declared outside.
|
|
10
|
+
* We import vue inside the factory to build the mock RouterView.
|
|
11
|
+
*
|
|
12
|
+
* After the mock, we grab a reference to the mocked RouterView so
|
|
13
|
+
* our renderComponent helper can identify it when resolving VNodes.
|
|
14
|
+
*/
|
|
15
|
+
const mockRoute = reactive({
|
|
16
|
+
meta: {} as Record<string, unknown>,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
vi.mock('vue-router', async () => {
|
|
20
|
+
const { defineComponent: dc, h: hFn } = await import('vue')
|
|
21
|
+
|
|
22
|
+
const MockRouterViewInner = dc({
|
|
23
|
+
name: 'MockRouterView',
|
|
24
|
+
setup(_: unknown, { slots }: { slots: Record<string, (...args: unknown[]) => unknown> }) {
|
|
25
|
+
return () => {
|
|
26
|
+
if (slots['default']) {
|
|
27
|
+
return slots['default']({ Component: null, route: null })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return hFn('div', 'RouterView-fallback')
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
useRoute: () => mockRoute,
|
|
37
|
+
RouterView: MockRouterViewInner,
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Import the mocked RouterView so we can identify it in VNode trees
|
|
42
|
+
const { RouterView: MockRouterView } = await import('vue-router')
|
|
43
|
+
|
|
44
|
+
const MockPageComponent = markRaw(
|
|
45
|
+
defineComponent({ render: () => h('div', 'PageComponent') }),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
type SlotFn = (...args: unknown[]) => VNode | VNode[] | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calls setup() on RouteResourceBoundView and returns a render()
|
|
52
|
+
* helper that also resolves the inner RouterView slot so we get
|
|
53
|
+
* the *actual* content the component would display.
|
|
54
|
+
*/
|
|
55
|
+
function renderComponent(slots: Record<string, SlotFn> = {}) {
|
|
56
|
+
const renderFn = (RouteResourceBoundView as unknown as {
|
|
57
|
+
setup: (props: Record<string, never>, ctx: { slots: Record<string, SlotFn> }) => () => VNode
|
|
58
|
+
}).setup({} as Record<string, never>, { slots })
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
/**
|
|
62
|
+
* Invoke the component's render function and, if the result is
|
|
63
|
+
* h(RouterView, …, { default: fn }), resolve the RouterView
|
|
64
|
+
* slot so we get the content that would actually be displayed.
|
|
65
|
+
*/
|
|
66
|
+
render(): VNode | VNode[] | undefined {
|
|
67
|
+
const vnode = renderFn()
|
|
68
|
+
|
|
69
|
+
// The component returns h(RouterView, null, { default: slotFn }).
|
|
70
|
+
// `vnode.children` holds the slot object when slots are passed
|
|
71
|
+
// to h() as the third argument.
|
|
72
|
+
if (vnode.type === MockRouterView && vnode.children) {
|
|
73
|
+
const children = vnode.children as Record<string, SlotFn>
|
|
74
|
+
if (typeof children['default'] === 'function') {
|
|
75
|
+
return children['default']({ Component: MockPageComponent, route: mockRoute })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return vnode
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function initRouteMeta(propName: string, state: { loading: boolean; error: Error | null }) {
|
|
85
|
+
mockRoute.meta._injectionState = reactive({
|
|
86
|
+
[propName]: reactive({ ...state }),
|
|
87
|
+
})
|
|
88
|
+
mockRoute.meta._injectedProps = reactive({})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('RouteResourceBoundView', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
mockRoute.meta = {}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('passes { Component, route } to the default scoped slot when resource is loaded', () => {
|
|
97
|
+
initRouteMeta('credential', { loading: false, error: null })
|
|
98
|
+
|
|
99
|
+
let receivedComponent: unknown = null
|
|
100
|
+
let receivedRoute: unknown = null
|
|
101
|
+
|
|
102
|
+
const { render } = renderComponent({
|
|
103
|
+
default: ({ Component, route }: { Component: unknown; route: unknown }) => {
|
|
104
|
+
receivedComponent = Component
|
|
105
|
+
receivedRoute = route
|
|
106
|
+
|
|
107
|
+
return h('div', 'Page Content')
|
|
108
|
+
},
|
|
109
|
+
loading: () => h('div', 'Loading...'),
|
|
110
|
+
error: () => h('div', 'Error!'),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
render()
|
|
114
|
+
expect(receivedComponent).toBe(MockPageComponent)
|
|
115
|
+
expect(receivedRoute).toBe(mockRoute)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('renders the loading slot while resource is loading', () => {
|
|
119
|
+
initRouteMeta('credential', { loading: true, error: null })
|
|
120
|
+
|
|
121
|
+
let loadingRendered = false
|
|
122
|
+
|
|
123
|
+
const { render } = renderComponent({
|
|
124
|
+
default: () => h('div', 'Page Content'),
|
|
125
|
+
loading: () => {
|
|
126
|
+
loadingRendered = true
|
|
127
|
+
|
|
128
|
+
return h('div', 'Loading...')
|
|
129
|
+
},
|
|
130
|
+
error: () => h('div', 'Error!'),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
render()
|
|
134
|
+
expect(loadingRendered).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('renders the error slot with error and refresh when resource has an error', () => {
|
|
138
|
+
const testError = new Error('Network failure')
|
|
139
|
+
initRouteMeta('credential', { loading: false, error: testError })
|
|
140
|
+
|
|
141
|
+
const refreshMock = vi.fn()
|
|
142
|
+
mockRoute.meta.refresh = refreshMock
|
|
143
|
+
|
|
144
|
+
let receivedError: Error | null = null
|
|
145
|
+
let receivedRefresh: (() => Promise<unknown>) | null = null
|
|
146
|
+
|
|
147
|
+
const { render } = renderComponent({
|
|
148
|
+
default: () => h('div', 'Page Content'),
|
|
149
|
+
loading: () => h('div', 'Loading...'),
|
|
150
|
+
error: ({ error, refresh }: { error: Error; refresh: () => Promise<unknown> }) => {
|
|
151
|
+
receivedError = error
|
|
152
|
+
receivedRefresh = refresh
|
|
153
|
+
|
|
154
|
+
return h('div', `Error: ${error.message}`)
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
render()
|
|
159
|
+
expect(receivedError).toBe(testError)
|
|
160
|
+
expect(receivedRefresh).toBeTypeOf('function')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('prioritizes error over loading when both are set', () => {
|
|
164
|
+
initRouteMeta('credential', { loading: true, error: new Error('fail') })
|
|
165
|
+
|
|
166
|
+
let errorRendered = false
|
|
167
|
+
|
|
168
|
+
const { render } = renderComponent({
|
|
169
|
+
default: () => h('div', 'Page Content'),
|
|
170
|
+
loading: () => h('div', 'Loading...'),
|
|
171
|
+
error: ({ error }: { error: Error }) => {
|
|
172
|
+
errorRendered = true
|
|
173
|
+
|
|
174
|
+
return h('div', `Error: ${error.message}`)
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
render()
|
|
179
|
+
expect(errorRendered).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('passes { Component, route } to the default slot when no injection state exists yet', () => {
|
|
183
|
+
let receivedComponent: unknown = null
|
|
184
|
+
|
|
185
|
+
const { render } = renderComponent({
|
|
186
|
+
default: ({ Component }: { Component: unknown }) => {
|
|
187
|
+
receivedComponent = Component
|
|
188
|
+
|
|
189
|
+
return h('div', 'Page Content')
|
|
190
|
+
},
|
|
191
|
+
loading: () => h('div', 'Loading...'),
|
|
192
|
+
error: () => h('div', 'Error!'),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
render()
|
|
196
|
+
expect(receivedComponent).toBe(MockPageComponent)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('renders errorComponent from route meta when resource has an error', () => {
|
|
200
|
+
const testError = new Error('Server error')
|
|
201
|
+
initRouteMeta('credential', { loading: false, error: testError })
|
|
202
|
+
|
|
203
|
+
const ErrorPage = defineComponent({
|
|
204
|
+
props: { error: { type: Error, required: true }, refresh: { type: Function, required: true } },
|
|
205
|
+
render() {
|
|
206
|
+
return h('div', `ErrorPage: ${this.error.message}`)
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
mockRoute.meta._errorComponent = markRaw(ErrorPage)
|
|
210
|
+
|
|
211
|
+
const { render } = renderComponent({
|
|
212
|
+
default: () => h('div', 'Page Content'),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const vnode = render()
|
|
216
|
+
|
|
217
|
+
// renderContent returns h(ErrorPage, ...) which the RouterView slot resolver unwraps
|
|
218
|
+
expect(vnode).toBeDefined()
|
|
219
|
+
const node = Array.isArray(vnode) ? vnode[0] : vnode
|
|
220
|
+
expect((node as VNode).type).toBe(ErrorPage)
|
|
221
|
+
expect((node as VNode).props?.error).toBe(testError)
|
|
222
|
+
expect((node as VNode).props?.refresh).toBeTypeOf('function')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('renders loadingComponent from route meta while resource is loading', () => {
|
|
226
|
+
initRouteMeta('credential', { loading: true, error: null })
|
|
227
|
+
|
|
228
|
+
const LoadingPage = defineComponent({
|
|
229
|
+
render() {
|
|
230
|
+
return h('div', 'LoadingPage')
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
mockRoute.meta._loadingComponent = markRaw(LoadingPage)
|
|
234
|
+
|
|
235
|
+
let defaultSlotCalled = false
|
|
236
|
+
const { render } = renderComponent({
|
|
237
|
+
default: () => {
|
|
238
|
+
defaultSlotCalled = true
|
|
239
|
+
|
|
240
|
+
return h('div', 'Page Content')
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const vnode = render()
|
|
245
|
+
const node = Array.isArray(vnode) ? vnode[0] : vnode
|
|
246
|
+
expect((node as VNode).type).toBe(LoadingPage)
|
|
247
|
+
expect(defaultSlotCalled).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('refresh function in error slot retries all errored props', async () => {
|
|
251
|
+
mockRoute.meta._injectionState = reactive({
|
|
252
|
+
credential: reactive({ loading: false, error: new Error('fail') }),
|
|
253
|
+
organization: reactive({ loading: false, error: new Error('also fail') }),
|
|
254
|
+
})
|
|
255
|
+
mockRoute.meta._injectedProps = reactive({})
|
|
256
|
+
|
|
257
|
+
const refreshMock = vi.fn().mockResolvedValue('refreshed')
|
|
258
|
+
mockRoute.meta.refresh = refreshMock
|
|
259
|
+
|
|
260
|
+
let capturedRefresh: (() => Promise<unknown>) | null = null
|
|
261
|
+
|
|
262
|
+
const { render } = renderComponent({
|
|
263
|
+
error: ({ refresh }: { refresh: () => Promise<unknown> }) => {
|
|
264
|
+
capturedRefresh = refresh
|
|
265
|
+
|
|
266
|
+
return h('div', 'Error')
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
render()
|
|
271
|
+
expect(capturedRefresh).toBeTypeOf('function')
|
|
272
|
+
|
|
273
|
+
await capturedRefresh!()
|
|
274
|
+
expect(refreshMock).toHaveBeenCalledWith('credential')
|
|
275
|
+
expect(refreshMock).toHaveBeenCalledWith('organization')
|
|
276
|
+
expect(refreshMock).toHaveBeenCalledTimes(2)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('renders the Component automatically when no default slot is provided', () => {
|
|
280
|
+
initRouteMeta('credential', { loading: false, error: null })
|
|
281
|
+
|
|
282
|
+
const { render } = renderComponent()
|
|
283
|
+
|
|
284
|
+
const vnode = render()
|
|
285
|
+
const node = Array.isArray(vnode) ? vnode[0] : vnode
|
|
286
|
+
expect((node as VNode).type).toBe(MockPageComponent)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('renders the default slot during loading when lazy is false', () => {
|
|
290
|
+
initRouteMeta('credential', { loading: true, error: null })
|
|
291
|
+
mockRoute.meta._lazy = false
|
|
292
|
+
|
|
293
|
+
let defaultCalled = false
|
|
294
|
+
|
|
295
|
+
const { render } = renderComponent({
|
|
296
|
+
default: () => {
|
|
297
|
+
defaultCalled = true
|
|
298
|
+
|
|
299
|
+
return h('div', 'Page Content')
|
|
300
|
+
},
|
|
301
|
+
loading: () => h('div', 'Loading...'),
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
render()
|
|
305
|
+
expect(defaultCalled).toBe(true)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('renders the default slot during error when lazy is false', () => {
|
|
309
|
+
initRouteMeta('credential', { loading: false, error: new Error('fail') })
|
|
310
|
+
mockRoute.meta._lazy = false
|
|
311
|
+
|
|
312
|
+
let defaultCalled = false
|
|
313
|
+
|
|
314
|
+
const { render } = renderComponent({
|
|
315
|
+
default: () => {
|
|
316
|
+
defaultCalled = true
|
|
317
|
+
|
|
318
|
+
return h('div', 'Page Content')
|
|
319
|
+
},
|
|
320
|
+
error: () => h('div', 'Error!'),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
render()
|
|
324
|
+
expect(defaultCalled).toBe(true)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('still intercepts loading/error when lazy is true (default)', () => {
|
|
328
|
+
initRouteMeta('credential', { loading: true, error: null })
|
|
329
|
+
|
|
330
|
+
let loadingCalled = false
|
|
331
|
+
|
|
332
|
+
const { render } = renderComponent({
|
|
333
|
+
default: () => h('div', 'Page Content'),
|
|
334
|
+
loading: () => {
|
|
335
|
+
loadingCalled = true
|
|
336
|
+
|
|
337
|
+
return h('div', 'Loading...')
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
render()
|
|
342
|
+
expect(loadingCalled).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
})
|