@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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
- const initialState = ref<T | null>(null)
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
- // Store raw state for field-level comparison
75
- initialState.value = JSON.parse(JSON.stringify(state)) as T
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
- if (dirty.value && initialState.value) {
89
- const current = getState()
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 initialVal = JSON.stringify(initial[key])
104
- const currentVal = JSON.stringify(currentForm[key])
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
- initialState.value = null
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
- const filtersMap = ref<Map<string, FilterConfig>>(new Map())
519
- const filterValues = ref<Record<string, unknown>>(savedFilters || {})
520
-
521
- function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
522
- filtersMap.value.set(name, {
523
- name,
524
- type: 'select',
525
- placeholder: name,
526
- ...filterConfig,
527
- })
528
- if (filterValues.value[name] === undefined) {
529
- filterValues.value[name] = filterConfig.default ?? null
530
- }
531
- }
532
-
533
- function removeFilter(name: string): void {
534
- filtersMap.value.delete(name)
535
- delete filterValues.value[name]
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
- // ============ AUTO-LOAD FROM REGISTRY ============
637
-
638
- function initFromRegistry(): void {
639
- if (!autoLoadFilters) return
640
-
641
- const entityConfig = entityFilters[entityName]
642
- if (!entityConfig) return
643
-
644
- if (entityConfig.search) {
645
- setSearch(entityConfig.search)
646
- }
647
-
648
- if (entityConfig.filters) {
649
- for (const filterDef of entityConfig.filters) {
650
- addFilter(filterDef.name, filterDef)
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
- async function invokeListAlterHook(): Promise<void> {
1221
- if (!hooks) return
1222
-
1223
- const configSnapshot = {
1224
- entity,
1225
- columns: Array.from(columnsMap.value.values()),
1226
- filters: Array.from(filtersMap.value.values()),
1227
- actions: Array.from(actionsMap.value.values()),
1228
- headerActions: Array.from(headerActionsMap.value.values()),
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
- // Nulls always sort LAST, both directions (#1221) — matches the
181
- // storage adapters' shared comparator; direction-aware placement
182
- // put "never seen" rows on top of every desc date sort.
185
+ // Null placement is configurable per field/manager (#1222 —
186
+ // depends on the API and the field semantics). Default 'last'
187
+ // keeps the #1221 behavior; e.g. nullSort: 'low' makes "Never"
188
+ // sort beyond the oldest period on a last-seen date column.
183
189
  if (aVal == null && bVal == null) return 0
184
- if (aVal == null) return 1
185
- if (bVal == null) return -1
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'
@@ -73,7 +73,7 @@ interface QueryManagerLike {
73
73
  /**
74
74
  * Minimal interface for Orchestrator (to avoid circular deps)
75
75
  */
76
- interface QueryOrchestratorLike {
76
+ export interface QueryOrchestratorLike {
77
77
  get(name: string): QueryManagerLike | null | undefined
78
78
  }
79
79
 
@@ -15,14 +15,55 @@
15
15
 
16
16
  export type SortOrder = 'asc' | 'desc'
17
17
 
18
- /** Sort by a field, nulls/undefined last — the canonical adapter comparator. */
19
- export function sortItems<T>(items: T[], sortBy?: string | null, sortOrder: SortOrder = 'asc'): T[] {
18
+ /**
19
+ * Null placement when sorting (#1222 — configurable, it depends on the
20
+ * API and the field semantics):
21
+ * - 'last' (default): nulls at the end, both directions — the historical
22
+ * adapter behavior, keeps empty rows out of the way.
23
+ * - 'first': nulls at the top, both directions.
24
+ * - 'low': null behaves as the lowest value — first in asc, last in
25
+ * desc. Right for "last seen" dates ("Never" sorts beyond the oldest
26
+ * period); equals PrimeVue nullSortOrder: -1.
27
+ * - 'high': null behaves as the highest value — last in asc, first in desc.
28
+ */
29
+ export type NullSortMode = 'first' | 'last' | 'low' | 'high'
30
+
31
+ export interface SortItemsOptions {
32
+ nulls?: NullSortMode
33
+ }
34
+
35
+ export function nullSortRank(mode: NullSortMode, sortOrder: SortOrder): number {
36
+ // Return the comparator result for "a is null, b is not"
37
+ switch (mode) {
38
+ case 'first':
39
+ return -1
40
+ case 'low':
41
+ return sortOrder === 'asc' ? -1 : 1
42
+ case 'high':
43
+ return sortOrder === 'asc' ? 1 : -1
44
+ case 'last':
45
+ default:
46
+ return 1
47
+ }
48
+ }
49
+
50
+ /** Sort by a field — the canonical comparator (null placement configurable). */
51
+ export function sortItems<T>(
52
+ items: T[],
53
+ sortBy?: string | null,
54
+ sortOrder: SortOrder = 'asc',
55
+ options: SortItemsOptions = {}
56
+ ): T[] {
20
57
  if (!sortBy) return items
58
+ const { nulls = 'last' } = options
21
59
  return items.sort((a, b) => {
22
60
  const aVal = a[sortBy as keyof T]
23
61
  const bVal = b[sortBy as keyof T]
24
- if (aVal === undefined || aVal === null) return 1
25
- if (bVal === undefined || bVal === null) return -1
62
+ const aNull = aVal === undefined || aVal === null
63
+ const bNull = bVal === undefined || bVal === null
64
+ if (aNull && bNull) return 0
65
+ if (aNull) return nullSortRank(nulls, sortOrder)
66
+ if (bNull) return -nullSortRank(nulls, sortOrder)
26
67
  if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1
27
68
  if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1
28
69
  return 0
@@ -79,6 +79,8 @@ export type FieldType =
79
79
  * Field configuration
80
80
  */
81
81
  export interface FieldConfig {
82
+ /** Null placement when this field is locally sorted: 'first' | 'last' | 'low' | 'high' (#1222). */
83
+ nullSort?: 'first' | 'last' | 'low' | 'high'
82
84
  type: FieldType
83
85
  label?: string
84
86
  required?: boolean