@sia-ui/cli 0.5.0 → 0.7.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @sia-ui/cli
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Aligne les packages publics de SIA UI sur la version 0.7.0 afin que les huit packages restent sur une version commune.
8
+
9
+ ## 0.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
14
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
15
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
16
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
17
+ l'écraser). Les dépendances de registre apparues sont installées, les
18
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
19
+ un fichier a été gardé reste « en retard » dans le verrou.
20
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
21
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
22
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
23
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
24
+ retard, modifiée ou copiée avant le suivi.
25
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
26
+ `add --overwrite`, qui écrasait aussi les retouches.
27
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
28
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
29
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
30
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
31
+ ni une erreur de champs.
32
+
3
33
  ## 0.5.0
4
34
 
5
35
  ### Patch Changes
package/README.md CHANGED
@@ -68,6 +68,36 @@ Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible
68
68
  <!-- sia:changelog:start -->
69
69
  # @sia-ui/cli
70
70
 
71
+ ## 0.7.0
72
+
73
+ ### Minor Changes
74
+
75
+ - Aligne les packages publics de SIA UI sur la version 0.7.0 afin que les huit packages restent sur une version commune.
76
+
77
+ ## 0.6.0
78
+
79
+ ### Minor Changes
80
+
81
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
82
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
83
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
84
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
85
+ l'écraser). Les dépendances de registre apparues sont installées, les
86
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
87
+ un fichier a été gardé reste « en retard » dans le verrou.
88
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
89
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
90
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
91
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
92
+ retard, modifiée ou copiée avant le suivi.
93
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
94
+ `add --overwrite`, qui écrasait aussi les retouches.
95
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
96
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
97
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
98
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
99
+ ni une erreur de champs.
100
+
71
101
  ## 0.5.0
72
102
 
73
103
  ### Patch Changes
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import {
@@ -10,8 +10,9 @@ import {
10
10
  lireLock,
11
11
  noter,
12
12
  } from "./lock.mjs";
13
- import { diff, doctor, outdated, remove } from "./lifecycle.mjs";
13
+ import { diff, doctor, outdated, remove, update } from "./lifecycle.mjs";
14
14
  import { choisirSource } from "./source.mjs";
15
+ import { synchroniserStyles } from "./styles.mjs";
15
16
 
16
17
  const here = dirname(fileURLToPath(import.meta.url));
17
18
  const registryRoot = resolve(here, "registry");
@@ -163,13 +164,15 @@ async function init(cwd) {
163
164
  }
164
165
 
165
166
  const config = await readConfig(cwd);
166
- const styleTarget = join(cwd, config.styles);
167
- await mkdir(dirname(styleTarget), { recursive: true });
168
-
169
- if (!(await exists(styleTarget))) {
170
- await cp(join(registryRoot, "styles/sia-ui.css"), styleTarget);
171
- console.log(`add ${config.styles}`);
172
- }
167
+ const source = await ouvrirSource(config);
168
+ // `init` initialise, et c'est tout : une feuille déjà là n'est pas
169
+ // touchée. La mettre à jour est le travail de `update`.
170
+ const lock = await lireLock(cwd);
171
+ await synchroniserStyles(
172
+ { cwd, config, lock, lireStyles: source.lireStyles },
173
+ { mode: "creer" },
174
+ );
175
+ await ecrireLock(cwd, lock);
173
176
 
174
177
  console.log("SIA UI initialisé.");
175
178
  }
@@ -228,6 +231,14 @@ async function add(cwd, names) {
228
231
  if (copieComplete) noter(lock, item, empreintes);
229
232
  }
230
233
 
234
+ // La feuille commune suit les composants : un composant à jour peut
235
+ // dépendre d'une règle qu'elle vient de gagner. Jamais écrasée ici si elle
236
+ // a été retouchée — `--overwrite` ne vise que les composants nommés.
237
+ await synchroniserStyles(
238
+ { cwd, config, lock, lireStyles: source.lireStyles },
239
+ { mode: "sur" },
240
+ );
241
+
231
242
  await ecrireLock(cwd, lock);
