@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
|
@@ -115,7 +115,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
115
115
|
private readonly original: FormBody
|
|
116
116
|
private readonly _model: { [K in keyof FormBody]: ComputedRef<FormBody[K]> }
|
|
117
117
|
private _errors: any = reactive({})
|
|
118
|
-
private _suggestions: any = reactive({})
|
|
119
118
|
private _hasErrors: ComputedRef<boolean>
|
|
120
119
|
protected append: string[] = []
|
|
121
120
|
protected ignore: string[] = []
|
|
@@ -673,15 +672,13 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
673
672
|
}
|
|
674
673
|
}
|
|
675
674
|
|
|
676
|
-
public fillSuggestions(suggestionsData: Partial<Record<keyof FormBody, string[] | object[]>>): void {
|
|
677
|
-
for (const key in suggestionsData) {
|
|
678
|
-
if (Object.prototype.hasOwnProperty.call(suggestionsData, key)) {
|
|
679
|
-
this._suggestions[key] = suggestionsData[key]
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
675
|
private transformValue(value: any, parentKey?: string): any {
|
|
676
|
+
if (value instanceof Date) {
|
|
677
|
+
return value
|
|
678
|
+
}
|
|
679
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) {
|
|
680
|
+
return value
|
|
681
|
+
}
|
|
685
682
|
if (value instanceof PropertyAwareArray) {
|
|
686
683
|
return [...value].map((item) => this.transformValue(item, parentKey))
|
|
687
684
|
}
|
|
@@ -694,12 +691,18 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
694
691
|
if (parentKey) {
|
|
695
692
|
const compositeMethod = 'get' + upperFirst(parentKey) + upperFirst(camelCase(prop))
|
|
696
693
|
if (typeof (this as any)[compositeMethod] === 'function') {
|
|
697
|
-
|
|
694
|
+
const transformed = (this as any)[compositeMethod](value[prop])
|
|
695
|
+
if (transformed !== undefined) {
|
|
696
|
+
result[prop] = transformed
|
|
697
|
+
}
|
|
698
698
|
continue
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
701
|
// Pass the parentKey along so that nested objects still use it.
|
|
702
|
-
|
|
702
|
+
const transformed = this.transformValue(value[prop], parentKey)
|
|
703
|
+
if (transformed !== undefined) {
|
|
704
|
+
result[prop] = transformed
|
|
705
|
+
}
|
|
703
706
|
}
|
|
704
707
|
return result
|
|
705
708
|
}
|
|
@@ -718,9 +721,15 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
718
721
|
const getterName = 'get' + upperFirst(camelCase(key))
|
|
719
722
|
const typedKey = key as unknown as keyof RequestBody
|
|
720
723
|
if (typeof (this as any)[getterName] === 'function') {
|
|
721
|
-
|
|
724
|
+
const transformed = (this as any)[getterName](value)
|
|
725
|
+
if (transformed !== undefined) {
|
|
726
|
+
payload[typedKey] = transformed
|
|
727
|
+
}
|
|
722
728
|
} else {
|
|
723
|
-
|
|
729
|
+
const transformed = this.transformValue(value, key)
|
|
730
|
+
if (transformed !== undefined) {
|
|
731
|
+
payload[typedKey] = transformed
|
|
732
|
+
}
|
|
724
733
|
}
|
|
725
734
|
}
|
|
726
735
|
|
|
@@ -732,7 +741,10 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
732
741
|
|
|
733
742
|
const getterName = 'get' + upperFirst(camelCase(fieldName))
|
|
734
743
|
if (typeof (this as any)[getterName] === 'function') {
|
|
735
|
-
|
|
744
|
+
const transformed = (this as any)[getterName]()
|
|
745
|
+
if (transformed !== undefined) {
|
|
746
|
+
payload[fieldName as keyof RequestBody] = transformed
|
|
747
|
+
}
|
|
736
748
|
} else {
|
|
737
749
|
console.warn(`Getter method '${getterName}' not found for appended field '${fieldName}' in ${this.constructor.name}.`)
|
|
738
750
|
}
|
|
@@ -766,9 +778,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
766
778
|
for (const key in this._errors) {
|
|
767
779
|
delete this._errors[key]
|
|
768
780
|
}
|
|
769
|
-
for (const key in this._suggestions) {
|
|
770
|
-
delete this._suggestions[key]
|
|
771
|
-
}
|
|
772
781
|
driver.set(this.constructor.name, {
|
|
773
782
|
state: toRaw(this.state),
|
|
774
783
|
original: toRaw(this.original),
|
|
@@ -881,7 +890,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
881
890
|
}
|
|
882
891
|
}),
|
|
883
892
|
errors: (this._errors[key] && this._errors[key][index] && this._errors[key][index][innerKey]) || [],
|
|
884
|
-
suggestions: (this._suggestions[key] && this._suggestions[key][index] && this._suggestions[key][index][innerKey]) || [],
|
|
885
893
|
dirty:
|
|
886
894
|
Array.isArray(this.dirty[key]) && this.dirty[key][index] && typeof (this.dirty[key] as any[])[index] === 'object'
|
|
887
895
|
? (this.dirty[key] as any[])[index][innerKey]
|
|
@@ -906,7 +914,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
906
914
|
}
|
|
907
915
|
}),
|
|
908
916
|
errors: (this._errors[key] && this._errors[key][index]) || [],
|
|
909
|
-
suggestions: (this._suggestions[key] && this._suggestions[key][index]) || [],
|
|
910
917
|
dirty: Array.isArray(this.dirty[key]) ? (this.dirty[key] as boolean[])[index] : false,
|
|
911
918
|
touched: this.touched[key] || false
|
|
912
919
|
}
|
|
@@ -917,7 +924,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
917
924
|
props[key] = {
|
|
918
925
|
model: this._model[key],
|
|
919
926
|
errors: this._errors[key] || [],
|
|
920
|
-
suggestions: this._suggestions[key] || [],
|
|
921
927
|
dirty: this.dirty[key] || false,
|
|
922
928
|
touched: this.touched[key] || false
|
|
923
929
|
}
|
|
@@ -6,7 +6,6 @@ import { type WritableComputedRef } from 'vue'
|
|
|
6
6
|
export interface PropertyAwareField<T> {
|
|
7
7
|
model: WritableComputedRef<T>
|
|
8
8
|
errors: any[]
|
|
9
|
-
suggestions: any[]
|
|
10
9
|
dirty: boolean
|
|
11
10
|
}
|
|
12
11
|
|
|
@@ -21,7 +20,7 @@ export type PropertyAware<T> = {
|
|
|
21
20
|
* Extends Array with property awareness.
|
|
22
21
|
* When a form field is defined as an instance of PropertyAwareArray,
|
|
23
22
|
* the BaseForm will transform each element into reactive properties with
|
|
24
|
-
* computed getters/setters, error
|
|
23
|
+
* computed getters/setters, error tracking, and dirty flags.
|
|
25
24
|
*/
|
|
26
25
|
export class PropertyAwareArray<T = any> extends Array<T> {
|
|
27
26
|
/**
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { computed, defineComponent, h, type Component as VueComponent, type VNode } from 'vue'
|
|
2
|
+
import { RouterView, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
type InjectionState = Record<string, { loading: boolean; error: Error | null }>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in replacement for `<router-view>` that handles route resource
|
|
8
|
+
* injection loading and error states.
|
|
9
|
+
*
|
|
10
|
+
* When any injected resource is loading → renders the route's `loadingComponent`.
|
|
11
|
+
* When any injected resource has an error → renders the route's `errorComponent`
|
|
12
|
+
* (receives `error` and `refresh` as props).
|
|
13
|
+
* Otherwise → passes `{ Component, route }` to the default scoped slot,
|
|
14
|
+
* just like `<RouterView>`.
|
|
15
|
+
*
|
|
16
|
+
* Error and loading components are defined in the route via `defineRoute`:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* defineRoute<{ credential: CredentialResource }>()({
|
|
20
|
+
* path: '/credentials/:credentialId',
|
|
21
|
+
* component: CredentialShowPage,
|
|
22
|
+
* errorComponent: GenericErrorPage,
|
|
23
|
+
* loadingComponent: LoadingSpinner,
|
|
24
|
+
* inject: {
|
|
25
|
+
* credential: {
|
|
26
|
+
* from: 'credentialId',
|
|
27
|
+
* resolve: (id) => new RouteResourceRequestResolver(new CredentialShowRequest(id)),
|
|
28
|
+
* },
|
|
29
|
+
* },
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Set `lazy: false` to render the target component immediately (without
|
|
34
|
+
* waiting for resources). The component can then handle loading/error
|
|
35
|
+
* states itself via `useRouteResource()`:
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* defineRoute<{ credential: CredentialResource }>()({
|
|
39
|
+
* path: '/credentials/:credentialId',
|
|
40
|
+
* component: CredentialShowPage,
|
|
41
|
+
* lazy: false,
|
|
42
|
+
* inject: { ... },
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* Usage in layout (supports the same v-slot pattern as RouterView):
|
|
47
|
+
* ```vue
|
|
48
|
+
* <RouteResourceBoundView v-slot="{ Component, route }">
|
|
49
|
+
* <component :is="Component" v-if="Component" :key="route.meta.usePathKey ? route.path : undefined" />
|
|
50
|
+
* <EmptyState v-else />
|
|
51
|
+
* </RouteResourceBoundView>
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Or without a slot (renders the matched component automatically):
|
|
55
|
+
* ```vue
|
|
56
|
+
* <RouteResourceBoundView />
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export const RouteResourceBoundView = defineComponent({
|
|
60
|
+
name: 'RouteResourceBoundView',
|
|
61
|
+
|
|
62
|
+
setup(_, { slots }) {
|
|
63
|
+
const route = useRoute()
|
|
64
|
+
|
|
65
|
+
const injectionState = computed(() => {
|
|
66
|
+
return route.meta._injectionState as InjectionState | undefined
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const firstError = computed((): Error | null => {
|
|
70
|
+
const state = injectionState.value
|
|
71
|
+
if (!state) return null
|
|
72
|
+
|
|
73
|
+
for (const key of Object.keys(state)) {
|
|
74
|
+
const entry = state[key]
|
|
75
|
+
if (entry?.error) return entry.error
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const anyLoading = computed((): boolean => {
|
|
82
|
+
const state = injectionState.value
|
|
83
|
+
if (!state) return false
|
|
84
|
+
|
|
85
|
+
return Object.keys(state).some((key) => state[key]?.loading)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const refresh = async () => {
|
|
89
|
+
const state = injectionState.value
|
|
90
|
+
if (!state) return
|
|
91
|
+
|
|
92
|
+
const erroredProps = Object.keys(state).filter((key) => state[key]?.error)
|
|
93
|
+
|
|
94
|
+
await Promise.all(erroredProps.map((propName) => route.meta.refresh?.(propName)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Core render logic that decides which slot/component to show
|
|
99
|
+
* given the resolved Component and route from RouterView.
|
|
100
|
+
*/
|
|
101
|
+
function renderContent(Component: VueComponent | undefined, resolvedRoute: RouteLocationNormalizedLoaded): VNode | VNode[] | undefined {
|
|
102
|
+
const isLazy = resolvedRoute.meta._lazy !== false
|
|
103
|
+
|
|
104
|
+
if (isLazy) {
|
|
105
|
+
if (firstError.value) {
|
|
106
|
+
const errorComponent = resolvedRoute.meta._errorComponent
|
|
107
|
+
|
|
108
|
+
if (errorComponent) {
|
|
109
|
+
return h(errorComponent, { error: firstError.value, refresh })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return slots['error']?.({ error: firstError.value, refresh })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (anyLoading.value) {
|
|
116
|
+
const loadingComponent = resolvedRoute.meta._loadingComponent
|
|
117
|
+
|
|
118
|
+
if (loadingComponent) {
|
|
119
|
+
return h(loadingComponent)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return slots['loading']?.()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (slots['default']) {
|
|
127
|
+
return slots['default']({ Component, route: resolvedRoute })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Component) {
|
|
131
|
+
return h(Component)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
return h(RouterView, null, {
|
|
139
|
+
default: ({ Component, route: resolvedRoute }: { Component: VueComponent | undefined; route: RouteLocationNormalizedLoaded }) => {
|
|
140
|
+
return renderContent(Component, resolvedRoute)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markRaw, type Component } from 'vue'
|
|
1
2
|
import { type RouteRecordRaw } from 'vue-router'
|
|
2
3
|
import { type InjectConfig } from './types'
|
|
3
4
|
|
|
@@ -7,16 +8,43 @@ import { type InjectConfig } from './types'
|
|
|
7
8
|
* - inject config types (must resolve Props[K])
|
|
8
9
|
*
|
|
9
10
|
* Usage:
|
|
10
|
-
* export default defineRoute<{ product: ProductResource }>()({
|
|
11
|
+
* export default defineRoute<{ product: ProductResource }>()({
|
|
12
|
+
* path: '/products/:productId',
|
|
13
|
+
* component: ProductShowPage,
|
|
14
|
+
* errorComponent: GenericErrorPage,
|
|
15
|
+
* loadingComponent: LoadingSpinner,
|
|
16
|
+
* inject: { product: { from: 'productId', resolve: ... } },
|
|
17
|
+
* })
|
|
11
18
|
*/
|
|
12
19
|
export function defineRoute<Props extends Record<string, unknown>>() {
|
|
13
20
|
return function <
|
|
14
21
|
T extends RouteRecordRaw & {
|
|
15
22
|
inject?: InjectConfig<Props>
|
|
23
|
+
errorComponent?: Component
|
|
24
|
+
loadingComponent?: Component
|
|
25
|
+
lazy?: boolean
|
|
16
26
|
}
|
|
17
27
|
>(route: T): RouteRecordRaw {
|
|
18
28
|
const originalProps = route.props
|
|
19
29
|
|
|
30
|
+
if (route.errorComponent) {
|
|
31
|
+
route.meta = route.meta ?? {}
|
|
32
|
+
route.meta._errorComponent = markRaw(route.errorComponent)
|
|
33
|
+
delete route.errorComponent
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (route.loadingComponent) {
|
|
37
|
+
route.meta = route.meta ?? {}
|
|
38
|
+
route.meta._loadingComponent = markRaw(route.loadingComponent)
|
|
39
|
+
delete route.loadingComponent
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (route.lazy !== undefined) {
|
|
43
|
+
route.meta = route.meta ?? {}
|
|
44
|
+
route.meta._lazy = route.lazy
|
|
45
|
+
delete route.lazy
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
route.props = (to) => {
|
|
21
49
|
const baseProps = typeof originalProps === 'function' ? originalProps(to) : originalProps === true ? to.params : (originalProps ?? {})
|
|
22
50
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { defineRoute } from './defineRoute'
|
|
2
2
|
import { installRouteInjection } from './installRouteInjection'
|
|
3
|
+
import { RouteResourceBoundView } from './RouteResourceBoundView'
|
|
3
4
|
import { RouteResourceRequestResolver } from './RouteResourceRequestResolver'
|
|
4
5
|
import { useRouteResource } from './useRouteResource'
|
|
5
6
|
|
|
6
7
|
import type { DataRequest, InjectConfig, Resolver } from './types'
|
|
7
8
|
|
|
8
|
-
export { defineRoute, installRouteInjection, RouteResourceRequestResolver, useRouteResource }
|
|
9
|
+
export { defineRoute, installRouteInjection, RouteResourceBoundView, RouteResourceRequestResolver, useRouteResource }
|
|
9
10
|
|
|
10
11
|
export type { DataRequest, InjectConfig, Resolver }
|
|
@@ -7,17 +7,26 @@ type InjectRuntimeConfig = {
|
|
|
7
7
|
getter: (payload: unknown) => unknown
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
type CachedEntry = {
|
|
11
|
+
paramValue: string
|
|
12
|
+
payload: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Installs the runtime part:
|
|
12
17
|
* - resolves all route.inject entries before navigation completes
|
|
13
18
|
* - stores results on to.meta._injectedProps
|
|
14
19
|
* - ensures route props include injected results (so components receive them as props)
|
|
20
|
+
* - caches resolved resources so child routes inherit parent-resolved data
|
|
21
|
+
* without triggering redundant requests (as long as the param value is unchanged)
|
|
15
22
|
*
|
|
16
23
|
* Notes:
|
|
17
24
|
* - This keeps router files clean: only an `inject` block per route.
|
|
18
25
|
* - This is intentionally runtime-generic; type safety happens at route definition time.
|
|
19
26
|
*/
|
|
20
27
|
export function installRouteInjection(router: Router) {
|
|
28
|
+
const cache: Record<string, CachedEntry> = {}
|
|
29
|
+
|
|
21
30
|
router.beforeResolve(async (to) => {
|
|
22
31
|
console.log('[Route Injection] Resolving route injections...')
|
|
23
32
|
|
|
@@ -25,7 +34,13 @@ export function installRouteInjection(router: Router) {
|
|
|
25
34
|
to.meta._injectedProps = reactive({})
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
if (!to.meta._injectionState) {
|
|
38
|
+
to.meta._injectionState = reactive({})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolvers: Record<string, (options?: { silent?: boolean }) => Promise<unknown>> = {}
|
|
42
|
+
const activePropNames = new Set<string>()
|
|
43
|
+
const pendingResolvers: Array<() => Promise<void>> = []
|
|
29
44
|
|
|
30
45
|
// Iterate through all matched route records (from parent to child)
|
|
31
46
|
for (const record of to.matched) {
|
|
@@ -57,37 +72,93 @@ export function installRouteInjection(router: Router) {
|
|
|
57
72
|
continue
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
activePropNames.add(propName)
|
|
76
|
+
|
|
77
|
+
// Initialize state for this prop
|
|
78
|
+
const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
79
|
+
if (!state[propName]) {
|
|
80
|
+
state[propName] = reactive({ loading: false, error: null })
|
|
81
|
+
}
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
// Define the refresh logic for this specific prop (always fetches fresh data)
|
|
84
|
+
const resolveProp = async (options?: { silent?: boolean }) => {
|
|
85
|
+
const propState = (to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>)[propName]!
|
|
86
|
+
if (!options?.silent) {
|
|
87
|
+
propState.loading = true
|
|
88
|
+
}
|
|
89
|
+
propState.error = null
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const resolver = cfg.resolve(paramValue)
|
|
93
|
+
let payload = await resolver.resolve()
|
|
94
|
+
if (typeof cfg.getter === 'function') {
|
|
95
|
+
payload = cfg.getter(payload)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Update cache
|
|
99
|
+
cache[propName] = { paramValue, payload }
|
|
100
|
+
|
|
101
|
+
// Updating the reactive object triggers the component re-render
|
|
102
|
+
const injectedProps = to.meta._injectedProps as Record<string, unknown>
|
|
103
|
+
injectedProps[propName] = payload
|
|
104
|
+
return payload
|
|
105
|
+
} catch (e) {
|
|
106
|
+
propState.error = e instanceof Error ? e : new Error(String(e))
|
|
107
|
+
throw e
|
|
108
|
+
} finally {
|
|
109
|
+
propState.loading = false
|
|
110
|
+
}
|
|
71
111
|
}
|
|
72
112
|
|
|
73
113
|
resolvers[propName] = resolveProp
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
// Reuse cached value if the param hasn't changed (e.g. navigating between child routes)
|
|
116
|
+
const cached = cache[propName]
|
|
117
|
+
if (cached && cached.paramValue === paramValue) {
|
|
118
|
+
console.debug(`[Route Injection] Using cached value for prop "${propName}" (param "${cfg.from}" unchanged)`)
|
|
119
|
+
const injectedPropsCache = to.meta._injectedProps as Record<string, unknown>
|
|
120
|
+
injectedPropsCache[propName] = cached.payload
|
|
76
121
|
|
|
77
|
-
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Set loading immediately and queue resolver (non-blocking)
|
|
126
|
+
const injectionState = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
|
|
127
|
+
injectionState[propName]!.loading = true
|
|
128
|
+
pendingResolvers.push(async () => {
|
|
129
|
+
try {
|
|
130
|
+
await resolveProp()
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`[Route Injection] Failed to resolve prop "${propName}"`, e)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
console.debug(`[Route Injection] Queued resolution for prop "${propName}" for route "${record.path}"`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Evict cache entries that are no longer part of the active route hierarchy
|
|
141
|
+
for (const key of Object.keys(cache)) {
|
|
142
|
+
if (!activePropNames.has(key)) {
|
|
143
|
+
delete cache[key]
|
|
78
144
|
}
|
|
79
145
|
}
|
|
80
146
|
|
|
81
147
|
// Attach the resolvers and a global refresh helper to the route meta
|
|
82
148
|
to.meta['_injectedResolvers'] = resolvers
|
|
83
|
-
to.meta['refresh'] = async (propName: string) => {
|
|
149
|
+
to.meta['refresh'] = async (propName: string, options?: { silent?: boolean }) => {
|
|
84
150
|
if (resolvers[propName]) {
|
|
85
|
-
return await resolvers[propName]()
|
|
151
|
+
return await resolvers[propName](options)
|
|
86
152
|
}
|
|
87
153
|
|
|
88
154
|
console.warn(`[Route Injection] No resolver found for "${propName}"`)
|
|
89
155
|
|
|
90
156
|
return undefined
|
|
91
157
|
}
|
|
158
|
+
|
|
159
|
+
// Fire pending resolvers without blocking navigation
|
|
160
|
+
if (pendingResolvers.length > 0) {
|
|
161
|
+
Promise.all(pendingResolvers.map((fn) => fn()))
|
|
162
|
+
}
|
|
92
163
|
})
|
|
93
164
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Component } from 'vue'
|
|
1
2
|
import { type RouteLocationNormalized } from 'vue-router'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -42,7 +43,11 @@ declare module 'vue-router' {
|
|
|
42
43
|
interface RouteMeta {
|
|
43
44
|
_injectedProps?: Record<string, any>
|
|
44
45
|
_injectedResolvers?: Record<string, () => Promise<any>>
|
|
45
|
-
|
|
46
|
+
_injectionState?: Record<string, { loading: boolean; error: Error | null }>
|
|
47
|
+
_errorComponent?: Component
|
|
48
|
+
_loadingComponent?: Component
|
|
49
|
+
_lazy?: boolean
|
|
50
|
+
refresh?: (propName: string, options?: { silent?: boolean }) => Promise<any>
|
|
46
51
|
inject?: Record<string, unknown>
|
|
47
52
|
}
|
|
48
53
|
}
|
|
@@ -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,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { FormDataBody } from '../../../src/service/requests/bodies/FormDataBody'
|
|
3
|
+
|
|
4
|
+
describe('FormDataBody', () => {
|
|
5
|
+
it('appends strings and files', () => {
|
|
6
|
+
const file = new File(['abc'], 'credentials.kdbx', { type: 'application/octet-stream' })
|
|
7
|
+
|
|
8
|
+
const body = new FormDataBody({
|
|
9
|
+
name: 'aerg',
|
|
10
|
+
type: 'kdbx',
|
|
11
|
+
password: 'aerg',
|
|
12
|
+
file,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const fd = body.getContent()
|
|
16
|
+
|
|
17
|
+
expect(fd.get('name')).toBe('aerg')
|
|
18
|
+
expect(fd.get('type')).toBe('kdbx')
|
|
19
|
+
expect(fd.get('password')).toBe('aerg')
|
|
20
|
+
expect(fd.get('file')).toBe(file)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('encodes null as empty string (key present)', () => {
|
|
24
|
+
const body = new FormDataBody({
|
|
25
|
+
name: 'aerg',
|
|
26
|
+
file: null,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const fd = body.getContent()
|
|
30
|
+
expect(fd.get('name')).toBe('aerg')
|
|
31
|
+
expect(fd.get('file')).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('rejects undefined values (should not be silently dropped)', () => {
|
|
35
|
+
expect(
|
|
36
|
+
() =>
|
|
37
|
+
new FormDataBody({
|
|
38
|
+
missing: undefined,
|
|
39
|
+
} as any),
|
|
40
|
+
).toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('stringifies number/boolean values', () => {
|
|
44
|
+
const body = new FormDataBody({
|
|
45
|
+
count: 3,
|
|
46
|
+
enabled: false,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const fd = body.getContent()
|
|
50
|
+
expect(fd.get('count')).toBe('3')
|
|
51
|
+
expect(fd.get('enabled')).toBe('false')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('supports arrays via bracket notation', () => {
|
|
55
|
+
const body = new FormDataBody({
|
|
56
|
+
tags: ['a', 'b'],
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const fd = body.getContent()
|
|
60
|
+
expect(fd.get('tags[0]')).toBe('a')
|
|
61
|
+
expect(fd.get('tags[1]')).toBe('b')
|
|
62
|
+
})
|
|
63
|
+
})
|