qdadm 0.13.0 → 0.14.2

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
@@ -1,14 +1,10 @@
1
1
  # qdadm
2
2
 
3
- Vue 3 framework for building admin dashboards with PrimeVue.
3
+ **Vue 3 admin framework. PrimeVue. Zero boilerplate.**
4
4
 
5
- ## Features
5
+ Full documentation: [../../README.md](../../README.md)
6
6
 
7
- - **Kernel**: All-in-one bootstrap (Vue app, router, Pinia, PrimeVue, auth guard)
8
- - **EntityManager**: CRUD operations with permission control (`canRead`/`canCreate`/`canUpdate`/`canDelete`)
9
- - **Module System**: Auto-discovery of modules with routes and navigation
10
- - **Components**: Forms, lists, dialogs, editors ready to use
11
- - **Composables**: `useForm`, `useListPageBuilder`, `useBareForm`, etc.
7
+ Changelog: [../../CHANGELOG.md](../../CHANGELOG.md)
12
8
 
13
9
  ## Installation
14
10
 
@@ -16,7 +12,7 @@ Vue 3 framework for building admin dashboards with PrimeVue.
16
12
  npm install qdadm
17
13
  ```
18
14
 
19
- ## Quick Start with Kernel
15
+ ## Quick Start
20
16
 
21
17
  ```js
22
18
  import { Kernel, EntityManager, LocalStorage } from 'qdadm'
@@ -24,135 +20,48 @@ import PrimeVue from 'primevue/config'
24
20
  import Aura from '@primeuix/themes/aura'
25
21
  import 'qdadm/styles'
26
22
 
