@sia-ui/cli 0.4.0 → 0.6.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -0
  3. package/dist/index.js +30 -11
  4. package/dist/lifecycle.mjs +189 -4
  5. package/dist/lock.mjs +16 -2
  6. package/dist/registry/package/components/ActivityLog/index.tsx +241 -0
  7. package/dist/registry/package/components/ActivityLog/styles.css +31 -0
  8. package/dist/registry/package/components/Badge/index.tsx +58 -0
  9. package/dist/registry/package/components/Calendar/index.tsx +4 -4
  10. package/dist/registry/package/components/CrudPage/dialogs.tsx +35 -22
  11. package/dist/registry/package/components/CrudPage/index.tsx +58 -26
  12. package/dist/registry/package/components/CrudPage/styles.css +7 -6
  13. package/dist/registry/package/components/DataTable/index.tsx +5 -1
  14. package/dist/registry/package/components/DataTable/query.ts +23 -3
  15. package/dist/registry/package/components/DataTable/row-actions.tsx +1 -1
  16. package/dist/registry/package/components/DataTable/types.ts +8 -0
  17. package/dist/registry/package/components/Drawer/index.tsx +9 -3
  18. package/dist/registry/package/components/Form/index.tsx +69 -3
  19. package/dist/registry/package/components/Icons/index.tsx +88 -4
  20. package/dist/registry/package/components/PageHeader/index.tsx +28 -4
  21. package/dist/registry/package/components/PageHeader/styles.css +14 -2
  22. package/dist/registry/package/components/Pagination/index.tsx +168 -61
  23. package/dist/registry/package/components/Pagination/styles.css +133 -38
  24. package/dist/registry/package/components/PhoneInput/index.tsx +46 -29
  25. package/dist/registry/package/components/PhoneInput/styles.css +7 -21
  26. package/dist/registry/package/components/Resource/index.tsx +69 -19
  27. package/dist/registry/package/components/SecretFields/index.tsx +193 -0
  28. package/dist/registry/package/components/SecretFields/styles.css +84 -0
  29. package/dist/registry/package/components/Select/index.tsx +34 -8
  30. package/dist/registry/package/components/Select/styles.css +18 -0
  31. package/dist/registry/package/components/Timer/index.tsx +1 -1
  32. package/dist/registry/package/lib/form-adapter-rhf.tsx +23 -10
  33. package/dist/registry/registry.json +71 -16
  34. package/dist/registry/styles/sia-ui.css +7 -1
  35. package/dist/source.mjs +10 -0
  36. package/dist/styles.mjs +111 -0
  37. package/package.json +5 -5
@@ -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; }
@@ -67,3 +67,61 @@ export function Badge({
67
67
  </span>
68
68
  );
69
69
  }
70
+
71
+ /**
72
+ * Le ton d'une valeur, lu dans une table de correspondance.
73
+ *
74
+ * Un statut, une action de journal, un environnement : chaque écran tenait
75
+ * sa propre table et sa propre fonction pour la lire. Celle-ci est la seule.
76
+ *
77
+ * ```ts
78
+ * const TONS = { payee: "success", attente: "warning", annulee: "danger" };
79
+ * toneOf(TONS, facture.statut); // "success" | … | "neutral"
80
+ * ```
81
+ */
82
+ export function toneOf(
83
+ tones: Readonly<Record<string, ComponentTone>> | undefined,
84
+ value: unknown,
85
+ fallback: ComponentTone = "neutral",
86
+ ): ComponentTone {
87
+ if (value === null || value === undefined) return fallback;
88
+ return tones?.[String(value)] ?? fallback;
89
+ }
90
+
91
+ export interface StatusBadgeProps extends Omit<BadgeProps, "tone" | "children"> {
92
+ /** La valeur brute : `"payee"`, `"prod"`, `"delete"`. */
93
+ value: string | number | null | undefined;
94
+ /** Le ton de chaque valeur. */
95
+ tones?: Readonly<Record<string, ComponentTone>>;
96
+ /** Le libellé de chaque valeur. À défaut, la valeur elle-même. */
97
+ labels?: Readonly<Record<string, ReactNode>>;
98
+ /** Le ton d'une valeur absente de `tones`. `neutral` par défaut. */
99
+ fallbackTone?: ComponentTone;
100
+ /** Ce qui s'affiche sans valeur. Rien par défaut. */
101
+ empty?: ReactNode;
102
+ }
103
+
104
+ /**
105
+ * Une valeur rendue en pastille, ton et libellé tirés de deux tables.
106
+ *
107
+ * Déclarées une fois par domaine — les statuts de facture, les actions du
108
+ * journal — puis passées partout où la valeur s'affiche : colonne libre,
109
+ * détail, carte. Une valeur inconnue garde une pastille neutre plutôt que de
110
+ * disparaître.
111
+ */
112
+ export function StatusBadge({
113
+ value,
114
+ tones,
115
+ labels,
116
+ fallbackTone = "neutral",
117
+ empty = null,
118
+ ...props
119
+ }: StatusBadgeProps) {
120
+ if (value === null || value === undefined || value === "") return <>{empty}</>;
121
+ const brut = String(value);
122
+ return (
123
+ <Badge tone={toneOf(tones, brut, fallbackTone)} {...props}>
124
+ {labels?.[brut] ?? brut}
125
+ </Badge>
126
+ );
127
+ }
@@ -1,4 +1,4 @@
1
- import { useMemo, useState, type ReactNode } from "react";
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
2
  import { cn, toneClass } from "@sia-ui/utils";
