@dnax/ui 0.0.55 → 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.
@@ -8,11 +8,19 @@
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"
11
15
  import type {
12
16
  QSpreadsheetValidation,
13
17
  QSpreadsheetValidationRule,
14
18
  } from "../lib/spreadsheetValidation"
15
19
 
20
+ export type {
21
+ QSpreadsheetCellOption,
22
+ QSpreadsheetOptionAccessor,
23
+ } from "../lib/spreadsheetOptions"
16
24
  export type {
17
25
  QSpreadsheetValidation,
18
26
  QSpreadsheetValidationRule,
@@ -31,18 +39,9 @@ export type QSpreadsheetCellType =
31
39
  | "select"
32
40
  | "multiselect"
33
41
 
34
- export interface QSpreadsheetCellOption {
35
- /** Valeur stockée dans la cellule */
36
- value: any
37
- /**
38
- * Libellé affiché. Les nombres sont acceptés (`{ value: 1, label: 1 }` pour une
39
- * note, un niveau…) : le composant les normalise en string partout où il fait des
40
- * opérations de chaîne (recherche, filtre, tri, éditeur).
41
- */
42
- label: string | number
43
- /** Couleur du badge (token ou hex, ex. "positive", "#22c55e") */
44
- color?: string
45
- }
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.
46
45
 
47
46
  export interface QSpreadsheetColumn {
48
47
  /** Clé de la propriété dans la ligne */
@@ -58,8 +57,23 @@ export interface QSpreadsheetColumn {
58
57
  type?: QSpreadsheetCellType
59
58
  /** La cellule est éditable (Entrée / double-clic / frappe) */
60
59
  editable?: boolean
61
- /** Pour type "select" / "multiselect" : options (badges colorés) */
62
- 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
63
77
  /** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
64
78
  chip?: boolean
65
79
  /** Validation à la saisie : objet `{ min, max, … }` ou expression ArkType (`"number < 4"`) */
@@ -157,6 +171,7 @@ import {
157
171
  parseCsv,
158
172
  } from "../lib/spreadsheet"
159
173
  import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
174
+ import { normalizeCellOptions } from "../lib/spreadsheetOptions"
160
175
  import {
161
176
  DEFAULT_ROW_KEY,
162
177
  diffDocuments,
@@ -513,6 +528,38 @@ const colNameAt = (i: number) => cols.value[i]?.name ?? ""
513
528
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
514
529
 
515
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
+
516
563
  /**
517
564
  * Valeurs d'une cellule à choix multiples, toujours sous forme de liste : un tableau est
518
565
  * repris tel quel, une valeur nue (`null`, `"it"`…) devient `[valeur]` (ou `[]` si vide).
@@ -526,7 +573,7 @@ const multiValues = (raw: any): any[] => {
526
573
  /** Libellés d'une cellule à choix multiples (le `value` brut si l'option est inconnue) */
527
574
  const multiLabels = (col: QSpreadsheetColumn | undefined, raw: any): string[] =>
528
575
  multiValues(raw).map((v) => {
529
- const opt = col?.options?.find((o) => o.value === v)
576
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
530
577
  return String(opt?.label ?? v)
531
578
  })
532
579
 
@@ -538,7 +585,7 @@ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
538
585
  if (col?.type === "multiselect") return multiLabels(col, raw).join(", ")
539
586
  if (isBlankValue(raw)) return ""
540
587
  if (col?.type === "select") {
541
- const opt = col.options?.find((o) => o.value === raw)
588
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
542
589
  if (opt?.label !== undefined) return String(opt.label)
543
590
  }
544
591
  return String(raw)
@@ -838,7 +885,7 @@ const startEdit = (row: number, column: string, initial?: string) => {
838
885
  if (col?.editable === false || col?.type === "boolean") return
839
886
  const raw = state.value[row]?.[column]
840
887
  if (col?.type === "select") {
841
- const opt = col.options?.find((o) => o.value === raw)
888
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
842
889
  // `label` peut être un nombre (options numériques) : `draft` alimente un
843
890
  // <input>/<textarea> et sert à `.trim()` / `.toLowerCase()` → toujours une string.
844
891
  draft.value = String(initial ?? opt?.label ?? "")
@@ -925,7 +972,9 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
925
972
  return raw === "true" ? true : raw === "false" ? false : old
926
973
  }
927
974
  if (col?.type === "select") {
928
- 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
+ )
929
978
  return opt ? opt.value : old
930
979
  }
931
980
  // `multiselect` : collage / saisie « a, b » → tableau de valeurs d'option
@@ -936,7 +985,7 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
936
985
  .filter((s) => s !== "")
937
986
  if (!parts.length) return null
938
987
  return parts.map((part) => {
939
- const opt = col.options?.find(
988
+ const opt = cellOptionsOf(col).find(
940
989
  (o) => String(o.label ?? "") === part || String(o.value) === part,
941
990
  )
942
991
  return opt ? opt.value : part
@@ -1637,7 +1686,7 @@ const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
1637
1686
  const col = colOf(editing.value.column)
1638
1687
  if (col?.type !== "select" && col?.type !== "multiselect") return []
1639
1688
  const q = String(draft.value ?? "").trim().toLowerCase()
1640
- return (col.options ?? []).filter(
1689
+ return cellOptionsOf(col).filter(
1641
1690
  (o) => !q || String(o.label ?? o.value ?? "").toLowerCase().includes(q),
1642
1691
  )
1643
1692
  })
@@ -2495,7 +2544,7 @@ const badgeStyle = (opt: QSpreadsheetCellOption) => {
2495
2544
 
2496
2545
  const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2497
2546
  if (col?.type !== "select" || !col.chip) return undefined
2498
- const opt = col.options?.find((o) => o.value === val)
2547
+ const opt = cellOptionsOf(col).find((o) => o.value === val)
2499
2548
  if (!opt) return undefined
2500
2549
  return { label: opt.label, style: badgeStyle(opt) }
2501
2550
  }
@@ -2504,7 +2553,7 @@ const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2504
2553
  const multiBadges = (col: QSpreadsheetColumn | undefined, val: any) => {
2505
2554
  if (col?.type !== "multiselect" || !col.chip) return []
2506
2555
  return multiValues(val).map((v) => {
2507
- const opt = col.options?.find((o) => o.value === v)
2556
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
2508
2557
  return {
2509
2558
  label: String(opt?.label ?? v),
2510
2559
  style: badgeStyle(opt ?? { value: v, label: String(v) }),
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.55",
3
+ "version": "0.0.56",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",