qdadm 0.14.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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 (auto-generated, can be disabled via features.breadcrumb: false) -->
207
- <Breadcrumb v-if="showBreadcrumb" :model="breadcrumbItems" class="layout-breadcrumb">
208
- <template #item="{ item }">
209
- <RouterLink v-if="item.to" :to="item.to" class="breadcrumb-link">
210
- <i v-if="item.icon" :class="item.icon"></i>
211
- <span>{{ item.label }}</span>
212
- </RouterLink>
213
- <span v-else class="breadcrumb-current">
214
- <i v-if="item.icon" :class="item.icon"></i>
215
- <span>{{ item.label }}</span>
216
- </span>
217
- </template>
218
- </Breadcrumb>
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 breadcrumb, title and actions
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: When features.breadcrumb is enabled in AppLayout, the layout handles
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
- * Use this for custom pages that don't follow the standard list pattern.
12
- * For CRUD list pages, use ListPage instead.
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: UnsavedChangesDialog is rendered automatically by AppLayout
15
- * via provide/inject from useBareForm/useForm.
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
- <PageHeader :title="title" :title-parts="titleParts" :breadcrumb="props.breadcrumb || breadcrumbItems">
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
- <PageHeader :title="title" :subtitle="subtitle" :breadcrumb="breadcrumb">
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 - { page, page_size, filters, sort_by, sort_order }
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, ...filters } = params
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 - { page, page_size, sort_by, sort_order, ...filters }
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', ...filters } = params
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().includes(value.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 - { page, page_size, sort_by, sort_order, ...filters }
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', ...filters } = params
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 - { entity?: string } - Entity name for permission checking
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
- ...(options.entity && { entity: options.entity })
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
+ }