qdadm 1.1.3 → 1.1.6

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": "1.1.3",
3
+ "version": "1.1.6",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -27,6 +27,11 @@ export { default as FormActions } from './forms/FormActions.vue'
27
27
  export { default as FormTabs } from './forms/FormTabs.vue'
28
28
  export { default as FormTab } from './forms/FormTab.vue'
29
29
 
30
+ // Show (read-only detail pages)
31
+ export { default as ShowPage } from './show/ShowPage.vue'
32
+ export { default as ShowField } from './show/ShowField.vue'
33
+ export { default as ShowDisplay } from './show/ShowDisplay.vue'
34
+
30
35
  // Lists
31
36
  export { default as ListPage } from './lists/ListPage.vue'
32
37
  export { default as ActionButtons } from './lists/ActionButtons.vue'
@@ -16,7 +16,7 @@
16
16
  * - showDetailsLink: Show "Details" link in navlinks (default: false)
17
17
  */
18
18
  import { computed, watch, inject, type Ref } from 'vue'
19
- import { useRoute } from 'vue-router'
19
+ import { useRoute, useRouter } from 'vue-router'
20
20
  import { getSiblingRoutes, getChildRoutes, type ModuleRoute, type ParentConfig } from '../../module/moduleRegistry'
21
21
  import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
22
22
  import type { EntityManager } from '../../entity/EntityManager'
@@ -37,6 +37,7 @@ const props = defineProps<{
37
37
  }>()
38
38
 
39
39
  const route = useRoute()
40
+ const router = useRouter()
40
41
  const { getManager } = useOrchestrator()
41
42
 
42
43
  // Parent config from route meta
