qdadm 0.14.2 → 0.15.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/package.json +1 -1
- package/src/components/index.js +1 -0
- package/src/components/layout/AppLayout.vue +113 -15
- package/src/components/layout/PageHeader.vue +3 -18
- package/src/components/layout/PageLayout.vue +13 -22
- package/src/components/layout/PageNav.vue +184 -0
- package/src/components/lists/ListPage.vue +4 -2
- package/src/composables/useListPageBuilder.js +10 -6
- package/src/entity/storage/ApiStorage.js +8 -3
- package/src/entity/storage/LocalStorage.js +25 -4
- package/src/entity/storage/MemoryStorage.js +7 -2
- package/src/module/moduleRegistry.js +25 -2
- package/src/styles/theme/components.css +43 -0
package/package.json
CHANGED
package/src/components/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { default as AppLayout } from './layout/AppLayout.vue'
|
|
|
7
7
|
export { default as PageLayout } from './layout/PageLayout.vue'
|
|
8
8
|
export { default as PageHeader } from './layout/PageHeader.vue'
|
|
9
9
|
export { default as Breadcrumb } from './layout/Breadcrumb.vue'
|
|
10
|
+
export { default as PageNav } from './layout/PageNav.vue'
|
|
10
11
|
|
|
11
12
|
// Forms
|
|
12
13
|
export { default as FormField } from './forms/FormField.vue'
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* </AppLayout>
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { ref, watch, onMounted, computed, inject, useSlots } from 'vue'
|
|
15
|
+
import { ref, watch, onMounted, computed, inject, provide, useSlots } from 'vue'
|
|
16
16
|
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|
17
17
|
import { useNavigation } from '../../composables/useNavigation'
|
|
18
18
|
import { useApp } from '../../composables/useApp'
|
|
@@ -126,7 +126,18 @@ const slots = useSlots()
|
|
|
126
126
|
const hasSlotContent = computed(() => !!slots.default)
|
|
127
127
|
|
|
128
128
|
// Breadcrumb (auto-generated from route)
|
|
129
|
-
const { breadcrumbItems } = useBreadcrumb()
|
|
129
|
+
const { breadcrumbItems: defaultBreadcrumb } = useBreadcrumb()
|
|
130
|
+
|
|
131
|
+
// Allow child pages to override breadcrumb via provide/inject
|
|
132
|
+
const breadcrumbOverride = ref(null)
|
|
133
|
+
const navlinksOverride = ref(null)
|
|
134
|
+
provide('qdadmBreadcrumbOverride', breadcrumbOverride)
|
|
135
|
+
provide('qdadmNavlinksOverride', navlinksOverride)
|
|
136
|
+
|
|
137
|
+
// Use override if provided, otherwise default
|
|
138
|
+
const breadcrumbItems = computed(() => breadcrumbOverride.value || defaultBreadcrumb.value)
|
|
139
|
+
const navlinks = computed(() => navlinksOverride.value || [])
|
|
140
|
+
|
|
130
141
|
// Show breadcrumb if enabled, has items, and not on home page
|
|
131
142
|
const showBreadcrumb = computed(() => {
|
|
132
143
|
if (!features.breadcrumb || breadcrumbItems.value.length === 0) return false
|
|
@@ -203,19 +214,35 @@ const showBreadcrumb = computed(() => {
|
|
|
203
214
|
|
|
204
215
|
<!-- Main content -->
|
|
205
216
|
<main class="main-content">
|
|
206
|
-
<!-- Breadcrumb
|
|
207
|
-
<
|
|
208
|
-
<
|
|
209
|
-
<
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
<!-- Breadcrumb + Navlinks bar -->
|
|
218
|
+
<div v-if="showBreadcrumb" class="layout-nav-bar">
|
|
219
|
+
<Breadcrumb :model="breadcrumbItems" class="layout-breadcrumb">
|
|
220
|
+
<template #item="{ item }">
|
|
221
|
+
<RouterLink v-if="item.to" :to="item.to" class="breadcrumb-link">
|
|
222
|
+
<i v-if="item.icon" :class="item.icon"></i>
|
|
223
|
+
<span>{{ item.label }}</span>
|
|
224
|
+
</RouterLink>
|
|
225
|
+
<span v-else class="breadcrumb-current">
|
|
226
|
+
<i v-if="item.icon" :class="item.icon"></i>
|
|
227
|
+
<span>{{ item.label }}</span>
|
|
228
|
+
</span>
|
|
229
|
+
</template>
|
|
230
|
+
</Breadcrumb>
|
|
231
|
+
|
|
232
|
+
<!-- Navlinks (provided by PageNav for child routes) -->
|
|
233
|
+
<div v-if="navlinks.length > 0" class="layout-navlinks">
|
|
234
|
+
<template v-for="(link, index) in navlinks" :key="link.to?.name || index">
|
|
235
|
+
<span v-if="index > 0" class="layout-navlinks-separator">|</span>
|
|
236
|
+
<RouterLink
|
|
237
|
+
:to="link.to"
|
|
238
|
+
class="layout-navlink"
|
|
239
|
+
:class="{ 'layout-navlink--active': link.active }"
|
|
240
|
+
>
|
|
241
|
+
{{ link.label }}
|
|
242
|
+
</RouterLink>
|
|
243
|
+
</template>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
219
246
|
|
|
220
247
|
<div class="page-content">
|
|
221
248
|
<!-- Use slot if provided, otherwise RouterView for nested routes -->
|
|
@@ -455,4 +482,75 @@ const showBreadcrumb = computed(() => {
|
|
|
455
482
|
.powered-by-text strong {
|
|
456
483
|
color: var(--p-surface-300, #cbd5e1);
|
|
457
484
|
}
|
|
485
|
+
|
|
486
|
+
/* Nav bar (breadcrumb + navlinks) - aligned with sidebar header */
|
|
487
|
+
.layout-nav-bar {
|
|
488
|
+
display: flex;
|
|
489
|
+
justify-content: space-between;
|
|
490
|
+
align-items: center;
|
|
491
|
+
padding: 1.5rem;
|
|
492
|
+
padding-bottom: 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.layout-navlinks {
|
|
496
|
+
display: flex;
|
|
497
|
+
align-items: center;
|
|
498
|
+
gap: 0.5rem;
|
|
499
|
+
font-size: 0.875rem;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.layout-navlinks-separator {
|
|
503
|
+
color: var(--p-surface-400, #94a3b8);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
.layout-navlink {
|
|
507
|
+
color: var(--p-primary-500, #3b82f6);
|
|
508
|
+
text-decoration: none;
|
|
509
|
+
transition: color 0.15s;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.layout-navlink:hover {
|
|
513
|
+
color: var(--p-primary-700, #1d4ed8);
|
|
514
|
+
text-decoration: underline;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.layout-navlink--active {
|
|
518
|
+
color: var(--p-surface-700, #334155);
|
|
519
|
+
font-weight: 500;
|
|
520
|
+
pointer-events: none;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* Override PrimeVue Breadcrumb styles for flat look */
|
|
524
|
+
.layout-nav-bar :deep(.p-breadcrumb) {
|
|
525
|
+
background: transparent;
|
|
526
|
+
border: none;
|
|
527
|
+
padding: 0;
|
|
528
|
+
border-radius: 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.layout-nav-bar :deep(.p-breadcrumb-list) {
|
|
532
|
+
gap: 0.5rem;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.layout-nav-bar :deep(.p-breadcrumb-item-link),
|
|
536
|
+
.layout-nav-bar .breadcrumb-link {
|
|
537
|
+
color: var(--p-primary-500, #3b82f6);
|
|
538
|
+
text-decoration: none;
|
|
539
|
+
font-size: 0.875rem;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.layout-nav-bar :deep(.p-breadcrumb-item-link:hover),
|
|
543
|
+
.layout-nav-bar .breadcrumb-link:hover {
|
|
544
|
+
color: var(--p-primary-700, #1d4ed8);
|
|
545
|
+
text-decoration: underline;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.layout-nav-bar .breadcrumb-current {
|
|
549
|
+
color: var(--p-surface-600, #475569);
|
|
550
|
+
font-size: 0.875rem;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.layout-nav-bar :deep(.p-breadcrumb-separator) {
|
|
554
|
+
color: var(--p-surface-400, #94a3b8);
|
|
555
|
+
}
|
|
458
556
|
</style>
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
/**
|
|
3
|
-
* PageHeader - Reusable page header with
|
|
3
|
+
* PageHeader - Reusable page header with title and actions
|
|
4
4
|
*
|
|
5
5
|
* Props:
|
|
6
6
|
* - title: Page title (simple string) OR
|
|
7
7
|
* - titleParts: { action, entityName, entityLabel } for decorated rendering
|
|
8
|
-
* - breadcrumb: Array of { label, to?, icon? } - optional breadcrumb items
|
|
9
8
|
*
|
|
10
9
|
* Title rendering:
|
|
11
10
|
* - Simple: "Edit Agent" → <h1>Edit Agent</h1>
|
|
@@ -16,14 +15,9 @@
|
|
|
16
15
|
* - subtitle: Optional content next to title
|
|
17
16
|
* - actions: Action buttons on the right
|
|
18
17
|
*
|
|
19
|
-
* Note:
|
|
20
|
-
* breadcrumb rendering globally. PageHeader only renders its own breadcrumb
|
|
21
|
-
* when the global feature is disabled.
|
|
18
|
+
* Note: Breadcrumb is handled globally by AppLayout.
|
|
22
19
|
*/
|
|
23
20
|
import { computed, inject } from 'vue'
|
|
24
|
-
import Breadcrumb from './Breadcrumb.vue'
|
|
25
|
-
|
|
26
|
-
const features = inject('qdadmFeatures', { breadcrumb: false })
|
|
27
21
|
|
|
28
22
|
// Auto-injected title from useForm (if available)
|
|
29
23
|
const injectedTitleParts = inject('qdadmPageTitleParts', null)
|
|
@@ -40,10 +34,6 @@ const props = defineProps({
|
|
|
40
34
|
subtitle: {
|
|
41
35
|
type: String,
|
|
42
36
|
default: null
|
|
43
|
-
},
|
|
44
|
-
breadcrumb: {
|
|
45
|
-
type: Array,
|
|
46
|
-
default: null
|
|
47
37
|
}
|
|
48
38
|
})
|
|
49
39
|
|
|
@@ -63,17 +53,11 @@ const titleBase = computed(() => {
|
|
|
63
53
|
}
|
|
64
54
|
return props.title
|
|
65
55
|
})
|
|
66
|
-
|
|
67
|
-
// Only show PageHeader breadcrumb if global breadcrumb feature is disabled
|
|
68
|
-
const showBreadcrumb = computed(() => {
|
|
69
|
-
return props.breadcrumb?.length && !features.breadcrumb
|
|
70
|
-
})
|
|
71
56
|
</script>
|
|
72
57
|
|
|
73
58
|
<template>
|
|
74
59
|
<div class="page-header">
|
|
75
60
|
<div class="page-header-content">
|
|
76
|
-
<Breadcrumb v-if="showBreadcrumb" :items="breadcrumb" />
|
|
77
61
|
<div class="page-header-row">
|
|
78
62
|
<div class="page-header-left">
|
|
79
63
|
<div>
|
|
@@ -105,6 +89,7 @@ const showBreadcrumb = computed(() => {
|
|
|
105
89
|
display: flex;
|
|
106
90
|
justify-content: space-between;
|
|
107
91
|
align-items: center;
|
|
92
|
+
min-height: 2.5rem; /* Consistent height with or without action buttons */
|
|
108
93
|
}
|
|
109
94
|
|
|
110
95
|
.page-header-left {
|
|
@@ -3,22 +3,21 @@
|
|
|
3
3
|
* PageLayout - Base layout for dashboard pages
|
|
4
4
|
*
|
|
5
5
|
* Provides:
|
|
6
|
-
* - Auto-generated breadcrumb
|
|
7
6
|
* - PageHeader with title and actions
|
|
8
7
|
* - CardsGrid zone (optional)
|
|
9
8
|
* - Main content slot
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
* For
|
|
10
|
+
* Slots:
|
|
11
|
+
* - #nav: For PageNav component (provides breadcrumb data to AppLayout)
|
|
12
|
+
* - #header-actions: Custom header action buttons
|
|
13
|
+
* - default: Main content
|
|
13
14
|
*
|
|
14
|
-
* Note:
|
|
15
|
-
*
|
|
15
|
+
* Note: Breadcrumb is handled globally by AppLayout.
|
|
16
|
+
* Use PageNav in #nav slot to customize breadcrumb for child routes.
|
|
16
17
|
*/
|
|
17
|
-
import { toRef } from 'vue'
|
|
18
18
|
import PageHeader from './PageHeader.vue'
|
|
19
19
|
import CardsGrid from '../display/CardsGrid.vue'
|
|
20
20
|
import Button from 'primevue/button'
|
|
21
|
-
import { useBreadcrumb } from '../../composables/useBreadcrumb'
|
|
22
21
|
|
|
23
22
|
const props = defineProps({
|
|
24
23
|
// Header - use title OR titleParts (for decorated entity label)
|
|
@@ -26,26 +25,12 @@ const props = defineProps({
|
|
|
26
25
|
titleParts: { type: Object, default: null }, // { action, entityName, entityLabel }
|
|
27
26
|
subtitle: { type: String, default: null },
|
|
28
27
|
headerActions: { type: Array, default: () => [] },
|
|
29
|
-
breadcrumb: { type: Array, default: null }, // Override auto breadcrumb
|
|
30
|
-
|
|
31
|
-
// Entity data for dynamic breadcrumb labels
|
|
32
|
-
entity: { type: Object, default: null },
|
|
33
|
-
manager: { type: Object, default: null }, // EntityManager - provides labelField automatically
|
|
34
28
|
|
|
35
29
|
// Cards
|
|
36
30
|
cards: { type: Array, default: () => [] },
|
|
37
31
|
cardsColumns: { type: [Number, String], default: 'auto' }
|
|
38
32
|
})
|
|
39
33
|
|
|
40
|
-
// Auto-generate breadcrumb from route, using entity data for labels
|
|
41
|
-
// getEntityLabel from manager handles both string field and callback
|
|
42
|
-
const { breadcrumbItems } = useBreadcrumb({
|
|
43
|
-
entity: toRef(() => props.entity), // Make reactive
|
|
44
|
-
getEntityLabel: props.manager
|
|
45
|
-
? (e) => props.manager.getEntityLabel(e)
|
|
46
|
-
: (e) => e?.name || null // Fallback if no manager
|
|
47
|
-
})
|
|
48
|
-
|
|
49
34
|
function resolveLabel(label) {
|
|
50
35
|
return typeof label === 'function' ? label() : label
|
|
51
36
|
}
|
|
@@ -53,7 +38,13 @@ function resolveLabel(label) {
|
|
|
53
38
|
|
|
54
39
|
<template>
|
|
55
40
|
<div>
|
|
56
|
-
|
|
41
|
+
<!-- Nav slot for PageNav (provides data to AppLayout, renders nothing visible) -->
|
|
42
|
+
<slot name="nav" />
|
|
43
|
+
|
|
44
|
+
<PageHeader
|
|
45
|
+
:title="title"
|
|
46
|
+
:title-parts="titleParts"
|
|
47
|
+
>
|
|
57
48
|
<template #subtitle>
|
|
58
49
|
<slot name="subtitle">
|
|
59
50
|
<span v-if="subtitle" class="page-subtitle">{{ subtitle }}</span>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* PageNav - Route-aware navigation provider for breadcrumb + navlinks
|
|
4
|
+
*
|
|
5
|
+
* This component doesn't render anything visible. Instead, it provides
|
|
6
|
+
* breadcrumb items and navlinks to AppLayout via provide/inject.
|
|
7
|
+
*
|
|
8
|
+
* Layout (rendered in AppLayout):
|
|
9
|
+
* Books > "Dune" Details | Loans | Reviews
|
|
10
|
+
* ↑ breadcrumb (left) ↑ navlinks (right)
|
|
11
|
+
*
|
|
12
|
+
* Auto-detects from current route:
|
|
13
|
+
* - Breadcrumb: parent chain from route.meta.parent
|
|
14
|
+
* - Navlinks: sibling routes (same parent entity + param)
|
|
15
|
+
*
|
|
16
|
+
* Props:
|
|
17
|
+
* - entity: Current entity data (for dynamic labels in breadcrumb)
|
|
18
|
+
* - parentEntity: Parent entity data (for parent label in breadcrumb)
|
|
19
|
+
*/
|
|
20
|
+
import { computed, ref, watch, onMounted, onUnmounted, inject } from 'vue'
|
|
21
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
22
|
+
import { getSiblingRoutes } from '../../module/moduleRegistry.js'
|
|
23
|
+
import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
|
|
24
|
+
|
|
25
|
+
// Inject override refs from AppLayout
|
|
26
|
+
const breadcrumbOverride = inject('qdadmBreadcrumbOverride', null)
|
|
27
|
+
const navlinksOverride = inject('qdadmNavlinksOverride', null)
|
|
28
|
+
|
|
29
|
+
const props = defineProps({
|
|
30
|
+
entity: { type: Object, default: null },
|
|
31
|
+
parentEntity: { type: Object, default: null }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const route = useRoute()
|
|
35
|
+
const router = useRouter()
|
|
36
|
+
const { getManager } = useOrchestrator()
|
|
37
|
+
|
|
38
|
+
// Parent config from route meta
|
|
39
|
+
const parentConfig = computed(() => route.meta?.parent)
|
|
40
|
+
|
|
41
|
+
// Parent entity data (loaded if not provided via prop)
|
|
42
|
+
const parentData = ref(props.parentEntity)
|
|
43
|
+
|
|
44
|
+
// Load parent entity if needed
|
|
45
|
+
watch(() => [parentConfig.value, route.params], async () => {
|
|
46
|
+
if (!parentConfig.value || props.parentEntity) return
|
|
47
|
+
|
|
48
|
+
const { entity: parentEntityName, param } = parentConfig.value
|
|
49
|
+
const parentId = route.params[param]
|
|
50
|
+
|
|
51
|
+
if (parentEntityName && parentId) {
|
|
52
|
+
try {
|
|
53
|
+
const manager = getManager(parentEntityName)
|
|
54
|
+
if (manager) {
|
|
55
|
+
parentData.value = await manager.get(parentId)
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn('[PageNav] Failed to load parent entity:', e)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}, { immediate: true })
|
|
62
|
+
|
|
63
|
+
// Build breadcrumb items
|
|
64
|
+
const breadcrumbItems = computed(() => {
|
|
65
|
+
const items = []
|
|
66
|
+
|
|
67
|
+
if (!parentConfig.value) {
|
|
68
|
+
// No parent - use simple breadcrumb from entity
|
|
69
|
+
const entityName = route.meta?.entity
|
|
70
|
+
if (entityName) {
|
|
71
|
+
const manager = getManager(entityName)
|
|
72
|
+
if (manager) {
|
|
73
|
+
items.push({
|
|
74
|
+
label: manager.labelPlural || manager.name,
|
|
75
|
+
to: { name: manager.routePrefix }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return items
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Has parent - build parent chain
|
|
83
|
+
const { entity: parentEntityName, param, itemRoute } = parentConfig.value
|
|
84
|
+
const parentId = route.params[param]
|
|
85
|
+
const parentManager = getManager(parentEntityName)
|
|
86
|
+
|
|
87
|
+
if (parentManager) {
|
|
88
|
+
// Parent list
|
|
89
|
+
items.push({
|
|
90
|
+
label: parentManager.labelPlural || parentManager.name,
|
|
91
|
+
to: { name: parentManager.routePrefix }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Parent item (with label from data)
|
|
95
|
+
const parentLabel = parentData.value
|
|
96
|
+
? parentManager.getEntityLabel(parentData.value)
|
|
97
|
+
: '...'
|
|
98
|
+
const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
|
|
99
|
+
|
|
100
|
+
items.push({
|
|
101
|
+
label: parentLabel,
|
|
102
|
+
to: { name: parentRouteName, params: { id: parentId } }
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Current entity (last item, no link)
|
|
107
|
+
const currentLabel = route.meta?.navLabel
|
|
108
|
+
if (currentLabel) {
|
|
109
|
+
items.push({ label: currentLabel })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return items
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Sibling navlinks (routes with same parent)
|
|
116
|
+
const navlinks = computed(() => {
|
|
117
|
+
if (!parentConfig.value) return []
|
|
118
|
+
|
|
119
|
+
const { entity: parentEntityName, param } = parentConfig.value
|
|
120
|
+
const siblings = getSiblingRoutes(parentEntityName, param)
|
|
121
|
+
|
|
122
|
+
// Build navlinks with current route params
|
|
123
|
+
return siblings.map(siblingRoute => {
|
|
124
|
+
const manager = siblingRoute.meta?.entity ? getManager(siblingRoute.meta.entity) : null
|
|
125
|
+
const label = siblingRoute.meta?.navLabel || manager?.labelPlural || siblingRoute.name
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
label,
|
|
129
|
+
to: { name: siblingRoute.name, params: route.params },
|
|
130
|
+
active: route.name === siblingRoute.name
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Also include parent "Details" link
|
|
136
|
+
const allNavlinks = computed(() => {
|
|
137
|
+
if (!parentConfig.value) return []
|
|
138
|
+
|
|
139
|
+
const { entity: parentEntityName, param, itemRoute } = parentConfig.value
|
|
140
|
+
const parentId = route.params[param]
|
|
141
|
+
const parentManager = getManager(parentEntityName)
|
|
142
|
+
|
|
143
|
+
if (!parentManager) return navlinks.value
|
|
144
|
+
|
|
145
|
+
const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
|
|
146
|
+
const isOnParentRoute = route.name === parentRouteName
|
|
147
|
+
|
|
148
|
+
// Details link to parent edit form
|
|
149
|
+
const detailsLink = {
|
|
150
|
+
label: 'Details',
|
|
151
|
+
to: { name: parentRouteName, params: { id: parentId } },
|
|
152
|
+
active: isOnParentRoute
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return [detailsLink, ...navlinks.value]
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Sync breadcrumb and navlinks to AppLayout via provide/inject
|
|
159
|
+
watch(breadcrumbItems, (items) => {
|
|
160
|
+
if (breadcrumbOverride) {
|
|
161
|
+
breadcrumbOverride.value = items
|
|
162
|
+
}
|
|
163
|
+
}, { immediate: true })
|
|
164
|
+
|
|
165
|
+
watch(allNavlinks, (links) => {
|
|
166
|
+
if (navlinksOverride) {
|
|
167
|
+
navlinksOverride.value = links
|
|
168
|
+
}
|
|
169
|
+
}, { immediate: true })
|
|
170
|
+
|
|
171
|
+
// Clear overrides when component unmounts (so other pages get default breadcrumb)
|
|
172
|
+
onUnmounted(() => {
|
|
173
|
+
if (breadcrumbOverride) {
|
|
174
|
+
breadcrumbOverride.value = null
|
|
175
|
+
}
|
|
176
|
+
if (navlinksOverride) {
|
|
177
|
+
navlinksOverride.value = null
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<template>
|
|
183
|
+
<!-- PageNav provides data to AppLayout via inject, renders nothing -->
|
|
184
|
+
</template>
|
|
@@ -29,7 +29,6 @@ const props = defineProps({
|
|
|
29
29
|
// Header
|
|
30
30
|
title: { type: String, required: true },
|
|
31
31
|
subtitle: { type: String, default: null },
|
|
32
|
-
breadcrumb: { type: Array, default: null },
|
|
33
32
|
headerActions: { type: Array, default: () => [] },
|
|
34
33
|
|
|
35
34
|
// Cards
|
|
@@ -207,7 +206,10 @@ function onSort(event) {
|
|
|
207
206
|
|
|
208
207
|
<template>
|
|
209
208
|
<div>
|
|
210
|
-
|
|
209
|
+
<!-- Nav slot for PageNav (child routes) -->
|
|
210
|
+
<slot name="nav" />
|
|
211
|
+
|
|
212
|
+
<PageHeader :title="title" :subtitle="subtitle">
|
|
211
213
|
<template #actions>
|
|
212
214
|
<slot name="header-actions" ></slot>
|
|
213
215
|
<Button
|
|
@@ -2,7 +2,6 @@ import { ref, computed, watch, onMounted, inject, provide } from 'vue'
|
|
|
2
2
|
import { useRouter, useRoute } from 'vue-router'
|
|
3
3
|
import { useToast } from 'primevue/usetoast'
|
|
4
4
|
import { useConfirm } from 'primevue/useconfirm'
|
|
5
|
-
import { useBreadcrumb } from './useBreadcrumb'
|
|
6
5
|
|
|
7
6
|
// Cookie utilities for pagination persistence
|
|
8
7
|
const COOKIE_NAME = 'qdadm_pageSize'
|
|
@@ -146,7 +145,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
146
145
|
const route = useRoute()
|
|
147
146
|
const toast = useToast()
|
|
148
147
|
const confirm = useConfirm()
|
|
149
|
-
const { breadcrumbItems } = useBreadcrumb()
|
|
150
148
|
|
|
151
149
|
// Get EntityManager via orchestrator
|
|
152
150
|
const orchestrator = inject('qdadmOrchestrator')
|
|
@@ -735,6 +733,16 @@ export function useListPageBuilder(config = {}) {
|
|
|
735
733
|
if (typeof filterDef?.local_filter === 'function') continue
|
|
736
734
|
filters[name] = value
|
|
737
735
|
}
|
|
736
|
+
|
|
737
|
+
// Auto-add parent filter from route config
|
|
738
|
+
const parentConfig = route.meta?.parent
|
|
739
|
+
if (parentConfig?.foreignKey && parentConfig?.param) {
|
|
740
|
+
const parentId = route.params[parentConfig.param]
|
|
741
|
+
if (parentId) {
|
|
742
|
+
filters[parentConfig.foreignKey] = parentId
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
738
746
|
if (Object.keys(filters).length > 0) {
|
|
739
747
|
params.filters = filters
|
|
740
748
|
}
|
|
@@ -1009,7 +1017,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
1009
1017
|
const listProps = computed(() => ({
|
|
1010
1018
|
// Header
|
|
1011
1019
|
title: manager.labelPlural,
|
|
1012
|
-
breadcrumb: breadcrumbItems.value,
|
|
1013
1020
|
headerActions: headerActions.value,
|
|
1014
1021
|
|
|
1015
1022
|
// Cards
|
|
@@ -1143,9 +1150,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
1143
1150
|
confirm,
|
|
1144
1151
|
router,
|
|
1145
1152
|
|
|
1146
|
-
// Breadcrumb
|
|
1147
|
-
breadcrumb: breadcrumbItems,
|
|
1148
|
-
|
|
1149
1153
|
// ListPage integration
|
|
1150
1154
|
props: listProps,
|
|
1151
1155
|
events: listEvents
|
|
@@ -56,13 +56,18 @@ export class ApiStorage {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* List entities with pagination/filtering
|
|
59
|
-
* @param {object} params -
|
|
59
|
+
* @param {object} params - Query parameters
|
|
60
|
+
* @param {number} [params.page=1] - Page number (1-based)
|
|
61
|
+
* @param {number} [params.page_size=20] - Items per page
|
|
62
|
+
* @param {string} [params.sort_by] - Field to sort by
|
|
63
|
+
* @param {string} [params.sort_order='asc'] - Sort order ('asc' or 'desc')
|
|
64
|
+
* @param {object} [params.filters] - Field filters { field: value }
|
|
60
65
|
* @returns {Promise<{ items: Array, total: number }>}
|
|
61
66
|
*/
|
|
62
67
|
async list(params = {}) {
|
|
63
|
-
const { page = 1, page_size = 20,
|
|
68
|
+
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
64
69
|
const response = await this.client.get(this.endpoint, {
|
|
65
|
-
params: { page, page_size, ...filters }
|
|
70
|
+
params: { page, page_size, sort_by, sort_order, ...filters }
|
|
66
71
|
})
|
|
67
72
|
|
|
68
73
|
const data = response.data
|
|
@@ -58,26 +58,47 @@ export class LocalStorage {
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* List entities with pagination/filtering
|
|
61
|
-
* @param {object} params -
|
|
61
|
+
* @param {object} params - Query parameters
|
|
62
|
+
* @param {number} [params.page=1] - Page number (1-based)
|
|
63
|
+
* @param {number} [params.page_size=20] - Items per page
|
|
64
|
+
* @param {string} [params.sort_by] - Field to sort by
|
|
65
|
+
* @param {string} [params.sort_order='asc'] - Sort order ('asc' or 'desc')
|
|
66
|
+
* @param {object} [params.filters] - Field filters { field: value }
|
|
67
|
+
* @param {string} [params.search] - Search query (substring match on string fields)
|
|
62
68
|
* @returns {Promise<{ items: Array, total: number }>}
|
|
63
69
|
*/
|
|
64
70
|
async list(params = {}) {
|
|
65
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc',
|
|
71
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
66
72
|
|
|
67
73
|
let items = this._getAll()
|
|
68
74
|
|
|
69
|
-
// Apply filters
|
|
75
|
+
// Apply filters (exact match for dropdown filters)
|
|
70
76
|
for (const [key, value] of Object.entries(filters)) {
|
|
71
77
|
if (value === null || value === undefined || value === '') continue
|
|
72
78
|
items = items.filter(item => {
|
|
73
79
|
const itemValue = item[key]
|
|
80
|
+
// Use exact match for filters (case-insensitive for strings)
|
|
74
81
|
if (typeof value === 'string' && typeof itemValue === 'string') {
|
|
75
|
-
return itemValue.toLowerCase()
|
|
82
|
+
return itemValue.toLowerCase() === value.toLowerCase()
|
|
76
83
|
}
|
|
77
84
|
return itemValue === value
|
|
78
85
|
})
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
// Apply search (substring match on all string fields)
|
|
89
|
+
if (search && search.trim()) {
|
|
90
|
+
const query = search.toLowerCase().trim()
|
|
91
|
+
items = items.filter(item => {
|
|
92
|
+
// Search in all string fields
|
|
93
|
+
for (const value of Object.values(item)) {
|
|
94
|
+
if (typeof value === 'string' && value.toLowerCase().includes(query)) {
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
const total = items.length
|
|
82
103
|
|
|
83
104
|
// Apply sorting
|
|
@@ -48,11 +48,16 @@ export class MemoryStorage {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* List entities with pagination/filtering
|
|
51
|
-
* @param {object} params -
|
|
51
|
+
* @param {object} params - Query parameters
|
|
52
|
+
* @param {number} [params.page=1] - Page number (1-based)
|
|
53
|
+
* @param {number} [params.page_size=20] - Items per page
|
|
54
|
+
* @param {string} [params.sort_by] - Field to sort by
|
|
55
|
+
* @param {string} [params.sort_order='asc'] - Sort order ('asc' or 'desc')
|
|
56
|
+
* @param {object} [params.filters] - Field filters { field: value }
|
|
52
57
|
* @returns {Promise<{ items: Array, total: number }>}
|
|
53
58
|
*/
|
|
54
59
|
async list(params = {}) {
|
|
55
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc',
|
|
60
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
56
61
|
|
|
57
62
|
let items = this._getAll()
|
|
58
63
|
|
|
@@ -44,15 +44,25 @@ const registry = {
|
|
|
44
44
|
* Add routes for this module
|
|
45
45
|
* @param {string} prefix - Path prefix for all routes (e.g., 'agents')
|
|
46
46
|
* @param {Array} moduleRoutes - Route definitions with relative paths
|
|
47
|
-
* @param {object} options -
|
|
47
|
+
* @param {object} options - Route options
|
|
48
|
+
* @param {string} [options.entity] - Entity name for permission checking
|
|
49
|
+
* @param {object} [options.parent] - Parent entity config for child routes
|
|
50
|
+
* @param {string} options.parent.entity - Parent entity name (e.g., 'books')
|
|
51
|
+
* @param {string} options.parent.param - Route param for parent ID (e.g., 'bookId')
|
|
52
|
+
* @param {string} options.parent.foreignKey - Foreign key field (e.g., 'book_id')
|
|
53
|
+
* @param {string} [options.parent.itemRoute] - Override parent item route (auto: parentEntity.routePrefix + '-edit')
|
|
54
|
+
* @param {string} [options.label] - Label for navlinks (defaults to entity labelPlural)
|
|
48
55
|
*/
|
|
49
56
|
addRoutes(prefix, moduleRoutes, options = {}) {
|
|
57
|
+
const { entity, parent, label } = options
|
|
50
58
|
const prefixedRoutes = moduleRoutes.map(route => ({
|
|
51
59
|
...route,
|
|
52
60
|
path: route.path ? `${prefix}/${route.path}` : prefix,
|
|
53
61
|
meta: {
|
|
54
62
|
...route.meta,
|
|
55
|
-
...(
|
|
63
|
+
...(entity && { entity }),
|
|
64
|
+
...(parent && { parent }),
|
|
65
|
+
...(label && { navLabel: label })
|
|
56
66
|
}
|
|
57
67
|
}))
|
|
58
68
|
routes.push(...prefixedRoutes)
|
|
@@ -193,6 +203,19 @@ export function getEntityConfig(name) {
|
|
|
193
203
|
return entityConfigs.get(name)
|
|
194
204
|
}
|
|
195
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Get sibling routes (routes sharing the same parent entity + param)
|
|
208
|
+
* @param {string} parentEntity - Parent entity name
|
|
209
|
+
* @param {string} parentParam - Parent route param
|
|
210
|
+
* @returns {Array} Routes with matching parent config
|
|
211
|
+
*/
|
|
212
|
+
export function getSiblingRoutes(parentEntity, parentParam) {
|
|
213
|
+
return routes.filter(route => {
|
|
214
|
+
const parent = route.meta?.parent
|
|
215
|
+
return parent?.entity === parentEntity && parent?.param === parentParam
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
196
219
|
/**
|
|
197
220
|
* Check if a route belongs to a family
|
|
198
221
|
*/
|
|
@@ -284,3 +284,46 @@
|
|
|
284
284
|
background: var(--p-primary-100);
|
|
285
285
|
color: var(--fad-primary);
|
|
286
286
|
}
|
|
287
|
+
|
|
288
|
+
/* ==========================================================================
|
|
289
|
+
* Page Navigation (breadcrumb + navlinks)
|
|
290
|
+
* ========================================================================== */
|
|
291
|
+
|
|
292
|
+
.fad-page-nav {
|
|
293
|
+
display: flex;
|
|
294
|
+
justify-content: space-between;
|
|
295
|
+
align-items: center;
|
|
296
|
+
margin-bottom: var(--fad-space-sm);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.fad-page-nav-breadcrumb {
|
|
300
|
+
flex: 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.fad-page-nav-links {
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: var(--fad-space-sm);
|
|
307
|
+
font-size: var(--fad-font-size-sm);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.fad-page-nav-separator {
|
|
311
|
+
color: var(--fad-text-muted);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.fad-page-nav-link {
|
|
315
|
+
color: var(--fad-primary);
|
|
316
|
+
text-decoration: none;
|
|
317
|
+
transition: color var(--fad-transition-fast);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.fad-page-nav-link:hover {
|
|
321
|
+
color: var(--fad-primary-hover);
|
|
322
|
+
text-decoration: underline;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.fad-page-nav-link--active {
|
|
326
|
+
color: var(--fad-text-primary);
|
|
327
|
+
font-weight: var(--fad-font-weight-medium);
|
|
328
|
+
pointer-events: none;
|
|
329
|
+
}
|