232
243
  console.log(`\nlock ${LOCK_FILE}`);
233
244
 
@@ -268,13 +279,16 @@ function help() {
268
279
  " list lister les composants disponibles",
269
280
  " list --outdated lister ce qui a une version plus récente",
270
281
  " add <nom...> installer des composants et leurs dépendances",
282
+ " update [nom...] mettre à jour ce qui est en retard, feuille comprise",
271
283
  " diff [nom...] comparer les fichiers installés au registre",
272
284
  " remove <nom...> retirer des composants installés",
273
285
  " doctor vérifier l'état de ce qui est installé",
274
286
  "",
275
287
  "Options :",
276
- " --overwrite écraser les fichiers déjà présents",
277
- " --force passer outre les avertissements de remove",
288
+ " --overwrite add : écraser les fichiers déjà présents",
289
+ " --force update : écraser aussi les fichiers retouchés",
290
+ " remove : passer outre les avertissements",
291
+ " --dry-run update : montrer sans rien écrire",
278
292
  " --registry <url> lire un registre servi en HTTP",
279
293
  "",
280
294
  "Les chemins d'installation viennent de sia-ui.json,",
@@ -304,6 +318,8 @@ async function contexteProjet(cwd) {
304
318
  // faire dans la même écriture, sinon aucun fichier ne se retrouve sous
305
319
  // Windows.
306
320
  cibleDe: (fichier, conf) => cheminUnix(targetFor(fichier, conf)),
321
+ lireStyles: source.lireStyles,
322
+ resoudre: (noms) => resolveItems(source.registre, noms),
307
323
  };
308
324
  }
309
325
 