27
- import App from './App.vue'
28
- import { authAdapter } from './adapters/authAdapter'
29
-
30
- const managers = {
31
- books: new EntityManager({
32
- name: 'books',
33
- storage: new LocalStorage({ key: 'my_books' }),
34
- fields: {
35
- title: { type: 'text', label: 'Title', required: true },
36
- author: { type: 'text', label: 'Author' }
37
- }
38
- })
39
- }
40
-
41
23
  const kernel = new Kernel({
42
24
  root: App,
43
25
  modules: import.meta.glob('./modules/*/init.js', { eager: true }),
44
- sectionOrder: ['Library'],
45
- managers,
46
- authAdapter,
47
- pages: {
48
- login: () => import('./pages/LoginPage.vue'),
49
- layout: () => import('./pages/MainLayout.vue')
26
+ managers: {
27
+ books: new EntityManager({
28
+ name: 'books',
29
+ storage: new LocalStorage({ key: 'books' }),
30
+ labelField: 'title'
31
+ })
50
32
  },
33
+ authAdapter,
34
+ pages: { login: LoginPage, layout: MainLayout },
51
35
  homeRoute: 'book',
52
- app: { name: 'My App', version: '1.0.0' },
36
+ app: { name: 'My App' },
53
37
  primevue: { plugin: PrimeVue, theme: Aura }
54
38
  })
55
39
 
56
40
  kernel.createApp().mount('#app')
57
41
  ```
58
42
 
59
- ## Manual Bootstrap (without Kernel)
43
+ ## Exports
60
44
 
61
45
  ```js
62
- import { createQdadm, initModules, getRoutes } from 'qdadm'
63
-
64
- // Init modules
65
- initModules(import.meta.glob('./modules/*/init.js', { eager: true }))
66
-
67
- // Create router with getRoutes()
68
- const router = createRouter({
69
- history: createWebHistory(),
70
- routes: [
71
- { path: '/login', component: LoginPage },
72
- { path: '/', component: Layout, children: getRoutes() }
73
- ]
74
- })
75
-
76
- // Install plugin
77
- app.use(createQdadm({ managers, authAdapter, router, toast }))
78
- ```
46
+ // Main
47
+ import { Kernel, createQdadm, EntityManager, ApiStorage, LocalStorage } from 'qdadm'
79
48
 
80
- ## Module Structure
49
+ // Composables
50
+ import { useForm, useBareForm, useListPageBuilder } from 'qdadm/composables'
81
51
 
82
- ```
83
- modules/
84
- └── books/
85
- ├── init.js # Route & nav registration
86
- └── pages/
87
- ├── BookList.vue
88
- └── BookForm.vue
89
- ```
52
+ // Components
53
+ import { ListPage, PageLayout, FormField, FormActions } from 'qdadm/components'
90
54
 
91
- **init.js:**
92
- ```js
93
- export function init(registry) {
94
- registry.addRoutes('books', [
95
- { path: '', name: 'book', component: () => import('./pages/BookList.vue') },
96
- { path: 'create', name: 'book-create', component: () => import('./pages/BookForm.vue') },
97
- { path: ':id/edit', name: 'book-edit', component: () => import('./pages/BookForm.vue') }
98
- ], { entity: 'books' })
99
-
100
- registry.addNavItem({
101
- section: 'Library',
102
- route: 'book',
103
- icon: 'pi pi-book',
104
- label: 'Books',
105
- entity: 'books'
106
- })
107
-
108
- registry.addRouteFamily('book', ['book-'])
109
- }
110
- ```
55
+ // Module system
56
+ import { initModules, getRoutes, setSectionOrder } from 'qdadm/module'
111
57
 
112
- ## EntityManager Permissions
58
+ // Utilities
59
+ import { formatDate, truncate } from 'qdadm/utils'
113
60
 
114
- ```js
115
- class UsersManager extends EntityManager {
116
- canRead() {
117
- return authAdapter.getUser()?.role === 'admin'
118
- }
119
- canCreate() {
120
- return authAdapter.getUser()?.role === 'admin'
121
- }
122
- canUpdate(entity) {
123
- return authAdapter.getUser()?.role === 'admin'
124
- }
125
- canDelete(entity) {
126
- return authAdapter.getUser()?.role === 'admin'
127
- }
128
- }
61
+ // Styles
62
+ import 'qdadm/styles'
129
63
  ```
130
64
 
131
- When `canRead()` returns false:
132
- - Navigation items are hidden
133
- - Routes redirect to `/`
134
-
135
- ## Components
136
-
137
- | Category | Components |
138
- |----------|------------|
139
- | Layout | `AppLayout`, `PageLayout`, `PageHeader`, `Breadcrumb` |
140
- | Forms | `FormField`, `FormActions`, `FormTabs`, `FormTab` |
141
- | Lists | `ListPage`, `ActionButtons`, `FilterBar` |
142
- | Editors | `JsonViewer`, `KeyValueEditor`, `VanillaJsonEditor` |
143
- | Dialogs | `SimpleDialog`, `MultiStepDialog`, `UnsavedChangesDialog` |
144
- | Display | `CardsGrid`, `CopyableId`, `EmptyState` |
145
-
146
- ## Composables
147
-
148
- | Composable | Description |
149
- |------------|-------------|
150
- | `useForm` | Form with validation, dirty state, navigation guard |
151
- | `useBareForm` | Lightweight form without routing |
152
- | `useListPageBuilder` | Paginated list with filters and actions |
153
- | `useTabSync` | Sync tabs with URL query params |
154
- | `useBreadcrumb` | Dynamic breadcrumb from route |
155
-
156
65
  ## Peer Dependencies
157
66
 
158
67
  - vue ^3.3.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.13.0",
3
+ "version": "0.14.2",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -25,7 +25,6 @@
25
25
  "files": [
26
26
  "src",
27
27
  "README.md",
28
- "CHANGELOG.md",
29
28
  "LICENSE"
30
29
  ],
31
30
  "peerDependencies": {
@@ -0,0 +1,71 @@
1
+ <script setup>
2
+ /**
3
+ * SeverityTag - Auto-discovers severity from EntityManager
4
+ *
5
+ * Entity can be specified explicitly or auto-discovered from list context.
6
+ *
7
+ * @example
8
+ * // Explicit entity
9
+ * <SeverityTag entity="jobs" field="status" :value="job.status" />
10
+ *
11
+ * // Auto-discover from list context (inside ListPage)
12
+ * <SeverityTag field="status" :value="data.status" />
13
+ *
14
+ * // With custom label
15
+ * <SeverityTag entity="stories" field="status" :value="story.status" label="Published" />
16
+ */
17
+ import { computed, inject } from 'vue'
18
+ import Tag from 'primevue/tag'
19
+
20
+ const props = defineProps({
21
+ // Entity name (e.g., 'jobs', 'stories') - optional if inside ListPage context
22
+ entity: {
23
+ type: String,
24
+ default: null
25
+ },
26
+ // Field name for severity lookup (e.g., 'status')
27
+ field: {
28
+ type: String,
29
+ required: true
30
+ },
31
+ // Field value
32
+ value: {
33
+ type: [String, Number, Boolean],
34
+ default: null
35
+ },
36
+ // Optional custom label (defaults to value)
37
+ label: {
38
+ type: String,
39
+ default: null
40
+ },
41
+ // Default severity if no mapping found
42
+ defaultSeverity: {
43
+ type: String,
44
+ default: 'secondary'
45
+ }
46
+ })
47
+
48
+ const orchestrator = inject('qdadmOrchestrator')
49
+ // Auto-discover entity from page context (provided by useListPageBuilder, useBareForm, etc.)
50
+ const mainEntity = inject('mainEntity', null)
51
+
52
+ const resolvedEntity = computed(() => props.entity || mainEntity)
53
+
54
+ const manager = computed(() => {
55
+ if (!resolvedEntity.value) return null
56
+ return orchestrator?.get(resolvedEntity.value)
57
+ })
58
+
59
+ const severity = computed(() => {
60
+ if (!manager.value) return props.defaultSeverity
61
+ return manager.value.getSeverity(props.field, props.value, props.defaultSeverity)
62
+ })
63
+
64
+ const displayLabel = computed(() => {
65
+ return props.label ?? props.value
66
+ })
67
+ </script>
68
+
69
+ <template>
70
+ <Tag :value="displayLabel" :severity="severity" />
71
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, onMounted, watch, inject } from 'vue'
2
+ import { ref, computed, onMounted, watch, inject } from 'vue'
3
3
  import AutoComplete from 'primevue/autocomplete'
4
4
  import Select from 'primevue/select'
5
5
  import Button from 'primevue/button'
@@ -13,29 +13,40 @@ const props = defineProps({
13
13
  type: Boolean,
14
14
  default: false
15
15
  },
16
- // Scope configuration
16
+ // Scope configuration (can be overridden globally via provide('scopeConfig', {...}))
17
17
  scopeEndpoint: {
18
18
  type: String,
19
- default: '/reference/scopes' // Endpoint to load scope definition from API
19
+ default: null
20
20
  },
21
21
  scopePrefix: {
22
22
  type: String,
23
- default: 'faketual' // Prefix for scope strings (e.g., "faketual.resource:action")
23
+ default: null
24
24
  },
25
25
  // Default resources/actions if API not available
26
26
  defaultResources: {
27
27
  type: Array,
28
- default: () => ['api', 'users', 'roles', 'apikeys']
28
+ default: null
29
29
  },
30
30
  defaultActions: {
31
31
  type: Array,
32
- default: () => ['read', 'write', 'grant']
32
+ default: null
33
33
  }
34
34
  })
35
35
 
36
36
  // Get API adapter (optional)
37
37
  const api = inject('apiAdapter', null)
38
38
 
39
+ // Get global scope config (optional) - allows app-level configuration
40
+ const globalScopeConfig = inject('scopeConfig', {})
41
+
42
+ // Computed config with priority: props > globalConfig > defaults
43
+ const config = computed(() => ({
44
+ endpoint: props.scopeEndpoint ?? globalScopeConfig.endpoint ?? '/reference/scopes',
45
+ prefix: props.scopePrefix ?? globalScopeConfig.prefix ?? 'app',
46
+ resources: props.defaultResources ?? globalScopeConfig.resources ?? ['api', 'users', 'roles', 'apikeys'],
47
+ actions: props.defaultActions ?? globalScopeConfig.actions ?? ['read', 'write', 'grant']
48
+ }))
49
+
39
50
  const emit = defineEmits(['update:modelValue'])
40
51
 
41
52
  // Scope structure from API
@@ -57,8 +68,6 @@ const allResources = computed(() => ['*', ...scopeDefinition.value.resources])
57
68
  // All actions with access prefix
58
69
  const allActions = computed(() => ['access', ...scopeDefinition.value.actions])
59
70
 
60
- import { computed } from 'vue'
61
-
62
71
  /**
63
72
  * Search resources for autocomplete
64
73
  */
@@ -79,7 +88,7 @@ function searchResources(event) {
79
88
  function parseScope(scope) {
80
89
  if (!scope) return { resource: '', action: '' }
81
90
  // prefix.resource:action
82
- const regex = new RegExp(`^${props.scopePrefix}\\.([^:]+):(.+)$`)
91
+ const regex = new RegExp(`^${config.value.prefix}\\.([^:]+):(.+)$`)
83
92
  const match = scope.match(regex)
84
93
  if (match) {
85
94
  return { resource: match[1], action: match[2] }
@@ -92,7 +101,7 @@ function parseScope(scope) {
92
101
  */
93
102
  function buildScope(resource, action) {
94
103
  if (!resource || !action) return ''
95
- return `${props.scopePrefix}.${resource}:${action}`
104
+ return `${config.value.prefix}.${resource}:${action}`
96
105
  }
97
106
 
98
107
  // Initialize from modelValue
@@ -154,22 +163,22 @@ async function loadScopeDefinition() {
154
163
  if (!api) {
155
164
  // No API adapter, use defaults
156
165
  scopeDefinition.value = {
157
- resources: [...props.defaultResources],
158
- actions: [...props.defaultActions],
166
+ resources: [...config.value.resources],
167
+ actions: [...config.value.actions],
159
168
  }
160
169
  return
161
170
  }
162
171
 
163
- const data = await api.request('GET', props.scopeEndpoint)
172
+ const data = await api.request('GET', config.value.endpoint)
164
173
  scopeDefinition.value = {
165
- resources: data.resources || [...props.defaultResources],
166
- actions: data.actions || [...props.defaultActions],
174
+ resources: data.resources || [...config.value.resources],
175
+ actions: data.actions || [...config.value.actions],
167
176
  }
168
177
  } catch (error) {
169
178
  console.error('[ScopeEditor] Failed to load scope definition:', error)
170
179
  scopeDefinition.value = {
171
- resources: [...props.defaultResources],
172
- actions: [...props.defaultActions],
180
+ resources: [...config.value.resources],
181
+ actions: [...config.value.actions],
173
182
  }
174
183
  } finally {
175
184
  loading.value = false
@@ -42,3 +42,4 @@ export { default as CopyableId } from './display/CopyableId.vue'
42
42
  export { default as EmptyState } from './display/EmptyState.vue'
43
43
  export { default as IntensityBar } from './display/IntensityBar.vue'
44
44
  export { default as BoolCell } from './BoolCell.vue'
45
+ export { default as SeverityTag } from './SeverityTag.vue'
@@ -18,12 +18,14 @@ import { useNavigation } from '../../composables/useNavigation'
18
18
  import { useApp } from '../../composables/useApp'
19
19
  import { useAuth } from '../../composables/useAuth'
20
20
  import { useGuardDialog } from '../../composables/useGuardStore'
21
+ import { useBreadcrumb } from '../../composables/useBreadcrumb'
21
22
  import Button from 'primevue/button'
23
+ import Breadcrumb from 'primevue/breadcrumb'
22
24
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
23
25
  import qdadmLogo from '../../assets/logo.svg'
24
26
  import { version as qdadmVersion } from '../../../package.json'
25
27
 
26
- const features = inject('qdadmFeatures', { poweredBy: true })
28
+ const features = inject('qdadmFeatures', { poweredBy: true, breadcrumb: true })
27
29
 
28
30
  // Guard dialog from shared store (registered by useBareForm/useForm when a form is active)
29
31
  const guardDialog = useGuardDialog()
@@ -122,6 +124,16 @@ function handleLogout() {
122
124
  // Check if slot content is provided
123
125
  const slots = useSlots()
124
126
  const hasSlotContent = computed(() => !!slots.default)
127
+
128
+ // Breadcrumb (auto-generated from route)
129
+ const { breadcrumbItems } = useBreadcrumb()
130
+ // Show breadcrumb if enabled, has items, and not on home page
131
+ const showBreadcrumb = computed(() => {
132
+ if (!features.breadcrumb || breadcrumbItems.value.length === 0) return false
133
+ // Don't show on home page (just "Dashboard" with no parents)
134
+ if (route.name === 'dashboard') return false
135
+ return true
136
+ })
125
137
  </script>
126
138
 
127
139
  <template>
@@ -191,6 +203,20 @@ const hasSlotContent = computed(() => !!slots.default)
191
203
 
192
204
  <!-- Main content -->
193
205
  <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>
219
+
194
220
  <div class="page-content">
195
221
  <!-- Use slot if provided, otherwise RouterView for nested routes -->
196
222
  <slot v-if="hasSlotContent" />
@@ -390,6 +416,8 @@ const hasSlotContent = computed(() => !!slots.default)
390
416
  overflow-y: auto;
391
417
  }
392
418
 
419
+ /* Breadcrumb styles are in main.css */
420
+
393
421
  /* Dark mode support */
394
422
  .dark-mode .sidebar {
395
423
  background: var(--p-surface-900);
@@ -3,19 +3,39 @@
3
3
  * PageHeader - Reusable page header with breadcrumb, title and actions
4
4
  *
5
5
  * Props:
6
- * - title: Page title (required)
6
+ * - title: Page title (simple string) OR
7
+ * - titleParts: { action, entityName, entityLabel } for decorated rendering
7
8
  * - breadcrumb: Array of { label, to?, icon? } - optional breadcrumb items
8
9
  *
10
+ * Title rendering:
11
+ * - Simple: "Edit Agent" → <h1>Edit Agent</h1>
12
+ * - Decorated: { action: 'Edit', entityName: 'Agent', entityLabel: 'David' }
13
+ * → <h1>Edit Agent: <span class="entity-label">David</span></h1>
14
+ *
9
15
  * Slots:
10
16
  * - subtitle: Optional content next to title
11
17
  * - actions: Action buttons on the right
18
+ *
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.
12
22
  */
23
+ import { computed, inject } from 'vue'
13
24
  import Breadcrumb from './Breadcrumb.vue'
14
25
 
15
- defineProps({
26
+ const features = inject('qdadmFeatures', { breadcrumb: false })
27
+
28
+ // Auto-injected title from useForm (if available)
29
+ const injectedTitleParts = inject('qdadmPageTitleParts', null)
30
+
31
+ const props = defineProps({
16
32
  title: {
17
33
  type: String,
18
- required: true
34
+ default: null
35
+ },
36
+ titleParts: {
37
+ type: Object,
38
+ default: null
19
39
  },
20
40
  subtitle: {
21
41
  type: String,
@@ -26,16 +46,42 @@ defineProps({
26
46
  default: null
27
47
  }
28
48
  })
49
+
50
+ // Use props first, then injected, then fallback
51
+ const effectiveTitleParts = computed(() => {
52
+ return props.titleParts || injectedTitleParts?.value || null
53
+ })
54
+
55
+ // Compute title display
56
+ const hasDecoratedTitle = computed(() => {
57
+ return effectiveTitleParts.value?.entityLabel
58
+ })
59
+
60
+ const titleBase = computed(() => {
61
+ if (effectiveTitleParts.value) {
62
+ return `${effectiveTitleParts.value.action} ${effectiveTitleParts.value.entityName}`
63
+ }
64
+ return props.title
65
+ })
66
+
67
+ // Only show PageHeader breadcrumb if global breadcrumb feature is disabled
68
+ const showBreadcrumb = computed(() => {
69
+ return props.breadcrumb?.length && !features.breadcrumb
70
+ })
29
71
  </script>
30
72
 
31
73
  <template>
32
74
  <div class="page-header">
33
75
  <div class="page-header-content">
34
- <Breadcrumb v-if="breadcrumb?.length" :items="breadcrumb" />
76
+ <Breadcrumb v-if="showBreadcrumb" :items="breadcrumb" />
35
77
  <div class="page-header-row">
36
78
  <div class="page-header-left">
37
79
  <div>
38
- <h1 class="page-title">{{ title }}</h1>
80
+ <h1 class="page-title">
81
+ <template v-if="hasDecoratedTitle"><span class="entity-label">{{ effectiveTitleParts.entityLabel }}</span></template>
82
+ <span v-if="effectiveTitleParts" class="action-badge">{{ effectiveTitleParts.action }} {{ effectiveTitleParts.entityName }}</span>
83
+ <template v-if="!effectiveTitleParts">{{ title }}</template>
84
+ </h1>
39
85
  <p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
40
86
  </div>
41
87
  <slot name="subtitle" ></slot>
@@ -72,4 +118,24 @@ defineProps({
72
118
  font-size: 0.875rem;
73
119
  color: var(--p-text-secondary);
74
120
  }
121
+
122
+ /* Action badge (Edit User / Create Role) - small grey badge */
123
+ .action-badge {
124
+ display: inline-block;
125
+ font-size: 0.5em;
126
+ font-weight: 600;
127
+ background: var(--p-surface-200);
128
+ color: var(--p-surface-600);
129
+ padding: 0.25em 0.6em;
130
+ border-radius: 4px;
131
+ margin-left: 0.5em;
132
+ vertical-align: middle;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.03em;
135
+ }
136
+
137
+ /* Entity label in title - main focus */
138
+ .entity-label {
139
+ font-weight: 600;
140
+ }
75
141
  </style>
@@ -21,8 +21,9 @@ import Button from 'primevue/button'
21
21
  import { useBreadcrumb } from '../../composables/useBreadcrumb'
22
22
 
23
23
  const props = defineProps({
24
- // Header
25
- title: { type: String, required: true },
24
+ // Header - use title OR titleParts (for decorated entity label)
25
+ title: { type: String, default: null },
26
+ titleParts: { type: Object, default: null }, // { action, entityName, entityLabel }
26
27
  subtitle: { type: String, default: null },
27
28
  headerActions: { type: Array, default: () => [] },
28
29
  breadcrumb: { type: Array, default: null }, // Override auto breadcrumb
@@ -52,7 +53,7 @@ function resolveLabel(label) {
52
53
 
53
54
  <template>
54
55
  <div>
55
- <PageHeader :title="title" :breadcrumb="props.breadcrumb || breadcrumbItems">
56
+ <PageHeader :title="title" :title-parts="titleParts" :breadcrumb="props.breadcrumb || breadcrumbItems">
56
57
  <template #subtitle>
57
58
  <slot name="subtitle">
58
59
  <span v-if="subtitle" class="page-subtitle">{{ subtitle }}</span>
@@ -17,3 +17,4 @@ export { useAuth } from './useAuth'
17
17
  export { useNavigation } from './useNavigation'
18
18
  export { useStatus } from './useStatus'
19
19
  export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
20
+ export { useManager } from './useManager'