@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.
@@ -1,14 +1,24 @@
1
+ import { computed, type ComputedRef } from 'vue'
1
2
  import { useRoute } from 'vue-router'
2
3
 
3
- export function useRouteResource() {
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 (propName: string) => {
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
- // If emit is passed, we can wrap it or just provide a helper
11
- // to be used like: onRefresh('product', () => ...)
12
- // but the most direct way for you is:
13
- return { refresh }
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
+ })