@quazardous/qdadm 2.5.0 → 2.6.1

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.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -46,8 +46,8 @@
46
46
  "LICENSE"
47
47
  ],
48
48
  "dependencies": {
49
- "@quazardous/qdcore": "^0.2.1",
50
- "@quazardous/qddebug": "^0.2.1",
49
+ "@quazardous/qdcore": "^1.0.0",
50
+ "@quazardous/qddebug": "^1.0.0",
51
51
  "@quazardous/quarkernel": "^2.1.0",
52
52
  "pluralize": "^8.0.0",
53
53
  "yaml": "^2.8.4"
@@ -261,26 +261,47 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
261
261
  // when the i18n bundle has no translation for the field.
262
262
  const columnInlineHeaders = new Map<string, string | undefined>()
263
263
 
264
+ // One-shot per entity+field (#1255): the catalog winning over an explicit
265
+ // header is by design, but it must never happen silently — the day a key
266
+ // appears, the page author learns which inline header it shadows.
267
+ const warnedHeaderShadows = new Set<string>()
268
+ function warnHeaderShadow(field: string, i18nValue: string, explicit: string | undefined): void {
269
+ if (explicit === undefined || explicit === i18nValue) return
270
+ const key = `${entity}.${field}`
271
+ if (warnedHeaderShadows.has(key)) return
272
+ warnedHeaderShadows.add(key)
273
+ console.warn(
274
+ `[qdadm] column '${field}' (${entity}): i18n key entities.${entity}.fields.${field} ` +
275
+ `("${i18nValue}") shadows explicit header ("${explicit}") — the catalog wins by design; ` +
276
+ `drop the inline header or remove the key.`
277
+ )
278
+ }
279
+
264
280
  /**
265
- * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
281
+ * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins
282
+ * (the inline `header` is the no-key fallback, not an absolute override),
266
283
  * otherwise the inline `header` option, otherwise a capitalized field name.
267
284
  */
268
285
  function resolveColumnHeader(field: string, inline?: string): string {
269
286
  if (entity && kernelI18n) {
270
287
  const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
271
- if (trace.hit) return trace.result
288
+ if (trace.hit) {
289
+ warnHeaderShadow(field, trace.result, inline)
290
+ return trace.result
291
+ }
272
292
  }
273
293
  return inline || field.charAt(0).toUpperCase() + field.slice(1)
274
294
  }
275
295
 
276
296
  function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
277
297
  columnInlineHeaders.set(field, columnConfig.header)
298
+ // Resolved header LAST so the catalog wins at registration too (#1255) —
299
+ // previously the inline header survived until the first locale change
300
+ // flipped it to the i18n value, silently and inconsistently.
278
301
  columnsMap.value.set(field, {
279
302
  field,
280
- header: resolveColumnHeader(field, columnConfig.header),
281
303
  ...columnConfig,
282
- // Re-apply resolved header after spread so it wins over a stale inline.
283
- ...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
304
+ header: resolveColumnHeader(field, columnConfig.header),
284
305
  })
285
306
  }
286
307
 
@@ -308,8 +329,17 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
308
329
  *
309
330
  * Header resolution: i18n key (`entities.{entity}.fields.{field}`) >
310
331
  * `overrides.header` > inline header given to `addColumn` >
