@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrato de rutas declarativas: un `routes.json` describe qué pantalla pinta cada
|
|
3
|
+
* dirección, con qué parámetros y query, y qué entradas aparecen en la navegación.
|
|
4
|
+
*
|
|
5
|
+
* Como el resto del motor, es entrada no confiable: no hay código ni URLs externas,
|
|
6
|
+
* solo nombres de pantalla (`page`) que el host resuelve. Las rutas con `children`
|
|
7
|
+
* son grupos (agrupan entradas de menú); solo las hojas se pueden pintar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { issue, type JsonEngineIssue } from "../shared/errors";
|
|
11
|
+
import type { SchemaValue } from "../kernel/types";
|
|
12
|
+
|
|
13
|
+
/** Entrada de menú asociada a una ruta. Sin `nav`, la ruta existe pero no se muestra. */
|
|
14
|
+
export interface RouteNavSchema {
|
|
15
|
+
/** Texto de la entrada; admite `{ i18n }`. */
|
|
16
|
+
label: SchemaValue;
|
|
17
|
+
/** Nombre de icono del registro del host. */
|
|
18
|
+
icon?: string;
|
|
19
|
+
/** Contador visible junto a la entrada. */
|
|
20
|
+
badge?: number | string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ruta tal como la escribe el JSON. */
|
|
24
|
+
export interface RouteSchema {
|
|
25
|
+
/** Identificador estable: lo usa la navegación para marcar la entrada activa. */
|
|
26
|
+
id: string;
|
|
27
|
+
/**
|
|
28
|
+
* Segmentos de la ruta, relativos al padre (`usuarios`, `:id`). Los hijos sin `path`
|
|
29
|
+
* heredan el del padre. Las rutas raíz empiezan por `/`.
|
|
30
|
+
*/
|
|
31
|
+
path?: string;
|
|
32
|
+
/** Pantalla JSON que pinta la ruta. Obligatoria en las hojas; prohibida en los grupos. */
|
|
33
|
+
page?: string;
|
|
34
|
+
/** Query que exige la ruta (`{ estado: "Activo" }`). Más claves = más específica. */
|
|
35
|
+
query?: Record<string, string>;
|
|
36
|
+
/** Entrada de menú. */
|
|
37
|
+
nav?: RouteNavSchema;
|
|
38
|
+
/** Rutas hijas. Si existen, la ruta es un grupo. */
|
|
39
|
+
children?: RouteSchema[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Documento de rutas completo. */
|
|
43
|
+
export interface RoutesSchema {
|
|
44
|
+
/** Identificador del formato. */
|
|
45
|
+
$schema?: "axzy/routes";
|
|
46
|
+
/** Versión del formato. */
|
|
47
|
+
version: number;
|
|
48
|
+
/** Rutas raíz. */
|
|
49
|
+
routes: RouteSchema[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Ruta aplanada y lista para casarla: solo hojas, con su path completo. */
|
|
53
|
+
export interface FlatRoute {
|
|
54
|
+
/** Identificador de la ruta. */
|
|
55
|
+
id: string;
|
|
56
|
+
/** Segmentos completos, sin barras (`["usuarios", ":id"]`). Vacío para la raíz. */
|
|
57
|
+
segments: string[];
|
|
58
|
+
/** Pantalla que pinta. */
|
|
59
|
+
page: string;
|
|
60
|
+
/** Query que exige la ruta. */
|
|
61
|
+
query: Record<string, string>;
|
|
62
|
+
/** Entrada de menú, si la tiene. */
|
|
63
|
+
nav?: RouteNavSchema;
|
|
64
|
+
/** Ids de los grupos a los que pertenece, de fuera a dentro. */
|
|
65
|
+
groups: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Une un path de padre con el propio y normaliza las barras. */
|
|
69
|
+
export function joinRoutePath(parent: string, own: unknown): string {
|
|
70
|
+
return [parent, typeof own === "string" ? own : ""]
|
|
71
|
+
.flatMap((part) => part.split("/"))
|
|
72
|
+
.filter((segment) => segment !== "")
|
|
73
|
+
.join("/");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Segmentos válidos: letras, dígitos, guiones, puntos o `:param`. */
|
|
77
|
+
const SEGMENT = /^(:[A-Za-z][A-Za-z0-9_]*|[A-Za-z0-9_.-]+)$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Valida un documento de rutas. Nunca lanza: devuelve los avisos con su código y su ruta.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* validateRoutes({ version: 1, routes: [{ id: "inicio", path: "/inicio", page: "inicio" }] }); // []
|
|
84
|
+
*/
|
|
85
|
+
export function validateRoutes(input: unknown): JsonEngineIssue[] {
|
|
86
|
+
const errors: JsonEngineIssue[] = [];
|
|
87
|
+
const document = input as Partial<RoutesSchema> | null | undefined;
|
|
88
|
+
|
|
89
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
90
|
+
return [issue("JR020", "El documento de rutas debe ser un objeto.", "", { severity: "error" })];
|
|
91
|
+
}
|
|
92
|
+
if (typeof document.version !== "number") {
|
|
93
|
+
errors.push(issue("JR020", "El documento de rutas no declara `version`.", "version", { severity: "error" }));
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(document.routes)) {
|
|
96
|
+
errors.push(issue("JR020", "`routes` debe ser una lista.", "routes", { severity: "error" }));
|
|
97
|
+
return errors;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const seen = new Map<string, string>();
|
|
101
|
+
|
|
102
|
+
const visit = (route: RouteSchema, path: string, parentPath: string, isRoot: boolean) => {
|
|
103
|
+
if (!route || typeof route !== "object") {
|
|
104
|
+
errors.push(issue("JR021", "La ruta no es un objeto.", path, { severity: "error" }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (typeof route.id !== "string" || route.id === "") {
|
|
108
|
+
errors.push(issue("JR021", "La ruta no declara `id`.", path, { severity: "error" }));
|
|
109
|
+
}
|
|
110
|
+
if (route.path !== undefined && route.path !== "") {
|
|
111
|
+
const parts = typeof route.path === "string" ? route.path.split("/").filter(Boolean) : [];
|
|
112
|
+
if (typeof route.path !== "string" || parts.some((segment) => !SEGMENT.test(segment))) {
|
|
113
|
+
errors.push(issue("JR021", `El path "${String(route.path)}" no es válido.`, `${path}.path`, { severity: "error", hint: "Usa segmentos con letras, dígitos, guiones o `:param`." }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const full = joinRoutePath(parentPath, route.path);
|
|
118
|
+
if (isRoot && full === "") {
|
|
119
|
+
errors.push(issue("JR021", "Una ruta raíz necesita `path`.", `${path}.path`, { severity: "error" }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (route.children !== undefined) {
|
|
123
|
+
if (route.page !== undefined) {
|
|
124
|
+
errors.push(issue("JR020", "Un grupo no lleva `page`: la pantalla va en sus hijos.", path, { severity: "error" }));
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(route.children)) {
|
|
127
|
+
errors.push(issue("JR020", "`children` debe ser una lista.", `${path}.children`, { severity: "error" }));
|
|
128
|
+
} else {
|
|
129
|
+
route.children.forEach((child, index) => visit(child, `${path}.children[${index}]`, full, false));
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
if (typeof route.page !== "string" || route.page === "") {
|
|
133
|
+
errors.push(issue("JR020", "La ruta no declara `page`.", path, { severity: "error" }));
|
|
134
|
+
}
|
|
135
|
+
const key = `${full}?${JSON.stringify(Object.entries(route.query ?? {}).sort())}`;
|
|
136
|
+
if (seen.has(key)) {
|
|
137
|
+
errors.push(issue("JR020", `La ruta "${full || "/"}" se repite con la misma query (ya está en ${seen.get(key)}).`, path, { severity: "error" }));
|
|
138
|
+
} else {
|
|
139
|
+
seen.set(key, path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const nav = route.nav as Partial<RouteNavSchema> | null | undefined;
|
|
144
|
+
if (nav !== undefined && (typeof nav !== "object" || nav === null || nav.label === undefined || nav.label === null)) {
|
|
145
|
+
errors.push(issue("JR021", "La entrada de menú no declara `label`.", `${path}.nav`, { severity: "error" }));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
document.routes.forEach((route, index) => visit(route, `routes[${index}]`, "", true));
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Aplana las rutas en hojas con su path completo. Asume un documento válido
|
|
155
|
+
* (ver `validateRoutes`); las ramas sin `page` se descartan.
|
|
156
|
+
*/
|
|
157
|
+
export function flattenRoutes(routes: RouteSchema[]): FlatRoute[] {
|
|
158
|
+
const flat: FlatRoute[] = [];
|
|
159
|
+
|
|
160
|
+
const visit = (route: RouteSchema, parentPath: string, groups: string[]) => {
|
|
161
|
+
const full = joinRoutePath(parentPath, route.path);
|
|
162
|
+
if (route.children) {
|
|
163
|
+
route.children.forEach((child) => visit(child, full, [...groups, route.id]));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (typeof route.page !== "string") return;
|
|
167
|
+
flat.push({
|
|
168
|
+
id: route.id,
|
|
169
|
+
segments: full === "" ? [] : full.split("/"),
|
|
170
|
+
page: route.page,
|
|
171
|
+
query: route.query ?? {},
|
|
172
|
+
nav: route.nav,
|
|
173
|
+
groups,
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
routes.forEach((route) => visit(route, "", []));
|
|
178
|
+
return flat;
|
|
179
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { validateRoutes, flattenRoutes, type RouteSchema } from "./routes";
|
|
3
|
+
import { matchRoute, routeHref } from "./match";
|
|
4
|
+
import { buildNavigationTree, navContainsRoute } from "./navigation";
|
|
5
|
+
|
|
6
|
+
/** Documento de ejemplo con la forma del demo: grupos con sub-items y una ruta con parámetro. */
|
|
7
|
+
const rutas: RouteSchema[] = [
|
|
8
|
+
{ id: "inicio", path: "/inicio", page: "inicio", nav: { label: { i18n: "nav.inicio" }, icon: "home" } },
|
|
9
|
+
{
|
|
10
|
+
id: "usuarios",
|
|
11
|
+
path: "/usuarios",
|
|
12
|
+
nav: { label: { i18n: "nav.usuarios" }, icon: "users" },
|
|
13
|
+
children: [
|
|
14
|
+
{ id: "u-todos", page: "usuarios", nav: { label: { i18n: "sub.todos" } } },
|
|
15
|
+
{ id: "u-activos", page: "usuarios", query: { estado: "Activo" }, nav: { label: { i18n: "sub.activos" } } },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{ id: "usuario-nuevo", path: "/usuarios/nuevo", page: "usuario-form" },
|
|
19
|
+
{ id: "usuario-detalle", path: "/usuarios/:id", page: "usuario-detalle" },
|
|
20
|
+
{
|
|
21
|
+
id: "pedidos",
|
|
22
|
+
path: "/pedidos",
|
|
23
|
+
nav: { label: { i18n: "nav.pedidos" }, icon: "cart", badge: 11 },
|
|
24
|
+
children: [{ id: "p-todos", page: "pedidos", nav: { label: { i18n: "sub.todos" } } }],
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe("validateRoutes", () => {
|
|
29
|
+
it("un documento correcto no tiene avisos", () => {
|
|
30
|
+
expect(validateRoutes({ version: 1, routes: rutas })).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("no lanza con entradas malformadas y devuelve avisos con código", () => {
|
|
34
|
+
expect(validateRoutes(null)[0]).toMatchObject({ code: "JR020" });
|
|
35
|
+
expect(validateRoutes({ routes: [] })[0]).toMatchObject({ code: "JR020", path: "version" });
|
|
36
|
+
expect(validateRoutes({ version: 1, routes: "x" })[0]).toMatchObject({ code: "JR020", path: "routes" });
|
|
37
|
+
expect(() => validateRoutes({ version: 1, routes: [null, { id: "x", path: 5, page: "p" }] })).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("avisa de una hoja sin page y de un grupo con page", () => {
|
|
41
|
+
const errores = validateRoutes({
|
|
42
|
+
version: 1,
|
|
43
|
+
routes: [
|
|
44
|
+
{ id: "a", path: "/a" },
|
|
45
|
+
{ id: "b", path: "/b", page: "b", children: [{ id: "b1", page: "b1" }] },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
expect(errores.map((e) => e.code)).toEqual(["JR020", "JR020"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("avisa de rutas duplicadas con la misma query y permite la misma ruta con query distinta", () => {
|
|
52
|
+
const duplicadas = validateRoutes({
|
|
53
|
+
version: 1,
|
|
54
|
+
routes: [
|
|
55
|
+
{ id: "a", path: "/a", page: "a" },
|
|
56
|
+
{ id: "a2", path: "/a", page: "a" },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
expect(duplicadas[0]).toMatchObject({ code: "JR020" });
|
|
60
|
+
|
|
61
|
+
const distintas = validateRoutes({
|
|
62
|
+
version: 1,
|
|
63
|
+
routes: [
|
|
64
|
+
{ id: "a", path: "/a", page: "a" },
|
|
65
|
+
{ id: "a-activo", path: "/a", page: "a", query: { estado: "Activo" } },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
expect(distintas).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("valida segmentos, la raíz y las etiquetas de menú", () => {
|
|
72
|
+
expect(validateRoutes({ version: 1, routes: [{ id: "x", page: "x" }] })[0]).toMatchObject({ code: "JR021" });
|
|
73
|
+
expect(validateRoutes({ version: 1, routes: [{ id: "x", path: "/a b", page: "x" }] })[0]).toMatchObject({ code: "JR021" });
|
|
74
|
+
expect(validateRoutes({ version: 1, routes: [{ id: "x", path: "/x", page: "x", nav: {} }] })[0]).toMatchObject({ code: "JR021" });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("flattenRoutes", () => {
|
|
79
|
+
it("aplana solo las hojas con su path completo y su query", () => {
|
|
80
|
+
const planas = flattenRoutes(rutas);
|
|
81
|
+
const activos = planas.find((ruta) => ruta.id === "u-activos");
|
|
82
|
+
expect(planas.some((ruta) => ruta.id === "usuarios")).toBe(false);
|
|
83
|
+
expect(activos).toMatchObject({ segments: ["usuarios"], page: "usuarios", query: { estado: "Activo" }, groups: ["usuarios"] });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("matchRoute", () => {
|
|
88
|
+
const planas = flattenRoutes(rutas);
|
|
89
|
+
|
|
90
|
+
it("un segmento estático gana a un :param en la misma posición", () => {
|
|
91
|
+
expect(matchRoute(planas, ["usuarios", "nuevo"])?.route.id).toBe("usuario-nuevo");
|
|
92
|
+
expect(matchRoute(planas, ["usuarios", "42"])).toMatchObject({ route: { id: "usuario-detalle" }, params: { id: "42" } });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("la query elige la ruta más específica y la ruta base sirve para el resto", () => {
|
|
96
|
+
expect(matchRoute(planas, ["usuarios"], { estado: "Activo" })?.route.id).toBe("u-activos");
|
|
97
|
+
expect(matchRoute(planas, ["usuarios"], { estado: "Inactivo" })?.route.id).toBe("u-todos");
|
|
98
|
+
expect(matchRoute(planas, ["usuarios"])?.route.id).toBe("u-todos");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("devuelve null cuando nada casa", () => {
|
|
102
|
+
expect(matchRoute(planas, ["no-existe"])).toBeNull();
|
|
103
|
+
expect(matchRoute(planas, ["usuarios", "1", "extra"])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("la raíz casa con la ruta vacía", () => {
|
|
107
|
+
const raiz = flattenRoutes([{ id: "raiz", path: "/", page: "inicio" }]);
|
|
108
|
+
expect(matchRoute(raiz, [])?.route.id).toBe("raiz");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("routeHref", () => {
|
|
113
|
+
it("rellena los :param y codifica la query", () => {
|
|
114
|
+
const detalle = flattenRoutes(rutas).find((ruta) => ruta.id === "usuario-detalle")!;
|
|
115
|
+
expect(routeHref(detalle, { id: "42" })).toBe("/usuarios/42");
|
|
116
|
+
const activos = flattenRoutes(rutas).find((ruta) => ruta.id === "u-activos")!;
|
|
117
|
+
expect(routeHref(activos)).toBe("/usuarios?estado=Activo");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("buildNavigationTree", () => {
|
|
122
|
+
const arbol = buildNavigationTree(rutas);
|
|
123
|
+
|
|
124
|
+
it("solo entran las rutas con nav, y los grupos llevan a su primera hija", () => {
|
|
125
|
+
expect(arbol.map((entrada) => entrada.id)).toEqual(["inicio", "usuarios", "pedidos"]);
|
|
126
|
+
const usuarios = arbol[1];
|
|
127
|
+
expect(usuarios.href).toBe("/usuarios");
|
|
128
|
+
expect(usuarios.children?.map((hijo) => hijo.href)).toEqual(["/usuarios", "/usuarios?estado=Activo"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("conserva etiqueta, icono y contador tal como los declara el JSON", () => {
|
|
132
|
+
expect(arbol[0]).toMatchObject({ label: { i18n: "nav.inicio" }, icon: "home", href: "/inicio" });
|
|
133
|
+
expect(arbol[2]).toMatchObject({ badge: 11 });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("un grupo sin nav no aparece: sus hijos suben de nivel", () => {
|
|
137
|
+
const sinGrupo = buildNavigationTree([
|
|
138
|
+
{ id: "seccion", children: [{ id: "a", path: "/a", page: "a", nav: { label: "A" } }] },
|
|
139
|
+
]);
|
|
140
|
+
expect(sinGrupo.map((entrada) => entrada.id)).toEqual(["a"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("navContainsRoute encuentra la ruta activa dentro de un grupo", () => {
|
|
144
|
+
expect(navContainsRoute(arbol[1], "u-activos")).toBe(true);
|
|
145
|
+
expect(navContainsRoute(arbol[1], "pedidos")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batería de seguridad y adversaria del motor.
|
|
3
|
+
*
|
|
4
|
+
* Cada caso de la tabla de `docs/json-renderer/SEGURIDAD.md` tiene su prueba aquí. Lo que
|
|
5
|
+
* el esquema intenta (inyectar HTML, pedir red, montar componentes arbitrarios, colgar
|
|
6
|
+
* una acción, romper el stream) tiene que quedar contenido: la pantalla sigue viva y el
|
|
7
|
+
* aviso aparece con su código.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { fireEvent, render, screen as testingScreen, waitFor } from "@testing-library/react";
|
|
13
|
+
import { ITSchemaRenderer } from "./kernel/SchemaRenderer";
|
|
14
|
+
import type { JsonEngineIssue } from "./shared/errors";
|
|
15
|
+
import type { NodeSchema, ScreenSchema } from "./kernel/types";
|
|
16
|
+
import { parsePartialSchema } from "./stream/tolerantParse";
|
|
17
|
+
import { validateSchema } from "./validation/validate";
|
|
18
|
+
|
|
19
|
+
/** Esquema mínimo con el contenido indicado. */
|
|
20
|
+
const pantalla = (content: unknown, extra: Partial<ScreenSchema> = {}): ScreenSchema =>
|
|
21
|
+
({ version: 1, id: "seguridad", content, ...extra }) as ScreenSchema;
|
|
22
|
+
|
|
23
|
+
/** Acumula los avisos del renderizador. */
|
|
24
|
+
function avisos() {
|
|
25
|
+
const lista: JsonEngineIssue[] = [];
|
|
26
|
+
return { lista, onIssue: (aviso: JsonEngineIssue) => lista.push(aviso) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Códigos de los avisos recibidos. */
|
|
30
|
+
const codigos = (lista: JsonEngineIssue[]) => lista.map((aviso) => aviso.code);
|
|
31
|
+
|
|
32
|
+
describe("capacidades: requires se evalúa y se deniega por defecto", () => {
|
|
33
|
+
it("un nodo que exige una capacidad no concedida no se pinta y avisa con JR060", () => {
|
|
34
|
+
const { lista, onIssue } = avisos();
|
|
35
|
+
render(
|
|
36
|
+
<ITSchemaRenderer
|
|
37
|
+
schema={pantalla([
|
|
38
|
+
{ type: "ITText", text: "secreto", requires: "usuarios.leer" },
|
|
39
|
+
{ type: "ITText", text: "publico" },
|
|
40
|
+
])}
|
|
41
|
+
onIssue={onIssue}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
expect(testingScreen.queryByText("secreto")).toBeNull();
|
|
45
|
+
expect(testingScreen.getByText("publico")).toBeTruthy();
|
|
46
|
+
expect(codigos(lista)).toContain("JR060");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("con la capacidad concedida, el nodo se pinta", () => {
|
|
50
|
+
render(
|
|
51
|
+
<ITSchemaRenderer
|
|
52
|
+
schema={pantalla([{ type: "ITText", text: "secreto", requires: ["usuarios.leer"] }])}
|
|
53
|
+
capabilities={["usuarios.leer"]}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
expect(testingScreen.getByText("secreto")).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sin capabilities, cualquier requires se deniega aunque el nombre sea razonable", () => {
|
|
60
|
+
render(<ITSchemaRenderer schema={pantalla([{ type: "ITText", text: "x", requires: "admin" }])} />);
|
|
61
|
+
expect(testingScreen.queryByText("x")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("un nodo denegado con fallback pinta el fallback en su lugar", () => {
|
|
65
|
+
render(
|
|
66
|
+
<ITSchemaRenderer
|
|
67
|
+
schema={pantalla([
|
|
68
|
+
{
|
|
69
|
+
type: "ITText",
|
|
70
|
+
text: "privado",
|
|
71
|
+
requires: "admin",
|
|
72
|
+
fallback: { type: "ITText", text: "sin permiso" },
|
|
73
|
+
} as NodeSchema,
|
|
74
|
+
])}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
expect(testingScreen.queryByText("privado")).toBeNull();
|
|
78
|
+
expect(testingScreen.getByText("sin permiso")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("una pantalla que exige una capacidad no concedida no se pinta y avisa en la raíz", () => {
|
|
82
|
+
const { lista, onIssue } = avisos();
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<ITSchemaRenderer
|
|
85
|
+
schema={pantalla({ type: "ITText", text: "contenido" }, { requires: "admin" })}
|
|
86
|
+
onIssue={onIssue}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
expect(container.textContent).toBe("");
|
|
90
|
+
expect(lista.find((aviso) => aviso.code === "JR060")?.path).toBe("requires");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("requires con valores basura no rompe ni concede nada", () => {
|
|
94
|
+
render(<ITSchemaRenderer schema={pantalla([{ type: "ITText", text: "x", requires: 42 as never }])} />);
|
|
95
|
+
expect(testingScreen.queryByText("x")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("acciones sin handler: JR040 en vez de no hacer nada en silencio", () => {
|
|
100
|
+
it("un botón con una acción no registrada avisa con JR040 y no rompe", () => {
|
|
101
|
+
const { lista, onIssue } = avisos();
|
|
102
|
+
render(
|
|
103
|
+
<ITSchemaRenderer
|
|
104
|
+
schema={pantalla({ type: "ITButton", props: { label: "Ir", onClick: { action: "no-existe" } } })}
|
|
105
|
+
resolveAction={() => undefined}
|
|
106
|
+
onIssue={onIssue}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
expect(testingScreen.getByRole("button", { name: "Ir" })).toBeTruthy();
|
|
110
|
+
expect(lista.some((aviso) => aviso.code === "JR040" && aviso.message.includes("no-existe"))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("un formulario cuyo onSubmit no está registrado avisa con JR040", () => {
|
|
114
|
+
const { lista, onIssue } = avisos();
|
|
115
|
+
render(
|
|
116
|
+
<ITSchemaRenderer
|
|
117
|
+
schema={pantalla({
|
|
118
|
+
type: "form",
|
|
119
|
+
props: { fields: [{ name: "a" }], onSubmit: { action: "enviar-nada" } },
|
|
120
|
+
})}
|
|
121
|
+
resolveAction={() => undefined}
|
|
122
|
+
onIssue={onIssue}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
expect(codigos(lista)).toContain("JR040");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("fuentes de datos: nunca pedir red", () => {
|
|
130
|
+
it("una fuente que declara url se rechaza con JR031 y no llama a ningún handler", async () => {
|
|
131
|
+
const handler = vi.fn(() => []);
|
|
132
|
+
const { lista, onIssue } = avisos();
|
|
133
|
+
const esquema = pantalla(
|
|
134
|
+
{
|
|
135
|
+
type: "ITDataTable",
|
|
136
|
+
props: {
|
|
137
|
+
columns: [{ key: "a", label: "A", type: "string" }],
|
|
138
|
+
fetchData: { $source: "remota" },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{ data: { remota: { handler: "lista", url: "https://evil.example/datos" } as never } }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
render(<ITSchemaRenderer schema={esquema} dataHandlers={{ lista: handler }} onIssue={onIssue} />);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(lista.some((aviso) => aviso.code === "JR031")).toBe(true));
|
|
147
|
+
expect(handler).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("acciones colgadas: JR041 y onError", () => {
|
|
152
|
+
it("una acción que no responde en el tiempo límite cuenta como fallida: onError y JR041", async () => {
|
|
153
|
+
const toast = vi.fn();
|
|
154
|
+
const { lista, onIssue } = avisos();
|
|
155
|
+
const colgada = () => new Promise<void>(() => undefined);
|
|
156
|
+
|
|
157
|
+
render(
|
|
158
|
+
<ITSchemaRenderer
|
|
159
|
+
schema={pantalla({
|
|
160
|
+
type: "ITButton",
|
|
161
|
+
props: {
|
|
162
|
+
label: "Guardar",
|
|
163
|
+
onClick: {
|
|
164
|
+
action: "guardar",
|
|
165
|
+
onError: { toast: { variant: "error", message: "Tiempo agotado" } },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
})}
|
|
169
|
+
resolveAction={() => colgada}
|
|
170
|
+
effects={{ toast }}
|
|
171
|
+
limits={{ actionTimeoutMs: 20 }}
|
|
172
|
+
onIssue={onIssue}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
fireEvent.click(testingScreen.getByRole("button", { name: "Guardar" }));
|
|
177
|
+
|
|
178
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith({ variant: "error", message: "Tiempo agotado" }));
|
|
179
|
+
await waitFor(() => expect(codigos(lista)).toContain("JR041"));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("batería adversaria: lo que el esquema intenta, contenido", () => {
|
|
184
|
+
it("un tipo fuera del catálogo avisa con JR010 y el resto de la pantalla se pinta", () => {
|
|
185
|
+
const { lista, onIssue } = avisos();
|
|
186
|
+
render(
|
|
187
|
+
<ITSchemaRenderer
|
|
188
|
+
schema={pantalla([
|
|
189
|
+
{ type: "ITComponenteInventado", text: "x" },
|
|
190
|
+
{ type: "ITText", text: "resto" },
|
|
191
|
+
])}
|
|
192
|
+
onIssue={onIssue}
|
|
193
|
+
/>
|
|
194
|
+
);
|
|
195
|
+
expect(codigos(lista)).toContain("JR010");
|
|
196
|
+
expect(testingScreen.getByText("resto")).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("dangerouslySetInnerHTML se descarta con JR011 y nunca llega al DOM como HTML", () => {
|
|
200
|
+
const { lista, onIssue } = avisos();
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<ITSchemaRenderer
|
|
203
|
+
schema={pantalla({
|
|
204
|
+
type: "ITText",
|
|
205
|
+
text: "seguro",
|
|
206
|
+
props: { dangerouslySetInnerHTML: { __html: "<img src=x onerror=alert(1)>" } },
|
|
207
|
+
})}
|
|
208
|
+
onIssue={onIssue}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
expect(container.querySelector("img")).toBeNull();
|
|
212
|
+
expect(codigos(lista)).toContain("JR011");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("HTML dentro de un texto se muestra como texto, no se interpreta", () => {
|
|
216
|
+
const { container } = render(
|
|
217
|
+
<ITSchemaRenderer schema={pantalla({ type: "ITText", text: "<img src=x onerror=alert(1)>" })} />
|
|
218
|
+
);
|
|
219
|
+
expect(container.querySelector("img")).toBeNull();
|
|
220
|
+
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("una expresión en un texto no se ejecuta", () => {
|
|
224
|
+
render(<ITSchemaRenderer schema={pantalla({ type: "ITText", text: "{{7*7}}" })} />);
|
|
225
|
+
expect(testingScreen.getByText("{{7*7}}")).toBeTruthy();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("un esquema que supera el presupuesto de nodos se recorta con JR013", () => {
|
|
229
|
+
const { lista, onIssue } = avisos();
|
|
230
|
+
const muchos = Array.from({ length: 200 }, (_, index) => ({ type: "ITText", text: `n${index}` }));
|
|
231
|
+
render(<ITSchemaRenderer schema={pantalla(muchos)} limits={{ maxNodes: 50 }} onIssue={onIssue} />);
|
|
232
|
+
expect(codigos(lista)).toContain("JR013");
|
|
233
|
+
expect(testingScreen.queryByText("n199")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("un handler de datos que lanza no tumba la pantalla", async () => {
|
|
237
|
+
const { lista, onIssue } = avisos();
|
|
238
|
+
const roto = () => {
|
|
239
|
+
throw new Error("boom");
|
|
240
|
+
};
|
|
241
|
+
render(
|
|
242
|
+
<ITSchemaRenderer
|
|
243
|
+
schema={pantalla(
|
|
244
|
+
{ type: "ITText", text: "sigue viva" },
|
|
245
|
+
{ data: { x: { handler: "roto" } } }
|
|
246
|
+
)}
|
|
247
|
+
dataHandlers={{ roto }}
|
|
248
|
+
onIssue={onIssue}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
expect(testingScreen.getByText("sigue viva")).toBeTruthy();
|
|
252
|
+
expect(lista).toEqual(expect.any(Array));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("un slot que lanza queda aislado con JR012 y el resto sigue", () => {
|
|
256
|
+
const { lista, onIssue } = avisos();
|
|
257
|
+
const Roto = () => {
|
|
258
|
+
throw new Error("slot roto");
|
|
259
|
+
};
|
|
260
|
+
const errorConsola = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
261
|
+
render(
|
|
262
|
+
<ITSchemaRenderer
|
|
263
|
+
schema={pantalla([
|
|
264
|
+
{ type: "slot", props: { name: "roto" } },
|
|
265
|
+
{ type: "ITText", text: "resto" },
|
|
266
|
+
])}
|
|
267
|
+
slots={{ roto: Roto }}
|
|
268
|
+
onIssue={onIssue}
|
|
269
|
+
/>
|
|
270
|
+
);
|
|
271
|
+
expect(testingScreen.getByText("resto")).toBeTruthy();
|
|
272
|
+
expect(codigos(lista)).toContain("JR012");
|
|
273
|
+
errorConsola.mockRestore();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("streams adversarios: JSON a medias o con basura", () => {
|
|
278
|
+
it("un stream cortado a la mitad devuelve un árbol pintable y veredicto partial", () => {
|
|
279
|
+
const { tree } = parsePartialSchema('{"version":1,"id":"x","content":{"type":"ITText","text":"hola"');
|
|
280
|
+
expect(() => validateSchema(tree)).not.toThrow();
|
|
281
|
+
expect(validateSchema(tree).verdict).toBe("partial");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("basura intercalada no lanza y conserva lo que sí es válido", () => {
|
|
285
|
+
expect(() =>
|
|
286
|
+
parsePartialSchema('{"version":1,"id":"x","content":[{"type":"ITText","text":"a"}, @@@ basura {{ , {"type":"ITText","text":"b"}]}')
|
|
287
|
+
).not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("un stream que nunca cierra no bloquea y deja la pantalla en partial", () => {
|
|
291
|
+
const { tree } = parsePartialSchema('{"version":1,"id":"x","content":{"type":"ITText","text":"ho');
|
|
292
|
+
expect(validateSchema(tree).verdict).not.toBe("invalid");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("texto vacío o basura pura devuelven un árbol vacío sin lanzar", () => {
|
|
296
|
+
expect(() => parsePartialSchema("")).not.toThrow();
|
|
297
|
+
expect(() => parsePartialSchema("}}}]]]")).not.toThrow();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -22,6 +22,7 @@ export type JsonEngineCode =
|
|
|
22
22
|
| "JR021" // nodo inválido (forma o `type` ausente)
|
|
23
23
|
| "JR030" // fuente de datos sin handler registrado
|
|
24
24
|
| "JR031" // el esquema intenta provocar red (prohibido)
|
|
25
|
+
| "JR032" // referencia sin valor o operador de binding no soportado todavía
|
|
25
26
|
| "JR040" // acción no registrada
|
|
26
27
|
| "JR041" // acción excedió su timeout
|
|
27
28
|
| "JR042" // encadenado de efectos demasiado profundo
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { NodeSchema, NodeStatus, ScreenSchema } from "../kernel/types";
|
|
18
|
+
import type { JsonEngineIssue } from "../shared/errors";
|
|
18
19
|
|
|
19
20
|
/** Resultado de parsear un texto posiblemente incompleto. */
|
|
20
21
|
export interface PartialParseResult {
|
|
@@ -441,6 +442,10 @@ export interface StreamState {
|
|
|
441
442
|
pendingNodes: number;
|
|
442
443
|
/** `true` cuando el emisor está activo. */
|
|
443
444
|
streaming: boolean;
|
|
445
|
+
/** Texto acumulado hasta ahora. */
|
|
446
|
+
text: string;
|
|
447
|
+
/** Avisos del stream (`JR080`: falló, o terminó sin cerrar el JSON). */
|
|
448
|
+
issues: JsonEngineIssue[];
|
|
444
449
|
}
|
|
445
450
|
|
|
446
451
|
/** `true` cuando un nodo está pendiente según el parser. */
|