@axzydev/axzy_ui_system 1.7.2 → 1.7.4

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.
Files changed (63) hide show
  1. package/dist/index.cjs +1630 -589
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +373 -13
  5. package/dist/index.d.ts +373 -13
  6. package/dist/index.js +1346 -314
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/flex/flex.props.ts +3 -1
  12. package/src/components/atoms/flex/flex.tsx +11 -3
  13. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  14. package/src/components/atoms/textarea/textarea.tsx +13 -3
  15. package/src/components/molecules/card/card.props.ts +2 -0
  16. package/src/components/molecules/card/card.tsx +15 -2
  17. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  18. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  19. package/src/components/molecules/stat-card/stat-card.props.ts +7 -0
  20. package/src/components/molecules/stat-card/stat-card.tsx +18 -0
  21. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  22. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  23. package/src/i18n/locales/en.ts +7 -0
  24. package/src/i18n/locales/es.ts +7 -0
  25. package/src/index.ts +31 -3
  26. package/src/json-renderer/actions/effects.test.tsx +115 -0
  27. package/src/json-renderer/actions/effects.ts +291 -0
  28. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  29. package/src/json-renderer/binding/binding.test.tsx +168 -0
  30. package/src/json-renderer/binding/resolveValue.ts +193 -0
  31. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  32. package/src/json-renderer/form/FormNode.tsx +92 -0
  33. package/src/json-renderer/form/form.test.tsx +148 -0
  34. package/src/json-renderer/form/formLength.test.tsx +99 -0
  35. package/src/json-renderer/form/rules.ts +162 -0
  36. package/src/json-renderer/index.ts +21 -0
  37. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  38. package/src/json-renderer/kernel/capabilities.ts +30 -0
  39. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  40. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  41. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  42. package/src/json-renderer/kernel/slots.ts +34 -0
  43. package/src/json-renderer/kernel/types.ts +4 -0
  44. package/src/json-renderer/routing/match.ts +82 -0
  45. package/src/json-renderer/routing/navigation.ts +71 -0
  46. package/src/json-renderer/routing/routes.ts +179 -0
  47. package/src/json-renderer/routing/routing.test.ts +147 -0
  48. package/src/json-renderer/seguridad.test.tsx +299 -0
  49. package/src/json-renderer/shared/errors.ts +1 -0
  50. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  51. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  52. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  53. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  54. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  55. package/src/json-renderer/validation/validate.ts +17 -0
  56. package/src/sandbox/i18n.ts +38 -0
  57. package/src/sandbox/useHashRoute.ts +2 -1
  58. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  59. package/src/showcases/DemoJsonDocs.tsx +11 -11
  60. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  61. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  62. package/src/showcases/demoApp/routes.test.ts +63 -0
  63. package/src/showcases/demoApp/schemas.test.ts +6 -17
