@sia-ui/cli 0.2.1 → 0.3.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/LICENSE +21 -21
- package/dist/index.js +2 -0
- package/dist/registry/package/components/Alert/index.tsx +7 -0
- package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
- package/dist/registry/package/components/AppShell/index.tsx +413 -3
- package/dist/registry/package/components/AppShell/styles.css +259 -7
- package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
- package/dist/registry/package/components/AsyncState/index.tsx +7 -0
- package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
- package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
- package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
- package/dist/registry/package/components/Avatar/index.tsx +333 -12
- package/dist/registry/package/components/Avatar/styles.css +236 -5
- package/dist/registry/package/components/Badge/index.tsx +7 -0
- package/dist/registry/package/components/Button/index.tsx +7 -0
- package/dist/registry/package/components/Calendar/index.tsx +11 -0
- package/dist/registry/package/components/Card/index.tsx +8 -0
- package/dist/registry/package/components/Checkbox/index.tsx +8 -0
- package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
- package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
- package/dist/registry/package/components/Container/index.tsx +7 -0
- package/dist/registry/package/components/Countdown/index.tsx +7 -0
- package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
- package/dist/registry/package/components/CrudPage/index.tsx +546 -5
- package/dist/registry/package/components/CrudPage/styles.css +25 -1
- package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
- package/dist/registry/package/components/DataTable/index.tsx +548 -200
- package/dist/registry/package/components/DataTable/query.ts +68 -0
- package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
- package/dist/registry/package/components/DataTable/styles.css +285 -73
- package/dist/registry/package/components/DataTable/types.ts +194 -0
- package/dist/registry/package/components/DataTable/values.ts +28 -0
- package/dist/registry/package/components/DatePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
- package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
- package/dist/registry/package/components/Descriptions/index.tsx +7 -0
- package/dist/registry/package/components/Divider/index.tsx +7 -0
- package/dist/registry/package/components/Drawer/index.tsx +8 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
- package/dist/registry/package/components/EmptyState/index.tsx +7 -0
- package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
- package/dist/registry/package/components/ErrorState/index.tsx +8 -0
- package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Field/index.tsx +12 -0
- package/dist/registry/package/components/FileUpload/index.tsx +7 -0
- package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
- package/dist/registry/package/components/HoverCard/index.tsx +14 -37
- package/dist/registry/package/components/IconButton/index.tsx +8 -0
- package/dist/registry/package/components/Icons/index.tsx +15 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
- package/dist/registry/package/components/Input/index.tsx +7 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
- package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
- package/dist/registry/package/components/Label/index.tsx +7 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
- package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Modal/index.tsx +8 -0
- package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
- package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
- package/dist/registry/package/components/OtpInput/index.tsx +7 -0
- package/dist/registry/package/components/Overlay/index.tsx +34 -24
- package/dist/registry/package/components/PageHeader/index.tsx +7 -0
- package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
- package/dist/registry/package/components/Popover/index.tsx +7 -0
- package/dist/registry/package/components/QrCode/index.tsx +7 -0
- package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
- package/dist/registry/package/components/Rating/index.tsx +6 -0
- package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
- package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
- package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
- package/dist/registry/package/components/Resource/index.tsx +514 -0
- package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
- package/dist/registry/package/components/SearchInput/index.tsx +8 -0
- package/dist/registry/package/components/Select/index.tsx +11 -0
- package/dist/registry/package/components/Sidebar/index.tsx +489 -113
- package/dist/registry/package/components/Sidebar/styles.css +96 -4
- package/dist/registry/package/components/Skeleton/index.tsx +7 -0
- package/dist/registry/package/components/Spinner/index.tsx +7 -0
- package/dist/registry/package/components/StatCard/index.tsx +7 -0
- package/dist/registry/package/components/Statistic/index.tsx +7 -0
- package/dist/registry/package/components/Switch/index.tsx +7 -0
- package/dist/registry/package/components/Tabs/index.tsx +7 -0
- package/dist/registry/package/components/TagsInput/index.tsx +7 -0
- package/dist/registry/package/components/Textarea/index.tsx +7 -0
- package/dist/registry/package/components/TimePicker/index.tsx +7 -0
- package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
- package/dist/registry/package/components/Timeline/index.tsx +7 -0
- package/dist/registry/package/components/Timer/index.tsx +7 -0
- package/dist/registry/package/components/Tour/index.tsx +7 -0
- package/dist/registry/package/components/Transfer/index.tsx +7 -0
- package/dist/registry/package/components/Tree/index.tsx +7 -0
- package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
- package/dist/registry/package/components/Typography/index.tsx +8 -0
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
- package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
- package/dist/registry/package/components/YearPicker/index.tsx +7 -0
- package/dist/registry/registry.json +69 -14
- package/package.json +14 -14
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La description d'un objet métier, écrite une seule fois.
|
|
3
|
+
*
|
|
4
|
+
* Une page de liste décrit le même objet trois fois : en colonnes pour le
|
|
5
|
+
* tableau, en champs pour le formulaire, en lignes pour le détail. Plus la
|
|
6
|
+
* validation, les clés de recherche et la règle de droits par opération. Ces
|
|
7
|
+
* descriptions divergent, et toujours dans le même sens — c'est le formulaire
|
|
8
|
+
* qui oublie le champ que le tableau affiche.
|
|
9
|
+
*
|
|
10
|
+
* `defineResource` la décrit une fois; tout le reste en est dérivé. Ce module
|
|
11
|
+
* n'exporte donc pas de composant, mais les fonctions qui font cette
|
|
12
|
+
* dérivation : `CrudPage` les appelle, et chacune reste remplaçable par la
|
|
13
|
+
* prop correspondante.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Key, ReactNode } from "react";
|
|
17
|
+
import { formatDate, formatDateTime, formatNumber } from "@sia-ui/utils";
|
|
18
|
+
import {
|
|
19
|
+
createValidator,
|
|
20
|
+
email as regleEmail,
|
|
21
|
+
phone as reglePhone,
|
|
22
|
+
required as regleRequise,
|
|
23
|
+
type Rule,
|
|
24
|
+
} from "@sia-ui/utils/validation";
|
|
25
|
+
import type { PermissionRule, FormValidator } from "@sia-ui/headless";
|
|
26
|
+
import type { ComponentTone } from "@sia-ui/tokens";
|
|
27
|
+
import type { FieldType, FieldValue } from "../Field";
|
|
28
|
+
import type { SelectOption } from "../Select";
|
|
29
|
+
import type { FormEntry, FormFieldConfig, FormShape } from "../Form";
|
|
30
|
+
import type { ColumnCardRole, DataTableColumn } from "../DataTable/types";
|
|
31
|
+
import { AmountDisplay } from "../AmountDisplay";
|
|
32
|
+
import { Badge } from "../Badge";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Une propriété d'une ressource.
|
|
36
|
+
*
|
|
37
|
+
* Elle est déclarée **une fois** et sert partout : colonne du tableau, champ
|
|
38
|
+
* du formulaire, ligne de la vue de détail, règle de validation, clé de
|
|
39
|
+
* recherche. C'est l'objet de toute cette déclaration — trois descriptions du
|
|
40
|
+
* même client finissent toujours par diverger, et c'est le formulaire qui
|
|
41
|
+
* oublie le champ que le tableau affiche.
|
|
42
|
+
*/
|
|
43
|
+
export interface ResourceField<T = unknown> {
|
|
44
|
+
/** Le contrôle de saisie et le rendu par défaut. `text` sans précision. */
|
|
45
|
+
type?: FieldType;
|
|
46
|
+
/** À défaut, la clé de la propriété, première lettre en capitale. */
|
|
47
|
+
label?: ReactNode;
|
|
48
|
+
description?: ReactNode;
|
|
49
|
+
helpText?: ReactNode;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
|
|
52
|
+
required?: boolean;
|
|
53
|
+
/** Les règles en plus de `required`. Celles du type sont ajoutées seules. */
|
|
54
|
+
rules?: Array<Rule<never>>;
|
|
55
|
+
|
|
56
|
+
/** Les choix d'un `select`, `radio` ou `multiselect`. */
|
|
57
|
+
options?: SelectOption[];
|
|
58
|
+
/** Valeur de départ d'une création. */
|
|
59
|
+
defaultValue?: FieldValue;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Lu, mais pas saisi : un identifiant, une date de création, un total.
|
|
63
|
+
*
|
|
64
|
+
* Reste au tableau et au détail, disparaît du formulaire. Sans quoi une
|
|
65
|
+
* page de modification renverrait au serveur des champs qu'il calcule
|
|
66
|
+
* lui-même.
|
|
67
|
+
*/
|
|
68
|
+
readOnly?: boolean;
|
|
69
|
+
|
|
70
|
+
/* ── Où la propriété apparaît ───────────────────────────────────────── */
|
|
71
|
+
|
|
72
|
+
/** Par défaut, tout sauf les types trop longs pour une cellule. */
|
|
73
|
+
inTable?: boolean;
|
|
74
|
+
/** Par défaut, tout sauf `readOnly`. */
|
|
75
|
+
inForm?: boolean;
|
|
76
|
+
/** Par défaut, tout. */
|
|
77
|
+
inDetail?: boolean;
|
|
78
|
+
/** Par défaut, les types textuels. */
|
|
79
|
+
searchable?: boolean;
|
|
80
|
+
sortable?: boolean;
|
|
81
|
+
|
|
82
|
+
/* ── Mise en forme ──────────────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
align?: "start" | "center" | "end";
|
|
85
|
+
width?: string;
|
|
86
|
+
truncate?: boolean;
|
|
87
|
+
card?: ColumnCardRole;
|
|
88
|
+
/** Place du champ dans la grille du formulaire. */
|
|
89
|
+
colSpan?: number;
|
|
90
|
+
/** Le groupe de formulaire où ranger le champ. */
|
|
91
|
+
group?: string;
|
|
92
|
+
|
|
93
|
+
/** La devise d'un montant. */
|
|
94
|
+
currency?: string;
|
|
95
|
+
/** Le nombre de décimales d'un `number`. */
|
|
96
|
+
decimals?: number;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Le ton d'une valeur de `select`, par valeur.
|
|
100
|
+
*
|
|
101
|
+
* Déclaré, la colonne rend une pastille plutôt qu'un mot : un statut se lit
|
|
102
|
+
* alors d'un balayage, sans qu'on écrive une fonction de rendu.
|
|
103
|
+
*/
|
|
104
|
+
tones?: Record<string, ComponentTone>;
|
|
105
|
+
|
|
106
|
+
/** Le rendu en tableau et en détail. Écrase celui déduit du type. */
|
|
107
|
+
render?: (row: T, index: number) => ReactNode;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ResourceDefinition<T> {
|
|
111
|
+
/** L'identifiant technique — sert aux droits et aux clés de cache. */
|
|
112
|
+
name: string;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Comment la ressource se nomme à l'écran.
|
|
116
|
+
*
|
|
117
|
+
* `create` est déclaré plutôt que fabriqué : « Nouvelle facture » et
|
|
118
|
+
* « Nouveau client » ne se déduisent pas du singulier sans connaître le
|
|
119
|
+
* genre, et se tromper une fois sur deux est pire que d'écrire « Créer ».
|
|
120
|
+
*/
|
|
121
|
+
label?: { singular?: ReactNode; plural?: ReactNode; create?: ReactNode };
|
|
122
|
+
description?: ReactNode;
|
|
123
|
+
|
|
124
|
+
/** La propriété qui identifie une ligne. `id` par défaut. */
|
|
125
|
+
key?: (keyof T & string) | ((row: T, index: number) => Key);
|
|
126
|
+
|
|
127
|
+
/** Les propriétés, dans l'ordre où elles s'affichent. */
|
|
128
|
+
fields: Record<string, ResourceField<T>>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Les droits des quatre opérations.
|
|
132
|
+
*
|
|
133
|
+
* `"auto"` les dérive du nom : `facture.creer`, `facture.modifier`,
|
|
134
|
+
* `facture.supprimer`, `facture.lire`. C'est une convention, pas une
|
|
135
|
+
* obligation — chaque règle se déclare aussi à la main.
|
|
136
|
+
*/
|
|
137
|
+
permissions?:
|
|
138
|
+
| "auto"
|
|
139
|
+
| {
|
|
140
|
+
create?: PermissionRule;
|
|
141
|
+
view?: PermissionRule;
|
|
142
|
+
edit?: PermissionRule;
|
|
143
|
+
delete?: PermissionRule;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Colonnes de la grille du formulaire. */
|
|
147
|
+
formColumns?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Déclarer une ressource.
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* const factures = defineResource<Facture>({
|
|
155
|
+
* name: "facture",
|
|
156
|
+
* label: { singular: "Facture", plural: "Factures" },
|
|
157
|
+
* fields: {
|
|
158
|
+
* reference: { label: "Référence", required: true },
|
|
159
|
+
* client: { label: "Client", required: true },
|
|
160
|
+
* montant: { type: "currency", currency: "XOF", align: "end" },
|
|
161
|
+
* statut: {
|
|
162
|
+
* type: "select",
|
|
163
|
+
* options: [{ value: "payee", label: "Payée" }],
|
|
164
|
+
* tones: { payee: "success" },
|
|
165
|
+
* },
|
|
166
|
+
* },
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* La fonction ne transforme rien : elle sert à ce que TypeScript vérifie la
|
|
171
|
+
* déclaration au moment où on l'écrit, plutôt qu'au moment où on la passe à
|
|
172
|
+
* `CrudPage`. Le message d'erreur y gagne dix lignes de lisibilité.
|
|
173
|
+
*/
|
|
174
|
+
export function defineResource<T>(
|
|
175
|
+
definition: ResourceDefinition<T>,
|
|
176
|
+
): ResourceDefinition<T> {
|
|
177
|
+
return definition;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ─────────────────────────────────────────────── les défauts par type */
|
|
181
|
+
|
|
182
|
+
/** Trop long, trop lourd ou trop secret pour une cellule de tableau. */
|
|
183
|
+
const HORS_TABLEAU = new Set<FieldType>([
|
|
184
|
+
"textarea",
|
|
185
|
+
"rich-text",
|
|
186
|
+
"markdown",
|
|
187
|
+
"json",
|
|
188
|
+
"password",
|
|
189
|
+
"file",
|
|
190
|
+
"image",
|
|
191
|
+
"otp",
|
|
192
|
+
"hidden",
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/** Ce qu'une recherche textuelle a une chance de trouver. */
|
|
196
|
+
const CHERCHABLES = new Set<FieldType>([
|
|
197
|
+
"text",
|
|
198
|
+
"textarea",
|
|
199
|
+
"email",
|
|
200
|
+
"phone",
|
|
201
|
+
"autocomplete",
|
|
202
|
+
"reference",
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/** Ce qui s'aligne à droite : des chiffres qu'on compare de haut en bas. */
|
|
206
|
+
const NUMERIQUES = new Set<FieldType>(["number", "currency", "slider"]);
|
|
207
|
+
|
|
208
|
+
/** La règle que le type impose de lui-même. */
|
|
209
|
+
const REGLE_DU_TYPE: Partial<Record<FieldType, () => Rule<never>>> = {
|
|
210
|
+
email: () => regleEmail() as Rule<never>,
|
|
211
|
+
phone: () => reglePhone() as Rule<never>,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** « dateEcheance » → « Date echeance ». Un repli, pas une traduction. */
|
|
215
|
+
function libelleParDefaut(cle: string): string {
|
|
216
|
+
const mots = cle
|
|
217
|
+
.replace(/[_-]+/g, " ")
|
|
218
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
219
|
+
.toLowerCase()
|
|
220
|
+
.trim();
|
|
221
|
+
return mots.charAt(0).toUpperCase() + mots.slice(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function estVisible(champ: ResourceField<never>, ou: "table" | "form" | "detail") {
|
|
225
|
+
const type = champ.type ?? "text";
|
|
226
|
+
if (ou === "table") return champ.inTable ?? !HORS_TABLEAU.has(type);
|
|
227
|
+
if (ou === "form") return champ.inForm ?? !champ.readOnly;
|
|
228
|
+
return champ.inDetail ?? true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Les propriétés dans l'ordre de déclaration, avec leur clé. */
|
|
232
|
+
function entrees<T>(resource: ResourceDefinition<T>) {
|
|
233
|
+
return Object.entries(resource.fields) as Array<[string, ResourceField<T>]>;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ──────────────────────────────────────────────────── les dérivations */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Le rendu d'une valeur, déduit de son type.
|
|
240
|
+
*
|
|
241
|
+
* Un montant se lit formaté, une date au format local, un statut en
|
|
242
|
+
* pastille. Sans cela, chaque page réécrirait les mêmes quatre fonctions de
|
|
243
|
+
* rendu, et la cinquième oublierait la devise.
|
|
244
|
+
*/
|
|
245
|
+
function renduParDefaut<T>(
|
|
246
|
+
cle: string,
|
|
247
|
+
champ: ResourceField<T>,
|
|
248
|
+
): ((row: T, index: number) => ReactNode) | undefined {
|
|
249
|
+
const type = champ.type ?? "text";
|
|
250
|
+
|
|
251
|
+
if (type === "currency") {
|
|
252
|
+
return (row) => {
|
|
253
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
254
|
+
if (typeof valeur !== "number") return null;
|
|
255
|
+
return (
|
|
256
|
+
<AmountDisplay
|
|
257
|
+
value={valeur}
|
|
258
|
+
{...(champ.currency ? { currency: champ.currency } : {})}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (type === "select" || type === "radio") {
|
|
265
|
+
return (row) => {
|
|
266
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
267
|
+
if (valeur === null || valeur === undefined || valeur === "") return null;
|
|
268
|
+
const brut = String(valeur);
|
|
269
|
+
const option = champ.options?.find((o) => o.value === brut);
|
|
270
|
+
const libelle = option?.label ?? brut;
|
|
271
|
+
const ton = champ.tones?.[brut];
|
|
272
|
+
return ton ? <Badge tone={ton}>{libelle}</Badge> : <>{libelle}</>;
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (type === "checkbox" || type === "switch") {
|
|
277
|
+
return (row) => ((row as Record<string, unknown>)[cle] ? "Oui" : "Non");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (type === "date" || type === "datetime") {
|
|
281
|
+
return (row) => {
|
|
282
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
283
|
+
if (typeof valeur !== "string" && !(valeur instanceof Date)) return null;
|
|
284
|
+
return type === "date" ? formatDate(valeur) : formatDateTime(valeur);
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (type === "number") {
|
|
289
|
+
return (row) => {
|
|
290
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
291
|
+
if (typeof valeur !== "number") return null;
|
|
292
|
+
return formatNumber(valeur, {
|
|
293
|
+
...(champ.decimals !== undefined
|
|
294
|
+
? {
|
|
295
|
+
minimumFractionDigits: champ.decimals,
|
|
296
|
+
maximumFractionDigits: champ.decimals,
|
|
297
|
+
}
|
|
298
|
+
: {}),
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (type === "multiselect" || type === "tags") {
|
|
304
|
+
return (row) => {
|
|
305
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
306
|
+
if (!Array.isArray(valeur)) return null;
|
|
307
|
+
return valeur
|
|
308
|
+
.map((v) => {
|
|
309
|
+
const brut = String(v);
|
|
310
|
+
const option = champ.options?.find((o) => o.value === brut);
|
|
311
|
+
return typeof option?.label === "string" ? option.label : brut;
|
|
312
|
+
})
|
|
313
|
+
.join(", ");
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Le reste tombe sur le rendu par défaut du tableau : la valeur en texte.
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Les colonnes du tableau, dans l'ordre de déclaration. */
|
|
322
|
+
export function resourceColumns<T>(
|
|
323
|
+
resource: ResourceDefinition<T>,
|
|
324
|
+
): Array<DataTableColumn<T>> {
|
|
325
|
+
const colonnes: Array<DataTableColumn<T>> = [];
|
|
326
|
+
let titreAttribue = false;
|
|
327
|
+
|
|
328
|
+
for (const [cle, champ] of entrees(resource)) {
|
|
329
|
+
if (!estVisible(champ as ResourceField<never>, "table")) continue;
|
|
330
|
+
|
|
331
|
+
const type = champ.type ?? "text";
|
|
332
|
+
const rendu = champ.render ?? renduParDefaut(cle, champ);
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
La première colonne textuelle devient le titre de la carte. Laisser la
|
|
336
|
+
grille de cartes sans titre la rendrait illisible, et l'exiger de
|
|
337
|
+
chaque déclaration serait une cérémonie de plus.
|
|
338
|
+
*/
|
|
339
|
+
let role = champ.card;
|
|
340
|
+
if (role === undefined && !titreAttribue && CHERCHABLES.has(type)) {
|
|
341
|
+
role = "title";
|
|
342
|
+
titreAttribue = true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
colonnes.push({
|
|
346
|
+
key: cle,
|
|
347
|
+
header: champ.label ?? libelleParDefaut(cle),
|
|
348
|
+
...(rendu ? { cell: rendu } : {}),
|
|
349
|
+
align: champ.align ?? (NUMERIQUES.has(type) ? "end" : "start"),
|
|
350
|
+
...(champ.width ? { width: champ.width } : {}),
|
|
351
|
+
...(champ.truncate ? { truncate: champ.truncate } : {}),
|
|
352
|
+
...(champ.sortable ? { sortable: champ.sortable } : {}),
|
|
353
|
+
...(role ? { card: role } : {}),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return colonnes;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Les lignes de la vue de détail.
|
|
362
|
+
*
|
|
363
|
+
* Tenues à part des colonnes : une note de trois phrases n'a pas sa place
|
|
364
|
+
* dans une cellule, mais c'est précisément au détail qu'on va la lire. Les
|
|
365
|
+
* dériver du tableau priverait la vue de détail de tout ce qui est long, ce
|
|
366
|
+
* qui est exactement ce qu'elle existe pour montrer.
|
|
367
|
+
*/
|
|
368
|
+
export function resourceDetailColumns<T>(
|
|
369
|
+
resource: ResourceDefinition<T>,
|
|
370
|
+
): Array<DataTableColumn<T>> {
|
|
371
|
+
const lignes: Array<DataTableColumn<T>> = [];
|
|
372
|
+
|
|
373
|
+
for (const [cle, champ] of entrees(resource)) {
|
|
374
|
+
if (!estVisible(champ as ResourceField<never>, "detail")) continue;
|
|
375
|
+
const rendu = champ.render ?? renduParDefaut(cle, champ);
|
|
376
|
+
lignes.push({
|
|
377
|
+
key: cle,
|
|
378
|
+
header: champ.label ?? libelleParDefaut(cle),
|
|
379
|
+
...(rendu ? { cell: rendu } : {}),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return lignes;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Les champs du formulaire, groupés si la déclaration les groupe. */
|
|
387
|
+
export function resourceFormFields<T>(
|
|
388
|
+
resource: ResourceDefinition<T>,
|
|
389
|
+
): FormEntry[] {
|
|
390
|
+
const libres: FormFieldConfig[] = [];
|
|
391
|
+
const groupes = new Map<string, FormFieldConfig[]>();
|
|
392
|
+
|
|
393
|
+
for (const [cle, champ] of entrees(resource)) {
|
|
394
|
+
if (!estVisible(champ as ResourceField<never>, "form")) continue;
|
|
395
|
+
|
|
396
|
+
const config: FormFieldConfig = {
|
|
397
|
+
name: cle,
|
|
398
|
+
label: champ.label ?? libelleParDefaut(cle),
|
|
399
|
+
...(champ.type ? { type: champ.type } : {}),
|
|
400
|
+
...(champ.required ? { required: champ.required } : {}),
|
|
401
|
+
...(champ.placeholder ? { placeholder: champ.placeholder } : {}),
|
|
402
|
+
...(champ.helpText ? { helpText: champ.helpText } : {}),
|
|
403
|
+
...(champ.description ? { description: champ.description } : {}),
|
|
404
|
+
...(champ.options ? { options: champ.options } : {}),
|
|
405
|
+
...(champ.colSpan ? { colSpan: champ.colSpan } : {}),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (champ.group) {
|
|
409
|
+
const liste = groupes.get(champ.group);
|
|
410
|
+
if (liste) liste.push(config);
|
|
411
|
+
else groupes.set(champ.group, [config]);
|
|
412
|
+
} else {
|
|
413
|
+
libres.push(config);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return [
|
|
418
|
+
...libres,
|
|
419
|
+
...Array.from(groupes, ([group, fields]) => ({ group, fields })),
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* La validation, dérivée des types et des `required`.
|
|
425
|
+
*
|
|
426
|
+
* Un champ `email` valide une adresse sans qu'on le demande : le type le dit
|
|
427
|
+
* déjà. Les règles déclarées s'y ajoutent, elles ne les remplacent pas.
|
|
428
|
+
*/
|
|
429
|
+
export function resourceValidator<T>(
|
|
430
|
+
resource: ResourceDefinition<T>,
|
|
431
|
+
): FormValidator<FormShape> | undefined {
|
|
432
|
+
const schema: Record<string, Array<Rule<never>>> = {};
|
|
433
|
+
|
|
434
|
+
for (const [cle, champ] of entrees(resource)) {
|
|
435
|
+
if (!estVisible(champ as ResourceField<never>, "form")) continue;
|
|
436
|
+
|
|
437
|
+
const regles: Array<Rule<never>> = [];
|
|
438
|
+
if (champ.required) regles.push(regleRequise() as Rule<never>);
|
|
439
|
+
|
|
440
|
+
const duType = REGLE_DU_TYPE[champ.type ?? "text"];
|
|
441
|
+
if (duType) regles.push(duType());
|
|
442
|
+
|
|
443
|
+
if (champ.rules) regles.push(...champ.rules);
|
|
444
|
+
if (regles.length > 0) schema[cle] = regles;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (Object.keys(schema).length === 0) return undefined;
|
|
448
|
+
return createValidator(
|
|
449
|
+
schema as Parameters<typeof createValidator<FormShape>>[0],
|
|
450
|
+
) as FormValidator<FormShape>;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Les clés que la recherche locale parcourt. */
|
|
454
|
+
export function resourceSearchKeys<T>(
|
|
455
|
+
resource: ResourceDefinition<T>,
|
|
456
|
+
): string[] {
|
|
457
|
+
return entrees(resource)
|
|
458
|
+
.filter(([, champ]) => champ.searchable ?? CHERCHABLES.has(champ.type ?? "text"))
|
|
459
|
+
.map(([cle]) => cle);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Les valeurs de départ d'une création. */
|
|
463
|
+
export function resourceDefaults<T>(resource: ResourceDefinition<T>): FormShape {
|
|
464
|
+
const valeurs: FormShape = {};
|
|
465
|
+
for (const [cle, champ] of entrees(resource)) {
|
|
466
|
+
if (!estVisible(champ as ResourceField<never>, "form")) continue;
|
|
467
|
+
valeurs[cle] =
|
|
468
|
+
champ.defaultValue ??
|
|
469
|
+
(champ.type === "checkbox" || champ.type === "switch"
|
|
470
|
+
? false
|
|
471
|
+
: champ.type === "multiselect" || champ.type === "tags"
|
|
472
|
+
? []
|
|
473
|
+
: "");
|
|
474
|
+
}
|
|
475
|
+
return valeurs;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Comment lire la clé d'une ligne. */
|
|
479
|
+
export function resourceRowKey<T>(
|
|
480
|
+
resource: ResourceDefinition<T>,
|
|
481
|
+
): (row: T, index: number) => Key {
|
|
482
|
+
const cle = resource.key ?? ("id" as keyof T & string);
|
|
483
|
+
if (typeof cle === "function") return cle;
|
|
484
|
+
return (row, index) => {
|
|
485
|
+
const valeur = (row as Record<string, unknown>)[cle];
|
|
486
|
+
return typeof valeur === "string" || typeof valeur === "number"
|
|
487
|
+
? valeur
|
|
488
|
+
: index;
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Les droits des quatre opérations.
|
|
494
|
+
*
|
|
495
|
+
* `"auto"` applique la convention `<ressource>.<verbe>` — la seule chose
|
|
496
|
+
* qu'une convention doit faire : éviter d'écrire quatre fois ce qui se
|
|
497
|
+
* déduit une fois.
|
|
498
|
+
*/
|
|
499
|
+
export function resourcePermissions<T>(resource: ResourceDefinition<T>): {
|
|
500
|
+
create?: PermissionRule;
|
|
501
|
+
view?: PermissionRule;
|
|
502
|
+
edit?: PermissionRule;
|
|
503
|
+
delete?: PermissionRule;
|
|
504
|
+
} {
|
|
505
|
+
if (resource.permissions === "auto") {
|
|
506
|
+
return {
|
|
507
|
+
create: `${resource.name}.creer`,
|
|
508
|
+
view: `${resource.name}.lire`,
|
|
509
|
+
edit: `${resource.name}.modifier`,
|
|
510
|
+
delete: `${resource.name}.supprimer`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return resource.permissions ?? {};
|
|
514
|
+
}
|
|
@@ -2,4 +2,11 @@ import { useEffect, useRef, type HTMLAttributes } from "react";
|
|
|
2
2
|
import { cn } from "@sia-ui/utils";
|
|
3
3
|
import "./styles.css";
|
|
4
4
|
export interface RichTextEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> { value?: string; defaultValue?: string; onValueChange?: (html: string) => void; placeholder?: string; disabled?: boolean; }
|
|
5
|
+
/**
|
|
6
|
+
* Une saisie de texte mis en forme.
|
|
7
|
+
*
|
|
8
|
+
* Elle rend du HTML, pas une structure à elle : ce qui sort se stocke, se
|
|
9
|
+
* renvoie et s'affiche partout ailleurs sans qu'un convertisseur soit
|
|
10
|
+
* nécessaire.
|
|
11
|
+
*/
|
|
5
12
|
export function RichTextEditor({ value, defaultValue = "", onValueChange, placeholder = "Saisir du contenu...", disabled, className, ...props }: RichTextEditorProps) { const editorRef = useRef<HTMLDivElement>(null); useEffect(() => { if (value !== undefined && editorRef.current && editorRef.current.innerHTML !== value) editorRef.current.innerHTML = value; }, [value]); const command = (name: string) => { editorRef.current?.focus(); document.execCommand(name); onValueChange?.(editorRef.current?.innerHTML ?? ""); }; return <div className={cn("sia-rich-text-editor", disabled && "sia-rich-text-editor--disabled", className)}><div className="sia-editor-toolbar"><button type="button" disabled={disabled} aria-label="Gras" onClick={() => command("bold")}><strong>B</strong></button><button type="button" disabled={disabled} aria-label="Italique" onClick={() => command("italic")}><em>I</em></button><button type="button" disabled={disabled} aria-label="Liste" onClick={() => command("insertUnorderedList")}>• Liste</button></div><div {...props} ref={editorRef} role="textbox" aria-multiline="true" contentEditable={!disabled} suppressContentEditableWarning data-placeholder={placeholder} dangerouslySetInnerHTML={value === undefined ? { __html: defaultValue } : undefined} onInput={(event) => onValueChange?.(event.currentTarget.innerHTML)} /></div>; }
|
|
@@ -30,6 +30,14 @@ export interface SearchInputProps
|
|
|
30
30
|
clearLabel?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Une recherche, avec son délai.
|
|
35
|
+
*
|
|
36
|
+
* `searchDelay` vaut zéro par défaut : la recherche ne part qu'à la
|
|
37
|
+
* validation. C'est le réglage sûr — une requête à chaque frappe se paie en
|
|
38
|
+
* appels serveur, et le défaut ne doit pas engager ce coût sans qu'on l'ait
|
|
39
|
+
* demandé.
|
|
40
|
+
*/
|
|
33
41
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
|
34
42
|
(
|
|
35
43
|
{
|
|
@@ -75,6 +75,17 @@ function optionText(option: SelectOption) {
|
|
|
75
75
|
.toLocaleLowerCase();
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Un choix parmi une liste.
|
|
80
|
+
*
|
|
81
|
+
* Le champ n'est pas un `<select>` natif : celui-ci ne sait ni chercher, ni
|
|
82
|
+
* afficher une description sous chaque option, ni charger ses choix à la
|
|
83
|
+
* demande. Le comportement clavier du natif, lui, est reproduit — flèches,
|
|
84
|
+
* première lettre, échappement.
|
|
85
|
+
*
|
|
86
|
+
* `fetcher` sert les listes qu'on ne peut pas descendre en entier : la
|
|
87
|
+
* requête part après la frappe et s'annule si l'on continue à taper.
|
|
88
|
+
*/
|
|
78
89
|
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
|
|
79
90
|
{
|
|
80
91
|
id,
|