@dnax/ui 0.0.70 → 0.0.71
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 +42 -1
- package/lib/spreadsheet.test.ts +39 -0
- package/lib/spreadsheet.ts +68 -0
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// édition en place (Entrée / F2 / double-clic / frappe directe),
|
|
5
5
|
// lignes & colonnes (ajouter / insérer / supprimer),
|
|
6
6
|
// tri, copier/coller, undo/redo, et cellules typées
|
|
7
|
-
//
|
|
7
|
+
// texte, nombre, entier, booléen, date, datetime, **time (heure)**, select / multiselect avec
|
|
8
|
+
// badges colorés).
|
|
8
9
|
// Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
|
|
9
10
|
// ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
|
|
10
11
|
// (`lib/spreadsheetChanges.ts`).
|
|
@@ -44,6 +45,7 @@ export type QSpreadsheetCellType =
|
|
|
44
45
|
| "boolean"
|
|
45
46
|
| "date"
|
|
46
47
|
| "datetime"
|
|
48
|
+
| "time"
|
|
47
49
|
| "select"
|
|
48
50
|
| "multiselect"
|
|
49
51
|
|
|
@@ -185,6 +187,9 @@ import {
|
|
|
185
187
|
isBlankValue,
|
|
186
188
|
ISO_DATE,
|
|
187
189
|
isoAddDays,
|
|
190
|
+
normalizeTime,
|
|
191
|
+
secondsToTime,
|
|
192
|
+
timeToSeconds,
|
|
188
193
|
shiftFormulaRefs,
|
|
189
194
|
functionQueryAt,
|
|
190
195
|
rankSuggestions,
|
|
@@ -877,6 +882,8 @@ const cellText = (row: number, column: string): string => {
|
|
|
877
882
|
let out: any = ev === null || ev === undefined ? "" : ev
|
|
878
883
|
// Apostrophe littérale (paste values) : affichée sans le préfixe
|
|
879
884
|
if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
|
|
885
|
+
// Heure : toujours `HH:MM` à l'affichage (données importées ou anciennes comprises)
|
|
886
|
+
if (col?.type === "time" && typeof out === "string") out = normalizeTime(out) ?? out
|
|
880
887
|
if (col?.format && out !== "") out = col.format(out, state.value[row] ?? {})
|
|
881
888
|
if (out === null || out === undefined || out === "") return ""
|
|
882
889
|
return String(out)
|
|
@@ -988,6 +995,8 @@ const editorInputType = computed(() => {
|
|
|
988
995
|
return "date"
|
|
989
996
|
case "datetime":
|
|
990
997
|
return "datetime-local"
|
|
998
|
+
case "time":
|
|
999
|
+
return "time"
|
|
991
1000
|
default:
|
|
992
1001
|
// PAS de `type="number"` pour number/integer : (1) le navigateur refuse
|
|
993
1002
|
// `=` (formule) ou un "-" isolé dans un input number, (2) `v-model` sur
|
|
@@ -1022,6 +1031,8 @@ const startEdit = (row: number, column: string, initial?: string) => {
|
|
|
1022
1031
|
} else {
|
|
1023
1032
|
let rawStr = String(raw ?? "")
|
|
1024
1033
|
if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
|
|
1034
|
+
// Heure : l'input natif n'accepte que `HH:MM` — on normalise dès l'ouverture
|
|
1035
|
+
if (col?.type === "time") rawStr = normalizeTime(rawStr) ?? rawStr
|
|
1025
1036
|
draft.value = String(initial ?? rawStr)
|
|
1026
1037
|
}
|
|
1027
1038
|
editing.value = { row, column }
|
|
@@ -1099,6 +1110,11 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
|
|
|
1099
1110
|
if (col?.type === "boolean") {
|
|
1100
1111
|
return raw === "true" ? true : raw === "false" ? false : old
|
|
1101
1112
|
}
|
|
1113
|
+
if (col?.type === "time") {
|
|
1114
|
+
// Heure valide → `HH:MM` (`HH:MM:SS` si les secondes sont non nulles) ; invalide → ancienne
|
|
1115
|
+
const time = normalizeTime(raw)
|
|
1116
|
+
return time === undefined ? old : time
|
|
1117
|
+
}
|
|
1102
1118
|
if (col?.type === "select") {
|
|
1103
1119
|
const opt = cellOptionsOf(col).find(
|
|
1104
1120
|
(o) => String(o.label ?? "") === raw || String(o.value) === raw,
|
|
@@ -3065,6 +3081,19 @@ const applyFill = () => {
|
|
|
3065
3081
|
const d1 = +new Date(s1 + "T00:00:00Z")
|
|
3066
3082
|
const step = Math.round((d1 - d0) / 86400000)
|
|
3067
3083
|
if (step !== 0) val = isoAddDays(s1, step * (rr - b.r1))
|
|
3084
|
+
} else if (
|
|
3085
|
+
b.r1 > b.r0 &&
|
|
3086
|
+
col?.type === "time" &&
|
|
3087
|
+
typeof s0 === "string" &&
|
|
3088
|
+
typeof s1 === "string"
|
|
3089
|
+
) {
|
|
3090
|
+
// Série d'heures : 09:00 puis 10:00 → 11:00, 12:00… (modulo 24 h)
|
|
3091
|
+
const t0 = timeToSeconds(s0)
|
|
3092
|
+
const t1 = timeToSeconds(s1)
|
|
3093
|
+
if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
|
|
3094
|
+
const step = (t1 - t0) / (b.r1 - b.r0)
|
|
3095
|
+
val = secondsToTime(t1 + step * (rr - b.r1))
|
|
3096
|
+
}
|
|
3068
3097
|
}
|
|
3069
3098
|
} else if (
|
|
3070
3099
|
!fillCopy.value &&
|
|
@@ -3081,6 +3110,18 @@ const applyFill = () => {
|
|
|
3081
3110
|
if (b.c1 > b.c0 && typeof s0 === "number" && typeof s1 === "number" && s1 !== s0) {
|
|
3082
3111
|
const step = (s1 - s0) / (b.c1 - b.c0)
|
|
3083
3112
|
val = s1 + step * (cc - b.c1)
|
|
3113
|
+
} else if (
|
|
3114
|
+
b.c1 > b.c0 &&
|
|
3115
|
+
col?.type === "time" &&
|
|
3116
|
+
typeof s0 === "string" &&
|
|
3117
|
+
typeof s1 === "string"
|
|
3118
|
+
) {
|
|
3119
|
+
const t0 = timeToSeconds(s0)
|
|
3120
|
+
const t1 = timeToSeconds(s1)
|
|
3121
|
+
if (t0 !== undefined && t1 !== undefined && t1 !== t0) {
|
|
3122
|
+
const step = (t1 - t0) / (b.c1 - b.c0)
|
|
3123
|
+
val = secondsToTime(t1 + step * (cc - b.c1))
|
|
3124
|
+
}
|
|
3084
3125
|
}
|
|
3085
3126
|
}
|
|
3086
3127
|
// Recopie du motif (source cyclique) — vers le bas, la droite, le haut,
|
package/lib/spreadsheet.test.ts
CHANGED
|
@@ -14,8 +14,47 @@ import {
|
|
|
14
14
|
csvSplitLine,
|
|
15
15
|
parseCsv,
|
|
16
16
|
deriveCols,
|
|
17
|
+
normalizeTime,
|
|
18
|
+
parseTimeOfDay,
|
|
19
|
+
secondsToTime,
|
|
20
|
+
timeToSeconds,
|
|
17
21
|
} from "./spreadsheet"
|
|
18
22
|
|
|
23
|
+
describe("heures (type `time`)", () => {
|
|
24
|
+
it("décompose les formes usuelles", () => {
|
|
25
|
+
expect(parseTimeOfDay("09:30")).toEqual({ hours: 9, minutes: 30, seconds: 0 })
|
|
26
|
+
expect(parseTimeOfDay("9h5")).toEqual({ hours: 9, minutes: 5, seconds: 0 })
|
|
27
|
+
expect(parseTimeOfDay("9")).toEqual({ hours: 9, minutes: 0, seconds: 0 })
|
|
28
|
+
expect(parseTimeOfDay("23:59:58")).toEqual({ hours: 23, minutes: 59, seconds: 58 })
|
|
29
|
+
// vide → cellule effacée ; null/undefined → pas une heure
|
|
30
|
+
expect(parseTimeOfDay("")).toBeNull()
|
|
31
|
+
expect(parseTimeOfDay(null)).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("refuse les heures invalides", () => {
|
|
35
|
+
for (const value of ["24:00", "9:75", "9:30:75", "abc", "1:2:3:4", "-1:00"]) {
|
|
36
|
+
expect(parseTimeOfDay(value), value).toBeUndefined()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("normalise en `HH:MM` (secondes conservées si non nulles)", () => {
|
|
41
|
+
expect(normalizeTime("9:5")).toBe("09:05")
|
|
42
|
+
expect(normalizeTime("9h30")).toBe("09:30")
|
|
43
|
+
expect(normalizeTime("09:30:00")).toBe("09:30")
|
|
44
|
+
expect(normalizeTime("09:30:07")).toBe("09:30:07")
|
|
45
|
+
expect(normalizeTime("")).toBeNull()
|
|
46
|
+
expect(normalizeTime("nope")).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("séries d'heures : secondes, modulo 24 h, valeurs négatives", () => {
|
|
50
|
+
expect(timeToSeconds("01:00")).toBe(3600)
|
|
51
|
+
expect(secondsToTime(3600)).toBe("01:00")
|
|
52
|
+
expect(secondsToTime(25 * 3600)).toBe("01:00")
|
|
53
|
+
expect(secondsToTime(-3600)).toBe("23:00")
|
|
54
|
+
expect(secondsToTime(3661)).toBe("01:01:01")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
19
58
|
describe("lettres de colonnes", () => {
|
|
20
59
|
it("colLetter : 0→A, 25→Z, 26→AA, 27→AB, 701→ZZ, 702→AAA", () => {
|
|
21
60
|
expect(colLetter(0)).toBe("A")
|
package/lib/spreadsheet.ts
CHANGED
|
@@ -247,6 +247,74 @@ export const isoAddDays = (iso: string, days: number): string => {
|
|
|
247
247
|
return d.toISOString().slice(0, 10)
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/** Heure du jour : `HH:MM` ou `HH:MM:SS` (toujours sur deux chiffres) */
|
|
251
|
+
export const TIME_OF_DAY = /^\d{2}:\d{2}(?::\d{2})?$/
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Décompose une **heure** saisie librement. Accepte `9`, `09:30`, `9:5`, `9h30`, `09:30:15`.
|
|
255
|
+
*
|
|
256
|
+
* - `null` → chaîne vide (cellule effacée) ;
|
|
257
|
+
* - `undefined` → ce n'est pas une heure valide (`24:00`, `9:75`, `abc`…).
|
|
258
|
+
*
|
|
259
|
+
* Le parsing est volontairement **local** (pas de `Date`) : aucune conversion de fuseau ne
|
|
260
|
+
* doit intervenir sur une heure de la journée.
|
|
261
|
+
*/
|
|
262
|
+
export function parseTimeOfDay(
|
|
263
|
+
value: any,
|
|
264
|
+
): { hours: number; minutes: number; seconds: number } | null | undefined {
|
|
265
|
+
if (value === null || value === undefined) return undefined
|
|
266
|
+
const raw = String(value).trim()
|
|
267
|
+
if (raw === "") return null
|
|
268
|
+
const match = raw.match(/^(\d{1,2})(?:[:h](\d{1,2}))?(?::(\d{1,2}))?$/i)
|
|
269
|
+
if (!match) return undefined
|
|
270
|
+
const hours = Number(match[1])
|
|
271
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2])
|
|
272
|
+
const seconds = match[3] === undefined ? 0 : Number(match[3])
|
|
273
|
+
if (
|
|
274
|
+
!Number.isInteger(hours) ||
|
|
275
|
+
!Number.isInteger(minutes) ||
|
|
276
|
+
!Number.isInteger(seconds) ||
|
|
277
|
+
hours > 23 ||
|
|
278
|
+
minutes > 59 ||
|
|
279
|
+
seconds > 59
|
|
280
|
+
) {
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
return { hours, minutes, seconds }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const two = (n: number) => String(n).padStart(2, "0")
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Normalise une heure en `HH:MM` — les secondes ne sont conservées que si elles sont non
|
|
290
|
+
* nulles (`HH:MM:SS`). `null` = vide, `undefined` = invalide (l'appelant garde l'ancienne
|
|
291
|
+
* valeur, comme pour un nombre refusé).
|
|
292
|
+
*/
|
|
293
|
+
export function normalizeTime(value: any): string | null | undefined {
|
|
294
|
+
const parsed = parseTimeOfDay(value)
|
|
295
|
+
if (parsed === null || parsed === undefined) return parsed
|
|
296
|
+
const base = `${two(parsed.hours)}:${two(parsed.minutes)}`
|
|
297
|
+
return parsed.seconds ? `${base}:${two(parsed.seconds)}` : base
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Secondes depuis minuit (séries d'auto-remplissage) — `undefined` si invalide */
|
|
301
|
+
export function timeToSeconds(value: any): number | undefined {
|
|
302
|
+
const parsed = parseTimeOfDay(value)
|
|
303
|
+
if (!parsed) return undefined
|
|
304
|
+
return parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Secondes depuis minuit → `HH:MM` (`HH:MM:SS` si nécessaire), **modulo 24 h** */
|
|
308
|
+
export function secondsToTime(total: number): string {
|
|
309
|
+
if (!Number.isFinite(total)) return "00:00"
|
|
310
|
+
const day = 86400
|
|
311
|
+
const normalized = ((Math.round(total) % day) + day) % day
|
|
312
|
+
const hours = Math.floor(normalized / 3600)
|
|
313
|
+
const minutes = Math.floor((normalized % 3600) / 60)
|
|
314
|
+
const seconds = normalized % 60
|
|
315
|
+
return seconds ? `${two(hours)}:${two(minutes)}:${two(seconds)}` : `${two(hours)}:${two(minutes)}`
|
|
316
|
+
}
|
|
317
|
+
|
|
250
318
|
/** Échappe une valeur pour un CSV (quote si délimiteur / guillemet / newline) */
|
|
251
319
|
export const csvEscape = (s: unknown, delim: string): string => {
|
|
252
320
|
const str = s === null || s === undefined ? "" : String(s)
|