@@ -44,13 +45,22 @@ const parentConfig = computed<ParentConfig | undefined>(() => route.meta?.parent
44
45
 
45
46
  /**
46
47
  * Get default item route for an entity manager
47
- * - Read-only entities: use -show suffix
48
- * - Editable entities: use -edit suffix
48
+ * Checks which routes actually exist and returns the appropriate one:
49
+ * - Prefers -edit if it exists (editable entity)
50
+ * - Falls back to -show if -edit doesn't exist
51
+ * - Returns null if neither exists
49
52
  */
50
53
  function getDefaultItemRoute(manager: EntityManager<EntityRecord> | null): string | null {
51
54
  if (!manager) return null
52
- const suffix = manager.readOnly ? '-show' : '-edit'
53
- return `${manager.routePrefix}${suffix}`
55
+
56
+ const editRoute = `${manager.routePrefix}-edit`
57
+ const showRoute = `${manager.routePrefix}-show`
58
+
59
+ // Check which routes actually exist and return the first available
60
+ if (router.hasRoute(editRoute)) return editRoute
61
+ if (router.hasRoute(showRoute)) return showRoute
62
+
63
+ return null
54
64
  }
55
65
 
56
66
  // Sibling navlinks (routes with same parent)
@@ -118,6 +118,7 @@ const props = defineProps({
118
118
  // Filters
119
119
  filters: { type: Array as PropType<FilterConfig[]>, default: () => [] },
120
120
  filterValues: { type: Object as PropType<Record<string, unknown>>, default: () => ({}) },
121
+ isFilterAtDefault: { type: Function as PropType<(name: string) => boolean>, default: () => () => true },
121
122
 
122
123
  // Row Actions
123
124
  getActions: { type: Function as unknown as PropType<((row: unknown) => ResolvedAction[]) | null>, default: null },
@@ -300,6 +301,24 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
300
301
  // event.data = row data, event.originalEvent = Event
301
302
  emit('row-click', event.data, event.originalEvent as MouseEvent)
302
303
  }
304
+
305
+ /**
306
+ * Get filter CSS class based on value and default state
307
+ * - No value (null/empty): no class
308
+ * - Value = default: 'filter-default' (blue/info)
309
+ * - Value ≠ default: 'filter-modified' (orange/warning)
310
+ */
311
+ function getFilterClass(filter: FilterConfig): Record<string, boolean> {
312
+ const value = localFilterValues.value[filter.name]
313
+ const hasValue = value != null && value !== ''
314
+ if (!hasValue) return {}
315
+
316
+ const isDefault = props.isFilterAtDefault(filter.name)
317
+ return {
318
+ 'filter-default': isDefault,
319
+ 'filter-modified': !isDefault
320
+ }
321
+ }
303
322
  </script>
304
323
 
305
324
  <template>
@@ -380,7 +399,7 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
380
399
  :dropdown="true"
381
400
  :minLength="0"
382
401
  :style="{ minWidth: filter.width || '160px' }"
383
- :class="{ 'filter-active': localFilterValues[filter.name] != null && localFilterValues[filter.name] !== '' }"
402
+ :class="getFilterClass(filter)"
384
403
  :inputClass="'filter-autocomplete-input'"
385
404
  />
386
405
  <!-- Standard select filter -->
@@ -393,7 +412,7 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
393
412
  :optionValue="filter.optionValue || 'value'"
394
413
  :placeholder="filter.placeholder"
395
414
  :style="{ minWidth: filter.width || '160px' }"
396
- :class="{ 'filter-active': localFilterValues[filter.name] != null && localFilterValues[filter.name] !== '' }"
415
+ :class="getFilterClass(filter)"
397
416
  />
398
417
  </template>
399
418
  <slot name="filters" ></slot>
@@ -0,0 +1,393 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ShowDisplay - Auto-renders the appropriate display widget based on field config
4
+ *
5
+ * Takes a field config object and renders the matching display component.
6
+ * Supports hint override but field.type takes precedence if specified.
7
+ *
8
+ * Usage:
9
+ * <ShowDisplay :field="f" :value="data[f.name]" />
10
+ *
11
+ * Display types:
12
+ * - text: simple text
13
+ * - number: formatted number
14
+ * - boolean: Yes/No or icon
15
+ * - date: formatted date
16
+ * - datetime: formatted datetime
17
+ * - select: label from options
18
+ * - email: mailto link
19
+ * - password: masked
20
+ * - textarea: multi-line text
21
+ * - reference: link to entity (requires referenceRoute)
22
+ * - image: image preview
23
+ * - url: clickable link
24
+ * - json: formatted JSON
25
+ * - currency: formatted currency
26
+ * - badge: styled tag
27
+ */
28
+ import { computed, type PropType } from 'vue'
29
+ import { RouterLink, type RouteLocationRaw } from 'vue-router'
30
+ import Tag from 'primevue/tag'
31
+
32
+ type DisplayType =
33
+ | 'text'
34
+ | 'email'
35
+ | 'password'
36
+ | 'number'
37
+ | 'textarea'
38
+ | 'select'
39
+ | 'boolean'
40
+ | 'date'
41
+ | 'datetime'
42
+ | 'reference'
43
+ | 'image'
44
+ | 'url'
45
+ | 'json'
46
+ | 'currency'
47
+ | 'badge'
48
+
49
+ type DisplayValue = string | number | boolean | Date | null | undefined | unknown
50
+
51
+ interface SelectOption {
52
+ label?: string
53
+ value?: string | number
54
+ [key: string]: unknown
55
+ }
56
+
57
+ interface FieldConfig {
58
+ name: string
59
+ type?: DisplayType
60
+ // Display options
61
+ dateFormat?: string
62
+ currencyCode?: string
63
+ locale?: string
64
+ booleanLabels?: { true: string; false: string }
65
+ // Reference options
66
+ reference?: string
67
+ referenceRoute?: string | ((value: unknown) => { name: string; params: Record<string, unknown> })
68
+ referenceLabel?: string | ((value: unknown, option?: unknown) => string)
69
+ // Select options (for label lookup)
70
+ options?: SelectOption[]
71
+ optionLabel?: string
72
+ optionValue?: string
73
+ // Badge options
74
+ severity?: string | ((value: unknown) => string)
75
+ // Image options
76
+ imageWidth?: string
77
+ imageHeight?: string
78
+ // Custom render
79
+ render?: (value: unknown) => string
80
+ }
81
+
82
+ const props = defineProps({
83
+ field: { type: Object as PropType<FieldConfig>, required: true },
84
+ value: { type: [String, Number, Boolean, Date, Object, Array] as PropType<DisplayValue>, default: null },
85
+ hint: { type: String as PropType<DisplayType | null>, default: null }
86
+ })
87
+
88
+ // Resolve display type: field.type > hint > 'text'
89
+ const displayType = computed<DisplayType>(() => (props.field?.type as DisplayType) || props.hint || 'text')
90
+
91
+ // Check if value is empty
92
+ const isEmpty = computed(() => {
93
+ return props.value === null || props.value === undefined || props.value === ''
94
+ })
95
+
96
+ // Format text value
97
+ const textValue = computed(() => {
98
+ if (isEmpty.value) return '-'
99
+ if (props.field.render) return props.field.render(props.value)
100
+ return String(props.value)
101
+ })
102
+
103
+ // Format number value
104
+ const numberValue = computed(() => {
105
+ if (isEmpty.value) return '-'
106
+ const num = Number(props.value)
107
+ if (isNaN(num)) return String(props.value)
108
+ const locale = props.field.locale || 'en-US'
109
+ return new Intl.NumberFormat(locale).format(num)
110
+ })
111
+
112
+ // Format currency value
113
+ const currencyValue = computed(() => {
114
+ if (isEmpty.value) return '-'
115
+ const num = Number(props.value)
116
+ if (isNaN(num)) return String(props.value)
117
+ const locale = props.field.locale || 'en-US'
118
+ const currency = props.field.currencyCode || 'USD'
119
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(num)
120
+ })
121
+
122
+ // Format boolean value
123
+ const booleanValue = computed(() => {
124
+ if (isEmpty.value) return '-'
125
+ const labels = props.field.booleanLabels || { true: 'Yes', false: 'No' }
126
+ return props.value ? labels.true : labels.false
127
+ })
128
+
129
+ const booleanIcon = computed(() => {
130
+ if (isEmpty.value) return 'pi-minus'
131
+ return props.value ? 'pi-check' : 'pi-times'
132
+ })
133
+
134
+ const booleanIconClass = computed(() => {
135
+ if (isEmpty.value) return 'text-muted'
136
+ return props.value ? 'text-success' : 'text-danger'
137
+ })
138
+
139
+ // Format date value
140
+ const dateValue = computed(() => {
141
+ if (isEmpty.value) return '-'
142
+ const v = props.value as unknown
143
+ const date = (typeof v === 'object' && v instanceof Date) ? v : new Date(String(v))
144
+ if (isNaN(date.getTime())) return String(props.value)
145
+ const locale = props.field.locale || 'en-US'
146
+ const format = props.field.dateFormat || 'short'
147
+ return new Intl.DateTimeFormat(locale, { dateStyle: format as 'short' | 'medium' | 'long' | 'full' }).format(date)
148
+ })
149
+
150
+ // Format datetime value
151
+ const datetimeValue = computed(() => {
152
+ if (isEmpty.value) return '-'
153
+ const v = props.value as unknown
154
+ const date = (typeof v === 'object' && v instanceof Date) ? v : new Date(String(v))
155
+ if (isNaN(date.getTime())) return String(props.value)
156
+ const locale = props.field.locale || 'en-US'
157
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short' }).format(date)
158
+ })
159
+
160
+ // Format select value (lookup label from options)
161
+ const selectValue = computed(() => {
162
+ if (isEmpty.value) return '-'
163
+ const options = props.field.options || []
164
+ const optionValue = props.field.optionValue || 'value'
165
+ const optionLabel = props.field.optionLabel || 'label'
166
+
167
+ const option = options.find((opt) => opt[optionValue] === props.value)
168
+ if (option) {
169
+ return option[optionLabel] as string
170
+ }
171
+ return String(props.value)
172
+ })
173
+
174
+ // Reference route
175
+ const referenceRoute = computed<RouteLocationRaw | null>(() => {
176
+ if (!props.field.referenceRoute || isEmpty.value) return null
177
+ if (typeof props.field.referenceRoute === 'function') {
178
+ return props.field.referenceRoute(props.value) as RouteLocationRaw
179
+ }
180
+ return { name: props.field.referenceRoute, params: { id: String(props.value) } }
181
+ })
182
+
183
+ // Reference label
184
+ const referenceLabel = computed(() => {
185
+ if (isEmpty.value) return '-'
186
+ if (typeof props.field.referenceLabel === 'function') {
187
+ return props.field.referenceLabel(props.value)
188
+ }
189
+ if (props.field.referenceLabel) {
190
+ return props.field.referenceLabel
191
+ }
192
+ return String(props.value)
193
+ })
194
+
195
+ // Badge severity
196
+ const badgeSeverity = computed(() => {
197
+ if (typeof props.field.severity === 'function') {
198
+ return props.field.severity(props.value)
199
+ }
200
+ return props.field.severity || 'info'
201
+ })
202
+
203
+ // JSON formatted
204
+ const jsonValue = computed(() => {
205
+ if (isEmpty.value) return '-'
206
+ try {
207
+ return JSON.stringify(props.value, null, 2)
208
+ } catch {
209
+ return String(props.value)
210
+ }
211
+ })
212
+
213
+ // Image dimensions
214
+ const imageStyle = computed(() => ({
215
+ maxWidth: props.field.imageWidth || '200px',
216
+ maxHeight: props.field.imageHeight || '150px'
217
+ }))
218
+ </script>
219
+
220
+ <template>
221
+ <!-- Empty value -->
222
+ <span v-if="isEmpty" class="show-display show-display--empty">-</span>
223
+
224
+ <!-- Text -->
225
+ <span v-else-if="displayType === 'text'" class="show-display show-display--text">
226
+ {{ textValue }}
227
+ </span>
228
+
229
+ <!-- Email -->
230
+ <a
231
+ v-else-if="displayType === 'email'"
232
+ :href="`mailto:${value}`"
233
+ class="show-display show-display--email"
234
+ >
235
+ {{ value }}
236
+ </a>
237
+
238
+ <!-- Password (masked) -->
239
+ <span v-else-if="displayType === 'password'" class="show-display show-display--password">
240
+ ••••••••
241
+ </span>
242
+
243
+ <!-- Number -->
244
+ <span v-else-if="displayType === 'number'" class="show-display show-display--number">
245
+ {{ numberValue }}
246
+ </span>
247
+
248
+ <!-- Currency -->
249
+ <span v-else-if="displayType === 'currency'" class="show-display show-display--currency">
250
+ {{ currencyValue }}
251
+ </span>
252
+
253
+ <!-- Boolean (icon) -->
254
+ <span v-else-if="displayType === 'boolean'" class="show-display show-display--boolean" :class="booleanIconClass">
255
+ <i :class="['pi', booleanIcon]"></i>
256
+ <span class="boolean-label">{{ booleanValue }}</span>
257
+ </span>
258
+
259
+ <!-- Date -->
260
+ <span v-else-if="displayType === 'date'" class="show-display show-display--date">
261
+ {{ dateValue }}
262
+ </span>
263
+
264
+ <!-- Datetime -->
265
+ <span v-else-if="displayType === 'datetime'" class="show-display show-display--datetime">
266
+ {{ datetimeValue }}
267
+ </span>
268
+
269
+ <!-- Select (label lookup) -->
270
+ <span v-else-if="displayType === 'select'" class="show-display show-display--select">
271
+ {{ selectValue }}
272
+ </span>
273
+
274
+ <!-- Textarea (multi-line) -->
275
+ <div v-else-if="displayType === 'textarea'" class="show-display show-display--textarea">
276
+ {{ textValue }}
277
+ </div>
278
+
279
+ <!-- Reference (link) -->
280
+ <RouterLink
281
+ v-else-if="displayType === 'reference' && referenceRoute"
282
+ :to="referenceRoute"
283
+ class="show-display show-display--reference"
284
+ >
285
+ {{ referenceLabel }}
286
+ </RouterLink>
287
+ <span v-else-if="displayType === 'reference'" class="show-display show-display--reference">
288
+ {{ referenceLabel }}
289
+ </span>
290
+
291
+ <!-- URL -->
292
+ <a
293
+ v-else-if="displayType === 'url'"
294
+ :href="String(value)"
295
+ target="_blank"
296
+ rel="noopener noreferrer"
297
+ class="show-display show-display--url"
298
+ >
299
+ {{ value }}
300
+ <i class="pi pi-external-link" style="font-size: 0.75em; margin-left: 0.25em"></i>
301
+ </a>
302
+
303
+ <!-- Image -->
304
+ <div v-else-if="displayType === 'image'" class="show-display show-display--image">
305
+ <img :src="String(value)" :alt="field.name" :style="imageStyle" />
306
+ </div>
307
+
308
+ <!-- JSON -->
309
+ <pre v-else-if="displayType === 'json'" class="show-display show-display--json">{{ jsonValue }}</pre>
310
+
311
+ <!-- Badge -->
312
+ <Tag
313
+ v-else-if="displayType === 'badge'"
314
+ :value="String(value)"
315
+ :severity="badgeSeverity"
316
+ class="show-display show-display--badge"
317
+ />
318
+
319
+ <!-- Fallback to text -->
320
+ <span v-else class="show-display show-display--text">
321
+ {{ textValue }}
322
+ </span>
323
+ </template>
324
+
325
+ <style scoped>
326
+ .show-display {
327
+ display: inline;
328
+ }
329
+
330
+ .show-display--empty {
331
+ color: var(--p-text-muted-color, #6c757d);
332
+ }
333
+
334
+ .show-display--email,
335
+ .show-display--url,
336
+ .show-display--reference {
337
+ color: var(--p-primary-color, #3b82f6);
338
+ text-decoration: none;
339
+ }
340
+
341
+ .show-display--email:hover,
342
+ .show-display--url:hover,
343
+ .show-display--reference:hover {
344
+ text-decoration: underline;
345
+ }
346
+
347
+ .show-display--password {
348
+ font-family: monospace;
349
+ letter-spacing: 0.1em;
350
+ }
351
+
352
+ .show-display--number,
353
+ .show-display--currency {
354
+ font-variant-numeric: tabular-nums;
355
+ }
356
+
357
+ .show-display--boolean {
358
+ display: inline-flex;
359
+ align-items: center;
360
+ gap: 0.5rem;
361
+ }
362
+
363
+ .show-display--boolean.text-success {
364
+ color: var(--p-green-500, #22c55e);
365
+ }
366
+
367
+ .show-display--boolean.text-danger {
368
+ color: var(--p-red-500, #ef4444);
369
+ }
370
+
371
+ .show-display--boolean.text-muted {
372
+ color: var(--p-text-muted-color, #6c757d);
373
+ }
374
+
375
+ .show-display--textarea {
376
+ white-space: pre-wrap;
377
+ line-height: 1.6;
378
+ }
379
+
380
+ .show-display--image img {
381
+ border-radius: 4px;
382
+ object-fit: cover;
383
+ }
384
+
385
+ .show-display--json {
386
+ background: var(--p-surface-100, #f1f5f9);
387
+ padding: 0.75rem;
388
+ border-radius: 4px;
389
+ font-size: 0.875rem;
390
+ overflow-x: auto;
391
+ margin: 0;
392
+ }
393
+ </style>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ShowField - Display field wrapper with label
4
+ *
5
+ * Provides consistent styling for field display in ShowPage.
6
+ * Mirrors FormField but for read-only display.
7
+ *
8
+ * Usage:
9
+ * <ShowField :name="f.name" :label="f.label">
10
+ * <ShowDisplay :field="f" :value="data[f.name]" />
11
+ * </ShowField>
12
+ *
13
+ * Or with auto-display:
14
+ * <ShowField :field="f" :value="data[f.name]" />
15
+ */
16
+ import { computed, type PropType } from 'vue'
17
+ import ShowDisplay from './ShowDisplay.vue'
18
+
19
+ interface FieldConfig {
20
+ name: string
21
+ type?: string
22
+ label?: string
23
+ [key: string]: unknown
24
+ }
25
+
26
+ const props = defineProps({
27
+ // Field config (for auto-display mode)
28
+ field: { type: Object as PropType<FieldConfig | null>, default: null },
29
+ // Value (for auto-display mode)
30
+ value: { type: [String, Number, Boolean, Date, Object, Array], default: null },
31
+ // Manual props (override field config)
32
+ name: { type: String, default: '' },
33
+ label: { type: String, default: '' },
34
+ // Layout options
35
+ horizontal: { type: Boolean, default: false },
36
+ labelWidth: { type: String, default: '120px' }
37
+ })
38
+
39
+ // Resolved name
40
+ const fieldName = computed(() => props.name || props.field?.name || '')
41
+
42
+ // Resolved label
43
+ const fieldLabel = computed(() => props.label || props.field?.label || fieldName.value)
44
+
45
+ // Auto-display mode (when field + value provided, no slot content)
46
+ const autoDisplay = computed(() => props.field !== null)
47
+ </script>
48
+
49
+ <template>
50
+ <div
51
+ class="show-field"
52
+ :class="{
53
+ 'show-field--horizontal': horizontal
54
+ }"
55
+ >
56
+ <label class="show-field__label" :style="horizontal ? { minWidth: labelWidth } : {}">
57
+ {{ fieldLabel }}
58
+ </label>
59
+ <div class="show-field__value">
60
+ <!-- Slot for custom content -->
61
+ <slot>
62
+ <!-- Auto-display when field is provided -->
63
+ <ShowDisplay v-if="autoDisplay" :field="(field as any)" :value="(value as any)" />
64
+ </slot>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .show-field {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 0.25rem;
74
+ }
75
+
76
+ .show-field--horizontal {
77
+ flex-direction: row;
78
+ align-items: flex-start;
79
+ gap: 1rem;
80
+ }
81
+
82
+ .show-field__label {
83
+ font-weight: 600;
84
+ color: var(--p-text-muted-color, #6c757d);
85
+ font-size: 0.875rem;
86
+ }
87
+
88
+ .show-field--horizontal .show-field__label {
89
+ padding-top: 0.125rem;
90
+ }
91
+
92
+ .show-field__value {
93
+ color: var(--p-text-color, #1e293b);
94
+ line-height: 1.5;
95
+ }
96
+ </style>