@axzydev/axzy_ui_system 1.7.3 → 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.
- package/dist/index.cjs +1621 -594
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +366 -12
- package/dist/index.d.ts +366 -12
- package/dist/index.js +1331 -313
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +4 -2
- package/src/App.tsx +6 -8
- package/src/components/atoms/flex/flex.props.ts +3 -1
- package/src/components/atoms/flex/flex.tsx +11 -3
- package/src/components/atoms/textarea/textarea.props.ts +4 -0
- package/src/components/atoms/textarea/textarea.tsx +13 -3
- package/src/components/molecules/card/card.props.ts +2 -0
- package/src/components/molecules/card/card.tsx +15 -2
- package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
- package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
- package/src/components/organisms/dropfile/dropfile.tsx +17 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +7 -0
- package/src/index.ts +31 -3
- package/src/json-renderer/actions/effects.test.tsx +115 -0
- package/src/json-renderer/actions/effects.ts +291 -0
- package/src/json-renderer/actions/effectsChain.test.ts +117 -0
- package/src/json-renderer/binding/binding.test.tsx +168 -0
- package/src/json-renderer/binding/resolveValue.ts +193 -0
- package/src/json-renderer/estado-acciones.test.tsx +166 -0
- package/src/json-renderer/form/FormNode.tsx +92 -0
- package/src/json-renderer/form/form.test.tsx +148 -0
- package/src/json-renderer/form/formLength.test.tsx +99 -0
- package/src/json-renderer/form/rules.ts +162 -0
- package/src/json-renderer/index.ts +21 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
- package/src/json-renderer/kernel/capabilities.ts +30 -0
- package/src/json-renderer/kernel/renderNode.tsx +256 -3
- package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
- package/src/json-renderer/kernel/slots.test.tsx +104 -0
- package/src/json-renderer/kernel/slots.ts +34 -0
- package/src/json-renderer/kernel/types.ts +4 -0
- package/src/json-renderer/routing/match.ts +82 -0
- package/src/json-renderer/routing/navigation.ts +71 -0
- package/src/json-renderer/routing/routes.ts +179 -0
- package/src/json-renderer/routing/routing.test.ts +147 -0
- package/src/json-renderer/seguridad.test.tsx +299 -0
- package/src/json-renderer/shared/errors.ts +1 -0
- package/src/json-renderer/stream/tolerantParse.ts +5 -0
- package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
- package/src/json-renderer/stream/useSchemaStream.ts +227 -0
- package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
- package/src/json-renderer/validation/jsonSchema.ts +317 -0
- package/src/json-renderer/validation/validate.ts +17 -0
- package/src/sandbox/i18n.ts +38 -0
- package/src/sandbox/useHashRoute.ts +2 -1
- package/src/showcases/DemoJsonDashboard.tsx +218 -103
- package/src/showcases/DemoJsonDocs.tsx +11 -11
- package/src/showcases/JsonScreenShowcase.tsx +43 -53
- package/src/showcases/demoApp/demoJson.test.tsx +241 -0
- package/src/showcases/demoApp/routes.test.ts +63 -0
- package/src/showcases/demoApp/schemas.test.ts +6 -17
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding mínimo del motor: resuelve las referencias declarativas del esquema
|
|
3
|
+
* (`$source`, `$state`, `$param`) antes de pintar cada prop.
|
|
4
|
+
*
|
|
5
|
+
* Solo lee y nunca hace red. Una fuente es un **loader**: el componente lo invoca
|
|
6
|
+
* cuando necesita datos (una tabla, al paginar o filtrar) y la función que lo
|
|
7
|
+
* atiende es un `handler` que registra el host por nombre. El esquema no puede
|
|
8
|
+
* nombrar una URL ni un endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Escribir estado llega con las acciones (fase 5); `$row` y `$form` llegan con las
|
|
11
|
+
* tablas y los formularios (fase 8). Hasta entonces avisan con `JR032`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { issue, type JsonEngineIssue } from "../shared/errors";
|
|
15
|
+
import { formatPath, type SchemaPath } from "../shared/paths";
|
|
16
|
+
import type { DataSourceSchema, SchemaValue } from "../kernel/types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handler que registra el host. Recibe la query que pide el componente (por ejemplo,
|
|
20
|
+
* página y filtros de una tabla) y los parámetros declarados en la fuente.
|
|
21
|
+
*/
|
|
22
|
+
export type DataHandler = (query: unknown, params: Record<string, SchemaValue>) => unknown;
|
|
23
|
+
|
|
24
|
+
/** Función de carga que recibe el componente. Siempre devuelve una promesa. */
|
|
25
|
+
export type DataLoader = (query?: unknown) => Promise<unknown>;
|
|
26
|
+
|
|
27
|
+
/** Lo que el binding necesita del esquema, del host y del render en curso. */
|
|
28
|
+
export interface BindingScope {
|
|
29
|
+
/** Fuentes declaradas por la pantalla (`ScreenSchema.data`). */
|
|
30
|
+
data?: Record<string, DataSourceSchema>;
|
|
31
|
+
/** Estado inicial de la pantalla (`ScreenSchema.state`). */
|
|
32
|
+
state?: Record<string, SchemaValue>;
|
|
33
|
+
/** Parámetros de la ruta, aportados por el host (`$param`). */
|
|
34
|
+
params?: Record<string, SchemaValue>;
|
|
35
|
+
/** Handlers que registra el host, por nombre. */
|
|
36
|
+
handlers?: Record<string, DataHandler>;
|
|
37
|
+
/** Recibe los avisos del binding (`JR030`, `JR032`). */
|
|
38
|
+
report: (aviso: JsonEngineIssue) => void;
|
|
39
|
+
/** Resuelve el resto de valores (por ejemplo, textos `{ i18n }`). Lo aporta el host. */
|
|
40
|
+
host?: (value: unknown, path: SchemaPath) => unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolutor de valores que consume el kernel al pintar cada prop. */
|
|
44
|
+
export type BindingResolver = (value: unknown, path: SchemaPath) => unknown;
|
|
45
|
+
|
|
46
|
+
/** Claves que una fuente no puede declarar: cualquiera de ellas sería una petición de red. */
|
|
47
|
+
const CLAVES_DE_RED = ["url", "endpoint", "href", "src"];
|
|
48
|
+
|
|
49
|
+
/** Operadores que el binding mínimo sabe resolver. */
|
|
50
|
+
const SUPPORTED_REFERENCES = ["$source", "$state", "$param"] as const;
|
|
51
|
+
|
|
52
|
+
/** `true` cuando la clave existe en el objeto (sin heredar de la cadena de prototipos). */
|
|
53
|
+
function own(record: object | undefined, key: string): boolean {
|
|
54
|
+
return record !== undefined && Object.prototype.hasOwnProperty.call(record, key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `true` cuando el valor es un objeto con alguna clave que empieza por `$`. */
|
|
58
|
+
function isDollarReference(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
60
|
+
return Object.keys(value).some((key) => key.startsWith("$"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Aviso de referencia sin valor o no soportada. */
|
|
64
|
+
function referenceIssue(message: string, path: SchemaPath, hint: string): JsonEngineIssue {
|
|
65
|
+
return issue("JR032", message, formatPath(path), { severity: "warning", hint });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Crea el resolutor de valores de una pantalla.
|
|
70
|
+
*
|
|
71
|
+
* Las referencias se resuelven aquí; el resto de valores pasa al `host`. Los
|
|
72
|
+
* loaders de fuente no llaman a ningún handler hasta que el componente los invoca.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const resolve = createBindingResolver({
|
|
76
|
+
* data: { usuarios: { handler: "usuarios" } },
|
|
77
|
+
* handlers: { usuarios: (query) => fetchUsuarios(query) },
|
|
78
|
+
* report: (aviso) => issues.push(aviso),
|
|
79
|
+
* });
|
|
80
|
+
* resolve({ $source: "usuarios" }, ["content", 0]); // loader (no se llama aún)
|
|
81
|
+
*/
|
|
82
|
+
export function createBindingResolver(scope: BindingScope): BindingResolver {
|
|
83
|
+
/** Lee `$state` o `$param`. Si no hay valor, avisa y devuelve `undefined`. */
|
|
84
|
+
const readValue = (
|
|
85
|
+
table: Record<string, SchemaValue> | undefined,
|
|
86
|
+
kind: "$state" | "$param",
|
|
87
|
+
key: string,
|
|
88
|
+
path: SchemaPath
|
|
89
|
+
): SchemaValue | undefined => {
|
|
90
|
+
if (own(table, key)) return table![key];
|
|
91
|
+
scope.report(
|
|
92
|
+
referenceIssue(
|
|
93
|
+
`La referencia \`${kind}: "${key}"\` no tiene valor.`,
|
|
94
|
+
path,
|
|
95
|
+
kind === "$state" ? "Declara el valor en `state` del esquema." : "El host debe pasar el parámetro en `params`."
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Resuelve los parámetros de una fuente. Solo admite valores y `$state`/`$param`. */
|
|
102
|
+
const resolveParams = (declared: Record<string, SchemaValue> | undefined, path: SchemaPath) => {
|
|
103
|
+
const resolved: Record<string, SchemaValue> = {};
|
|
104
|
+
for (const [name, raw] of Object.entries(declared ?? {})) {
|
|
105
|
+
if (!isDollarReference(raw)) {
|
|
106
|
+
resolved[name] = raw;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const [key] = Object.keys(raw).filter((candidate) => candidate.startsWith("$"));
|
|
110
|
+
if (key === "$state" && typeof raw[key] === "string") {
|
|
111
|
+
const value = readValue(scope.state, "$state", raw[key] as string, path);
|
|
112
|
+
if (value !== undefined) resolved[name] = value;
|
|
113
|
+
} else if (key === "$param" && typeof raw[key] === "string") {
|
|
114
|
+
const value = readValue(scope.params, "$param", raw[key] as string, path);
|
|
115
|
+
if (value !== undefined) resolved[name] = value;
|
|
116
|
+
} else {
|
|
117
|
+
scope.report(
|
|
118
|
+
referenceIssue(
|
|
119
|
+
"Un parámetro de fuente solo admite valores, `$state` o `$param`.",
|
|
120
|
+
path,
|
|
121
|
+
"Las fuentes no se encadenan: declara la fuente sin referencias a otras fuentes."
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return resolved;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Loader que falla de forma controlada: avisa con `JR030` y rechaza la carga. */
|
|
130
|
+
const failingLoader = (message: string, path: SchemaPath, hint: string): DataLoader => {
|
|
131
|
+
scope.report(issue("JR030", message, formatPath(path), { severity: "error", hint }));
|
|
132
|
+
return () => Promise.reject(new Error(message));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Crea el loader de una fuente declarada. No invoca el handler todavía. */
|
|
136
|
+
const sourceLoader = (name: string, path: SchemaPath): DataLoader => {
|
|
137
|
+
if (!own(scope.data, name)) {
|
|
138
|
+
return failingLoader(
|
|
139
|
+
`La fuente \`${name}\` no está declarada en \`data\`.`,
|
|
140
|
+
path,
|
|
141
|
+
"Declárala en `data` del esquema con el `handler` que registra el host."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const declared = scope.data![name];
|
|
145
|
+
// Una fuente nunca pide red: si el esquema intenta declarar una URL, se rechaza y se avisa.
|
|
146
|
+
const claveDeRed = Object.keys(declared).find((clave) => CLAVES_DE_RED.includes(clave));
|
|
147
|
+
if (claveDeRed) {
|
|
148
|
+
scope.report(
|
|
149
|
+
issue("JR031", `El esquema declara \`${claveDeRed}\` en la fuente "${name}": las fuentes no pueden pedir red.`, formatPath(path), {
|
|
150
|
+
severity: "error",
|
|
151
|
+
hint: "Una fuente solo nombra un `handler` que registra el host.",
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
return () => Promise.reject(new Error(`La fuente "${name}" no puede pedir red.`));
|
|
155
|
+
}
|
|
156
|
+
const handler = own(scope.handlers, declared.handler) ? scope.handlers![declared.handler] : undefined;
|
|
157
|
+
if (typeof handler !== "function") {
|
|
158
|
+
return failingLoader(
|
|
159
|
+
`El handler \`${declared.handler}\` no está registrado por el host.`,
|
|
160
|
+
path,
|
|
161
|
+
"El host debe registrarlo en `dataHandlers` del renderizador."
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const params = resolveParams(declared.params, path);
|
|
165
|
+
// Dentro de la promesa: un handler que lanza se convierte en rechazo, no en error del render.
|
|
166
|
+
return (query) => new Promise((resolve) => resolve(handler(query, params)));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Resuelve una referencia `$…` de una prop. Operadores no soportados avisan. */
|
|
170
|
+
const resolveReference = (ref: Record<string, unknown>, path: SchemaPath): unknown => {
|
|
171
|
+
const keys = Object.keys(ref).filter((key) => key.startsWith("$"));
|
|
172
|
+
const [key] = keys;
|
|
173
|
+
if (keys.length !== 1 || !(SUPPORTED_REFERENCES as readonly string[]).includes(key) || typeof ref[key] !== "string") {
|
|
174
|
+
scope.report(
|
|
175
|
+
referenceIssue(
|
|
176
|
+
`Referencia no soportada: ${keys.join(", ") || "sin operador"}.`,
|
|
177
|
+
path,
|
|
178
|
+
"Soportadas: `$source`, `$state` y `$param`. Los demás operadores llegan con las acciones y las tablas."
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const name = ref[key] as string;
|
|
184
|
+
if (key === "$source") return sourceLoader(name, path);
|
|
185
|
+
if (key === "$state") return readValue(scope.state, "$state", name, path);
|
|
186
|
+
return readValue(scope.params, "$param", name, path);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (value, path) => {
|
|
190
|
+
if (isDollarReference(value)) return resolveReference(value, path);
|
|
191
|
+
return scope.host ? scope.host(value, path) : value;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fireEvent, render, screen as testingScreen, waitFor } 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
|
+
|
|
8
|
+
/** Esquema mínimo con el contenido indicado y un estado inicial opcional. */
|
|
9
|
+
const pantalla = (content: unknown, state?: Record<string, unknown>): ScreenSchema =>
|
|
10
|
+
({ version: 1, id: "estado", content, ...(state ? { state } : {}) }) as ScreenSchema;
|
|
11
|
+
|
|
12
|
+
/** Acumula los avisos del renderizador. */
|
|
13
|
+
function avisos() {
|
|
14
|
+
const lista: JsonEngineIssue[] = [];
|
|
15
|
+
return { lista, onIssue: (aviso: JsonEngineIssue) => lista.push(aviso) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("efecto set: escribir en el estado de la pantalla", () => {
|
|
19
|
+
it("un botón escribe un valor y el texto que lo lee se actualiza", () => {
|
|
20
|
+
render(
|
|
21
|
+
<ITSchemaRenderer
|
|
22
|
+
schema={pantalla(
|
|
23
|
+
[
|
|
24
|
+
{ type: "ITText", text: { $state: "titulo" } },
|
|
25
|
+
{
|
|
26
|
+
type: "ITButton",
|
|
27
|
+
props: { label: "Cambiar", onClick: { action: "cambiar", then: [{ set: { titulo: "Nuevo" } }] } },
|
|
28
|
+
} as NodeSchema,
|
|
29
|
+
],
|
|
30
|
+
{ titulo: "Inicial" }
|
|
31
|
+
)}
|
|
32
|
+
resolveAction={() => () => undefined}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
expect(testingScreen.getByText("Inicial")).toBeTruthy();
|
|
36
|
+
|
|
37
|
+
fireEvent.click(testingScreen.getByRole("button", { name: "Cambiar" }));
|
|
38
|
+
|
|
39
|
+
expect(testingScreen.getByText("Nuevo")).toBeTruthy();
|
|
40
|
+
expect(testingScreen.queryByText("Inicial")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("set puede tomar un dato del resultado del handler con $result", () => {
|
|
44
|
+
render(
|
|
45
|
+
<ITSchemaRenderer
|
|
46
|
+
schema={pantalla([
|
|
47
|
+
{ type: "ITText", text: { $state: "folio" } },
|
|
48
|
+
{
|
|
49
|
+
type: "ITButton",
|
|
50
|
+
props: { label: "Crear", onClick: { action: "crear", then: [{ set: { folio: { $result: "id" } } }] } },
|
|
51
|
+
} as NodeSchema,
|
|
52
|
+
])}
|
|
53
|
+
resolveAction={() => () => ({ id: "PED-7" })}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
fireEvent.click(testingScreen.getByRole("button", { name: "Crear" }));
|
|
57
|
+
expect(testingScreen.getByText("PED-7")).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("al cambiar de esquema, el estado vuelve al inicial del nuevo esquema", () => {
|
|
61
|
+
const primera = pantalla([{ type: "ITText", text: { $state: "titulo" } }], { titulo: "A" });
|
|
62
|
+
const { rerender } = render(<ITSchemaRenderer schema={primera} />);
|
|
63
|
+
expect(testingScreen.getByText("A")).toBeTruthy();
|
|
64
|
+
|
|
65
|
+
rerender(<ITSchemaRenderer schema={pantalla([{ type: "ITText", text: { $state: "titulo" } }], { titulo: "B" })} />);
|
|
66
|
+
expect(testingScreen.getByText("B")).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("JR061: permisos por acción", () => {
|
|
71
|
+
it("una acción que exige una capacidad no concedida no se ejecuta y avisa con JR061", () => {
|
|
72
|
+
const handler = vi.fn();
|
|
73
|
+
const { lista, onIssue } = avisos();
|
|
74
|
+
render(
|
|
75
|
+
<ITSchemaRenderer
|
|
76
|
+
schema={pantalla({
|
|
77
|
+
type: "ITButton",
|
|
78
|
+
props: { label: "Borrar", onClick: { action: "borrar", requires: "usuarios.eliminar" } },
|
|
79
|
+
})}
|
|
80
|
+
resolveAction={() => handler}
|
|
81
|
+
onIssue={onIssue}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
fireEvent.click(testingScreen.getByRole("button", { name: "Borrar" }));
|
|
85
|
+
expect(handler).not.toHaveBeenCalled();
|
|
86
|
+
expect(lista.some((aviso) => aviso.code === "JR061")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("con la capacidad concedida, la acción sí se ejecuta", () => {
|
|
90
|
+
const handler = vi.fn();
|
|
91
|
+
render(
|
|
92
|
+
<ITSchemaRenderer
|
|
93
|
+
schema={pantalla({
|
|
94
|
+
type: "ITButton",
|
|
95
|
+
props: { label: "Borrar", onClick: { action: "borrar", requires: "usuarios.eliminar" } },
|
|
96
|
+
})}
|
|
97
|
+
resolveAction={() => handler}
|
|
98
|
+
capabilities={["usuarios.eliminar"]}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
fireEvent.click(testingScreen.getByRole("button", { name: "Borrar" }));
|
|
102
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("botones de fila en tablas", () => {
|
|
107
|
+
const tabla = (rowActions: unknown[]) =>
|
|
108
|
+
pantalla({
|
|
109
|
+
type: "ITDataTable",
|
|
110
|
+
props: {
|
|
111
|
+
columns: [
|
|
112
|
+
{ key: "nombre", label: "Nombre", type: "string" },
|
|
113
|
+
{ key: "acciones", label: "Acciones", type: "actions", rowActions },
|
|
114
|
+
],
|
|
115
|
+
fetchData: { $source: "personas" },
|
|
116
|
+
},
|
|
117
|
+
}, undefined) as ScreenSchema;
|
|
118
|
+
|
|
119
|
+
const conFuente = (esquema: ScreenSchema) => ({
|
|
120
|
+
...esquema,
|
|
121
|
+
data: { personas: { handler: "personas" } },
|
|
122
|
+
}) as ScreenSchema;
|
|
123
|
+
|
|
124
|
+
const filas = { personas: async () => ({ data: [{ id: 1, nombre: "Ana" }], total: 1 }) };
|
|
125
|
+
|
|
126
|
+
it("cada fila muestra el botón declarado y pulsarlo llama a la acción con la fila", async () => {
|
|
127
|
+
const ver = vi.fn();
|
|
128
|
+
render(
|
|
129
|
+
<ITSchemaRenderer
|
|
130
|
+
schema={conFuente(tabla([{ label: "Ver", action: "ver" }]))}
|
|
131
|
+
dataHandlers={filas}
|
|
132
|
+
resolveAction={(_node, _prop, accion) => ((accion as { action?: string }).action === "ver" ? ver : undefined)}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
const boton = await testingScreen.findByRole("button", { name: "Ver" });
|
|
136
|
+
fireEvent.click(boton);
|
|
137
|
+
expect(ver).toHaveBeenCalledWith(expect.objectContaining({ id: 1, nombre: "Ana" }));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("un botón de fila sin handler no se pinta y avisa con JR040", async () => {
|
|
141
|
+
const { lista, onIssue } = avisos();
|
|
142
|
+
render(
|
|
143
|
+
<ITSchemaRenderer
|
|
144
|
+
schema={conFuente(tabla([{ label: "Borrar", action: "no-existe" }]))}
|
|
145
|
+
dataHandlers={filas}
|
|
146
|
+
resolveAction={() => undefined}
|
|
147
|
+
onIssue={onIssue}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
await waitFor(() => expect(lista.some((aviso) => aviso.code === "JR040")).toBe(true));
|
|
151
|
+
expect(testingScreen.queryByRole("button", { name: "Borrar" })).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("un botón de fila sin action avisa con JR021", async () => {
|
|
155
|
+
const { lista, onIssue } = avisos();
|
|
156
|
+
render(
|
|
157
|
+
<ITSchemaRenderer
|
|
158
|
+
schema={conFuente(tabla([{ label: "Roto" }]))}
|
|
159
|
+
dataHandlers={filas}
|
|
160
|
+
resolveAction={() => () => undefined}
|
|
161
|
+
onIssue={onIssue}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
await waitFor(() => expect(lista.some((aviso) => aviso.code === "JR021")).toBe(true));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nodo `form` del motor: campos declarados en JSON, validación y envío por acción.
|
|
3
|
+
*
|
|
4
|
+
* El esquema describe los campos; el motor los pinta con `ITInput`, valida al enviar
|
|
5
|
+
* y muestra el error bajo cada campo. Los valores válidos salen por la acción
|
|
6
|
+
* `onSubmit` que resuelve el host: el host decide qué hacer con ellos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState } from "react";
|
|
10
|
+
import ITButton from "../../components/atoms/button/button";
|
|
11
|
+
import ITFlex from "../../components/atoms/flex/flex";
|
|
12
|
+
import ITInput from "../../components/atoms/input/input";
|
|
13
|
+
import ITStack from "../../components/atoms/stack/stack";
|
|
14
|
+
import { useITTranslation } from "../../i18n/useITTranslation";
|
|
15
|
+
import { validateForm, type FormField, type FormRuleCode } from "./rules";
|
|
16
|
+
|
|
17
|
+
/** Props del nodo `form` ya resueltos por el renderizador. */
|
|
18
|
+
export interface FormNodeProps {
|
|
19
|
+
/** Id del nodo: se refleja en el DOM como ancla. */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Campos, con sus textos ya traducidos. */
|
|
22
|
+
fields: FormField[];
|
|
23
|
+
/** Texto del botón de envío. Sin valor, `jsonRenderer.form.submit`. */
|
|
24
|
+
submitLabel?: string;
|
|
25
|
+
/** Acción del host: recibe los valores de los campos cuando el formulario es válido. */
|
|
26
|
+
onSubmit?: (values: Record<string, string>) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Valor de un evento o de un valor directo de `ITInput`. */
|
|
30
|
+
function valorDe(evento: unknown): string {
|
|
31
|
+
if (typeof evento === "string") return evento;
|
|
32
|
+
return (evento as { target?: { value?: unknown } } | null)?.target?.value?.toString() ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Formulario declarado en JSON. Nunca lanza por los valores que escribe el usuario.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <FormNode fields={[{ name: "correo", kind: "email", label: "Correo", required: true }]}
|
|
40
|
+
* submitLabel="Enviar" onSubmit={(valores) => console.log(valores)} />
|
|
41
|
+
*/
|
|
42
|
+
export function FormNode({ id, fields, submitLabel, onSubmit }: FormNodeProps) {
|
|
43
|
+
const { t } = useITTranslation();
|
|
44
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
45
|
+
const [errors, setErrors] = useState<Record<string, FormRuleCode>>({});
|
|
46
|
+
// Desde el primer envío, los campos se consideran tocados: sus errores se muestran.
|
|
47
|
+
const [intentado, setIntentado] = useState(false);
|
|
48
|
+
|
|
49
|
+
/** Mensaje traducido para un error de campo. Las longitudes van como parámetros. */
|
|
50
|
+
const mensaje = (code: FormRuleCode, campo: FormField) =>
|
|
51
|
+
t(`jsonRenderer.form.${code}`, { min: String(campo.minLength ?? 0), max: String(campo.maxLength ?? 0) });
|
|
52
|
+
|
|
53
|
+
const enviar = () => {
|
|
54
|
+
const encontrados = validateForm(fields, values);
|
|
55
|
+
setIntentado(true);
|
|
56
|
+
setErrors(encontrados);
|
|
57
|
+
if (Object.keys(encontrados).length === 0) onSubmit?.(values);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div id={id}>
|
|
62
|
+
<ITStack direction="column" spacing={3} alignItems="stretch">
|
|
63
|
+
{fields.map((field) => (
|
|
64
|
+
<ITInput
|
|
65
|
+
key={field.name}
|
|
66
|
+
name={field.name}
|
|
67
|
+
label={field.label}
|
|
68
|
+
type={field.kind === "email" ? "email" : "text"}
|
|
69
|
+
value={values[field.name] ?? ""}
|
|
70
|
+
touched={intentado}
|
|
71
|
+
error={errors[field.name] ? mensaje(errors[field.name], field) : undefined}
|
|
72
|
+
onChange={(evento: unknown) => {
|
|
73
|
+
const valor = valorDe(evento);
|
|
74
|
+
setValues((previo) => ({ ...previo, [field.name]: valor }));
|
|
75
|
+
setErrors((previo) => {
|
|
76
|
+
if (!previo[field.name]) return previo;
|
|
77
|
+
const resto = { ...previo };
|
|
78
|
+
delete resto[field.name];
|
|
79
|
+
return resto;
|
|
80
|
+
});
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
<ITFlex>
|
|
85
|
+
<ITButton label={submitLabel ?? t("jsonRenderer.form.submit")} size="sm" onClick={enviar} />
|
|
86
|
+
</ITFlex>
|
|
87
|
+
</ITStack>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default FormNode;
|
|
@@ -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
|
+
});
|