3
3
  import type { ComponentTone } from "@sia-ui/tokens";
4
4
  import { ChevronLeftIcon, ChevronRightIcon } from "../Icons";
@@ -119,10 +119,10 @@ export function Calendar({
119
119
  if (mode === undefined) setPanelMode(next);
120
120
  onPanelChange?.(monthIso(controlledMonth), next);
121
121
  };
122
- const isDisabled = (date: string) => Boolean(
122
+ const isDisabled = useCallback((date: string) => Boolean(
123
123
  disabledDate?.(date) ||
124
124
  (validRange && (date < validRange[0] || date > validRange[1])),
125
- );
125
+ ), [disabledDate, validRange]);
126
126
  const selectDate = (date: string) => {
127
127
  if (isDisabled(date)) return;
128
128
  if (selectionMode === "range") {
@@ -160,7 +160,7 @@ export function Calendar({
160
160
  ...(rangeEdge ? { rangeEdge } : {}),
161
161
  };
162
162
  });
163
- }, [year, monthIndex, firstDayOfWeek, disabledDate, validRange, currentValue, currentRange.start, currentRange.end]);
163
+ }, [year, monthIndex, firstDayOfWeek, isDisabled, currentValue, currentRange.start, currentRange.end]);
164
164
  const weekdays = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format(new Date(2024, 0, 7 + firstDayOfWeek + index)));
165
165
  const monthName = new Intl.DateTimeFormat(locale, { month: "long" }).format(controlledMonth);
166
166
  const updateFromString = (next: string) => setMonth(parseMonth(next));
@@ -1,7 +1,16 @@
1
1
  import { type Key, type ReactNode } from "react";
2
- import type { FormValidator } from "@sia-ui/headless";
2
+ import {
3
+ hasFormErrors,
4
+ type FormSubmitResult,
5
+ type FormValidator,
6
+ } from "@sia-ui/headless";
3
7
  import { Modal } from "../Modal";
4
- import { Form, type FormEntry, type FormShape } from "../Form";
8
+ import {
9
+ Form,
10
+ formFieldNames,
11
+ type FormEntry,
12
+ type FormShape,
13
+ } from "../Form";
5
14
  import { Descriptions } from "../Descriptions";
6
15
  import type { DataTableColumn } from "../DataTable/types";
7
16
  import { renduCellule } from "../DataTable/values";
@@ -29,24 +38,12 @@ export interface CrudDialogOptions {
29
38
  }
30
39
 
31
40
  /**
32
- * Les noms de champs, groupes aplatis.
33
- *
34
- * Servent à fabriquer un formulaire vide : sans valeur initiale déclarée, un
35
- * champ non contrôlé le devient à la première frappe, et React s'en plaint à
36
- * juste titre.
41
+ * Un formulaire vide : sans valeur initiale déclarée, un champ non contrôlé
42
+ * le devient à la première frappe, et React s'en plaint à juste titre.
37
43
  */
38
- function nomsDeChamps(fields: FormEntry[]): string[] {
39
- const noms: string[] = [];
40
- for (const entree of fields) {
41
- if ("group" in entree) noms.push(...entree.fields.map((f) => f.name));
42
- else noms.push(entree.name);
43
- }
44
- return noms;
45
- }
46
-
47
44
  export function valeursVides(fields: FormEntry[]): FormShape {
48
45
  const valeurs: FormShape = {};
49
- for (const nom of nomsDeChamps(fields)) valeurs[nom] = "";
46
+ for (const nom of formFieldNames(fields)) valeurs[nom] = "";
50
47
  return valeurs;
51
48
  }
