@quazardous/qdadm 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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'
@@ -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