@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 +34 -0
- package/README.md +34 -0
- package/dist/registry/package/components/Calendar/index.tsx +4 -4
- package/dist/registry/package/components/CrudPage/dialogs.tsx +20 -20
- package/dist/registry/package/components/CrudPage/index.tsx +34 -4
- package/dist/registry/package/components/DataTable/index.tsx +5 -1
- package/dist/registry/package/components/DataTable/types.ts +8 -0
- package/dist/registry/package/components/Drawer/index.tsx +9 -3
- package/dist/registry/package/components/Form/index.tsx +69 -3
- package/dist/registry/package/components/Pagination/index.tsx +168 -61
- package/dist/registry/package/components/Pagination/styles.css +133 -38
- package/dist/registry/package/components/PhoneInput/index.tsx +46 -29
- package/dist/registry/package/components/PhoneInput/styles.css +7 -21
- package/dist/registry/package/components/Select/index.tsx +34 -8
- package/dist/registry/package/components/Select/styles.css +18 -0
- package/dist/registry/package/components/Timer/index.tsx +1 -1
- package/dist/registry/package/lib/form-adapter-rhf.tsx +23 -10
- package/dist/registry/registry.json +19 -12
- package/dist/registry/styles/sia-ui.css +6 -0
- package/package.json +5 -5
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,
|
|
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
|
|
2
|
+
import {
|
|
3
|
+
hasFormErrors,
|
|
4
|
+
type FormSubmitResult,
|
|
5
|
+
type FormValidator,
|
|
6
|
+
} from "@sia-ui/headless";
|
|
3
7
|
import { Modal } from "../Modal";
|
|
4
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
) =>
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
) =>
|
|
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
|
-
...
|
|
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
|
|
104
|
+
toggle,
|
|
99
105
|
isOpen: () => isOpen,
|
|
100
106
|
}),
|
|
101
|
-
[isOpen,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|