@dnax/ui 0.0.54 → 0.0.56

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.
@@ -9,6 +9,7 @@ import { cn } from "../lib/utils"
9
9
  import { radiusStyle, useRadius } from "../lib/useComponentProps"
10
10
  import type { RadiusProp } from "../lib/useComponentProps"
11
11
  import { useOverlayBack } from "../lib/overlayBack"
12
+ import { optionsOf } from "../lib/select"
12
13
 
13
14
  interface Props {
14
15
  /** Valeur sélectionnée */
@@ -230,18 +231,23 @@ const getLabel = (opt: any): string => {
230
231
  return v === undefined || v === null ? "" : String(v)
231
232
  }
232
233
 
234
+ // `options` peut arriver `false` (v-if/&&, config non chargée) : sans cette coercition,
235
+ // `.find` / `.filter` / `.some` jetteraient et le popup cesserait de se rendre
236
+ // (même classe de bug que `q-select multiple` — cf. `.memory/knowledges.md`).
237
+ const optionList = computed<any[]>(() => optionsOf(props.options))
238
+
233
239
  const selectedLabel = computed(() => {
234
240
  if (props.modelValue === undefined || props.modelValue === null) return ""
235
- const opt = props.options.find((o) => getValue(o) === props.modelValue)
241
+ const opt = optionList.value.find((o) => getValue(o) === props.modelValue)
236
242
  return opt ? getLabel(opt) : String(props.modelValue)
237
243
  })
238
244
 
239
245
  // — Filtre client (sauté en mode serveur @filter) —
240
246
  const filtered = computed(() => {
241
- if (hasFilter.value) return props.options
247
+ if (hasFilter.value) return optionList.value
242
248
  const q = query.value.trim().toLowerCase()
243
- if (!q) return props.options
244
- return props.options.filter((o) => getLabel(o).toLowerCase().includes(q))
249
+ if (!q) return optionList.value
250
+ return optionList.value.filter((o) => getLabel(o).toLowerCase().includes(q))
245
251
  })
246
252
 
247
253
  // — Affichage du champ —
@@ -323,7 +329,7 @@ const commit = () => {
323
329
  const q = query.value.trim()
324
330
  if (!q) return
325
331
  const label = q.toLowerCase()
326
- const matches = props.options.some((o) => getLabel(o).toLowerCase() === label)
332
+ const matches = optionList.value.some((o) => getLabel(o).toLowerCase() === label)
327
333
  if (!matches) {
328
334
  query.value = ""
329
335
  emit("update:inputValue", "")
@@ -8,6 +8,24 @@
8
8
  // Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
9
9
  // ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
10
10
  // (`lib/spreadsheetChanges.ts`).
11
+ import type {
12
+ QSpreadsheetCellOption,
13
+ QSpreadsheetOptionAccessor,
14
+ } from "../lib/spreadsheetOptions"
15
+ import type {
16
+ QSpreadsheetValidation,
17
+ QSpreadsheetValidationRule,
18
+ } from "../lib/spreadsheetValidation"
19
+
20
+ export type {
21
+ QSpreadsheetCellOption,
22
+ QSpreadsheetOptionAccessor,
23
+ } from "../lib/spreadsheetOptions"
24
+ export type {
25
+ QSpreadsheetValidation,
26
+ QSpreadsheetValidationRule,
27
+ } from "../lib/spreadsheetValidation"
28
+
11
29
  export type QSpreadsheetCellType =
12
30
  | "string"
13
31
  | "text"
@@ -21,18 +39,9 @@ export type QSpreadsheetCellType =
21
39
  | "select"
22
40
  | "multiselect"
23
41
 
24
- export interface QSpreadsheetCellOption {
25
- /** Valeur stockée dans la cellule */
26
- value: any
27
- /**
28
- * Libellé affiché. Les nombres sont acceptés (`{ value: 1, label: 1 }` pour une
29
- * note, un niveau…) : le composant les normalise en string partout où il fait des
30
- * opérations de chaîne (recherche, filtre, tri, éditeur).
31
- */
32
- label: string | number
33
- /** Couleur du badge (token ou hex, ex. "positive", "#22c55e") */
34
- color?: string
35
- }
42
+ // `QSpreadsheetCellOption` (option **normalisée**) et `QSpreadsheetOptionAccessor` vivent dans
43
+ // `lib/spreadsheetOptions.ts` — source unique, testée hors navigateur — et sont ré-exportés en
44
+ // tête de ce bloc.
36
45
 
37
46
  export interface QSpreadsheetColumn {
38
47
  /** Clé de la propriété dans la ligne */
@@ -48,12 +57,27 @@ export interface QSpreadsheetColumn {
48
57
  type?: QSpreadsheetCellType
49
58
  /** La cellule est éditable (Entrée / double-clic / frappe) */
50
59
  editable?: boolean
51
- /** Pour type "select" / "multiselect" : options (badges colorés) */
52
- options?: QSpreadsheetCellOption[]
60
+ /**
61
+ * Pour type `select` / `multiselect` : les options. Forme par défaut
62
+ * `{ value, label, color? }` — ou **n'importe quelle forme d'objet** (ex. `[{ _id, name }]`
63
+ * renvoyé par une API) dès que `optionLabel` / `optionValue` sont fournis.
64
+ */
65
+ options?: any[]
66
+ /**
67
+ * Pour type `select` / `multiselect` : clé (ou fonction) du **libellé** dans une option —
68
+ * défaut `"label"`. Ex. `option-label="name"`. Vocabulaire de `<q-select>`.
69
+ */
70
+ optionLabel?: QSpreadsheetOptionAccessor
71
+ /**
72
+ * Pour type `select` / `multiselect` : clé (ou fonction) de la **valeur stockée** dans la
73
+ * cellule — défaut `"value"`. Doit être **primitive** (string / number / bool) et unique.
74
+ * Ex. `option-value="_id"`.
75
+ */
76
+ optionValue?: QSpreadsheetOptionAccessor
53
77
  /** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
54
78
  chip?: boolean
55
- /** Validation à la saisie : { min, max, integer?, pattern?, message? } */
56
- validation?: QSpreadsheetValidation
79
+ /** Validation à la saisie : objet `{ min, max, … }` ou expression ArkType (`"number < 4"`) */
80
+ validation?: QSpreadsheetValidationRule
57
81
  /** Formatteur de la valeur affichée */
58
82
  format?: (val: any, row: Record<string, any>) => any
59
83
  /** Classe CSS de la cellule (défaut : ) */
@@ -64,24 +88,9 @@ export interface QSpreadsheetColumn {
64
88
  headerStyle?: string
65
89
  }
66
90
 
67
- export interface QSpreadsheetValidation {
68
- /** Valeur minimale (types numériques / entiers) */
69
- min?: number
70
- /** Valeur maximale (types numériques / entiers) */
71
- max?: number
72
- /** Exige un entier (si présent, valide les entiers) */
73
- integer?: boolean
74
- /** Expression régulière validée contre le texte */
75
- pattern?: string
76
- /** Champ obligatoire (vide refusé) */
77
- required?: boolean
78
- /** Valeurs autorisées (liste) */
79
- list?: any[]
80
- /** Message d'erreur affiché (sinon message générique) */
81
- message?: string
82
- /** Schéma ArkType (string) — ex. "number.integer & >= 0 & <= 100" (optionnel) */
83
- schema?: string
84
- }
91
+ // `QSpreadsheetValidation` et `QSpreadsheetValidationRule` vivent dans
92
+ // `lib/spreadsheetValidation.ts` (source unique, testée hors navigateur) et sont
93
+ // ré-exportées en tête de ce bloc.
85
94
 
86
95
  /** Validation par plage de cellules (rows/cols 0-based) */
87
96
  export interface QSpreadsheetRangeValidator {
@@ -89,7 +98,7 @@ export interface QSpreadsheetRangeValidator {
89
98
  c0: number
90
99
  r1: number
91
100
  c1: number
92
- validation: QSpreadsheetValidation
101
+ validation: QSpreadsheetValidationRule
93
102
  }
94
103
 
95
104
  export interface QSpreadsheetSelection {
@@ -161,6 +170,8 @@ import {
161
170
  deriveCols,
162
171
  parseCsv,
163
172
  } from "../lib/spreadsheet"
173
+ import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
174
+ import { normalizeCellOptions } from "../lib/spreadsheetOptions"
164
175
  import {
165
176
  DEFAULT_ROW_KEY,
166
177
  diffDocuments,
@@ -517,6 +528,38 @@ const colNameAt = (i: number) => cols.value[i]?.name ?? ""
517
528
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
518
529
 
519
530
  // ─── Cellules à choix (select = une valeur, multiselect = un tableau de valeurs) ───
531
+ /**
532
+ * Options d'une colonne, **normalisées une seule fois** vers `{ value, label, color? }` —
533
+ * `optionLabel` / `optionValue` y sont appliqués (défauts `"label"` / `"value"`). C'est ce qui
534
+ * garantit que badge, info-bulle, éditeur, tri, filtre et copie/CSV lisent tous la même forme :
535
+ * un accesseur oublié quelque part donnerait un badge `_id` et un tooltip « Ada ».
536
+ * Mémoïsé par nom de colonne (le cache vit dans l'instance du composant).
537
+ */
538
+ const optionsCache = new Map<
539
+ string,
540
+ { opts: QSpreadsheetCellOption[]; options: unknown; label: unknown; value: unknown }
541
+ >()
542
+ const cellOptionsOf = (col: QSpreadsheetColumn | undefined): QSpreadsheetCellOption[] => {
543
+ if (!col) return []
544
+ const hit = optionsCache.get(col.name)
545
+ if (
546
+ hit &&
547
+ hit.options === col.options &&
548
+ hit.label === col.optionLabel &&
549
+ hit.value === col.optionValue
550
+ ) {
551
+ return hit.opts
552
+ }
553
+ const opts = normalizeCellOptions(col.options, col.optionLabel, col.optionValue)
554
+ optionsCache.set(col.name, {
555
+ opts,
556
+ options: col.options,
557
+ label: col.optionLabel,
558
+ value: col.optionValue,
559
+ })
560
+ return opts
561
+ }
562
+
520
563
  /**
521
564
  * Valeurs d'une cellule à choix multiples, toujours sous forme de liste : un tableau est
522
565
  * repris tel quel, une valeur nue (`null`, `"it"`…) devient `[valeur]` (ou `[]` si vide).
@@ -530,7 +573,7 @@ const multiValues = (raw: any): any[] => {
530
573
  /** Libellés d'une cellule à choix multiples (le `value` brut si l'option est inconnue) */
531
574
  const multiLabels = (col: QSpreadsheetColumn | undefined, raw: any): string[] =>
532
575
  multiValues(raw).map((v) => {
533
- const opt = col?.options?.find((o) => o.value === v)
576
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
534
577
  return String(opt?.label ?? v)
535
578
  })
536
579
 
@@ -542,7 +585,7 @@ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
542
585
  if (col?.type === "multiselect") return multiLabels(col, raw).join(", ")
543
586
  if (isBlankValue(raw)) return ""
544
587
  if (col?.type === "select") {
545
- const opt = col.options?.find((o) => o.value === raw)
588
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
546
589
  if (opt?.label !== undefined) return String(opt.label)
547
590
  }
548
591
  return String(raw)
@@ -842,7 +885,7 @@ const startEdit = (row: number, column: string, initial?: string) => {
842
885
  if (col?.editable === false || col?.type === "boolean") return
843
886
  const raw = state.value[row]?.[column]
844
887
  if (col?.type === "select") {
845
- const opt = col.options?.find((o) => o.value === raw)
888
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
846
889
  // `label` peut être un nombre (options numériques) : `draft` alimente un
847
890
  // <input>/<textarea> et sert à `.trim()` / `.toLowerCase()` → toujours une string.
848
891
  draft.value = String(initial ?? opt?.label ?? "")
@@ -929,7 +972,9 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
929
972
  return raw === "true" ? true : raw === "false" ? false : old
930
973
  }
931
974
  if (col?.type === "select") {
932
- const opt = col.options?.find((o) => String(o.label ?? "") === raw || String(o.value) === raw)
975
+ const opt = cellOptionsOf(col).find(
976
+ (o) => String(o.label ?? "") === raw || String(o.value) === raw,
977
+ )
933
978
  return opt ? opt.value : old
934
979
  }
935
980
  // `multiselect` : collage / saisie « a, b » → tableau de valeurs d'option
@@ -940,7 +985,7 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
940
985
  .filter((s) => s !== "")
941
986
  if (!parts.length) return null
942
987
  return parts.map((part) => {
943
- const opt = col.options?.find(
988
+ const opt = cellOptionsOf(col).find(
944
989
  (o) => String(o.label ?? "") === part || String(o.value) === part,
945
990
  )
946
991
  return opt ? opt.value : part
@@ -1641,7 +1686,7 @@ const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
1641
1686
  const col = colOf(editing.value.column)
1642
1687
  if (col?.type !== "select" && col?.type !== "multiselect") return []
1643
1688
  const q = String(draft.value ?? "").trim().toLowerCase()
1644
- return (col.options ?? []).filter(
1689
+ return cellOptionsOf(col).filter(
1645
1690
  (o) => !q || String(o.label ?? o.value ?? "").toLowerCase().includes(q),
1646
1691
  )
1647
1692
  })
@@ -1676,7 +1721,7 @@ const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
1676
1721
  const next = multiSelected(opt)
1677
1722
  ? current.filter((v) => v !== opt.value)
1678
1723
  : [...current, opt.value]
1679
- validateAndSet(row, column, old, next)
1724
+ void validateAndSet(row, column, old, next)
1680
1725
  }
1681
1726
 
1682
1727
  // ─── Barre de formule (fx) ───
@@ -2499,7 +2544,7 @@ const badgeStyle = (opt: QSpreadsheetCellOption) => {
2499
2544
 
2500
2545
  const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2501
2546
  if (col?.type !== "select" || !col.chip) return undefined
2502
- const opt = col.options?.find((o) => o.value === val)
2547
+ const opt = cellOptionsOf(col).find((o) => o.value === val)
2503
2548
  if (!opt) return undefined
2504
2549
  return { label: opt.label, style: badgeStyle(opt) }
2505
2550
  }
@@ -2508,7 +2553,7 @@ const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2508
2553
  const multiBadges = (col: QSpreadsheetColumn | undefined, val: any) => {
2509
2554
  if (col?.type !== "multiselect" || !col.chip) return []
2510
2555
  return multiValues(val).map((v) => {
2511
- const opt = col.options?.find((o) => o.value === v)
2556
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
2512
2557
  return {
2513
2558
  label: String(opt?.label ?? v),
2514
2559
  style: badgeStyle(opt ?? { value: v, label: String(v) }),
@@ -3854,60 +3899,50 @@ const condStyleOf = (r: number, c: number, name: string) => {
3854
3899
  return Object.keys(st).length ? st : undefined
3855
3900
  }
3856
3901
 
3857
- // ════════ Validation de colonne (min / max / entier / pattern) ════════
3902
+ // ════════ Validation de colonne (min / max / entier / pattern / ArkType) ════════
3858
3903
  const valErrors = ref<Record<string, string>>({})
3859
3904
  const valKey = (r: number, name: string) => r + ":" + name
3905
+
3906
+ // ArkType : import **dynamique** (jamais dans le graphe du barrel) mémoïsé au niveau
3907
+ // module — une seule promesse pour toute la session, et non une par édition de cellule.
3908
+ let arkModule: Promise<any | null> | null = null
3909
+ const loadArk = (): Promise<any | null> => {
3910
+ arkModule ??= import("arktype")
3911
+ .then((m) => m as any)
3912
+ .catch(() => null)
3913
+ return arkModule
3914
+ }
3915
+
3916
+ /**
3917
+ * Vérifie une expression ArkType. Une expression **invalide** (faute de syntaxe) ne doit
3918
+ * JAMAIS jeter : l'exception remonterait en rejet non géré et la cellule ne serait ni
3919
+ * validée ni commitée, sans aucun message (cf. `.memory/knowledges.md`).
3920
+ */
3921
+ const arkCheck: ArkCheck = async (schema, value) => {
3922
+ const m = await loadArk()
3923
+ if (!m) return "ArkType unavailable"
3924
+ try {
3925
+ const problems = m.type(schema)(value)
3926
+ const p = Array.isArray(problems) ? problems[0] : undefined
3927
+ return p?.message ?? null
3928
+ } catch (e) {
3929
+ return `Invalid ArkType schema: ${(e as Error)?.message ?? String(e)}`
3930
+ }
3931
+ }
3932
+
3933
+ /** Validation d'une cellule : règles de la colonne, puis celles des plages qui la couvrent. */
3860
3934
  const guardValidation = async (
3861
3935
  row: number,
3862
3936
  col: QSpreadsheetColumn | undefined,
3863
3937
  value: any,
3864
3938
  ): Promise<string | null> => {
3865
- let ark: Promise<{ type: (s: string) => (v: unknown) => { message?: string }[] } | null> | null = null
3866
- const arkCheck = async (schema: string): Promise<string | null> => {
3867
- if (!ark)
3868
- ark = import("arktype")
3869
- .then((m) => m as any)
3870
- .catch(() => null)
3871
- const m = await ark
3872
- if (!m) return "ArkType unavailable"
3873
- const problems = m.type(schema)(value)
3874
- const p = Array.isArray(problems) ? problems[0] : undefined
3875
- return p?.message ?? null
3876
- }
3877
- const check = async (v: QSpreadsheetValidation | undefined): Promise<string | null> => {
3878
- if (!v) return null
3879
- const multi = Array.isArray(value)
3880
- const blank =
3881
- value === null || value === undefined || value === "" || (multi && value.length === 0)
3882
- if (v.required && blank) return v.message ?? "Required"
3883
- if (blank) return null // champ vide autorisé (sauf required)
3884
- if (v.list) {
3885
- // Choix multiples : chacune des valeurs doit être dans la liste
3886
- const values = multi ? value : [value]
3887
- if (values.some((x) => !v.list!.some((y) => String(y) === String(x))))
3888
- return v.message ?? "Not in the allowed list"
3889
- }
3890
- if (v.integer && typeof value === "number" && !Number.isInteger(value))
3891
- return v.message ?? "Integer required"
3892
- if (typeof value === "number") {
3893
- if (v.min !== undefined && value < v.min) return v.message ?? "Value below minimum (" + v.min + ")"
3894
- if (v.max !== undefined && value > v.max) return v.message ?? "Value above maximum (" + v.max + ")"
3895
- }
3896
- if (v.pattern && typeof value === "string" && !new RegExp(v.pattern).test(value))
3897
- return v.message ?? "Value does not match the required format"
3898
- if (v.schema) {
3899
- const e = await arkCheck(v.schema)
3900
- if (e) return v.message ?? e
3901
- }
3902
- return null
3903
- }
3904
- const colErr = await check(col?.validation)
3939
+ const colErr = await checkValidation(col?.validation, value, arkCheck)
3905
3940
  if (colErr) return colErr
3906
3941
  if (props.validators?.length && col) {
3907
3942
  const ci = colIndex(col.name)
3908
3943
  for (const rv of props.validators) {
3909
3944
  if (row >= rv.r0 && row <= rv.r1 && ci >= rv.c0 && ci <= rv.c1) {
3910
- const e = await check(rv.validation)
3945
+ const e = await checkValidation(rv.validation, value, arkCheck)
3911
3946
  if (e) return e
3912
3947
  }
3913
3948
  }
package/lib/select.ts CHANGED
@@ -1,10 +1,11 @@
1
- // Coercitions défensives de QSelect (`lib/select.ts`).
1
+ // Coercitions défensives des composants de sélection (QSelect, QAutocomplete) (`lib/select.ts`).
2
2
  //
3
3
  // Un `v-model` ou des `options` mal formés ne doivent **jamais** casser le rendu : un
4
4
  // `list.map is not a function` dans un `computed` du template tue tout le popup, sans
5
5
  // message utile. Cas réel : `<q-select multiple v-model="x">` avec `x` initialisé à
6
6
  // `false` (défaut d'un `ref` booléen, retour d'un `&&`, config non chargée) — le garde
7
- // `=== null`/`=== undefined` ne l'attrape pas, puis `false.map(...)` jette.
7
+ // `=== null`/`=== undefined` ne l'attrape pas, puis `false.map(...)` jette. Idem
8
+ // `:options` arrivant `false` (`props.options.map` / `.filter` / `.find`).
8
9
  //
9
10
  // Pur (aucun DOM) → testable hors navigateur, comme `lib/pagePadding.ts`.
10
11
 
@@ -0,0 +1,84 @@
1
+ // Tests unitaires de la normalisation des options de colonne (`lib/spreadsheetOptions.ts`).
2
+ // Cas visé : des données d'API `[{ _id, name }]` (ou `{ _id, name, value }`) utilisées
3
+ // directement via `optionLabel` / `optionValue`, sans mapping manuel.
4
+ import { describe, expect, it } from "bun:test"
5
+ import { normalizeCellOptions } from "./spreadsheetOptions"
6
+
7
+ const users = [
8
+ { _id: "u_8f3a", name: "Ada Lovelace", value: "ADA" },
9
+ { _id: "u_1b2c", name: "Alan Turing", value: "ALAN" },
10
+ ]
11
+
12
+ describe("normalizeCellOptions — sans accesseurs (forme historique)", () => {
13
+ it("laisse passer `{ value, label, color? }`", () => {
14
+ const opts = [{ value: "tea", label: "Tea", color: "#ede9fe" }]
15
+ expect(normalizeCellOptions(opts)).toEqual([{ value: "tea", label: "Tea", color: "#ede9fe" }])
16
+ })
17
+
18
+ it("accepte un label numérique et l'absence de couleur", () => {
19
+ expect(normalizeCellOptions([{ value: 1, label: 1 }])).toEqual([{ value: 1, label: 1 }])
20
+ })
21
+
22
+ it("normalise une option primitive", () => {
23
+ expect(normalizeCellOptions(["a", 3])).toEqual([
24
+ { value: "a", label: "a" },
25
+ { value: 3, label: "3" },
26
+ ])
27
+ })
28
+
29
+ it("replie le label sur la valeur string si `label` manque", () => {
30
+ expect(normalizeCellOptions([{ value: "it" }])).toEqual([{ value: "it", label: "it" }])
31
+ })
32
+
33
+ it("tolère une entrée qui n'est pas un tableau", () => {
34
+ expect(normalizeCellOptions(false)).toEqual([])
35
+ expect(normalizeCellOptions(undefined)).toEqual([])
36
+ })
37
+ })
38
+
39
+ describe("normalizeCellOptions — `optionLabel` / `optionValue`", () => {
40
+ it("mappe `[{ _id, name }]` par nom de clé", () => {
41
+ expect(normalizeCellOptions(users, "name", "_id")).toEqual([
42
+ { value: "u_8f3a", label: "Ada Lovelace" },
43
+ { value: "u_1b2c", label: "Alan Turing" },
44
+ ])
45
+ })
46
+
47
+ it("mappe par fonction, comme `option-label` de QSelect", () => {
48
+ expect(normalizeCellOptions(users, (u) => u.name, (u) => u._id)).toEqual([
49
+ { value: "u_8f3a", label: "Ada Lovelace" },
50
+ { value: "u_1b2c", label: "Alan Turing" },
51
+ ])
52
+ })
53
+
54
+ it("permet de stocker le code métier (`value`) plutôt que l'`_id`", () => {
55
+ expect(normalizeCellOptions(users, "name", "value")[0]).toEqual({
56
+ value: "ADA",
57
+ label: "Ada Lovelace",
58
+ })
59
+ })
60
+
61
+ it("transmet `color` tel quel (décoré côté app)", () => {
62
+ const decorated = users.map((u) => ({ ...u, color: u.value === "ADA" ? "#ede9fe" : "#dcfce7" }))
63
+ const opts = normalizeCellOptions(decorated, "name", "_id")
64
+ expect(opts[0]).toEqual({ value: "u_8f3a", label: "Ada Lovelace", color: "#ede9fe" })
65
+ expect(opts[1]!.color).toBe("#dcfce7")
66
+ })
67
+
68
+ it("replie le label sur la valeur si la clé de label est absente de l'objet", () => {
69
+ expect(normalizeCellOptions([{ _id: "x" }], "name", "_id")).toEqual([{ value: "x", label: "x" }])
70
+ })
71
+
72
+ it("ne mute pas les objets source", () => {
73
+ const source = [{ _id: "u_1", name: "Ada" }]
74
+ normalizeCellOptions(source, "name", "_id")
75
+ expect(source).toEqual([{ _id: "u_1", name: "Ada" }])
76
+ })
77
+
78
+ it("produit une nouvelle liste à chaque appel (le cache est géré par le composant)", () => {
79
+ const a = normalizeCellOptions(users, "name", "_id")
80
+ const b = normalizeCellOptions(users, "name", "_id")
81
+ expect(a).not.toBe(b)
82
+ expect(a).toEqual(b)
83
+ })
84
+ })
@@ -0,0 +1,70 @@
1
+ // Options de cellule de QSpreadsheet (`<q-spreadsheet>`, colonnes `select` / `multiselect`).
2
+ //
3
+ // Une colonne reçoit `options` — par défaut sous la forme `{ value, label, color? }`. Avec
4
+ // `optionLabel` / `optionValue`, **n'importe quelle forme d'objet** est acceptée (ex. le
5
+ // `[{ _id, name }]` renvoyé par une API) : on la **normalise une seule fois** vers la forme
6
+ // interne. C'est le point clé — badge, info-bulle, éditeur, tri, filtre et copie/CSV lisent
7
+ // tous la même forme, donc aucun risque d'incohérence entre deux sites.
8
+ //
9
+ // Pur (aucun DOM) → testable hors navigateur, comme `spreadsheetValidation.ts`.
10
+
11
+ import { optionsOf } from "./select"
12
+
13
+ /** Option interne normalisée : la **seule** forme lue par le composant. */
14
+ export interface QSpreadsheetCellOption {
15
+ /**
16
+ * Valeur stockée dans la cellule. Doit être **primitive** (string / number / boolean),
17
+ * unique dans la liste : le composant compare les valeurs par `===` et le document est
18
+ * sérialisé par `toJSON()`.
19
+ */
20
+ value: any
21
+ /**
22
+ * Libellé affiché. Les nombres sont acceptés (`{ value: 1, label: 1 }` pour une note, un
23
+ * niveau…) : le composant les normalise en string partout où il fait des opérations de
24
+ * chaîne (recherche, filtre, tri, éditeur).
25
+ */
26
+ label: string | number
27
+ /** Couleur du badge (token ou hex, ex. "positive", "#22c55e") */
28
+ color?: string
29
+ }
30
+
31
+ /**
32
+ * Accès à un champ d'une option : nom de clé (`"name"`, `"_id"`) **ou** fonction
33
+ * (`(o) => o.name`), comme l'`option-label` / `option-value` de `<q-select>`.
34
+ */
35
+ export type QSpreadsheetOptionAccessor = string | ((opt: any) => any)
36
+
37
+ const read = (opt: any, accessor: QSpreadsheetOptionAccessor | undefined): any =>
38
+ typeof accessor === "function" ? accessor(opt) : accessor === undefined ? undefined : opt?.[accessor]
39
+
40
+ /**
41
+ * Normalise les options d'une colonne vers `{ value, label, color? }`.
42
+ *
43
+ * - `optionLabel` / `optionValue` : clé ou fonction — défauts `"label"` / `"value"` (donc une
44
+ * colonne déjà écrite sous la forme `{ value, label }` n'est pas modifiée) ;
45
+ * - une option **primitive** (`"a"`, `3`) devient `{ value, label: String(value) }` ;
46
+ * - `label` absent → repli sur `String(value)` ;
47
+ * - `color` est **transmis tel quel** (la couleur est une décision de présentation) ;
48
+ * - `options` qui n'est pas un tableau → `[]` (jamais d'exception).
49
+ */
50
+ export function normalizeCellOptions(
51
+ options: unknown,
52
+ optionLabel?: QSpreadsheetOptionAccessor,
53
+ optionValue?: QSpreadsheetOptionAccessor,
54
+ ): QSpreadsheetCellOption[] {
55
+ return optionsOf(options).map((opt): QSpreadsheetCellOption => {
56
+ if (opt === null || typeof opt !== "object") {
57
+ return { value: opt, label: String(opt ?? "") }
58
+ }
59
+
60
+ const value = read(opt, optionValue ?? "value")
61
+ const label = read(opt, optionLabel ?? "label")
62
+
63
+ const out: QSpreadsheetCellOption = {
64
+ value,
65
+ label: label === undefined || label === null ? String(value ?? "") : label,
66
+ }
67
+ if (opt.color !== undefined) out.color = opt.color
68
+ return out
69
+ })
70
+ }
@@ -0,0 +1,106 @@
1
+ // Tests unitaires de la validation de colonne de QSpreadsheet (`lib/spreadsheetValidation.ts`).
2
+ // L'accès à ArkType est **injecté** : on teste l'ordonnancement des règles sans bundler
3
+ // ni navigateur, et on vérifie qu'une expression est bien transmise telle quelle.
4
+ import { describe, expect, it } from "bun:test"
5
+ import { checkValidation, normalizeValidation, type ArkCheck } from "./spreadsheetValidation"
6
+
7
+ /** Faux ArkType : échoue pour toute valeur >= 4 (comme une règle `"number < 4"`). */
8
+ const fakeArk: ArkCheck = async (schema, value) =>
9
+ schema === "number < 4" && typeof value === "number" && value >= 4 ? "must be < 4" : null
10
+
11
+ const noArk: ArkCheck = async () => {
12
+ throw new Error("arkCheck ne devait pas être appelé")
13
+ }
14
+
15
+ describe("normalizeValidation", () => {
16
+ it("une chaîne devient un schéma ArkType", () => {
17
+ expect(normalizeValidation("number < 4")).toEqual({ schema: "number < 4" })
18
+ })
19
+
20
+ it("un objet est laissé tel quel", () => {
21
+ const rule = { min: 0, max: 10 }
22
+ expect(normalizeValidation(rule)).toBe(rule)
23
+ })
24
+
25
+ it("undefined → undefined", () => {
26
+ expect(normalizeValidation(undefined)).toBeUndefined()
27
+ })
28
+ })
29
+
30
+ describe("checkValidation — expression directe (« number < 4 »)", () => {
31
+ it("transmet l'expression à ArkType et remonte son message", async () => {
32
+ expect(await checkValidation("number < 4", 3, fakeArk)).toBeNull()
33
+ expect(await checkValidation("number < 4", 4, fakeArk)).toBe("must be < 4")
34
+ })
35
+
36
+ it("`message` surcharge le message d'ArkType", async () => {
37
+ const rule = { schema: "number < 4", message: "Trop grand" }
38
+ expect(await checkValidation(rule, 9, fakeArk)).toBe("Trop grand")
39
+ })
40
+
41
+ it("une valeur vide passe (comme la forme objet)", async () => {
42
+ expect(await checkValidation("number < 4", null, noArk)).toBeNull()
43
+ expect(await checkValidation("number < 4", "", noArk)).toBeNull()
44
+ })
45
+
46
+ it("`required` refuse le vide **avant** d'appeler ArkType", async () => {
47
+ expect(await checkValidation({ required: true, schema: "number < 4" }, null, noArk)).toBe(
48
+ "Required",
49
+ )
50
+ })
51
+ })
52
+
53
+ describe("checkValidation — règle objet", () => {
54
+ it("sans règle → null", async () => {
55
+ expect(await checkValidation(undefined, 42, noArk)).toBeNull()
56
+ })
57
+
58
+ it("required + message", async () => {
59
+ expect(await checkValidation({ required: true }, "", noArk)).toBe("Required")
60
+ expect(await checkValidation({ required: true, message: "Obligatoire" }, [], noArk)).toBe(
61
+ "Obligatoire",
62
+ )
63
+ })
64
+
65
+ it("list : une valeur (mode simple)", async () => {
66
+ const rule = { list: ["a", "b"] }
67
+ expect(await checkValidation(rule, "a", noArk)).toBeNull()
68
+ expect(await checkValidation(rule, "z", noArk)).toBe("Not in the allowed list")
69
+ })
70
+
71
+ it("list : chaque valeur (multiselect)", async () => {
72
+ const rule = { list: [1, 2, 3] }
73
+ expect(await checkValidation(rule, [1, 3], noArk)).toBeNull()
74
+ expect(await checkValidation(rule, [1, 9], noArk)).toBe("Not in the allowed list")
75
+ })
76
+
77
+ it("list : compare en texte (1 ≡ « 1 »)", async () => {
78
+ expect(await checkValidation({ list: ["1", "2"] }, 1, noArk)).toBeNull()
79
+ })
80
+
81
+ it("integer / min / max", async () => {
82
+ expect(await checkValidation({ integer: true }, 2.5, noArk)).toBe("Integer required")
83
+ expect(await checkValidation({ min: 0 }, -1, noArk)).toBe("Value below minimum (0)")
84
+ expect(await checkValidation({ max: 100 }, 150, noArk)).toBe("Value above maximum (100)")
85
+ expect(await checkValidation({ min: 0, max: 100 }, 42, noArk)).toBeNull()
86
+ })
87
+
88
+ it("min/max ne s'appliquent qu'aux nombres", async () => {
89
+ expect(await checkValidation({ min: 0 }, "abc", noArk)).toBeNull()
90
+ })
91
+
92
+ it("pattern (texte seulement)", async () => {
93
+ const rule = { pattern: "^\\d{4}$" }
94
+ expect(await checkValidation(rule, "2026", noArk)).toBeNull()
95
+ expect(await checkValidation(rule, "26", noArk)).toBe(
96
+ "Value does not match the required format",
97
+ )
98
+ expect(await checkValidation(rule, 2026 as any, noArk)).toBeNull() // non-string : ignoré
99
+ })
100
+
101
+ it("le schéma est évalué en dernier (min échoue avant)", async () => {
102
+ const rule = { min: 0, schema: "number < 4" }
103
+ expect(await checkValidation(rule, -1, fakeArk)).toBe("Value below minimum (0)")
104
+ expect(await checkValidation(rule, 9, fakeArk)).toBe("must be < 4")
105
+ })
106
+ })
@@ -0,0 +1,102 @@
1
+ // Validation de colonne de QSpreadsheet (`<q-spreadsheet>`).
2
+ //
3
+ // Vit dans `lib/` — et non dans le SFC — pour être **testable sans navigateur** : c'est
4
+ // exactement le code du composant, seul l'accès à ArkType est **injecté** (`ArkCheck`).
5
+ //
6
+ // Deux écritures possibles pour une colonne (ou une plage) :
7
+ //
8
+ // { name: "score", validation: { min: 0, max: 100, integer: true } } // objet
9
+ // { name: "priority", validation: "number < 4" } // expression ArkType
10
+ //
11
+ // Une **chaîne** est une expression ArkType : c'est le raccourci de `{ schema: "…" }`.
12
+ // Les deux formes se combinent (`{ required: true, schema: "number < 4" }`) et `message`
13
+ // surcharge le message de la règle qui échoue.
14
+
15
+ /** Règles de validation d'une colonne (ou d'une plage de cellules). */
16
+ export interface QSpreadsheetValidation {
17
+ /** Valeur minimale (types numériques / entiers) */
18
+ min?: number
19
+ /** Valeur maximale (types numériques / entiers) */
20
+ max?: number
21
+ /** Exige un entier (si présent, valide les entiers) */
22
+ integer?: boolean
23
+ /** Expression régulière validée contre le texte */
24
+ pattern?: string
25
+ /** Champ obligatoire (vide refusé) */
26
+ required?: boolean
27
+ /** Valeurs autorisées (liste) */
28
+ list?: any[]
29
+ /** Message d'erreur affiché (sinon message générique) */
30
+ message?: string
31
+ /**
32
+ * Expression **ArkType** — ex. `"number < 4"`,
33
+ * `"number.integer & number >= 0 & number <= 100"`, `"string.email"`.
34
+ * Équivalent d'une règle écrite directement en chaîne.
35
+ */
36
+ schema?: string
37
+ }
38
+
39
+ /**
40
+ * Règle de validation : objet `{ min, max, … }` **ou** expression ArkType écrite
41
+ * directement (`validation: "number < 4"`).
42
+ */
43
+ export type QSpreadsheetValidationRule = QSpreadsheetValidation | string
44
+
45
+ /**
46
+ * Vérification d'un schéma ArkType, **injectée** par le composant (import dynamique +
47
+ * mémoïsation + `try/catch`) : renvoie un message d'erreur, ou `null` si la valeur passe.
48
+ */
49
+ export type ArkCheck = (schema: string, value: unknown) => Promise<string | null>
50
+
51
+ /** Normalise une règle : une chaîne devient un schéma ArkType (`{ schema }`). */
52
+ export const normalizeValidation = (
53
+ rule: QSpreadsheetValidationRule | undefined,
54
+ ): QSpreadsheetValidation | undefined => (typeof rule === "string" ? { schema: rule } : rule)
55
+
56
+ /**
57
+ * Valide une valeur contre une règle. Renvoie le message d'erreur, ou `null`.
58
+ *
59
+ * Ordre : `required` → vide toléré → `list` → `integer` → `min` / `max` → `pattern` →
60
+ * `schema` (ArkType). Une valeur **vide** passe toutes les règles sauf `required`.
61
+ * En `multiselect` (valeur = tableau), `list` s'applique **à chaque valeur** et un tableau
62
+ * vide compte comme vide, donc `required` le refuse.
63
+ */
64
+ export async function checkValidation(
65
+ rule: QSpreadsheetValidationRule | undefined,
66
+ value: any,
67
+ arkCheck: ArkCheck,
68
+ ): Promise<string | null> {
69
+ const v = normalizeValidation(rule)
70
+ if (!v) return null
71
+
72
+ const multi = Array.isArray(value)
73
+ const blank =
74
+ value === null || value === undefined || value === "" || (multi && value.length === 0)
75
+ if (v.required && blank) return v.message ?? "Required"
76
+ if (blank) return null // champ vide autorisé (sauf required)
77
+
78
+ if (v.list) {
79
+ // Choix multiples : chacune des valeurs doit être dans la liste
80
+ const values = multi ? value : [value]
81
+ if (values.some((x) => !v.list!.some((y) => String(y) === String(x))))
82
+ return v.message ?? "Not in the allowed list"
83
+ }
84
+
85
+ if (v.integer && typeof value === "number" && !Number.isInteger(value))
86
+ return v.message ?? "Integer required"
87
+
88
+ if (typeof value === "number") {
89
+ if (v.min !== undefined && value < v.min) return v.message ?? `Value below minimum (${v.min})`
90
+ if (v.max !== undefined && value > v.max) return v.message ?? `Value above maximum (${v.max})`
91
+ }
92
+
93
+ if (v.pattern && typeof value === "string" && !new RegExp(v.pattern).test(value))
94
+ return v.message ?? "Value does not match the required format"
95
+
96
+ if (v.schema) {
97
+ const e = await arkCheck(v.schema, value)
98
+ if (e) return v.message ?? e
99
+ }
100
+
101
+ return null
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",