qdadm 0.13.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/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- package/src/utils/transformers.js +105 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ref, computed, provide, onUnmounted } from 'vue'
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
3
|
+
import { useToast } from 'primevue/usetoast'
|
|
4
|
+
import { useDirtyState } from './useDirtyState'
|
|
5
|
+
import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
|
|
6
|
+
import { useBreadcrumb } from './useBreadcrumb'
|
|
7
|
+
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base form composable providing common form functionality.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const {
|
|
14
|
+
* // State
|
|
15
|
+
* loading, saving, dirty, isEdit, entityId,
|
|
16
|
+
* // Dirty tracking
|
|
17
|
+
* isFieldDirty, takeSnapshot, checkDirty,
|
|
18
|
+
* // Helpers
|
|
19
|
+
* router, route, toast, cancel
|
|
20
|
+
* } = useBareForm({
|
|
21
|
+
* getState: () => ({ form: form.value }),
|
|
22
|
+
* routePrefix: 'agents', // for cancel() navigation
|
|
23
|
+
* guard: true, // enable unsaved changes modal
|
|
24
|
+
* onGuardSave: () => save() // optional save callback for guard modal
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* This composable provides:
|
|
28
|
+
* - Dirty state tracking (form-level and field-level)
|
|
29
|
+
* - isFieldDirty for child FormField components via inject
|
|
30
|
+
* - Unsaved changes guard modal
|
|
31
|
+
* - Common state refs (loading, saving)
|
|
32
|
+
* - Common computed (isEdit, entityId)
|
|
33
|
+
* - Navigation helpers (cancel)
|
|
34
|
+
* - Access to router, route, toast
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
* @param {Function} options.getState - Function returning current form state for comparison
|
|
38
|
+
* @param {string} options.routePrefix - Route name for cancel navigation (default: '')
|
|
39
|
+
* @param {boolean} options.guard - Enable unsaved changes guard (default: true)
|
|
40
|
+
* @param {Function} options.onGuardSave - Callback for save button in guard modal
|
|
41
|
+
* @param {Function} options.getId - Custom function to extract entity ID from route (optional)
|
|
42
|
+
* @param {Ref|Object} options.entity - Entity data for dynamic breadcrumb labels (optional)
|
|
43
|
+
* @param {Function} options.breadcrumbLabel - Callback (entity) => string for custom breadcrumb label (optional)
|
|
44
|
+
*/
|
|
45
|
+
export function useBareForm(options = {}) {
|
|
46
|
+
const {
|
|
47
|
+
getState,
|
|
48
|
+
routePrefix = '',
|
|
49
|
+
guard = true,
|
|
50
|
+
onGuardSave = null,
|
|
51
|
+
getId = null,
|
|
52
|
+
entity = null,
|
|
53
|
+
breadcrumbLabel = null
|
|
54
|
+
} = options
|
|
55
|
+
|
|
56
|
+
if (!getState || typeof getState !== 'function') {
|
|
57
|
+
throw new Error('useBareForm requires a getState function')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Router, route, toast - common dependencies
|
|
61
|
+
const router = useRouter()
|
|
62
|
+
const route = useRoute()
|
|
63
|
+
const toast = useToast()
|
|
64
|
+
|
|
65
|
+
// Common state
|
|
66
|
+
const loading = ref(false)
|
|
67
|
+
const saving = ref(false)
|
|
68
|
+
|
|
69
|
+
// Common computed
|
|
70
|
+
const entityId = computed(() => {
|
|
71
|
+
if (getId) return getId()
|
|
72
|
+
return route.params.id || route.params.key || null
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const isEdit = computed(() => !!entityId.value)
|
|
76
|
+
|
|
77
|
+
// Dirty state tracking
|
|
78
|
+
const {
|
|
79
|
+
dirty,
|
|
80
|
+
dirtyFields,
|
|
81
|
+
isFieldDirty,
|
|
82
|
+
takeSnapshot,
|
|
83
|
+
checkDirty,
|
|
84
|
+
reset
|
|
85
|
+
} = useDirtyState(getState)
|
|
86
|
+
|
|
87
|
+
// Provide isFieldDirty and dirtyFields for child components (FormField)
|
|
88
|
+
provide('isFieldDirty', isFieldDirty)
|
|
89
|
+
provide('dirtyFields', dirtyFields)
|
|
90
|
+
|
|
91
|
+
// Breadcrumb (auto-generated from route path, with optional entity for dynamic labels)
|
|
92
|
+
const { breadcrumbItems } = useBreadcrumb({ entity, getEntityLabel: breadcrumbLabel })
|
|
93
|
+
|
|
94
|
+
// Unsaved changes guard
|
|
95
|
+
let guardDialog = null
|
|
96
|
+
if (guard) {
|
|
97
|
+
const guardOptions = onGuardSave ? { onSave: onGuardSave } : {}
|
|
98
|
+
const guardResult = useUnsavedChangesGuard(dirty, guardOptions)
|
|
99
|
+
guardDialog = guardResult.guardDialog
|
|
100
|
+
// Register guardDialog in shared store so AppLayout can render it
|
|
101
|
+
registerGuardDialog(guardDialog)
|
|
102
|
+
onUnmounted(() => unregisterGuardDialog(guardDialog))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Navigation helper
|
|
106
|
+
function cancel() {
|
|
107
|
+
if (routePrefix) {
|
|
108
|
+
router.push({ name: routePrefix })
|
|
109
|
+
} else {
|
|
110
|
+
router.back()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
// Dependencies (for custom logic in form)
|
|
116
|
+
router,
|
|
117
|
+
route,
|
|
118
|
+
toast,
|
|
119
|
+
|
|
120
|
+
// State
|
|
121
|
+
loading,
|
|
122
|
+
saving,
|
|
123
|
+
dirty,
|
|
124
|
+
dirtyFields,
|
|
125
|
+
isEdit,
|
|
126
|
+
entityId,
|
|
127
|
+
|
|
128
|
+
// Dirty tracking
|
|
129
|
+
isFieldDirty,
|
|
130
|
+
takeSnapshot,
|
|
131
|
+
checkDirty,
|
|
132
|
+
reset,
|
|
133
|
+
|
|
134
|
+
// Helpers
|
|
135
|
+
cancel,
|
|
136
|
+
|
|
137
|
+
// Breadcrumb
|
|
138
|
+
breadcrumb: breadcrumbItems,
|
|
139
|
+
|
|
140
|
+
// Guard dialog (for UnsavedChangesDialog component)
|
|
141
|
+
guardDialog
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useBreadcrumb - Auto-generate breadcrumb from route hierarchy
|
|
6
|
+
*
|
|
7
|
+
* Generates breadcrumb items automatically from:
|
|
8
|
+
* 1. route.meta.breadcrumb if defined (manual override)
|
|
9
|
+
* 2. Route path segments with smart label generation
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const { breadcrumbItems } = useBreadcrumb()
|
|
13
|
+
*
|
|
14
|
+
* // Or with entity data for dynamic labels
|
|
15
|
+
* const { breadcrumbItems } = useBreadcrumb({ entity: agentData })
|
|
16
|
+
*
|
|
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
|
+
* }
|
|
34
|
+
*/
|
|
35
|
+
export function useBreadcrumb(options = {}) {
|
|
36
|
+
const route = useRoute()
|
|
37
|
+
const router = useRouter()
|
|
38
|
+
|
|
39
|
+
// Label mapping for common route names
|
|
40
|
+
const labelMap = {
|
|
41
|
+
dashboard: 'Dashboard',
|
|
42
|
+
users: 'Users',
|
|
43
|
+
roles: 'Roles',
|
|
44
|
+
apikeys: 'API Keys',
|
|
45
|
+
newsrooms: 'Newsrooms',
|
|
46
|
+
agents: 'Agents',
|
|
47
|
+
events: 'Events',
|
|
48
|
+
taxonomy: 'Taxonomy',
|
|
49
|
+
domains: 'Domains',
|
|
50
|
+
nexus: 'Nexus',
|
|
51
|
+
queue: 'Queue',
|
|
52
|
+
create: 'Create',
|
|
53
|
+
edit: 'Edit',
|
|
54
|
+
show: 'View'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Icon mapping for root sections
|
|
58
|
+
const iconMap = {
|
|
59
|
+
users: 'pi pi-users',
|
|
60
|
+
roles: 'pi pi-shield',
|
|
61
|
+
apikeys: 'pi pi-key',
|
|
62
|
+
newsrooms: 'pi pi-building',
|
|
63
|
+
agents: 'pi pi-user',
|
|
64
|
+
events: 'pi pi-calendar',
|
|
65
|
+
taxonomy: 'pi pi-tags',
|
|
66
|
+
domains: 'pi pi-globe',
|
|
67
|
+
nexus: 'pi pi-sitemap',
|
|
68
|
+
queue: 'pi pi-server'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Capitalize first letter
|
|
73
|
+
*/
|
|
74
|
+
function capitalize(str) {
|
|
75
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get human-readable label from path segment
|
|
80
|
+
*/
|
|
81
|
+
function getLabel(segment) {
|
|
82
|
+
// Check labelMap first
|
|
83
|
+
if (labelMap[segment]) return labelMap[segment]
|
|
84
|
+
// Capitalize and replace hyphens
|
|
85
|
+
return capitalize(segment.replace(/-/g, ' '))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve dynamic label from entity data
|
|
90
|
+
*/
|
|
91
|
+
function resolveDynamicLabel(label, entity) {
|
|
92
|
+
if (!label.startsWith(':')) return label
|
|
93
|
+
const field = label.slice(1) // Remove ':'
|
|
94
|
+
return entity?.[field] || label
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if a route exists
|
|
99
|
+
*/
|
|
100
|
+
function routeExists(name) {
|
|
101
|
+
return router.getRoutes().some(r => r.name === name)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get home breadcrumb item (dashboard if exists, otherwise null)
|
|
106
|
+
*/
|
|
107
|
+
function getHomeItem() {
|
|
108
|
+
if (routeExists('dashboard')) {
|
|
109
|
+
return { label: 'Dashboard', to: { name: 'dashboard' }, icon: 'pi pi-home' }
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build breadcrumb from route.meta.breadcrumb (manual)
|
|
116
|
+
*/
|
|
117
|
+
function buildFromMeta(metaBreadcrumb, entity) {
|
|
118
|
+
const items = []
|
|
119
|
+
const home = getHomeItem()
|
|
120
|
+
if (home) items.push(home)
|
|
121
|
+
|
|
122
|
+
for (const item of metaBreadcrumb) {
|
|
123
|
+
const resolved = {
|
|
124
|
+
label: item.dynamic ? resolveDynamicLabel(item.label, entity) : item.label,
|
|
125
|
+
to: item.to || null,
|
|
126
|
+
icon: item.icon || null
|
|
127
|
+
}
|
|
128
|
+
items.push(resolved)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return items
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build breadcrumb automatically from route path
|
|
136
|
+
*/
|
|
137
|
+
function buildFromPath(entity, getEntityLabel) {
|
|
138
|
+
const items = []
|
|
139
|
+
const home = getHomeItem()
|
|
140
|
+
if (home) items.push(home)
|
|
141
|
+
|
|
142
|
+
// Get path segments, filter empty and params
|
|
143
|
+
const segments = route.path.split('/').filter(s => s && !s.startsWith(':'))
|
|
144
|
+
|
|
145
|
+
let currentPath = ''
|
|
146
|
+
for (let i = 0; i < segments.length; i++) {
|
|
147
|
+
const segment = segments[i]
|
|
148
|
+
currentPath += `/${segment}`
|
|
149
|
+
|
|
150
|
+
// Handle IDs: numeric, UUID, ULID, or any alphanumeric string > 10 chars - use entity label instead
|
|
151
|
+
const isId = /^\d+$/.test(segment) || // numeric
|
|
152
|
+
segment.match(/^[0-9a-f-]{36}$/i) || // UUID
|
|
153
|
+
segment.match(/^[0-7][0-9a-hjkmnp-tv-z]{25}$/i) || // ULID
|
|
154
|
+
(segment.match(/^[a-z0-9]+$/i) && segment.length > 10) // Generated ID (like LocalStorage)
|
|
155
|
+
if (isId) {
|
|
156
|
+
// If we have entity data, show its label instead of the ID
|
|
157
|
+
if (entity && getEntityLabel) {
|
|
158
|
+
const entityLabel = getEntityLabel(entity)
|
|
159
|
+
if (entityLabel) {
|
|
160
|
+
items.push({ label: entityLabel, to: null, icon: null })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Get label for this segment
|
|
167
|
+
const label = getLabel(segment)
|
|
168
|
+
|
|
169
|
+
// Find matching route for this path
|
|
170
|
+
const matchedRoute = router.getRoutes().find(r => {
|
|
171
|
+
const routePath = r.path.replace(/:\w+/g, '[^/]+')
|
|
172
|
+
const regex = new RegExp(`^${routePath}$`)
|
|
173
|
+
return regex.test(currentPath)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const item = {
|
|
177
|
+
label,
|
|
178
|
+
to: matchedRoute ? { name: matchedRoute.name } : null,
|
|
179
|
+
icon: i === 0 ? iconMap[segment] : null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Last item has no link
|
|
183
|
+
if (i === segments.length - 1) {
|
|
184
|
+
item.to = null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
items.push(item)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return items
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Computed breadcrumb items
|
|
195
|
+
*/
|
|
196
|
+
const breadcrumbItems = computed(() => {
|
|
197
|
+
const entity = options.entity?.value || options.entity
|
|
198
|
+
const getEntityLabel = options.getEntityLabel || null
|
|
199
|
+
|
|
200
|
+
// Use meta.breadcrumb if defined
|
|
201
|
+
if (route.meta?.breadcrumb) {
|
|
202
|
+
return buildFromMeta(route.meta.breadcrumb, entity)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Auto-generate from path
|
|
206
|
+
return buildFromPath(entity, getEntityLabel)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Manual override - set custom breadcrumb items
|
|
211
|
+
*/
|
|
212
|
+
function setBreadcrumb(items) {
|
|
213
|
+
const home = getHomeItem()
|
|
214
|
+
return home ? [home, ...items] : items
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
breadcrumbItems,
|
|
219
|
+
setBreadcrumb
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for tracking form dirty state
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { dirty, dirtyFields, isFieldDirty, takeSnapshot, checkDirty } = useDirtyState(() => ({
|
|
6
|
+
* form: formData.value
|
|
7
|
+
* }))
|
|
8
|
+
*
|
|
9
|
+
* // Take snapshot after loading data
|
|
10
|
+
* takeSnapshot()
|
|
11
|
+
*
|
|
12
|
+
* // Watch for changes
|
|
13
|
+
* watch(formData, checkDirty, { deep: true })
|
|
14
|
+
*
|
|
15
|
+
* // Check if specific field is dirty
|
|
16
|
+
* isFieldDirty('username') // true/false
|
|
17
|
+
*
|
|
18
|
+
* // Get all dirty field names
|
|
19
|
+
* dirtyFields.value // ['username', 'email']
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { ref, nextTick } from 'vue'
|
|
23
|
+
|
|
24
|
+
export function useDirtyState(getState) {
|
|
25
|
+
const dirty = ref(false)
|
|
26
|
+
const dirtyFields = ref([])
|
|
27
|
+
const initialSnapshot = ref(null)
|
|
28
|
+
const initialState = ref(null)
|
|
29
|
+
const ready = ref(false)
|
|
30
|
+
|
|
31
|
+
function getFormSnapshot() {
|
|
32
|
+
return JSON.stringify(getState())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function takeSnapshot() {
|
|
36
|
+
// Disable dirty checking temporarily to prevent watchers from re-setting dirty
|
|
37
|
+
ready.value = false
|
|
38
|
+
dirty.value = false
|
|
39
|
+
dirtyFields.value = []
|
|
40
|
+
|
|
41
|
+
// Use nextTick to ensure all reactive updates have settled for snapshot
|
|
42
|
+
nextTick(() => {
|
|
43
|
+
const state = getState()
|
|
44
|
+
initialSnapshot.value = JSON.stringify(state)
|
|
45
|
+
// Store raw state for field-level comparison
|
|
46
|
+
initialState.value = JSON.parse(JSON.stringify(state))
|
|
47
|
+
ready.value = true
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkDirty() {
|
|
52
|
+
// Don't check until snapshot is taken and ready
|
|
53
|
+
if (!ready.value || !initialSnapshot.value) return
|
|
54
|
+
|
|
55
|
+
const currentSnapshot = getFormSnapshot()
|
|
56
|
+
dirty.value = currentSnapshot !== initialSnapshot.value
|
|
57
|
+
|
|
58
|
+
// Calculate dirty fields
|
|
59
|
+
if (dirty.value && initialState.value) {
|
|
60
|
+
const current = getState()
|
|
61
|
+
const changed = []
|
|
62
|
+
|
|
63
|
+
// Compare form fields if state has a 'form' or 'formData' key (common patterns)
|
|
64
|
+
const initial = initialState.value.form || initialState.value.formData || initialState.value
|
|
65
|
+
const currentForm = current.form || current.formData || current
|
|
66
|
+
|
|
67
|
+
for (const key in currentForm) {
|
|
68
|
+
const initialVal = JSON.stringify(initial[key])
|
|
69
|
+
const currentVal = JSON.stringify(currentForm[key])
|
|
70
|
+
if (initialVal !== currentVal) {
|
|
71
|
+
changed.push(key)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
dirtyFields.value = changed
|
|
75
|
+
} else {
|
|
76
|
+
dirtyFields.value = []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isFieldDirty(fieldName) {
|
|
81
|
+
return dirtyFields.value.includes(fieldName)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reset() {
|
|
85
|
+
dirty.value = false
|
|
86
|
+
dirtyFields.value = []
|
|
87
|
+
initialSnapshot.value = null
|
|
88
|
+
initialState.value = null
|
|
89
|
+
ready.value = false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
dirty,
|
|
94
|
+
dirtyFields,
|
|
95
|
+
ready,
|
|
96
|
+
takeSnapshot,
|
|
97
|
+
checkDirty,
|
|
98
|
+
isFieldDirty,
|
|
99
|
+
reset
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default useDirtyState
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for generating display titles for entities
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { displayTitle, pageTitle } = useEntityTitle(entity, {
|
|
6
|
+
* type: 'Agent',
|
|
7
|
+
* nameField: 'name', // default
|
|
8
|
+
* fallbackField: 'id' // default
|
|
9
|
+
* })
|
|
10
|
+
*
|
|
11
|
+
* // In template:
|
|
12
|
+
* <h1>{{ pageTitle }}</h1> // "Edit Agent John Doe" or "Create Agent"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { computed } from 'vue'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Field priority for display title (first match wins)
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_NAME_FIELDS = ['name', 'title', 'label', 'username', 'slug']
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get display title from an entity object
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} entity - Entity object
|
|
26
|
+
* @param {Object} options - Options
|
|
27
|
+
* @param {string|string[]} options.nameField - Field(s) to use for display name
|
|
28
|
+
* @param {string} options.fallbackField - Fallback field (usually 'id')
|
|
29
|
+
* @param {number} options.maxLength - Max length before truncation (default: 50)
|
|
30
|
+
* @returns {string} Display title
|
|
31
|
+
*/
|
|
32
|
+
export function getEntityDisplayTitle(entity, options = {}) {
|
|
33
|
+
if (!entity) return ''
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
nameField = DEFAULT_NAME_FIELDS,
|
|
37
|
+
fallbackField = 'id',
|
|
38
|
+
maxLength = 50
|
|
39
|
+
} = options
|
|
40
|
+
|
|
41
|
+
// Try name fields in order
|
|
42
|
+
const fields = Array.isArray(nameField) ? nameField : [nameField]
|
|
43
|
+
for (const field of fields) {
|
|
44
|
+
const value = entity[field]
|
|
45
|
+
if (value && typeof value === 'string' && value.trim()) {
|
|
46
|
+
const title = value.trim()
|
|
47
|
+
if (title.length > maxLength) {
|
|
48
|
+
return title.slice(0, maxLength - 3) + '...'
|
|
49
|
+
}
|
|
50
|
+
return title
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback to ID (truncated)
|
|
55
|
+
const fallback = entity[fallbackField]
|
|
56
|
+
if (fallback) {
|
|
57
|
+
const id = String(fallback)
|
|
58
|
+
if (id.length > 12) {
|
|
59
|
+
return id.slice(0, 8) + '...'
|
|
60
|
+
}
|
|
61
|
+
return id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Composable for entity display titles
|
|
69
|
+
*
|
|
70
|
+
* @param {Ref<Object>} entityRef - Reactive entity reference
|
|
71
|
+
* @param {Object} options - Options
|
|
72
|
+
* @param {string} options.type - Entity type name (e.g., 'Agent', 'Newsroom')
|
|
73
|
+
* @param {string|string[]} options.nameField - Field(s) to use for display name
|
|
74
|
+
* @param {string} options.fallbackField - Fallback field (usually 'id')
|
|
75
|
+
* @param {Ref<boolean>} options.isEdit - Reactive boolean for edit mode
|
|
76
|
+
*/
|
|
77
|
+
export function useEntityTitle(entityRef, options = {}) {
|
|
78
|
+
const {
|
|
79
|
+
type = 'Entity',
|
|
80
|
+
nameField = DEFAULT_NAME_FIELDS,
|
|
81
|
+
fallbackField = 'id',
|
|
82
|
+
isEdit = null
|
|
83
|
+
} = options
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Display title for the entity (just the name/id part)
|
|
87
|
+
*/
|
|
88
|
+
const displayTitle = computed(() => {
|
|
89
|
+
return getEntityDisplayTitle(entityRef.value, { nameField, fallbackField })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Full page title (e.g., "Edit Agent John Doe" or "Create Agent")
|
|
94
|
+
*/
|
|
95
|
+
const pageTitle = computed(() => {
|
|
96
|
+
if (isEdit?.value) {
|
|
97
|
+
const title = displayTitle.value
|
|
98
|
+
return title ? `Edit ${type}: ${title}` : `Edit ${type}`
|
|
99
|
+
}
|
|
100
|
+
return `Create ${type}`
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Browser document title
|
|
105
|
+
*/
|
|
106
|
+
const documentTitle = computed(() => {
|
|
107
|
+
if (isEdit?.value) {
|
|
108
|
+
const title = displayTitle.value
|
|
109
|
+
return title ? `${title} - ${type}` : `Edit ${type}`
|
|
110
|
+
}
|
|
111
|
+
return `New ${type}`
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
displayTitle,
|
|
116
|
+
pageTitle,
|
|
117
|
+
documentTitle
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default useEntityTitle
|