@quazardous/qdadm 2.4.1 → 2.4.3

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": "@quazardous/qdadm",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@ import { computed, type PropType } from 'vue'
36
36
  import PageHeader from '../layout/PageHeader.vue'
37
37
  import FormActions from './FormActions.vue'
38
38
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
39
- import Card from 'primevue/card'
39
+ import CardShell from '../layout/CardShell.vue'
40
+ import { formatFetchError } from '../../utils/errors'
40
41
  import Button from 'primevue/button'
41
42
  import Message from 'primevue/message'
42
43
  import type { ResolvedAction, ResolvedFieldConfig } from '../../composables/useEntityItemFormPage'
@@ -119,11 +120,7 @@ const headerActions = computed<ResolvedAction[]>(() =>
119
120
  )
120
121
 
121
122
  // Get error message from fetchError
122
- const fetchErrorMessage = computed<string | null>(() => {
123
- if (!props.fetchError) return null
124
- if (typeof props.fetchError === 'string') return props.fetchError
125
- return props.fetchError.message || props.fetchError.detail || 'Failed to load entity'
126
- })
123
+ const fetchErrorMessage = computed<string | null>(() => formatFetchError(props.fetchError))
127
124
 
128
125
  // Guard dialog handlers
129
126
  function onGuardSaveAndLeave(): void {
@@ -205,29 +202,8 @@ function onGuardStay(): void {
205
202
  </ul>
206
203
  </Message>
207
204
 
208
- <!-- Card wrapper or direct content -->
209
- <Card v-if="cardWrapper">
210
- <template #content>
211
- <slot name="fields" />
212
-
213
- <!-- Form Actions (in footer) -->
214
- <template v-if="showFormActions">
215
- <slot name="footer">
216
- <FormActions
217
- :isEdit="isEdit"
218
- :saving="saving"
219
- :dirty="dirty"
220
- :showSaveAndClose="showSaveAndClose"
221
- @save="emit('save')"
222
- @saveAndClose="emit('saveAndClose')"
223
- @cancel="emit('cancel')"
224
- />
225
- </slot>
226
- </template>
227
- </template>
228
- </Card>
229
-
230
- <template v-else>
205
+ <!-- Single content body; Card wrapper is conditional (#1193) -->
206
+ <CardShell :card="cardWrapper">
231
207
  <slot name="fields" />
232
208
 
233
209
  <!-- Form Actions (in footer) -->
@@ -244,7 +220,7 @@ function onGuardStay(): void {
244
220
  />
245
221
  </slot>
246
222
  </template>
247
- </template>
223
+ </CardShell>
248
224
  </template>
249
225
 
250
226
  <!-- Unsaved Changes Dialog -->
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * CardShell — conditional Card wrapper (#1193).
4
+ *
5
+ * ShowPage and FormPage duplicated their entire content body twice (once
6
+ * inside <Card><template #content>, once bare). Wrapping the single body
7
+ * in this shell keeps the slots in the host component (no forwarding).
8
+ */
9
+ import Card from 'primevue/card'
10
+
11
+ defineProps<{
12
+ /** Wrap the default slot in a PrimeVue Card (#content). */
13
+ card?: boolean
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <Card v-if="card">
19
+ <template #content>
20
+ <slot />
21
+ </template>
22
+ </Card>
23
+ <template v-else>
24
+ <slot />
25
+ </template>
26
+ </template>
@@ -180,7 +180,7 @@ const emit = defineEmits<{
180
180
  (e: 'update:searchQuery', value: string): void
181
181
  (e: 'update:filterValues', values: Record<string, unknown>): void
182
182
  (e: 'page', event: { page: number; rows: number }): void
183
- (e: 'sort', event: { sortField: string; sortOrder: number }): void
183
+ (e: 'sort', event: { sortField: string | null; sortOrder: number | null }): void
184
184
  (e: 'row-click', data: unknown, originalEvent: MouseEvent): void
185
185
  }>()
186
186
 
@@ -293,7 +293,9 @@ function onPage(event: { page: number; rows: number }): void {
293
293
 
294
294
  function onSort(event: unknown): void {
295
295
  const e = event as { sortField?: string | null; sortOrder?: number | null }
296
- emit('sort', { sortField: String(e.sortField ?? ''), sortOrder: e.sortOrder ?? 1 })
296
+ // Pass removableSort's "sort removed" state through honestly (#1222):
297
+ // null stays null — the composable decides the fallback (defaultSort).
298
+ emit('sort', { sortField: e.sortField ?? null, sortOrder: e.sortOrder ?? null })
297
299
  }
298
300
 
299
301
  function onRowClick(event: { data: unknown; originalEvent: Event }): void {
@@ -39,7 +39,8 @@
39
39
  */
40
40
  import { computed, type PropType } from 'vue'
41
41
  import PageHeader from '../layout/PageHeader.vue'
42
- import Card from 'primevue/card'
42
+ import CardShell from '../layout/CardShell.vue'
43
+ import { formatFetchError } from '../../utils/errors'
43
44
  import Button from 'primevue/button'
44
45
  import Message from 'primevue/message'
45
46
  import FieldGroups from '../item/FieldGroups.vue'
@@ -163,7 +164,7 @@ const useGroupLayout = computed(() => {
163
164
  return hasRealGroups && props.layout !== 'flat'
164
165
  })
165
166
 
166
- const emit = defineEmits<{
167
+ defineEmits<{
167
168
  (e: 'edit'): void
168
169
  (e: 'delete'): void
169
170
  (e: 'back'): void
@@ -180,11 +181,11 @@ const footerActions = computed<ResolvedAction[]>(() =>
180
181
  )
181
182
 
182
183
  // Get error message from fetchError
183
- const fetchErrorMessage = computed<string | null>(() => {
184
- if (!props.fetchError) return null
185
- if (typeof props.fetchError === 'string') return props.fetchError
186
- return props.fetchError.message || props.fetchError.detail || 'Failed to load entity'
187
- })
184
+ const fetchErrorMessage = computed<string | null>(() => formatFetchError(props.fetchError))
185
+
186
+ /** Value union accepted by ShowField — aliased so the template cast has no
187
+ * `|` (the eslint vue/no-deprecated-filter rule misreads it as a filter). */
188
+ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown> | unknown[]
188
189
  </script>
189
190
 
190
191
  <template>
@@ -232,70 +233,8 @@ const fetchErrorMessage = computed<string | null>(() => {
232
233
 
233
234
  <!-- Content -->
234
235
  <template v-else>
235
- <!-- Card wrapper or direct content -->
236
- <Card v-if="cardWrapper">
237
- <template #content>
238
- <!-- Grid layout with optional media zone -->
239
- <div
240
- class="show-content"
241
- :class="{ 'show-content--with-media': slots.media }"
242
- :style="slots.media ? { '--media-width': mediaWidth } : {}"
243
- >
244
- <!-- Media zone (optional) -->
245
- <div v-if="slots.media" class="show-media">
246
- <slot name="media" />
247
- </div>
248
-
249
- <!-- Fields/Groups zone -->
250
- <div class="show-fields" :class="{ 'show-fields--horizontal': horizontalFields && !useGroupLayout }">
251
- <!-- Group layout mode -->
252
- <template v-if="useGroupLayout">
253
- <slot name="groups">
254
- <FieldGroups
255
- :groups="groups"
256
- :data="data"
257
- :layout="layout"
258
- :child-layout="childLayout"
259
- >
260
- <template #field="{ field, value }">
261
- <ShowField
262
- :field="field"
263
- :value="value as string | number | boolean | Date | Record<string, unknown> | unknown[]"
264
- :horizontal="horizontalFields"
265
- :label-width="labelWidth"
266
- />
267
- </template>
268
- </FieldGroups>
269
- </slot>
270
- </template>
271
- <!-- Flat fields mode (default) -->
272
- <template v-else>
273
- <slot name="fields" />
274
- </template>
275
- </div>
276
- </div>
277
-
278
- <!-- Footer Actions -->
279
- <template v-if="showActions && footerActions.length > 0">
280
- <slot name="footer">
281
- <div class="show-actions">
282
- <Button
283
- v-for="action in footerActions"
284
- :key="action.name"
285
- :label="action.label"
286
- :icon="action.icon"
287
- :severity="action.severity"
288
- :loading="action.isLoading"
289
- :disabled="action.isDisabled"
290
- @click="action.onClick"
291
- />
292
- </div>
293
- </slot>
294
- </template>
295
- </template>
296
- </Card>
297
-
298
- <template v-else>
236
+ <!-- Single content body; Card wrapper is conditional (#1193) -->
237
+ <CardShell :card="cardWrapper">
299
238
  <!-- Grid layout with optional media zone -->
300
239
  <div
301
240
  class="show-content"
@@ -321,7 +260,7 @@ const fetchErrorMessage = computed<string | null>(() => {
321
260
  <template #field="{ field, value }">
322
261
  <ShowField
323
262
  :field="field"
324
- :value="value as string | number | boolean | Date | Record<string, unknown> | unknown[]"
263
+ :value="value as ShowFieldValue"
325
264
  :horizontal="horizontalFields"
326
265
  :label-width="labelWidth"
327
266
  />
@@ -353,7 +292,7 @@ const fetchErrorMessage = computed<string | null>(() => {
353
292
  </div>
354
293
  </slot>
355
294
  </template>
356
- </template>
295
+ </CardShell>
357
296
  </template>
358
297
  </div>
359
298
  </template>
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Generic action registry (#1193) — the map + ordered add/remove/resolve
3
+ * skeleton was rebuilt in useListPage, useEntityItemFormPage and
4
+ * useEntityItemShowPage; only the resolution context differs, so the
5
+ * consumer supplies `resolve(action, ctx)` and calls `resolveAll(ctx)`.
6
+ */
7
+ import { ref, type Ref } from 'vue'
8
+
9
+ export interface UseActionRegistryOptions<A extends { name: string }, Ctx, R> {
10
+ /** Defaults merged under every added action (e.g. { severity: 'secondary' }). */
11
+ defaults?: Partial<A>
12
+ /**
13
+ * Resolve one action against the consumer's context. Return null to
14
+ * filter it out (visibility check lives here).
15
+ */
16
+ resolve: (action: A, ctx: Ctx) => R | null
17
+ }
18
+
19
+ export interface UseActionRegistryReturn<A extends { name: string }, Ctx, R> {
20
+ /** Raw registered actions, insertion-ordered (exposed for advanced consumers). */
21
+ actionsMap: Ref<Map<string, A>>
22
+ /** Registration order — kept explicit so external entries (e.g. lazy actions) can share it. */
23
+ order: Ref<string[]>
24
+ add(action: A): void
25
+ remove(name: string): void
26
+ has(name: string): boolean
27
+ /** Drop every registered action (order included). */
28
+ clear(): void
29
+ /** Resolve every registered action in order, dropping nulls. */
30
+ resolveAll(ctx: Ctx): R[]
31
+ /** Resolve a single registered action by name (null if absent or filtered). */
32
+ resolveOne(name: string, ctx: Ctx): R | null
33
+ }
34
+
35
+ export function useActionRegistry<A extends { name: string }, Ctx = void, R = A>(
36
+ options: UseActionRegistryOptions<A, Ctx, R>
37
+ ): UseActionRegistryReturn<A, Ctx, R> {
38
+ const { defaults, resolve } = options
39
+ const actionsMap = ref(new Map()) as Ref<Map<string, A>>
40
+ const order = ref<string[]>([])
41
+
42
+ function add(action: A): void {
43
+ actionsMap.value.set(action.name, { ...defaults, ...action })
44
+ if (!order.value.includes(action.name)) {
45
+ order.value.push(action.name)
46
+ }
47
+ }
48
+
49
+ function remove(name: string): void {
50
+ actionsMap.value.delete(name)
51
+ const idx = order.value.indexOf(name)
52
+ if (idx !== -1) order.value.splice(idx, 1)
53
+ }
54
+
55
+ function has(name: string): boolean {
56
+ return actionsMap.value.has(name)
57
+ }
58
+
59
+ function clear(): void {
60
+ actionsMap.value.clear()
61
+ order.value = []
62
+ }
63
+
64
+ function resolveAll(ctx: Ctx): R[] {
65
+ const resolved: R[] = []
66
+ for (const name of order.value) {
67
+ const action = actionsMap.value.get(name)
68
+ if (!action) continue
69
+ const r = resolve(action, ctx)
70
+ if (r !== null) resolved.push(r)
71
+ }
72
+ return resolved
73
+ }
74
+
75
+ function resolveOne(name: string, ctx: Ctx): R | null {
76
+ const action = actionsMap.value.get(name)
77
+ if (!action) return null
78
+ return resolve(action, ctx)
79
+ }
80
+
81
+ return { actionsMap, order, add, remove, has, clear, resolveAll, resolveOne }
82
+ }
@@ -36,6 +36,7 @@ import { useUnsavedChangesGuard, type GuardDialogState } from './useUnsavedChang
36
36
  import { useBreadcrumb, type BreadcrumbDisplayItem } from './useBreadcrumb'
37
37
  import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
38
38
  import type { EntityManagerLike, OrchestratorLike } from '../entity/EntityManager.interface'
39
+ import { createOrchestratorToast } from './useOrchestratorToast'
39
40
 
40
41
  // #1191 — shared minimal structural views (was: local redeclarations)
41
42
  type EntityManager = EntityManagerLike
@@ -169,12 +170,8 @@ export function useBareForm(options: UseBareFormOptions): UseBareFormReturn {
169
170
  const route = useRoute()
170
171
  const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator', null)
171
172
 
172
- // Toast helper - wraps orchestrator.toast for legacy compatibility
173
- const toast: ToastHelper = {
174
- add({ severity, summary, detail, emitter }) {
175
- orchestrator?.toast[severity]?.(summary, detail, emitter)
176
- },
177
- }
173
+ // Toast helper (#1193 — shared facade)
174
+ const toast: ToastHelper = createOrchestratorToast(orchestrator)
178
175
 
179
176
  // Common state
180
177
  const loading = ref(false)
@@ -50,6 +50,8 @@ import type {
50
50
  ErrorSummaryItem,
51
51
  PageTitleParts,
52
52
  } from './useEntityItemFormPage.types'
53
+ import { createOrchestratorToast } from './useOrchestratorToast'
54
+ import { useActionRegistry } from './useActionRegistry'
53
55
  import { TYPE_MAPPINGS, TYPE_VALIDATORS, snakeCaseToTitle, isEmpty } from './useEntityItemFormPage.types'
54
56
 
55
57
  // Re-export public types
@@ -100,7 +102,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
100
102
  // Mode detection
101
103
  getId = null,
102
104
  createRouteSuffix = 'create',
103
- editRouteSuffix: _editRouteSuffix = 'edit', // eslint-disable-line @typescript-eslint/no-unused-vars
105
+ editRouteSuffix = 'edit',
104
106
  // Form options
105
107
  loadOnMount = true,
106
108
  enableGuard = true,
@@ -143,12 +145,8 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
143
145
  hydrator,
144
146
  } = itemPage
145
147
 
146
- // Toast helper - wraps orchestrator.toast for legacy compatibility
147
- const toast: ToastHelper = {
148
- add({ severity, summary, detail, emitter }) {
149
- ;(orchestrator as Orchestrator)?.toast?.[severity]?.(summary, detail, emitter)
150
- },
151
- }
148
+ // Toast helper (#1193 — shared facade)
149
+ const toast: ToastHelper = createOrchestratorToast(orchestrator as Orchestrator | null)
152
150
 
153
151
  // Read config from manager with option overrides
154
152
  const entityName = config.entityName ?? manager.label
@@ -331,7 +329,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
331
329
  const createdId = (responseData as Record<string, unknown>)?.[manager.idField]
332
330
  if (createdId) {
333
331
  const currentRouteName = (route.name as string) || ''
334
- const editRouteName = currentRouteName.replace(/(-create|-new)$/, '-edit')
332
+ const editRouteName = currentRouteName.replace(/(-create|-new)$/, `-${editRouteSuffix}`)
335
333
  router.push({
336
334
  name: editRouteName,
337
335
  params: { ...route.params, [manager.idField]: String(createdId) },
@@ -505,7 +503,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
505
503
  })
506
504
 
507
505
  // Expose refs for validation (which needs direct access)
508
- const { fieldsMap, fieldOrder, excludedFields, fields, groups } = fieldManager
506
+ const { fieldsMap, fieldOrder, fields, groups } = fieldManager
509
507
 
510
508
  /**
511
509
  * Generate fields from manager schema
@@ -572,36 +570,43 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
572
570
 
573
571
  // ============ VALIDATION ============
574
572
 
575
- function validateField(name: string): string | null {
576
- const fieldConfig = fieldsMap.value.get(name)
577
- if (!fieldConfig) return null
578
-
579
- const value = data.value[name]
580
-
573
+ /**
574
+ * Shared validator pipeline (#1193): required → type validator → custom
575
+ * validate. Pure — returns the error message or null; callers own the
576
+ * errors ref bookkeeping.
577
+ */
578
+ function runFieldValidators(fieldConfig: ResolvedFieldConfig, value: unknown): string | null {
581
579
  if (fieldConfig.required && isEmpty(value)) {
582
- const error = `${fieldConfig.label} is required`
583
- errors.value = { ...errors.value, [name]: error }
584
- return error
580
+ return `${fieldConfig.label} is required`
585
581
  }
586
582
 
587
583
  const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
588
584
  if (typeValidator) {
589
585
  const result = typeValidator(value)
590
- if (result !== true) {
591
- errors.value = { ...errors.value, [name]: result as string }
592
- return result as string
593
- }
586
+ if (result !== true) return result as string
594
587
  }
595
588
 
596
589
  if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
597
590
  const result = fieldConfig.validate(value, data.value)
598
591
  if (result !== true && result !== undefined && result !== null) {
599
- const error = typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
600
- errors.value = { ...errors.value, [name]: error }
601
- return error
592
+ return typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
602
593
  }
603
594
  }
604
595
 
596
+ return null
597
+ }
598
+
599
+ function validateField(name: string): string | null {
600
+ const fieldConfig = fieldsMap.value.get(name)
601
+ if (!fieldConfig) return null
602
+
603
+ const error = runFieldValidators(fieldConfig, data.value[name])
604
+
605
+ if (error) {
606
+ errors.value = { ...errors.value, [name]: error }
607
+ return error
608
+ }
609
+
605
610
  if (errors.value[name]) {
606
611
  const { [name]: _removed, ...rest } = errors.value
607
612
  void _removed // Intentionally unused - destructuring to omit
@@ -618,29 +623,8 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
618
623
  const fieldConfig = fieldsMap.value.get(fieldName)
619
624
  if (!fieldConfig) continue
620
625
 
621
- const value = data.value[fieldName]
622
-
623
- if (fieldConfig.required && isEmpty(value)) {
624
- newErrors[fieldName] = `${fieldConfig.label} is required`
625
- continue
626
- }
627
-
628
- const typeValidator = TYPE_VALIDATORS[fieldConfig.schemaType]
629
- if (typeValidator) {
630
- const result = typeValidator(value)
631
- if (result !== true) {
632
- newErrors[fieldName] = result as string
633
- continue
634
- }
635
- }
636
-
637
- if (fieldConfig.validate && typeof fieldConfig.validate === 'function') {
638
- const result = fieldConfig.validate(value, data.value)
639
- if (result !== true && result !== undefined && result !== null) {
640
- newErrors[fieldName] =
641
- typeof result === 'string' ? result : `${fieldConfig.label} is invalid`
642
- }
643
- }
626
+ const error = runFieldValidators(fieldConfig, data.value[fieldName])
627
+ if (error) newErrors[fieldName] = error
644
628
  }
645
629
 
646
630
  errors.value = newErrors
@@ -684,29 +668,30 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
684
668
 
685
669
  // ============ ACTIONS ============
686
670
 
687
- const actionsMap = ref<Map<string, ActionConfig>>(new Map())
671
+ // #1193 — shared registry skeleton; form-state resolution stays here
672
+ const actionRegistry = useActionRegistry<ActionConfig, void, ResolvedAction>({
673
+ resolve: (action) => {
674
+ if (action.visible && !action.visible({ isEdit: isEdit.value, dirty: dirty.value })) return null
675
+ return {
676
+ ...action,
677
+ isLoading: action.loading ? action.loading() : false,
678
+ isDisabled: action.disabled
679
+ ? action.disabled({ dirty: dirty.value, saving: saving.value })
680
+ : false,
681
+ }
682
+ },
683
+ })
688
684
 
689
685
  function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
690
- actionsMap.value.set(name, { name, ...actionConfig })
686
+ actionRegistry.add({ name, ...actionConfig } as ActionConfig)
691
687
  }
692
688
 
693
689
  function removeAction(name: string): void {
694
- actionsMap.value.delete(name)
690
+ actionRegistry.remove(name)
695
691
  }
696
692
 
697
693
  function getActions(): ResolvedAction[] {
698
- const actions: ResolvedAction[] = []
699
- for (const [, action] of actionsMap.value) {
700
- if (action.visible && !action.visible({ isEdit: isEdit.value, dirty: dirty.value })) continue
701
- actions.push({
702
- ...action,
703
- isLoading: action.loading ? action.loading() : false,
704
- isDisabled: action.disabled
705
- ? action.disabled({ dirty: dirty.value, saving: saving.value })
706
- : false,
707
- })
708
- }
709
- return actions
694
+ return actionRegistry.resolveAll(undefined)
710
695
  }
711
696
 
712
697
  const actions = computed(() => getActions())
@@ -12,11 +12,12 @@
12
12
  * - Show pages (read-only detail pages)
13
13
  * - useEntityItemFormPage (create/edit forms)
14
14
  */
15
- import { ref, computed, onMounted, inject, provide, type Ref, type ComputedRef } from 'vue'
15
+ import { ref, computed, onMounted, provide, type Ref, type ComputedRef } from 'vue'
16
16
  import { useRoute } from 'vue-router'
17
17
  import { useStackHydrator, type StackHydratorReturn } from '../chain/useStackHydrator'
18
18
  import type { EntityManagerCrud } from '../entity/EntityManager.interface'
19
19
  import type { OrchestratorLike } from '../entity/EntityManager.interface'
20
+ import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
20
21
 
21
22
  /**
22
23
  * Parent configuration from route.meta.parent
@@ -142,7 +143,8 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
142
143
  const route = useRoute()
143
144
 
144
145
  // Get EntityManager via orchestrator
145
- const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
146
+ // #1193 — canonical injection (single error-message source), erased to the CRUD-tier view
147
+ const orchestrator = useOrchestrator().orchestrator as unknown as Orchestrator
146
148
  if (!orchestrator) {
147
149
  throw new Error(
148
150
  '[qdadm] Orchestrator not provided.\n' +
@@ -232,7 +234,6 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
232
234
  // Calculate total depth to set correct breadcrumb levels
233
235
  // If chain is: grandparent -> parent -> current
234
236
  // grandparent = level 1, parent = level 2, current = level 3
235
- // const totalDepth = getChainDepth()
236
237
 
237
238
  // Load chain from immediate parent up to root
238
239
  let currentConfig: ParentConfig | undefined = parentConfig.value
@@ -55,6 +55,7 @@ import {
55
55
  type ShowResolvedFieldConfig,
56
56
  } from './createShowFieldResolver'
57
57
  import type { OrchestratorLike } from '../entity/EntityManager.interface'
58
+ import { useActionRegistry } from './useActionRegistry'
58
59
 
59
60
  // #1191 — shared structural view (manager tier from useEntityItemPage)
60
61
  type Orchestrator = OrchestratorLike<EntityManager>
@@ -344,17 +345,27 @@ export function useEntityItemShowPage<T = unknown>(
344
345
 
345
346
  // ============ ACTION MANAGEMENT ============
346
347
 
347
- const actionsMap = ref<Map<string, ActionConfig>>(new Map())
348
- const actionOrder = ref<string[]>([])
349
-
350
348
  const canEdit = computed(() => manager.canUpdate?.(data.value) ?? true)
351
349
  const canDelete = computed(() => manager.canDelete?.(data.value) ?? true)
352
350
 
351
+ // #1193 — shared registry skeleton; the order list is shared with lazy
352
+ // actions so declaration order survives the regular/lazy interleave.
353
+ type ShowActionCtx = { canEdit: boolean; canDelete: boolean; loading: boolean }
354
+ const actionRegistry = useActionRegistry<ActionConfig, ShowActionCtx, ResolvedAction>({
355
+ resolve: (action, ctx) => {
356
+ if (action.visible && !action.visible(ctx)) return null
357
+ return {
358
+ ...action,
359
+ isLoading: action.loading ? action.loading() : false,
360
+ isDisabled: action.disabled ? action.disabled(ctx) : false,
361
+ }
362
+ },
363
+ })
364
+ const actionsMap = actionRegistry.actionsMap
365
+ const actionOrder = actionRegistry.order
366
+
353
367
  function addAction(action: ActionConfig): UseEntityItemShowPageReturn<T> {
354
- actionsMap.value.set(action.name, action)
355
- if (!actionOrder.value.includes(action.name)) {
356
- actionOrder.value.push(action.name)
357
- }
368
+ actionRegistry.add(action)
358
369
  return returnValue
359
370
  }
360
371
 
@@ -445,20 +456,16 @@ export function useEntityItemShowPage<T = unknown>(
445
456
  return null
446
457
  })
447
458
  .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
448
- .filter((entry) => {
449
- if (entry.type === 'regular') return !entry.action.visible || entry.action.visible(ctx)
450
- const state = resolved.get(entry.action.name)
451
- return state?.visible === true
452
- })
453
459
  .map((entry) => {
454
- const a = entry.action
455
- if (entry.type === 'lazy') {
456
- const state = resolved.get(a.name)
457
- return { ...a, isLoading: a.loading ? a.loading() : false, isDisabled: state?.disabled ?? false }
460
+ if (entry.type === 'regular') {
461
+ return actionRegistry.resolveOne(entry.action.name, ctx)
458
462
  }
459
- const regular = a as ActionConfig
460
- return { ...regular, isLoading: regular.loading ? regular.loading() : false, isDisabled: regular.disabled ? regular.disabled(ctx) : false }
463
+ const state = resolved.get(entry.action.name)
464
+ if (state?.visible !== true) return null
465
+ const a = entry.action
466
+ return { ...a, isLoading: a.loading ? a.loading() : false, isDisabled: state?.disabled ?? false }
461
467
  })
468
+ .filter((a): a is ResolvedAction => a !== null)
462
469
  })
463
470
 
464
471
  // ============ PAGE TITLE ============
@@ -95,6 +95,9 @@ import {
95
95
  setSessionFilters,
96
96
  snakeToTitle,
97
97
  } from './useListPage.utils'
98
+ import { useActionRegistry } from './useActionRegistry'
99
+ import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
100
+ import { createOrchestratorToast } from './useOrchestratorToast'
98
101
 
99
102
  // Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
100
103
  // composables/index.ts) keep their import path.
@@ -134,15 +137,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
134
137
  // i18n integration — column headers resolve via entities.{entity}.fields.{field}
135
138
  const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
136
139
 
137
- // Get EntityManager via orchestrator
138
- const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
139
-
140
- // Toast helper - wraps orchestrator.toast for legacy compatibility
141
- const toast: ToastHelper = {
142
- add({ severity, summary, detail, emitter }) {
143
- orchestrator?.toast[severity]?.(summary, detail, emitter)
144
- },
145
- }
140
+ // Get EntityManager via orchestrator (#1193 — canonical injection: one
141
+ // error-message source; erased to this file's Read-tier view)
142
+ const orchestrator = useOrchestrator().orchestrator as unknown as Orchestrator
143
+ const toast: ToastHelper = createOrchestratorToast(orchestrator)
146
144
 
147
145
  if (!orchestrator) {
148
146
  throw new Error(
@@ -835,38 +833,37 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
835
833
  }
836
834
 
837
835
  // ============ ACTIONS ============
838
- const actionsMap = ref<Map<string, ActionConfig>>(new Map())
839
-
840
- function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
841
- actionsMap.value.set(name, {
842
- name,
843
- severity: 'secondary',
844
- ...actionConfig,
845
- } as ActionConfig)
846
- }
847
-
848
- function removeAction(name: string): void {
849
- actionsMap.value.delete(name)
850
- }
851
-
852
836
  function resolveValue<T>(value: T | ((row: unknown) => T), row: unknown): T {
853
837
  return typeof value === 'function' ? (value as (row: unknown) => T)(row) : value
854
838
  }
855
839
 
856
- function getActions(row: unknown): ResolvedAction[] {
857
- const actions: ResolvedAction[] = []
858
- for (const [, action] of actionsMap.value) {
859
- if (action.visible && !action.visible(row)) continue
860
- actions.push({
840
+ // #1193 — shared registry skeleton; row-aware resolution stays here
841
+ const actionRegistry = useActionRegistry<ActionConfig, unknown, ResolvedAction>({
842
+ defaults: { severity: 'secondary' },
843
+ resolve: (action, row) => {
844
+ if (action.visible && !action.visible(row)) return null
845
+ return {
861
846
  name: action.name,
862
847
  icon: action.icon ? resolveValue(action.icon, row) : undefined,
863
848
  tooltip: action.tooltip ? resolveValue(action.tooltip, row) : undefined,
864
849
  severity: resolveValue(action.severity || 'secondary', row),
865
850
  isDisabled: action.disabled ? action.disabled(row) : false,
866
851
  handler: () => action.onClick(row),
867
- })
868
- }
869
- return actions
852
+ }
853
+ },
854
+ })
855
+ const actionsMap = actionRegistry.actionsMap
856
+
857
+ function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
858
+ actionRegistry.add({ name, ...actionConfig } as ActionConfig)
859
+ }
860
+
861
+ function removeAction(name: string): void {
862
+ actionRegistry.remove(name)
863
+ }
864
+
865
+ function getActions(row: unknown): ResolvedAction[] {
866
+ return actionRegistry.resolveAll(row)
870
867
  }
871
868
 
872
869
  // ============ SEARCH ============
@@ -912,7 +909,15 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
912
909
  manager.invalidateCache()
913
910
  }
914
911
 
915
- loading.value = true
912
+ // Delayed loading indicator (#1222): near-synchronous loads (cache/local
913
+ // storage, fast APIs) used to flip loading true->false within one frame,
914
+ // interrupting PrimeVue's overlay-mask enter transition — the leave
915
+ // transitionend never fired and the invisible mask stayed in the DOM,
916
+ // swallowing every subsequent click (sort toggles became no-ops). The
917
+ // spinner only appears when a load actually takes time.
918
+ const loadingTimer = setTimeout(() => {
919
+ loading.value = true
920
+ }, 150)
916
921
  try {
917
922
  let params: Record<string, unknown> = { ...extraParams }
918
923
 
@@ -992,6 +997,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
992
997
  })
993
998
  console.error('Load error:', error)
994
999
  } finally {
1000
+ clearTimeout(loadingTimer)
995
1001
  loading.value = false
996
1002
  }
997
1003
  }
@@ -1004,9 +1010,18 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1004
1010
  loadItems()
1005
1011
  }
1006
1012
 
1007
- function onSort(event: { sortField: string; sortOrder: number }): void {
1008
- sortField.value = event.sortField
1009
- sortOrder.value = event.sortOrder as 1 | -1
1013
+ function onSort(event: { sortField: string | null; sortOrder: number | null }): void {
1014
+ // removableSort's third state ("sort removed") reaches us as nulls —
1015
+ // fall back to the list's default sort instead of loading unsorted
1016
+ // (#1222: on the cache path an unsorted load re-served the cache in
1017
+ // whatever order the previous sort left it, a visual no-op).
1018
+ if (!event.sortField || !event.sortOrder) {
1019
+ sortField.value = defaultSort
1020
+ sortOrder.value = defaultSortOrder
1021
+ } else {
1022
+ sortField.value = event.sortField
1023
+ sortOrder.value = event.sortOrder as 1 | -1
1024
+ }
1010
1025
  if (persistSort) {
1011
1026
  setSessionSort(filterSessionKey, {
1012
1027
  field: sortField.value,
@@ -1276,9 +1291,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1276
1291
  }
1277
1292
 
1278
1293
  if (alteredConfig.actions) {
1279
- actionsMap.value.clear()
1294
+ // Route through the registry so the order list stays in sync (#1193)
1295
+ actionRegistry.clear()
1280
1296
  for (const action of alteredConfig.actions) {
1281
- actionsMap.value.set(action.name, action)
1297
+ actionRegistry.add(action)
1282
1298
  }
1283
1299
  }
1284
1300
 
@@ -106,7 +106,7 @@ export function getSessionSort(key: string): SessionSort | null {
106
106
  const stored = sessionStorage.getItem(SORT_SESSION_PREFIX + key)
107
107
  if (!stored) return null
108
108
  const parsed = JSON.parse(stored) as SessionSort
109
- if (parsed && (parsed.order === 1 || parsed.order === -1)) return parsed
109
+ if (parsed && (parsed.order === 1 || parsed.order === -1) && parsed.field !== '') return parsed
110
110
  return null
111
111
  } catch {
112
112
  return null
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Orchestrator toast facade (#1193) — was copy-pasted verbatim in
3
+ * useListPage, useEntityItemFormPage and useBareForm.
4
+ */
5
+ import type { OrchestratorLike } from '../entity/EntityManager.interface'
6
+
7
+ /** Legacy-compatible toast surface used across the page composables. */
8
+ export interface ToastHelper {
9
+ add(options: {
10
+ severity: 'success' | 'error' | 'warn' | 'info'
11
+ summary: string
12
+ detail?: string
13
+ emitter?: unknown
14
+ }): void
15
+ }
16
+
17
+ /** Build the facade over any orchestrator-shaped object (null-tolerant). */
18
+ export function createOrchestratorToast(
19
+ orchestrator: Pick<OrchestratorLike, 'toast'> | null | undefined
20
+ ): ToastHelper {
21
+ return {
22
+ add({ severity, summary, detail, emitter }) {
23
+ orchestrator?.toast?.[severity]?.(summary, detail, emitter)
24
+ },
25
+ }
26
+ }
@@ -1,6 +1,7 @@
1
1
  import { QueryExecutor, type QueryObject } from '../query/QueryExecutor'
2
2
  import type { ListParams, ListResult } from '../types'
3
3
  import type { EntityManagerInternal, OperationStats, QueryOptions } from './EntityManager.types'
4
+ import { nullSortRank, type NullSortMode } from '../query/clientFilter'
4
5
 
5
6
  type Self = EntityManagerInternal<any>
6
7
 
@@ -168,18 +169,26 @@ export function applyQueryMethods(EntityManagerClass: { prototype: any }): void
168
169
  // Total after filtering (before pagination)
169
170
  const total = result.length
170
171
 
171
- // Apply sort (QueryExecutor does not sort)
172
+ // Apply sort (QueryExecutor does not sort). Copy first (#1222): result
173
+ // may still BE cache.items — an in-place sort would mutate the cache,
174
+ // making later unsorted reads return whatever order the last sort left.
172
175
  if (sort_by) {
176
+ // Resolve null placement: field config > manager option > 'last'
177
+ const fieldCfg = this.getFieldConfig?.(sort_by) as { nullSort?: NullSortMode } | null
178
+ const nullMode: NullSortMode = fieldCfg?.nullSort ?? this.nullSort ?? 'last'
179
+ const nullCmp = nullSortRank(nullMode, sort_order === 'asc' ? 'asc' : 'desc')
180
+ result = [...result]
173
181
  result.sort((a: any, b: any) => {
174
182
  const aVal = a[sort_by]
175
183
  const bVal = b[sort_by]
176
184
 
177
- // Nulls always sort LAST, both directions (#1221) — matches the
178
- // storage adapters' shared comparator; direction-aware placement
179
- // put "never seen" rows on top of every desc date sort.
185
+ // Null placement is configurable per field/manager (#1222 —
186
+ // depends on the API and the field semantics). Default 'last'
187
+ // keeps the #1221 behavior; e.g. nullSort: 'low' makes "Never"
188
+ // sort beyond the oldest period on a last-seen date column.
180
189
  if (aVal == null && bVal == null) return 0
181
- if (aVal == null) return 1
182
- if (bVal == null) return -1
190
+ if (aVal == null) return nullCmp
191
+ if (bVal == null) return -nullCmp
183
192
 
184
193
  // Compare
185
194
  if (typeof aVal === 'string' && typeof bVal === 'string') {
@@ -30,6 +30,7 @@ import type {
30
30
  StorageResolution,
31
31
  Orchestrator,
32
32
  } from './EntityManager.types'
33
+ import type { NullSortMode } from '../query/clientFilter'
33
34
 
34
35
  // Re-export public types from types file
35
36
  export type {
@@ -83,6 +84,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
83
84
  protected _fields: Record<string, FieldConfig>
84
85
 
85
86
  localFilterThreshold: number | null
87
+ /** Default null placement for local sorts (#1222); field-level nullSort wins. */
88
+ nullSort?: NullSortMode
86
89
  protected _cacheTtlMs: number | null
87
90
  protected _asymmetric: boolean
88
91
  protected _detailCache: DetailCacheState<T> = { items: new Map() }
@@ -153,6 +156,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
153
156
  badges = null,
154
157
  fields = {},
155
158
  localFilterThreshold = null,
159
+ nullSort,
156
160
  cacheTtlMs = null,
157
161
  asymmetric = false,
158
162
  detailCacheTtlMs = 0,
@@ -183,6 +187,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
183
187
  this._fields = fields
184
188
 
185
189
  this.localFilterThreshold = localFilterThreshold
190
+ this.nullSort = nullSort
186
191
  this._cacheTtlMs = cacheTtlMs
187
192
  this._asymmetric = asymmetric
188
193
  this._detailCacheTtlMs = detailCacheTtlMs
@@ -11,6 +11,7 @@ import type {
11
11
  } from '../types'
12
12
  import type { AuthUser, EntityAuthAdapter as IEntityAuthAdapter } from './auth/EntityAuthAdapter'
13
13
  import type { SignalBus } from '../kernel/SignalBus'
14
+ import type { NullSortMode } from '../query/clientFilter'
14
15
  import type { HookRegistry } from '../hooks/HookRegistry'
15
16
 
16
17
  // Circular type import — safe because `import type` is erased at runtime
@@ -214,6 +215,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
214
215
  badges?: (entity: T) => EntityBadge[]
215
216
  fields?: Record<string, FieldConfig>
216
217
  localFilterThreshold?: number | null
218
+ /** Default null placement for local sorts: 'first' | 'last' | 'low' | 'high' (default 'last', #1222). Field-level nullSort wins. */
219
+ nullSort?: NullSortMode
217
220
  /** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
218
221
  cacheTtlMs?: number | null
219
222
  /** Asymmetric mode: list() and get() return different structures. get() skips list cache. */
@@ -0,0 +1,46 @@
1
+ /**
2
+ * TestManager - Auto-generated EntityManager
3
+ *
4
+ * Generated by qdadm/gen/generateManagers
5
+ * DO NOT EDIT MANUALLY - Changes will be overwritten
6
+ *
7
+ * Entity: test
8
+ * Endpoint: /test
9
+ */
10
+
11
+ import { EntityManager } from '@quazardous/qdadm'
12
+ import type { EntityRecord } from '@quazardous/qdadm'
13
+ import { ApiStorage } from '@quazardous/qdadm'
14
+
15
+ export interface TestEntity extends EntityRecord {
16
+ }
17
+
18
+ /**
19
+ * Schema definition for test
20
+ */
21
+ export const testSchema = {
22
+ name: "test",
23
+ endpoint: "/test",
24
+ fields: {}
25
+ } as const
26
+
27
+ /**
28
+ * Storage options for test
29
+ */
30
+ const storageOptions = {
31
+ endpoint: "/test"
32
+ }
33
+
34
+ /**
35
+ * TestManager instance
36
+ *
37
+ * Provides CRUD operations for test entity.
38
+ */
39
+ export const testManager = new EntityManager<TestEntity>({
40
+ ...{
41
+ name: "test",
42
+ idField: "id",
43
+ fields: {}
44
+ },
45
+ storage: new ApiStorage<TestEntity>(storageOptions)
46
+ })
package/src/index.ts CHANGED
@@ -299,6 +299,18 @@ export {
299
299
  API_CLIENT_INJECTION_KEY,
300
300
  type ApiClientSource,
301
301
  } from './api/apiClient'
302
+ export {
303
+ sortItems,
304
+ filterItems,
305
+ searchItems,
306
+ paginate,
307
+ defaultGenerateId,
308
+ nullSortRank,
309
+ type SortOrder,
310
+ type FilterItemsOptions,
311
+ type NullSortMode,
312
+ type SortItemsOptions,
313
+ } from './query/clientFilter'
302
314
  export { useChildPage, type UseChildPageReturn } from './composables/useChildPage'
303
315
  export { useSidebarState, SIDEBAR_COLLAPSED_KEY } from './composables/useSidebarState'
304
316
 
@@ -15,14 +15,55 @@
15
15
 
16
16
  export type SortOrder = 'asc' | 'desc'
17
17
 
18
- /** Sort by a field, nulls/undefined last — the canonical adapter comparator. */
19
- export function sortItems<T>(items: T[], sortBy?: string | null, sortOrder: SortOrder = 'asc'): T[] {
18
+ /**
19
+ * Null placement when sorting (#1222 — configurable, it depends on the
20
+ * API and the field semantics):
21
+ * - 'last' (default): nulls at the end, both directions — the historical
22
+ * adapter behavior, keeps empty rows out of the way.
23
+ * - 'first': nulls at the top, both directions.
24
+ * - 'low': null behaves as the lowest value — first in asc, last in
25
+ * desc. Right for "last seen" dates ("Never" sorts beyond the oldest
26
+ * period); equals PrimeVue nullSortOrder: -1.
27
+ * - 'high': null behaves as the highest value — last in asc, first in desc.
28
+ */
29
+ export type NullSortMode = 'first' | 'last' | 'low' | 'high'
30
+
31
+ export interface SortItemsOptions {
32
+ nulls?: NullSortMode
33
+ }
34
+
35
+ export function nullSortRank(mode: NullSortMode, sortOrder: SortOrder): number {
36
+ // Return the comparator result for "a is null, b is not"
37
+ switch (mode) {
38
+ case 'first':
39
+ return -1
40
+ case 'low':
41
+ return sortOrder === 'asc' ? -1 : 1
42
+ case 'high':
43
+ return sortOrder === 'asc' ? 1 : -1
44
+ case 'last':
45
+ default:
46
+ return 1
47
+ }
48
+ }
49
+
50
+ /** Sort by a field — the canonical comparator (null placement configurable). */
51
+ export function sortItems<T>(
52
+ items: T[],
53
+ sortBy?: string | null,
54
+ sortOrder: SortOrder = 'asc',
55
+ options: SortItemsOptions = {}
56
+ ): T[] {
20
57
  if (!sortBy) return items
58
+ const { nulls = 'last' } = options
21
59
  return items.sort((a, b) => {
22
60
  const aVal = a[sortBy as keyof T]
23
61
  const bVal = b[sortBy as keyof T]
24
- if (aVal === undefined || aVal === null) return 1
25
- if (bVal === undefined || bVal === null) return -1
62
+ const aNull = aVal === undefined || aVal === null
63
+ const bNull = bVal === undefined || bVal === null
64
+ if (aNull && bNull) return 0
65
+ if (aNull) return nullSortRank(nulls, sortOrder)
66
+ if (bNull) return -nullSortRank(nulls, sortOrder)
26
67
  if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1
27
68
  if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1
28
69
  return 0
@@ -79,6 +79,8 @@ export type FieldType =
79
79
  * Field configuration
80
80
  */
81
81
  export interface FieldConfig {
82
+ /** Null placement when this field is locally sorted: 'first' | 'last' | 'low' | 'high' (#1222). */
83
+ nullSort?: 'first' | 'last' | 'low' | 'high'
82
84
  type: FieldType
83
85
  label?: string
84
86
  required?: boolean
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Error formatting helpers (#1193).
3
+ */
4
+
5
+ /** Fetch-error shape accepted by the page components. */
6
+ export interface FetchErrorLike {
7
+ message?: string
8
+ detail?: string
9
+ }
10
+
11
+ /**
12
+ * Normalize a fetch error (string or axios-ish object) to a display
13
+ * message — was duplicated byte-identically in ShowPage and FormPage.
14
+ */
15
+ export function formatFetchError(
16
+ error: string | FetchErrorLike | null | undefined,
17
+ fallback = 'Failed to load entity'
18
+ ): string | null {
19
+ if (!error) return null
20
+ if (typeof error === 'string') return error
21
+ return error.message || error.detail || fallback
22
+ }
@@ -18,6 +18,9 @@ export {
18
18
  formatPercent,
19
19
  } from './formatters'
20
20
 
21
+ // Error formatting
22
+ export { formatFetchError, type FetchErrorLike } from './errors'
23
+
21
24
  // Transformers
22
25
  export {
23
26
  toKeyValueArray,