@ogygie/ui 0.0.0-stage → 0.16.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 -0
- package/README.md +60 -2
- package/package.json +59 -3
- package/src/CharacterCount.tsx +17 -0
- package/src/ContractForm.test.tsx +121 -0
- package/src/ContractForm.tsx +269 -0
- package/src/DataTable.test.tsx +84 -0
- package/src/DataTable.tsx +161 -0
- package/src/EditableText.test.tsx +107 -0
- package/src/EditableText.tsx +117 -0
- package/src/EmptyState.test.tsx +25 -0
- package/src/EmptyState.tsx +30 -0
- package/src/ErrorBanner.test.tsx +46 -0
- package/src/ErrorBanner.tsx +58 -0
- package/src/ScreenState.test.tsx +104 -0
- package/src/ScreenState.tsx +102 -0
- package/src/VarianceChart.test.tsx +45 -0
- package/src/VarianceChart.tsx +148 -0
- package/src/contract-fields.test.ts +100 -0
- package/src/contract-fields.ts +274 -0
- package/src/failures.test.ts +31 -0
- package/src/failures.ts +39 -0
- package/src/fetch-contract.test.ts +121 -0
- package/src/fetch-contract.ts +157 -0
- package/src/index.ts +32 -0
- package/src/published-contract.test.ts +84 -0
- package/src/query-like.ts +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 x10sion LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ogygie/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Le socle front du framework **Ogygie** (DECISIONS.md D39) : les briques
|
|
4
|
+
d'écran que le starter prescrivait sans les livrer. Un produit les IMPORTE ;
|
|
5
|
+
il ne les recopie plus dans `apps/web/src/patterns/`, qui ne garde que ses
|
|
6
|
+
patterns propres. Le paquet livre ses sources TypeScript (comme `@ogygie/core`)
|
|
7
|
+
et ne porte **aucune chaîne lue par un humain** : tout texte lui arrive en
|
|
8
|
+
prop, traduit par la feature (Lingui).
|
|
9
|
+
|
|
10
|
+
| Point d'entrée | Ce qu'il porte | Peer |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `@ogygie/ui` | `ScreenState` (accès refusé → erreur → chargement → vide → corps), `EmptyState`, `ErrorBanner`, `EditableText`, `Skeleton`, `fetchContract` / `createContractFetcher`, `QueryLike` / `MutationLike` et leurs fixtures | `react`, `zod` (`@ogygie/core` est une DÉPENDANCE, à la version du lockstep) |
|
|
13
|
+
| `@ogygie/ui/data-table` | `DataTable` (colonnes typées, tri, chiffres alignés) + `DataTable.Skeleton` | `@tanstack/react-table` (optionnel) |
|
|
14
|
+
| `@ogygie/ui/contract-form` | `ContractForm` (formulaire dérivé d'un contrat Zod) + `formFieldsOf` | `react-hook-form` (optionnel) |
|
|
15
|
+
| `@ogygie/ui/variance-chart` | `VarianceChart` (constaté contre référence) + `VarianceChart.Skeleton` | `recharts` (optionnel) |
|
|
16
|
+
|
|
17
|
+
## Thème
|
|
18
|
+
|
|
19
|
+
Les composants portent les utilitaires Tailwind des tokens du starter
|
|
20
|
+
(`surface`, `surface-raised`, `border`, `text`, `text-muted`, `accent`,
|
|
21
|
+
`accent-contrast`, `positive`, `warning`, `danger`, `focus`, les rayons
|
|
22
|
+
`sm`/`md`/`lg`). La feuille Tailwind du produit déclare le paquet comme
|
|
23
|
+
source, sinon ces classes ne sont pas générées :
|
|
24
|
+
|
|
25
|
+
```css
|
|
26
|
+
@import "tailwindcss";
|
|
27
|
+
@source "../node_modules/@ogygie/ui/src";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Les couleurs du graphique passent par les variables CSS du thème
|
|
31
|
+
(`--color-accent`, `--color-text-muted`, `--color-border`).
|
|
32
|
+
|
|
33
|
+
## Un écran
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { ScreenState } from "@ogygie/ui";
|
|
37
|
+
import { DataTable } from "@ogygie/ui/data-table";
|
|
38
|
+
|
|
39
|
+
export function OrdersList() {
|
|
40
|
+
return (
|
|
41
|
+
<ScreenState
|
|
42
|
+
query={useOrders()}
|
|
43
|
+
errorTitle={<Trans>This view could not be loaded</Trans>}
|
|
44
|
+
loading={<DataTable.Skeleton />}
|
|
45
|
+
isEmpty={(data) => data.orders.length === 0}
|
|
46
|
+
emptyState={{ title: <Trans>No order yet</Trans>, explanation: <Trans>Orders appear here once recorded.</Trans> }}
|
|
47
|
+
>
|
|
48
|
+
{(data) => <DataTable rows={data.orders} columns={orderColumns} rowKey={(order) => order.id} />}
|
|
49
|
+
</ScreenState>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Le corps d'un écran reçoit des `QueryLike` et des `MutationLike`, jamais un
|
|
55
|
+
hook : il se teste en rendu, sans client de requêtes ni réseau.
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
Les tests du paquet tournent sous jsdom (`@testing-library/react`), un par
|
|
60
|
+
état de chaque pattern : `pnpm vitest run packages/ogygie-ui` depuis le repo
|
|
61
|
+
du framework.
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"description": "Ogygie front socle: the five prescribed patterns (DataTable, EmptyState, ErrorBanner, ContractForm, VarianceChart), one screen-state component (ScreenState), fetchContract and EditableText. Ships TypeScript sources — Ogygie products run Vite/tsx/vitest by construction (D1); a product imports them, it no longer copies them (D39).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./data-table": "./src/DataTable.tsx",
|
|
18
|
+
"./contract-form": "./src/ContractForm.tsx",
|
|
19
|
+
"./variance-chart": "./src/VarianceChart.tsx"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@ogygie/core": "0.16.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@tanstack/react-table": "^9.0.0",
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"react-dom": "^19.0.0",
|
|
28
|
+
"react-hook-form": "^7.60.0",
|
|
29
|
+
"recharts": "^2.15.0 || ^3.0.0",
|
|
30
|
+
"zod": "^4.0.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"@tanstack/react-table": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"react-hook-form": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"recharts": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@tanstack/react-table": "9.2.4",
|
|
45
|
+
"@testing-library/dom": "^10.4.1",
|
|
46
|
+
"@testing-library/react": "^16.3.2",
|
|
47
|
+
"@types/node": "^24.13.3",
|
|
48
|
+
"@types/react": "^19.2.18",
|
|
49
|
+
"@types/react-dom": "^19.2.4",
|
|
50
|
+
"jsdom": "^30.0.1",
|
|
51
|
+
"react": "^19.2.8",
|
|
52
|
+
"react-dom": "^19.2.8",
|
|
53
|
+
"react-hook-form": "^7.90.0",
|
|
54
|
+
"recharts": "^3.10.1",
|
|
55
|
+
"typescript": "^5.9.3",
|
|
56
|
+
"vitest": "^3.2.7",
|
|
57
|
+
"zod": "^4.4.3"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
61
|
+
}
|
|
6
62
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent How much of a bounded field is used, `12 / 80`, turning to the
|
|
3
|
+
* warning colour at the bound — figures only, nothing to translate. Rendered
|
|
4
|
+
* by EditableText and ContractForm next to a field whose contract declares a
|
|
5
|
+
* maximum length (D39).
|
|
6
|
+
*/
|
|
7
|
+
export function CharacterCount({ length, max }: { length: number; max: number }) {
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
role="status"
|
|
11
|
+
data-pattern="CharacterCount"
|
|
12
|
+
className={`ml-2 text-xs tabular-nums ${length >= max ? "text-warning" : "text-text-muted"}`}
|
|
13
|
+
>
|
|
14
|
+
{length} / {max}
|
|
15
|
+
</span>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* @intent Proves the contract-derived form (spec ogygie-ui B6): controls from
|
|
4
|
+
* the schema, labels and option labels from the product, a refused submission
|
|
5
|
+
* showing one sentence per faulty field and calling nothing, an accepted one
|
|
6
|
+
* handing the PARSED output over, the pending and server-refusal states, the
|
|
7
|
+
* form-level issue of a refine.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
|
|
13
|
+
import { ContractForm } from "./ContractForm.js";
|
|
14
|
+
|
|
15
|
+
const CreateInputSchema = z.object({
|
|
16
|
+
label: z.string().trim().min(1).max(80),
|
|
17
|
+
horizon: z.string().regex(/^2\d{3}-Q[1-4]$/, "expected a quarter such as 2027-Q2"),
|
|
18
|
+
level: z.number().int().min(1).max(4).nullable(),
|
|
19
|
+
tier: z.enum(["A", "B"]),
|
|
20
|
+
active: z.boolean(),
|
|
21
|
+
weight: z.number().positive().default(1),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const labels = { label: "Name", horizon: "Target quarter", level: "Level", tier: "Tier", active: "Active", weight: "Weight" };
|
|
25
|
+
|
|
26
|
+
describe("ContractForm", () => {
|
|
27
|
+
afterEach(cleanup);
|
|
28
|
+
|
|
29
|
+
it("ÉTANT DONNÉ le contrat ALORS un contrôle par champ, du bon type, avec son libellé et sa marque de requis", () => {
|
|
30
|
+
render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} optionLabels={{ tier: { A: "Tier A", B: "Tier B" } }} />);
|
|
31
|
+
expect((screen.getByLabelText(/Name/)).getAttribute("type")).toBe("text");
|
|
32
|
+
expect((screen.getByLabelText(/Name/)).getAttribute("maxlength")).toBe("80");
|
|
33
|
+
expect((screen.getByLabelText(/Target quarter/)).getAttribute("pattern")).toBe("^2\\d{3}-Q[1-4]$");
|
|
34
|
+
const level = screen.getByLabelText(/Level/);
|
|
35
|
+
expect((level).getAttribute("type")).toBe("number");
|
|
36
|
+
expect((level).getAttribute("min")).toBe("1");
|
|
37
|
+
expect((level).getAttribute("max")).toBe("4");
|
|
38
|
+
expect((level).getAttribute("step")).toBe("1");
|
|
39
|
+
expect(screen.getByLabelText(/Tier/).tagName).toBe("SELECT");
|
|
40
|
+
expect(screen.getByRole("option", { name: "Tier B" })).toBeDefined();
|
|
41
|
+
expect((screen.getByLabelText("Active")).getAttribute("type")).toBe("checkbox");
|
|
42
|
+
expect(screen.getByLabelText(/Name/).closest("[data-field]")?.textContent).toContain("*");
|
|
43
|
+
expect(screen.getByLabelText(/Weight/).closest("[data-field]")?.textContent).not.toContain("*");
|
|
44
|
+
expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("QUAND la soumission viole le contrat ALORS une phrase par champ fautif, et rien n'est envoyé", async () => {
|
|
48
|
+
const onSubmit = vi.fn();
|
|
49
|
+
render(<ContractForm schema={CreateInputSchema} onSubmit={onSubmit} submitLabel="Create" labels={labels} />);
|
|
50
|
+
fireEvent.change(screen.getByLabelText(/Target quarter/), { target: { value: "soon" } });
|
|
51
|
+
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
|
|
52
|
+
const alerts = await screen.findAllByRole("alert");
|
|
53
|
+
expect(alerts.map((alert) => alert.textContent)).toContain("expected a quarter such as 2027-Q2");
|
|
54
|
+
expect((screen.getByLabelText(/Target quarter/)).getAttribute("aria-invalid")).toBe("true");
|
|
55
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("QUAND la soumission respecte le contrat ALORS onSubmit reçoit la sortie parsée (nombres, null, défauts)", async () => {
|
|
59
|
+
const onSubmit = vi.fn();
|
|
60
|
+
render(<ContractForm schema={CreateInputSchema} onSubmit={onSubmit} submitLabel="Create" labels={labels} />);
|
|
61
|
+
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: " First grade " } });
|
|
62
|
+
fireEvent.change(screen.getByLabelText(/Target quarter/), { target: { value: "2027-Q2" } });
|
|
63
|
+
fireEvent.change(screen.getByLabelText(/Tier/), { target: { value: "B" } });
|
|
64
|
+
fireEvent.click(screen.getByLabelText("Active"));
|
|
65
|
+
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
|
|
66
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
67
|
+
expect(onSubmit).toHaveBeenCalledWith({ label: "First grade", horizon: "2027-Q2", level: null, tier: "B", active: true, weight: 1 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("ÉTANT DONNÉ renderIssue ALORS le produit traduit la phrase d'un champ", async () => {
|
|
71
|
+
render(
|
|
72
|
+
<ContractForm
|
|
73
|
+
schema={CreateInputSchema}
|
|
74
|
+
onSubmit={() => undefined}
|
|
75
|
+
submitLabel="Create"
|
|
76
|
+
labels={labels}
|
|
77
|
+
renderIssue={(issue, field) => `${field?.name ?? "form"}: ${issue.code}`}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
|
|
81
|
+
const alerts = await screen.findAllByRole("alert");
|
|
82
|
+
expect(alerts.map((alert) => alert.textContent)).toContain("label: too_small");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ÉTANT DONNÉ un refine sur l'objet ALORS la phrase se lit au niveau du formulaire", async () => {
|
|
86
|
+
const UpdateSchema = z
|
|
87
|
+
.object({ label: z.string().optional(), horizon: z.string().optional() })
|
|
88
|
+
.refine((body) => Object.values(body).some((value) => value !== undefined), { message: "at least one field must be sent" });
|
|
89
|
+
const onSubmit = vi.fn();
|
|
90
|
+
render(<ContractForm schema={UpdateSchema} onSubmit={onSubmit} submitLabel="Save" />);
|
|
91
|
+
fireEvent.submit(screen.getByRole("button", { name: "Save" }).closest("form") as HTMLFormElement);
|
|
92
|
+
expect((await screen.findAllByRole("alert")).map((alert) => alert.textContent)).toContain("at least one field must be sent");
|
|
93
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ÉTANT DONNÉ pending ALORS le bouton est inactif ; ÉTANT DONNÉ error ALORS un bandeau save au-dessus des actions", () => {
|
|
97
|
+
const refusal = new OgygieFailure(ogygieError({ code: "GRADE_CODE_ALREADY_USED", message: "This code is already used.", file: null, cause: "first-grade", suggestedFix: "Pick another label." }), 409);
|
|
98
|
+
render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} pending error={refusal} errorTitle="This change could not be saved" />);
|
|
99
|
+
expect((screen.getByRole("button", { name: "Create" }) as HTMLButtonElement).disabled).toBe(true);
|
|
100
|
+
expect((screen.getByRole("alert")).getAttribute("data-code")).toBe("GRADE_CODE_ALREADY_USED");
|
|
101
|
+
expect((screen.getByRole("alert")).getAttribute("data-kind")).toBe("save");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ÉTANT DONNÉ fields ALORS seuls ces champs se montrent, dans cet ordre, et un cancel est un bouton", () => {
|
|
105
|
+
const onCancel = vi.fn();
|
|
106
|
+
render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} fields={["horizon", "label"]} cancel={{ label: "Cancel", onClick: onCancel }} />);
|
|
107
|
+
const shown = Array.from(document.querySelectorAll("[data-field]")).map((node) => node.getAttribute("data-field"));
|
|
108
|
+
expect(shown).toEqual(["horizon", "label"]);
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
110
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ÉTANT DONNÉ resetOnSubmit ALORS le formulaire se vide après une soumission acceptée", async () => {
|
|
114
|
+
const Simple = z.object({ label: z.string().min(1) });
|
|
115
|
+
render(<ContractForm schema={Simple} onSubmit={() => undefined} submitLabel="Create" resetOnSubmit />);
|
|
116
|
+
const input = screen.getByLabelText(/label/);
|
|
117
|
+
fireEvent.change(input, { target: { value: "Grade" } });
|
|
118
|
+
fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
|
|
119
|
+
await waitFor(() => expect((input as HTMLInputElement).value).toBe(""));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent A form DERIVED from a Zod contract (D39): the fields come from the
|
|
3
|
+
* schema's shape, their controls from their kinds, their constraints from the
|
|
4
|
+
* schema's own bounds — nothing is redeclared by hand — and the schema itself
|
|
5
|
+
* validates every submission (`safeParse`, through a react-hook-form resolver
|
|
6
|
+
* written here, no extra dependency). States: typing, submitting (button
|
|
7
|
+
* inactive), field errors (one sentence under the field, from the Zod issue,
|
|
8
|
+
* translated by the product through `renderIssue`), server refusal (an
|
|
9
|
+
* ErrorBanner `kind: save` above the actions). Every text is a prop: labels,
|
|
10
|
+
* hints, option labels, the submit and cancel labels. react-hook-form is an
|
|
11
|
+
* OPTIONAL peer carried by this entry point only (`@ogygie/ui/contract-form`).
|
|
12
|
+
*/
|
|
13
|
+
import { useId, useMemo, useRef, type ReactNode } from "react";
|
|
14
|
+
import { useForm, type FieldErrors, type Resolver } from "react-hook-form";
|
|
15
|
+
import type { z } from "zod";
|
|
16
|
+
import type { $ZodIssue } from "zod/v4/core";
|
|
17
|
+
import { CharacterCount } from "./CharacterCount.js";
|
|
18
|
+
import { ErrorBanner } from "./ErrorBanner.js";
|
|
19
|
+
import { coerceFormValues, formFieldsOf, type FormField } from "./contract-fields.js";
|
|
20
|
+
|
|
21
|
+
type FormValues = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface ContractFormProps<TSchema extends z.ZodType<Record<string, unknown>, Record<string, unknown>>> {
|
|
24
|
+
schema: TSchema;
|
|
25
|
+
/** Receives the PARSED output of the contract — defaults applied, transforms run. */
|
|
26
|
+
onSubmit: (values: z.output<TSchema>) => void | Promise<void>;
|
|
27
|
+
submitLabel: ReactNode;
|
|
28
|
+
defaultValues?: Partial<z.input<TSchema>>;
|
|
29
|
+
/** The label of each field, translated. A field without one shows its name. */
|
|
30
|
+
labels?: Partial<Record<keyof z.input<TSchema> & string, ReactNode>>;
|
|
31
|
+
/** A sentence under a field. */
|
|
32
|
+
hints?: Partial<Record<keyof z.input<TSchema> & string, ReactNode>>;
|
|
33
|
+
/** The label of each option of a `select` field, by option value. */
|
|
34
|
+
optionLabels?: Partial<Record<keyof z.input<TSchema> & string, Record<string, ReactNode>>>;
|
|
35
|
+
/** Which fields to show, in which order. Default: every field of the shape, in its order. A hidden field still validates (its default value is sent). */
|
|
36
|
+
fields?: ReadonlyArray<keyof z.input<TSchema> & string>;
|
|
37
|
+
/** The write in flight outside the form (a mutation): the submit stays inactive. */
|
|
38
|
+
pending?: boolean;
|
|
39
|
+
/** The server's refusal, shown as a `save` banner above the actions. */
|
|
40
|
+
error?: unknown;
|
|
41
|
+
errorTitle?: ReactNode;
|
|
42
|
+
cancel?: { label: ReactNode; onClick: () => void };
|
|
43
|
+
/** The sentence for a Zod issue. Default: the issue's own message. */
|
|
44
|
+
renderIssue?: (issue: $ZodIssue, field: FormField | null) => ReactNode;
|
|
45
|
+
/** Empties the form after a submission the contract accepted. */
|
|
46
|
+
resetOnSubmit?: boolean;
|
|
47
|
+
/** Focus the first field when the form appears. */
|
|
48
|
+
autoFocus?: boolean;
|
|
49
|
+
id?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CONTROL_CLASS =
|
|
53
|
+
"w-full rounded-sm border bg-surface-raised px-2 py-1.5 text-sm text-text outline-none focus:border-focus";
|
|
54
|
+
|
|
55
|
+
function controlClassOf(invalid: boolean): string {
|
|
56
|
+
return `${CONTROL_CLASS} ${invalid ? "border-danger" : "border-border"}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ContractForm<TSchema extends z.ZodType<Record<string, unknown>, Record<string, unknown>>>(
|
|
60
|
+
props: ContractFormProps<TSchema>,
|
|
61
|
+
) {
|
|
62
|
+
const { schema, pending = false, resetOnSubmit = false, autoFocus = false } = props;
|
|
63
|
+
const generatedId = useId();
|
|
64
|
+
const formId = props.id ?? generatedId;
|
|
65
|
+
const allFields = useMemo(() => formFieldsOf(schema), [schema]);
|
|
66
|
+
const shownNames = props.fields;
|
|
67
|
+
const fields = useMemo(
|
|
68
|
+
() =>
|
|
69
|
+
shownNames === undefined
|
|
70
|
+
? allFields
|
|
71
|
+
: shownNames.flatMap((name) => allFields.filter((field) => field.name === name)),
|
|
72
|
+
[allFields, shownNames],
|
|
73
|
+
);
|
|
74
|
+
// The issues of the last validation, by field — what the error sentences render from.
|
|
75
|
+
const issuesRef = useRef<Map<string, $ZodIssue>>(new Map());
|
|
76
|
+
|
|
77
|
+
const resolver: Resolver<FormValues, unknown, FormValues> = (raw) => {
|
|
78
|
+
const values = coerceFormValues(allFields, raw);
|
|
79
|
+
const parsed = schema.safeParse(values);
|
|
80
|
+
if (parsed.success) {
|
|
81
|
+
issuesRef.current = new Map();
|
|
82
|
+
return { values: parsed.data, errors: {} };
|
|
83
|
+
}
|
|
84
|
+
const errors: FieldErrors<FormValues> = {};
|
|
85
|
+
const issues = new Map<string, $ZodIssue>();
|
|
86
|
+
for (const issue of parsed.error.issues) {
|
|
87
|
+
const key = typeof issue.path[0] === "string" ? issue.path[0] : "root";
|
|
88
|
+
if (issues.has(key)) continue;
|
|
89
|
+
issues.set(key, issue);
|
|
90
|
+
errors[key] = { type: issue.code, message: issue.message };
|
|
91
|
+
}
|
|
92
|
+
issuesRef.current = issues;
|
|
93
|
+
return { values: {}, errors };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const initialValues: FormValues = {};
|
|
97
|
+
for (const field of allFields) {
|
|
98
|
+
const given = (props.defaultValues as Record<string, unknown> | undefined)?.[field.name];
|
|
99
|
+
initialValues[field.name] = given ?? field.defaultValue ?? (field.kind === "checkbox" ? false : "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const form = useForm<FormValues, unknown, FormValues>({ resolver, defaultValues: initialValues, mode: "onTouched" });
|
|
103
|
+
const { register, handleSubmit, formState, reset, watch } = form;
|
|
104
|
+
const renderIssue = props.renderIssue ?? ((issue: $ZodIssue) => issue.message);
|
|
105
|
+
const rootIssue = issuesRef.current.get("root");
|
|
106
|
+
const busy = formState.isSubmitting || pending;
|
|
107
|
+
|
|
108
|
+
const submit = handleSubmit(async (values) => {
|
|
109
|
+
await props.onSubmit(values as z.output<TSchema>);
|
|
110
|
+
if (resetOnSubmit) reset(initialValues);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<form
|
|
115
|
+
id={formId}
|
|
116
|
+
data-pattern="ContractForm"
|
|
117
|
+
noValidate
|
|
118
|
+
aria-busy={busy ? "true" : undefined}
|
|
119
|
+
onSubmit={(event) => {
|
|
120
|
+
void submit(event);
|
|
121
|
+
}}
|
|
122
|
+
className="flex flex-col gap-4"
|
|
123
|
+
>
|
|
124
|
+
{fields.map((field, index) => {
|
|
125
|
+
const inputId = `${formId}-${field.name}`;
|
|
126
|
+
const fieldError = formState.errors[field.name];
|
|
127
|
+
const issue = issuesRef.current.get(field.name);
|
|
128
|
+
const invalid = fieldError !== undefined;
|
|
129
|
+
const hint = props.hints?.[field.name as keyof z.input<TSchema> & string];
|
|
130
|
+
const describedBy = [hint === undefined ? null : `${inputId}-hint`, invalid ? `${inputId}-error` : null]
|
|
131
|
+
.filter((part): part is string => part !== null)
|
|
132
|
+
.join(" ");
|
|
133
|
+
const label = props.labels?.[field.name as keyof z.input<TSchema> & string] ?? field.title ?? field.name;
|
|
134
|
+
const shared = {
|
|
135
|
+
id: inputId,
|
|
136
|
+
"aria-invalid": invalid ? true : undefined,
|
|
137
|
+
"aria-describedby": describedBy === "" ? undefined : describedBy,
|
|
138
|
+
disabled: busy,
|
|
139
|
+
autoFocus: autoFocus && index === 0,
|
|
140
|
+
};
|
|
141
|
+
let control: ReactNode;
|
|
142
|
+
switch (field.kind) {
|
|
143
|
+
case "checkbox":
|
|
144
|
+
control = (
|
|
145
|
+
<input type="checkbox" {...shared} {...register(field.name)} className="h-4 w-4 rounded-sm border-border accent-accent" />
|
|
146
|
+
);
|
|
147
|
+
break;
|
|
148
|
+
case "select": {
|
|
149
|
+
const optionLabels = props.optionLabels?.[field.name as keyof z.input<TSchema> & string];
|
|
150
|
+
control = (
|
|
151
|
+
<select {...shared} {...register(field.name)} className={controlClassOf(invalid)}>
|
|
152
|
+
<option value="">{""}</option>
|
|
153
|
+
{field.options.map((option) => (
|
|
154
|
+
<option key={String(option.value)} value={String(option.value)}>
|
|
155
|
+
{optionLabels?.[String(option.value)] ?? String(option.value)}
|
|
156
|
+
</option>
|
|
157
|
+
))}
|
|
158
|
+
</select>
|
|
159
|
+
);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "textarea": {
|
|
163
|
+
const length = String(watch(field.name) ?? "").length;
|
|
164
|
+
control = (
|
|
165
|
+
<>
|
|
166
|
+
<textarea
|
|
167
|
+
{...shared}
|
|
168
|
+
{...register(field.name)}
|
|
169
|
+
rows={4}
|
|
170
|
+
{...(field.maxLength === null ? {} : { maxLength: field.maxLength })}
|
|
171
|
+
className={controlClassOf(invalid)}
|
|
172
|
+
/>
|
|
173
|
+
{field.maxLength !== null && <CharacterCount length={length} max={field.maxLength} />}
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "number":
|
|
179
|
+
case "integer":
|
|
180
|
+
control = (
|
|
181
|
+
<input
|
|
182
|
+
type="number"
|
|
183
|
+
{...shared}
|
|
184
|
+
{...register(field.name, { valueAsNumber: true })}
|
|
185
|
+
{...(field.minimum === null ? {} : { min: field.minimum })}
|
|
186
|
+
{...(field.maximum === null ? {} : { max: field.maximum })}
|
|
187
|
+
step={field.kind === "integer" ? 1 : "any"}
|
|
188
|
+
className={`${controlClassOf(invalid)} text-right tabular-nums`}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
break;
|
|
192
|
+
case "unsupported":
|
|
193
|
+
control = (
|
|
194
|
+
<p className="text-xs text-warning" data-unsupported={field.typeName}>
|
|
195
|
+
{field.name}: {field.typeName}
|
|
196
|
+
</p>
|
|
197
|
+
);
|
|
198
|
+
break;
|
|
199
|
+
default: {
|
|
200
|
+
const type =
|
|
201
|
+
field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "date" ? "date" : field.kind === "datetime" ? "datetime-local" : "text";
|
|
202
|
+
control = (
|
|
203
|
+
<input
|
|
204
|
+
type={type}
|
|
205
|
+
{...shared}
|
|
206
|
+
{...register(field.name)}
|
|
207
|
+
{...(field.maxLength === null ? {} : { maxLength: field.maxLength })}
|
|
208
|
+
{...(field.pattern === null ? {} : { pattern: field.pattern })}
|
|
209
|
+
className={controlClassOf(invalid)}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return (
|
|
215
|
+
<div key={field.name} data-field={field.name} className="flex flex-col gap-1">
|
|
216
|
+
<label htmlFor={inputId} className="text-sm font-medium text-text">
|
|
217
|
+
{label}
|
|
218
|
+
{field.required && field.kind !== "checkbox" && (
|
|
219
|
+
<span aria-hidden="true" className="text-danger">
|
|
220
|
+
{" *"}
|
|
221
|
+
</span>
|
|
222
|
+
)}
|
|
223
|
+
</label>
|
|
224
|
+
{control}
|
|
225
|
+
{hint !== undefined && (
|
|
226
|
+
<p id={`${inputId}-hint`} className="text-xs text-text-muted">
|
|
227
|
+
{hint}
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
{invalid && (
|
|
231
|
+
<p id={`${inputId}-error`} role="alert" className="text-xs text-danger">
|
|
232
|
+
{issue === undefined ? fieldError.message : renderIssue(issue, field)}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
{rootIssue !== undefined && (
|
|
239
|
+
<p role="alert" className="text-xs text-danger">
|
|
240
|
+
{renderIssue(rootIssue, null)}
|
|
241
|
+
</p>
|
|
242
|
+
)}
|
|
243
|
+
{props.error != null && (
|
|
244
|
+
<ErrorBanner error={props.error} kind="save" {...(props.errorTitle === undefined ? {} : { title: props.errorTitle })} />
|
|
245
|
+
)}
|
|
246
|
+
<div className="flex items-center gap-3">
|
|
247
|
+
<button
|
|
248
|
+
type="submit"
|
|
249
|
+
disabled={busy}
|
|
250
|
+
className="rounded-sm bg-accent px-4 py-1.5 text-sm font-medium text-accent-contrast hover:opacity-90 disabled:opacity-60"
|
|
251
|
+
>
|
|
252
|
+
{props.submitLabel}
|
|
253
|
+
</button>
|
|
254
|
+
{props.cancel !== undefined && (
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={props.cancel.onClick}
|
|
258
|
+
disabled={formState.isSubmitting}
|
|
259
|
+
className="rounded-sm border border-border bg-surface px-4 py-1.5 text-sm font-medium text-text hover:bg-surface-raised"
|
|
260
|
+
>
|
|
261
|
+
{props.cancel.label}
|
|
262
|
+
</button>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</form>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export { formFieldsOf, coerceFormValues, type FormField, type FormFieldKind, type FormFieldOption } from "./contract-fields.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* @intent Proves the data table (spec ogygie-ui B7): headers and cells from
|
|
4
|
+
* typed columns, sorting toggled on a header with its aria-sort, figures
|
|
5
|
+
* right-aligned, the empty state delegated to the caller, a skeleton for the
|
|
6
|
+
* loading state.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
10
|
+
import { DataTable, DataTableSkeleton, type DataTableColumn } from "./DataTable.js";
|
|
11
|
+
|
|
12
|
+
interface Row {
|
|
13
|
+
code: string;
|
|
14
|
+
label: string;
|
|
15
|
+
overall: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ROWS: Row[] = [
|
|
19
|
+
{ code: "second-grade", label: "Second grade", overall: 2.5 },
|
|
20
|
+
{ code: "first-grade", label: "First grade", overall: 3.2 },
|
|
21
|
+
{ code: "third-grade", label: "Third grade", overall: null },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const COLUMNS: DataTableColumn<Row>[] = [
|
|
25
|
+
{ id: "label", header: "Label", cell: (row) => row.label, sortValue: (row) => row.label },
|
|
26
|
+
{ id: "overall", header: "Overall", cell: (row) => (row.overall === null ? "—" : row.overall.toFixed(1)), sortValue: (row) => row.overall, align: "end" },
|
|
27
|
+
{ id: "actions", header: "Actions", cell: (row) => <button type="button">{`Delete ${row.code}`}</button> },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const bodyLabels = (): string[] =>
|
|
31
|
+
within(screen.getByRole("table")).getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[0]?.textContent ?? "");
|
|
32
|
+
|
|
33
|
+
describe("DataTable", () => {
|
|
34
|
+
afterEach(cleanup);
|
|
35
|
+
|
|
36
|
+
it("ÉTANT DONNÉ des colonnes typées ALORS en-têtes et cellules, dans l'ordre servi", () => {
|
|
37
|
+
render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} caption="Grades" />);
|
|
38
|
+
expect(screen.getAllByRole("columnheader").map((header) => header.textContent?.replace(/[▲▼⇅]/g, ""))).toEqual(["Label", "Overall", "Actions"]);
|
|
39
|
+
expect(bodyLabels()).toEqual(["Second grade", "First grade", "Third grade"]);
|
|
40
|
+
expect(screen.getByRole("button", { name: "Delete first-grade" })).toBeDefined();
|
|
41
|
+
expect(screen.getByRole("table").querySelector("caption")?.textContent).toBe("Grades");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("QUAND un en-tête triable est cliqué ALORS les lignes se trient et aria-sort le dit ; un second clic inverse", () => {
|
|
45
|
+
render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} />);
|
|
46
|
+
const labelHeader = screen.getAllByRole("columnheader")[0] as HTMLElement;
|
|
47
|
+
fireEvent.click(within(labelHeader).getByRole("button"));
|
|
48
|
+
expect((labelHeader).getAttribute("aria-sort")).toBe("ascending");
|
|
49
|
+
expect(bodyLabels()).toEqual(["First grade", "Second grade", "Third grade"]);
|
|
50
|
+
fireEvent.click(within(labelHeader).getByRole("button"));
|
|
51
|
+
expect((labelHeader).getAttribute("aria-sort")).toBe("descending");
|
|
52
|
+
expect(bodyLabels()).toEqual(["Third grade", "Second grade", "First grade"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ÉTANT DONNÉ un tri initial sur une colonne de chiffres ALORS les vides vont à la fin, et la colonne est alignée à droite", () => {
|
|
56
|
+
render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} initialSort={{ id: "overall", desc: true }} />);
|
|
57
|
+
expect(bodyLabels()).toEqual(["First grade", "Second grade", "Third grade"]);
|
|
58
|
+
const overallCell = within(screen.getAllByRole("row")[1] as HTMLElement).getAllByRole("cell")[1];
|
|
59
|
+
expect(overallCell?.className).toContain("tabular-nums");
|
|
60
|
+
expect(overallCell?.className).toContain("text-right");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ÉTANT DONNÉ une colonne sans sortValue ALORS son en-tête n'est pas un bouton", () => {
|
|
64
|
+
render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} />);
|
|
65
|
+
const actionsHeader = screen.getAllByRole("columnheader")[2] as HTMLElement;
|
|
66
|
+
expect(within(actionsHeader).queryByRole("button")).toBeNull();
|
|
67
|
+
expect((actionsHeader).hasAttribute("aria-sort")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("ÉTANT DONNÉ aucune ligne et un emptyState ALORS lui seul se rend ; sans lui, les en-têtes seuls", () => {
|
|
71
|
+
const { rerender } = render(<DataTable rows={[]} columns={COLUMNS} rowKey={(row) => row.code} emptyState={<p>No grade yet</p>} />);
|
|
72
|
+
expect(screen.getByText("No grade yet")).toBeDefined();
|
|
73
|
+
expect(screen.queryByRole("table")).toBeNull();
|
|
74
|
+
rerender(<DataTable rows={[]} columns={COLUMNS} rowKey={(row) => row.code} />);
|
|
75
|
+
expect(screen.getAllByRole("columnheader")).toHaveLength(3);
|
|
76
|
+
expect(screen.getAllByRole("row")).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("le squelette est un status occupé", () => {
|
|
80
|
+
render(<DataTableSkeleton rows={2} />);
|
|
81
|
+
expect((screen.getByRole("status")).getAttribute("aria-busy")).toBe("true");
|
|
82
|
+
expect(DataTable.Skeleton).toBe(DataTableSkeleton);
|
|
83
|
+
});
|
|
84
|
+
});
|