@quazardous/qdadm 2.4.2 → 2.4.4
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/composables/useDirtyState.ts +33 -20
- package/src/composables/useListPage.alterHooks.ts +153 -0
- package/src/composables/useListPage.filters.ts +421 -0
- package/src/composables/useListPage.ts +52 -412
- package/src/entity/EntityManager.query.ts +11 -5
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +3 -0
- package/src/index.ts +3 -0
- package/src/query/FilterQuery.ts +1 -1
- package/src/query/clientFilter.ts +45 -4
- package/src/types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -54,9 +54,28 @@ export function useDirtyState<T extends FormState = FormState>(
|
|
|
54
54
|
const dirty = ref(false)
|
|
55
55
|
const dirtyFields = ref<string[]>([])
|
|
56
56
|
const initialSnapshot = ref<string | null>(null)
|
|
57
|
-
|
|
57
|
+
// Per-key snapshot strings, precomputed once at takeSnapshot() (#1194):
|
|
58
|
+
// checkDirty runs on every keystroke (deep watch) — re-stringifying the
|
|
59
|
+
// initial side each time doubled the per-key work, and the old
|
|
60
|
+
// JSON.parse(JSON.stringify(state)) deep clone tripled the snapshot cost.
|
|
61
|
+
let initialFieldSnapshots: Map<string, string> | null = null
|
|
58
62
|
const ready = ref(false)
|
|
59
63
|
|
|
64
|
+
/** Resolve the form object inside the state (common 'form'/'formData' patterns). */
|
|
65
|
+
function resolveForm(state: T): Record<string, unknown> {
|
|
66
|
+
return (
|
|
67
|
+
(state.form as Record<string, unknown>) ||
|
|
68
|
+
(state.formData as Record<string, unknown>) ||
|
|
69
|
+
(state as Record<string, unknown>)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stable stringify — JSON.stringify(undefined) is undefined, normalize it. */
|
|
74
|
+
function stringifyField(value: unknown): string {
|
|
75
|
+
const s = JSON.stringify(value)
|
|
76
|
+
return s === undefined ? 'undefined' : s
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
function getFormSnapshot(): string {
|
|
61
80
|
return JSON.stringify(getState())
|
|
62
81
|
}
|
|
@@ -71,8 +90,12 @@ export function useDirtyState<T extends FormState = FormState>(
|
|
|
71
90
|
nextTick(() => {
|
|
72
91
|
const state = getState()
|
|
73
92
|
initialSnapshot.value = JSON.stringify(state)
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
const form = resolveForm(state)
|
|
94
|
+
const fieldSnapshots = new Map<string, string>()
|
|
95
|
+
for (const key in form) {
|
|
96
|
+
fieldSnapshots.set(key, stringifyField(form[key]))
|
|
97
|
+
}
|
|
98
|
+
initialFieldSnapshots = fieldSnapshots
|
|
76
99
|
ready.value = true
|
|
77
100
|
})
|
|
78
101
|
}
|
|
@@ -84,25 +107,15 @@ export function useDirtyState<T extends FormState = FormState>(
|
|
|
84
107
|
const currentSnapshot = getFormSnapshot()
|
|
85
108
|
dirty.value = currentSnapshot !== initialSnapshot.value
|
|
86
109
|
|
|
87
|
-
// Calculate dirty fields
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
// Calculate dirty fields — current side only is stringified; the
|
|
111
|
+
// initial side comes from the cached per-key snapshots (#1194).
|
|
112
|
+
if (dirty.value && initialFieldSnapshots) {
|
|
113
|
+
const currentForm = resolveForm(getState())
|
|
90
114
|
const changed: string[] = []
|
|
91
115
|
|
|
92
|
-
// Compare form fields if state has a 'form' or 'formData' key (common patterns)
|
|
93
|
-
const initial =
|
|
94
|
-
(initialState.value.form as Record<string, unknown>) ||
|
|
95
|
-
(initialState.value.formData as Record<string, unknown>) ||
|
|
96
|
-
(initialState.value as Record<string, unknown>)
|
|
97
|
-
const currentForm =
|
|
98
|
-
(current.form as Record<string, unknown>) ||
|
|
99
|
-
(current.formData as Record<string, unknown>) ||
|
|
100
|
-
(current as Record<string, unknown>)
|
|
101
|
-
|
|
102
116
|
for (const key in currentForm) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
if (initialVal !== currentVal) {
|
|
117
|
+
const currentVal = stringifyField(currentForm[key])
|
|
118
|
+
if (initialFieldSnapshots.get(key) !== currentVal) {
|
|
106
119
|
changed.push(key)
|
|
107
120
|
}
|
|
108
121
|
}
|
|
@@ -120,7 +133,7 @@ export function useDirtyState<T extends FormState = FormState>(
|
|
|
120
133
|
dirty.value = false
|
|
121
134
|
dirtyFields.value = []
|
|
122
135
|
initialSnapshot.value = null
|
|
123
|
-
|
|
136
|
+
initialFieldSnapshots = null
|
|
124
137
|
ready.value = false
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListAlterHooks — the list:alter / filter:alter hook wiring of
|
|
3
|
+
* useListPage (#1195, KPI-8).
|
|
4
|
+
*
|
|
5
|
+
* Mechanical extraction: snapshots the list config, runs the global and
|
|
6
|
+
* entity-scoped alter hooks, and applies the altered config back onto the
|
|
7
|
+
* live state (columns, filters, actions via the shared registry, header
|
|
8
|
+
* actions). useListPage composes it back in; behavior is unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import type { Ref } from 'vue'
|
|
11
|
+
import type {
|
|
12
|
+
ActionConfig,
|
|
13
|
+
ColumnConfig,
|
|
14
|
+
FilterConfig,
|
|
15
|
+
HeaderActionConfig,
|
|
16
|
+
ResolvedAction,
|
|
17
|
+
} from './useListPage.types'
|
|
18
|
+
import type { UseActionRegistryReturn } from './useActionRegistry'
|
|
19
|
+
|
|
20
|
+
/** Minimal HookRegistry view (what the alter flow actually uses). */
|
|
21
|
+
export interface AlterHooksLike {
|
|
22
|
+
alter: (name: string, value: unknown) => Promise<unknown>
|
|
23
|
+
hasHook: (name: string) => boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Dependencies injected by useListPage. */
|
|
27
|
+
export interface UseListAlterHooksDeps {
|
|
28
|
+
hooks: AlterHooksLike | null | undefined
|
|
29
|
+
entity: string
|
|
30
|
+
manager: unknown
|
|
31
|
+
columnsMap: Ref<Map<string, ColumnConfig>>
|
|
32
|
+
filtersMap: Ref<Map<string, FilterConfig>>
|
|
33
|
+
filterValues: Ref<Record<string, unknown>>
|
|
34
|
+
headerActionsMap: Ref<Map<string, HeaderActionConfig>>
|
|
35
|
+
actionRegistry: UseActionRegistryReturn<ActionConfig, unknown, ResolvedAction>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseListAlterHooksReturn {
|
|
39
|
+
invokeListAlterHook: () => Promise<void>
|
|
40
|
+
invokeFilterAlterHook: () => Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useListAlterHooks(deps: UseListAlterHooksDeps): UseListAlterHooksReturn {
|
|
44
|
+
const {
|
|
45
|
+
hooks,
|
|
46
|
+
entity,
|
|
47
|
+
manager,
|
|
48
|
+
columnsMap,
|
|
49
|
+
filtersMap,
|
|
50
|
+
filterValues,
|
|
51
|
+
headerActionsMap,
|
|
52
|
+
actionRegistry,
|
|
53
|
+
} = deps
|
|
54
|
+
const actionsMap = actionRegistry.actionsMap
|
|
55
|
+
|
|
56
|
+
async function invokeListAlterHook(): Promise<void> {
|
|
57
|
+
if (!hooks) return
|
|
58
|
+
|
|
59
|
+
const configSnapshot = {
|
|
60
|
+
entity,
|
|
61
|
+
columns: Array.from(columnsMap.value.values()),
|
|
62
|
+
filters: Array.from(filtersMap.value.values()),
|
|
63
|
+
actions: Array.from(actionsMap.value.values()),
|
|
64
|
+
headerActions: Array.from(headerActionsMap.value.values()),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Include entity context in the snapshot for hooks
|
|
68
|
+
const configWithContext = { ...configSnapshot, entity, manager }
|
|
69
|
+
|
|
70
|
+
let alteredConfig = await hooks.alter('list:alter', configWithContext)
|
|
71
|
+
|
|
72
|
+
const entityHookName = `${entity}:list:alter`
|
|
73
|
+
if (hooks.hasHook(entityHookName)) {
|
|
74
|
+
alteredConfig = await hooks.alter(entityHookName, alteredConfig)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
applyAlteredConfig(
|
|
78
|
+
alteredConfig as {
|
|
79
|
+
columns?: ColumnConfig[]
|
|
80
|
+
filters?: FilterConfig[]
|
|
81
|
+
actions?: ActionConfig[]
|
|
82
|
+
headerActions?: HeaderActionConfig[]
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function invokeFilterAlterHook(): Promise<void> {
|
|
88
|
+
if (!hooks) return
|
|
89
|
+
|
|
90
|
+
const filterSnapshot = {
|
|
91
|
+
entity,
|
|
92
|
+
filters: Array.from(filtersMap.value.values()),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let alteredFilters = (await hooks.alter('filter:alter', filterSnapshot)) as typeof filterSnapshot
|
|
96
|
+
|
|
97
|
+
const entityHookName = `${entity}:filter:alter`
|
|
98
|
+
if (hooks.hasHook(entityHookName)) {
|
|
99
|
+
alteredFilters = (await hooks.alter(entityHookName, alteredFilters)) as typeof filterSnapshot
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (alteredFilters.filters) {
|
|
103
|
+
filtersMap.value.clear()
|
|
104
|
+
for (const filter of alteredFilters.filters) {
|
|
105
|
+
filtersMap.value.set(filter.name, filter)
|
|
106
|
+
if (filterValues.value[filter.name] === undefined) {
|
|
107
|
+
filterValues.value[filter.name] = filter.default ?? null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applyAlteredConfig(alteredConfig: {
|
|
114
|
+
columns?: ColumnConfig[]
|
|
115
|
+
filters?: FilterConfig[]
|
|
116
|
+
actions?: ActionConfig[]
|
|
117
|
+
headerActions?: HeaderActionConfig[]
|
|
118
|
+
}): void {
|
|
119
|
+
if (alteredConfig.columns) {
|
|
120
|
+
columnsMap.value.clear()
|
|
121
|
+
for (const col of alteredConfig.columns) {
|
|
122
|
+
columnsMap.value.set(col.field, col)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (alteredConfig.filters) {
|
|
127
|
+
filtersMap.value.clear()
|
|
128
|
+
for (const filter of alteredConfig.filters) {
|
|
129
|
+
filtersMap.value.set(filter.name, filter)
|
|
130
|
+
if (filterValues.value[filter.name] === undefined) {
|
|
131
|
+
filterValues.value[filter.name] = filter.default ?? null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (alteredConfig.actions) {
|
|
137
|
+
// Route through the registry so the order list stays in sync (#1193)
|
|
138
|
+
actionRegistry.clear()
|
|
139
|
+
for (const action of alteredConfig.actions) {
|
|
140
|
+
actionRegistry.add(action)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (alteredConfig.headerActions) {
|
|
145
|
+
headerActionsMap.value.clear()
|
|
146
|
+
for (const action of alteredConfig.headerActions) {
|
|
147
|
+
headerActionsMap.value.set(action.name, action)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { invokeListAlterHook, invokeFilterAlterHook }
|
|
153
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListFilters — the filter subsystem of useListPage (#1195, KPI-8).
|
|
3
|
+
*
|
|
4
|
+
* Mechanical extraction: owns the filter state (filtersMap/filterValues),
|
|
5
|
+
* the three option-source modes (optionsEntity / optionsEndpoint /
|
|
6
|
+
* optionsFromCache), session persistence, URL sync and registry auto-load.
|
|
7
|
+
* useListPage composes it back in; behavior is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
|
10
|
+
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
|
11
|
+
import { FilterQuery, type QueryOrchestratorLike } from '../query/FilterQuery'
|
|
12
|
+
import type { FilterConfig, SearchConfig } from './useListPage.types'
|
|
13
|
+
import {
|
|
14
|
+
SMART_FILTER_THRESHOLD,
|
|
15
|
+
clearSessionFilters,
|
|
16
|
+
setSessionFilters,
|
|
17
|
+
snakeToTitle,
|
|
18
|
+
} from './useListPage.utils'
|
|
19
|
+
|
|
20
|
+
/** Dependencies injected by useListPage. */
|
|
21
|
+
export interface UseListFiltersDeps {
|
|
22
|
+
/** Entity name used for the registry lookup. */
|
|
23
|
+
entityName: string
|
|
24
|
+
/** EntityManager (needs .request for optionsEndpoint mode). */
|
|
25
|
+
manager: { request: (method: string, endpoint: string) => Promise<unknown> }
|
|
26
|
+
/** Orchestrator, forwarded to FilterQuery for optionsEntity mode. */
|
|
27
|
+
orchestrator: QueryOrchestratorLike | null | undefined
|
|
28
|
+
/** Loaded list items (source for optionsFromCache mode). */
|
|
29
|
+
items: Ref<unknown[]>
|
|
30
|
+
/** Current page ref — filter changes reset it to 1. */
|
|
31
|
+
page: Ref<number>
|
|
32
|
+
/** Search query ref (shared with the search subsystem). */
|
|
33
|
+
searchQuery: Ref<string>
|
|
34
|
+
route: RouteLocationNormalizedLoaded
|
|
35
|
+
router: Router
|
|
36
|
+
/** Session-restored filter values (already stripped of _search). */
|
|
37
|
+
savedFilters: Record<string, unknown> | null
|
|
38
|
+
persistFilters: boolean
|
|
39
|
+
syncUrlParams: boolean
|
|
40
|
+
autoLoadFilters: boolean
|
|
41
|
+
filterSessionKey: string
|
|
42
|
+
/** Entity filters registry (injected by the consuming app). */
|
|
43
|
+
entityFilters: Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>
|
|
44
|
+
/** Thunks — resolved lazily, the targets are declared later in useListPage. */
|
|
45
|
+
loadItems: () => void
|
|
46
|
+
setSearch: (searchCfg: Partial<SearchConfig>) => void
|
|
47
|
+
invokeFilterAlterHook: () => Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UseListFiltersReturn {
|
|
51
|
+
filtersMap: Ref<Map<string, FilterConfig>>
|
|
52
|
+
filterValues: Ref<Record<string, unknown>>
|
|
53
|
+
filters: ComputedRef<FilterConfig[]>
|
|
54
|
+
hasActiveFilters: ComputedRef<boolean>
|
|
55
|
+
addFilter: (name: string, filterConfig: Omit<FilterConfig, 'name'>) => void
|
|
56
|
+
removeFilter: (name: string) => void
|
|
57
|
+
setFilterValue: (name: string, value: unknown) => void
|
|
58
|
+
updateFilters: (newValues: Record<string, unknown>) => void
|
|
59
|
+
onFiltersChanged: () => void
|
|
60
|
+
clearFilters: () => void
|
|
61
|
+
isFilterAtDefault: (name: string) => boolean
|
|
62
|
+
initFromRegistry: () => void
|
|
63
|
+
loadFilterOptions: () => Promise<void>
|
|
64
|
+
updateCacheBasedFilters: () => Promise<void>
|
|
65
|
+
restoreFilters: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
69
|
+
const {
|
|
70
|
+
entityName,
|
|
71
|
+
manager,
|
|
72
|
+
orchestrator,
|
|
73
|
+
items,
|
|
74
|
+
page,
|
|
75
|
+
searchQuery,
|
|
76
|
+
route,
|
|
77
|
+
router,
|
|
78
|
+
savedFilters,
|
|
79
|
+
persistFilters,
|
|
80
|
+
syncUrlParams,
|
|
81
|
+
autoLoadFilters,
|
|
82
|
+
filterSessionKey,
|
|
83
|
+
entityFilters,
|
|
84
|
+
loadItems,
|
|
85
|
+
setSearch,
|
|
86
|
+
invokeFilterAlterHook,
|
|
87
|
+
} = deps
|
|
88
|
+
|
|
89
|
+
const filtersMap = ref<Map<string, FilterConfig>>(new Map())
|
|
90
|
+
const filterValues = ref<Record<string, unknown>>(savedFilters || {})
|
|
91
|
+
|
|
92
|
+
function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
|
|
93
|
+
filtersMap.value.set(name, {
|
|
94
|
+
name,
|
|
95
|
+
type: 'select',
|
|
96
|
+
placeholder: name,
|
|
97
|
+
...filterConfig,
|
|
98
|
+
})
|
|
99
|
+
if (filterValues.value[name] === undefined) {
|
|
100
|
+
filterValues.value[name] = filterConfig.default ?? null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function removeFilter(name: string): void {
|
|
105
|
+
filtersMap.value.delete(name)
|
|
106
|
+
delete filterValues.value[name]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setFilterValue(name: string, value: unknown): void {
|
|
110
|
+
filterValues.value = { ...filterValues.value, [name]: value }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateFilters(newValues: Record<string, unknown>): void {
|
|
114
|
+
filterValues.value = { ...filterValues.value, ...newValues }
|
|
115
|
+
onFiltersChanged()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onFiltersChanged(): void {
|
|
119
|
+
page.value = 1
|
|
120
|
+
loadItems()
|
|
121
|
+
if (persistFilters) {
|
|
122
|
+
const toPersist: Record<string, unknown> = {}
|
|
123
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
124
|
+
const filterDef = filtersMap.value.get(name)
|
|
125
|
+
if (
|
|
126
|
+
filterDef?.persist !== false &&
|
|
127
|
+
value !== null &&
|
|
128
|
+
value !== undefined &&
|
|
129
|
+
value !== ''
|
|
130
|
+
) {
|
|
131
|
+
toPersist[name] = value
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (searchQuery.value) {
|
|
135
|
+
toPersist._search = searchQuery.value
|
|
136
|
+
}
|
|
137
|
+
setSessionFilters(filterSessionKey, toPersist)
|
|
138
|
+
}
|
|
139
|
+
if (syncUrlParams) {
|
|
140
|
+
const query = { ...route.query } as Record<string, string>
|
|
141
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
142
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
143
|
+
query[name] = String(value)
|
|
144
|
+
} else {
|
|
145
|
+
delete query[name]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (searchQuery.value) {
|
|
149
|
+
query.search = searchQuery.value
|
|
150
|
+
} else {
|
|
151
|
+
delete query.search
|
|
152
|
+
}
|
|
153
|
+
router.replace({ query })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function clearFilters(): void {
|
|
158
|
+
const cleared: Record<string, unknown> = {}
|
|
159
|
+
for (const [key, filterDef] of filtersMap.value.entries()) {
|
|
160
|
+
cleared[key] = filterDef.default ?? null
|
|
161
|
+
}
|
|
162
|
+
filterValues.value = cleared
|
|
163
|
+
searchQuery.value = ''
|
|
164
|
+
if (persistFilters) {
|
|
165
|
+
clearSessionFilters(filterSessionKey)
|
|
166
|
+
}
|
|
167
|
+
if (syncUrlParams) {
|
|
168
|
+
const query = { ...route.query } as Record<string, string>
|
|
169
|
+
for (const key of filtersMap.value.keys()) {
|
|
170
|
+
delete query[key]
|
|
171
|
+
}
|
|
172
|
+
delete query.search
|
|
173
|
+
router.replace({ query })
|
|
174
|
+
}
|
|
175
|
+
page.value = 1
|
|
176
|
+
loadItems()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const filters = computed(() => Array.from(filtersMap.value.values()))
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if a filter is at its default value
|
|
183
|
+
* Used for styling: default = blue (info), modified = orange (warning)
|
|
184
|
+
*/
|
|
185
|
+
function isFilterAtDefault(name: string): boolean {
|
|
186
|
+
const filterDef = filtersMap.value.get(name)
|
|
187
|
+
if (!filterDef) return true
|
|
188
|
+
const currentValue = filterValues.value[name]
|
|
189
|
+
const defaultValue = filterDef.default ?? null
|
|
190
|
+
return currentValue === defaultValue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if any filter is NOT at its default value (or search is active)
|
|
195
|
+
* Useful to show a "clear filters" button only when needed
|
|
196
|
+
*/
|
|
197
|
+
const hasActiveFilters = computed(() => {
|
|
198
|
+
if (searchQuery.value) return true
|
|
199
|
+
for (const [name, filterDef] of filtersMap.value.entries()) {
|
|
200
|
+
const currentValue = filterValues.value[name]
|
|
201
|
+
const defaultValue = filterDef.default ?? null
|
|
202
|
+
if (currentValue !== defaultValue) return true
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
function initFromRegistry(): void {
|
|
208
|
+
if (!autoLoadFilters) return
|
|
209
|
+
|
|
210
|
+
const entityConfig = entityFilters[entityName]
|
|
211
|
+
if (!entityConfig) return
|
|
212
|
+
|
|
213
|
+
if (entityConfig.search) {
|
|
214
|
+
setSearch(entityConfig.search)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (entityConfig.filters) {
|
|
218
|
+
for (const filterDef of entityConfig.filters) {
|
|
219
|
+
addFilter(filterDef.name, filterDef)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loadFilterOptions(): Promise<void> {
|
|
225
|
+
// Process filters configured directly via addFilter() (smart filter modes)
|
|
226
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
227
|
+
if (filterDef.options && filterDef.options.length > 1) continue
|
|
228
|
+
if (filterDef.optionsFromCache) continue
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
let rawOptions: Array<{ label: string; value: unknown }> | null = null
|
|
232
|
+
|
|
233
|
+
// Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
|
|
234
|
+
if (filterDef.optionsEntity) {
|
|
235
|
+
const filterQuery = new FilterQuery({
|
|
236
|
+
source: 'entity',
|
|
237
|
+
entity: filterDef.optionsEntity,
|
|
238
|
+
label: filterDef.optionLabel || 'name',
|
|
239
|
+
value: filterDef.optionValue || 'id',
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
rawOptions = (await filterQuery.getOptions(orchestrator)) as Array<{
|
|
243
|
+
label: string
|
|
244
|
+
value: unknown
|
|
245
|
+
}>
|
|
246
|
+
filterDef._filterQuery = filterQuery
|
|
247
|
+
}
|
|
248
|
+
// Mode 2: optionsEndpoint - fetch from API endpoint
|
|
249
|
+
else if (filterDef.optionsEndpoint) {
|
|
250
|
+
const endpoint =
|
|
251
|
+
filterDef.optionsEndpoint === true
|
|
252
|
+
? `distinct/${filterName}`
|
|
253
|
+
: filterDef.optionsEndpoint
|
|
254
|
+
const response = await manager.request('GET', endpoint as string)
|
|
255
|
+
const data = Array.isArray(response)
|
|
256
|
+
? response
|
|
257
|
+
: ((response as Record<string, unknown>)?.items as unknown[]) || []
|
|
258
|
+
rawOptions = data.map((opt) => {
|
|
259
|
+
if (typeof opt === 'object' && opt !== null) {
|
|
260
|
+
const o = opt as Record<string, unknown>
|
|
261
|
+
return {
|
|
262
|
+
label: (o.label || o.name || String(o.value ?? o.id)) as string,
|
|
263
|
+
value: o.value ?? o.id,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { label: snakeToTitle(String(opt)), value: opt }
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (rawOptions !== null) {
|
|
271
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
272
|
+
let shouldCache = cacheOptions === true
|
|
273
|
+
|
|
274
|
+
if (cacheOptions === 'auto') {
|
|
275
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const componentType =
|
|
279
|
+
filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
|
|
280
|
+
const allLabel =
|
|
281
|
+
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
282
|
+
let finalOptions = [{ label: allLabel, value: null as unknown }, ...rawOptions]
|
|
283
|
+
|
|
284
|
+
if (typeof filterDef.processor === 'function') {
|
|
285
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const updatedFilter: FilterConfig = {
|
|
289
|
+
...filterDef,
|
|
290
|
+
options: finalOptions,
|
|
291
|
+
type: componentType,
|
|
292
|
+
_cacheOptions: shouldCache,
|
|
293
|
+
_optionsLoaded: shouldCache,
|
|
294
|
+
}
|
|
295
|
+
delete updatedFilter.optionLabel
|
|
296
|
+
delete updatedFilter.optionValue
|
|
297
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Invoke filter:alter hooks after all options are loaded
|
|
305
|
+
await invokeFilterAlterHook()
|
|
306
|
+
|
|
307
|
+
// Trigger Vue reactivity
|
|
308
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function updateCacheBasedFilters(): Promise<void> {
|
|
312
|
+
if (items.value.length === 0) return
|
|
313
|
+
|
|
314
|
+
let hasChanges = false
|
|
315
|
+
|
|
316
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
317
|
+
if (!filterDef.optionsFromCache) continue
|
|
318
|
+
if (filterDef._optionsLoaded) continue
|
|
319
|
+
// Skip if filter has an explicit query property (advanced usage)
|
|
320
|
+
if (filterDef.query) continue
|
|
321
|
+
|
|
322
|
+
const currentValue = filterValues.value[filterName]
|
|
323
|
+
if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const fieldName =
|
|
328
|
+
typeof filterDef.optionsFromCache === 'string' ? filterDef.optionsFromCache : filterName
|
|
329
|
+
|
|
330
|
+
const filterQuery = new FilterQuery({
|
|
331
|
+
source: 'field',
|
|
332
|
+
field: fieldName,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Provide a minimal mock manager with cached data for field-based filtering
|
|
336
|
+
filterQuery.setParentManager({
|
|
337
|
+
_cache: items.value,
|
|
338
|
+
list: async () => ({ items: items.value }),
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const rawOptions = (await filterQuery.getOptions()) as Array<{ label: string; value: unknown }>
|
|
342
|
+
|
|
343
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
344
|
+
let shouldCache = true
|
|
345
|
+
|
|
346
|
+
if (cacheOptions === 'auto') {
|
|
347
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
348
|
+
} else if (cacheOptions === false) {
|
|
349
|
+
shouldCache = false
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const componentType =
|
|
353
|
+
filterDef.component ||
|
|
354
|
+
(rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
|
|
355
|
+
|
|
356
|
+
const allLabel =
|
|
357
|
+
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
358
|
+
let finalOptions = [
|
|
359
|
+
{ label: allLabel, value: null as unknown },
|
|
360
|
+
...rawOptions.map((opt) => ({
|
|
361
|
+
label: snakeToTitle(String(opt.label)),
|
|
362
|
+
value: opt.value,
|
|
363
|
+
})),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
if (typeof filterDef.processor === 'function') {
|
|
367
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const updatedFilter: FilterConfig = {
|
|
371
|
+
...filterDef,
|
|
372
|
+
options: finalOptions,
|
|
373
|
+
type: componentType,
|
|
374
|
+
_cacheOptions: shouldCache,
|
|
375
|
+
_optionsLoaded: true,
|
|
376
|
+
_filterQuery: filterQuery,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
380
|
+
hasChanges = true
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (hasChanges) {
|
|
384
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function restoreFilters(): void {
|
|
389
|
+
for (const key of filtersMap.value.keys()) {
|
|
390
|
+
if (route.query[key] !== undefined) {
|
|
391
|
+
let value: unknown = route.query[key]
|
|
392
|
+
if (value === 'true') value = true
|
|
393
|
+
else if (value === 'false') value = false
|
|
394
|
+
else if (value === 'null') value = null
|
|
395
|
+
else if (!isNaN(Number(value)) && value !== '') value = Number(value)
|
|
396
|
+
filterValues.value[key] = value
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (route.query.search) {
|
|
400
|
+
searchQuery.value = route.query.search as string
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
filtersMap,
|
|
406
|
+
filterValues,
|
|
407
|
+
filters,
|
|
408
|
+
hasActiveFilters,
|
|
409
|
+
addFilter,
|
|
410
|
+
removeFilter,
|
|
411
|
+
setFilterValue,
|
|
412
|
+
updateFilters,
|
|
413
|
+
onFiltersChanged,
|
|
414
|
+
clearFilters,
|
|
415
|
+
isFilterAtDefault,
|
|
416
|
+
initFromRegistry,
|
|
417
|
+
loadFilterOptions,
|
|
418
|
+
updateCacheBasedFilters,
|
|
419
|
+
restoreFilters,
|
|
420
|
+
}
|
|
421
|
+
}
|
|
@@ -33,7 +33,6 @@ import { useConfirm } from 'primevue/useconfirm'
|
|
|
33
33
|
import { useHooks } from './useHooks.js'
|
|
34
34
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
35
35
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
|
-
import { FilterQuery } from '../query/FilterQuery'
|
|
37
36
|
import { useI18n } from '../i18n/useI18n'
|
|
38
37
|
import { formatDateOnly } from '../utils/formatters'
|
|
39
38
|
|
|
@@ -85,17 +84,15 @@ export type {
|
|
|
85
84
|
// Stateless utilities (cookies, session storage, formatters, constants).
|
|
86
85
|
import {
|
|
87
86
|
PAGE_SIZE_OPTIONS,
|
|
88
|
-
SMART_FILTER_THRESHOLD,
|
|
89
|
-
clearSessionFilters,
|
|
90
87
|
getSavedPageSize,
|
|
91
88
|
getSessionFilters,
|
|
92
89
|
getSessionSort,
|
|
93
90
|
setSessionSort,
|
|
94
91
|
persistPageSize,
|
|
95
|
-
setSessionFilters,
|
|
96
|
-
snakeToTitle,
|
|
97
92
|
} from './useListPage.utils'
|
|
98
93
|
import { useActionRegistry } from './useActionRegistry'
|
|
94
|
+
import { useListFilters } from './useListPage.filters'
|
|
95
|
+
import { useListAlterHooks } from './useListPage.alterHooks'
|
|
99
96
|
import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
|
|
100
97
|
import { createOrchestratorToast } from './useOrchestratorToast'
|
|
101
98
|
|
|
@@ -514,323 +511,45 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
514
511
|
|
|
515
512
|
const cards = computed(() => Array.from(cardsMap.value.values()))
|
|
516
513
|
|
|
517
|
-
// ============ FILTERS ============
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
function setFilterValue(name: string, value: unknown): void {
|
|
539
|
-
filterValues.value = { ...filterValues.value, [name]: value }
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function updateFilters(newValues: Record<string, unknown>): void {
|
|
543
|
-
filterValues.value = { ...filterValues.value, ...newValues }
|
|
544
|
-
onFiltersChanged()
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function onFiltersChanged(): void {
|
|
548
|
-
page.value = 1
|
|
549
|
-
loadItems()
|
|
550
|
-
if (persistFilters) {
|
|
551
|
-
const toPersist: Record<string, unknown> = {}
|
|
552
|
-
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
553
|
-
const filterDef = filtersMap.value.get(name)
|
|
554
|
-
if (
|
|
555
|
-
filterDef?.persist !== false &&
|
|
556
|
-
value !== null &&
|
|
557
|
-
value !== undefined &&
|
|
558
|
-
value !== ''
|
|
559
|
-
) {
|
|
560
|
-
toPersist[name] = value
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
if (searchQuery.value) {
|
|
564
|
-
toPersist._search = searchQuery.value
|
|
565
|
-
}
|
|
566
|
-
setSessionFilters(filterSessionKey, toPersist)
|
|
567
|
-
}
|
|
568
|
-
if (syncUrlParams) {
|
|
569
|
-
const query = { ...route.query } as Record<string, string>
|
|
570
|
-
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
571
|
-
if (value !== null && value !== undefined && value !== '') {
|
|
572
|
-
query[name] = String(value)
|
|
573
|
-
} else {
|
|
574
|
-
delete query[name]
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (searchQuery.value) {
|
|
578
|
-
query.search = searchQuery.value
|
|
579
|
-
} else {
|
|
580
|
-
delete query.search
|
|
581
|
-
}
|
|
582
|
-
router.replace({ query })
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function clearFilters(): void {
|
|
587
|
-
const cleared: Record<string, unknown> = {}
|
|
588
|
-
for (const [key, filterDef] of filtersMap.value.entries()) {
|
|
589
|
-
cleared[key] = filterDef.default ?? null
|
|
590
|
-
}
|
|
591
|
-
filterValues.value = cleared
|
|
592
|
-
searchQuery.value = ''
|
|
593
|
-
if (persistFilters) {
|
|
594
|
-
clearSessionFilters(filterSessionKey)
|
|
595
|
-
}
|
|
596
|
-
if (syncUrlParams) {
|
|
597
|
-
const query = { ...route.query } as Record<string, string>
|
|
598
|
-
for (const key of filtersMap.value.keys()) {
|
|
599
|
-
delete query[key]
|
|
600
|
-
}
|
|
601
|
-
delete query.search
|
|
602
|
-
router.replace({ query })
|
|
603
|
-
}
|
|
604
|
-
page.value = 1
|
|
605
|
-
loadItems()
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const filters = computed(() => Array.from(filtersMap.value.values()))
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Check if a filter is at its default value
|
|
612
|
-
* Used for styling: default = blue (info), modified = orange (warning)
|
|
613
|
-
*/
|
|
614
|
-
function isFilterAtDefault(name: string): boolean {
|
|
615
|
-
const filterDef = filtersMap.value.get(name)
|
|
616
|
-
if (!filterDef) return true
|
|
617
|
-
const currentValue = filterValues.value[name]
|
|
618
|
-
const defaultValue = filterDef.default ?? null
|
|
619
|
-
return currentValue === defaultValue
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Check if any filter is NOT at its default value (or search is active)
|
|
624
|
-
* Useful to show a "clear filters" button only when needed
|
|
625
|
-
*/
|
|
626
|
-
const hasActiveFilters = computed(() => {
|
|
627
|
-
if (searchQuery.value) return true
|
|
628
|
-
for (const [name, filterDef] of filtersMap.value.entries()) {
|
|
629
|
-
const currentValue = filterValues.value[name]
|
|
630
|
-
const defaultValue = filterDef.default ?? null
|
|
631
|
-
if (currentValue !== defaultValue) return true
|
|
632
|
-
}
|
|
633
|
-
return false
|
|
514
|
+
// ============ FILTERS (#1195 — extracted subsystem) ============
|
|
515
|
+
// invokeFilterAlterHook is a thunk: alterHooks is declared after the
|
|
516
|
+
// actions section (it needs the action registry) and only runs post-setup.
|
|
517
|
+
const listFilters = useListFilters({
|
|
518
|
+
entityName,
|
|
519
|
+
manager,
|
|
520
|
+
orchestrator,
|
|
521
|
+
items: items as Ref<unknown[]>,
|
|
522
|
+
page,
|
|
523
|
+
searchQuery,
|
|
524
|
+
route,
|
|
525
|
+
router,
|
|
526
|
+
savedFilters,
|
|
527
|
+
persistFilters,
|
|
528
|
+
syncUrlParams,
|
|
529
|
+
autoLoadFilters,
|
|
530
|
+
filterSessionKey,
|
|
531
|
+
entityFilters,
|
|
532
|
+
loadItems: () => loadItems(),
|
|
533
|
+
setSearch: (searchCfg) => setSearch(searchCfg),
|
|
534
|
+
invokeFilterAlterHook: () => alterHooks.invokeFilterAlterHook(),
|
|
634
535
|
})
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
async function loadFilterOptions(): Promise<void> {
|
|
656
|
-
// Process filters configured directly via addFilter() (smart filter modes)
|
|
657
|
-
for (const [filterName, filterDef] of filtersMap.value) {
|
|
658
|
-
if (filterDef.options && filterDef.options.length > 1) continue
|
|
659
|
-
if (filterDef.optionsFromCache) continue
|
|
660
|
-
|
|
661
|
-
try {
|
|
662
|
-
let rawOptions: Array<{ label: string; value: unknown }> | null = null
|
|
663
|
-
|
|
664
|
-
// Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
|
|
665
|
-
if (filterDef.optionsEntity) {
|
|
666
|
-
const filterQuery = new FilterQuery({
|
|
667
|
-
source: 'entity',
|
|
668
|
-
entity: filterDef.optionsEntity,
|
|
669
|
-
label: filterDef.optionLabel || 'name',
|
|
670
|
-
value: filterDef.optionValue || 'id',
|
|
671
|
-
})
|
|
672
|
-
|
|
673
|
-
rawOptions = (await filterQuery.getOptions(orchestrator)) as Array<{
|
|
674
|
-
label: string
|
|
675
|
-
value: unknown
|
|
676
|
-
}>
|
|
677
|
-
filterDef._filterQuery = filterQuery
|
|
678
|
-
}
|
|
679
|
-
// Mode 2: optionsEndpoint - fetch from API endpoint
|
|
680
|
-
else if (filterDef.optionsEndpoint) {
|
|
681
|
-
const endpoint =
|
|
682
|
-
filterDef.optionsEndpoint === true
|
|
683
|
-
? `distinct/${filterName}`
|
|
684
|
-
: filterDef.optionsEndpoint
|
|
685
|
-
const response = await manager.request('GET', endpoint as string)
|
|
686
|
-
const data = Array.isArray(response)
|
|
687
|
-
? response
|
|
688
|
-
: ((response as Record<string, unknown>)?.items as unknown[]) || []
|
|
689
|
-
rawOptions = data.map((opt) => {
|
|
690
|
-
if (typeof opt === 'object' && opt !== null) {
|
|
691
|
-
const o = opt as Record<string, unknown>
|
|
692
|
-
return {
|
|
693
|
-
label: (o.label || o.name || String(o.value ?? o.id)) as string,
|
|
694
|
-
value: o.value ?? o.id,
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return { label: snakeToTitle(String(opt)), value: opt }
|
|
698
|
-
})
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (rawOptions !== null) {
|
|
702
|
-
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
703
|
-
let shouldCache = cacheOptions === true
|
|
704
|
-
|
|
705
|
-
if (cacheOptions === 'auto') {
|
|
706
|
-
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const componentType =
|
|
710
|
-
filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
|
|
711
|
-
const allLabel =
|
|
712
|
-
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
713
|
-
let finalOptions = [{ label: allLabel, value: null as unknown }, ...rawOptions]
|
|
714
|
-
|
|
715
|
-
if (typeof filterDef.processor === 'function') {
|
|
716
|
-
finalOptions = filterDef.processor(finalOptions)
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const updatedFilter: FilterConfig = {
|
|
720
|
-
...filterDef,
|
|
721
|
-
options: finalOptions,
|
|
722
|
-
type: componentType,
|
|
723
|
-
_cacheOptions: shouldCache,
|
|
724
|
-
_optionsLoaded: shouldCache,
|
|
725
|
-
}
|
|
726
|
-
delete updatedFilter.optionLabel
|
|
727
|
-
delete updatedFilter.optionValue
|
|
728
|
-
filtersMap.value.set(filterName, updatedFilter)
|
|
729
|
-
}
|
|
730
|
-
} catch (error) {
|
|
731
|
-
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Invoke filter:alter hooks after all options are loaded
|
|
736
|
-
await invokeFilterAlterHook()
|
|
737
|
-
|
|
738
|
-
// Trigger Vue reactivity
|
|
739
|
-
filtersMap.value = new Map(filtersMap.value)
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
async function updateCacheBasedFilters(): Promise<void> {
|
|
743
|
-
if (items.value.length === 0) return
|
|
744
|
-
|
|
745
|
-
let hasChanges = false
|
|
746
|
-
|
|
747
|
-
for (const [filterName, filterDef] of filtersMap.value) {
|
|
748
|
-
if (!filterDef.optionsFromCache) continue
|
|
749
|
-
if (filterDef._optionsLoaded) continue
|
|
750
|
-
// Skip if filter has an explicit query property (advanced usage)
|
|
751
|
-
if (filterDef.query) continue
|
|
752
|
-
|
|
753
|
-
const currentValue = filterValues.value[filterName]
|
|
754
|
-
if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
|
|
755
|
-
continue
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
const fieldName =
|
|
759
|
-
typeof filterDef.optionsFromCache === 'string' ? filterDef.optionsFromCache : filterName
|
|
760
|
-
|
|
761
|
-
const filterQuery = new FilterQuery({
|
|
762
|
-
source: 'field',
|
|
763
|
-
field: fieldName,
|
|
764
|
-
})
|
|
765
|
-
|
|
766
|
-
// Provide a minimal mock manager with cached data for field-based filtering
|
|
767
|
-
filterQuery.setParentManager({
|
|
768
|
-
_cache: items.value,
|
|
769
|
-
list: async () => ({ items: items.value }),
|
|
770
|
-
})
|
|
771
|
-
|
|
772
|
-
const rawOptions = (await filterQuery.getOptions()) as Array<{ label: string; value: unknown }>
|
|
773
|
-
|
|
774
|
-
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
775
|
-
let shouldCache = true
|
|
776
|
-
|
|
777
|
-
if (cacheOptions === 'auto') {
|
|
778
|
-
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
779
|
-
} else if (cacheOptions === false) {
|
|
780
|
-
shouldCache = false
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const componentType =
|
|
784
|
-
filterDef.component ||
|
|
785
|
-
(rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
|
|
786
|
-
|
|
787
|
-
const allLabel =
|
|
788
|
-
filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
789
|
-
let finalOptions = [
|
|
790
|
-
{ label: allLabel, value: null as unknown },
|
|
791
|
-
...rawOptions.map((opt) => ({
|
|
792
|
-
label: snakeToTitle(String(opt.label)),
|
|
793
|
-
value: opt.value,
|
|
794
|
-
})),
|
|
795
|
-
]
|
|
796
|
-
|
|
797
|
-
if (typeof filterDef.processor === 'function') {
|
|
798
|
-
finalOptions = filterDef.processor(finalOptions)
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const updatedFilter: FilterConfig = {
|
|
802
|
-
...filterDef,
|
|
803
|
-
options: finalOptions,
|
|
804
|
-
type: componentType,
|
|
805
|
-
_cacheOptions: shouldCache,
|
|
806
|
-
_optionsLoaded: true,
|
|
807
|
-
_filterQuery: filterQuery,
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
filtersMap.value.set(filterName, updatedFilter)
|
|
811
|
-
hasChanges = true
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
if (hasChanges) {
|
|
815
|
-
filtersMap.value = new Map(filtersMap.value)
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function restoreFilters(): void {
|
|
820
|
-
for (const key of filtersMap.value.keys()) {
|
|
821
|
-
if (route.query[key] !== undefined) {
|
|
822
|
-
let value: unknown = route.query[key]
|
|
823
|
-
if (value === 'true') value = true
|
|
824
|
-
else if (value === 'false') value = false
|
|
825
|
-
else if (value === 'null') value = null
|
|
826
|
-
else if (!isNaN(Number(value)) && value !== '') value = Number(value)
|
|
827
|
-
filterValues.value[key] = value
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
if (route.query.search) {
|
|
831
|
-
searchQuery.value = route.query.search as string
|
|
832
|
-
}
|
|
833
|
-
}
|
|
536
|
+
const {
|
|
537
|
+
filtersMap,
|
|
538
|
+
filterValues,
|
|
539
|
+
filters,
|
|
540
|
+
hasActiveFilters,
|
|
541
|
+
addFilter,
|
|
542
|
+
removeFilter,
|
|
543
|
+
setFilterValue,
|
|
544
|
+
updateFilters,
|
|
545
|
+
onFiltersChanged,
|
|
546
|
+
clearFilters,
|
|
547
|
+
isFilterAtDefault,
|
|
548
|
+
initFromRegistry,
|
|
549
|
+
loadFilterOptions,
|
|
550
|
+
updateCacheBasedFilters,
|
|
551
|
+
restoreFilters,
|
|
552
|
+
} = listFilters
|
|
834
553
|
|
|
835
554
|
// ============ ACTIONS ============
|
|
836
555
|
function resolveValue<T>(value: T | ((row: unknown) => T), row: unknown): T {
|
|
@@ -852,7 +571,6 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
852
571
|
}
|
|
853
572
|
},
|
|
854
573
|
})
|
|
855
|
-
const actionsMap = actionRegistry.actionsMap
|
|
856
574
|
|
|
857
575
|
function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
|
|
858
576
|
actionRegistry.add({ name, ...actionConfig } as ActionConfig)
|
|
@@ -1215,96 +933,18 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1215
933
|
}
|
|
1216
934
|
})
|
|
1217
935
|
|
|
1218
|
-
// ============ HOOKS ============
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
// Include entity context in the snapshot for hooks
|
|
1232
|
-
const configWithContext = { ...configSnapshot, entity, manager }
|
|
1233
|
-
|
|
1234
|
-
let alteredConfig = await hooks.alter('list:alter', configWithContext)
|
|
1235
|
-
|
|
1236
|
-
const entityHookName = `${entity}:list:alter`
|
|
1237
|
-
if (hooks.hasHook(entityHookName)) {
|
|
1238
|
-
alteredConfig = await hooks.alter(entityHookName, alteredConfig)
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
applyAlteredConfig(alteredConfig)
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
async function invokeFilterAlterHook(): Promise<void> {
|
|
1245
|
-
if (!hooks) return
|
|
1246
|
-
|
|
1247
|
-
const filterSnapshot = {
|
|
1248
|
-
entity,
|
|
1249
|
-
filters: Array.from(filtersMap.value.values()),
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
let alteredFilters = (await hooks.alter('filter:alter', filterSnapshot)) as typeof filterSnapshot
|
|
1253
|
-
|
|
1254
|
-
const entityHookName = `${entity}:filter:alter`
|
|
1255
|
-
if (hooks.hasHook(entityHookName)) {
|
|
1256
|
-
alteredFilters = (await hooks.alter(entityHookName, alteredFilters)) as typeof filterSnapshot
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
if (alteredFilters.filters) {
|
|
1260
|
-
filtersMap.value.clear()
|
|
1261
|
-
for (const filter of alteredFilters.filters) {
|
|
1262
|
-
filtersMap.value.set(filter.name, filter)
|
|
1263
|
-
if (filterValues.value[filter.name] === undefined) {
|
|
1264
|
-
filterValues.value[filter.name] = filter.default ?? null
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
function applyAlteredConfig(alteredConfig: {
|
|
1271
|
-
columns?: ColumnConfig[]
|
|
1272
|
-
filters?: FilterConfig[]
|
|
1273
|
-
actions?: ActionConfig[]
|
|
1274
|
-
headerActions?: HeaderActionConfig[]
|
|
1275
|
-
}): void {
|
|
1276
|
-
if (alteredConfig.columns) {
|
|
1277
|
-
columnsMap.value.clear()
|
|
1278
|
-
for (const col of alteredConfig.columns) {
|
|
1279
|
-
columnsMap.value.set(col.field, col)
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
if (alteredConfig.filters) {
|
|
1284
|
-
filtersMap.value.clear()
|
|
1285
|
-
for (const filter of alteredConfig.filters) {
|
|
1286
|
-
filtersMap.value.set(filter.name, filter)
|
|
1287
|
-
if (filterValues.value[filter.name] === undefined) {
|
|
1288
|
-
filterValues.value[filter.name] = filter.default ?? null
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
if (alteredConfig.actions) {
|
|
1294
|
-
// Route through the registry so the order list stays in sync (#1193)
|
|
1295
|
-
actionRegistry.clear()
|
|
1296
|
-
for (const action of alteredConfig.actions) {
|
|
1297
|
-
actionRegistry.add(action)
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
if (alteredConfig.headerActions) {
|
|
1302
|
-
headerActionsMap.value.clear()
|
|
1303
|
-
for (const action of alteredConfig.headerActions) {
|
|
1304
|
-
headerActionsMap.value.set(action.name, action)
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
936
|
+
// ============ HOOKS (#1195 — extracted subsystem) ============
|
|
937
|
+
const alterHooks = useListAlterHooks({
|
|
938
|
+
hooks,
|
|
939
|
+
entity,
|
|
940
|
+
manager,
|
|
941
|
+
columnsMap,
|
|
942
|
+
filtersMap,
|
|
943
|
+
filterValues,
|
|
944
|
+
headerActionsMap,
|
|
945
|
+
actionRegistry,
|
|
946
|
+
})
|
|
947
|
+
const { invokeListAlterHook } = alterHooks
|
|
1308
948
|
|
|
1309
949
|
// ============ LIFECYCLE ============
|
|
1310
950
|
initFromRegistry()
|
|
@@ -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
|
|
|
@@ -172,17 +173,22 @@ export function applyQueryMethods(EntityManagerClass: { prototype: any }): void
|
|
|
172
173
|
// may still BE cache.items — an in-place sort would mutate the cache,
|
|
173
174
|
// making later unsorted reads return whatever order the last sort left.
|
|
174
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')
|
|
175
180
|
result = [...result]
|
|
176
181
|
result.sort((a: any, b: any) => {
|
|
177
182
|
const aVal = a[sort_by]
|
|
178
183
|
const bVal = b[sort_by]
|
|
179
184
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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.
|
|
183
189
|
if (aVal == null && bVal == null) return 0
|
|
184
|
-
if (aVal == null) return
|
|
185
|
-
if (bVal == null) return -
|
|
190
|
+
if (aVal == null) return nullCmp
|
|
191
|
+
if (bVal == null) return -nullCmp
|
|
186
192
|
|
|
187
193
|
// Compare
|
|
188
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. */
|
package/src/index.ts
CHANGED
|
@@ -305,8 +305,11 @@ export {
|
|
|
305
305
|
searchItems,
|
|
306
306
|
paginate,
|
|
307
307
|
defaultGenerateId,
|
|
308
|
+
nullSortRank,
|
|
308
309
|
type SortOrder,
|
|
309
310
|
type FilterItemsOptions,
|
|
311
|
+
type NullSortMode,
|
|
312
|
+
type SortItemsOptions,
|
|
310
313
|
} from './query/clientFilter'
|
|
311
314
|
export { useChildPage, type UseChildPageReturn } from './composables/useChildPage'
|
|
312
315
|
export { useSidebarState, SIDEBAR_COLLAPSED_KEY } from './composables/useSidebarState'
|
package/src/query/FilterQuery.ts
CHANGED
|
@@ -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
|