311
- * `manager.fields[name].label` > humanized field name. Pure read — it
312
- * never registers into columnsMap, so calling it during render is safe.
332
+ * `manager.fields[name].label` > humanized field name.
333
+ *
334
+ * NOTE the precedence truth (#1255): an inline `header` is the fallback
335
+ * used when no i18n key exists — NOT an absolute override. The catalog
336
+ * wins by design (or a hardcoded header would pin the column to one
337
+ * language); to pin a header, don't create the key. When a key shadows
338
+ * an explicit header with a different value, a one-shot dev warning
339
+ * announces it instead of letting the header change silently.
340
+ *
341
+ * Pure read — it never registers into columnsMap, so calling it during
342
+ * render is safe.
313
343
  */
314
344
  function column(name: string, overrides: Partial<ColumnConfig> = {}): ColumnConfig {
315
345
  // Depend on the locale so headers re-render on locale change.
@@ -318,9 +348,15 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
318
348
  let header: string | undefined
319
349
  if (entity && kernelI18n) {
320
350
  const trace = kernelI18n.resolve(`entities.${entity}.fields.${name}`)
321
- if (trace.hit) header = trace.result
351
+ if (trace.hit) {
352
+ header = trace.result
353
+ // Both explicit sources are checked: per-call override AND the
354
+ // inline header registered via addColumn (values compared — a key
355
+ // that merely copies the header stays silent).
356
+ warnHeaderShadow(name, trace.result, overrides.header ?? columnInlineHeaders.get(name))
357
+ }
322
358
  }
323
- const fieldConfig = manager.getFieldConfig?.(name) as { label?: string } | null | undefined
359
+ const fieldConfig = manager.getFieldConfig(name) as { label?: string } | null | undefined
324
360
  header ??=
325
361
  overrides.header ??
326
362
  columnInlineHeaders.get(name) ??
@@ -61,6 +61,11 @@ export interface ConfirmService {
61
61
  */
62
62
  export interface ColumnConfig {
63
63
  field: string
64
+ /**
65
+ * Column header. When an i18n key `entities.{entity}.fields.{field}`
66
+ * exists, the catalog WINS — an inline header is the no-key fallback,
67
+ * not an absolute override (#1255). To pin a header, don't create the key.
68
+ */
64
69
  header?: string
65
70
  sortable?: boolean
66
71
  style?: string
@@ -13,6 +13,15 @@
13
13
  *
14
14
  * All interfaces accept an optional generic parameter `T` (default: `unknown`)
15
15
  * that flows through to method signatures for typed entity data.
16
+ *
17
+ * ## Optionality contract (#1253)
18
+ *
19
+ * These views describe the IMPLEMENTER'S MINIMUM, not a mirror of the
20
+ * EntityManager class. A member is REQUIRED iff qdadm's own composables
21
+ * call it unguarded (or capability coherence demands it — e.g. the four
22
+ * can* permission checks travel together). A member stays OPTIONAL only
23
+ * when it is a genuine capability a minimal manager-like need not carry;
24
+ * the reason is documented inline. `?` means "conditional", never "lazy".
16
25
  */
17
26
 
18
27
  import type { EntityBadge } from './EntityManager.types'
@@ -32,6 +41,9 @@ export interface EntityManagerBase<T = unknown> {
32
41
  labelField?: string | ((entity: T) => string)
33
42
  readOnly?: boolean
34
43
  getEntityLabel: (data: T) => string
44
+ // Optional BY DESIGN (#1253): part of the badge/severity presentation
45
+ // capability; call sites guard (e.g. useEntityItemShowPage checks
46
+ // `if (!manager.getEntityBadges)`).
35
47
  getEntityBadges?: (data: T) => Array<{ label: string; severity?: string }>
36
48
  }
37
49
 
@@ -41,7 +53,10 @@ export interface EntityManagerBase<T = unknown> {
41
53
  * Permission checks — used by guards, list pages, forms.
42
54
  */
43
55
  export interface EntityManagerPermissions<T = unknown> {
44
- canRead?: (entity?: T) => boolean
56
+ // The four checks are ONE capability — a manager-like answering canUpdate
57
+ // but not canRead is not a smaller contract, it's an incoherent one. The
58
+ // `?` on canRead was a #1191 leftover (#1253).
59
+ canRead: (entity?: T) => boolean
45
60
  canCreate: () => boolean
46
61
  canUpdate: (entity?: T) => boolean
47
62
  canDelete: (entity?: T) => boolean
@@ -55,13 +70,18 @@ export interface EntityManagerPermissions<T = unknown> {
55
70
  export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, EntityManagerPermissions<T> {
56
71
  get: (id: string | number, context?: unknown) => Promise<T>
57
72
  list: (params?: unknown, context?: unknown) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
58
- query?: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
73
+ // query/invalidateCache/getFieldConfig are required since #1253: they are
74
+ // unconditionally implemented on the class (prototype-applied) and called
75
+ // unguarded by list pages — the `?` was a #1191 leftover that forced
76
+ // consumer-side casts on base methods.
77
+ query: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
59
78
  delete: (id: string | number, context?: unknown) => Promise<void>
60
79
  request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
61
- invalidateCache?: () => void
62
- localFilterThreshold?: number
80
+ invalidateCache: () => void
63
81
  /** Field config lookup — read-side too since list column binding (#1255) */
64
- getFieldConfig?: (name: string) => unknown | null
82
+ getFieldConfig: (name: string) => unknown | null
83
+ /** Config-carrying property: always present on the class, VALUE nullable */
84
+ localFilterThreshold?: number | null
65
85
  }
66
86
 
67
87
  // ─── CRUD ────────────────────────────────────────────────────────────────────
@@ -73,11 +93,14 @@ export interface EntityManagerCrud<T = unknown> extends EntityManagerRead<T> {
73
93
  fields?: Record<string, unknown> | unknown[]
74
94
  getInitialData: () => Record<string, unknown>
75
95
  getFormFields: () => Array<{ name: string; [key: string]: unknown }>
76
- getFieldConfig: (name: string) => unknown | null
96
+ // getFieldConfig inherited (required) from EntityManagerRead since #1253
77
97
  resolveReferenceOptions: (fieldName: string) => Promise<unknown[]>
78
98
  create: (data: unknown, context?: unknown) => Promise<T>
79
99
  update: (id: string | number, data: unknown, context?: unknown) => Promise<T>
80
100
  patch: (id: string | number, data: unknown, context?: unknown) => Promise<T>
101
+ // Optional BY DESIGN (#1253): the severity/badge presentation capability —
102
+ // a minimal manager-like need not carry it; every qdadm call site guards
103
+ // (`?.` or truthiness). The canonical class always provides it.
81
104
  hasSeverityMap?: (field: string) => boolean
82
105
  getSeverity?: (field: string, value: string | number, defaultSeverity?: string) => string
83
106
  getSeverityDescriptor?: (field: string, value: string | number, defaultSeverity?: string) => { severity: string; icon?: string; label?: string }
package/src/index.ts CHANGED
@@ -106,6 +106,17 @@ export {
106
106
  type CacheInfo,
107
107
  } from './entity/EntityManager'
108
108
 
109
+ // Structural manager/orchestrator views (#1253) — the implementer's minimum
110
+ // that composables consume; import these to type manager-likes and test doubles.
111
+ export type {
112
+ EntityManagerBase,
113
+ EntityManagerPermissions,
114
+ EntityManagerRead,
115
+ EntityManagerCrud,
116
+ EntityManagerLike,
117
+ OrchestratorLike,
118
+ } from './entity/EntityManager.interface'
119
+
109
120
  // Re-export core entity types
110
121
  export type {
111
122
  EntityRecord,