@sia-ui/cli 0.4.0 → 0.5.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,39 @@
1
1
  # @sia-ui/cli
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
8
+
9
+ ## Défauts
10
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
11
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
12
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
13
+ son installation.
14
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
15
+ reset côté projet, `AppShell` débordait de 32 px.
16
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
17
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
18
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
19
+
20
+ ## Formulaires et erreurs serveur
21
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
22
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
23
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
24
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
25
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
26
+ la modification de leur champ.
27
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
28
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
29
+ dans `@sia-ui/headless`.
30
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
31
+ ne déballe plus `data` quand l'objet porte une pagination.
32
+
33
+ ## Tableaux
34
+ - `searchDelay` sur `DataTable` et `CrudPage`.
35
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
36
+
3
37
  ## 0.4.0
4
38
 
5
39
  ### Minor Changes
package/README.md CHANGED
@@ -68,6 +68,40 @@ 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.5.0
72
+
73
+ ### Patch Changes
74
+
75
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
76
+
77
+ ## Défauts
78
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
79
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
80
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
81
+ son installation.
82
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
83
+ reset côté projet, `AppShell` débordait de 32 px.
84
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
85
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
86
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
87
+
88
+ ## Formulaires et erreurs serveur
89
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
90
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
91
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
92
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
93
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
94
+ la modification de leur champ.
95
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
96
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
97
+ dans `@sia-ui/headless`.
98
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
99
+ ne déballe plus `data` quand l'objet porte une pagination.
100
+
101
+ ## Tableaux
102
+ - `searchDelay` sur `DataTable` et `CrudPage`.
103
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
104
+
71
105
  ## 0.4.0
72
106
 
73
107
  ### Minor Changes
@@ -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" ||
@@ -84,7 +81,7 @@ export interface CrudDialogsProps<T> {
84
81
  | ((
85
82
  values: FormShape,
86
83
  context: { mode: "create" | "edit"; row?: T; index?: number },
87
- ) => void | Promise<void>)
84
+ ) => FormSubmitResult<FormShape> | Promise<FormSubmitResult<FormShape>>)
88
85
  | undefined;
89
86
 
90
87
  createDefaults?: FormShape | undefined;
