@blueprint-ts/core 1.2.0 → 3.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 +18 -0
- package/docs/vue/forms.md +179 -28
- package/docs/vue/requests/route-resource-binding.md +193 -25
- package/package.json +1 -1
- package/src/service/requests/bodies/FormDataBody.ts +45 -9
- package/src/vue/forms/BaseForm.ts +26 -20
- package/src/vue/forms/PropertyAwareArray.ts +1 -2
- 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/service/requests/FormDataBody.test.ts +63 -0
- package/tests/vue/forms/BaseForm.transformers.test.ts +109 -0
- package/tests/vue/router/routeResourceBinding/RouteResourceBoundView.test.ts +344 -0
- package/tests/vue/router/routeResourceBinding/installRouteInjection.test.ts +450 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BaseForm } from '../../../src/vue/forms/BaseForm'
|
|
3
|
+
|
|
4
|
+
type PositionsItem = { id: number; internal: string }
|
|
5
|
+
|
|
6
|
+
interface TestFormState {
|
|
7
|
+
name: string
|
|
8
|
+
email: string | null
|
|
9
|
+
meta: { id: string; secret: string }
|
|
10
|
+
positions: PositionsItem[]
|
|
11
|
+
file: File | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type TestRequestPayload = {
|
|
15
|
+
name?: string
|
|
16
|
+
email: string | null
|
|
17
|
+
meta: { id: string }
|
|
18
|
+
positions: Array<{ id: number }>
|
|
19
|
+
file?: File
|
|
20
|
+
started_at?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class TestForm extends BaseForm<TestRequestPayload, TestFormState> {
|
|
24
|
+
protected override append: string[] = ['started_at']
|
|
25
|
+
|
|
26
|
+
public constructor(overrides: Partial<TestFormState> = {}) {
|
|
27
|
+
super({
|
|
28
|
+
name: '',
|
|
29
|
+
email: null,
|
|
30
|
+
meta: { id: 'm1', secret: 'top-secret' },
|
|
31
|
+
positions: [
|
|
32
|
+
{ id: 1, internal: 'x' },
|
|
33
|
+
{ id: 2, internal: 'y' },
|
|
34
|
+
],
|
|
35
|
+
file: null,
|
|
36
|
+
...overrides,
|
|
37
|
+
}, { persist: false })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Field getter: omit name if empty by returning undefined.
|
|
41
|
+
protected getName(value: string): string | undefined {
|
|
42
|
+
const trimmed = value.trim()
|
|
43
|
+
return trimmed.length ? trimmed : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Composite getter: omit nested meta.secret by returning undefined.
|
|
47
|
+
protected getMetaSecret(_value: string): undefined {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Composite getter: omit positions[].internal by returning undefined.
|
|
52
|
+
protected getPositionsInternal(_value: string): undefined {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Appended getter: omit started_at by returning undefined.
|
|
57
|
+
protected getStartedAt(): undefined {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('BaseForm transformers / getters', () => {
|
|
63
|
+
it('omits top-level fields when a field getter returns undefined', () => {
|
|
64
|
+
const form = new TestForm({ name: ' ' })
|
|
65
|
+
const payload = form.buildPayload()
|
|
66
|
+
|
|
67
|
+
expect(Object.prototype.hasOwnProperty.call(payload, 'name')).toBe(false)
|
|
68
|
+
expect(payload).toMatchObject({
|
|
69
|
+
email: null,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps null values (only undefined omits)', () => {
|
|
74
|
+
const form = new TestForm({ name: 'Alice', email: null })
|
|
75
|
+
const payload = form.buildPayload()
|
|
76
|
+
|
|
77
|
+
expect(payload).toHaveProperty('name', 'Alice')
|
|
78
|
+
expect(payload).toHaveProperty('email', null)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('omits nested properties when a composite getter returns undefined', () => {
|
|
82
|
+
const form = new TestForm({ name: 'Alice' })
|
|
83
|
+
const payload = form.buildPayload()
|
|
84
|
+
|
|
85
|
+
expect(payload.meta).toEqual({ id: 'm1' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('applies composite omission to arrays of objects', () => {
|
|
89
|
+
const form = new TestForm({ name: 'Alice' })
|
|
90
|
+
const payload = form.buildPayload()
|
|
91
|
+
|
|
92
|
+
expect(payload.positions).toEqual([{ id: 1 }, { id: 2 }])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('omits appended fields when their getter returns undefined', () => {
|
|
96
|
+
const form = new TestForm({ name: 'Alice' })
|
|
97
|
+
const payload = form.buildPayload()
|
|
98
|
+
|
|
99
|
+
expect(Object.prototype.hasOwnProperty.call(payload, 'started_at')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('keeps File values intact (does not transform into a plain object)', () => {
|
|
103
|
+
const file = new File(['abc'], 'credentials.kdbx', { type: 'application/octet-stream' })
|
|
104
|
+
const form = new TestForm({ name: 'Alice', file })
|
|
105
|
+
const payload = form.buildPayload()
|
|
106
|
+
|
|
107
|
+
expect(payload.file).toBe(file)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -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
|
+
})
|