@axzydev/axzy_ui_system 1.7.3 → 1.8.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.
Files changed (82) hide show
  1. package/dist/index.cjs +1845 -721
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +386 -15
  5. package/dist/index.d.ts +386 -15
  6. package/dist/index.js +1532 -418
  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/button/button.tsx +7 -0
  12. package/src/components/atoms/flex/flex.props.ts +3 -1
  13. package/src/components/atoms/flex/flex.tsx +11 -3
  14. package/src/components/atoms/input/input.tsx +5 -0
  15. package/src/components/atoms/segmented-control/segmented-control.tsx +22 -5
  16. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  17. package/src/components/atoms/textarea/textarea.tsx +13 -3
  18. package/src/components/molecules/card/card.props.ts +14 -0
  19. package/src/components/molecules/card/card.tsx +66 -23
  20. package/src/components/molecules/chip-input/chip-input.tsx +2 -0
  21. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  22. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  23. package/src/components/molecules/multi-select/multi-select.tsx +2 -0
  24. package/src/components/molecules/tabs/tabs.tsx +22 -8
  25. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  26. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  27. package/src/components/organisms/page-header/page-header.tsx +22 -6
  28. package/src/components/organisms/sidebar/sidebar.tsx +8 -5
  29. package/src/components/organisms/topbar/topbar.props.ts +2 -0
  30. package/src/components/organisms/topbar/topbar.tsx +56 -34
  31. package/src/components/templates/layout/layout.tsx +3 -2
  32. package/src/components/theme-provider/themeProvider.props.ts +1 -1
  33. package/src/components/theme-provider/themeProvider.tsx +54 -1
  34. package/src/i18n/locales/en.ts +7 -0
  35. package/src/i18n/locales/es.ts +7 -0
  36. package/src/index.ts +35 -7
  37. package/src/json-renderer/actions/effects.test.tsx +115 -0
  38. package/src/json-renderer/actions/effects.ts +291 -0
  39. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  40. package/src/json-renderer/binding/binding.test.tsx +168 -0
  41. package/src/json-renderer/binding/resolveValue.ts +193 -0
  42. package/src/json-renderer/catalog/axzyCatalog.ts +5 -0
  43. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  44. package/src/json-renderer/form/FormNode.tsx +92 -0
  45. package/src/json-renderer/form/form.test.tsx +148 -0
  46. package/src/json-renderer/form/formLength.test.tsx +99 -0
  47. package/src/json-renderer/form/rules.ts +162 -0
  48. package/src/json-renderer/index.ts +21 -0
  49. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  50. package/src/json-renderer/kernel/capabilities.ts +30 -0
  51. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  52. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  53. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  54. package/src/json-renderer/kernel/slots.ts +34 -0
  55. package/src/json-renderer/kernel/types.ts +4 -0
  56. package/src/json-renderer/routing/match.ts +82 -0
  57. package/src/json-renderer/routing/navigation.ts +71 -0
  58. package/src/json-renderer/routing/routes.ts +179 -0
  59. package/src/json-renderer/routing/routing.test.ts +147 -0
  60. package/src/json-renderer/seguridad.test.tsx +299 -0
  61. package/src/json-renderer/shared/errors.ts +1 -0
  62. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  63. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  64. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  65. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  66. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  67. package/src/json-renderer/validation/validate.ts +17 -0
  68. package/src/sandbox/axzyConfig.test.ts +4 -0
  69. package/src/sandbox/axzyConfig.ts +1 -1
  70. package/src/sandbox/i18n.ts +44 -0
  71. package/src/sandbox/useHashRoute.ts +2 -1
  72. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  73. package/src/showcases/DemoJsonDocs.tsx +11 -11
  74. package/src/showcases/FeedbackShowcases.tsx +2 -1
  75. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  76. package/src/showcases/StructureShowcases.tsx +17 -2
  77. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  78. package/src/showcases/demoApp/routes.test.ts +63 -0
  79. package/src/showcases/demoApp/schemas.test.ts +6 -17
  80. package/src/theme/theme-context.ts +10 -2
  81. package/src/theme/theme.ts +1 -1
  82. package/src/theme/theme.types.ts +2 -1
