@blueprint-ts/core 1.1.2 → 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 +16 -0
- package/docs/vue/requests/route-resource-binding.md +193 -25
- package/package.json +1 -1
- package/src/service/pagination/BasePaginator.ts +36 -0
- package/src/service/pagination/InfiniteScroller.ts +2 -2
- package/src/service/pagination/PageAwarePaginator.ts +124 -0
- package/src/service/pagination/Paginator.ts +9 -147
- package/src/service/pagination/StatePaginator.ts +92 -0
- package/src/service/pagination/contracts/BaseViewDriverContract.ts +6 -0
- package/src/service/pagination/contracts/BaseViewDriverFactoryContract.ts +5 -0
- package/src/service/pagination/contracts/StatePaginationDataDriverContract.ts +5 -0
- package/src/service/pagination/contracts/ViewDriverContract.ts +3 -5
- package/src/service/pagination/dtos/StatePaginationDataDto.ts +19 -0
- package/src/service/pagination/factories/VueBaseViewDriverFactory.ts +9 -0
- package/src/service/pagination/frontendDrivers/VueBaseViewDriver.ts +28 -0
- package/src/service/pagination/index.ts +34 -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/vue/router/routeResourceBinding/RouteResourceBoundView.test.ts +344 -0
- package/tests/vue/router/routeResourceBinding/installRouteInjection.test.ts +450 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { StatePaginationDataDto } from './dtos/StatePaginationDataDto'
|
|
2
|
+
import { type BaseViewDriverContract } from './contracts/BaseViewDriverContract'
|
|
3
|
+
import { type BaseViewDriverFactoryContract } from './contracts/BaseViewDriverFactoryContract'
|
|
4
|
+
import { type PaginatorLoadDataOptions } from './contracts/PaginatorLoadDataOptions'
|
|
5
|
+
import { type StatePaginationDataDriverContract } from './contracts/StatePaginationDataDriverContract'
|
|
6
|
+
import { BasePaginator } from './BasePaginator'
|
|
7
|
+
|
|
8
|
+
export interface StatePaginatorOptions {
|
|
9
|
+
viewDriverFactory?: BaseViewDriverFactoryContract
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StatePaginator<ResourceInterface> extends BasePaginator<ResourceInterface, BaseViewDriverContract<ResourceInterface[]>> {
|
|
13
|
+
protected static viewDriverFactory: BaseViewDriverFactoryContract
|
|
14
|
+
|
|
15
|
+
protected override viewDriver: BaseViewDriverContract<ResourceInterface[]>
|
|
16
|
+
|
|
17
|
+
protected currentState: string | null = null
|
|
18
|
+
|
|
19
|
+
public static setViewDriverFactory(value: BaseViewDriverFactoryContract): void {
|
|
20
|
+
StatePaginator.viewDriverFactory = value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public constructor(
|
|
24
|
+
protected override dataDriver: StatePaginationDataDriverContract<ResourceInterface[]>,
|
|
25
|
+
options?: StatePaginatorOptions
|
|
26
|
+
) {
|
|
27
|
+
super(dataDriver)
|
|
28
|
+
this.viewDriver = options?.viewDriverFactory
|
|
29
|
+
? options.viewDriverFactory.make<ResourceInterface>()
|
|
30
|
+
: StatePaginator.viewDriverFactory.make<ResourceInterface>()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public setDataDriver(dataDriver: StatePaginationDataDriverContract<ResourceInterface[]>): this {
|
|
34
|
+
this.dataDriver = dataDriver
|
|
35
|
+
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public getDataDriver(): StatePaginationDataDriverContract<ResourceInterface[]> {
|
|
40
|
+
return this.dataDriver
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public init(): Promise<StatePaginationDataDto<ResourceInterface[]>> {
|
|
44
|
+
this.initialized = true
|
|
45
|
+
this.currentState = null
|
|
46
|
+
|
|
47
|
+
return this.loadData()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public refresh(options?: PaginatorLoadDataOptions): Promise<StatePaginationDataDto<ResourceInterface[]>> {
|
|
51
|
+
this.currentState = null
|
|
52
|
+
|
|
53
|
+
return this.loadData({ ...options, flush: true })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public loadNext(): Promise<StatePaginationDataDto<ResourceInterface[]>> {
|
|
57
|
+
return this.loadData()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public hasNextPage(): boolean {
|
|
61
|
+
return this.currentState !== null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public getCurrentState(): string | null {
|
|
65
|
+
return this.currentState
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected loadData(options?: PaginatorLoadDataOptions): Promise<StatePaginationDataDto<ResourceInterface[]>> {
|
|
69
|
+
return this.dataDriver.get(this.currentState).then((value: StatePaginationDataDto<ResourceInterface[]>) => {
|
|
70
|
+
this.currentState = value.getState()
|
|
71
|
+
this.passStateDataToViewDriver(value, options)
|
|
72
|
+
|
|
73
|
+
return value
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected passStateDataToViewDriver(dto: StatePaginationDataDto<ResourceInterface[]>, options?: PaginatorLoadDataOptions): void {
|
|
78
|
+
const { flush = false, replace = false } = options || {}
|
|
79
|
+
|
|
80
|
+
if (flush) {
|
|
81
|
+
this.flush()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (replace) {
|
|
85
|
+
this.viewDriver.setData(dto.getData())
|
|
86
|
+
} else {
|
|
87
|
+
this.viewDriver.setData(this.viewDriver.getData().concat(dto.getData()))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.viewDriver.setTotal(dto.getTotal())
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
getData(): ResourceInterface
|
|
1
|
+
import { type BaseViewDriverContract } from './BaseViewDriverContract'
|
|
2
|
+
|
|
3
|
+
export interface ViewDriverContract<ResourceInterface> extends BaseViewDriverContract<ResourceInterface> {
|
|
5
4
|
getCurrentPage(): number
|
|
6
5
|
setPage(value: number): void
|
|
7
6
|
setPageSize(value: number): void
|
|
8
7
|
getPageSize(): number
|
|
9
8
|
getLastPage(): number
|
|
10
9
|
getPages(): number[]
|
|
11
|
-
getTotal(): number
|
|
12
10
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PaginationDataDto } from './PaginationDataDto'
|
|
2
|
+
|
|
3
|
+
export class StatePaginationDataDto<ResourceInterface> extends PaginationDataDto<ResourceInterface> {
|
|
4
|
+
public constructor(
|
|
5
|
+
data: ResourceInterface,
|
|
6
|
+
total: number,
|
|
7
|
+
protected state: string | null = null
|
|
8
|
+
) {
|
|
9
|
+
super(data, total)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public getState(): string | null {
|
|
13
|
+
return this.state
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public hasNextPage(): boolean {
|
|
17
|
+
return this.state !== null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BaseViewDriverFactoryContract } from '../contracts/BaseViewDriverFactoryContract'
|
|
2
|
+
import { type BaseViewDriverContract } from '../contracts/BaseViewDriverContract'
|
|
3
|
+
import { VueBaseViewDriver } from '../frontendDrivers/VueBaseViewDriver'
|
|
4
|
+
|
|
5
|
+
export class VueBaseViewDriverFactory implements BaseViewDriverFactoryContract {
|
|
6
|
+
public make<ResourceInterface>(): BaseViewDriverContract<ResourceInterface[]> {
|
|
7
|
+
return new VueBaseViewDriver<ResourceInterface>()
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
import { type BaseViewDriverContract } from '../contracts/BaseViewDriverContract'
|
|
3
|
+
|
|
4
|
+
export class VueBaseViewDriver<ResourceInterface> implements BaseViewDriverContract<ResourceInterface[]> {
|
|
5
|
+
protected dataRef: Ref<ResourceInterface[]>
|
|
6
|
+
protected totalRef: Ref<number>
|
|
7
|
+
|
|
8
|
+
public constructor() {
|
|
9
|
+
this.dataRef = ref([]) as Ref<ResourceInterface[]>
|
|
10
|
+
this.totalRef = ref<number>(0)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public setData(data: ResourceInterface[]): void {
|
|
14
|
+
this.dataRef.value = data
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getData(): ResourceInterface[] {
|
|
18
|
+
return this.dataRef.value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public setTotal(value: number): void {
|
|
22
|
+
this.totalRef.value = value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public getTotal(): number {
|
|
26
|
+
return this.totalRef.value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
import { PaginationDataDto } from './dtos/PaginationDataDto'
|
|
2
|
+
import { StatePaginationDataDto } from './dtos/StatePaginationDataDto'
|
|
2
3
|
import { VuePaginationDriver } from './frontendDrivers/VuePaginationDriver'
|
|
4
|
+
import { VueBaseViewDriver } from './frontendDrivers/VueBaseViewDriver'
|
|
3
5
|
import { Paginator } from './Paginator'
|
|
6
|
+
import { BasePaginator } from './BasePaginator'
|
|
7
|
+
import { PageAwarePaginator } from './PageAwarePaginator'
|
|
8
|
+
import { StatePaginator } from './StatePaginator'
|
|
4
9
|
import { InfiniteScroller } from './InfiniteScroller'
|
|
5
10
|
import { VuePaginationDriverFactory } from './factories/VuePaginationDriverFactory'
|
|
11
|
+
import { VueBaseViewDriverFactory } from './factories/VueBaseViewDriverFactory'
|
|
6
12
|
import { type PaginateableRequestContract } from './contracts/PaginateableRequestContract'
|
|
7
13
|
import { type PaginationResponseContract } from './contracts/PaginationResponseContract'
|
|
8
14
|
import { type PaginationDataDriverContract } from './contracts/PaginationDataDriverContract'
|
|
15
|
+
import { type StatePaginationDataDriverContract } from './contracts/StatePaginationDataDriverContract'
|
|
9
16
|
import { getDisplayablePages } from '../../helpers'
|
|
10
17
|
import { ArrayDriver } from './dataDrivers/ArrayDriver'
|
|
18
|
+
import { type BaseViewDriverContract } from './contracts/BaseViewDriverContract'
|
|
11
19
|
import { type ViewDriverContract } from './contracts/ViewDriverContract'
|
|
20
|
+
import { type BaseViewDriverFactoryContract } from './contracts/BaseViewDriverFactoryContract'
|
|
12
21
|
import { type ViewDriverFactoryContract } from './contracts/ViewDriverFactoryContract'
|
|
13
22
|
|
|
14
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
PaginationDataDto,
|
|
25
|
+
StatePaginationDataDto,
|
|
26
|
+
VuePaginationDriver,
|
|
27
|
+
VueBaseViewDriver,
|
|
28
|
+
Paginator,
|
|
29
|
+
BasePaginator,
|
|
30
|
+
PageAwarePaginator,
|
|
31
|
+
StatePaginator,
|
|
32
|
+
InfiniteScroller,
|
|
33
|
+
VuePaginationDriverFactory,
|
|
34
|
+
VueBaseViewDriverFactory,
|
|
35
|
+
getDisplayablePages,
|
|
36
|
+
ArrayDriver
|
|
37
|
+
}
|
|
15
38
|
|
|
16
|
-
export type {
|
|
39
|
+
export type {
|
|
40
|
+
PaginationDataDriverContract,
|
|
41
|
+
StatePaginationDataDriverContract,
|
|
42
|
+
PaginationResponseContract,
|
|
43
|
+
PaginateableRequestContract,
|
|
44
|
+
BaseViewDriverContract,
|
|
45
|
+
ViewDriverContract,
|
|
46
|
+
BaseViewDriverFactoryContract,
|
|
47
|
+
ViewDriverFactoryContract
|
|
48
|
+
}
|
|
@@ -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
|
}
|