@dnax/ui 0.0.56 → 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 +143 -26
- 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,6 +8,7 @@
|
|
|
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"
|
|
11
12
|
import type {
|
|
12
13
|
QSpreadsheetCellOption,
|
|
13
14
|
QSpreadsheetOptionAccessor,
|
|
@@ -17,6 +18,13 @@ import type {
|
|
|
17
18
|
QSpreadsheetValidationRule,
|
|
18
19
|
} from "../lib/spreadsheetValidation"
|
|
19
20
|
|
|
21
|
+
export type {
|
|
22
|
+
QSpreadsheetFill,
|
|
23
|
+
QSpreadsheetFillContext,
|
|
24
|
+
QSpreadsheetFillFormula,
|
|
25
|
+
QSpreadsheetLock,
|
|
26
|
+
QSpreadsheetLockPoint,
|
|
27
|
+
} from "../lib/spreadsheetZones"
|
|
20
28
|
export type {
|
|
21
29
|
QSpreadsheetCellOption,
|
|
22
30
|
QSpreadsheetOptionAccessor,
|
|
@@ -84,6 +92,14 @@ export interface QSpreadsheetColumn {
|
|
|
84
92
|
cellClass?: (val: any, row: Record<string, any>) => string | undefined
|
|
85
93
|
/** Couleur de fond de la cellule (plan) */
|
|
86
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
|
|
87
103
|
headerClass?: string
|
|
88
104
|
headerStyle?: string
|
|
89
105
|
}
|
|
@@ -92,6 +108,10 @@ export interface QSpreadsheetColumn {
|
|
|
92
108
|
// `lib/spreadsheetValidation.ts` (source unique, testée hors navigateur) et sont
|
|
93
109
|
// ré-exportées en tête de ce bloc.
|
|
94
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
|
+
|
|
95
115
|
/** Validation par plage de cellules (rows/cols 0-based) */
|
|
96
116
|
export interface QSpreadsheetRangeValidator {
|
|
97
117
|
r0: number
|
|
@@ -172,6 +192,7 @@ import {
|
|
|
172
192
|
} from "../lib/spreadsheet"
|
|
173
193
|
import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
|
|
174
194
|
import { normalizeCellOptions } from "../lib/spreadsheetOptions"
|
|
195
|
+
import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
|
|
175
196
|
import {
|
|
176
197
|
DEFAULT_ROW_KEY,
|
|
177
198
|
diffDocuments,
|
|
@@ -207,6 +228,42 @@ interface Props {
|
|
|
207
228
|
lang?: "en" | "fr"
|
|
208
229
|
/** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
|
|
209
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[]
|
|
210
267
|
/** Affiche le numéro de ligne (colonne de gauche) */
|
|
211
268
|
showRowNumbers?: boolean
|
|
212
269
|
/** Affiche l'en-tête de colonne (lettre + label) */
|
|
@@ -791,6 +848,27 @@ const cellTitle = (row: number, column: string): string | undefined => {
|
|
|
791
848
|
return undefined
|
|
792
849
|
}
|
|
793
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
|
+
|
|
794
872
|
const cellClasses = (row: number, column: string) => {
|
|
795
873
|
const col = colOf(column)
|
|
796
874
|
const v = state.value[row]?.[column]
|
|
@@ -806,6 +884,9 @@ const cellClasses = (row: number, column: string) => {
|
|
|
806
884
|
isActive(row, column) && "q-spreadsheet__cell--active",
|
|
807
885
|
editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
|
|
808
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",
|
|
809
890
|
)
|
|
810
891
|
}
|
|
811
892
|
|
|
@@ -880,9 +961,8 @@ const editorInputMode = computed(() => {
|
|
|
880
961
|
})
|
|
881
962
|
|
|
882
963
|
const startEdit = (row: number, column: string, initial?: string) => {
|
|
883
|
-
if (props.readonly || props.disable) return
|
|
884
964
|
const col = colOf(column)
|
|
885
|
-
if (
|
|
965
|
+
if (isCellLocked(row, column) || col?.type === "boolean") return
|
|
886
966
|
const raw = state.value[row]?.[column]
|
|
887
967
|
if (col?.type === "select") {
|
|
888
968
|
const opt = cellOptionsOf(col).find((o) => o.value === raw)
|
|
@@ -1018,17 +1098,14 @@ const setCellValue = (row: number, column: string, old: any, next: any) => {
|
|
|
1018
1098
|
}
|
|
1019
1099
|
|
|
1020
1100
|
const clearCell = (row: number, column: string) => {
|
|
1021
|
-
|
|
1022
|
-
if (col?.editable === false || props.readonly || props.disable) return
|
|
1101
|
+
if (isCellLocked(row, column)) return
|
|
1023
1102
|
const old = state.value[row]?.[column]
|
|
1024
1103
|
if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
|
|
1025
1104
|
setCellValue(row, column, old, null)
|
|
1026
1105
|
}
|
|
1027
1106
|
|
|
1028
1107
|
const toggleBoolean = (row: number, column: string) => {
|
|
1029
|
-
if (
|
|
1030
|
-
const col = colOf(column)
|
|
1031
|
-
if (col?.editable === false) return
|
|
1108
|
+
if (isCellLocked(row, column)) return
|
|
1032
1109
|
const old = !!state.value[row]?.[column]
|
|
1033
1110
|
setCellValue(row, column, old, !old)
|
|
1034
1111
|
}
|
|
@@ -1040,7 +1117,7 @@ const clearSelection = () => {
|
|
|
1040
1117
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
1041
1118
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1042
1119
|
const col = cols.value[c]
|
|
1043
|
-
if (!col || col.
|
|
1120
|
+
if (!col || isCellLocked(r, col.name)) continue
|
|
1044
1121
|
const v = state.value[r]?.[col.name]
|
|
1045
1122
|
if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
|
|
1046
1123
|
changed.push({ row: r, column: col.name })
|
|
@@ -1185,19 +1262,56 @@ const redo = () => {
|
|
|
1185
1262
|
}
|
|
1186
1263
|
|
|
1187
1264
|
// ─── Lignes & colonnes ───
|
|
1188
|
-
const blankRow = (): Record<string, any> => {
|
|
1265
|
+
const blankRow = (at = state.value.length): Record<string, any> => {
|
|
1189
1266
|
// `_key` posée dès la création : la ligne est identifiable sans attendre un emit
|
|
1190
1267
|
const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
1191
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
|
+
)
|
|
1192
1278
|
return row
|
|
1193
1279
|
}
|
|
1194
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
|
+
|
|
1195
1309
|
const addRow = (at?: number) => {
|
|
1196
1310
|
if (props.readonly || props.disable) return
|
|
1197
1311
|
pushHistory()
|
|
1198
1312
|
const idx = at ?? state.value.length
|
|
1199
1313
|
const next = [...state.value]
|
|
1200
|
-
next.splice(idx, 0, blankRow())
|
|
1314
|
+
next.splice(idx, 0, blankRow(idx))
|
|
1201
1315
|
state.value = next
|
|
1202
1316
|
emit("update:rows", next)
|
|
1203
1317
|
emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
|
|
@@ -1363,7 +1477,7 @@ const pasteClip = async () => {
|
|
|
1363
1477
|
const c = startCol + j
|
|
1364
1478
|
if (r >= state.value.length || c >= cols.value.length) return
|
|
1365
1479
|
const col = cols.value[c]!
|
|
1366
|
-
if (col.
|
|
1480
|
+
if (isCellLocked(r, col.name)) return
|
|
1367
1481
|
const old = state.value[r]?.[col.name]
|
|
1368
1482
|
const next = coerceValue(col, text, old)
|
|
1369
1483
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -1458,10 +1572,11 @@ const fillDownKey = () => {
|
|
|
1458
1572
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1459
1573
|
const name = colNameAt(c)
|
|
1460
1574
|
const col = colOf(name)
|
|
1461
|
-
if (!col
|
|
1575
|
+
if (!col) continue
|
|
1462
1576
|
const src = rect.r0 - 1
|
|
1463
1577
|
const sv = state.value[src]?.[name]
|
|
1464
1578
|
for (let r = rect.r0; r <= rect.r1; r++) {
|
|
1579
|
+
if (isCellLocked(r, name)) continue
|
|
1465
1580
|
const old = state.value[r]?.[name]
|
|
1466
1581
|
if (old !== sv) setCellValue(r, name, old, sv)
|
|
1467
1582
|
}
|
|
@@ -1477,10 +1592,11 @@ const fillRightKey = () => {
|
|
|
1477
1592
|
const src = rect.c0 - 1
|
|
1478
1593
|
const sname = colNameAt(src)
|
|
1479
1594
|
const col = colOf(sname)
|
|
1480
|
-
if (!col
|
|
1595
|
+
if (!col) continue
|
|
1481
1596
|
const sv = state.value[r]?.[sname]
|
|
1482
1597
|
for (let c = rect.c0; c <= rect.c1; c++) {
|
|
1483
1598
|
const name = colNameAt(c)
|
|
1599
|
+
if (isCellLocked(r, name)) continue
|
|
1484
1600
|
const old = state.value[r]?.[name]
|
|
1485
1601
|
if (old !== sv) setCellValue(r, name, old, sv)
|
|
1486
1602
|
}
|
|
@@ -1596,7 +1712,7 @@ const onKeydown = (e: KeyboardEvent) => {
|
|
|
1596
1712
|
// Frappe directe : remplace le contenu (comportement Excel)
|
|
1597
1713
|
if (e.key.length === 1 && !mod && !e.altKey) {
|
|
1598
1714
|
const col = colOf(sel.value.column)
|
|
1599
|
-
if (col && col.
|
|
1715
|
+
if (col && col.type !== "boolean" && !isCellLocked(sel.value.row, sel.value.column)) {
|
|
1600
1716
|
e.preventDefault()
|
|
1601
1717
|
startEdit(sel.value.row, sel.value.column, e.key)
|
|
1602
1718
|
}
|
|
@@ -1714,8 +1830,7 @@ const multiSelected = (opt: QSpreadsheetCellOption) =>
|
|
|
1714
1830
|
const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
|
|
1715
1831
|
if (!editing.value) return
|
|
1716
1832
|
const { row, column } = editing.value
|
|
1717
|
-
|
|
1718
|
-
if (col?.editable === false || props.readonly || props.disable) return
|
|
1833
|
+
if (isCellLocked(row, column)) return
|
|
1719
1834
|
const old = state.value[row]?.[column]
|
|
1720
1835
|
const current = multiValues(old)
|
|
1721
1836
|
const next = multiSelected(opt)
|
|
@@ -1739,11 +1854,12 @@ const syncFx = () => {
|
|
|
1739
1854
|
watch([sel, state], syncFx)
|
|
1740
1855
|
|
|
1741
1856
|
const fxCanEdit = computed(() => {
|
|
1742
|
-
if (
|
|
1743
|
-
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)
|
|
1744
1861
|
return (
|
|
1745
1862
|
!!col &&
|
|
1746
|
-
col.editable !== false &&
|
|
1747
1863
|
col.type !== "boolean" &&
|
|
1748
1864
|
col.type !== "select" &&
|
|
1749
1865
|
col.type !== "multiselect"
|
|
@@ -2495,7 +2611,7 @@ const insertRowAt = (pos: "above" | "below") => {
|
|
|
2495
2611
|
: state.value.length
|
|
2496
2612
|
const idx = Math.max(0, Math.min(state.value.length, at))
|
|
2497
2613
|
const next = [...state.value]
|
|
2498
|
-
next.splice(idx, 0, blankRow())
|
|
2614
|
+
next.splice(idx, 0, blankRow(idx))
|
|
2499
2615
|
state.value = next
|
|
2500
2616
|
shiftRowKeys(idx, 1)
|
|
2501
2617
|
emit("update:rows", next)
|
|
@@ -2688,7 +2804,7 @@ const applyFill = () => {
|
|
|
2688
2804
|
if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
|
|
2689
2805
|
const name = colNameAt(cc)
|
|
2690
2806
|
const col = colOf(name)
|
|
2691
|
-
if (!col ||
|
|
2807
|
+
if (!col || isCellLocked(rr, name)) continue
|
|
2692
2808
|
|
|
2693
2809
|
let val: any
|
|
2694
2810
|
// Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
|
|
@@ -3421,8 +3537,7 @@ const findMarkClass = (r: number, ci: number) =>
|
|
|
3421
3537
|
)
|
|
3422
3538
|
const findReplaceCurrent = () => {
|
|
3423
3539
|
const m = findMatches.value[findIdx.value]
|
|
3424
|
-
if (!m ||
|
|
3425
|
-
const col = cols.value[m.ci]
|
|
3540
|
+
if (!m || isCellLocked(m.r, m.name)) return
|
|
3426
3541
|
const raw = state.value[m.r]?.[m.name]
|
|
3427
3542
|
if (typeof raw !== "string" || !findQuery.value) return
|
|
3428
3543
|
const next = findCase.value
|
|
@@ -3437,6 +3552,7 @@ const findReplaceAll = () => {
|
|
|
3437
3552
|
pushHistory()
|
|
3438
3553
|
const changed: { r: number; name: string; old: string; next: string }[] = []
|
|
3439
3554
|
for (const m of findMatches.value) {
|
|
3555
|
+
if (isCellLocked(m.r, m.name)) continue
|
|
3440
3556
|
const raw = state.value[m.r]?.[m.name]
|
|
3441
3557
|
if (typeof raw !== "string") continue
|
|
3442
3558
|
const next = findCase.value
|
|
@@ -3575,7 +3691,7 @@ const pasteValues = async () => {
|
|
|
3575
3691
|
const c = c0 + j
|
|
3576
3692
|
if (r >= state.value.length || c >= cols.value.length) continue
|
|
3577
3693
|
const col = cols.value[c]!
|
|
3578
|
-
if (col.
|
|
3694
|
+
if (isCellLocked(r, col.name)) continue
|
|
3579
3695
|
const old = state.value[r]?.[col.name]
|
|
3580
3696
|
const next = coerceLiteral(col, lines[i]![j] ?? "", old)
|
|
3581
3697
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -3636,7 +3752,7 @@ const pasteTransposed = async () => {
|
|
|
3636
3752
|
const c = c0 + i
|
|
3637
3753
|
if (r >= state.value.length || c >= cols.value.length) continue
|
|
3638
3754
|
const col = cols.value[c]!
|
|
3639
|
-
if (col.
|
|
3755
|
+
if (isCellLocked(r, col.name)) continue
|
|
3640
3756
|
const old = state.value[r]?.[col.name]
|
|
3641
3757
|
const next = coerceValue(col, lines[i]![j] ?? "", old)
|
|
3642
3758
|
if (next !== old) setCellValue(r, col.name, old, next)
|
|
@@ -4151,6 +4267,7 @@ defineExpose({
|
|
|
4151
4267
|
commitEdit,
|
|
4152
4268
|
cancelEdit,
|
|
4153
4269
|
addRow,
|
|
4270
|
+
applyPrefill,
|
|
4154
4271
|
insertRowAt,
|
|
4155
4272
|
removeSelectedRows,
|
|
4156
4273
|
addColumn,
|
|
@@ -4641,7 +4758,7 @@ defineExpose({
|
|
|
4641
4758
|
type="button"
|
|
4642
4759
|
class="q-spreadsheet__checkbox"
|
|
4643
4760
|
:class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
|
|
4644
|
-
:disabled="
|
|
4761
|
+
:disabled="isCellLocked(ri, col.name)"
|
|
4645
4762
|
aria-label="Toggle"
|
|
4646
4763
|
@click.stop="toggleBoolean(ri, col.name)"
|
|
4647
4764
|
>
|
|
@@ -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
|
}
|