@dnax/ui 0.0.55 → 0.0.58

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,27 @@
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 { QSpreadsheetFill, QSpreadsheetLock } from "../lib/spreadsheetZones"
12
+ import type {
13
+ QSpreadsheetCellOption,
14
+ QSpreadsheetOptionAccessor,
15
+ } from "../lib/spreadsheetOptions"
11
16
  import type {
12
17
  QSpreadsheetValidation,
13
18
  QSpreadsheetValidationRule,
14
19
  } from "../lib/spreadsheetValidation"
15
20
 
21
+ export type {
22
+ QSpreadsheetFill,
23
+ QSpreadsheetFillContext,
24
+ QSpreadsheetFillFormula,
25
+ QSpreadsheetLock,
26
+ QSpreadsheetLockPoint,
27
+ } from "../lib/spreadsheetZones"
28
+ export type {
29
+ QSpreadsheetCellOption,
30
+ QSpreadsheetOptionAccessor,
31
+ } from "../lib/spreadsheetOptions"
16
32
  export type {
17
33
  QSpreadsheetValidation,
18
34
  QSpreadsheetValidationRule,
@@ -31,18 +47,9 @@ export type QSpreadsheetCellType =
31
47
  | "select"
32
48
  | "multiselect"
33
49
 
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
- }
50
+ // `QSpreadsheetCellOption` (option **normalisée**) et `QSpreadsheetOptionAccessor` vivent dans
51
+ // `lib/spreadsheetOptions.ts` — source unique, testée hors navigateur — et sont ré-exportés en
52
+ // tête de ce bloc.
46
53
 
