@dnax/ui 0.0.75 → 0.0.76
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/QFilePicker.vue +22 -65
- package/components/QSpreadsheet.vue +593 -65
- package/index.ts +14 -0
- package/lib/cellSize.test.ts +52 -0
- package/lib/cellSize.ts +43 -0
- package/lib/fileList.test.ts +119 -0
- package/lib/fileList.ts +159 -0
- package/lib/pagination.test.ts +77 -0
- package/lib/pagination.ts +50 -0
- package/package.json +1 -1
- package/styles/main.css +179 -1
package/index.ts
CHANGED
|
@@ -209,6 +209,20 @@ export type { TextareaMetrics, TextareaSizing, TextareaComputedStyle } from "./l
|
|
|
209
209
|
export { useTextareaAutosize } from "./lib/useTextareaAutosize"
|
|
210
210
|
export type { TextareaAutosize, UseTextareaAutosizeOptions } from "./lib/useTextareaAutosize"
|
|
211
211
|
export { iconsRegistered, registerIconSet, registerIcons } from "./lib/registerIcons"
|
|
212
|
+
export { pageInfo } from "./lib/pagination"
|
|
213
|
+
export type { PageInfo, PageInfoOptions } from "./lib/pagination"
|
|
214
|
+
export { CELL_SIZE_MAX, CELL_SIZE_MIN, CELL_SIZE_PX, cellFontFor, cellSizePx, cellWidthFor } from "./lib/cellSize"
|
|
215
|
+
export type { CellSizePreset } from "./lib/cellSize"
|
|
216
|
+
export { acceptsFile, attachmentsOf, fileIconKind, formatFileSize, isAttachment, selectFiles } from "./lib/fileList"
|
|
217
|
+
export type {
|
|
218
|
+
AttachmentLike,
|
|
219
|
+
FileDescriptor,
|
|
220
|
+
FileIconKind,
|
|
221
|
+
FileRejection,
|
|
222
|
+
FileRejectionReason,
|
|
223
|
+
FileSelection,
|
|
224
|
+
FileSelectionOptions,
|
|
225
|
+
} from "./lib/fileList"
|
|
212
226
|
export { lucideIconSet } from "./lib/iconSet"
|
|
213
227
|
export { icons } from "./lib/icons"
|
|
214
228
|
export type { IconSetJson } from "./lib/icons"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Taille de cellule : presets, bornes, dérivés (largeur, police).
|
|
2
|
+
// Le point sensible est la **non-régression** : `md` doit reproduire exactement les valeurs
|
|
3
|
+
// historiques (32 px de haut, 160 px de large, 13 px de police).
|
|
4
|
+
import { describe, expect, it } from "bun:test"
|
|
5
|
+
import { CELL_SIZE_MAX, CELL_SIZE_MIN, CELL_SIZE_PX, cellFontFor, cellSizePx, cellWidthFor } from "./cellSize"
|
|
6
|
+
|
|
7
|
+
describe("cellSizePx", () => {
|
|
8
|
+
it("md est le défaut historique (32 px)", () => {
|
|
9
|
+
expect(cellSizePx()).toBe(32)
|
|
10
|
+
expect(cellSizePx(null)).toBe(32)
|
|
11
|
+
expect(cellSizePx("md")).toBe(32)
|
|
12
|
+
expect(CELL_SIZE_PX.md).toBe(32)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("résout les presets", () => {
|
|
16
|
+
expect(cellSizePx("sm")).toBe(28)
|
|
17
|
+
expect(cellSizePx("lg")).toBe(40)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("accepte une valeur en px, arrondie et bornée", () => {
|
|
21
|
+
expect(cellSizePx(48)).toBe(48)
|
|
22
|
+
expect(cellSizePx(66)).toBe(66)
|
|
23
|
+
expect(cellSizePx(47.6)).toBe(48)
|
|
24
|
+
expect(cellSizePx(2)).toBe(CELL_SIZE_MIN)
|
|
25
|
+
expect(cellSizePx(999)).toBe(CELL_SIZE_MAX)
|
|
26
|
+
expect(cellSizePx(Number.NaN)).toBe(32)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("cellWidthFor", () => {
|
|
31
|
+
it("garde le ratio historique 5:1", () => {
|
|
32
|
+
expect(cellWidthFor(32)).toBe(160)
|
|
33
|
+
expect(cellWidthFor(28)).toBe(140)
|
|
34
|
+
expect(cellWidthFor(40)).toBe(200)
|
|
35
|
+
expect(cellWidthFor(48)).toBe(240)
|
|
36
|
+
expect(cellWidthFor(66)).toBe(330)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe("cellFontFor", () => {
|
|
41
|
+
it("md garde la police historique (13 px)", () => {
|
|
42
|
+
expect(cellFontFor(32)).toBe(13)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("grandit avec la cellule puis plafonne", () => {
|
|
46
|
+
expect(cellFontFor(28)).toBe(11)
|
|
47
|
+
expect(cellFontFor(40)).toBe(16)
|
|
48
|
+
expect(cellFontFor(48)).toBe(19)
|
|
49
|
+
expect(cellFontFor(66)).toBe(20)
|
|
50
|
+
expect(cellFontFor(120)).toBe(20)
|
|
51
|
+
})
|
|
52
|
+
})
|
package/lib/cellSize.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Taille de cellule du tableur — calculs **purs** (aucun DOM).
|
|
2
|
+
//
|
|
3
|
+
// Une seule échelle pilote la hauteur de ligne, la largeur de colonne par défaut **et** la
|
|
4
|
+
// taille de police des cellules, pour qu'une grille « large » reste proportionnée (une
|
|
5
|
+
// cellule de 66px avec une police de 13px paraît vide).
|
|
6
|
+
|
|
7
|
+
/** Presets de hauteur de cellule, en px. */
|
|
8
|
+
export type CellSizePreset = "sm" | "md" | "lg"
|
|
9
|
+
|
|
10
|
+
export const CELL_SIZE_PX: Record<CellSizePreset, number> = {
|
|
11
|
+
sm: 28,
|
|
12
|
+
md: 32,
|
|
13
|
+
lg: 40,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Bornes d'une hauteur passée en nombre (px). */
|
|
17
|
+
export const CELL_SIZE_MIN = 20
|
|
18
|
+
export const CELL_SIZE_MAX = 120
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hauteur de cellule en px : preset (`sm` 28, `md` 32, `lg` 40), nombre borné
|
|
22
|
+
* `[20, 120]`, ou `md` par défaut / pour une valeur invalide.
|
|
23
|
+
*/
|
|
24
|
+
export const cellSizePx = (size?: CellSizePreset | number | null): number => {
|
|
25
|
+
if (typeof size === "number" && Number.isFinite(size)) {
|
|
26
|
+
return Math.min(CELL_SIZE_MAX, Math.max(CELL_SIZE_MIN, Math.round(size)))
|
|
27
|
+
}
|
|
28
|
+
if (size === "sm" || size === "md" || size === "lg") return CELL_SIZE_PX[size]
|
|
29
|
+
return CELL_SIZE_PX.md
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Largeur de colonne par défaut dérivée de la hauteur — ratio **5:1**, cohérent avec le
|
|
34
|
+
* couple historique 32 px / 160 px. Une cellule haute est aussi plus large.
|
|
35
|
+
*/
|
|
36
|
+
export const cellWidthFor = (heightPx: number): number => Math.round(heightPx * 5)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Taille de police des cellules : suit la hauteur (~40 %), bornée pour rester lisible.
|
|
40
|
+
* `32 px → 13` (la valeur historique), `40 → 16`, `48 → 20`, `66 → 20` (plafond).
|
|
41
|
+
*/
|
|
42
|
+
export const cellFontFor = (heightPx: number): number =>
|
|
43
|
+
Math.min(20, Math.max(11, Math.round(heightPx * 0.406)))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Fichiers joints : règles d'`accept`, quota, icônes — testées sans navigateur ni DOM.
|
|
2
|
+
// Ces helpers sont la seule source de vérité (QFilePicker et QSpreadsheet `attachment`).
|
|
3
|
+
import { describe, expect, it } from "bun:test"
|
|
4
|
+
import {
|
|
5
|
+
acceptsFile,
|
|
6
|
+
attachmentsOf,
|
|
7
|
+
fileIconKind,
|
|
8
|
+
formatFileSize,
|
|
9
|
+
selectFiles,
|
|
10
|
+
type FileDescriptor,
|
|
11
|
+
} from "./fileList"
|
|
12
|
+
|
|
13
|
+
const file = (name: string, type = "", size = 1000): File =>
|
|
14
|
+
new File([new Uint8Array(size)], name, { type })
|
|
15
|
+
|
|
16
|
+
const desc = (name: string, type = ""): FileDescriptor => ({ name, type, size: 0 })
|
|
17
|
+
|
|
18
|
+
describe("formatFileSize", () => {
|
|
19
|
+
it("choisit l'unité lisible", () => {
|
|
20
|
+
expect(formatFileSize(0)).toBe("0 o")
|
|
21
|
+
expect(formatFileSize(512)).toBe("512 o")
|
|
22
|
+
expect(formatFileSize(2048)).toBe("2 Ko")
|
|
23
|
+
expect(formatFileSize(5 * 1024 * 1024)).toBe("5 Mo")
|
|
24
|
+
expect(formatFileSize(3 * 1024 * 1024 * 1024)).toBe("3.0 Go")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("neutralise les valeurs invalides", () => {
|
|
28
|
+
expect(formatFileSize(Number.NaN)).toBe("0 o")
|
|
29
|
+
expect(formatFileSize(-12)).toBe("0 o")
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("acceptsFile", () => {
|
|
34
|
+
it("accepte tout sans motif", () => {
|
|
35
|
+
expect(acceptsFile(desc("a.bin"), "")).toBe(true)
|
|
36
|
+
expect(acceptsFile(desc("a.bin"), undefined)).toBe(true)
|
|
37
|
+
expect(acceptsFile(desc("a.bin"), " , ")).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("gère les jokers de type MIME", () => {
|
|
41
|
+
expect(acceptsFile(desc("photo.png", "image/png"), "image/*")).toBe(true)
|
|
42
|
+
expect(acceptsFile(desc("doc.pdf", "application/pdf"), "image/*")).toBe(false)
|
|
43
|
+
expect(acceptsFile(desc("clip.mp4", "video/mp4"), "image/*, video/*")).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("gère les extensions et les types complets", () => {
|
|
47
|
+
expect(acceptsFile(desc("rapport.PDF"), ".pdf")).toBe(true)
|
|
48
|
+
expect(acceptsFile(desc("rapport.txt"), ".pdf")).toBe(false)
|
|
49
|
+
expect(acceptsFile(desc("a.bin", "Application/PDF"), "application/pdf")).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("ignore un motif invalide (ni MIME, ni extension)", () => {
|
|
53
|
+
expect(acceptsFile(desc("a.png", "image/png"), "image")).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("fileIconKind", () => {
|
|
58
|
+
it("classe par type MIME puis par extension", () => {
|
|
59
|
+
expect(fileIconKind(desc("a.png", "image/png"))).toBe("image")
|
|
60
|
+
expect(fileIconKind(desc("a.mp4", "video/mp4"))).toBe("video")
|
|
61
|
+
expect(fileIconKind(desc("a.mp3", "audio/mpeg"))).toBe("audio")
|
|
62
|
+
expect(fileIconKind(desc("a.zip", "application/zip"))).toBe("archive")
|
|
63
|
+
expect(fileIconKind(desc("a.xlsx"))).toBe("spreadsheet")
|
|
64
|
+
expect(fileIconKind(desc("a.ts"))).toBe("code")
|
|
65
|
+
expect(fileIconKind(desc("a.bin"))).toBe("file")
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("selectFiles", () => {
|
|
70
|
+
it("rejette par type, taille puis quota (dans cet ordre)", () => {
|
|
71
|
+
const result = selectFiles(
|
|
72
|
+
[file("a.png", "image/png"), file("b.pdf", "application/pdf"), file("c.png", "image/png", 5000)],
|
|
73
|
+
{ accept: "image/*", maxSize: 2000, maxFiles: 1 },
|
|
74
|
+
)
|
|
75
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["a.png"])
|
|
76
|
+
expect(result.rejected.map((r) => r.reason)).toEqual(["type", "size"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("tient compte des fichiers déjà présents dans le quota", () => {
|
|
80
|
+
const result = selectFiles([file("a.png"), file("b.png")], { maxFiles: 3, current: 2 })
|
|
81
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["a.png"])
|
|
82
|
+
expect(result.rejected.map((r) => r.reason)).toEqual(["count"])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("sans quota, tout passe (ordre conservé)", () => {
|
|
86
|
+
const result = selectFiles([file("a.png"), file("b.png")])
|
|
87
|
+
expect(result.accepted.map((f) => f.name)).toEqual(["a.png", "b.png"])
|
|
88
|
+
expect(result.rejected).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("quota déjà atteint → tout est rejeté en `count`", () => {
|
|
92
|
+
const result = selectFiles([file("a.png")], { maxFiles: 2, current: 2 })
|
|
93
|
+
expect(result.accepted).toEqual([])
|
|
94
|
+
expect(result.rejected.map((r) => r.reason)).toEqual(["count"])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("attachmentsOf", () => {
|
|
99
|
+
it("normalise un tableau et ignore les entrées invalides", () => {
|
|
100
|
+
const list = attachmentsOf([
|
|
101
|
+
{ id: "a", name: "a.png", size: 1, type: "image/png" },
|
|
102
|
+
{ body: "pas un fichier" },
|
|
103
|
+
null,
|
|
104
|
+
{ name: "b.pdf" },
|
|
105
|
+
])
|
|
106
|
+
expect(list.map((att) => att.name)).toEqual(["a.png", "b.pdf"])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("accepte un fichier seul (donnée posée à la main)", () => {
|
|
110
|
+
expect(attachmentsOf({ name: "seul.pdf" }).map((att) => att.name)).toEqual(["seul.pdf"])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("renvoie un tableau vide pour le vide ou une chaîne", () => {
|
|
114
|
+
expect(attachmentsOf(null)).toEqual([])
|
|
115
|
+
expect(attachmentsOf("")).toEqual([])
|
|
116
|
+
expect(attachmentsOf([])).toEqual([])
|
|
117
|
+
expect(attachmentsOf("a.png")).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
})
|
package/lib/fileList.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Fichiers joints — helpers **purs** partagés par `QFilePicker` et `QSpreadsheet`
|
|
2
|
+
// (type de cellule `attachment`). Aucun DOM : les règles `accept` sont exactement celles
|
|
3
|
+
// de l'attribut HTML (`image/*`, `application/pdf`, `.pdf`…) pour que le filtre du
|
|
4
|
+
// sélecteur et la validation programmatique disent la même chose.
|
|
5
|
+
|
|
6
|
+
/** Raison d'un rejet de sélection. */
|
|
7
|
+
export type FileRejectionReason = "type" | "size" | "count"
|
|
8
|
+
|
|
9
|
+
export interface FileRejection {
|
|
10
|
+
file: File
|
|
11
|
+
reason: FileRejectionReason
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Ce dont les helpers ont besoin — un vrai `File` y satisfait. */
|
|
15
|
+
export interface FileDescriptor {
|
|
16
|
+
name: string
|
|
17
|
+
type: string
|
|
18
|
+
size: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ARCHIVE_EXT = /\.(zip|rar|7z|tar|gz|bz2|xz|tgz)$/
|
|
22
|
+
const SPREADSHEET_EXT = /\.(xlsx|xls|csv|ods|tsv)$/
|
|
23
|
+
const CODE_EXT =
|
|
24
|
+
/\.(json|js|mjs|cjs|ts|jsx|tsx|html?|xml|css|scss|sass|less|vue|py|java|c|cpp|h|hpp|sh|bash|yml|yaml|toml|ini|go|rs|php|rb|swift|kt)$/
|
|
25
|
+
|
|
26
|
+
/** Famille d'icône d'un fichier (le composant la mappe sur `icons.*`). */
|
|
27
|
+
export type FileIconKind =
|
|
28
|
+
| "image"
|
|
29
|
+
| "video"
|
|
30
|
+
| "audio"
|
|
31
|
+
| "archive"
|
|
32
|
+
| "spreadsheet"
|
|
33
|
+
| "code"
|
|
34
|
+
| "file"
|
|
35
|
+
|
|
36
|
+
export const fileIconKind = (file: Pick<FileDescriptor, "name" | "type">): FileIconKind => {
|
|
37
|
+
const type = (file.type ?? "").toLowerCase()
|
|
38
|
+
const name = (file.name ?? "").toLowerCase()
|
|
39
|
+
if (type.startsWith("video/")) return "video"
|
|
40
|
+
if (type.startsWith("audio/")) return "audio"
|
|
41
|
+
if (ARCHIVE_EXT.test(name) || /zip|compressed/.test(type)) return "archive"
|
|
42
|
+
if (SPREADSHEET_EXT.test(name) || type.includes("spreadsheet") || type.includes("excel")) {
|
|
43
|
+
return "spreadsheet"
|
|
44
|
+
}
|
|
45
|
+
if (CODE_EXT.test(name)) return "code"
|
|
46
|
+
if (type.startsWith("image/")) return "image"
|
|
47
|
+
return "file"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Taille lisible : `o` / `Ko` / `Mo` / `Go`. */
|
|
51
|
+
export const formatFileSize = (bytes: number): string => {
|
|
52
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "0 o"
|
|
53
|
+
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`
|
|
54
|
+
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} Mo`
|
|
55
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} Ko`
|
|
56
|
+
return `${bytes} o`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Le fichier correspond-il à `accept` ? Mêmes règles que l'attribut HTML : liste
|
|
61
|
+
* séparée par des virgules, `type/*`, type MIME complet ou extension (`.pdf`).
|
|
62
|
+
* `accept` vide/absent → tout passe.
|
|
63
|
+
*/
|
|
64
|
+
export const acceptsFile = (
|
|
65
|
+
file: Pick<FileDescriptor, "name" | "type">,
|
|
66
|
+
accept?: string,
|
|
67
|
+
): boolean => {
|
|
68
|
+
const patterns = (accept ?? "")
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((pattern) => pattern.trim().toLowerCase())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
if (patterns.length === 0) return true
|
|
73
|
+
|
|
74
|
+
const name = (file.name ?? "").toLowerCase()
|
|
75
|
+
const type = (file.type ?? "").toLowerCase()
|
|
76
|
+
|
|
77
|
+
return patterns.some((pattern) => {
|
|
78
|
+
// `image/*`, `video/*`… (et `application/*` par généralisation)
|
|
79
|
+
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1))
|
|
80
|
+
if (pattern.startsWith(".")) return name.endsWith(pattern)
|
|
81
|
+
if (pattern.includes("/")) return type === pattern
|
|
82
|
+
return false
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FileSelectionOptions {
|
|
87
|
+
/** Motif `accept` de la colonne (voir `acceptsFile`) */
|
|
88
|
+
accept?: string
|
|
89
|
+
/** Taille maximale par fichier, en octets (0/absent = illimité) */
|
|
90
|
+
maxSize?: number
|
|
91
|
+
/** Nombre maximum de fichiers au total (0/absent = illimité) */
|
|
92
|
+
maxFiles?: number
|
|
93
|
+
/** Fichiers déjà présents (réduit le quota restant) */
|
|
94
|
+
current?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FileSelection {
|
|
98
|
+
accepted: File[]
|
|
99
|
+
rejected: FileRejection[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Filtre une sélection : par type, par taille, puis par quota — dans cet ordre, pour que
|
|
104
|
+
* le motif de rejet soit le plus précis possible. L'ordre des fichiers acceptés est celui
|
|
105
|
+
* de `incoming`.
|
|
106
|
+
*/
|
|
107
|
+
export const selectFiles = (
|
|
108
|
+
incoming: File[],
|
|
109
|
+
options: FileSelectionOptions = {},
|
|
110
|
+
): FileSelection => {
|
|
111
|
+
const accepted: File[] = []
|
|
112
|
+
const rejected: FileRejection[] = []
|
|
113
|
+
|
|
114
|
+
const valid: File[] = []
|
|
115
|
+
for (const file of incoming) {
|
|
116
|
+
if (options.maxSize && options.maxSize > 0 && file.size > options.maxSize) {
|
|
117
|
+
rejected.push({ file, reason: "size" })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (!acceptsFile(file, options.accept)) {
|
|
121
|
+
rejected.push({ file, reason: "type" })
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
valid.push(file)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const quota = options.maxFiles && options.maxFiles > 0 ? options.maxFiles : Number.POSITIVE_INFINITY
|
|
128
|
+
const remaining = Math.max(0, quota - Math.max(0, options.current ?? 0))
|
|
129
|
+
for (const [index, file] of valid.entries()) {
|
|
130
|
+
if (index < remaining) accepted.push(file)
|
|
131
|
+
else rejected.push({ file, reason: "count" })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { accepted, rejected }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Fichiers stockés dans une cellule (`attachment`) ───
|
|
138
|
+
|
|
139
|
+
/** Forme minimale d'un fichier stocké (ce qu'une cellule `attachment` contient). */
|
|
140
|
+
export interface AttachmentLike {
|
|
141
|
+
id?: string
|
|
142
|
+
name: string
|
|
143
|
+
size?: number
|
|
144
|
+
type?: string
|
|
145
|
+
url?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** L'entrée ressemble-t-elle à un fichier stocké ? (tolérance des données importées) */
|
|
149
|
+
export const isAttachment = (value: unknown): value is AttachmentLike =>
|
|
150
|
+
!!value && typeof value === "object" && typeof (value as { name?: unknown }).name === "string"
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Valeur d'une cellule `attachment` normalisée en tableau : un fichier **seul** est accepté
|
|
154
|
+
* (donnée posée à la main ou importée), tout le reste est ignoré.
|
|
155
|
+
*/
|
|
156
|
+
export const attachmentsOf = (value: unknown): AttachmentLike[] => {
|
|
157
|
+
if (Array.isArray(value)) return value.filter(isAttachment)
|
|
158
|
+
return isAttachment(value) ? [value] : []
|
|
159
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Pagination distante : bornes, nombre de pages, plage globale des lignes.
|
|
2
|
+
// Les cas qui cassent en pratique : total qui rétrécit (page hors bornes), dernière page
|
|
3
|
+
// partielle, `pageSize` désactivé, total nul.
|
|
4
|
+
import { describe, expect, it } from "bun:test"
|
|
5
|
+
import { pageInfo } from "./pagination"
|
|
6
|
+
|
|
7
|
+
describe("pageInfo", () => {
|
|
8
|
+
it("calcule la première page", () => {
|
|
9
|
+
expect(pageInfo({ page: 1, pageSize: 400, total: 1200 })).toEqual({
|
|
10
|
+
page: 1,
|
|
11
|
+
pageCount: 3,
|
|
12
|
+
offset: 0,
|
|
13
|
+
from: 1,
|
|
14
|
+
to: 400,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("décale la plage sur les pages suivantes", () => {
|
|
19
|
+
expect(pageInfo({ page: 3, pageSize: 400, total: 1200 })).toEqual({
|
|
20
|
+
page: 3,
|
|
21
|
+
pageCount: 3,
|
|
22
|
+
offset: 800,
|
|
23
|
+
from: 801,
|
|
24
|
+
to: 1200,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("gère une dernière page partielle", () => {
|
|
29
|
+
expect(pageInfo({ page: 2, pageSize: 400, total: 500 })).toEqual({
|
|
30
|
+
page: 2,
|
|
31
|
+
pageCount: 2,
|
|
32
|
+
offset: 400,
|
|
33
|
+
from: 401,
|
|
34
|
+
to: 500,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("tient compte des lignes réellement chargées", () => {
|
|
39
|
+
// La page 2 n'a reçu que 80 lignes (chargement partiel) : la plage le reflète
|
|
40
|
+
expect(pageInfo({ page: 2, pageSize: 400, total: 1200, loaded: 80 })).toMatchObject({
|
|
41
|
+
from: 401,
|
|
42
|
+
to: 480,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("borne une page trop haute ou trop basse", () => {
|
|
47
|
+
expect(pageInfo({ page: 99, pageSize: 400, total: 1200 }).page).toBe(3)
|
|
48
|
+
expect(pageInfo({ page: 0, pageSize: 400, total: 1200 }).page).toBe(1)
|
|
49
|
+
expect(pageInfo({ page: -5, pageSize: 400, total: 1200 }).page).toBe(1)
|
|
50
|
+
expect(pageInfo({ page: Number.NaN, pageSize: 400, total: 1200 }).page).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("sans pagination : une seule page, tout est affiché", () => {
|
|
54
|
+
expect(pageInfo({ page: 1, pageSize: 0, total: 250 })).toEqual({
|
|
55
|
+
page: 1,
|
|
56
|
+
pageCount: 1,
|
|
57
|
+
offset: 0,
|
|
58
|
+
from: 1,
|
|
59
|
+
to: 250,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("total nul : from/to à 0", () => {
|
|
64
|
+
expect(pageInfo({ page: 1, pageSize: 400, total: 0 })).toEqual({
|
|
65
|
+
page: 1,
|
|
66
|
+
pageCount: 1,
|
|
67
|
+
offset: 0,
|
|
68
|
+
from: 0,
|
|
69
|
+
to: 0,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("ne dépasse jamais le total", () => {
|
|
74
|
+
// `loaded` plus grand que le quota (données incohérentes) → plage bornée au total
|
|
75
|
+
expect(pageInfo({ page: 1, pageSize: 400, total: 10, loaded: 400 }).to).toBe(10)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Pagination « distante » d'une grille — calculs **purs** (aucun DOM, aucun état).
|
|
2
|
+
//
|
|
3
|
+
// Le tableau ne découpe rien lui-même : `rows` contient **la page courante**, chargée par
|
|
4
|
+
// l'app. Ce module borne la page, calcule le nombre de pages et la plage globale des lignes
|
|
5
|
+
// affichées (numérotation des en-têtes de ligne, « lignes 401–800 sur 12 000 »).
|
|
6
|
+
|
|
7
|
+
export interface PageInfo {
|
|
8
|
+
/** Page courante, bornée à `[1, pageCount]` */
|
|
9
|
+
page: number
|
|
10
|
+
/** Nombre de pages (toujours ≥ 1) */
|
|
11
|
+
pageCount: number
|
|
12
|
+
/** Décalage global de la première ligne affichée (0-based) */
|
|
13
|
+
offset: number
|
|
14
|
+
/** Numéro global (1-based) de la première ligne affichée — `0` si le total est nul */
|
|
15
|
+
from: number
|
|
16
|
+
/** Numéro global (1-based) de la dernière ligne affichée — `0` si le total est nul */
|
|
17
|
+
to: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PageInfoOptions {
|
|
21
|
+
/** Page demandée (1-based) — bornée au résultat */
|
|
22
|
+
page: number
|
|
23
|
+
/** Lignes par page (0 = pas de pagination) */
|
|
24
|
+
pageSize: number
|
|
25
|
+
/** Total de lignes, toutes pages confondues */
|
|
26
|
+
total: number
|
|
27
|
+
/** Lignes réellement fournies pour cette page (défaut : `pageSize`, ou `total` sans pagination) */
|
|
28
|
+
loaded?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Calcule l'état de pagination. Une page hors bornes est **ramenée** dans `[1, pageCount]`
|
|
33
|
+
* (un total qui rétrécit après un filtre ne laisse pas la vue sur une page vide), et une
|
|
34
|
+
* dernière page peut être partielle (`loaded` < `pageSize`).
|
|
35
|
+
*/
|
|
36
|
+
export const pageInfo = (options: PageInfoOptions): PageInfo => {
|
|
37
|
+
const size = Math.max(0, Math.trunc(options.pageSize || 0))
|
|
38
|
+
const total = Math.max(0, Math.trunc(options.total || 0))
|
|
39
|
+
const pageCount = size > 0 ? Math.max(1, Math.ceil(total / size)) : 1
|
|
40
|
+
|
|
41
|
+
const raw = Number.isFinite(options.page) ? Math.trunc(options.page) : 1
|
|
42
|
+
const page = Math.min(Math.max(1, raw), pageCount)
|
|
43
|
+
const offset = size > 0 ? (page - 1) * size : 0
|
|
44
|
+
|
|
45
|
+
const loaded = Math.max(0, Math.trunc(options.loaded ?? (size > 0 ? size : total)))
|
|
46
|
+
const from = total === 0 ? 0 : Math.min(offset + 1, total)
|
|
47
|
+
const to = total === 0 ? 0 : Math.min(offset + loaded, total)
|
|
48
|
+
|
|
49
|
+
return { page, pageCount, offset, from, to }
|
|
50
|
+
}
|