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 +26 -117
- package/package.json +1 -2
- package/src/components/SeverityTag.vue +71 -0
- package/src/components/editors/ScopeEditor.vue +26 -17
- package/src/components/index.js +1 -0
- package/src/components/layout/AppLayout.vue +29 -1
- package/src/components/layout/PageHeader.vue +71 -5
- package/src/components/layout/PageLayout.vue +4 -3
- package/src/composables/index.js +1 -0
- package/src/composables/useBareForm.js +76 -4
- package/src/composables/useBreadcrumb.js +23 -14
- package/src/composables/useForm.js +48 -2
- package/src/composables/useListPageBuilder.js +51 -74
- package/src/composables/useManager.js +20 -0
- package/src/entity/EntityManager.js +391 -9
- package/src/entity/storage/ApiStorage.js +5 -0
- package/src/entity/storage/LocalStorage.js +5 -0
- package/src/kernel/Kernel.js +25 -8
- package/src/plugin.js +3 -1
- package/src/styles/main.css +44 -0
- package/src/styles/theme/index.css +1 -1
- package/CHANGELOG.md +0 -270
package/README.md
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
# qdadm
|
|
2
2
|
|
|
3
|
-
Vue 3
|
|
3
|
+
**Vue 3 admin framework. PrimeVue. Zero boilerplate.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Full documentation: [../../README.md](../../README.md)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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'
|
|
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
|
-
##
|
|
43
|
+
## Exports
|
|
60
44
|
|
|
61
45
|
```js
|
|
62
|
-
|
|
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
|
-
|
|
49
|
+
// Composables
|
|
50
|
+
import { useForm, useBareForm, useListPageBuilder } from 'qdadm/composables'
|
|
81
51
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
58
|
+
// Utilities
|
|
59
|
+
import { formatDate, truncate } from 'qdadm/utils'
|
|
113
60
|
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
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:
|
|
19
|
+
default: null
|
|
20
20
|
},
|
|
21
21
|
scopePrefix: {
|
|
22
22
|
type: String,
|
|
23
|
-
default:
|
|
23
|
+
default: null
|
|
24
24
|
},
|
|
25
25
|
// Default resources/actions if API not available
|
|
26
26
|
defaultResources: {
|
|
27
27
|
type: Array,
|
|
28
|
-
default:
|
|
28
|
+
default: null
|
|
29
29
|
},
|
|
30
30
|
defaultActions: {
|
|
31
31
|
type: Array,
|
|
32
|
-
default:
|
|
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(`^${
|
|
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 `${
|
|
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: [...
|
|
158
|
-
actions: [...
|
|
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',
|
|
172
|
+
const data = await api.request('GET', config.value.endpoint)
|
|
164
173
|
scopeDefinition.value = {
|
|
165
|
-
resources: data.resources || [...
|
|
166
|
-
actions: data.actions || [...
|
|
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: [...
|
|
172
|
-
actions: [...
|
|
180
|
+
resources: [...config.value.resources],
|
|
181
|
+
actions: [...config.value.actions],
|
|
173
182
|
}
|
|
174
183
|
} finally {
|
|
175
184
|
loading.value = false
|
package/src/components/index.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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">
|
|
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,
|
|
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>
|
package/src/composables/index.js
CHANGED