@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 +1 -1
- package/src/components/edit/FormPage.vue +6 -30
- package/src/components/layout/CardShell.vue +26 -0
- package/src/components/lists/ListPage.vue +4 -2
- package/src/components/show/ShowPage.vue +12 -73
- package/src/composables/useActionRegistry.ts +82 -0
- package/src/composables/useBareForm.ts +3 -6
- package/src/composables/useEntityItemFormPage.ts +48 -63
- package/src/composables/useEntityItemPage.ts +4 -3
- package/src/composables/useEntityItemShowPage.ts +25 -18
- package/src/composables/useListPage.ts +53 -37
- package/src/composables/useListPage.utils.ts +1 -1
- package/src/composables/useOrchestratorToast.ts +26 -0
- package/src/entity/EntityManager.query.ts +15 -6
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +3 -0
- package/src/generated/managers/testManager.ts +46 -0
- package/src/index.ts +12 -0
- package/src/query/clientFilter.ts +45 -4
- package/src/types/index.ts +2 -0
- package/src/utils/errors.ts +22 -0
- package/src/utils/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
209
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
236
|
-
<
|
|
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
|
|
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
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)$/,
|
|
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,
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
686
|
+
actionRegistry.add({ name, ...actionConfig } as ActionConfig)
|
|
691
687
|
}
|
|
692
688
|
|
|
693
689
|
function removeAction(name: string): void {
|
|
694
|
-
|
|
690
|
+
actionRegistry.remove(name)
|
|
695
691
|
}
|
|
696
692
|
|
|
697
693
|
function getActions(): ResolvedAction[] {
|
|
698
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
460
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
|
182
|
-
if (bVal == null) return -
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|