@@ -0,0 +1,148 @@
1
+ import React from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen as testingScreen } from "@testing-library/react";
4
+ import { ITSchemaRenderer } from "../kernel/SchemaRenderer";
5
+ import type { JsonEngineIssue } from "../shared/errors";
6
+ import type { NodeSchema, ScreenSchema } from "../kernel/types";
7
+ import { validateSchema } from "../validation/validate";
8
+ import { readFormFields, validateField, validateForm, type FormField } from "./rules";
9
+
10
+ /** Esquema mínimo con un formulario de contacto. */
11
+ const pantalla = (fields: unknown, extra: Record<string, unknown> = {}): ScreenSchema =>
12
+ ({
13
+ version: 1,
14
+ id: "formulario",
15
+ content: {
16
+ type: "form",
17
+ id: "contacto",
18
+ props: { fields, onSubmit: { action: "enviar" }, ...extra },
19
+ } as NodeSchema,
20
+ }) as ScreenSchema;
21
+
22
+ const CAMPOS: FormField[] = [
23
+ { name: "nombre", kind: "text", label: "Nombre", required: true },
24
+ { name: "correo", kind: "email", label: "Correo", required: true },
25
+ ];
26
+
27
+ describe("readFormFields", () => {
28
+ const ruta = ["content", 0, "props", "fields"];
29
+
30
+ it("lee los campos con su tipo y obligatoriedad", () => {
31
+ const { fields, issues } = readFormFields([{ name: "correo", kind: "email", label: "Correo", required: true }], ruta);
32
+ expect(issues).toEqual([]);
33
+ expect(fields).toEqual([{ name: "correo", kind: "email", required: true, label: "Correo" }]);
34
+ });
35
+
36
+ it("un campo sin name se descarta con JR021 y el resto sigue", () => {
37
+ const { fields, issues } = readFormFields([{ kind: "text" }, { name: "ok" }], ruta);
38
+ expect(fields.map((campo) => campo.name)).toEqual(["ok"]);
39
+ expect(issues[0]).toMatchObject({ code: "JR021", severity: "error" });
40
+ });
41
+
42
+ it("un name repetido se descarta con JR021", () => {
43
+ const { fields, issues } = readFormFields([{ name: "a" }, { name: "a" }], ruta);
44
+ expect(fields).toHaveLength(1);
45
+ expect(issues[0]).toMatchObject({ code: "JR021" });
46
+ });
47
+
48
+ it("un tipo no admitido cae a text con aviso JR011", () => {
49
+ const { fields, issues } = readFormFields([{ name: "edad", kind: "number" }], ruta);
50
+ expect(fields[0].kind).toBe("text");
51
+ expect(issues[0]).toMatchObject({ code: "JR011", severity: "warning" });
52
+ });
53
+
54
+ it("si fields no es una lista, avisa con JR021 y no hay campos", () => {
55
+ const { fields, issues } = readFormFields("no-es-lista", ruta);
56
+ expect(fields).toEqual([]);
57
+ expect(issues[0]).toMatchObject({ code: "JR021" });
58
+ });
59
+ });
60
+
61
+ describe("reglas de valor", () => {
62
+ it("required rechaza vacío y solo espacios", () => {
63
+ expect(validateField({ kind: "text", required: true }, "")).toBe("required");
64
+ expect(validateField({ kind: "text", required: true }, " ")).toBe("required");
65
+ expect(validateField({ kind: "text", required: true }, "Ana")).toBeUndefined();
66
+ });
67
+
68
+ it("un campo opcional vacío no falla", () => {
69
+ expect(validateField({ kind: "email", required: false }, "")).toBeUndefined();
70
+ });
71
+
72
+ it("email acepta una dirección y rechaza formas incompletas", () => {
73
+ expect(validateField({ kind: "email", required: true }, "ana@axzy.dev")).toBeUndefined();
74
+ expect(validateField({ kind: "email", required: true }, "ana@axzy")).toBe("email");
75
+ expect(validateField({ kind: "email", required: true }, "ana axzy@dev.com")).toBe("email");
76
+ expect(validateField({ kind: "email", required: true }, "sin-arroba")).toBe("email");
77
+ });
78
+
79
+ it("validateForm devuelve solo los campos con error", () => {
80
+ expect(validateForm(CAMPOS, { nombre: "Ana", correo: "mal" })).toEqual({ correo: "email" });
81
+ expect(validateForm(CAMPOS, { nombre: "Ana", correo: "ana@axzy.dev" })).toEqual({});
82
+ });
83
+ });
84
+
85
+ describe("validación del esquema con nodos form", () => {
86
+ it("un formulario bien declarado es válido", () => {
87
+ expect(validateSchema(pantalla([{ name: "nombre" }])).verdict).toBe("valid");
88
+ });
89
+
90
+ it("un formulario sin fields es inválido con JR021", () => {
91
+ const informe = validateSchema(pantalla(undefined));
92
+ expect(informe.verdict).toBe("invalid");
93
+ expect(informe.errors[0]).toMatchObject({ code: "JR021" });
94
+ });
95
+ });
96
+
97
+ describe("nodo form en pantalla", () => {
98
+ it("pinta un input por campo con su name", () => {
99
+ const { container } = render(<ITSchemaRenderer schema={pantalla(CAMPOS)} />);
100
+ expect(container.querySelector('input[name="nombre"]')).not.toBeNull();
101
+ expect(container.querySelector('input[name="correo"]')).not.toBeNull();
102
+ });
103
+
104
+ it("enviar vacío muestra el error traducido y no llama a la acción", () => {
105
+ const onSubmit = vi.fn();
106
+ render(<ITSchemaRenderer schema={pantalla(CAMPOS)} resolveAction={() => onSubmit} />);
107
+
108
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
109
+
110
+ expect(testingScreen.getAllByText("Campo obligatorio.").length).toBeGreaterThan(0);
111
+ expect(onSubmit).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("con datos válidos llama a la acción con los valores de los campos", () => {
115
+ const onSubmit = vi.fn();
116
+ const { container } = render(<ITSchemaRenderer schema={pantalla(CAMPOS)} resolveAction={() => onSubmit} />);
117
+
118
+ fireEvent.change(container.querySelector('input[name="nombre"]')!, { target: { value: "Ana" } });
119
+ fireEvent.change(container.querySelector('input[name="correo"]')!, { target: { value: "ana@axzy.dev" } });
120
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
121
+
122
+ expect(onSubmit).toHaveBeenCalledTimes(1);
123
+ expect(onSubmit.mock.calls[0][0]).toEqual({ nombre: "Ana", correo: "ana@axzy.dev" });
124
+ });
125
+
126
+ it("el error de un campo desaparece al volver a escribir en él", () => {
127
+ const { container } = render(<ITSchemaRenderer schema={pantalla(CAMPOS)} />);
128
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
129
+ expect(testingScreen.getAllByText("Campo obligatorio.").length).toBeGreaterThan(0);
130
+
131
+ fireEvent.change(container.querySelector('input[name="nombre"]')!, { target: { value: "A" } });
132
+
133
+ // El campo "nombre" ya no tiene error; "correo" sigue en error.
134
+ expect(testingScreen.getAllByText("Campo obligatorio.")).toHaveLength(1);
135
+ });
136
+
137
+ it("un campo con un nombre inválido no tumba el formulario y avisa", () => {
138
+ const issues: JsonEngineIssue[] = [];
139
+ const { container } = render(
140
+ <ITSchemaRenderer
141
+ schema={pantalla([{ name: "ok", label: "OK" }, { label: "sin nombre" }])}
142
+ onIssue={(issue) => issues.push(issue)}
143
+ />
144
+ );
145
+ expect(container.querySelector('input[name="ok"]')).not.toBeNull();
146
+ expect(issues.some((issue) => issue.code === "JR021")).toBe(true);
147
+ });
148
+ });
@@ -0,0 +1,99 @@
1
+ import React from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { fireEvent, render, screen as testingScreen } from "@testing-library/react";
4
+ import { ITSchemaRenderer } from "../kernel/SchemaRenderer";
5
+ import type { JsonEngineIssue } from "../shared/errors";
6
+ import type { NodeSchema, ScreenSchema } from "../kernel/types";
7
+ import { validateSchema } from "../validation/validate";
8
+ import { readFormFields, validateField } from "./rules";
9
+
10
+ const RUTA = ["content", 0, "props", "fields"];
11
+
12
+ /** Esquema con un formulario de un único campo. */
13
+ const pantalla = (campo: Record<string, unknown>): ScreenSchema =>
14
+ ({
15
+ version: 1,
16
+ id: "longitud",
17
+ content: { type: "form", id: "f", props: { fields: [campo], onSubmit: { action: "enviar" } } } as NodeSchema,
18
+ }) as ScreenSchema;
19
+
20
+ describe("lectura de longitudes", () => {
21
+ it("lee minLength y maxLength como enteros no negativos", () => {
22
+ const { fields, issues } = readFormFields([{ name: "n", minLength: 2, maxLength: 10 }], RUTA);
23
+ expect(issues).toEqual([]);
24
+ expect(fields[0]).toMatchObject({ minLength: 2, maxLength: 10 });
25
+ });
26
+
27
+ it("una longitud que no es entero no negativo se ignora con JR011", () => {
28
+ for (const malo of [-1, 2.5, "3"]) {
29
+ const { fields, issues } = readFormFields([{ name: "n", minLength: malo }], RUTA);
30
+ expect(fields[0].minLength, String(malo)).toBeUndefined();
31
+ expect(issues[0], String(malo)).toMatchObject({ code: "JR011", severity: "warning" });
32
+ }
33
+ });
34
+
35
+ it("minLength mayor que maxLength descarta el campo con JR021", () => {
36
+ const { fields, issues } = readFormFields([{ name: "n", minLength: 5, maxLength: 2 }], RUTA);
37
+ expect(fields).toEqual([]);
38
+ expect(issues[0]).toMatchObject({ code: "JR021", severity: "error" });
39
+ });
40
+
41
+ it("un esquema con longitudes válidas no tiene errores de validación", () => {
42
+ expect(validateSchema(pantalla({ name: "n", minLength: 2, maxLength: 10 })).verdict).toBe("valid");
43
+ });
44
+ });
45
+
46
+ describe("reglas de longitud", () => {
47
+ it("minLength rechaza un valor más corto, contando sin espacios de los extremos", () => {
48
+ expect(validateField({ kind: "text", required: true, minLength: 3 }, "ab")).toBe("minLength");
49
+ expect(validateField({ kind: "text", required: true, minLength: 3 }, " ab ")).toBe("minLength");
50
+ expect(validateField({ kind: "text", required: true, minLength: 3 }, "abc")).toBeUndefined();
51
+ });
52
+
53
+ it("maxLength rechaza un valor más largo", () => {
54
+ expect(validateField({ kind: "text", required: false, maxLength: 3 }, "abcd")).toBe("maxLength");
55
+ expect(validateField({ kind: "text", required: false, maxLength: 3 }, "abc")).toBeUndefined();
56
+ });
57
+
58
+ it("un campo opcional vacío no se valida por longitud", () => {
59
+ expect(validateField({ kind: "text", required: false, minLength: 3 }, "")).toBeUndefined();
60
+ });
61
+
62
+ it("obligatorio gana a longitud: vacío sigue siendo required", () => {
63
+ expect(validateField({ kind: "text", required: true, minLength: 3 }, "")).toBe("required");
64
+ });
65
+ });
66
+
67
+ describe("mensajes de longitud en pantalla", () => {
68
+ it("muestra el mínimo con su número", () => {
69
+ const { container } = render(<ITSchemaRenderer schema={pantalla({ name: "nombre", label: "Nombre", required: true, minLength: 2 })} />);
70
+ fireEvent.change(container.querySelector('input[name="nombre"]')!, { target: { value: "A" } });
71
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
72
+ expect(testingScreen.getByText("Escribe al menos 2 caracteres.")).toBeTruthy();
73
+ });
74
+
75
+ it("muestra el máximo con su número y no llama a la acción", () => {
76
+ const onSubmit = { called: false };
77
+ const { container } = render(
78
+ <ITSchemaRenderer
79
+ schema={pantalla({ name: "codigo", label: "Código", required: true, maxLength: 4 })}
80
+ resolveAction={() => () => {
81
+ onSubmit.called = true;
82
+ }}
83
+ />
84
+ );
85
+ fireEvent.change(container.querySelector('input[name="codigo"]')!, { target: { value: "abcdef" } });
86
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
87
+ expect(testingScreen.getByText("Escribe como máximo 4 caracteres.")).toBeTruthy();
88
+ expect(onSubmit.called).toBe(false);
89
+ });
90
+
91
+ it("una longitud inválida no tumba el formulario y avisa", () => {
92
+ const issues: JsonEngineIssue[] = [];
93
+ const { container } = render(
94
+ <ITSchemaRenderer schema={pantalla({ name: "n", label: "N", minLength: -1 })} onIssue={(issue) => issues.push(issue)} />
95
+ );
96
+ expect(container.querySelector('input[name="n"]')).not.toBeNull();
97
+ expect(issues.some((issue) => issue.code === "JR011")).toBe(true);
98
+ });
99
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Reglas del nodo `form`: lectura de los campos del esquema y validación de valores.
3
+ *
4
+ * Son funciones puras, sin React. Las usan a la vez el renderizador (para pintar) y el
5
+ * validador (para avisar antes de pintar), así que una forma inválida se trata igual
6
+ * en los dos sitios. Las validaciones devuelven el **código** del error, no el texto:
7
+ * el texto lo pone `FormNode` con `jsonRenderer.form.*`.
8
+ *
9
+ * Deliberadamente no hay reglas con expresiones regulares declaradas en el JSON: una
10
+ * regex que llega del esquema puede bloquear la pestaña (retrocesos catastróficos).
11
+ */
12
+
13
+ import { issue, type JsonEngineIssue } from "../shared/errors";
14
+ import { formatPath, type SchemaPath } from "../shared/paths";
15
+
16
+ /** Tipos de campo que admite el nodo `form`. */
17
+ export const FORM_FIELD_KINDS = ["text", "email"] as const;
18
+
19
+ /** Tipo de campo. */
20
+ export type FormFieldKind = (typeof FORM_FIELD_KINDS)[number];
21
+
22
+ /** Código de error de un campo. */
23
+ export type FormRuleCode = "required" | "email" | "minLength" | "maxLength";
24
+
25
+ /** Campo ya validado en forma, con la etiqueta aún sin traducir. */
26
+ export interface DeclaredField {
27
+ /** Nombre del campo: clave del valor enviado. */
28
+ name: string;
29
+ /** Tipo de campo. */
30
+ kind: FormFieldKind;
31
+ /** Si el campo es obligatorio. */
32
+ required: boolean;
33
+ /** Mínimo de caracteres (sin contar espacios de los extremos). */
34
+ minLength?: number;
35
+ /** Máximo de caracteres (sin contar espacios de los extremos). */
36
+ maxLength?: number;
37
+ /** Etiqueta tal cual la declaró el esquema (puede ser `{ i18n }`). */
38
+ label: unknown;
39
+ }
40
+
41
+ /** Campo listo para pintar: la etiqueta ya es texto. */
42
+ export interface FormField {
43
+ /** Nombre del campo. */
44
+ name: string;
45
+ /** Tipo de campo. */
46
+ kind: FormFieldKind;
47
+ /** Etiqueta visible. */
48
+ label: string;
49
+ /** Si el campo es obligatorio. */
50
+ required: boolean;
51
+ /** Mínimo de caracteres. */
52
+ minLength?: number;
53
+ /** Máximo de caracteres. */
54
+ maxLength?: number;
55
+ }
56
+
57
+ /** Forma razonable de un correo: algo@algo.dominio, sin espacios. */
58
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
59
+
60
+ /** Longitud declarada: entero no negativo, o `undefined` si no hay o no es válida. */
61
+ function longitudDeclarada(value: unknown): number | undefined {
62
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
63
+ }
64
+
65
+ /**
66
+ * Lee los campos declarados. Un campo sin `name` o repetido se descarta con `JR021`;
67
+ * un tipo no admitido cae a `text` con `JR011`; una longitud inválida se ignora con
68
+ * `JR011`. Si el mínimo supera al máximo, se descarta el campo con `JR021`.
69
+ *
70
+ * @example
71
+ * readFormFields([{ name: "correo", kind: "email" }], ["content", 0, "props", "fields"]).fields[0].kind; // "email"
72
+ */
73
+ export function readFormFields(declared: unknown, fieldsPath: SchemaPath): { fields: DeclaredField[]; issues: JsonEngineIssue[] } {
74
+ const issues: JsonEngineIssue[] = [];
75
+ const fields: DeclaredField[] = [];
76
+
77
+ if (!Array.isArray(declared)) {
78
+ issues.push(issue("JR021", "El formulario necesita `props.fields` como lista.", formatPath(fieldsPath), { severity: "error" }));
79
+ return { fields, issues };
80
+ }
81
+
82
+ const nombres = new Set<string>();
83
+ declared.forEach((raw, index) => {
84
+ const fieldPath = [...fieldsPath, index];
85
+ const campo = raw as Record<string, unknown> | null;
86
+ if (!campo || typeof campo !== "object" || typeof campo.name !== "string" || campo.name === "") {
87
+ issues.push(issue("JR021", "Un campo del formulario no declara `name`.", formatPath(fieldPath), { severity: "error" }));
88
+ return;
89
+ }
90
+ if (nombres.has(campo.name)) {
91
+ issues.push(issue("JR021", `El campo "${campo.name}" se repite.`, formatPath(fieldPath), { severity: "error" }));
92
+ return;
93
+ }
94
+
95
+ let kind: FormFieldKind = "text";
96
+ if (campo.kind !== undefined) {
97
+ if (typeof campo.kind === "string" && (FORM_FIELD_KINDS as readonly string[]).includes(campo.kind)) {
98
+ kind = campo.kind as FormFieldKind;
99
+ } else {
100
+ issues.push(
101
+ issue("JR011", `Tipo de campo "${String(campo.kind)}" no admitido.`, formatPath(fieldPath), {
102
+ severity: "warning",
103
+ hint: `Admitidos: ${FORM_FIELD_KINDS.join(", ")}. Se usa "text".`,
104
+ })
105
+ );
106
+ }
107
+ }
108
+
109
+ const minLength = longitudDeclarada(campo.minLength);
110
+ const maxLength = longitudDeclarada(campo.maxLength);
111
+ for (const [clave, valor, declarada] of [["minLength", campo.minLength, minLength], ["maxLength", campo.maxLength, maxLength]] as const) {
112
+ if (valor !== undefined && declarada === undefined) {
113
+ issues.push(issue("JR011", `\`${clave}\` debe ser un entero no negativo.`, formatPath(fieldPath), { severity: "warning", hint: "Se ignora." }));
114
+ }
115
+ }
116
+ if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
117
+ issues.push(issue("JR021", `El campo "${campo.name}" tiene minLength mayor que maxLength.`, formatPath(fieldPath), { severity: "error" }));
118
+ return;
119
+ }
120
+
121
+ nombres.add(campo.name);
122
+ fields.push({ name: campo.name, kind, required: campo.required === true, minLength, maxLength, label: campo.label });
123
+ });
124
+
125
+ return { fields, issues };
126
+ }
127
+
128
+ /**
129
+ * Valida un valor contra las reglas de su campo. Devuelve el código del primer error o `undefined`.
130
+ *
131
+ * El orden importa para el mensaje: obligatorio, longitud, correo. Un campo opcional vacío
132
+ * no tiene más reglas que cumplir.
133
+ */
134
+ export function validateField(
135
+ field: Pick<FormField, "kind" | "required" | "minLength" | "maxLength">,
136
+ value: string
137
+ ): FormRuleCode | undefined {
138
+ const texto = value.trim();
139
+ if (texto === "") return field.required ? "required" : undefined;
140
+ if (field.minLength !== undefined && texto.length < field.minLength) return "minLength";
141
+ if (field.maxLength !== undefined && texto.length > field.maxLength) return "maxLength";
142
+ if (field.kind === "email" && !EMAIL.test(texto)) return "email";
143
+ return undefined;
144
+ }
145
+
146
+ /**
147
+ * Valida todos los campos. Devuelve solo los que tienen error, por nombre de campo.
148
+ *
149
+ * @example
150
+ * validateForm(campos, { nombre: "" }); // { nombre: "required" }
151
+ */
152
+ export function validateForm(
153
+ fields: Pick<FormField, "name" | "kind" | "required" | "minLength" | "maxLength">[],
154
+ values: Record<string, string>
155
+ ): Record<string, FormRuleCode> {
156
+ const errores: Record<string, FormRuleCode> = {};
157
+ for (const field of fields) {
158
+ const code = validateField(field, values[field.name] ?? "");
159
+ if (code) errores[field.name] = code;
160
+ }
161
+ return errores;
162
+ }
@@ -22,6 +22,25 @@ export { NodeBoundary } from "./kernel/NodeBoundary";
22
22
  export { renderNode, childList, hasChildren } from "./kernel/renderNode";
23
23
  export { auditTree, countPendingNodes } from "./kernel/partial";
24
24
  export type { ITJsonRenderEngineProps } from "./kernel/schemaRenderer.props";
25
+
26
+ /* ── Rutas y navegación ────────────────────────────────────────────────── */
27
+ export { validateRoutes, flattenRoutes, joinRoutePath } from "./routing/routes";
28
+ export type { RoutesSchema, RouteSchema, RouteNavSchema, FlatRoute } from "./routing/routes";
29
+ export { matchRoute, routeHref } from "./routing/match";
30
+ export type { RouteMatch } from "./routing/match";
31
+ export { buildNavigationTree, navContainsRoute } from "./routing/navigation";
32
+ export type { NavNode } from "./routing/navigation";
33
+
34
+ /* ── Efectos de acción (lo que el host cumple tras una acción) ─────────── */
35
+ export { wrapAction } from "./actions/effects";
36
+ export type { EffectRegistry, EffectContext, ToastVariant } from "./actions/effects";
37
+
38
+ /* ── Slots (bloques JSX que registra el host) ──────────────────────────── */
39
+ export type { SlotComponent, SlotRegistry } from "./kernel/slots";
40
+
41
+ /* ── Binding ───────────────────────────────────────────────────────────── */
42
+ export { createBindingResolver } from "./binding/resolveValue";
43
+ export type { DataHandler, DataLoader, BindingScope, BindingResolver } from "./binding/resolveValue";
25
44
  export type {
26
45
  ScreenSchema,
27
46
  NodeSchema,
@@ -60,6 +79,8 @@ export type { VocabularyContract, ComponentContract, PropContract } from "./voca
60
79
  /* ── Streaming ─────────────────────────────────────────────────────────── */
61
80
  export { parsePartialSchema, isNodePending } from "./stream/tolerantParse";
62
81
  export type { PartialParseResult, StreamState } from "./stream/tolerantParse";
82
+ export { useSchemaStream } from "./stream/useSchemaStream";
83
+ export type { StreamSource } from "./stream/useSchemaStream";
63
84
 
64
85
  /* ── Compartido ────────────────────────────────────────────────────────── */
65
86
  export { JsonEngineError, issue } from "./shared/errors";
@@ -1,14 +1,18 @@
1
- import React, { useEffect, useMemo, useRef } from "react";
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { Catalog } from "../catalog/catalog.props";
3
3
  import { axzyCatalog } from "../catalog/axzyCatalog";
4
- import type { JsonEngineIssue } from "../shared/errors";
4
+ import { createBindingResolver } from "../binding/resolveValue";
5
+ import { issue, type JsonEngineIssue } from "../shared/errors";
5
6
  import { resolveLimits, type EngineLimits } from "../shared/limits";
6
- import type { SchemaPath } from "../shared/paths";
7
+ import { formatPath, type SchemaPath } from "../shared/paths";
8
+ import { faltanCapacidades } from "./capabilities";
7
9
  import { NodeBoundary } from "./NodeBoundary";
8
10
  import { auditTree } from "./partial";
9
11
  import { childList, renderNode } from "./renderNode";
10
12
  import type { ITJsonRenderEngineProps } from "./schemaRenderer.props";
11
- import type { NodeSchema } from "./types";
13
+ import type { SlotRegistry } from "./slots";
14
+ import type { EffectRegistry } from "../actions/effects";
15
+ import type { NodeSchema, SchemaValue } from "./types";
12
16
 
13
17
  /** Contexto que comparten los nodos durante un render. */
14
18
  interface RenderContext {
@@ -21,6 +25,12 @@ interface RenderContext {
21
25
  resolveValue?: ITJsonRenderEngineProps["resolveValue"];
22
26
  resolveAction?: ITJsonRenderEngineProps["resolveAction"];
23
27
  pendingFallback?: React.ReactNode;
28
+ slots?: SlotRegistry;
29
+ effects?: EffectRegistry;
30
+ /** Capacidades que concede el host. Un `requires` sin concesión no se pinta. */
31
+ capabilities?: readonly string[];
32
+ /** Escribe en el estado de la pantalla (efecto `set`). */
33
+ setState?: (cambios: Record<string, unknown>) => void;
24
34
  }
25
35
 
26
36
  /**
@@ -44,6 +54,19 @@ function renderTree(node: NodeSchema, path: SchemaPath, context: RenderContext,
44
54
  context.onIssue?.(issue);
45
55
  };
46
56
 
57
+ // Capacidades: un nodo que exige algo que el host no concede no se pinta. Si tiene
58
+ // `fallback`, se pinta el fallback en su lugar; si no, el nodo desaparece con aviso.
59
+ const faltan = faltanCapacidades(node.requires, context.capabilities);
60
+ if (faltan.length > 0) {
61
+ reportIssue(
62
+ issue("JR060", `Falta la capacidad ${faltan.join(", ")} para pintar \`${node.type}\`.`, formatPath(path), {
63
+ severity: "warning",
64
+ hint: "El host debe conceder la capacidad con la prop `capabilities`, o el nodo debe declarar un `fallback`.",
65
+ })
66
+ );
67
+ return node.fallback ? renderTree(node.fallback, [...path, "fallback"], context, level + 1) : null;
68
+ }
69
+
47
70
  const issuesBefore = issues.length;
48
71
 
49
72
  const element = renderNode(node, {
@@ -53,6 +76,12 @@ function renderTree(node: NodeSchema, path: SchemaPath, context: RenderContext,
53
76
  resolveValue: context.resolveValue,
54
77
  resolveAction: context.resolveAction,
55
78
  pendingFallback: context.pendingFallback,
79
+ slots: context.slots,
80
+ effects: context.effects,
81
+ actionTimeoutMs: context.limits.actionTimeoutMs,
82
+ onIssue: context.onIssue,
83
+ setState: context.setState,
84
+ capabilities: context.capabilities,
56
85
  renderChildren: (current, currentPath) => {
57
86
  const children = childList(current);
58
87
  if (children.length === 0) return undefined;
@@ -111,6 +140,11 @@ export function ITSchemaRenderer({
111
140
  schema,
112
141
  catalog = axzyCatalog,
113
142
  limits,
143
+ params,
144
+ dataHandlers,
145
+ slots,
146
+ effects,
147
+ capabilities,
114
148
  resolveValue,
115
149
  resolveAction,
116
150
  pendingFallback,
@@ -118,6 +152,25 @@ export function ITSchemaRenderer({
118
152
  className,
119
153
  }: ITJsonRenderEngineProps) {
120
154
  const resolvedLimits = useMemo(() => resolveLimits(limits), [limits]);
155
+
156
+ // Estado de la pantalla: el inicial del esquema más lo que escriben las acciones (efecto
157
+ // `set`). Va ligado a la identidad del esquema: una pantalla nueva empieza limpia.
158
+ const [escrito, setEscrito] = useState<{ origen: unknown; valores: Record<string, unknown> }>({
159
+ origen: schema,
160
+ valores: {},
161
+ });
162
+ const valoresPantalla = useMemo<Record<string, unknown>>(
163
+ () => ({ ...(schema?.state ?? {}), ...(escrito.origen === schema ? escrito.valores : {}) }),
164
+ [schema, escrito]
165
+ );
166
+ const escribirEstado = useCallback(
167
+ (cambios: Record<string, unknown>) =>
168
+ setEscrito((previo) => ({
169
+ origen: schema,
170
+ valores: { ...(previo.origen === schema ? previo.valores : {}), ...cambios },
171
+ })),
172
+ [schema]
173
+ );
121
174
  // Un único array de avisos por render: el resolutor y los boundaries escriben
122
175
  // en él, así que no se pierde ninguno por el camino.
123
176
  const issues: JsonEngineIssue[] = [];
@@ -137,17 +190,40 @@ export function ITSchemaRenderer({
137
190
  limits: resolvedLimits,
138
191
  issues,
139
192
  onIssue,
140
- resolveValue,
193
+ // El binding escribe sus avisos (JR030, JR032) en el mismo array que el resto del render.
194
+ resolveValue: createBindingResolver({
195
+ data: schema?.data,
196
+ state: valoresPantalla as Record<string, SchemaValue>,
197
+ params,
198
+ handlers: dataHandlers,
199
+ report: (aviso) => issues.push(aviso),
200
+ host: resolveValue,
201
+ }),
141
202
  resolveAction,
142
203
  pendingFallback,
204
+ slots,
205
+ effects,
206
+ capabilities,
207
+ setState: escribirEstado,
143
208
  }),
144
209
  // `issues` es un array nuevo en cada render a propósito: acumular avisos
145
210
  // entre renders duplicaría los avisos de esquemas que no han cambiado.
146
211
  // eslint-disable-next-line react-hooks/exhaustive-deps
147
- [catalog, resolvedLimits, onIssue, resolveValue, resolveAction, pendingFallback]
212
+ [schema, params, valoresPantalla, escribirEstado, dataHandlers, slots, effects, capabilities, catalog, resolvedLimits, onIssue, resolveValue, resolveAction, pendingFallback]
148
213
  );
149
214
 
150
- const content = schema
215
+ // Una pantalla que exige una capacidad no concedida no se pinta en absoluto.
216
+ const faltanRaiz = schema ? faltanCapacidades(schema.requires, capabilities) : [];
217
+ if (faltanRaiz.length > 0) {
218
+ issues.push(
219
+ issue("JR060", `Falta la capacidad ${faltanRaiz.join(", ")} para esta pantalla.`, "requires", {
220
+ severity: "error",
221
+ hint: "Concede la capacidad con la prop `capabilities` del renderizador.",
222
+ })
223
+ );
224
+ }
225
+
226
+ const content = schema && faltanRaiz.length === 0
151
227
  ? (Array.isArray(schema.content) ? schema.content : [schema.content]).filter(
152
228
  (node): node is NodeSchema => Boolean(node) && typeof node === "object"
153
229
  )
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Capacidades: lo que el host concede y lo que un nodo o una pantalla exige (`requires`).
3
+ *
4
+ * Denegado por defecto: si un nodo exige una capacidad y el host no la concede (o no
5
+ * pasa ninguna), el nodo no se pinta. Si tiene `fallback`, se pinta el fallback.
6
+ * El esquema nunca decide permisos: solo los nombra.
7
+ */
8
+
9
+ /**
10
+ * Normaliza `requires` (texto o lista de textos) a una lista de capacidades.
11
+ * Sin `requires`, la lista es vacía. Un valor que no se entiende devuelve `null`: el
12
+ * motor lo trata como denegado (falla cerrado), nunca como «no hay requisito».
13
+ */
14
+ export function capacidadesRequeridas(requires: unknown): string[] | null {
15
+ if (requires === undefined || requires === null) return [];
16
+ if (typeof requires === "string") return requires === "" ? null : [requires];
17
+ if (Array.isArray(requires) && requires.every((item) => typeof item === "string" && item !== "")) {
18
+ return requires as string[];
19
+ }
20
+ return null;
21
+ }
22
+
23
+ /** Capacidades que exige `requires` y que el host no concede. Lista vacía = todo concedido. */
24
+ export function faltanCapacidades(requires: unknown, concedidas: readonly string[] | undefined): string[] {
25
+ const requeridas = capacidadesRequeridas(requires);
26
+ if (requeridas === null) return ["(requires no válido)"];
27
+ if (requeridas.length === 0) return [];
28
+ const conjunto = new Set(concedidas ?? []);
29
+ return requeridas.filter((capacidad) => !conjunto.has(capacidad));
30
+ }