qdadm 0.38.1 → 0.40.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/README.md CHANGED
@@ -26,6 +26,10 @@ import { MockApiStorage, ApiStorage, SdkStorage } from 'qdadm'
26
26
  // Auth
27
27
  import { SessionAuthAdapter, LocalStorageSessionAuthAdapter } from 'qdadm'
28
28
 
29
+ // Security (permissions, roles)
30
+ import { SecurityChecker, PermissionMatcher, PermissionRegistry } from 'qdadm/security'
31
+ import { PersistableRoleGranterAdapter, createLocalStorageRoleGranter } from 'qdadm/security'
32
+
29
33
  // Composables
30
34
  import { useForm, useBareForm, useListPageBuilder } from 'qdadm/composables'
31
35
 
@@ -39,6 +43,33 @@ import { KernelContext } from 'qdadm/module'
39
43
  import 'qdadm/styles'
40
44
  ```
41
45
 
46
+ ## ctx.crud() - Route Helper
47
+
48
+ Register CRUD routes + navigation in one call:
49
+
50
+ ```js
51
+ class BooksModule extends Module {
52
+ async connect(ctx) {
53
+ ctx.entity('books', { ... })
54
+
55
+ // Full CRUD with single form
56
+ ctx.crud('books', {
57
+ list: () => import('./pages/BookList.vue'),
58
+ form: () => import('./pages/BookForm.vue') // create + edit
59
+ }, {
60
+ nav: { section: 'Library', icon: 'pi pi-book' }
61
+ })
62
+
63
+ // List only (read-only entity)
64
+ ctx.crud('settings', {
65
+ list: () => import('./pages/SettingsPage.vue')
66
+ }, { nav: { section: 'Config', icon: 'pi pi-cog' } })
67
+ }
68
+ }
69
+ ```
70
+
71
+ Auto-generates: routes, route family, nav item. Use `ctx.routes()` for custom pages.
72
+
42
73
  ## SdkStorage
43
74
 
44
75
  Adapter for generated SDK clients (hey-api, openapi-generator, etc.):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -61,3 +61,4 @@ export { default as SeverityTag } from './SeverityTag.vue'
61
61
 
62
62
  // Pages
63
63
  export { default as LoginPage } from './pages/LoginPage.vue'
64
+ export { default as NotFoundPage } from './pages/NotFoundPage.vue'
@@ -0,0 +1,94 @@
1
+ <script setup>
2
+ /**
3
+ * NotFoundPage - Default 404 page
4
+ *
5
+ * Simple page with link to home. Can be replaced via pages.notFound option.
6
+ */
7
+ import { computed } from 'vue'
8
+ import { useRouter } from 'vue-router'
9
+
10
+ const router = useRouter()
11
+
12
+ const homeRoute = computed(() => {
13
+ // Find home route (first route or named 'home'/'dashboard')
14
+ const routes = router.getRoutes()
15
+ const home = routes.find(r => r.name === 'home' || r.name === 'dashboard')
16
+ return home?.name || '/'
17
+ })
18
+
19
+ function goHome() {
20
+ if (typeof homeRoute.value === 'string' && homeRoute.value.startsWith('/')) {
21
+ router.push(homeRoute.value)
22
+ } else {
23
+ router.push({ name: homeRoute.value })
24
+ }
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="not-found-page">
30
+ <div class="not-found-content">
31
+ <div class="not-found-code">404</div>
32
+ <h1>Page not found</h1>
33
+ <p>The page you're looking for doesn't exist or has been moved.</p>
34
+ <button class="home-link" @click="goHome">
35
+ <i class="pi pi-home"></i>
36
+ Back to Home
37
+ </button>
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <style scoped>
43
+ .not-found-page {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ min-height: 100vh;
48
+ background: var(--p-surface-ground, #f8fafc);
49
+ }
50
+
51
+ .not-found-content {
52
+ text-align: center;
53
+ padding: 2rem;
54
+ }
55
+
56
+ .not-found-code {
57
+ font-size: 8rem;
58
+ font-weight: 700;
59
+ color: var(--p-primary-color, #3b82f6);
60
+ line-height: 1;
61
+ margin-bottom: 1rem;
62
+ opacity: 0.8;
63
+ }
64
+
65
+ h1 {
66
+ font-size: 1.5rem;
67
+ font-weight: 600;
68
+ color: var(--p-text-color, #1e293b);
69
+ margin: 0 0 0.5rem 0;
70
+ }
71
+
72
+ p {
73
+ color: var(--p-text-muted-color, #64748b);
74
+ margin: 0 0 2rem 0;
75
+ }
76
+
77
+ .home-link {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 0.5rem;
81
+ padding: 0.75rem 1.5rem;
82
+ background: var(--p-primary-color, #3b82f6);
83
+ color: white;
84
+ border: none;
85
+ border-radius: 0.5rem;
86
+ font-size: 1rem;
87
+ cursor: pointer;
88
+ transition: background 0.2s;
89
+ }
90
+
91
+ .home-link:hover {
92
+ background: var(--p-primary-600, #2563eb);
93
+ }
94
+ </style>
@@ -4,6 +4,7 @@
4
4
 
5
5
  export { useBareForm } from './useBareForm'
6
6
  export { useBreadcrumb } from './useBreadcrumb'
7
+ export { useSemanticBreadcrumb, computeSemanticBreadcrumb } from './useSemanticBreadcrumb'
7
8
  export { useDirtyState } from './useDirtyState'
8
9
  export { useForm } from './useForm'
9
10
  export { useFormPageBuilder } from './useFormPageBuilder'
@@ -1,99 +1,75 @@
1
1
  import { computed, inject } from 'vue'
2
- import { useRoute, useRouter } from 'vue-router'
2
+ import { useRouter } from 'vue-router'
3
+ import { useSemanticBreadcrumb } from './useSemanticBreadcrumb'
3
4
 
4
5
  /**
5
- * useBreadcrumb - Auto-generate breadcrumb from route hierarchy
6
+ * useBreadcrumb - Generate display-ready breadcrumb from semantic breadcrumb
6
7
  *
7
- * Generates breadcrumb items automatically from:
8
- * 1. route.meta.breadcrumb if defined (manual override)
9
- * 2. Route path segments with smart label generation
8
+ * Uses useSemanticBreadcrumb for the semantic model, then applies adapters
9
+ * to resolve labels, paths, and icons for display.
10
10
  *
11
- * Usage:
12
- * const { breadcrumbItems } = useBreadcrumb()
11
+ * @param {object} options - Options
12
+ * @param {object} [options.entity] - Current entity data for dynamic labels
13
+ * @param {Function} [options.getEntityLabel] - Custom label resolver for entities
14
+ * @param {object} [options.labelMap] - Custom label mappings
15
+ * @param {object} [options.iconMap] - Custom icon mappings
13
16
  *
14
- * // Or with entity data for dynamic labels
15
- * const { breadcrumbItems } = useBreadcrumb({ entity: agentData })
17
+ * @example
18
+ * const { breadcrumbItems, semanticBreadcrumb } = useBreadcrumb()
16
19
  *
17
- * // Or with custom label callback
18
- * const { breadcrumbItems } = useBreadcrumb({
19
- * entity: newsroomData,
20
- * getEntityLabel: (entity) => entity.name || entity.slug
21
- * })
22
- *
23
- * Route meta example:
24
- * {
25
- * path: 'agents/:id/edit',
26
- * name: 'agent-edit',
27
- * meta: {
28
- * breadcrumb: [
29
- * { label: 'Agents', to: { name: 'agents' } },
30
- * { label: ':name', dynamic: true } // Resolved from entity.name
31
- * ]
32
- * }
33
- * }
20
+ * // With entity data for dynamic labels
21
+ * const { breadcrumbItems } = useBreadcrumb({ entity: bookData })
34
22
  */
35
23
  export function useBreadcrumb(options = {}) {
36
- const route = useRoute()
37
24
  const router = useRouter()
38
25
  const homeRouteName = inject('qdadmHomeRoute', null)
39
26
 
40
- // Label mapping for common route names
41
- const labelMap = {
27
+ // Get semantic breadcrumb
28
+ const { breadcrumb: semanticBreadcrumb } = useSemanticBreadcrumb()
29
+
30
+ // Default label mapping
31
+ const defaultLabelMap = {
42
32
  home: 'Home',
43
33
  dashboard: 'Dashboard',
44
- users: 'Users',
45
- roles: 'Roles',
46
- apikeys: 'API Keys',
47
- newsrooms: 'Newsrooms',
48
- agents: 'Agents',
49
- events: 'Events',
50
- taxonomy: 'Taxonomy',
51
- domains: 'Domains',
52
- nexus: 'Nexus',
53
- queue: 'Queue',
54
34
  create: 'Create',
55
35
  edit: 'Edit',
56
- show: 'View'
36
+ show: 'View',
37
+ delete: 'Delete'
57
38
  }
58
39
 
59
- // Icon mapping for root sections
60
- const iconMap = {
40
+ // Default icon mapping for entities
41
+ const defaultIconMap = {
61
42
  users: 'pi pi-users',
62
43
  roles: 'pi pi-shield',
63
- apikeys: 'pi pi-key',
64
- newsrooms: 'pi pi-building',
65
- agents: 'pi pi-user',
66
- events: 'pi pi-calendar',
67
- taxonomy: 'pi pi-tags',
68
- domains: 'pi pi-globe',
69
- nexus: 'pi pi-sitemap',
70
- queue: 'pi pi-server'
44
+ books: 'pi pi-book',
45
+ genres: 'pi pi-tags',
46
+ loans: 'pi pi-exchange',
47
+ settings: 'pi pi-cog'
71
48
  }
72
49
 
50
+ // Merge with custom mappings
51
+ const labelMap = { ...defaultLabelMap, ...options.labelMap }
52
+ const iconMap = { ...defaultIconMap, ...options.iconMap }
53
+
73
54
  /**
74
55
  * Capitalize first letter
75
56
  */
76
57
  function capitalize(str) {
58
+ if (!str) return ''
77
59
  return str.charAt(0).toUpperCase() + str.slice(1)
78
60
  }
79
61
 
80
62
  /**
81
- * Get human-readable label from path segment
82
- */
83
- function getLabel(segment) {
84
- // Check labelMap first
85
- if (labelMap[segment]) return labelMap[segment]
86
- // Capitalize and replace hyphens
87
- return capitalize(segment.replace(/-/g, ' '))
88
- }
89
-
90
- /**
91
- * Resolve dynamic label from entity data
63
+ * Get route name for entity
92
64
  */
93
- function resolveDynamicLabel(label, entity) {
94
- if (!label.startsWith(':')) return label
95
- const field = label.slice(1) // Remove ':'
96
- return entity?.[field] || label
65
+ function getEntityRouteName(entity, kind) {
66
+ // Convention: entity-list -> 'book', entity-edit -> 'book-edit'
67
+ const singular = entity.endsWith('s') ? entity.slice(0, -1) : entity
68
+ if (kind === 'entity-list') return singular
69
+ if (kind === 'entity-show') return `${singular}-show`
70
+ if (kind === 'entity-edit') return `${singular}-edit`
71
+ if (kind === 'entity-create') return `${singular}-create`
72
+ return singular
97
73
  }
98
74
 
99
75
  /**
@@ -104,131 +80,114 @@ export function useBreadcrumb(options = {}) {
104
80
  }
105
81
 
106
82
  /**
107
- * Get home breadcrumb item from configured homeRoute
83
+ * Resolve semantic item to display item
84
+ * @param {object} item - Semantic breadcrumb item
85
+ * @param {number} index - Index in the breadcrumb array
86
+ * @param {object} entity - Current entity data (for labels)
87
+ * @param {boolean} isLast - Whether this is the last item (no link)
108
88
  */
109
- function getHomeItem() {
110
- if (!homeRouteName || !routeExists(homeRouteName)) {
111
- return null
89
+ function resolveItem(item, index, entity, isLast) {
90
+ const displayItem = {
91
+ label: '',
92
+ to: null,
93
+ icon: null
112
94
  }
113
- // Use label from labelMap or capitalize route name
114
- const label = labelMap[homeRouteName] || capitalize(homeRouteName)
115
- return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
116
- }
117
95
 
118
- /**
119
- * Build breadcrumb from route.meta.breadcrumb (manual)
120
- */
121
- function buildFromMeta(metaBreadcrumb, entity) {
122
- const items = []
123
- const home = getHomeItem()
124
- if (home) items.push(home)
125
-
126
- for (const item of metaBreadcrumb) {
127
- const resolved = {
128
- label: item.dynamic ? resolveDynamicLabel(item.label, entity) : item.label,
129
- to: item.to || null,
130
- icon: item.icon || null
96
+ if (item.kind === 'route') {
97
+ // Generic route
98
+ const routeName = item.route
99
+ displayItem.label = item.label || labelMap[routeName] || capitalize(routeName)
100
+ displayItem.to = isLast ? null : (routeExists(routeName) ? { name: routeName } : null)
101
+ if (routeName === 'home') {
102
+ displayItem.icon = 'pi pi-home'
103
+ }
104
+ } else if (item.kind.startsWith('entity-')) {
105
+ // Entity-related item
106
+ const entityName = item.entity
107
+
108
+ if (item.kind === 'entity-list') {
109
+ // Entity list - use plural label
110
+ displayItem.label = labelMap[entityName] || capitalize(entityName)
111
+ displayItem.icon = index === 1 ? iconMap[entityName] : null
112
+ const routeName = getEntityRouteName(entityName, item.kind)
113
+ displayItem.to = isLast ? null : (routeExists(routeName) ? { name: routeName } : null)
114
+ } else if (item.id) {
115
+ // Entity instance (show/edit/delete)
116
+ // Try to get label from entity data or use ID
117
+ if (entity && options.getEntityLabel) {
118
+ displayItem.label = options.getEntityLabel(entity)
119
+ } else if (entity?.name) {
120
+ displayItem.label = entity.name
121
+ } else if (entity?.title) {
122
+ displayItem.label = entity.title
123
+ } else {
124
+ displayItem.label = `#${item.id}`
125
+ }
126
+
127
+ // Last item = no link. Otherwise link to show page if exists
128
+ if (!isLast && (item.kind === 'entity-edit' || item.kind === 'entity-delete')) {
129
+ const showRouteName = getEntityRouteName(entityName, 'entity-show')
130
+ if (routeExists(showRouteName)) {
131
+ displayItem.to = { name: showRouteName, params: { id: item.id } }
132
+ }
133
+ }
134
+ } else if (item.kind === 'entity-create') {
135
+ // Create page - label as action
136
+ displayItem.label = labelMap.create || 'Create'
131
137
  }
