@ogygie/ui 0.0.0-stage → 0.17.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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the structural reading of a thrown value (spec ogygie-ui
|
|
3
|
+
* B1): an OgygieFailure yields its payload and status whatever module built
|
|
4
|
+
* it; a plain Error yields its message; 401 and 403 read as access denied.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
|
|
8
|
+
import { failureOf, isAccessDenied, messageOf, statusOf } from "./failures.js";
|
|
9
|
+
|
|
10
|
+
const payload = ogygieError({ code: "WORKSPACE_ACCESS_DENIED", message: "Not a member of this workspace.", file: null, cause: "membership absent", suggestedFix: "Switch workspace." });
|
|
11
|
+
|
|
12
|
+
describe("failures", () => {
|
|
13
|
+
it("lit une OgygieFailure par sa forme, pas par sa classe", () => {
|
|
14
|
+
const lookalike = { error: payload, httpStatus: 403 };
|
|
15
|
+
expect(failureOf(lookalike)).toEqual(payload);
|
|
16
|
+
expect(statusOf(lookalike)).toBe(403);
|
|
17
|
+
expect(isAccessDenied(lookalike)).toBe(true);
|
|
18
|
+
expect(failureOf(new OgygieFailure(payload, 401))).toEqual(payload);
|
|
19
|
+
expect(isAccessDenied(new OgygieFailure(payload, 401))).toBe(true);
|
|
20
|
+
expect(isAccessDenied(new OgygieFailure(payload, 409))).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("dégrade une erreur quelconque à son message", () => {
|
|
24
|
+
expect(failureOf(new Error("boom"))).toBeNull();
|
|
25
|
+
expect(statusOf(new Error("boom"))).toBeNull();
|
|
26
|
+
expect(messageOf(new Error("boom"))).toBe("boom");
|
|
27
|
+
expect(messageOf("plain")).toBe("plain");
|
|
28
|
+
expect(messageOf(new OgygieFailure(payload, 403))).toBe(payload.message);
|
|
29
|
+
expect(failureOf({ error: { code: "x" } })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/failures.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Reads a thrown value the way a screen needs it: the OgygieError
|
|
3
|
+
* payload and the HTTP status of an `OgygieFailure` (`@ogygie/core/errors`),
|
|
4
|
+
* found STRUCTURALLY — the payload re-validated against its schema — never
|
|
5
|
+
* through `instanceof`. A product may hold two copies of the core for a
|
|
6
|
+
* moment (an upgrade in flight, a nested install): an instance check would
|
|
7
|
+
* then mask a perfectly good failure as "unknown error", and the banner would
|
|
8
|
+
* lose the code and the suggested fix, the only two things a reader can act
|
|
9
|
+
* on. Anything else degrades to its message.
|
|
10
|
+
*/
|
|
11
|
+
import { OgygieErrorSchema, type OgygieError } from "@ogygie/core/errors";
|
|
12
|
+
|
|
13
|
+
/** The OgygieError a failure carries, or null when the thrown value is not one. */
|
|
14
|
+
export function failureOf(error: unknown): OgygieError | null {
|
|
15
|
+
if (typeof error !== "object" || error === null || !("error" in error)) return null;
|
|
16
|
+
const parsed = OgygieErrorSchema.safeParse((error as { error: unknown }).error);
|
|
17
|
+
return parsed.success ? parsed.data : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The HTTP status a failure carries, or null when the thrown value is not an OgygieFailure. */
|
|
21
|
+
export function statusOf(error: unknown): number | null {
|
|
22
|
+
if (typeof error !== "object" || error === null || !("httpStatus" in error)) return null;
|
|
23
|
+
const status = (error as { httpStatus: unknown }).httpStatus;
|
|
24
|
+
return typeof status === "number" ? status : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 401 or 403: the caller is not allowed, which a screen shows as an explanation, never as a failure to load. */
|
|
28
|
+
export function isAccessDenied(error: unknown): boolean {
|
|
29
|
+
const status = statusOf(error);
|
|
30
|
+
return status === 401 || status === 403;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The sentence a banner shows for ANY thrown value: the payload's message, an Error's message, or the value itself. */
|
|
34
|
+
export function messageOf(error: unknown): string {
|
|
35
|
+
const failure = failureOf(error);
|
|
36
|
+
if (failure !== null) return failure.message;
|
|
37
|
+
if (error instanceof Error) return error.message;
|
|
38
|
+
return String(error);
|
|
39
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the one fetch path of a front (spec ogygie-ui B1): an answer
|
|
3
|
+
* on its contract is served parsed; an OgygieError body comes back as an
|
|
4
|
+
* OgygieFailure UNTOUCHED with its status; a network throw, a non-JSON answer
|
|
5
|
+
* and an answer off its contract speak the same language (NETWORK_ERROR,
|
|
6
|
+
* CONTRACT_BROKEN); the JSON content type travels only with a body; the
|
|
7
|
+
* product's `onFailure` sees every refusal before it is thrown.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
|
|
12
|
+
import { createContractFetcher, fetchContract, jsonBody } from "./fetch-contract.js";
|
|
13
|
+
|
|
14
|
+
const OutputSchema = z.object({ items: z.array(z.object({ code: z.string() })) });
|
|
15
|
+
|
|
16
|
+
function answering(status: number, body: string, headers: Record<string, string> = {}) {
|
|
17
|
+
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
|
18
|
+
const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => {
|
|
19
|
+
calls.push({ url, init });
|
|
20
|
+
return new Response(body, { status, headers: { "content-type": "application/json", ...headers } });
|
|
21
|
+
});
|
|
22
|
+
return { fetchImpl, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function failureOf(promise: Promise<unknown>): Promise<OgygieFailure> {
|
|
26
|
+
try {
|
|
27
|
+
await promise;
|
|
28
|
+
} catch (thrown) {
|
|
29
|
+
if (thrown instanceof OgygieFailure) return thrown;
|
|
30
|
+
throw new Error(`expected an OgygieFailure, got ${String(thrown)}`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error("expected a refusal");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("fetchContract", () => {
|
|
36
|
+
it("ÉTANT DONNÉ une réponse conforme au contrat QUAND on la lit ALORS elle est servie parsée", async () => {
|
|
37
|
+
const { fetchImpl, calls } = answering(200, JSON.stringify({ items: [{ code: "a" }] }));
|
|
38
|
+
const fetch = createContractFetcher({ fetchImpl });
|
|
39
|
+
await expect(fetch("/api/items", OutputSchema)).resolves.toEqual({ items: [{ code: "a" }] });
|
|
40
|
+
expect(calls[0]?.url).toBe("/api/items");
|
|
41
|
+
expect(new Headers(calls[0]?.init?.headers).get("content-type")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ÉTANT DONNÉ un corps QUAND on écrit ALORS le content-type JSON l'accompagne, et lui seul", async () => {
|
|
45
|
+
const { fetchImpl, calls } = answering(201, JSON.stringify({ items: [] }));
|
|
46
|
+
const fetch = createContractFetcher({ fetchImpl });
|
|
47
|
+
await fetch("/api/items", OutputSchema, { method: "POST", ...jsonBody({ code: "a" }) });
|
|
48
|
+
const headers = new Headers(calls[0]?.init?.headers);
|
|
49
|
+
expect(headers.get("content-type")).toBe("application/json");
|
|
50
|
+
expect(calls[0]?.init?.method).toBe("POST");
|
|
51
|
+
expect(calls[0]?.init?.body).toBe('{"code":"a"}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("ÉTANT DONNÉ un statut d'erreur avec un corps OgygieError QUAND on lit ALORS la failure porte ce corps intact et le statut", async () => {
|
|
55
|
+
const payload = ogygieError({
|
|
56
|
+
code: "GRADE_IN_USE",
|
|
57
|
+
message: "The row is still referenced.",
|
|
58
|
+
file: null,
|
|
59
|
+
cause: "3 holders",
|
|
60
|
+
suggestedFix: "Move the holders first.",
|
|
61
|
+
});
|
|
62
|
+
const { fetchImpl } = answering(409, JSON.stringify(payload));
|
|
63
|
+
const failure = await failureOf(createContractFetcher({ fetchImpl })("/api/items/a", OutputSchema, { method: "DELETE" }));
|
|
64
|
+
expect(failure.httpStatus).toBe(409);
|
|
65
|
+
expect(failure.error).toEqual(payload);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("ÉTANT DONNÉ un corps d'erreur réduit à code et message QUAND on lit ALORS la failure le complète sans le perdre", async () => {
|
|
69
|
+
const { fetchImpl } = answering(400, JSON.stringify({ code: "VALIDATION_FAILED", message: "The request does not satisfy the contract." }));
|
|
70
|
+
const failure = await failureOf(createContractFetcher({ fetchImpl })("/api/items", OutputSchema));
|
|
71
|
+
expect(failure.error.code).toBe("VALIDATION_FAILED");
|
|
72
|
+
expect(failure.error.suggestedFix).toBe("");
|
|
73
|
+
expect(failure.httpStatus).toBe(400);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ÉTANT DONNÉ un statut d'erreur sans corps OgygieError QUAND on lit ALORS NETWORK_ERROR porte le statut dans details", async () => {
|
|
77
|
+
const { fetchImpl } = answering(502, "<html>bad gateway</html>", { "content-type": "text/html" });
|
|
78
|
+
const failure = await failureOf(createContractFetcher({ fetchImpl })("/api/items", OutputSchema));
|
|
79
|
+
expect(failure.error.code).toBe("CONTRACT_BROKEN");
|
|
80
|
+
expect(failure.httpStatus).toBe(502);
|
|
81
|
+
const { fetchImpl: jsonImpl } = answering(502, JSON.stringify({ oops: true }));
|
|
82
|
+
const jsonFailure = await failureOf(createContractFetcher({ fetchImpl: jsonImpl })("/api/items", OutputSchema));
|
|
83
|
+
expect(jsonFailure.error.code).toBe("NETWORK_ERROR");
|
|
84
|
+
expect(jsonFailure.error.details).toEqual({ status: 502 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("ÉTANT DONNÉ un réseau qui lève QUAND on lit ALORS NETWORK_ERROR 503, jamais une rejection nue", async () => {
|
|
88
|
+
const fetchImpl = vi.fn(async () => {
|
|
89
|
+
throw new TypeError("Failed to fetch");
|
|
90
|
+
});
|
|
91
|
+
const failure = await failureOf(createContractFetcher({ fetchImpl })("/api/items", OutputSchema));
|
|
92
|
+
expect(failure.error.code).toBe("NETWORK_ERROR");
|
|
93
|
+
expect(failure.httpStatus).toBe(503);
|
|
94
|
+
expect(failure.error.cause).toContain("Failed to fetch");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ÉTANT DONNÉ une réponse 200 hors contrat QUAND on lit ALORS CONTRACT_BROKEN nomme le chemin fautif", async () => {
|
|
98
|
+
const { fetchImpl } = answering(200, JSON.stringify({ items: [{ code: 12 }] }));
|
|
99
|
+
const failure = await failureOf(createContractFetcher({ fetchImpl })("/api/items", OutputSchema));
|
|
100
|
+
expect(failure.error.code).toBe("CONTRACT_BROKEN");
|
|
101
|
+
expect(failure.error.cause).toContain("items.0.code");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ÉTANT DONNÉ un onFailure du produit QUAND une lecture est refusée ALORS il la voit avant qu'elle soit levée", async () => {
|
|
105
|
+
const seen: OgygieFailure[] = [];
|
|
106
|
+
const { fetchImpl } = answering(401, JSON.stringify(ogygieError({ code: "AUTH_REQUIRED", message: "A valid session is required.", file: null, cause: "no cookie", suggestedFix: "Sign in." })));
|
|
107
|
+
const fetch = createContractFetcher({ fetchImpl, onFailure: (failure) => seen.push(failure) });
|
|
108
|
+
await failureOf(fetch("/api/items", OutputSchema));
|
|
109
|
+
expect(seen.map((failure) => failure.error.code)).toEqual(["AUTH_REQUIRED"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("le fetcher par défaut passe par globalThis.fetch", async () => {
|
|
113
|
+
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
|
114
|
+
try {
|
|
115
|
+
await expect(fetchContract("/api/items", OutputSchema)).resolves.toEqual({ items: [] });
|
|
116
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
117
|
+
} finally {
|
|
118
|
+
spy.mockRestore();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent The ONE fetch path of an Ogygie front (D39): a relative `/api/...`
|
|
3
|
+
* call whose answer is RE-VALIDATED against the Zod contract the API
|
|
4
|
+
* declares. Failures speak one language — an OgygieError body comes back as
|
|
5
|
+
* an `OgygieFailure` of the core, untouched (code, cause, suggestedFix, the
|
|
6
|
+
* HTTP status beside it); a network failure, a non-JSON answer or an answer
|
|
7
|
+
* off its contract become NETWORK_ERROR / CONTRACT_BROKEN failures — never a
|
|
8
|
+
* raw rejection, never a shape nobody expected flowing into a screen.
|
|
9
|
+
*
|
|
10
|
+
* The transport is an injected effect (`fetchImpl`, principle 5): a test
|
|
11
|
+
* passes a fake, a product passes nothing and gets `globalThis.fetch`. What a
|
|
12
|
+
* product does on a given failure — redirect to its sign-in on 401, log — is
|
|
13
|
+
* its decision, declared once through `onFailure` when it CREATES its
|
|
14
|
+
* fetcher; the default fetcher decides nothing.
|
|
15
|
+
*
|
|
16
|
+
* Why not `@ogygie/core/client`? That module derives operations from the
|
|
17
|
+
* manifest; this one serves the screens written before that derivation
|
|
18
|
+
* reaches them (every existing product), and the generated `queries.gen.ts`
|
|
19
|
+
* of the next lot — one call, one URL, one schema.
|
|
20
|
+
*/
|
|
21
|
+
import type { z } from "zod";
|
|
22
|
+
import { OgygieErrorSchema, OgygieFailure, ogygieError, type OgygieError } from "@ogygie/core/errors";
|
|
23
|
+
|
|
24
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
export interface ContractFetcherOptions {
|
|
27
|
+
/** The network effect. Defaults to `globalThis.fetch`, bound. */
|
|
28
|
+
fetchImpl?: FetchLike;
|
|
29
|
+
/** Called with EVERY failure before it is thrown — the product's one place to react (a 401 → its sign-in). */
|
|
30
|
+
onFailure?: (failure: OgygieFailure) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ContractFetcher = <TSchema extends z.ZodType>(
|
|
34
|
+
url: string,
|
|
35
|
+
schema: TSchema,
|
|
36
|
+
init?: RequestInit,
|
|
37
|
+
) => Promise<z.infer<TSchema>>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An OgygieError body is the api SPEAKING: it comes back untouched. A body
|
|
41
|
+
* with at least a code and a message is completed (a product whose error
|
|
42
|
+
* handler predates `details` or `timestamp` still names its codes); anything
|
|
43
|
+
* else is a NETWORK_ERROR carrying the status and the start of the body.
|
|
44
|
+
*/
|
|
45
|
+
function failurePayloadOf(body: unknown, status: number, url: string): OgygieError {
|
|
46
|
+
const strict = OgygieErrorSchema.safeParse(body);
|
|
47
|
+
if (strict.success) return strict.data;
|
|
48
|
+
if (typeof body === "object" && body !== null) {
|
|
49
|
+
const candidate = body as Record<string, unknown>;
|
|
50
|
+
const lenient = OgygieErrorSchema.safeParse({
|
|
51
|
+
ogygie: true,
|
|
52
|
+
details: {},
|
|
53
|
+
timestamp: new Date(0).toISOString(),
|
|
54
|
+
file: null,
|
|
55
|
+
cause: "",
|
|
56
|
+
suggestedFix: "",
|
|
57
|
+
...candidate,
|
|
58
|
+
});
|
|
59
|
+
if (lenient.success) return lenient.data;
|
|
60
|
+
}
|
|
61
|
+
return ogygieError({
|
|
62
|
+
code: "NETWORK_ERROR",
|
|
63
|
+
message: `The api answered ${status} without an OgygieError body.`,
|
|
64
|
+
file: null,
|
|
65
|
+
cause: `${url} answered ${status} with ${JSON.stringify(body).slice(0, 200)}`,
|
|
66
|
+
suggestedFix: "Find what answered (gateway? proxy? an api without its error handler?) — every Ogygie failure carries an OgygieError body.",
|
|
67
|
+
details: { status },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createContractFetcher(options: ContractFetcherOptions = {}): ContractFetcher {
|
|
72
|
+
const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
|
|
73
|
+
const refuse = (failure: OgygieFailure): never => {
|
|
74
|
+
options.onFailure?.(failure);
|
|
75
|
+
throw failure;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return async (url, schema, init) => {
|
|
79
|
+
let response: Response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetchImpl(url, {
|
|
82
|
+
...init,
|
|
83
|
+
headers: {
|
|
84
|
+
accept: "application/json",
|
|
85
|
+
// JSON content type ONLY when there is a body: Fastify refuses an
|
|
86
|
+
// empty body announced as JSON, which silently broke every verb that
|
|
87
|
+
// sends nothing (astorg-ai-pdlc, 2026-09).
|
|
88
|
+
...(init?.body === undefined || init.body === null ? {} : { "content-type": "application/json" }),
|
|
89
|
+
...(init?.headers ?? {}),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
} catch (thrown) {
|
|
93
|
+
return refuse(
|
|
94
|
+
new OgygieFailure(
|
|
95
|
+
ogygieError({
|
|
96
|
+
code: "NETWORK_ERROR",
|
|
97
|
+
message: `The call to ${url} never reached the api.`,
|
|
98
|
+
file: null,
|
|
99
|
+
cause: `fetch threw ${String(thrown)}`,
|
|
100
|
+
suggestedFix: "Check that the api answers on this path (dev proxy up? offline?) — the front never invents a fallback answer.",
|
|
101
|
+
}),
|
|
102
|
+
503,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rawBody = await response.text();
|
|
108
|
+
let body: unknown = undefined;
|
|
109
|
+
if (rawBody.trim() !== "") {
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.parse(rawBody) as unknown;
|
|
112
|
+
} catch (thrown) {
|
|
113
|
+
return refuse(
|
|
114
|
+
new OgygieFailure(
|
|
115
|
+
ogygieError({
|
|
116
|
+
code: "CONTRACT_BROKEN",
|
|
117
|
+
message: `The answer of ${url} is not JSON.`,
|
|
118
|
+
file: null,
|
|
119
|
+
cause: `status ${response.status}, body starting with ${JSON.stringify(rawBody.slice(0, 80))} (${String(thrown)})`,
|
|
120
|
+
suggestedFix: "Find what really answered on this path (a proxy error page? an html fallback?) — the declared output schema promises JSON.",
|
|
121
|
+
}),
|
|
122
|
+
response.status,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
return refuse(new OgygieFailure(failurePayloadOf(body, response.status, url), response.status));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parsed = schema.safeParse(body);
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
return refuse(
|
|
135
|
+
new OgygieFailure(
|
|
136
|
+
ogygieError({
|
|
137
|
+
code: "CONTRACT_BROKEN",
|
|
138
|
+
message: `The answer of ${url} violates the contract this screen reads.`,
|
|
139
|
+
file: null,
|
|
140
|
+
cause: parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join(" ; "),
|
|
141
|
+
suggestedFix: "The API and the front disagree on a contract: align the answer with the schema, or amend the contract on both sides in the same PR.",
|
|
142
|
+
}),
|
|
143
|
+
response.status,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return parsed.data;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The fetcher a product uses when it has nothing to decide on failures. */
|
|
152
|
+
export const fetchContract: ContractFetcher = createContractFetcher();
|
|
153
|
+
|
|
154
|
+
/** A JSON body for a write: `fetchContract(url, Schema, { method: "POST", ...jsonBody(input) })`. */
|
|
155
|
+
export function jsonBody(body: unknown): Pick<RequestInit, "body"> {
|
|
156
|
+
return { body: JSON.stringify(body) };
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Root entry point of `@ogygie/ui` (D39): the dependency-light socle
|
|
3
|
+
* every Ogygie front imports — the screen states, the error and empty
|
|
4
|
+
* surfaces, the inline editor, the contract fetcher and the shapes a body
|
|
5
|
+
* receives. It pulls React, zod and the core, nothing else: the three patterns
|
|
6
|
+
* that carry an optional peer live behind their own entry points —
|
|
7
|
+
* `@ogygie/ui/data-table`, `@ogygie/ui/contract-form`,
|
|
8
|
+
* `@ogygie/ui/variance-chart` — so a product that installs none of them never
|
|
9
|
+
* resolves them.
|
|
10
|
+
*/
|
|
11
|
+
export { EmptyState, type EmptyStateProps } from "./EmptyState.js";
|
|
12
|
+
export { ErrorBanner, type ErrorBannerProps } from "./ErrorBanner.js";
|
|
13
|
+
export { ScreenState, Skeleton, type ScreenStateProps, type StateCopy } from "./ScreenState.js";
|
|
14
|
+
export { EditableText, type EditableTextProps } from "./EditableText.js";
|
|
15
|
+
export { CharacterCount } from "./CharacterCount.js";
|
|
16
|
+
export {
|
|
17
|
+
createContractFetcher,
|
|
18
|
+
fetchContract,
|
|
19
|
+
jsonBody,
|
|
20
|
+
type ContractFetcher,
|
|
21
|
+
type ContractFetcherOptions,
|
|
22
|
+
type FetchLike,
|
|
23
|
+
} from "./fetch-contract.js";
|
|
24
|
+
export { failureOf, isAccessDenied, messageOf, statusOf } from "./failures.js";
|
|
25
|
+
export {
|
|
26
|
+
failedQuery,
|
|
27
|
+
idleMutation,
|
|
28
|
+
pendingQuery,
|
|
29
|
+
readyQuery,
|
|
30
|
+
type MutationLike,
|
|
31
|
+
type QueryLike,
|
|
32
|
+
} from "./query-like.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Guards what this package PUBLISHES (D39, same family as the studio's
|
|
3
|
+
* and the MCP server's contract tests): every entry point resolves to a source
|
|
4
|
+
* file under a shipped folder; the ROOT entry pulls none of the optional peers
|
|
5
|
+
* (a product that installs none of them must still import it); the three
|
|
6
|
+
* heavy patterns each sit behind their own entry point; the optional peers are
|
|
7
|
+
* declared optional; the version is the lockstep's.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
const packageRoot = new URL("../", import.meta.url);
|
|
14
|
+
const packageJson: {
|
|
15
|
+
version: string;
|
|
16
|
+
exports?: Record<string, string>;
|
|
17
|
+
files?: string[];
|
|
18
|
+
dependencies?: Record<string, string>;
|
|
19
|
+
peerDependencies?: Record<string, string>;
|
|
20
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
21
|
+
} = JSON.parse(readFileSync(new URL("package.json", packageRoot), "utf8"));
|
|
22
|
+
const corePackageJson: { version: string } = JSON.parse(readFileSync(new URL("../ogygie-core/package.json", packageRoot), "utf8"));
|
|
23
|
+
|
|
24
|
+
const OPTIONAL_PEERS = ["@tanstack/react-table", "react-hook-form", "recharts"];
|
|
25
|
+
|
|
26
|
+
/** Every relative module a source file imports, transitively, under src/. */
|
|
27
|
+
function importsOf(relativePath: string, seen = new Set<string>()): Set<string> {
|
|
28
|
+
if (seen.has(relativePath)) return seen;
|
|
29
|
+
seen.add(relativePath);
|
|
30
|
+
const source = readFileSync(new URL(relativePath, packageRoot), "utf8");
|
|
31
|
+
for (const match of source.matchAll(/from\s+"([^"]+)"/g)) {
|
|
32
|
+
const specifier = match[1] ?? "";
|
|
33
|
+
if (specifier.startsWith("./")) {
|
|
34
|
+
const target = specifier.replace(/\.js$/, "");
|
|
35
|
+
const directory = relativePath.slice(0, relativePath.lastIndexOf("/") + 1);
|
|
36
|
+
const candidates = [`${directory}${target.slice(2)}.ts`, `${directory}${target.slice(2)}.tsx`];
|
|
37
|
+
const existing = candidates.find((candidate) => existsSync(new URL(candidate, packageRoot)));
|
|
38
|
+
if (existing !== undefined) importsOf(existing, seen);
|
|
39
|
+
} else {
|
|
40
|
+
seen.add(`pkg:${specifier}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return seen;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("@ogygie/ui — contrat du paquet publié", () => {
|
|
47
|
+
it("chaque point d'entrée vit sous un dossier LIVRÉ et existe", () => {
|
|
48
|
+
const shippedRoots = (packageJson.files ?? []).map((entry) => entry.replace(/\/$/, ""));
|
|
49
|
+
const targets = Object.values(packageJson.exports ?? {});
|
|
50
|
+
expect(targets.length).toBeGreaterThan(0);
|
|
51
|
+
for (const target of targets) {
|
|
52
|
+
expect(shippedRoots).toContain(target.replace(/^\.\//, "").split("/")[0] ?? "");
|
|
53
|
+
expect(existsSync(new URL(target, packageRoot))).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("le point d'entrée racine ne tire AUCUN peer optionnel", () => {
|
|
58
|
+
const reached = importsOf(packageJson.exports?.["."] ?? "./src/index.ts");
|
|
59
|
+
const externals = [...reached].filter((entry) => entry.startsWith("pkg:")).map((entry) => entry.slice(4));
|
|
60
|
+
for (const peer of OPTIONAL_PEERS) {
|
|
61
|
+
expect(externals.some((external) => external === peer || external.startsWith(`${peer}/`))).toBe(false);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("les trois patterns lourds ont chacun leur point d'entrée, et leur peer est déclaré optionnel", () => {
|
|
66
|
+
expect(packageJson.exports?.["./data-table"]).toBe("./src/DataTable.tsx");
|
|
67
|
+
expect(packageJson.exports?.["./contract-form"]).toBe("./src/ContractForm.tsx");
|
|
68
|
+
expect(packageJson.exports?.["./variance-chart"]).toBe("./src/VarianceChart.tsx");
|
|
69
|
+
for (const peer of OPTIONAL_PEERS) {
|
|
70
|
+
expect(packageJson.peerDependencies?.[peer]).toBeDefined();
|
|
71
|
+
expect(packageJson.peerDependenciesMeta?.[peer]?.optional).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
expect(packageJson.peerDependenciesMeta?.["react"]).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("@ogygie/core est une DÉPENDANCE, jamais un peer : un peer qui monte ferait monter le lockstep en majeur (FAILURES.md 2026-10-18)", () => {
|
|
77
|
+
expect(packageJson.dependencies?.["@ogygie/core"]).toBeDefined();
|
|
78
|
+
expect(packageJson.peerDependencies?.["@ogygie/core"]).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("la version est celle du lockstep (= @ogygie/core)", () => {
|
|
82
|
+
expect(packageJson.version).toBe(corePackageJson.version);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent The two shapes a screen BODY receives instead of a hook (D39): the
|
|
3
|
+
* subset of a TanStack Query result it branches on (`QueryLike`) and the
|
|
4
|
+
* subset of a mutation result it acts through (`MutationLike`). A body typed
|
|
5
|
+
* on these renders without a QueryClient, a router or the network — the form
|
|
6
|
+
* the first product found on its own (every screen of the dtd app of
|
|
7
|
+
* astorg-ai-pdlc), lifted here so a generated container and a written body
|
|
8
|
+
* speak the same types. `UseQueryResult` and `UseMutationResult` are
|
|
9
|
+
* structurally assignable to them: a container passes its hooks' results as
|
|
10
|
+
* they are. The three fixtures at the end are what a body test builds its
|
|
11
|
+
* states from, so every product test spells them the same way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A read result shaped like a subset of `UseQueryResult`: only what a body branches on. Pending = no data and no error yet. */
|
|
15
|
+
export interface QueryLike<TData> {
|
|
16
|
+
data: TData | undefined;
|
|
17
|
+
error: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A write result shaped like a subset of `UseMutationResult`: only what a
|
|
22
|
+
* body acts through. `variables` lets a body tell WHICH row is being written
|
|
23
|
+
* (one inactive cell, never the whole grid).
|
|
24
|
+
*/
|
|
25
|
+
export interface MutationLike<TVariables, TData = unknown> {
|
|
26
|
+
mutate: (variables: TVariables, options?: { onSuccess?: (data: TData) => void }) => void;
|
|
27
|
+
variables?: TVariables | undefined;
|
|
28
|
+
isPending: boolean;
|
|
29
|
+
error: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A mutation that never writes — what a read-only body receives, and what a test passes when writes are out of its scope. */
|
|
33
|
+
export function idleMutation<TVariables, TData = unknown>(): MutationLike<TVariables, TData> {
|
|
34
|
+
return { mutate: () => undefined, variables: undefined, isPending: false, error: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The loading state of a read: nothing served yet, nothing failed. */
|
|
38
|
+
export function pendingQuery<TData>(): QueryLike<TData> {
|
|
39
|
+
return { data: undefined, error: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The failed state of a read: the error a container would have received. */
|
|
43
|
+
export function failedQuery(error: unknown): QueryLike<never> {
|
|
44
|
+
return { data: undefined, error };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The served state of a read. */
|
|
48
|
+
export function readyQuery<TData>(data: TData): QueryLike<TData> {
|
|
49
|
+
return { data, error: null };
|
|
50
|
+
}
|