@@ -0,0 +1,168 @@
1
+ import React from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen as testingScreen } from "@testing-library/react";
4
+ import { ITSchemaRenderer } from "../kernel/SchemaRenderer";
5
+ import type { ScreenSchema } from "../kernel/types";
6
+ import type { JsonEngineIssue } from "../shared/errors";
7
+ import { createBindingResolver, type BindingScope, type DataLoader } from "./resolveValue";
8
+
9
+ /** Resolutor con avisos acumulados, para leerlos en cada prueba. */
10
+ function setup(scope: Partial<BindingScope> = {}) {
11
+ const avisos: JsonEngineIssue[] = [];
12
+ const resolve = createBindingResolver({ report: (aviso) => avisos.push(aviso), ...scope });
13
+ return { resolve, avisos };
14
+ }
15
+
16
+ describe("$source", () => {
17
+ it("devuelve un loader que no llama al handler hasta que el componente lo invoca", async () => {
18
+ const handler = vi.fn(async (query: unknown) => ({ data: [], total: 0, query }));
19
+ const { resolve, avisos } = setup({
20
+ data: { usuarios: { handler: "usuarios" } },
21
+ handlers: { usuarios: handler },
22
+ });
23
+
24
+ const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
25
+ expect(typeof loader).toBe("function");
26
+ expect(handler).not.toHaveBeenCalled();
27
+
28
+ const respuesta = await loader({ page: 2 });
29
+ expect(handler).toHaveBeenCalledWith({ page: 2 }, {});
30
+ expect(respuesta).toMatchObject({ total: 0, query: { page: 2 } });
31
+ expect(avisos).toHaveLength(0);
32
+ });
33
+
34
+ it("resuelve los parámetros declarados con $state y $param antes de llamar al handler", async () => {
35
+ const handler = vi.fn(() => []);
36
+ const { resolve } = setup({
37
+ data: { pedidos: { handler: "pedidos", params: { estado: { $state: "estado" }, empresa: { $param: "empresa" }, limite: 5 } } },
38
+ state: { estado: "Pendiente" },
39
+ params: { empresa: "CheckApp" },
40
+ handlers: { pedidos: handler },
41
+ });
42
+
43
+ await (resolve({ $source: "pedidos" }, ["content", 0]) as DataLoader)();
44
+ expect(handler).toHaveBeenCalledWith(undefined, { estado: "Pendiente", empresa: "CheckApp", limite: 5 });
45
+ });
46
+
47
+ it("una fuente no declarada avisa con JR030 y su carga rechaza sin lanzar al resolver", async () => {
48
+ const { resolve, avisos } = setup({ data: {}, handlers: {} });
49
+
50
+ const loader = resolve({ $source: "pedidos" }, ["content", 1]) as DataLoader;
51
+ expect(avisos).toHaveLength(1);
52
+ expect(avisos[0]).toMatchObject({ code: "JR030", severity: "error" });
53
+ await expect(loader()).rejects.toThrow(/no está declarada en `data`/);
54
+ });
55
+
56
+ it("un handler que el host no registró avisa con JR030 y rechaza la carga", async () => {
57
+ const { resolve, avisos } = setup({ data: { usuarios: { handler: "usuarios" } }, handlers: {} });
58
+
59
+ const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
60
+ expect(avisos[0]).toMatchObject({ code: "JR030" });
61
+ await expect(loader()).rejects.toThrow(/no está registrado por el host/);
62
+ });
63
+
64
+ it("un handler que lanza se convierte en rechazo de la carga, no en error del render", async () => {
65
+ const { resolve, avisos } = setup({
66
+ data: { usuarios: { handler: "usuarios" } },
67
+ handlers: {
68
+ usuarios: () => {
69
+ throw new Error("fallo del host");
70
+ },
71
+ },
72
+ });
73
+
74
+ const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
75
+ await expect(loader()).rejects.toThrow("fallo del host");
76
+ expect(avisos).toHaveLength(0);
77
+ });
78
+
79
+ it("un $source usado como parámetro avisa con JR032 y no se resuelve", async () => {
80
+ const handler = vi.fn(() => []);
81
+ const { resolve, avisos } = setup({
82
+ data: { usuarios: { handler: "usuarios", params: { origen: { $source: "pedidos" } } } },
83
+ handlers: { usuarios: handler },
84
+ });
85
+
86
+ await (resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader)();
87
+ expect(handler).toHaveBeenCalledWith(undefined, {});
88
+ expect(avisos[0]).toMatchObject({ code: "JR032" });
89
+ });
90
+ });
91
+
92
+ describe("$state y $param", () => {
93
+ it("$state lee el estado inicial de la pantalla", () => {
94
+ const { resolve, avisos } = setup({ state: { titulo: "Hola" } });
95
+ expect(resolve({ $state: "titulo" }, ["content", 0])).toBe("Hola");
96
+ expect(avisos).toHaveLength(0);
97
+ });
98
+
99
+ it("$state sin valor avisa con JR032 y devuelve undefined", () => {
100
+ const { resolve, avisos } = setup({ state: {} });
101
+ expect(resolve({ $state: "titulo" }, ["content", 0])).toBeUndefined();
102
+ expect(avisos[0]).toMatchObject({ code: "JR032", severity: "warning" });
103
+ });
104
+
105
+ it("$param lee el valor que pone el host, no el esquema", () => {
106
+ const { resolve } = setup({ params: { id: "42" } });
107
+ expect(resolve({ $param: "id" }, ["content", 0])).toBe("42");
108
+ });
109
+
110
+ it("$param sin valor avisa con JR032", () => {
111
+ const { resolve, avisos } = setup({ params: {} });
112
+ expect(resolve({ $param: "id" }, ["content", 0])).toBeUndefined();
113
+ expect(avisos[0]).toMatchObject({ code: "JR032" });
114
+ });
115
+
116
+ it("no hereda claves de la cadena de prototipos", () => {
117
+ const { resolve, avisos } = setup({ state: {} });
118
+ expect(resolve({ $state: "toString" }, ["content", 0])).toBeUndefined();
119
+ expect(avisos[0]).toMatchObject({ code: "JR032" });
120
+ });
121
+ });
122
+
123
+ describe("operadores no soportados y valores del host", () => {
124
+ it("un operador de otra fase (`$row`) avisa con JR032 y no llega a la prop", () => {
125
+ const { resolve, avisos } = setup();
126
+ expect(resolve({ $row: "nombre" }, ["content", 0])).toBeUndefined();
127
+ expect(avisos[0]).toMatchObject({ code: "JR032", severity: "warning" });
128
+ });
129
+
130
+ it("los textos `{ i18n }` y los literales pasan al host", () => {
131
+ const host = vi.fn((value: unknown) => ({ host: value }));
132
+ const { resolve, avisos } = setup({ host });
133
+
134
+ expect(resolve({ i18n: "sandbox.app.back" }, ["content", 0])).toEqual({ host: { i18n: "sandbox.app.back" } });
135
+ expect(resolve("Hola", ["content", 0])).toEqual({ host: "Hola" });
136
+ expect(host).toHaveBeenCalledTimes(2);
137
+ expect(avisos).toHaveLength(0);
138
+ });
139
+
140
+ it("sin host, los valores que no son referencias se devuelven tal cual", () => {
141
+ const { resolve } = setup();
142
+ expect(resolve(3, ["content", 0])).toBe(3);
143
+ });
144
+ });
145
+
146
+ describe("ITSchemaRenderer con binding", () => {
147
+ const pantalla = (estado: Partial<ScreenSchema>, content: unknown): ScreenSchema =>
148
+ ({ version: 1, id: "binding", content, ...estado }) as ScreenSchema;
149
+
150
+ it("pinta un $state declarado en la pantalla", () => {
151
+ render(
152
+ <ITSchemaRenderer
153
+ schema={pantalla({ state: { titulo: "Estado de pantalla" } }, { type: "ITText", text: { $state: "titulo" } })}
154
+ />
155
+ );
156
+ expect(testingScreen.getByText("Estado de pantalla")).toBeTruthy();
157
+ });
158
+
159
+ it("pinta un $param que llega por la prop `params`", () => {
160
+ render(
161
+ <ITSchemaRenderer
162
+ schema={pantalla({}, { type: "ITText", text: { $param: "nombre" } })}
163
+ params={{ nombre: "Ruta con parámetro" }}
164
+ />
165
+ );
166
+ expect(testingScreen.getByText("Ruta con parámetro")).toBeTruthy();
167
+ });
168
+ });
@@ -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
+ }
@@ -91,6 +91,11 @@ export const AXZY_CATALOG_ENTRIES: CatalogEntry[] = [
91
91
  summary: "Tarjeta con título, imagen y contenido.",
92
92
  props: {
93
93
  title: { kind: "string" },
94
+ tone: {
95
+ kind: "enum",
96
+ values: ["primary", "secondary", "ternary", "success", "info", "warning", "danger"],
97
+ doc: "Tinte pastel de la tarjeta (token --color-<tone>-soft).",
98
+ },
94
99
  image: { kind: "string", doc: "URL de la imagen de cabecera." },
95
100
  alt: { kind: "string" },
96
101
  onClick: { kind: "action" },
@@ -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;