47
54
  export interface QSpreadsheetColumn {
48
55
  /** Clé de la propriété dans la ligne */
@@ -58,8 +65,23 @@ export interface QSpreadsheetColumn {
58
65
  type?: QSpreadsheetCellType
59
66
  /** La cellule est éditable (Entrée / double-clic / frappe) */
60
67
  editable?: boolean
61
- /** Pour type "select" / "multiselect" : options (badges colorés) */
62
- options?: QSpreadsheetCellOption[]
68
+ /**
69
+ * Pour type `select` / `multiselect` : les options. Forme par défaut
70
+ * `{ value, label, color? }` — ou **n'importe quelle forme d'objet** (ex. `[{ _id, name }]`
71
+ * renvoyé par une API) dès que `optionLabel` / `optionValue` sont fournis.
72
+ */
73
+ options?: any[]
74
+ /**
75
+ * Pour type `select` / `multiselect` : clé (ou fonction) du **libellé** dans une option —
76
+ * défaut `"label"`. Ex. `option-label="name"`. Vocabulaire de `<q-select>`.
77
+ */
78
+ optionLabel?: QSpreadsheetOptionAccessor
79
+ /**
80
+ * Pour type `select` / `multiselect` : clé (ou fonction) de la **valeur stockée** dans la
81
+ * cellule — défaut `"value"`. Doit être **primitive** (string / number / bool) et unique.
82
+ * Ex. `option-value="_id"`.
83
+ */
84
+ optionValue?: QSpreadsheetOptionAccessor
63
85
  /** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
64
86
  chip?: boolean
65
87
  /** Validation à la saisie : objet `{ min, max, … }` ou expression ArkType (`"number < 4"`) */
@@ -70,6 +92,14 @@ export interface QSpreadsheetColumn {
70
92
  cellClass?: (val: any, row: Record<string, any>) => string | undefined
71
93
  /** Couleur de fond de la cellule (plan) */
72
94
  cellBackground?: (val: any, row: Record<string, any>) => string | undefined
95
+ /**
96
+ * Cellule **non modifiable** : édition, effacement, bascule booléenne, collage, remplissage et
97
+ * barre de formule sont refusés pour les lignes où le prédicat est vrai. Reçoit la valeur
98
+ * courante, la ligne et son **index** — donc verrouille aussi une cellule **vide**
99
+ * (`(v) => v !== null`, `(_, row) => row.locked`, `(_, __, i) => i === 0`…).
100
+ * Pour désigner un bloc par ses coordonnées, préférer `lockedRanges`.
101
+ */
102
+ cellReadonly?: (val: any, row: Record<string, any>, rowIndex: number) => boolean
73
103
  headerClass?: string
74
104
  headerStyle?: string
75
105
  }
@@ -78,6 +108,10 @@ export interface QSpreadsheetColumn {
78
108
  // `lib/spreadsheetValidation.ts` (source unique, testée hors navigateur) et sont
79
109
  // ré-exportées en tête de ce bloc.
80
110
 
111
+ // Les verrous (`QSpreadsheetLock`) et le préremplissage (`QSpreadsheetFill`) vivent dans
112
+ // `lib/spreadsheetZones.ts` — forme **lisible** (noms de colonnes) traduite en rectangles
113
+ // d'index par des fonctions pures, testées hors navigateur.
114
+
81
115
  /** Validation par plage de cellules (rows/cols 0-based) */
82
116
  export interface QSpreadsheetRangeValidator {
83
117
  r0: number
@@ -157,6 +191,8 @@ import {
157
191
  parseCsv,
158
192
  } from "../lib/spreadsheet"
159
193
  import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
194
+ import { normalizeCellOptions } from "../lib/spreadsheetOptions"
195
+ import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
160
196
  import {
161
197
  DEFAULT_ROW_KEY,
162
198
  diffDocuments,
@@ -192,6 +228,42 @@ interface Props {
192
228
  lang?: "en" | "fr"
193
229
  /** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
194
230
  validators?: QSpreadsheetRangeValidator[]
231
+ /**
232
+ * Cellules **verrouillées**, écrites avec des **noms** (jamais d'index) :
233
+ * - `{ row: 2, column: "total" }` → la cellule « total » de la ligne 2 ;
234
+ * - `{ row: 0 }` → toute la ligne 0 ;
235
+ * - `{ column: "total" }` → toute la colonne « total » ;
236
+ * - `{ from: { row, column }, to: { row, column } }` → le bloc entre les deux coins.
237
+ *
238
+ * S'applique **même à une cellule vide**. S'ajoute à `columns[].cellReadonly` et aux props
239
+ * globales `readonly` / `disable`.
240
+ */
241
+ lockedRanges?: QSpreadsheetLock[]
242
+ /**
243
+ * **Préremplit** des zones, écrites avec des **noms** de colonnes (jamais d'index) —
244
+ * même vocabulaire que `lockedRanges`, plus une `value` :
245
+ * - `[{ column: "status", value: "todo" }]` → toute la colonne « status » ;
246
+ * - `[{ row: 0, value: "—" }]` → toute la ligne 0 ;
247
+ * - `[{ row: 2, column: "total", value: 0 }]` → la cellule (ligne 2, colonne « total ») ;
248
+ * - `[{ from: { row: 0, column: "total" }, to: { row: 2, column: "status" }, value: false }]`
249
+ * → le bloc entre les deux coins (bornes **incluses**).
250
+ *
251
+ * `value` est une **constante** *ou* une **fonction** `(ctx) => any` (ctx : `rowIndex`,
252
+ * `row`, `column`, `letter`, `rowCount`, `letterOf`) — pour une valeur **dynamique**.
253
+ *
254
+ * `fx` porte une **formule**, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
255
+ * initial est ajouté s'il manque) ou une fonction `(ctx) => string`. C'est la façon explicite
256
+ * d'écrire une formule, p. ex. la somme d'une colonne :
257
+ * `{ row: 3, column: "net", fx: (c) => `SUM(${c.letterOf("effectif")}1:${c.letterOf("effectif")}${c.rowCount})` }`.
258
+ * (Une chaîne de `value` commençant par `"="` reste stockée comme formule, mais `fx` est
259
+ * préféré : il documente l'intention et n'exige pas le préfixe.)
260
+ *
261
+ * Une ligne **neuve** (bouton « + », insertion) naît déjà préremplie. Les données **déjà
262
+ * chargées** ne sont **jamais** modifiées automatiquement : appeler `applyPrefill()`
263
+ * (méthode exposée) pour combler les cellules **vides** — le dernier `prefill` gagne,
264
+ * les noms de colonnes inconnus sont ignorés.
265
+ */
266
+ prefill?: QSpreadsheetFill[]
195
267
  /** Affiche le numéro de ligne (colonne de gauche) */
196
268
  showRowNumbers?: boolean
197
269
  /** Affiche l'en-tête de colonne (lettre + label) */
@@ -513,6 +585,38 @@ const colNameAt = (i: number) => cols.value[i]?.name ?? ""
513
585
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
514
586
 
515
587
  // ─── Cellules à choix (select = une valeur, multiselect = un tableau de valeurs) ───
588
+ /**
589
+ * Options d'une colonne, **normalisées une seule fois** vers `{ value, label, color? }` —
590
+ * `optionLabel` / `optionValue` y sont appliqués (défauts `"label"` / `"value"`). C'est ce qui
591
+ * garantit que badge, info-bulle, éditeur, tri, filtre et copie/CSV lisent tous la même forme :
592
+ * un accesseur oublié quelque part donnerait un badge `_id` et un tooltip « Ada ».
593
+ * Mémoïsé par nom de colonne (le cache vit dans l'instance du composant).
594
+ */
595
+ const optionsCache = new Map<
596
+ string,
597
+ { opts: QSpreadsheetCellOption[]; options: unknown; label: unknown; value: unknown }
598
+ >()
599
+ const cellOptionsOf = (col: QSpreadsheetColumn | undefined): QSpreadsheetCellOption[] => {
600
+ if (!col) return []
601
+ const hit = optionsCache.get(col.name)
602
+ if (
603
+ hit &&
604
+ hit.options === col.options &&
605
+ hit.label === col.optionLabel &&
606
+ hit.value === col.optionValue
607
+ ) {
608
+ return hit.opts
609
+ }
610
+ const opts = normalizeCellOptions(col.options, col.optionLabel, col.optionValue)
611
+ optionsCache.set(col.name, {
612
+ opts,
613
+ options: col.options,
614
+ label: col.optionLabel,
615
+ value: col.optionValue,
616
+ })
617
+ return opts
618
+ }
619
+
516
620
  /**
517
621
  * Valeurs d'une cellule à choix multiples, toujours sous forme de liste : un tableau est
518
622
  * repris tel quel, une valeur nue (`null`, `"it"`…) devient `[valeur]` (ou `[]` si vide).
@@ -526,7 +630,7 @@ const multiValues = (raw: any): any[] => {
526
630
  /** Libellés d'une cellule à choix multiples (le `value` brut si l'option est inconnue) */
527
631
  const multiLabels = (col: QSpreadsheetColumn | undefined, raw: any): string[] =>
528
632
  multiValues(raw).map((v) => {
529
- const opt = col?.options?.find((o) => o.value === v)
633
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
530
634
  return String(opt?.label ?? v)
531
635
  })
532
636
 
@@ -538,7 +642,7 @@ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
538
642
  if (col?.type === "multiselect") return multiLabels(col, raw).join(", ")
539
643
  if (isBlankValue(raw)) return ""
540
644
  if (col?.type === "select") {
541
- const opt = col.options?.find((o) => o.value === raw)
645
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
542
646
  if (opt?.label !== undefined) return String(opt.label)
543
647
  }
544
648
  return String(raw)
@@ -744,6 +848,27 @@ const cellTitle = (row: number, column: string): string | undefined => {
744
848
  return undefined
745
849
  }
746
850
 
851
+ /** Verrous de `lockedRanges`, traduits en rectangles d'index (noms de colonnes résolus). */
852
+ const lockRectsResolved = computed(() => lockRects(props.lockedRanges, colIndex))
853
+
854
+ /**
855
+ * La cellule est-elle **verrouillée** ? Une seule porte pour toutes les mutations : props
856
+ * globales (`readonly` / `disable`), colonne `editable: false`, prédicat `cellReadonly(val, row)`
857
+ * et verrous `lockedRanges`. S'applique **même à une cellule vide** (il n'y a rien à effacer,
858
+ * mais on ne peut pas y écrire) — verrouiller n'est pas effacer.
859
+ */
860
+ const isCellLocked = (row: number, column: string): boolean => {
861
+ if (props.readonly || props.disable) return true
862
+ const col = colOf(column)
863
+ if (!col) return false
864
+ if (col.editable === false) return true
865
+ const rowData = state.value[row] ?? {}
866
+ if (col.cellReadonly?.(rowData[column], rowData, row)) return true
867
+ const ci = colIndex(column)
868
+ if (ci === -1) return false
869
+ return lockRectsResolved.value.some((z) => inLockRect(z, row, ci))
870
+ }
871
+
747
872
  const cellClasses = (row: number, column: string) => {
748
873
  const col = colOf(column)
749
874
  const v = state.value[row]?.[column]
@@ -759,6 +884,9 @@ const cellClasses = (row: number, column: string) => {
759
884
  isActive(row, column) && "q-spreadsheet__cell--active",
760
885
  editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
761
886
  props.readonly && "q-spreadsheet__cell--readonly",
887
+ // Verrou **ciblé** (colonne, prédicat, plage) : affordance visuelle. Le verrou global
888
+ // (`readonly` / `disable`) n'ajoute rien — hacher toute la grille serait illisible.
889
+ isCellLocked(row, column) && !props.readonly && !props.disable && "q-spreadsheet__cell--locked",
762
890
  )
763
891
  }
764
892
 
@@ -833,12 +961,11 @@ const editorInputMode = computed(() => {
833
961
  })
834
962
 
835
963
  const startEdit = (row: number, column: string, initial?: string) => {
836
- if (props.readonly || props.disable) return
837
964
  const col = colOf(column)
838
- if (col?.editable === false || col?.type === "boolean") return
965
+ if (isCellLocked(row, column) || col?.type === "boolean") return
839
966
  const raw = state.value[row]?.[column]
840
967
  if (col?.type === "select") {
841
- const opt = col.options?.find((o) => o.value === raw)
968
+ const opt = cellOptionsOf(col).find((o) => o.value === raw)
842
969
  // `label` peut être un nombre (options numériques) : `draft` alimente un
843
970
  // <input>/<textarea> et sert à `.trim()` / `.toLowerCase()` → toujours une string.
844
971
  draft.value = String(initial ?? opt?.label ?? "")
@@ -925,7 +1052,9 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
925
1052
  return raw === "true" ? true : raw === "false" ? false : old
926
1053
  }
927
1054
  if (col?.type === "select") {
928
- const opt = col.options?.find((o) => String(o.label ?? "") === raw || String(o.value) === raw)
1055
+ const opt = cellOptionsOf(col).find(
1056
+ (o) => String(o.label ?? "") === raw || String(o.value) === raw,
1057
+ )
929
1058
  return opt ? opt.value : old
930
1059
  }
931
1060
  // `multiselect` : collage / saisie « a, b » → tableau de valeurs d'option
@@ -936,7 +1065,7 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
936
1065
  .filter((s) => s !== "")
937
1066
  if (!parts.length) return null
938
1067
  return parts.map((part) => {
939
- const opt = col.options?.find(
1068
+ const opt = cellOptionsOf(col).find(
940
1069
  (o) => String(o.label ?? "") === part || String(o.value) === part,
941
1070
  )
942
1071
  return opt ? opt.value : part
@@ -969,17 +1098,14 @@ const setCellValue = (row: number, column: string, old: any, next: any) => {
969
1098
  }
970
1099
 
971
1100
  const clearCell = (row: number, column: string) => {
972
- const col = colOf(column)
973
- if (col?.editable === false || props.readonly || props.disable) return
1101
+ if (isCellLocked(row, column)) return
974
1102
  const old = state.value[row]?.[column]
975
1103
  if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
976
1104
  setCellValue(row, column, old, null)
977
1105
  }
978
1106
 
979
1107
  const toggleBoolean = (row: number, column: string) => {
980
- if (props.readonly || props.disable) return
981
- const col = colOf(column)
982
- if (col?.editable === false) return
1108
+ if (isCellLocked(row, column)) return
983
1109
  const old = !!state.value[row]?.[column]
984
1110
  setCellValue(row, column, old, !old)
985
1111
  }
@@ -991,7 +1117,7 @@ const clearSelection = () => {
991
1117
  for (let r = rect.r0; r <= rect.r1; r++) {
992
1118
  for (let c = rect.c0; c <= rect.c1; c++) {
993
1119
  const col = cols.value[c]
994
- if (!col || col.editable === false || props.readonly || props.disable) continue
1120
+ if (!col || isCellLocked(r, col.name)) continue
995
1121
  const v = state.value[r]?.[col.name]
996
1122
  if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
997
1123
  changed.push({ row: r, column: col.name })
@@ -1136,19 +1262,56 @@ const redo = () => {
1136
1262
  }
1137
1263
 
1138
1264
  // ─── Lignes & colonnes ───
1139
- const blankRow = (): Record<string, any> => {
1265
+ const blankRow = (at = state.value.length): Record<string, any> => {
1140
1266
  // `_key` posée dès la création : la ligne est identifiable sans attendre un emit
1141
1267
  const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
1142
1268
  for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
1269
+ // Une ligne neuve naît préremplie par les zones `prefill` qui la visent.
1270
+ // `rowCount` compte la ligne en cours de création (le document l'aura).
1271
+ Object.assign(
1272
+ row,
1273
+ prefillForRow(props.prefill, at, cols.value.map((c) => c.name), {
1274
+ row,
1275
+ rowCount: state.value.length + 1,
1276
+ }),
1277
+ )
1143
1278
  return row
1144
1279
  }
1145
1280
 
1281
+ /**
1282
+ * Comble les cellules **vides** d'après `prefill` (sans jamais écraser une valeur
1283
+ * existante) et renvoie le nombre de cellules remplies. Utile après avoir chargé
1284
+ * `rows` : les données déjà présentes ne sont pas touchées à l'initialisation.
1285
+ */
1286
+ const applyPrefill = (): number => {
1287
+ const fills = props.prefill
1288
+ if (!fills?.length || props.disable) return 0
1289
+ const names = cols.value.map((c) => c.name)
1290
+ let count = 0
1291
+ const next = state.value.map((row, r) => {
1292
+ const patch = prefillForRow(fills, r, names, { row, rowCount: state.value.length })
1293
+ let out = row
1294
+ for (const [name, value] of Object.entries(patch)) {
1295
+ if (!isBlankValue(row[name])) continue
1296
+ if (out === row) out = { ...row }
1297
+ out[name] = value
1298
+ count++
1299
+ }
1300
+ return out
1301
+ })
1302
+ if (!count) return 0
1303
+ pushHistory()
1304
+ state.value = next
1305
+ emit("update:rows", next)
1306
+ return count
1307
+ }
1308
+
1146
1309
  const addRow = (at?: number) => {
1147
1310
  if (props.readonly || props.disable) return
1148
1311
  pushHistory()
1149
1312
  const idx = at ?? state.value.length
1150
1313
  const next = [...state.value]
1151
- next.splice(idx, 0, blankRow())
1314
+ next.splice(idx, 0, blankRow(idx))
1152
1315
  state.value = next
1153
1316
  emit("update:rows", next)
1154
1317
  emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
@@ -1314,7 +1477,7 @@ const pasteClip = async () => {
1314
1477
  const c = startCol + j
1315
1478
  if (r >= state.value.length || c >= cols.value.length) return
1316
1479
  const col = cols.value[c]!
1317
- if (col.editable === false) return
1480
+ if (isCellLocked(r, col.name)) return
1318
1481
  const old = state.value[r]?.[col.name]
1319
1482
  const next = coerceValue(col, text, old)
1320
1483
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -1409,10 +1572,11 @@ const fillDownKey = () => {
1409
1572
  for (let c = rect.c0; c <= rect.c1; c++) {
1410
1573
  const name = colNameAt(c)
1411
1574
  const col = colOf(name)
1412
- if (!col || col.editable === false) continue
1575
+ if (!col) continue
1413
1576
  const src = rect.r0 - 1
1414
1577
  const sv = state.value[src]?.[name]
1415
1578
  for (let r = rect.r0; r <= rect.r1; r++) {
1579
+ if (isCellLocked(r, name)) continue
1416
1580
  const old = state.value[r]?.[name]
1417
1581
  if (old !== sv) setCellValue(r, name, old, sv)
1418
1582
  }
@@ -1428,10 +1592,11 @@ const fillRightKey = () => {
1428
1592
  const src = rect.c0 - 1
1429
1593
  const sname = colNameAt(src)
1430
1594
  const col = colOf(sname)
1431
- if (!col || col.editable === false) continue
1595
+ if (!col) continue
1432
1596
  const sv = state.value[r]?.[sname]
1433
1597
  for (let c = rect.c0; c <= rect.c1; c++) {
1434
1598
  const name = colNameAt(c)
1599
+ if (isCellLocked(r, name)) continue
1435
1600
  const old = state.value[r]?.[name]
1436
1601
  if (old !== sv) setCellValue(r, name, old, sv)
1437
1602
  }
@@ -1547,7 +1712,7 @@ const onKeydown = (e: KeyboardEvent) => {
1547
1712
  // Frappe directe : remplace le contenu (comportement Excel)
1548
1713
  if (e.key.length === 1 && !mod && !e.altKey) {
1549
1714
  const col = colOf(sel.value.column)
1550
- if (col && col.editable !== false && col.type !== "boolean") {
1715
+ if (col && col.type !== "boolean" && !isCellLocked(sel.value.row, sel.value.column)) {
1551
1716
  e.preventDefault()
1552
1717
  startEdit(sel.value.row, sel.value.column, e.key)
1553
1718
  }
@@ -1637,7 +1802,7 @@ const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
1637
1802
  const col = colOf(editing.value.column)
1638
1803
  if (col?.type !== "select" && col?.type !== "multiselect") return []
1639
1804
  const q = String(draft.value ?? "").trim().toLowerCase()
1640
- return (col.options ?? []).filter(
1805
+ return cellOptionsOf(col).filter(
1641
1806
  (o) => !q || String(o.label ?? o.value ?? "").toLowerCase().includes(q),
1642
1807
  )
1643
1808
  })
@@ -1665,8 +1830,7 @@ const multiSelected = (opt: QSpreadsheetCellOption) =>
1665
1830
  const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
1666
1831
  if (!editing.value) return
1667
1832
  const { row, column } = editing.value
1668
- const col = colOf(column)
1669
- if (col?.editable === false || props.readonly || props.disable) return
1833
+ if (isCellLocked(row, column)) return
1670
1834
  const old = state.value[row]?.[column]
1671
1835
  const current = multiValues(old)
1672
1836
  const next = multiSelected(opt)
@@ -1690,11 +1854,12 @@ const syncFx = () => {
1690
1854
  watch([sel, state], syncFx)
1691
1855
 
1692
1856
  const fxCanEdit = computed(() => {
1693
- if (props.readonly || props.disable || !sel.value) return false
1694
- const col = colOf(sel.value.column)
1857
+ if (!sel.value) return false
1858
+ const { row, column } = sel.value
1859
+ if (isCellLocked(row, column)) return false
1860
+ const col = colOf(column)
1695
1861
  return (
1696
1862
  !!col &&
1697
- col.editable !== false &&
1698
1863
  col.type !== "boolean" &&
1699
1864
  col.type !== "select" &&
1700
1865
  col.type !== "multiselect"
@@ -2446,7 +2611,7 @@ const insertRowAt = (pos: "above" | "below") => {
2446
2611
  : state.value.length
2447
2612
  const idx = Math.max(0, Math.min(state.value.length, at))
2448
2613
  const next = [...state.value]
2449
- next.splice(idx, 0, blankRow())
2614
+ next.splice(idx, 0, blankRow(idx))
2450
2615
  state.value = next
2451
2616
  shiftRowKeys(idx, 1)
2452
2617
  emit("update:rows", next)
@@ -2495,7 +2660,7 @@ const badgeStyle = (opt: QSpreadsheetCellOption) => {
2495
2660
 
2496
2661
  const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2497
2662
  if (col?.type !== "select" || !col.chip) return undefined
2498
- const opt = col.options?.find((o) => o.value === val)
2663
+ const opt = cellOptionsOf(col).find((o) => o.value === val)
2499
2664
  if (!opt) return undefined
2500
2665
  return { label: opt.label, style: badgeStyle(opt) }
2501
2666
  }
@@ -2504,7 +2669,7 @@ const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2504
2669
  const multiBadges = (col: QSpreadsheetColumn | undefined, val: any) => {
2505
2670
  if (col?.type !== "multiselect" || !col.chip) return []
2506
2671
  return multiValues(val).map((v) => {
2507
- const opt = col.options?.find((o) => o.value === v)
2672
+ const opt = cellOptionsOf(col).find((o) => o.value === v)
2508
2673
  return {
2509
2674
  label: String(opt?.label ?? v),
2510
2675
  style: badgeStyle(opt ?? { value: v, label: String(v) }),
@@ -2639,7 +2804,7 @@ const applyFill = () => {
2639
2804
  if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
2640
2805
  const name = colNameAt(cc)
2641
2806
  const col = colOf(name)
2642
- if (!col || col.editable === false) continue
2807
+ if (!col || isCellLocked(rr, name)) continue
2643
2808
 
2644
2809
  let val: any
2645
2810
  // Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
@@ -3372,8 +3537,7 @@ const findMarkClass = (r: number, ci: number) =>
3372
3537
  )
3373
3538
  const findReplaceCurrent = () => {
3374
3539
  const m = findMatches.value[findIdx.value]
3375
- if (!m || props.readonly || props.disable) return
3376
- const col = cols.value[m.ci]
3540
+ if (!m || isCellLocked(m.r, m.name)) return
3377
3541
  const raw = state.value[m.r]?.[m.name]
3378
3542
  if (typeof raw !== "string" || !findQuery.value) return
3379
3543
  const next = findCase.value
@@ -3388,6 +3552,7 @@ const findReplaceAll = () => {
3388
3552
  pushHistory()
3389
3553
  const changed: { r: number; name: string; old: string; next: string }[] = []
3390
3554
  for (const m of findMatches.value) {
3555
+ if (isCellLocked(m.r, m.name)) continue
3391
3556
  const raw = state.value[m.r]?.[m.name]
3392
3557
  if (typeof raw !== "string") continue
3393
3558
  const next = findCase.value
@@ -3526,7 +3691,7 @@ const pasteValues = async () => {
3526
3691
  const c = c0 + j
3527
3692
  if (r >= state.value.length || c >= cols.value.length) continue
3528
3693
  const col = cols.value[c]!
3529
- if (col.editable === false) continue
3694
+ if (isCellLocked(r, col.name)) continue
3530
3695
  const old = state.value[r]?.[col.name]
3531
3696
  const next = coerceLiteral(col, lines[i]![j] ?? "", old)
3532
3697
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -3587,7 +3752,7 @@ const pasteTransposed = async () => {
3587
3752
  const c = c0 + i
3588
3753
  if (r >= state.value.length || c >= cols.value.length) continue
3589
3754
  const col = cols.value[c]!
3590
- if (col.editable === false) continue
3755
+ if (isCellLocked(r, col.name)) continue
3591
3756
  const old = state.value[r]?.[col.name]
3592
3757
  const next = coerceValue(col, lines[i]![j] ?? "", old)
3593
3758
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -4102,6 +4267,7 @@ defineExpose({
4102
4267
  commitEdit,
4103
4268
  cancelEdit,
4104
4269
  addRow,
4270
+ applyPrefill,
4105
4271
  insertRowAt,
4106
4272
  removeSelectedRows,
4107
4273
  addColumn,
@@ -4592,7 +4758,7 @@ defineExpose({
4592
4758
  type="button"
4593
4759
  class="q-spreadsheet__checkbox"
4594
4760
  :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
4595
- :disabled="readonly || disable || col.editable === false"
4761
+ :disabled="isCellLocked(ri, col.name)"
4596
4762
  aria-label="Toggle"
4597
4763
  @click.stop="toggleBoolean(ri, col.name)"
4598
4764
  >
@@ -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,239 @@
1
+ // Tests unitaires des **zones** de QSpreadsheet (`lib/spreadsheetZones.ts`) : la traduction de la
2
+ // forme **lisible** (noms de colonnes) vers les rectangles d'index, et le préremplissage.
3
+ import { describe, expect, it } from "bun:test"
4
+ import { inLockRect, lockRect, lockRects, prefillForRow } from "./spreadsheetZones"
5
+
6
+ /** Colonnes : task(0) · total(1) · status(2) */
7
+ const cols = ["task", "total", "status"]
8
+ const colIndex = (name: string) => cols.indexOf(name)
9
+
10
+ describe("lockRect", () => {
11
+ it("une cellule : `{ row, column }`", () => {
12
+ expect(lockRect({ row: 2, column: "total" }, colIndex)).toEqual({
13
+ r0: 2,
14
+ c0: 1,
15
+ r1: 2,
16
+ c1: 1,
17
+ })
18
+ })
19
+
20
+ it("toute une ligne : `{ row }` (colonnes non bornées)", () => {
21
+ expect(lockRect({ row: 0 }, colIndex)).toEqual({ r0: 0, c0: null, r1: 0, c1: null })
22
+ })
23
+
24
+ it("toute une colonne : `{ column }` (lignes non bornées)", () => {
25
+ expect(lockRect({ column: "total" }, colIndex)).toEqual({
26
+ r0: null,
27
+ c0: 1,
28
+ r1: null,
29
+ c1: 1,
30
+ })
31
+ })
32
+
33
+ it("un bloc : `{ from, to }` (bornes incluses)", () => {
34
+ expect(
35
+ lockRect(
36
+ { from: { row: 0, column: "task" }, to: { row: 2, column: "status" } },
37
+ colIndex,
38
+ ),
39
+ ).toEqual({ r0: 0, c0: 0, r1: 2, c1: 2 })
40
+ })
41
+
42
+ it("un bloc `from`/`to` dans n'importe quel ordre", () => {
43
+ expect(
44
+ lockRect(
45
+ { from: { row: 3, column: "status" }, to: { row: 1, column: "task" } },
46
+ colIndex,
47
+ ),
48
+ ).toEqual({ r0: 1, c0: 0, r1: 3, c1: 2 })
49
+ })
50
+
51
+ it("ignore ce qui ne désigne rien (objet vide, colonne inconnue, from sans to)", () => {
52
+ expect(lockRect({}, colIndex)).toBeNull()
53
+ expect(lockRect({ row: 1, column: "nope" }, colIndex)).toBeNull()
54
+ expect(lockRect({ from: { row: 0, column: "task" } }, colIndex)).toBeNull()
55
+ expect(lockRect(undefined, colIndex)).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe("lockRects", () => {
60
+ it("résout la liste et ignore les verrous invalides", () => {
61
+ expect(lockRects([{ row: 0 }, { column: "nope" }, { row: 1, column: "task" }], colIndex)).toEqual(
62
+ [
63
+ { r0: 0, c0: null, r1: 0, c1: null },
64
+ { r0: 1, c0: 0, r1: 1, c1: 0 },
65
+ ],
66
+ )
67
+ })
68
+
69
+ it("tolère une entrée non-tableau", () => {
70
+ expect(lockRects(undefined, colIndex)).toEqual([])
71
+ })
72
+ })
73
+
74
+ describe("inLockRect", () => {
75
+ const rect = { r0: 1, c0: 1, r1: 2, c1: 1 }
76
+
77
+ it("borne inclusivement", () => {
78
+ expect(inLockRect(rect, 1, 1)).toBe(true)
79
+ expect(inLockRect(rect, 2, 1)).toBe(true)
80
+ expect(inLockRect(rect, 0, 1)).toBe(false)
81
+ expect(inLockRect(rect, 3, 1)).toBe(false)
82
+ expect(inLockRect(rect, 1, 0)).toBe(false)
83
+ expect(inLockRect(rect, 1, 2)).toBe(false)
84
+ })
85
+
86
+ it("une borne `null` est non bornée", () => {
87
+ expect(inLockRect({ r0: null, c0: 1, r1: null, c1: 1 }, 999, 1)).toBe(true)
88
+ expect(inLockRect({ r0: 0, c0: null, r1: 0, c1: null }, 0, 999)).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe("prefillForRow", () => {
93
+ const names = ["task", "total", "status"]
94
+
95
+ it("toute la colonne : la valeur est posée sur chaque ligne", () => {
96
+ const fills = [{ column: "status", value: "todo" }]
97
+ expect(prefillForRow(fills, 0, names)).toEqual({ status: "todo" })
98
+ expect(prefillForRow(fills, 7, names)).toEqual({ status: "todo" })
99
+ })
100
+
101
+ it("une cellule : seulement la ligne visée", () => {
102
+ const fills = [{ row: 2, column: "total", value: 0 }]
103
+ expect(prefillForRow(fills, 2, names)).toEqual({ total: 0 })
104
+ expect(prefillForRow(fills, 1, names)).toEqual({})
105
+ })
106
+
107
+ it("toute une ligne : toutes les colonnes", () => {
108
+ expect(prefillForRow([{ row: 1, value: "—" }], 1, names)).toEqual({
109
+ task: "—",
110
+ total: "—",
111
+ status: "—",
112
+ })
113
+ })
114
+
115
+ it("un bloc : les colonnes et lignes couvertes", () => {
116
+ const fills = [
117
+ { from: { row: 0, column: "total" }, to: { row: 1, column: "status" }, value: false },
118
+ ]
119
+ expect(prefillForRow(fills, 0, names)).toEqual({ total: false, status: false })
120
+ expect(prefillForRow(fills, 1, names)).toEqual({ total: false, status: false })
121
+ expect(prefillForRow(fills, 2, names)).toEqual({})
122
+ })
123
+
124
+ it("cumule plusieurs zones (la dernière gagne) et ignore l'inconnu", () => {
125
+ expect(
126
+ prefillForRow(
127
+ [
128
+ { column: "status", value: "todo" },
129
+ { row: 0, column: "status", value: "urgent" },
130
+ { column: "nope", value: 1 },
131
+ ],
132
+ 0,
133
+ names,
134
+ ),
135
+ ).toEqual({ status: "urgent" })
136
+ })
137
+
138
+ it("tolère une entrée vide ou non-tableau", () => {
139
+ expect(prefillForRow(undefined, 0, names)).toEqual({})
140
+ expect(prefillForRow([], 0, names)).toEqual({})
141
+ expect(prefillForRow([{ column: "task", value: 1 }], 0, [])).toEqual({})
142
+ })
143
+ })
144
+
145
+ describe("prefillForRow — value fonction (valeurs dynamiques)", () => {
146
+ const names = ["task", "effectif", "net"]
147
+
148
+ it("appelle la fonction avec le contexte (lettre, index, taille du document)", () => {
149
+ const seen: any[] = []
150
+ const fills = [
151
+ {
152
+ row: 1,
153
+ column: "net",
154
+ value: (ctx: any) => {
155
+ seen.push(ctx)
156
+ const L = ctx.letterOf("effectif")
157
+ return `=SUM(${L}1:${L}${ctx.rowCount})`
158
+ },
159
+ },
160
+ ]
161
+ expect(prefillForRow(fills, 1, names, { rowCount: 4 })).toEqual({ net: "=SUM(B1:B4)" })
162
+ expect(seen[0]).toMatchObject({
163
+ rowIndex: 1,
164
+ column: "net",
165
+ columnIndex: 2,
166
+ letter: "C",
167
+ rowCount: 4,
168
+ })
169
+ expect(seen[0].letterOf("effectif")).toBe("B")
170
+ })
171
+
172
+ it("reçoit la ligne ciblée (agrégat calculé, pas une formule)", () => {
173
+ const fills = [{ row: 0, column: "net", value: (ctx: any) => Number(ctx.row.effectif) * 2 }]
174
+ expect(prefillForRow(fills, 0, names, { row: { effectif: 21 } })).toEqual({ net: 42 })
175
+ })
176
+
177
+ it("une chaîne de formule constante passe telle quelle", () => {
178
+ expect(prefillForRow([{ row: 0, column: "net", value: "=B1*2" }], 0, names)).toEqual({
179
+ net: "=B1*2",
180
+ })
181
+ })
182
+
183
+ it("une value **fonction** peut renvoyer une formule", () => {
184
+ const fills = [
185
+ {
186
+ row: 0,
187
+ column: "net",
188
+ value: (ctx: any) => `=SUM(${ctx.letterOf("effectif")}1:${ctx.letterOf("effectif")}${ctx.rowCount})`,
189
+ },
190
+ ]
191
+ expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B3)" })
192
+ })
193
+
194
+ it("value reste littéral (pas de normalisation du préfixe `=`)", () => {
195
+ expect(prefillForRow([{ row: 0, column: "net", value: "SUM(B1:B2)" }], 0, names)).toEqual({
196
+ net: "SUM(B1:B2)",
197
+ })
198
+ })
199
+ })
200
+
201
+ describe("prefillForRow — fx (formule prioritaire)", () => {
202
+ const names = ["task", "effectif", "net"]
203
+
204
+ it("fx accepte une formule sans préfixe `=` (normalisée)", () => {
205
+ expect(prefillForRow([{ row: 0, column: "net", fx: "SUM(B1:B2)" }], 0, names)).toEqual({
206
+ net: "=SUM(B1:B2)",
207
+ })
208
+ expect(prefillForRow([{ row: 0, column: "net", fx: "=B1*2" }], 0, names)).toEqual({
209
+ net: "=B1*2",
210
+ })
211
+ })
212
+
213
+ it("fx est prioritaire sur value", () => {
214
+ expect(
215
+ prefillForRow([{ row: 0, column: "net", value: 42, fx: "B1*2" }], 0, names),
216
+ ).toEqual({ net: "=B1*2" })
217
+ })
218
+
219
+ it("fx fonction reçoit le contexte et peut être dynamique", () => {
220
+ const fills = [
221
+ {
222
+ row: 0,
223
+ column: "net",
224
+ fx: (ctx: any) => {
225
+ const L = ctx.letterOf("effectif")
226
+ return `SUM(${L}1:${L}${ctx.rowCount - 1})`
227
+ },
228
+ },
229
+ ]
230
+ expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B2)" })
231
+ })
232
+
233
+ it("un fx vide retombe sur value ; sinon rien n'est posé", () => {
234
+ expect(prefillForRow([{ row: 0, column: "net", fx: " ", value: 7 }], 0, names)).toEqual({
235
+ net: 7,
236
+ })
237
+ expect(prefillForRow([{ row: 0, column: "net" }], 0, names)).toEqual({})
238
+ })
239
+ })
@@ -0,0 +1,253 @@
1
+ // **Zones** de QSpreadsheet (`<q-spreadsheet>`) désignées par des **noms** — servent à deux
2
+ // choses : verrouiller des cellules (`lockedRanges`) et les **préremplir** (`prefill`).
3
+ //
4
+ // On désigne les cellules par **nom de colonne** (la clé dans `rows`), jamais par index :
5
+ //
6
+ // { row: 2, column: "total" } → la cellule « total » de la ligne 2
7
+ // { row: 0 } → toute la ligne 0
8
+ // { column: "total" } → toute la colonne « total »
9
+ // { from: {...}, to: {...} } → un bloc (bornes incluses, dans n'importe quel ordre)
10
+ //
11
+ // `lockRect()` traduit cette forme en rectangle d'**index** (une borne `null` = non bornée) ;
12
+ // `prefillForRow()` en déduit les valeurs à poser dans une ligne neuve. Logique **pure**,
13
+ // testable hors navigateur — comme `spreadsheetValidation.ts`.
14
+ import { colLetter } from "./spreadsheet"
15
+
16
+ /** Un coin, en clair : ligne (0-based) + **nom** de colonne. */
17
+ export interface QSpreadsheetLockPoint {
18
+ /** Ligne, 0-based (0 = première ligne de données) */
19
+ row: number
20
+ /** Nom de la colonne — la clé utilisée dans `rows` (ex. `"total"`) */
21
+ column: string
22
+ }
23
+
24
+ /**
25
+ * Un verrou, écrit avec des **noms** :
26
+ * - `{ row: 2, column: "total" }` → une cellule ;
27
+ * - `{ row: 0 }` → toute la ligne 0 ;
28
+ * - `{ column: "total" }` → toute la colonne ;
29
+ * - `{ from, to }` → le bloc entre les deux coins (bornes incluses).
30
+ */
31
+ export interface QSpreadsheetLock {
32
+ /** Ligne (0-based). Omise → **toutes** les lignes. */
33
+ row?: number
34
+ /** Nom de colonne. Omise → **toutes** les colonnes. */
35
+ column?: string
36
+ /** Premier coin d'un **bloc** (avec `to`) — prioritaire sur `row` / `column`. */
37
+ from?: QSpreadsheetLockPoint
38
+ /** Coin opposé du bloc (avec `from`). */
39
+ to?: QSpreadsheetLockPoint
40
+ }
41
+
42
+ /** Rectangle d'index. Une borne `null` = **non bornée** (toutes les lignes / colonnes). */
43
+ export interface LockRect {
44
+ r0: number | null
45
+ c0: number | null
46
+ r1: number | null
47
+ c1: number | null
48
+ }
49
+
50
+ /**
51
+ * Traduit un verrou lisible en rectangle d'index.
52
+ * `null` quand il ne désigne rien : objet vide, `from`/`to` incomplets, ou nom de colonne
53
+ * inconnu (un verrou mal orthographié est **ignoré** plutôt que de tout verrouiller).
54
+ */
55
+ export function lockRect(
56
+ lock: QSpreadsheetLock | undefined,
57
+ columnIndex: (name: string) => number,
58
+ ): LockRect | null {
59
+ if (!lock) return null
60
+
61
+ if (lock.from && lock.to) {
62
+ const a = columnIndex(lock.from.column)
63
+ const b = columnIndex(lock.to.column)
64
+ if (a === -1 || b === -1) return null
65
+ return {
66
+ r0: Math.min(lock.from.row, lock.to.row),
67
+ r1: Math.max(lock.from.row, lock.to.row),
68
+ c0: Math.min(a, b),
69
+ c1: Math.max(a, b),
70
+ }
71
+ }
72
+
73
+ const hasRow = typeof lock.row === "number"
74
+ const hasColumn = typeof lock.column === "string"
75
+ if (!hasRow && !hasColumn) return null
76
+
77
+ let c0: number | null = null
78
+ let c1: number | null = null
79
+ if (hasColumn) {
80
+ const ci = columnIndex(lock.column as string)
81
+ if (ci === -1) return null
82
+ c0 = ci
83
+ c1 = ci
84
+ }
85
+
86
+ const r = hasRow ? (lock.row as number) : null
87
+ return { r0: r, r1: r, c0, c1 }
88
+ }
89
+
90
+ /** Traduit une liste de verrous (les entrées non résolvables sont ignorées). */
91
+ export function lockRects(
92
+ locks: QSpreadsheetLock[] | undefined,
93
+ columnIndex: (name: string) => number,
94
+ ): LockRect[] {
95
+ if (!Array.isArray(locks)) return []
96
+ const out: LockRect[] = []
97
+ for (const lock of locks) {
98
+ const rect = lockRect(lock, columnIndex)
99
+ if (rect) out.push(rect)
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** La cellule `(row, col)` tombe-t-elle dans le rectangle ? (bornes `null` = non bornées) */
105
+ export const inLockRect = (rect: LockRect, row: number, col: number): boolean =>
106
+ (rect.r0 === null || row >= rect.r0) &&
107
+ (rect.r1 === null || row <= rect.r1) &&
108
+ (rect.c0 === null || col >= rect.c0) &&
109
+ (rect.c1 === null || col <= rect.c1)
110
+
111
+ // ─────────────────────────── Préremplissage ───────────────────────────
112
+
113
+ /**
114
+ * Une **formule** de préremplissage : une chaîne (`"SUM(B1:B2)"` ou `"=SUM(B1:B2)"` — le `=`
115
+ * initial est ajouté s'il manque) ou une **fonction** du contexte qui renvoie cette chaîne.
116
+ */
117
+ export type QSpreadsheetFillFormula = string | ((ctx: QSpreadsheetFillContext) => string)
118
+
119
+ /** Zone **préremplie** : mêmes coordonnées qu'un verrou, plus une `value` **et/ou** une `fx`. */
120
+ export interface QSpreadsheetFill extends QSpreadsheetLock {
121
+ /**
122
+ * Valeur posée dans les cellules **vides** de la zone. Une **constante** (`0`, `"todo"`, …)
123
+ * **ou** une **fonction** du contexte — pour une valeur **dynamique** (agrégat calculé…).
124
+ * Une chaîne commençant par `"="` est stockée comme **formule** et évaluée par le tableur.
125
+ *
126
+ * Si `fx` est **aussi** fourni, c'est `fx` qui gagne.
127
+ */
128
+ value?: any | ((ctx: QSpreadsheetFillContext) => any)
129
+ /**
130
+ * **Formule** de la zone, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
131
+ * initial est ajouté s'il manque) ou une fonction qui la renvoie. Écrit explicitement « c'est
132
+ * une formule », sans se soucier du préfixe :
133
+ *
134
+ * ```ts
135
+ * { row: 2, column: "total", fx: ({ letterOf, rowCount }) =>
136
+ * `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount - 1})` }
137
+ * ```
138
+ */
139
+ fx?: QSpreadsheetFillFormula
140
+ }
141
+
142
+ /**
143
+ * Contexte remis à une `value` **fonction**, décrit la cellule remplie et le document
144
+ * au moment du remplissage. Permet d'écrire des formules dont la référence dépend de la
145
+ * position ou de la taille des données :
146
+ *
147
+ * ```ts
148
+ * // somme de TOUTE la colonne « effectif » (autant de lignes qu'il y en a)
149
+ * { row: 3, column: "net", fx: ({ letterOf, rowCount }) =>
150
+ * `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount})` }
151
+ * ```
152
+ */
153
+ export interface QSpreadsheetFillContext {
154
+ /** Index (0-based) de la ligne remplie */
155
+ rowIndex: number
156
+ /** La ligne ciblée (référence live sur `rows`) */
157
+ row: Record<string, any>
158
+ /** Nom de la colonne remplie */
159
+ column: string
160
+ /** Index (0-based) de la colonne remplie */
161
+ columnIndex: number
162
+ /** Lettre A1 de la colonne remplie (ex. `"C"`) */
163
+ letter: string
164
+ /** Nombre de lignes du document au moment du remplissage */
165
+ rowCount: number
166
+ /** Lettre A1 d'une colonne par son **nom** (ex. `letterOf("effectif")` → `"C"`) */
167
+ letterOf: (column: string) => string
168
+ }
169
+
170
+ /** Contexte minimal fourni par le composant (ligne ciblée + taille du document). */
171
+ export interface QSpreadsheetFillEnv {
172
+ /** La ligne ciblée (référence live sur `rows`) */
173
+ row?: Record<string, any>
174
+ /** Nombre de lignes du document au moment du remplissage */
175
+ rowCount?: number
176
+ }
177
+
178
+ /**
179
+ * Valeurs à poser dans une ligne d'index `rowIndex`, d'après les zones `prefill` :
180
+ * chaque zone qui couvre cette ligne fournit sa valeur pour toutes ses colonnes — une `fx`
181
+ * (formule, **prioritaire**) sinon une `value` ; les deux peuvent être des fonctions appelées
182
+ * avec le `QSpreadsheetFillContext`. Renvoie `{}` s'il n'y a rien à poser. Pur → testable sans
183
+ * navigateur.
184
+ */
185
+ export function prefillForRow(
186
+ fills: QSpreadsheetFill[] | undefined,
187
+ rowIndex: number,
188
+ columnNames: string[],
189
+ env: QSpreadsheetFillEnv = {},
190
+ ): Record<string, any> {
191
+ if (!Array.isArray(fills) || !fills.length || !columnNames.length) return {}
192
+
193
+ const columnIndex = (name: string) => columnNames.indexOf(name)
194
+ const letterAt = (i: number) => colLetter(i)
195
+ const letterOf = (name: string) => letterAt(columnIndex(name))
196
+ const rowCount = env.rowCount ?? 0
197
+ const out: Record<string, any> = {}
198
+
199
+ for (const fill of fills) {
200
+ const rect = lockRect(fill, columnIndex)
201
+ if (!rect) continue
202
+ if (rect.r0 !== null && rowIndex < rect.r0) continue
203
+ if (rect.r1 !== null && rowIndex > rect.r1) continue
204
+
205
+ const from = rect.c0 ?? 0
206
+ const to = rect.c1 ?? columnNames.length - 1
207
+ for (let c = from; c <= to; c++) {
208
+ const name = columnNames[c]
209
+ if (name === undefined) continue
210
+
211
+ const ctx: QSpreadsheetFillContext = {
212
+ rowIndex,
213
+ row: env.row ?? {},
214
+ column: name,
215
+ columnIndex: c,
216
+ letter: letterAt(c),
217
+ rowCount,
218
+ letterOf,
219
+ }
220
+
221
+ // `fx` (formule) est prioritaire ; `value` ne sert que si aucune formule n'est fournie.
222
+ const formula = fillFormula(fill, ctx)
223
+ if (formula !== undefined) {
224
+ out[name] = formula
225
+ continue
226
+ }
227
+ if (fill.value !== undefined) {
228
+ out[name] = typeof fill.value === "function" ? fill.value(ctx) : fill.value
229
+ }
230
+ // ni `fx` ni `value` → rien à poser
231
+ }
232
+ }
233
+
234
+ return out
235
+ }
236
+
237
+ /** Formule résolue d'une zone (normalisée avec un `=` initial), ou `undefined`. */
238
+ function fillFormula(
239
+ fill: QSpreadsheetFill,
240
+ ctx: QSpreadsheetFillContext,
241
+ ): string | undefined {
242
+ if (fill.fx === undefined || fill.fx === null) return undefined
243
+ const raw = typeof fill.fx === "function" ? fill.fx(ctx) : fill.fx
244
+ return normalizeFormula(raw)
245
+ }
246
+
247
+ /** `"SUM(B1:B2)"` → `"=SUM(B1:B2)"` ; `""` / non-chaîne → `undefined`. */
248
+ function normalizeFormula(raw: unknown): string | undefined {
249
+ if (typeof raw !== "string") return undefined
250
+ const s = raw.trim()
251
+ if (!s) return undefined
252
+ return s.startsWith("=") ? s : "=" + s
253
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.55",
3
+ "version": "0.0.58",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -9493,6 +9493,26 @@ a.q-breadcrumbs__el:hover {
9493
9493
  .q-spreadsheet__cell--readonly {
9494
9494
  cursor: default;
9495
9495
  }
9496
+
9497
+ /* Cellule verrouillée (`cellReadonly` / `lockedRanges`) : pas d'édition + affordance discrète.
9498
+ Seule la `background-image` est posée — la `background-color` (formatage conditionnel,
9499
+ `cellBackground`) reste intacte. */
9500
+ .q-spreadsheet__cell--locked {
9501
+ cursor: default;
9502
+ background-image: repeating-linear-gradient(
9503
+ 135deg,
9504
+ rgb(0 0 0 / 0.05) 0 2px,
9505
+ transparent 2px 6px
9506
+ );
9507
+ }
9508
+
9509
+ .dark .q-spreadsheet__cell--locked {
9510
+ background-image: repeating-linear-gradient(
9511
+ 135deg,
9512
+ rgb(255 255 255 / 0.07) 0 2px,
9513
+ transparent 2px 6px
9514
+ );
9515
+ }
9496
9516
  .q-spreadsheet__cell--bool {
9497
9517
  text-align: center;
9498
9518
  }