132
- items.push(resolved)
133
138
  }
134
139
 
135
- return items
140
+ return displayItem
136
141
  }
137
142
 
138
- // Action segments that should be excluded from breadcrumb
139
- const actionSegments = ['edit', 'create', 'show', 'view', 'new', 'delete']
140
-
141
143
  /**
142
- * Build breadcrumb automatically from route path
143
- *
144
- * Breadcrumb shows navigable parents only:
145
- * - Excludes action segments (edit, create, show, etc.)
146
- * - Excludes IDs
147
- * - All items have links (to navigate back to parent)
144
+ * Get home breadcrumb item
148
145
  */
149
- function buildFromPath(entity, getEntityLabel) {
150
- const items = []
151
- const home = getHomeItem()
152
- if (home) items.push(home)
153
-
154
- // Get path segments, filter empty and params
155
- const segments = route.path.split('/').filter(s => s && !s.startsWith(':'))
156
-
157
- let currentPath = ''
158
- for (let i = 0; i < segments.length; i++) {
159
- const segment = segments[i]
160
- currentPath += `/${segment}`
161
-
162
- // Skip action segments (edit, create, show, etc.)
163
- if (actionSegments.includes(segment.toLowerCase())) {
164
- continue
165
- }
166
-
167
- // Skip IDs: numeric, UUID, ULID, or any alphanumeric string > 10 chars
168
- const isId = /^\d+$/.test(segment) || // numeric
169
- segment.match(/^[0-9a-f-]{36}$/i) || // UUID
170
- segment.match(/^[0-7][0-9a-hjkmnp-tv-z]{25}$/i) || // ULID
171
- (segment.match(/^[a-z0-9]+$/i) && segment.length > 10) // Generated ID
172
- if (isId) {
173
- continue
174
- }
175
-
176
- // Get label for this segment
177
- const label = getLabel(segment)
178
-
179
- // Find matching route for this path
180
- const matchedRoute = router.getRoutes().find(r => {
181
- const routePath = r.path.replace(/:\w+/g, '[^/]+')
182
- const regex = new RegExp(`^${routePath}$`)
183
- return regex.test(currentPath)
184
- })
185
-
186
- const item = {
187
- label,
188
- to: matchedRoute ? { name: matchedRoute.name } : null,
189
- icon: i === 0 ? iconMap[segment] : null
190
- }
191
-
192
- items.push(item)
193
- }
194
-
195
- // Remove last item if it matches current route (we only show parents)
196
- if (items.length > 1) {
197
- const lastItem = items[items.length - 1]
198
- if (lastItem.to?.name === route.name) {
199
- items.pop()
200
- }
146
+ function getHomeItem() {
147
+ if (!homeRouteName || !routeExists(homeRouteName)) return null
148
+ return {
149
+ label: labelMap.home || 'Home',
150
+ to: { name: homeRouteName },
151
+ icon: 'pi pi-home'
201
152
  }
202
-
203
- return items
204
153
  }
205
154
 
206
155
  /**
207
- * Computed breadcrumb items
156
+ * Computed display-ready breadcrumb items
208
157
  */
209
158
  const breadcrumbItems = computed(() => {
210
159
  const entity = options.entity?.value || options.entity
211
- const getEntityLabel = options.getEntityLabel || null
160
+ const items = []
161
+
162
+ // Add home first (display concern, not in semantic breadcrumb)
163
+ const homeItem = getHomeItem()
164
+ if (homeItem) {
165
+ items.push(homeItem)
166
+ }
212
167
 
213
- // Use meta.breadcrumb if defined
214
- if (route.meta?.breadcrumb) {
215
- return buildFromMeta(route.meta.breadcrumb, entity)
168
+ // Resolve semantic items to display items
169
+ const semanticItems = semanticBreadcrumb.value
170
+ for (let i = 0; i < semanticItems.length; i++) {
171
+ const semanticItem = semanticItems[i]
172
+ const isLast = i === semanticItems.length - 1
173
+ const displayItem = resolveItem(semanticItem, i, entity, isLast)
174
+ items.push(displayItem)
216
175
  }
217
176
 
218
- // Auto-generate from path
219
- return buildFromPath(entity, getEntityLabel)
177
+ return items
220
178
  })
221
179
 
222
180
  /**
223
181
  * Manual override - set custom breadcrumb items
224
182
  */
225
183
  function setBreadcrumb(items) {
226
- const home = getHomeItem()
227
- return home ? [home, ...items] : items
184
+ const homeItem = { label: labelMap.home || 'Home', to: { name: homeRouteName }, icon: 'pi pi-home' }
185
+ return homeRouteName && routeExists(homeRouteName) ? [homeItem, ...items] : items
228
186
  }
229
187
 
230
188
  return {
231
189
  breadcrumbItems,
190
+ semanticBreadcrumb,
232
191
  setBreadcrumb
233
192
  }
234
193
  }
@@ -1547,6 +1547,7 @@ export function useListPageBuilder(config = {}) {
1547
1547
  selectable: hasBulkActions.value,
1548
1548
 
1549
1549
  // Pagination
1550
+ lazy: true, // Let parent handle pagination via @page events
1550
1551
  totalRecords: totalRecords.value,
1551
1552
  rows: pageSize.value,
1552
1553
  rowsPerPageOptions,