@@ -320,6 +336,9 @@ try {
320
336
  else if (command === "remove") {
321
337
  await remove(await contexteProjet(cwd), args, flags);
322
338
  } else if (command === "doctor") await doctor(await contexteProjet(cwd));
339
+ else if (command === "update") {
340
+ await update(await contexteProjet(cwd), args, flags);
341
+ }
323
342
  else help();
324
343
  } catch (error) {
325
344
  console.error(error instanceof Error ? error.message : error);
@@ -1,7 +1,8 @@
1
- import { readFile, rm } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
3
  import { lignesDiff } from "./diff.mjs";
4
4
  import { LOCK_FILE, ecrireLock, empreinte, lireLock, oublier } from "./lock.mjs";
5
+ import { etatStyles, synchroniserStyles } from "./styles.mjs";
5
6
 
6
7
  /**
7
8
  * Ce qu'on peut faire d'un composant **après** l'avoir copié.
@@ -180,6 +181,18 @@ export async function doctor(contexte) {
180
181
 
181
182
  const problemes = [];
182
183
 
184
+ const { etat: feuille } = await etatStyles({
185
+ ...contexte,
186
+ lock: await lireLock(contexte.cwd),
187
+ });
188
+ const conseil = {
189
+ absente: "absente — sia-ui init la recopie",
190
+ "en retard": "en retard sur le registre — sia-ui update la met à jour",
191
+ "non suivie": "différente de celle du registre — sia-ui update --force la remplace",
192
+ modifiée: "modifiée localement — sia-ui update --force écraserait la modification",
193
+ }[feuille];
194
+ if (conseil) problemes.push(`${contexte.config.styles} — ${conseil}`);
195
+
183
196
  for (const etat of liste) {
184
197
  if (etat.inconnu) {
185
198
  problemes.push(`${etat.nom} — absent du registre de cette CLI`);
@@ -204,7 +217,7 @@ export async function doctor(contexte) {
204
217
  problemes.push(`${etat.nom} — ${fichier.relatif} a disparu`);
205
218
  } else if (fichier.etat === "modifié") {
206
219
  // Ce n'est pas une erreur : posséder le code, c'est le droit de le
207
- // modifier. Mais `add --overwrite` écraserait la modification.
220
+ // modifier. Mais `update --force` écraserait la modification.
208
221
  problemes.push(
209
222
  `${etat.nom} — ${fichier.relatif} modifié depuis l'installation`,
210
223
  );
@@ -238,5 +251,177 @@ export async function outdated(contexte) {
238
251
  `${etat.nom.padEnd(largeur)} ${etat.verrou.version} → ${etat.item.version}`,
239
252
  );
240
253
  }
241
- console.log(`\nMettre à jour :\n sia-ui add ${enRetard.map((e) => e.nom).join(" ")} --overwrite`);
254
+ console.log("\nMettre à jour, sans écraser ce qui a été retouché :\n sia-ui update");
255
+ }
256
+
257
+ /**
258
+ * Met à jour ce qui a une version plus récente, sans perdre de retouche.
259
+ *
260
+ * Pour chaque fichier, le verrou dit s'il a été modifié depuis son
261
+ * installation. Intact, il est remplacé ; retouché, il est gardé et signalé —
262
+ * `sia-ui diff` montre l'écart, `--force` l'écrase. `add --overwrite`, seul
263
+ * recours jusqu'ici, écrasait tout sans distinction.
264
+ *
265
+ * Une entrée n'est notée à sa nouvelle version que si tous ses fichiers l'ont
266
+ * rejointe : un fichier gardé la laisse « en retard », et la question se
267
+ * reposera au prochain `update` plutôt que de disparaître.
268
+ */
269
+ export async function update(contexte, noms, flags) {
270
+ const { cwd, config, cibleDe, lireSource, resoudre } = contexte;
271
+ const dryRun = flags.has("--dry-run");
272
+ const force = flags.has("--force");
273
+ const { lock, etats: liste } = await etats(contexte, noms);
274
+
275
+ const npm = new Set();
276
+ const gardes = new Map();
277
+ const avertissements = [];
278
+ let misesAJour = 0;
279
+
280
+ if (dryRun) console.log("Simulation : rien ne sera écrit.\n");
281
+
282
+ /** Écrit un fichier et rend son empreinte, ou rien en simulation. */
283
+ const ecrire = async (relatif, contenu) => {
284
+ if (!dryRun) {
285
+ await mkdir(dirname(join(cwd, relatif)), { recursive: true });
286
+ await writeFile(join(cwd, relatif), contenu);
287
+ }
288
+ return empreinte(contenu);
289
+ };
290
+
291
+ const enRetard = [];
292
+ for (const etat of liste) {
293
+ if (etat.inconnu) {
294
+ avertissements.push(`${etat.nom} — absent de ce registre, laissé tel quel`);
295
+ } else if (etat.retire) {
296
+ avertissements.push(
297
+ `${etat.nom} — retiré du registre${etat.item.replacedBy ? ` ; le remplacer par ${etat.item.replacedBy}` : ""}`,
298
+ );
299
+ } else if (etat.desynchronise) {
300
+ if (etat.deprecie) {
301
+ avertissements.push(
302
+ `${etat.nom} — déprécié${etat.item.replacedBy ? `, préférer ${etat.item.replacedBy}` : ""}`,
303
+ );
304
+ }
305
+ enRetard.push(etat);
306
+ }
307
+ }
308
+
309
+ for (const etat of enRetard) {
310
+ const { item, verrou } = etat;
311
+ console.log(`${etat.nom} ${verrou.version} → ${item.version}`);
312
+ for (const dependance of item.dependencies ?? []) npm.add(dependance);
313
+
314
+ const empreintes = {};
315
+ let complet = true;
316
+
317
+ for (const fichier of item.files) {
318
+ const relatif = cibleDe(fichier, config);
319
+ const installe = etat.fichiers.find((f) => f.relatif === relatif);
320
+ const contenu = await lireSource(fichier);
321
+
322
+ // Retouché depuis l'installation : le travail de quelqu'un.
323
+ if (installe?.etat === "modifié" && !force) {
324
+ console.log(` keep ${relatif} (modifié localement)`);
325
+ empreintes[relatif] = verrou.files[relatif];
326
+ complet = false;
327
+ const liste = gardes.get(etat.nom) ?? [];
328
+ liste.push(relatif);
329
+ gardes.set(etat.nom, liste);
330
+ continue;
331
+ }
332
+
333
+ // Un fichier nouveau dans cette version, mais déjà présent sur le disque
334
+ // sans que le verrou le connaisse : il n'est pas à nous.
335
+ if (!installe && !force && (await existe(join(cwd, relatif)))) {
336
+ console.log(` keep ${relatif} (présent mais non suivi)`);
337
+ complet = false;
338
+ continue;
339
+ }
340
+
341
+ empreintes[relatif] = await ecrire(relatif, contenu);
342
+ console.log(` ${installe ? "update" : "add "} ${relatif}`);
343
+ }
344
+
345
+ // Un fichier que la nouvelle version ne fournit plus reste chez
346
+ // l'utilisateur : le supprimer d'office pourrait casser un import local.
347
+ for (const ancien of Object.keys(verrou.files ?? {})) {
348
+ if (!(ancien in empreintes) && !item.files.some((f) => cibleDe(f, config) === ancien)) {
349
+ console.log(` note ${ancien} n'est plus fourni — à retirer à la main s'il ne sert plus`);
350
+ }
351
+ }
352
+
353
+ if (!dryRun) {
354
+ lock.items[etat.nom] = {
355
+ // Incomplète, l'entrée garde sa version : elle reste « en retard ».
356
+ version: complet ? item.version : verrou.version,
357
+ installedAt: complet ? new Date().toISOString() : verrou.installedAt,
358
+ files: Object.fromEntries(
359
+ Object.keys(empreintes).sort().map((f) => [f, empreintes[f]]),
360
+ ),
361
+ };
362
+ }
363
+ if (complet) misesAJour += 1;
364
+ }
365
+
366
+ // Les dépendances de registre apparues avec les nouvelles versions.
367
+ if (enRetard.length > 0) {
368
+ for (const item of resoudre(enRetard.map((e) => e.nom))) {
369
+ if (lock.items[item.name]) continue;
370
+ console.log(`${item.name} nouvelle dépendance`);
371
+ for (const dependance of item.dependencies ?? []) npm.add(dependance);
372
+
373
+ const empreintes = {};
374
+ for (const fichier of item.files) {
375
+ const relatif = cibleDe(fichier, config);
376
+ if (!force && (await existe(join(cwd, relatif)))) {
377
+ console.log(` keep ${relatif} (présent mais non suivi)`);
378
+ continue;
379
+ }
380
+ empreintes[relatif] = await ecrire(relatif, await lireSource(fichier));
381
+ console.log(` add ${relatif}`);
382
+ }
383
+ if (!dryRun) {
384
+ lock.items[item.name] = {
385
+ version: item.version,
386
+ installedAt: new Date().toISOString(),
387
+ files: empreintes,
388
+ };
389
+ }
390
+ }
391
+ }
392
+
393
+ // La feuille commune, qu'une entrée à jour peut supposer à jour aussi.
394
+ await synchroniserStyles(
395
+ { ...contexte, lock },
396
+ { mode: force ? "forcer" : "sur", dryRun },
397
+ );
398
+
399
+ if (!dryRun) await ecrireLock(cwd, lock);
400
+
401
+ console.log("");
402
+ if (enRetard.length === 0) console.log("Composants : tout est à jour.");
403
+ else {
404
+ console.log(
405
+ `${misesAJour} entrée(s) mise(s) à jour sur ${enRetard.length}${dryRun ? " (simulation)" : ""}.`,
406
+ );
407
+ }
408
+
409
+ if (gardes.size > 0) {
410
+ console.log(
411
+ `\nFichiers retouchés gardés — comparer, puis reprendre ou forcer :\n sia-ui diff ${[...gardes.keys()].join(" ")}\n sia-ui update ${[...gardes.keys()].join(" ")} --force`,
412
+ );
413
+ }
414
+ for (const avertissement of avertissements) console.warn(`\n⚠ ${avertissement}`);
415
+ if (npm.size > 0) {
416
+ console.log(`\nÀ installer :\n pnpm add ${[...npm].sort().join(" ")}`);
417
+ }
418
+ }
419
+
420
+ async function existe(chemin) {
421
+ try {
422
+ await access(chemin);
423
+ return true;
424
+ } catch {
425
+ return false;
426
+ }
242
427
  }
package/dist/lock.mjs CHANGED
@@ -44,7 +44,13 @@ export async function lireLock(cwd) {
44
44
  );
45
45
  }
