@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.
- package/CHANGELOG.md +58 -0
- package/README.md +58 -0
- package/dist/index.js +30 -11
- package/dist/lifecycle.mjs +189 -4
- package/dist/lock.mjs +16 -2
- package/dist/registry/package/components/ActivityLog/index.tsx +241 -0
- package/dist/registry/package/components/ActivityLog/styles.css +31 -0
- package/dist/registry/package/components/Badge/index.tsx +58 -0
- package/dist/registry/package/components/Calendar/index.tsx +4 -4
- package/dist/registry/package/components/CrudPage/dialogs.tsx +35 -22
- package/dist/registry/package/components/CrudPage/index.tsx +58 -26
- package/dist/registry/package/components/CrudPage/styles.css +7 -6
- package/dist/registry/package/components/DataTable/index.tsx +5 -1
- package/dist/registry/package/components/DataTable/query.ts +23 -3
- package/dist/registry/package/components/DataTable/row-actions.tsx +1 -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/Icons/index.tsx +88 -4
- package/dist/registry/package/components/PageHeader/index.tsx +28 -4
- package/dist/registry/package/components/PageHeader/styles.css +14 -2
- 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/Resource/index.tsx +69 -19
- package/dist/registry/package/components/SecretFields/index.tsx +193 -0
- package/dist/registry/package/components/SecretFields/styles.css +84 -0
- 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 +71 -16
- package/dist/registry/styles/sia-ui.css +7 -1
- package/dist/source.mjs +10 -0
- package/dist/styles.mjs +111 -0
- 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,
|
|
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" ||
|
|
@@ -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
|
-
) =>
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
) =>
|
|
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: <
|
|
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
|
-
...
|
|
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
|
|
442
|
-
{
|
|
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>
|