52
49
 
@@ -60,7 +57,7 @@ export function valeursVides(fields: FormEntry[]): FormShape {
60
57
  export function valeursDeLigne<T>(row: T, fields: FormEntry[]): FormShape {
61
58
  const source = row as Record<string, unknown>;
62
59
  const valeurs: FormShape = {};
63
- for (const nom of nomsDeChamps(fields)) {
60
+ for (const nom of formFieldNames(fields)) {
64
61
  const valeur = source[nom];
65
62
  valeurs[nom] =
66
63
  typeof valeur === "string" ||
@@ -80,11 +77,18 @@ export interface CrudDialogsProps<T> {
80
77
  columns: Array<DataTableColumn<T>>;
81
78
  fields?: FormEntry[] | undefined;
82
79
  validate?: FormValidator<FormShape> | undefined;
80
+ /**
81
+ * Les champs et la validation de la modification, quand ils diffèrent de
82
+ * la création — un mot de passe exigé à la création seulement. À défaut,
83
+ * `fields` et `validate` servent aux deux.
84
+ */
85
+ editFields?: FormEntry[] | undefined;
86
+ editValidate?: FormValidator<FormShape> | undefined;
83
87
  onSubmit?:
84
88
  | ((
85
89
  values: FormShape,
86
90
  context: { mode: "create" | "edit"; row?: T; index?: number },
87
- ) => void | Promise<void>)
91
+ ) => FormSubmitResult<FormShape> | Promise<FormSubmitResult<FormShape>>)
88
92
  | undefined;
89
93
 
90
94
  createDefaults?: FormShape | undefined;
@@ -107,8 +111,10 @@ export function CrudDialogs<T>({
107
111
  state,
108
112
  onClose,
109
113
  columns,
110
- fields,
111
- validate,
114
+ fields: champsCreation,
115
+ validate: validationCreation,
116
+ editFields,
117
+ editValidate,
112
118
  onSubmit,
113
119
  createDefaults,
114
120
  toFormValues,
@@ -119,6 +125,10 @@ export function CrudDialogs<T>({
119
125
  const creation = state?.mode === "create";
120
126
  const modification = state?.mode === "edit";
121
127
  const consultation = state?.mode === "view";
128
+ const fields = modification ? (editFields ?? champsCreation) : champsCreation;
129
+ const validate = modification
130
+ ? (editValidate ?? validationCreation)
131
+ : validationCreation;
122
132
 
123
133
  /*
124
134
  Une clé qui change d'une ouverture à l'autre.
@@ -177,11 +187,14 @@ export function CrudDialogs<T>({
177
187
  // conviennent.
178
188
  requireDirty={modification}
179
189
  onSubmit={async (values) => {
180
- await onSubmit?.(values, {
190
+ const resultat = await onSubmit?.(values, {
181
191
  mode: creation ? "create" : "edit",
182
192
  ...(state?.row !== undefined ? { row: state.row } : {}),
183
193
  ...(state?.index !== undefined ? { index: state.index } : {}),
184
194
  });
195
+ // Refusé par le serveur : la boîte reste ouverte, les erreurs
196
+ // sous les champs. La fermer perdrait la saisie.
197
+ if (hasFormErrors(resultat)) return resultat;
185
198
  onClose();
186
199
  }}
187
200
  />
@@ -1,11 +1,15 @@
1
1
  import { useMemo, useState, type Key, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
- import type { FormValidator, PermissionRule } from "@sia-ui/headless";
3
+ import type {
4
+ FormSubmitResult,
5
+ FormValidator,
6
+ PermissionRule,
7
+ } from "@sia-ui/headless";
4
8
  import { PageHeader } from "../PageHeader";
5
9
  import { Button } from "../Button";
6
10
  import { Pagination } from "../Pagination";
7
11
  import { DataTable } from "../DataTable";
8
- import { EyeIcon, PlusIcon, TrashIcon } from "../Icons";
12
+ import { EyeIcon, PencilIcon, PlusIcon, TrashIcon } from "../Icons";
9
13
  import type {
10
14
  DataTableColumn,
11
15
  DataTableProps,
@@ -65,6 +69,12 @@ interface CrudPageBase<T> {
65
69
  title?: ReactNode;
66
70
  description?: ReactNode;
67
71
  eyebrow?: ReactNode;
72
+ /**
73
+ * Le niveau du titre. `1` pour un écran ; `2` quand la liste est une
74
+ * section d'une fiche — les comptes d'un fournisseur — qui a déjà son
75
+ * `<h1>`.
76
+ */
77
+ headingLevel?: 1 | 2 | 3;
68
78
 
69
79
  data: T[];
70
80
  getRowKey?: (row: T, index: number) => Key;
@@ -98,11 +108,16 @@ interface CrudPageBase<T> {
98
108
  */
99
109
  fields?: FormEntry[];
100
110
 
101
- /** Ce que la page fait d'un formulaire envoyé. */
111
+ /**
112
+ * Ce que la page fait d'un formulaire envoyé.
113
+ *
114
+ * Rendre des erreurs par champ, ou lever une `HttpError`, garde la boîte
115
+ * ouverte avec les erreurs affichées ; sinon elle se ferme.
116
+ */
102
117
  onSubmit?: (
103
118
  values: FormShape,
104
119
  context: { mode: "create" | "edit"; row?: T; index?: number },
105
- ) => void | Promise<void>;
120
+ ) => FormSubmitResult<FormShape> | Promise<FormSubmitResult<FormShape>>;
106
121
 
107
122
  /**
108
123
  * La validation, sous la même forme que `Form`.
@@ -164,6 +179,8 @@ interface CrudPageBase<T> {
164
179
  searchKeys?: string[];
165
180
  searchPlaceholder?: string;
166
181
  onSearch?: (query: string) => void;
182
+ /** Temporise `onSearch` — voir `DataTable`. 300 pour une recherche serveur. */
183
+ searchDelay?: number;
167
184
 
168
185
  loading?: boolean;
169
186
  error?: ReactNode;
@@ -173,6 +190,11 @@ interface CrudPageBase<T> {
173
190
  page?: number;
174
191
  totalPages?: number;
175
192
  onPageChange?: (page: number) => void;
193
+ /** Avec `onPageSizeChange`, ajoute le choix du nombre de lignes par page. */
194
+ pageSize?: number;
195
+ onPageSizeChange?: (pageSize: number) => void;
196
+ /** Les tailles proposées — voir `Pagination`. */
197
+ pageSizeOptions?: number[];
176
198
 
177
199
  /**
178
200
  * Ce qui n'a pas de raccourci ici passe au tableau tel quel — densité,
@@ -213,21 +235,6 @@ export type CrudPageProps<T> = CrudPageBase<T> &
213
235
  }
214
236
  );
215
237
 
216
- /** Une icône de crayon. Dessinée ici : le jeu commun n'en a pas. */
217
- function EditIcon() {
218
- return (
219
- <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
220
- <path
221
- d="M4 20h4l10-10a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5 4 20Z"
222
- fill="none"
223
- stroke="currentColor"
224
- strokeWidth="1.7"
225
- strokeLinejoin="round"
226
- />
227
- </svg>
228
- );
229
- }
230
-
231
238
  /**
232
239
  * Une page de liste, avec ses quatre opérations.
233
240
  *
@@ -243,6 +250,7 @@ export function CrudPage<T>({
243
250
  title,
244
251
  description,
245
252
  eyebrow,
253
+ headingLevel,
246
254
  columns: colonnesFournies,
247
255
  data,
248
256
  getRowKey: cleFournie,
@@ -266,6 +274,7 @@ export function CrudPage<T>({
266
274
  searchKeys: clesDeRecherche,
267
275
  searchPlaceholder,
268
276
  onSearch,
277
+ searchDelay,
269
278
  loading = false,
270
279
  error,
271
280
  onRetry,
@@ -273,6 +282,9 @@ export function CrudPage<T>({
273
282
  page,
274
283
  totalPages,
275
284
  onPageChange,
285
+ pageSize,
286
+ onPageSizeChange,
287
+ pageSizeOptions,
276
288
  table,
277
289
  children,
278
290
  className,
@@ -293,8 +305,10 @@ export function CrudPage<T>({
293
305
  ? {
294
306
  columns: resourceColumns(resource),
295
307
  detailColumns: resourceDetailColumns(resource),
296
- fields: resourceFormFields(resource),
297
- validate: resourceValidator(resource),
308
+ fields: resourceFormFields(resource, "create"),
309
+ validate: resourceValidator(resource, "create"),
310
+ editFields: resourceFormFields(resource, "edit"),
311
+ editValidate: resourceValidator(resource, "edit"),
298
312
  searchKeys: resourceSearchKeys(resource),
299
313
  defaults: resourceDefaults(resource),
300
314
  getRowKey: resourceRowKey(resource),
@@ -393,7 +407,7 @@ export function CrudPage<T>({
393
407
  };
394
408
 
395
409
  ajouter("view", consulter, { label: "Consulter", icon: <EyeIcon /> });
396
- ajouter("edit", modifier, { label: "Modifier", icon: <EditIcon /> });
410
+ ajouter("edit", modifier, { label: "Modifier", icon: <PencilIcon /> });
397
411
  ajouter("delete", onDelete, {
398
412
  label: "Supprimer",
399
413
  icon: <TrashIcon />,
@@ -416,11 +430,17 @@ export function CrudPage<T>({
416
430
  return actionsDeLigne.length > 0 ? actionsDeLigne : undefined;
417
431
  }
418
432
 
433
+ // Les ajouts se glissent avant « Supprimer » : la destruction reste la
434
+ // dernière action, là où on ne la vise pas par mégarde.
435
+ const suppression = actionsDeLigne.filter((a) => a.key === "delete");
436
+ const autres = actionsDeLigne.filter((a) => a.key !== "delete");
437
+
419
438
  return (row: T, index: number) => [
420
- ...actionsDeLigne,
439
+ ...autres,
421
440
  ...(typeof extraRowActions === "function"
422
441
  ? extraRowActions(row, index)
423
442
  : extraRowActions),
443
+ ...suppression,
424
444
  ];
425
445
  }, [actionsDeLigne, extraRowActions]);
426
446
 
@@ -438,8 +458,11 @@ export function CrudPage<T>({
438
458
 
439
459
  const boutonCreer =
440
460
  creer && creation && creationAutorisee ? (
441
- <Button onClick={creer} disabled={creation.disabled ?? false}>
442
- {creation.icon ?? <PlusIcon />}
461
+ <Button
462
+ onClick={creer}
463
+ disabled={creation.disabled ?? false}
464
+ leftIcon={creation.icon ?? <PlusIcon />}
465
+ >
443
466
  {creation.label ?? derive?.label?.create ?? "Créer"}
444
467
  </Button>
445
468
  ) : null;
@@ -459,6 +482,7 @@ export function CrudPage<T>({
459
482
  ? { description: description ?? resource?.description }
460
483
  : {})}
461
484
  {...(eyebrow ? { eyebrow } : {})}
485
+ {...(headingLevel ? { level: headingLevel } : {})}
462
486
  {...(enTeteActions ? { actions: enTeteActions } : {})}
463
487
  />
464
488
 
@@ -471,6 +495,7 @@ export function CrudPage<T>({
471
495
  {...(searchKeys ? { searchKeys } : {})}
472
496
  {...(searchPlaceholder ? { searchPlaceholder } : {})}
473
497
  {...(onSearch ? { onSearch } : {})}
498
+ {...(searchDelay !== undefined ? { searchDelay } : {})}
474
499
  {...(toutesLesActions ? { rowActions: toutesLesActions } : {})}
475
500
  {...(can ? { can } : {})}
476
501
  loading={loading}
@@ -485,12 +510,12 @@ export function CrudPage<T>({
485
510
  size="sm"
486
511
  variant="outline"
487
512
  tone="danger"
513
+ leftIcon={<TrashIcon />}
488
514
  onClick={async () => {
489
515
  await onBulkDelete(keys, rows);
490
516
  clear();
491
517
  }}
492
518
  >
493
- <TrashIcon />
494
519
  Supprimer
495
520
  </Button>
496
521
  ),
@@ -513,6 +538,10 @@ export function CrudPage<T>({
513
538
  columns={derive?.detailColumns ?? columns}
514
539
  fields={fields}
515
540
  validate={validate}
541
+ // Les champs fournis à la main servent aux deux modes ; ceux de la
542
+ // ressource suivent `inForm` et `required` mode par mode.
543
+ editFields={champsFournis ?? derive?.editFields}
544
+ editValidate={validationFournie ?? derive?.editValidate}
516
545
  onSubmit={onSubmit}
517
546
  createDefaults={createDefaults ?? derive?.defaults}
518
547
  toFormValues={toFormValues}
@@ -543,6 +572,9 @@ export function CrudPage<T>({
543
572
  totalPages={totalPages}
544
573
  onPageChange={onPageChange}
545
574
  showTotal
575
+ {...(pageSize !== undefined ? { pageSize } : {})}
576
+ {...(onPageSizeChange ? { onPageSizeChange } : {})}
577
+ {...(pageSizeOptions ? { pageSizeOptions } : {})}
546
578
  />
547
579
  )}
548
580
  </section>