@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.
- package/components/QSpreadsheet.vue +214 -48
- package/lib/spreadsheetOptions.test.ts +84 -0
- package/lib/spreadsheetOptions.ts +70 -0
- package/lib/spreadsheetZones.test.ts +239 -0
- package/lib/spreadsheetZones.ts +253 -0
- package/package.json +1 -1
- package/styles/main.css +20 -0
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1694
|
-
const
|
|
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.
|
|
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.
|
|
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 ||
|
|
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 ||
|
|
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.
|
|
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.
|
|
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="
|
|
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
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
|
}
|