46
46
 
47
- return { format: FORMAT, items: lock.items ?? {} };
47
+ return {
48
+ format: FORMAT,
49
+ items: lock.items ?? {},
50
+ // La feuille commune, suivie depuis qu'`add` la met à jour. Absente
51
+ // d'un verrou plus ancien : un champ en plus, pas un nouveau format.
52
+ ...(lock.styles ? { styles: lock.styles } : {}),
53
+ };
48
54
  } catch (error) {
49
55
  if (error.code === "ENOENT") return structuredClone(VIDE);
50
56
  if (error instanceof SyntaxError) {
@@ -67,7 +73,15 @@ export async function ecrireLock(cwd, lock) {
67
73
 
68
74
  await writeFile(
69
75
  join(cwd, LOCK_FILE),
70
- `${JSON.stringify({ format: FORMAT, items }, null, 2)}\n`,
76
+ `${JSON.stringify(
77
+ {
78
+ format: FORMAT,
79
+ ...(lock.styles ? { styles: lock.styles } : {}),
80
+ items,
81
+ },
82
+ null,
83
+ 2,
84
+ )}\n`,
71
85
  );
72
86
  }
73
87
 
@@ -0,0 +1,241 @@
1
+ import { useMemo, type Key, type ReactNode } from "react";
2
+ import { cn, formatDateTime } from "@sia-ui/utils";
3
+ import type { TableQuery } from "@sia-ui/headless";
4
+ import type { ComponentTone } from "@sia-ui/tokens";
5
+ import { DataTable } from "../DataTable";
6
+ import type { DataTableColumn, DataTableProps } from "../DataTable/types";
7
+ import { FiltersBar } from "../FiltersBar";
8
+ import { PageHeader } from "../PageHeader";
9
+ import { Pagination } from "../Pagination";
10
+ import { SearchInput } from "../SearchInput";
11
+ import { Select, type SelectOption } from "../Select";
12
+ import { StatusBadge } from "../Badge";
13
+ import "./styles.css";
14
+
15
+ /** Une entrée de journal, dans sa forme la plus courante. */
16
+ export interface ActivityEntry {
17
+ id: string | number;
18
+ /** Quand : une date ISO, un horodatage ou une `Date`. */
19
+ at: string | number | Date;
20
+ /** Qui : un nom lisible. */
21
+ actor: string;
22
+ /** Quoi : un code — `create`, `delete`, `login`. */
23
+ action: string;
24
+ /** Sur quoi : « Projet Atlas », « Compte ovh-prod ». */
25
+ target?: string | undefined;
26
+ /** Le type de la cible : `project`, `account`. */
27
+ targetType?: string | undefined;
28
+ /** Une phrase de contexte, facultative. */
29
+ summary?: ReactNode;
30
+ }
31
+
32
+ /** Un filtre déclaré : une clé de requête et ses choix. */
33
+ export interface ActivityLogFilter {
34
+ /** La clé envoyée au serveur : `actor`, `action`, `targetType`. */
35
+ key: string;
36
+ label: string;
37
+ options: SelectOption[];
38
+ /** Cherchable au-delà d'une dizaine de choix. */
39
+ searchable?: boolean;
40
+ }
41
+
42
+ export interface ActivityLogProps<T = ActivityEntry> {
43
+ entries: T[];
44
+ /**
45
+ * L'état de la requête : page, taille, recherche, filtres.
46
+ *
47
+ * `useDataTableQuery()` le fournit, synchronisé avec l'URL ou le routeur ;
48
+ * `query.params` part tel quel au serveur. Le journal ne filtre rien
49
+ * lui-même : un journal se compte en milliers de lignes.
50
+ */
51
+ query: TableQuery;
52
+ totalPages?: number;
53
+ filters?: ActivityLogFilter[];
54
+ /** Absente, la recherche n'est pas proposée. */
55
+ searchPlaceholder?: string;
56
+
57
+ /** Par défaut, les colonnes d'une `ActivityEntry`. */
58
+ columns?: Array<DataTableColumn<T>>;
59
+ getRowKey?: (row: T, index: number) => Key;
60
+ /** Le ton de chaque action dans les colonnes par défaut. */
61
+ actionTones?: Record<string, ComponentTone>;
62
+ /** Le libellé de chaque action — « Suppression » pour `delete`. */
63
+ actionLabels?: Record<string, ReactNode>;
64
+
65
+ title?: ReactNode;
66
+ description?: ReactNode;
67
+ headingLevel?: 1 | 2 | 3;
68
+
69
+ loading?: boolean;
70
+ error?: ReactNode;
71
+ onRetry?: () => void;
72
+ empty?: DataTableProps<T>["empty"];
73
+ className?: string;
74
+ }
75
+
76
+ /** Les colonnes d'une `ActivityEntry` : quand, qui, quoi, sur quoi. */
77
+ export function activityColumns(
78
+ options: {
79
+ actionTones?: Record<string, ComponentTone> | undefined;
80
+ actionLabels?: Record<string, ReactNode> | undefined;
81
+ } = {},
82
+ ): Array<DataTableColumn<ActivityEntry>> {
83
+ return [
84
+ {
85
+ key: "at",
86
+ header: "Date",
87
+ width: "11rem",
88
+ cell: (entry) => (
89
+ <time className="sia-activity__date" dateTime={new Date(entry.at).toISOString()}>
90
+ {formatDateTime(entry.at)}
91
+ </time>
92
+ ),
93
+ card: "meta",
94
+ },
95
+ { key: "actor", header: "Acteur", card: "title" },
96
+ {
97
+ key: "action",
98
+ header: "Action",
99
+ cell: (entry) => (
100
+ <StatusBadge
101
+ value={entry.action}
102
+ {...(options.actionTones ? { tones: options.actionTones } : {})}
103
+ {...(options.actionLabels ? { labels: options.actionLabels } : {})}
104
+ />
105
+ ),
106
+ card: "meta",
107
+ },
108
+ {
109
+ key: "target",
110
+ header: "Cible",
111
+ cell: (entry) =>
112
+ entry.target || entry.summary ? (
113
+ <span className="sia-activity__target">
114
+ {entry.target && <span>{entry.target}</span>}
115
+ {entry.summary && <small>{entry.summary}</small>}
116
+ </span>
117
+ ) : null,
118
+ card: "meta",
119
+ },
120
+ ];
121
+ }
122
+
123
+ /**
124
+ * Un journal d'activité, filtrable et paginé par le serveur.
125
+ *
126
+ * `AuditMeta` et `Timeline` racontent l'histoire d'un objet ; ce bloc sert
127
+ * l'autre question — qui a fait quoi, sur tout le système, cette semaine.
128
+ * Filtres déclarés, recherche temporisée, pagination et taille de page : tout
129
+ * passe par la requête, rien n'est filtré dans le navigateur.
130
+ */
131
+ export function ActivityLog<T = ActivityEntry>({
132
+ entries,
133
+ query,
134
+ totalPages,
135
+ filters = [],
136
+ searchPlaceholder,
137
+ columns,
138
+ getRowKey,
139
+ actionTones,
140
+ actionLabels,
141
+ title,
142
+ description,
143
+ headingLevel,
144
+ loading = false,
145
+ error,
146
+ onRetry,
147
+ empty,
148
+ className,
149
+ }: ActivityLogProps<T>) {
150
+ const colonnes = useMemo(
151
+ () =>
152
+ columns ??
153
+ (activityColumns({ actionTones, actionLabels }) as unknown as Array<
154
+ DataTableColumn<T>
155
+ >),
156
+ [actionLabels, actionTones, columns],
157
+ );
158
+
159
+ const actifs =
160
+ filters.filter((filtre) => query.state.filters[filtre.key]).length +
161
+ (query.state.search ? 1 : 0);
162
+
163
+ const cle =
164
+ getRowKey ??
165
+ ((row: T, index: number) => (row as { id?: Key }).id ?? index);
166
+
167
+ return (
168
+ <section className={cn("sia-activity", className)}>
169
+ {title && (
170
+ <PageHeader
171
+ title={title}
172
+ {...(description ? { description } : {})}
173
+ {...(headingLevel ? { level: headingLevel } : {})}
174
+ />
175
+ )}
176
+
177
+ {(filters.length > 0 || searchPlaceholder) && (
178
+ <FiltersBar
179
+ {...(searchPlaceholder
180
+ ? {
181
+ search: (
182
+ <SearchInput
183
+ // Remonté à la réinitialisation : le champ reprend la
184
+ // recherche en vigueur plutôt que sa saisie d'avant.
185
+ key={query.state.search === "" ? "vide" : "saisie"}
186
+ defaultValue={query.state.search}
187
+ placeholder={searchPlaceholder}
188
+ searchDelay={300}
189
+ onSearch={query.setSearch}
190
+ />
191
+ ),
192
+ }
193
+ : {})}
194
+ filters={filters.map((filtre) => (
195
+ <div className="sia-activity__filter" key={filtre.key}>
196
+ <Select
197
+ aria-label={filtre.label}
198
+ placeholder={filtre.label}
199
+ options={filtre.options}
200
+ value={query.state.filters[filtre.key] ?? ""}
201
+ clearable
202
+ searchable={filtre.searchable ?? filtre.options.length > 10}
203
+ onValueChange={(valeur) => query.setFilter(filtre.key, valeur)}
204
+ />
205
+ </div>
206
+ ))}
207
+ activeCount={actifs}
208
+ onReset={query.reset}
209
+ />
210
+ )}
211
+
212
+ <DataTable<T>
213
+ columns={colonnes}
214
+ data={entries}
215
+ getRowKey={cle}
216
+ loading={loading}
217
+ {...(error ? { error } : {})}
218
+ {...(onRetry ? { onRetry } : {})}
219
+ empty={
220
+ empty ?? {
221
+ title: actifs > 0 ? "Aucune activité pour ces filtres" : "Aucune activité",
222
+ ...(actifs > 0
223
+ ? { description: "Élargissez la période ou retirez un filtre." }
224
+ : {}),
225
+ }
226
+ }
227
+ />
228
+
229
+ {totalPages !== undefined && (
230
+ <Pagination
231
+ page={query.state.page}
232
+ totalPages={totalPages}
233
+ onPageChange={query.setPage}
234
+ pageSize={query.state.perPage}
235
+ onPageSizeChange={query.setPerPage}
236
+ showTotal
237
+ />
238
+ )}
239
+ </section>
240
+ );
241
+ }
@@ -0,0 +1,31 @@
1
+ /* Même rythme vertical que la page de liste : en-tête, filtres, tableau,
2
+ pagination, séparés d'un seul écart. */
3
+ .sia-activity {
4
+ container: sia-activity / inline-size;
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 1rem;
8
+ min-width: 0;
9
+ }
10
+ .sia-activity > .sia-page-header { margin-bottom: 0; }
11
+
12
+ /* Un filtre a une largeur de lecture, pas toute la barre : quatre filtres
13
+ tiennent côte à côte avant de passer à la ligne. */
14
+ .sia-activity__filter { width: 12rem; max-width: 100%; }
15
+
16
+ /* Étroit : chaque filtre prend la ligne, comme la recherche au-dessus —
17
+ des champs de largeurs différentes empilés donnent un bord droit en
18
+ escalier. */
19
+ @container sia-activity (max-width: 34rem) {
20
+ .sia-activity .sia-filters-bar__filters { width: 100%; }
21
+ .sia-activity__filter { width: 100%; }
22
+ }
23
+
24
+ .sia-activity__date {
25
+ color: var(--sia-muted);
26
+ font-variant-numeric: tabular-nums;
27
+ white-space: nowrap;
28
+ }
29
+
30
+ .sia-activity__target { display: grid; gap: .1rem; min-width: 0; }
31
+ .sia-activity__target small { color: var(--sia-muted); font-size: .78rem; }