@@ -177,11 +174,14 @@ export function CrudDialogs<T>({
177
174
  // conviennent.
178
175
  requireDirty={modification}
179
176
  onSubmit={async (values) => {
180
- await onSubmit?.(values, {
177
+ const resultat = await onSubmit?.(values, {
181
178
  mode: creation ? "create" : "edit",
182
179
  ...(state?.row !== undefined ? { row: state.row } : {}),
183
180
  ...(state?.index !== undefined ? { index: state.index } : {}),
184
181
  });
182
+ // Refusé par le serveur : la boîte reste ouverte, les erreurs
183
+ // sous les champs. La fermer perdrait la saisie.
184
+ if (hasFormErrors(resultat)) return resultat;
185
185
  onClose();
186
186
  }}
187
187
  />
@@ -1,6 +1,10 @@
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";
@@ -98,11 +102,16 @@ interface CrudPageBase<T> {
98
102
  */
99
103
  fields?: FormEntry[];
100
104
 
101
- /** Ce que la page fait d'un formulaire envoyé. */
105
+ /**
106
+ * Ce que la page fait d'un formulaire envoyé.
107
+ *
108
+ * Rendre des erreurs par champ, ou lever une `HttpError`, garde la boîte
109
+ * ouverte avec les erreurs affichées ; sinon elle se ferme.
110
+ */
102
111
  onSubmit?: (
103
112
  values: FormShape,
104
113
  context: { mode: "create" | "edit"; row?: T; index?: number },
105
- ) => void | Promise<void>;
114
+ ) => FormSubmitResult<FormShape> | Promise<FormSubmitResult<FormShape>>;
106
115
 
107
116
  /**
108
117
  * La validation, sous la même forme que `Form`.
@@ -164,6 +173,8 @@ interface CrudPageBase<T> {
164
173
  searchKeys?: string[];
165
174
  searchPlaceholder?: string;
166
175
  onSearch?: (query: string) => void;
176
+ /** Temporise `onSearch` — voir `DataTable`. 300 pour une recherche serveur. */
177
+ searchDelay?: number;
167
178
 
168
179
  loading?: boolean;
169
180
  error?: ReactNode;
@@ -173,6 +184,11 @@ interface CrudPageBase<T> {
173
184
  page?: number;
174
185
  totalPages?: number;
175
186
  onPageChange?: (page: number) => void;
187
+ /** Avec `onPageSizeChange`, ajoute le choix du nombre de lignes par page. */
188
+ pageSize?: number;
189
+ onPageSizeChange?: (pageSize: number) => void;
190
+ /** Les tailles proposées — voir `Pagination`. */
191
+ pageSizeOptions?: number[];
176
192
 
177
193
  /**
178
194
  * Ce qui n'a pas de raccourci ici passe au tableau tel quel — densité,
@@ -266,6 +282,7 @@ export function CrudPage<T>({
266
282
  searchKeys: clesDeRecherche,
267
283
  searchPlaceholder,
268
284
  onSearch,
285
+ searchDelay,
269
286
  loading = false,
270
287
  error,
271
288
  onRetry,
@@ -273,6 +290,9 @@ export function CrudPage<T>({
273
290
  page,
274
291
  totalPages,
275
292
  onPageChange,
293
+ pageSize,
294
+ onPageSizeChange,
295
+ pageSizeOptions,
276
296
  table,
277
297
  children,
278
298
  className,
@@ -416,11 +436,17 @@ export function CrudPage<T>({
416
436
  return actionsDeLigne.length > 0 ? actionsDeLigne : undefined;
417
437
  }
418
438
 
439
+ // Les ajouts se glissent avant « Supprimer » : la destruction reste la
440
+ // dernière action, là où on ne la vise pas par mégarde.
441
+ const suppression = actionsDeLigne.filter((a) => a.key === "delete");
442
+ const autres = actionsDeLigne.filter((a) => a.key !== "delete");
443
+
419
444
  return (row: T, index: number) => [
420
- ...actionsDeLigne,
445
+ ...autres,
421
446
  ...(typeof extraRowActions === "function"
422
447
  ? extraRowActions(row, index)
423
448
  : extraRowActions),
449
+ ...suppression,
424
450
  ];
425
451
  }, [actionsDeLigne, extraRowActions]);
426
452
 
@@ -471,6 +497,7 @@ export function CrudPage<T>({
471
497
  {...(searchKeys ? { searchKeys } : {})}
472
498
  {...(searchPlaceholder ? { searchPlaceholder } : {})}
473
499
  {...(onSearch ? { onSearch } : {})}
500
+ {...(searchDelay !== undefined ? { searchDelay } : {})}
474
501
  {...(toutesLesActions ? { rowActions: toutesLesActions } : {})}
475
502
  {...(can ? { can } : {})}
476
503
  loading={loading}
@@ -543,6 +570,9 @@ export function CrudPage<T>({
543
570
  totalPages={totalPages}
544
571
  onPageChange={onPageChange}
545
572
  showTotal
573
+ {...(pageSize !== undefined ? { pageSize } : {})}
574
+ {...(onPageSizeChange ? { onPageSizeChange } : {})}
575
+ {...(pageSizeOptions ? { pageSizeOptions } : {})}
546
576
  />
547
577
  )}
548
578
  </section>
@@ -54,6 +54,7 @@ export function DataTable<T>({
54
54
  searchKeys,
55
55
  searchPlaceholder,
56
56
  onSearch,
57
+ searchDelay = 0,
57
58
  layout = "auto",
58
59
  maxHeight,
59
60
  stickyHeader = false,
@@ -210,9 +211,12 @@ export function DataTable<T>({
210
211
  <SearchInput
211
212
  value={recherche}
212
213
  placeholder={searchPlaceholder ?? locale.search}
214
+ // Avec un délai, c'est le champ qui temporise : il sait aussi
215
+ // déclencher tout de suite sur Entrée et à l'effacement.
216
+ {...(onSearch && searchDelay > 0 ? { onSearch, searchDelay } : {})}
213
217
  onValueChange={(v) => {
214
218
  setRecherche(v);
215
- onSearch?.(v);
219
+ if (searchDelay <= 0) onSearch?.(v);
216
220
  }}
217
221
  />
218
222
  )}
@@ -107,6 +107,14 @@ export interface DataTableProps<T> {
107
107
  searchPlaceholder?: string;
108
108
  /** Recherche déléguée. Fournie, le tableau cesse de filtrer lui-même. */
109
109
  onSearch?: (query: string) => void;
110
+ /**
111
+ * Millisecondes d'inactivité avant d'appeler `onSearch`.
112
+ *
113
+ * `0` — le défaut — l'appelle à chaque frappe, ce qui convient à un
114
+ * filtrage local. Pour une recherche serveur, 300 évite une requête par
115
+ * lettre ; Entrée et l'effacement déclenchent sans attendre.
116
+ */
117
+ searchDelay?: number;
110
118
 
111
119
  /* ─────────────────────────────────────────────────────── mise en page */
112
120
 
@@ -79,6 +79,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
79
79
  phase: state,
80
80
  open: doOpen,
81
81
  close: doClose,
82
+ toggle,
82
83
  } = useDisclosureState({
83
84
  ...(open !== undefined ? { open } : {}),
84
85
  defaultOpen,
@@ -88,6 +89,11 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
88
89
 
89
90
  const drawerRef = useRef<HTMLDivElement | null>(null);
90
91
  const zIndexRef = useRef<number | null>(null);
92
+ // Lue par l'écouteur d'Échap : la mettre dans ses dépendances le
93
+ // réabonnerait — et rebasculerait le verrou du body — à chaque rendu où
94
+ // `onOpenChange` est une fonction en ligne.
95
+ const fermer = useRef(doClose);
96
+ fermer.current = doClose;
91
97
 
92
98
  /* 🔌 API IMPÉRATIVE */
93
99
  useImperativeHandle(
@@ -95,10 +101,10 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
95
101
  () => ({
96
102
  open: doOpen,
97
103
  close: doClose,
98
- toggle: () => (isOpen ? doClose() : doOpen()),
104
+ toggle,
99
105
  isOpen: () => isOpen,
100
106
  }),
101
- [isOpen, open],
107
+ [isOpen, doOpen, doClose, toggle],
102
108
  );
103
109
 
104
110
  /* Overlay stack */
@@ -123,7 +129,7 @@ export const Drawer = forwardRef<DrawerRef, IDrawerProps>(
123
129
  const onKeyDown = (e: KeyboardEvent) => {
124
130
  if (!closeOnEsc || e.key !== "Escape") return;
125
131
  if (overlayStack.isTop(zIndexRef.current)) {
126
- doClose();
132
+ fermer.current();
127
133
  }
128
134
  };
129
135
 
@@ -1,10 +1,15 @@
1
- import { type CSSProperties, type ReactNode } from "react";
1
+ import { useState, type CSSProperties, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import {
4
+ readSubmitError,
4
5
  useLocalForm,
6
+ useSiaLocale,
5
7
  type FormAdapter,
8
+ type FormErrors,
9
+ type FormSubmitResult,
6
10
  type FormValidator,
7
11
  } from "@sia-ui/headless";
12
+ import { Alert } from "../Alert";
8
13
  import { Button, type ButtonProps } from "../Button";
9
14
  import { Field, type FieldProps, type FieldValue } from "../Field";
10
15
  import "./styles.css";
@@ -32,6 +37,16 @@ function isGroup(entry: FormEntry): entry is FormFieldGroup {
32
37
  return "group" in entry;
33
38
  }
34
39
 
40
+ /** Les noms de champs, groupes aplatis. */
41
+ export function formFieldNames(fields: FormEntry[]): string[] {
42
+ const noms: string[] = [];
43
+ for (const entree of fields) {
44
+ if (isGroup(entree)) noms.push(...entree.fields.map((f) => f.name));
45
+ else noms.push(entree.name);
46
+ }
47
+ return noms;
48
+ }
49
+
35
50
  /**
36
51
  * Les valeurs qu'un formulaire déclaratif peut porter.
37
52
  *
@@ -54,7 +69,16 @@ export interface FormProps<TValues extends FormShape = FormShape> {
54
69
  defaultValues?: TValues;
55
70
  /** La validation. Un schéma Zod s'y branche en cinq lignes. */
56
71
  validate?: FormValidator<TValues>;
57
- onSubmit?: (values: TValues) => void | Promise<void>;
72
+ /**
73
+ * L'envoi.
74
+ *
75
+ * Rendre des erreurs par champ les pose sous les champs. Lever une erreur
76
+ * l'affiche dans une alerte en tête des boutons — et une `HttpError` de
77
+ * `@sia-ui/api` voit en plus ses erreurs de champ posées à leur place.
78
+ */
79
+ onSubmit?: (
80
+ values: TValues,
81
+ ) => FormSubmitResult<TValues> | Promise<FormSubmitResult<TValues>>;
58
82
  columns?: number;
59
83
  submitText?: ReactNode;
60
84
  clearText?: ReactNode;
@@ -118,6 +142,38 @@ export function Form<TValues extends FormShape = FormShape>({
118
142
  ...(onSubmit ? { onSubmit } : {}),
119
143
  });
120
144
  const form = adapter ?? interne;
145
+ const locale = useSiaLocale();
146
+
147
+ /**
148
+ * L'échec du dernier envoi, quand il ne se range pas sous un champ.
149
+ *
150
+ * Sans lui, un 409 ou un 500 devenait une promesse rejetée que personne ne
151
+ * lisait : le bouton se relâchait, et rien ne disait pourquoi.
152
+ */
153
+ const [echec, setEchec] = useState<string | null>(null);
154
+
155
+ const envoyer = () => {
156
+ setEchec(null);
157
+ form.submit().catch((error: unknown) => {
158
+ const { fields: erreurs, message } = readSubmitError(error);
159
+ const connus = new Set(formFieldNames(fields));
160
+ const sousLesChamps: Record<string, string> = {};
161
+ const ailleurs: string[] = [];
162
+
163
+ for (const [nom, texte] of Object.entries(erreurs)) {
164
+ if (connus.has(nom)) sousLesChamps[nom] = texte;
165
+ else ailleurs.push(texte);
166
+ }
167
+
168
+ form.setErrors(sousLesChamps as FormErrors<TValues>);
169
+ // Ce que les champs affichent déjà n'est pas répété dans l'alerte ; ce
170
+ // qui vise un champ absent du formulaire, si.
171
+ if (ailleurs.length > 0) setEchec(ailleurs.join(" "));
172
+ else if (Object.keys(sousLesChamps).length === 0) {
173
+ setEchec(message ?? locale.submitError);
174
+ }
175
+ });
176
+ };
121
177
 
122
178
  const busy = loading || form.state.isSubmitting;
123
179
  const submitDisabled =
@@ -157,7 +213,7 @@ export function Form<TValues extends FormShape = FormShape>({
157
213
  noValidate
158
214
  onSubmit={(event) => {
159
215
  event.preventDefault();
160
- void form.submit();
216
+ envoyer();
161
217
  }}
162
218
  >
163
219
  <div className="sia-form-grid" style={grille(columns)}>
@@ -184,6 +240,16 @@ export function Form<TValues extends FormShape = FormShape>({
184
240
  )}
185
241
  </div>
186
242
 
243
+ {echec && (
244
+ <Alert
245
+ tone="danger"
246
+ className="sia-form-error"
247
+ onDismiss={() => setEchec(null)}
248
+ >
249
+ {echec}
250
+ </Alert>
251
+ )}
252
+
187
253
  {actions ?? (
188
254
  <div className="sia-form-actions">
189
255
  {showClear && (