@axzydev/axzy_ui_system 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.cjs +1630 -589
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +373 -13
  5. package/dist/index.d.ts +373 -13
  6. package/dist/index.js +1346 -314
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/flex/flex.props.ts +3 -1
  12. package/src/components/atoms/flex/flex.tsx +11 -3
  13. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  14. package/src/components/atoms/textarea/textarea.tsx +13 -3
  15. package/src/components/molecules/card/card.props.ts +2 -0
  16. package/src/components/molecules/card/card.tsx +15 -2
  17. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  18. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  19. package/src/components/molecules/stat-card/stat-card.props.ts +7 -0
  20. package/src/components/molecules/stat-card/stat-card.tsx +18 -0
  21. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  22. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  23. package/src/i18n/locales/en.ts +7 -0
  24. package/src/i18n/locales/es.ts +7 -0
  25. package/src/index.ts +31 -3
  26. package/src/json-renderer/actions/effects.test.tsx +115 -0
  27. package/src/json-renderer/actions/effects.ts +291 -0
  28. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  29. package/src/json-renderer/binding/binding.test.tsx +168 -0
  30. package/src/json-renderer/binding/resolveValue.ts +193 -0
  31. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  32. package/src/json-renderer/form/FormNode.tsx +92 -0
  33. package/src/json-renderer/form/form.test.tsx +148 -0
  34. package/src/json-renderer/form/formLength.test.tsx +99 -0
  35. package/src/json-renderer/form/rules.ts +162 -0
  36. package/src/json-renderer/index.ts +21 -0
  37. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  38. package/src/json-renderer/kernel/capabilities.ts +30 -0
  39. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  40. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  41. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  42. package/src/json-renderer/kernel/slots.ts +34 -0
  43. package/src/json-renderer/kernel/types.ts +4 -0
  44. package/src/json-renderer/routing/match.ts +82 -0
  45. package/src/json-renderer/routing/navigation.ts +71 -0
  46. package/src/json-renderer/routing/routes.ts +179 -0
  47. package/src/json-renderer/routing/routing.test.ts +147 -0
  48. package/src/json-renderer/seguridad.test.tsx +299 -0
  49. package/src/json-renderer/shared/errors.ts +1 -0
  50. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  51. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  52. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  53. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  54. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  55. package/src/json-renderer/validation/validate.ts +17 -0
  56. package/src/sandbox/i18n.ts +38 -0
  57. package/src/sandbox/useHashRoute.ts +2 -1
  58. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  59. package/src/showcases/DemoJsonDocs.tsx +11 -11
  60. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  61. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  62. package/src/showcases/demoApp/routes.test.ts +63 -0
  63. package/src/showcases/demoApp/schemas.test.ts +6 -17
@@ -9,10 +9,48 @@
9
9
  import React from "react";
10
10
  import type { Catalog } from "../catalog/catalog.props";
11
11
  import { resolveNode } from "../catalog/resolve";
12
- import type { JsonEngineIssue } from "../shared/errors";
13
- import type { SchemaPath } from "../shared/paths";
12
+ import { issue, type JsonEngineIssue } from "../shared/errors";
13
+ import { formatPath, propPath, type SchemaPath } from "../shared/paths";
14
14
  import { isNodePending } from "../stream/tolerantParse";
15
+ import { isSlotNode, slotName, type SlotRegistry } from "./slots";
15
16
  import type { NodeSchema } from "./types";
17
+ import { FormNode } from "../form/FormNode";
18
+ import { readFormFields, type FormField } from "../form/rules";
19
+ import { wrapAction, type EffectContext, type EffectRegistry } from "../actions/effects";
20
+ import { faltanCapacidades } from "./capabilities";
21
+ import ITButton from "../../components/atoms/button/button";
22
+
23
+ /** Contexto de efectos de un nodo: el registro del host, la traducción y los avisos. */
24
+ function effectContext(options: RenderNodeOptions, path: SchemaPath, node: NodeSchema): EffectContext {
25
+ return {
26
+ effects: options.effects,
27
+ path,
28
+ resolveText: (value, valuePath) => textoDe(options.resolveValue ? options.resolveValue(value, valuePath) : value),
29
+ report: (aviso) => options.issues.push(aviso),
30
+ // Una acción encadenada desde un efecto se resuelve con el mismo `resolveAction` del host.
31
+ resolveChained: (name) => options.resolveAction?.(node, "chain", { action: name }, path),
32
+ timeoutMs: options.actionTimeoutMs,
33
+ reportRuntime: options.onIssue,
34
+ setState: options.setState,
35
+ resolveRaw: (value, valuePath) => (options.resolveValue ? options.resolveValue(value, valuePath) : value),
36
+ };
37
+ }
38
+
39
+ /** Nombre de una acción declarada, para los avisos. */
40
+ function nombreDeAccion(action: unknown): string {
41
+ const nombre = (action as { action?: unknown } | null)?.action;
42
+ return typeof nombre === "string" ? nombre : "(sin nombre)";
43
+ }
44
+
45
+ /** `true` cuando el nodo es un formulario del motor. */
46
+ function isFormNode(node: NodeSchema): boolean {
47
+ return node.type === "form";
48
+ }
49
+
50
+ /** Texto de un valor ya resuelto: solo los primitivos se muestran. */
51
+ function textoDe(value: unknown): string {
52
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
53
+ }
16
54
 
17
55
  /** Opciones de render de un nodo. */
18
56
  export interface RenderNodeOptions {
@@ -31,6 +69,18 @@ export interface RenderNodeOptions {
31
69
  action: unknown,
32
70
  path: SchemaPath
33
71
  ) => ((...args: unknown[]) => void) | undefined;
72
+ /** Slots que registra el host. Un nodo `slot` monta el componente con su nombre. */
73
+ slots?: SlotRegistry;
74
+ /** Efectos que cumple el host (`toast`, `navigate`…) tras una acción. */
75
+ effects?: EffectRegistry;
76
+ /** Milisegundos que una acción puede tardar antes de considerarla colgada (`JR041`). */
77
+ actionTimeoutMs?: number;
78
+ /** Escribe en el estado de la pantalla (efecto `set`). */
79
+ setState?: (cambios: Record<string, unknown>) => void;
80
+ /** Capacidades que concede el host (para las acciones con `requires`). */
81
+ capabilities?: readonly string[];
82
+ /** Recibe los avisos que se producen fuera del render (por ejemplo, un timeout). */
83
+ onIssue?: (aviso: JsonEngineIssue) => void;
34
84
  /** Renderiza los hijos de un nodo (lo aporta el recorrido del árbol). */
35
85
  renderChildren?: (node: NodeSchema, path: SchemaPath) => React.ReactNode;
36
86
  /** Qué pintar mientras un nodo está pendiente (streaming). */
@@ -55,6 +105,174 @@ export function childList(node: NodeSchema | null | undefined): NodeSchema[] {
55
105
  return Array.isArray(node.children) ? node.children : [node.children];
56
106
  }
57
107
 
108
+ /**
109
+ * Monta un slot: el componente que el host registró con el nombre del nodo.
110
+ *
111
+ * Se monta como componente (no se llama como función) para que pueda usar hooks.
112
+ * Los props del nodo pasan por `resolveValue`, igual que los del resto de nodos.
113
+ */
114
+ function renderSlot(node: NodeSchema, options: RenderNodeOptions): React.ReactNode {
115
+ const { path, issues, slots, resolveValue } = options;
116
+ const name = slotName(node);
117
+
118
+ if (!name) {
119
+ issues.push(
120
+ issue("JR011", "El slot no declara `props.name`.", formatPath(propPath(path, "name")), {
121
+ severity: "error",
122
+ hint: 'Declara el nombre que el host registró, por ejemplo { "type": "slot", "props": { "name": "resumen" } }.',
123
+ })
124
+ );
125
+ return null;
126
+ }
127
+
128
+ const Component = slots && Object.prototype.hasOwnProperty.call(slots, name) ? slots[name] : undefined;
129
+ if (!Component) {
130
+ issues.push(
131
+ issue("JR010", `El slot \`${name}\` no está registrado por el host.`, formatPath(path), {
132
+ severity: "error",
133
+ hint: "El host debe registrarlo en `slots` del renderizador.",
134
+ })
135
+ );
136
+ return null;
137
+ }
138
+
139
+ const props: Record<string, unknown> = {};
140
+ for (const [key, raw] of Object.entries(node.props ?? {})) {
141
+ if (key === "name") continue;
142
+ props[key] = resolveValue ? resolveValue(raw, path) : raw;
143
+ }
144
+
145
+ return React.createElement(Component, { key: node.id ?? path.join("-"), ...props });
146
+ }
147
+
148
+ /**
149
+ * Pinta un nodo `form`: campos declarados en JSON, validación y envío por la acción del host.
150
+ *
151
+ * Los campos se leen con las mismas reglas que el validador (`readFormFields`). Los
152
+ * textos (etiquetas, botón) pasan por `resolveValue`, así que pueden ser `{ i18n }`.
153
+ */
154
+ function renderForm(node: NodeSchema, options: RenderNodeOptions): React.ReactNode {
155
+ const { path, issues, resolveValue, resolveAction } = options;
156
+ const { fields: declarados, issues: avisos } = readFormFields(node.props?.fields, [...path, "props", "fields"]);
157
+ issues.push(...avisos);
158
+
159
+ const campos: FormField[] = declarados.map((campo, index) => {
160
+ const etiqueta = resolveValue ? resolveValue(campo.label, [...path, "props", "fields", index]) : campo.label;
161
+ return {
162
+ name: campo.name,
163
+ kind: campo.kind,
164
+ required: campo.required,
165
+ minLength: campo.minLength,
166
+ maxLength: campo.maxLength,
167
+ label: textoDe(etiqueta) || campo.name,
168
+ };
169
+ });
170
+
171
+ const textoEnvio = node.props?.submitLabel;
172
+ // Sin texto declarado, el nodo usa `jsonRenderer.form.submit` (lo resuelve FormNode).
173
+ const submitLabel = textoDe(resolveValue ? resolveValue(textoEnvio, path) : textoEnvio) || undefined;
174
+ const accion = resolveAction?.(node, "onSubmit", node.props?.onSubmit, path);
175
+ if (node.props?.onSubmit !== undefined && typeof accion !== "function") {
176
+ issues.push(
177
+ issue("JR040", `La acción "${nombreDeAccion(node.props.onSubmit)}" no está registrada por el host.`, formatPath(propPath(path, "onSubmit")), {
178
+ severity: "warning",
179
+ hint: "Regístrala en `resolveAction` del host. Sin ella, enviar el formulario no hace nada.",
180
+ })
181
+ );
182
+ }
183
+ const conEfectos = accion ? wrapAction(accion, node.props?.onSubmit, effectContext(options, path, node)) : undefined;
184
+
185
+ return React.createElement(FormNode, {
186
+ key: node.id ?? path.join("-"),
187
+ id: node.id,
188
+ fields: campos,
189
+ submitLabel,
190
+ onSubmit: conEfectos ? (valores: Record<string, string>) => conEfectos(valores) : undefined,
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Convierte las columnas `type: "actions"` con `rowActions` en columnas reales de la
196
+ * tabla. Cada botón de fila llama a la acción del host con la fila como primer argumento,
197
+ * igual que `onRowClick`. El JSON nunca da una función: solo nombra la acción.
198
+ *
199
+ * Un botón sin handler, sin permiso o mal formado no se pinta y avisa (JR040, JR061, JR021).
200
+ */
201
+ function prepararColumnas(columnas: unknown, node: NodeSchema, options: RenderNodeOptions, path: SchemaPath): unknown {
202
+ if (!Array.isArray(columnas)) return columnas;
203
+
204
+ return columnas.map((columna, indice) => {
205
+ if (!columna || typeof columna !== "object") return columna;
206
+ const declarada = columna as Record<string, unknown>;
207
+ if (declarada.type !== "actions" || !Array.isArray(declarada.rowActions)) return columna;
208
+
209
+ const colPath = [...path, "props", "columns", indice];
210
+ const botones = declarada.rowActions.flatMap((raw, indiceAccion) => {
211
+ const accion = raw as Record<string, unknown> | null;
212
+ const rutaAccion = [...colPath, "rowActions", indiceAccion];
213
+ if (!accion || typeof accion.action !== "string" || accion.action === "") {
214
+ options.issues.push(issue("JR021", "Un botón de fila no declara `action`.", formatPath(rutaAccion), { severity: "error" }));
215
+ return [];
216
+ }
217
+ const faltan = faltanCapacidades(accion.requires, options.capabilities);
218
+ if (faltan.length > 0) {
219
+ options.issues.push(
220
+ issue("JR061", `El botón de fila "${accion.action}" exige ${faltan.join(", ")}, que no está concedido.`, formatPath(rutaAccion), {
221
+ severity: "warning",
222
+ hint: "Concede la capacidad con la prop `capabilities` del renderizador.",
223
+ })
224
+ );
225
+ return [];
226
+ }
227
+ const handler = options.resolveAction?.(node, `fila:${accion.action}`, accion, rutaAccion);
228
+ if (typeof handler !== "function") {
229
+ options.issues.push(
230
+ issue("JR040", `La acción de fila "${accion.action}" no está registrada por el host.`, formatPath(rutaAccion), {
231
+ severity: "warning",
232
+ hint: "Regístrala en `resolveAction` del host, o quita el botón del esquema.",
233
+ })
234
+ );
235
+ return [];
236
+ }
237
+ const etiqueta = textoDe(options.resolveValue ? options.resolveValue(accion.label, rutaAccion) : accion.label);
238
+ return [
239
+ {
240
+ clave: String(indiceAccion),
241
+ etiqueta: etiqueta || accion.action,
242
+ color: typeof accion.color === "string" ? accion.color : "secondary",
243
+ variante: typeof accion.variant === "string" ? accion.variant : "outlined",
244
+ ejecutar: wrapAction(handler, accion, effectContext(options, rutaAccion, node)),
245
+ },
246
+ ];
247
+ });
248
+
249
+ const copia: Record<string, unknown> = { ...declarada };
250
+ delete copia.rowActions;
251
+ const etiquetaColumna = textoDe(options.resolveValue ? options.resolveValue(copia.label, colPath) : copia.label);
252
+ const Boton = ITButton as unknown as React.ComponentType<Record<string, unknown>>;
253
+
254
+ return {
255
+ ...copia,
256
+ label: etiquetaColumna || String(copia.key ?? ""),
257
+ actions: (fila: unknown) =>
258
+ React.createElement(
259
+ React.Fragment,
260
+ null,
261
+ botones.map((boton) =>
262
+ React.createElement(Boton, {
263
+ key: boton.clave,
264
+ label: boton.etiqueta,
265
+ size: "sm",
266
+ variant: boton.variante,
267
+ color: boton.color,
268
+ onClick: () => boton.ejecutar(fila),
269
+ })
270
+ )
271
+ ),
272
+ };
273
+ });
274
+ }
275
+
58
276
  /**
59
277
  * Pinta un nodo del esquema.
60
278
  *
@@ -73,6 +291,16 @@ export function renderNode(node: NodeSchema, options: RenderNodeOptions): React.
73
291
  return options.pendingFallback ?? null;
74
292
  }
75
293
 
294
+ // Los slots no están en el catálogo: los registra el host.
295
+ if (isSlotNode(node)) {
296
+ return renderSlot(node, options);
297
+ }
298
+
299
+ // El formulario lo compila el motor: campos, validación y envío por acción.
300
+ if (isFormNode(node)) {
301
+ return renderForm(node, options);
302
+ }
303
+
76
304
  const resolved = resolveNode(node, catalog, path);
77
305
  issues.push(...resolved.issues);
78
306
 
@@ -87,11 +315,36 @@ export function renderNode(node: NodeSchema, options: RenderNodeOptions): React.
87
315
  for (const [name, value] of Object.entries(props)) {
88
316
  finalProps[name] = options.resolveValue ? options.resolveValue(value, path) : value;
89
317
  }
318
+ // Las tablas reciben las columnas con sus botones de fila ya convertidos en funciones.
319
+ if (node.type === "ITDataTable" && finalProps.columns !== undefined) {
320
+ finalProps.columns = prepararColumnas(finalProps.columns, node, options, path);
321
+ }
90
322
 
91
323
  // Las acciones se traducen a handlers del host; las no registradas se avisan.
92
324
  for (const [name, action] of Object.entries(actions)) {
325
+ // Permisos por acción: una acción que exige algo no concedido no se ejecuta (JR061).
326
+ const faltan = faltanCapacidades((action as { requires?: unknown } | null)?.requires, options.capabilities);
327
+ if (faltan.length > 0) {
328
+ issues.push(
329
+ issue("JR061", `La acción "${nombreDeAccion(action)}" exige ${faltan.join(", ")}, que no está concedido.`, formatPath(propPath(path, name)), {
330
+ severity: "warning",
331
+ hint: "Concede la capacidad con la prop `capabilities` del renderizador. Sin ella, la acción no se ejecuta.",
332
+ })
333
+ );
334
+ continue;
335
+ }
93
336
  const handler = options.resolveAction?.(node, name, action, path);
94
- if (typeof handler === "function") finalProps[name] = handler;
337
+ if (typeof handler === "function") {
338
+ finalProps[name] = wrapAction(handler, action, effectContext(options, path, node));
339
+ } else {
340
+ // Sin handler, el botón no hace nada: que se note en vez de fallar en silencio.
341
+ issues.push(
342
+ issue("JR040", `La acción "${nombreDeAccion(action)}" no está registrada por el host.`, formatPath(propPath(path, name)), {
343
+ severity: "warning",
344
+ hint: "Regístrala en `resolveAction` del host, o quita la prop del esquema.",
345
+ })
346
+ );
347
+ }
95
348
  }
96
349
 
97
350
  // Contenido: `children` declarados y, si el componente acepta texto, el atajo.
@@ -3,7 +3,10 @@ import type { Catalog } from "../catalog/catalog.props";
3
3
  import type { JsonEngineIssue } from "../shared/errors";
4
4
  import type { EngineLimits } from "../shared/limits";
5
5
  import type { SchemaPath } from "../shared/paths";
6
- import type { NodeSchema, ScreenSchema } from "./types";
6
+ import type { DataHandler } from "../binding/resolveValue";
7
+ import type { SlotRegistry } from "./slots";
8
+ import type { EffectRegistry } from "../actions/effects";
9
+ import type { NodeSchema, ScreenSchema, SchemaValue } from "./types";
7
10
 
8
11
  /**
9
12
  * Props de `ITSchemaRenderer`.
@@ -18,9 +21,32 @@ export interface ITJsonRenderEngineProps {
18
21
  catalog?: Catalog;
19
22
  /** Presupuesto de nodos, profundidad y tiempos. */
20
23
  limits?: Partial<EngineLimits>;
24
+ /** Valores de la ruta para `$param`. Los pone el host; el esquema no puede crearlos. */
25
+ params?: Record<string, SchemaValue>;
21
26
  /**
22
- * Resuelve un valor declarativo del esquema (`$state`, `$param`, `$source`…) al
23
- * valor real. Sin esta prop, los valores se pasan tal cual (útil para pruebas).
27
+ * Handlers de datos que registra el host, por nombre. Las fuentes de `data` los
28
+ * referencian con `handler`. Conviene memoizarlo: cada fuente se resuelve contra él.
29
+ */
30
+ dataHandlers?: Record<string, DataHandler>;
31
+ /**
32
+ * Bloques JSX que registra el host, por nombre. Un nodo `{ "type": "slot", "props": { "name": "…" } }`
33
+ * monta el componente con ese nombre; el esquema no puede elegir otro.
34
+ */
35
+ slots?: SlotRegistry;
36
+ /**
37
+ * Efectos que cumple el host. Una acción con `then`, `onError` o efectos sueltos
38
+ * los ejecuta después de su handler; `toast` y `navigate` son los más comunes.
39
+ */
40
+ effects?: EffectRegistry;
41
+ /**
42
+ * Capacidades que concede el host (`["usuarios.leer", …]`). Un nodo o la pantalla que
43
+ * declara `requires` solo se pinta si todas están concedidas; si no, avisa con `JR060`.
44
+ * Sin esta prop, cualquier `requires` se deniega.
45
+ */
46
+ capabilities?: readonly string[];
47
+ /**
48
+ * Resuelve los valores que no son referencias del motor (por ejemplo, textos
49
+ * `{ i18n }`). Las referencias `$source`, `$state` y `$param` las resuelve el motor.
24
50
  */
25
51
  resolveValue?: (value: unknown, path: SchemaPath) => unknown;
26
52
  /**
@@ -0,0 +1,104 @@
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 { validateSchema } from "../validation/validate";
5
+ import type { JsonEngineIssue } from "../shared/errors";
6
+ import { ITSchemaRenderer } from "./SchemaRenderer";
7
+ import type { SlotRegistry } from "./slots";
8
+ import type { NodeSchema, ScreenSchema } from "./types";
9
+
10
+ /** Esquema mínimo con el contenido indicado. */
11
+ const screen = (content: NodeSchema | NodeSchema[], extra: Partial<ScreenSchema> = {}): ScreenSchema => ({
12
+ version: 1,
13
+ id: "slots",
14
+ content,
15
+ ...extra,
16
+ });
17
+
18
+ /** Acumula los avisos del renderizador. */
19
+ function collectIssues() {
20
+ const issues: JsonEngineIssue[] = [];
21
+ return { issues, onIssue: (issue: JsonEngineIssue) => issues.push(issue) };
22
+ }
23
+
24
+ describe("slots", () => {
25
+ it("monta el componente registrado con los props del nodo ya resueltos, sin el nombre", () => {
26
+ const Resumen = vi.fn((props: Record<string, unknown>) => <p>{`Resumen ${String(props.titulo)}`}</p>);
27
+ const slots: SlotRegistry = { resumen: Resumen };
28
+
29
+ render(
30
+ <ITSchemaRenderer
31
+ schema={screen(
32
+ { type: "slot", props: { name: "resumen", titulo: { $state: "titulo" } } },
33
+ { state: { titulo: "Pedidos" } }
34
+ )}
35
+ slots={slots}
36
+ />
37
+ );
38
+
39
+ expect(testingScreen.getByText("Resumen Pedidos")).toBeTruthy();
40
+ const props = Resumen.mock.calls[0][0];
41
+ expect(props).toMatchObject({ titulo: "Pedidos" });
42
+ expect(props).not.toHaveProperty("name");
43
+ });
44
+
45
+ it("un nombre que el host no registró avisa con JR010 y no pinta nada", () => {
46
+ const { issues, onIssue } = collectIssues();
47
+ const { container } = render(
48
+ <ITSchemaRenderer
49
+ schema={screen([
50
+ { type: "slot", props: { name: "inexistente" } },
51
+ { type: "ITText", text: "resto" },
52
+ ])}
53
+ slots={{}}
54
+ onIssue={onIssue}
55
+ />
56
+ );
57
+
58
+ expect(issues.some((issue) => issue.code === "JR010" && issue.message.includes("inexistente"))).toBe(true);
59
+ expect(testingScreen.getByText("resto")).toBeTruthy();
60
+ expect(container.textContent).toBe("resto");
61
+ });
62
+
63
+ it("un slot sin `props.name` avisa con JR011", () => {
64
+ const { issues, onIssue } = collectIssues();
65
+ render(<ITSchemaRenderer schema={screen({ type: "slot", props: {} })} slots={{}} onIssue={onIssue} />);
66
+ expect(issues.some((issue) => issue.code === "JR011")).toBe(true);
67
+ });
68
+
69
+ it("un slot que lanza se aísla: el resto de la pantalla sigue pintándose", () => {
70
+ const { issues, onIssue } = collectIssues();
71
+ const Roto = () => {
72
+ throw new Error("fallo del slot");
73
+ };
74
+ const errorConsola = vi.spyOn(console, "error").mockImplementation(() => {});
75
+
76
+ render(
77
+ <ITSchemaRenderer
78
+ schema={screen([
79
+ { type: "slot", props: { name: "roto" } },
80
+ { type: "ITText", text: "sigue viva" },
81
+ ])}
82
+ slots={{ roto: Roto }}
83
+ onIssue={onIssue}
84
+ />
85
+ );
86
+
87
+ expect(testingScreen.getByText("sigue viva")).toBeTruthy();
88
+ expect(issues.some((issue) => issue.code === "JR012")).toBe(true);
89
+ errorConsola.mockRestore();
90
+ });
91
+ });
92
+
93
+ describe("validación de slots", () => {
94
+ it("un slot con nombre es válido aunque el host lo registre después", () => {
95
+ const informe = validateSchema(screen({ type: "slot", props: { name: "resumen" } }));
96
+ expect(informe.verdict).toBe("valid");
97
+ });
98
+
99
+ it("un slot sin nombre es un error de validación (JR011)", () => {
100
+ const informe = validateSchema(screen({ type: "slot", props: { titulo: "x" } }));
101
+ expect(informe.verdict).toBe("invalid");
102
+ expect(informe.errors[0]).toMatchObject({ code: "JR011" });
103
+ });
104
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Slots: bloques que registra el **host** y el esquema solo nombra.
3
+ *
4
+ * Un nodo `{ "type": "slot", "props": { "name": "…", … } }` monta el componente que
5
+ * el host registró con ese nombre en `slots`. El esquema no puede elegir el
6
+ * componente ni inyectar código: solo pasa valores (ya resueltos por el binding)
7
+ * como props. Es la vía para reutilizar pantallas JSX existentes dentro de un JSON.
8
+ *
9
+ * Los slots no están en el catálogo: son autoridad del host, igual que los handlers
10
+ * de datos. Un nombre que el host no registró avisa con `JR010` y no se pinta nada.
11
+ */
12
+
13
+ import type { ComponentType } from "react";
14
+ import type { NodeSchema } from "./types";
15
+
16
+ /** Tipo de nodo reservado para los slots. Minúscula: lo compila el motor, no es un componente. */
17
+ export const SLOT_TYPE = "slot";
18
+
19
+ /** Componente que el host registra como slot. Recibe los props del nodo ya resueltos. */
20
+ export type SlotComponent = ComponentType<Record<string, unknown>>;
21
+
22
+ /** Slots que registra el host, por nombre. */
23
+ export type SlotRegistry = Record<string, SlotComponent>;
24
+
25
+ /** `true` cuando el nodo es un slot. */
26
+ export function isSlotNode(node: NodeSchema): boolean {
27
+ return node.type === SLOT_TYPE;
28
+ }
29
+
30
+ /** Nombre del slot (`props.name`), o `undefined` si falta o no es texto. */
31
+ export function slotName(node: NodeSchema): string | undefined {
32
+ const name = node.props?.name;
33
+ return typeof name === "string" && name !== "" ? name : undefined;
34
+ }
@@ -76,6 +76,10 @@ export interface EffectSchema {
76
76
  openOverlay?: string;
77
77
  /** Cierra el overlay indicado, o el que esté abierto si se omite. */
78
78
  closeOverlay?: string;
79
+ /** Escribe en el estado de la pantalla: `{ "clave": valor }`. Los valores admiten referencias. */
80
+ set?: Record<string, SchemaValue>;
81
+ /** Capacidad que exige la acción. Sin concesión, la acción no se ejecuta (`JR061`). */
82
+ requires?: string | string[];
79
83
  /** Refresca una fuente de datos. */
80
84
  refresh?: string;
81
85
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Casa una dirección (path + query) con una ruta declarada.
3
+ *
4
+ * Reglas, de más a menos específica:
5
+ * 1. Más segmentos estáticos gana a un `:param` en la misma posición
6
+ * (`/usuarios/nuevo` antes que `/usuarios/:id`).
7
+ * 2. Con el mismo path, gana la ruta que exige más claves de query
8
+ * (`?estado=Activo` antes que la ruta sin query).
9
+ * 3. Empate: la primera declarada.
10
+ *
11
+ * Nunca lanza: una dirección que no casa devuelve `null`.
12
+ */
13
+
14
+ import type { FlatRoute } from "./routes";
15
+
16
+ /** Resultado de casar una dirección. */
17
+ export interface RouteMatch {
18
+ /** Ruta que casa. */
19
+ route: FlatRoute;
20
+ /** Valores de los `:param` de la ruta, por nombre (sin los dos puntos). */
21
+ params: Record<string, string>;
22
+ }
23
+
24
+ /**
25
+ * Busca la ruta que corresponde a una dirección.
26
+ *
27
+ * @example
28
+ * matchRoute(flattenRoutes(routes), ["usuarios"], { estado: "Activo" })?.route.id; // "u-activos"
29
+ */
30
+ export function matchRoute(
31
+ routes: FlatRoute[],
32
+ segments: string[],
33
+ query: Record<string, string> = {}
34
+ ): RouteMatch | null {
35
+ let best: RouteMatch | null = null;
36
+ let bestStatics = -1;
37
+ let bestConstraints = -1;
38
+
39
+ for (const route of routes) {
40
+ if (route.segments.length !== segments.length) continue;
41
+
42
+ const params: Record<string, string> = {};
43
+ let statics = 0;
44
+ let samePath = true;
45
+ route.segments.forEach((expected, index) => {
46
+ if (!samePath) return;
47
+ if (expected.startsWith(":")) {
48
+ params[expected.slice(1)] = segments[index];
49
+ return;
50
+ }
51
+ statics += 1;
52
+ samePath = expected === segments[index];
53
+ });
54
+ if (!samePath) continue;
55
+
56
+ const constraints = Object.entries(route.query);
57
+ if (!constraints.every(([key, value]) => query[key] === value)) continue;
58
+
59
+ // Un empate en estáticos y query conserva la ruta declarada antes (no se reemplaza).
60
+ if (statics > bestStatics || (statics === bestStatics && constraints.length > bestConstraints)) {
61
+ best = { route, params };
62
+ bestStatics = statics;
63
+ bestConstraints = constraints.length;
64
+ }
65
+ }
66
+
67
+ return best;
68
+ }
69
+
70
+ /**
71
+ * Dirección (`/usuarios/42?estado=Activo`) de una ruta, con sus `:param` rellenos.
72
+ * Los `:param` sin valor se dejan tal cual para que se vean en el menú.
73
+ */
74
+ export function routeHref(route: FlatRoute, params: Record<string, string> = {}): string {
75
+ const path = route.segments
76
+ .map((segment) => (segment.startsWith(":") ? (params[segment.slice(1)] ?? segment) : segment))
77
+ .join("/");
78
+ const queryString = Object.entries(route.query)
79
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
80
+ .join("&");
81
+ return `/${path}${queryString ? `?${queryString}` : ""}`;
82
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Árbol de navegación derivado de `routes.json`.
3
+ *
4
+ * Solo entran las rutas con `nav`. Un grupo con `nav` se convierte en una entrada con
5
+ * hijos y, como enlace, apunta a su primera hija con dirección. Un grupo sin `nav` no
6
+ * aparece: sus hijos suben de nivel. El host decide cómo pintar el árbol (sidebar, top
7
+ * bar…); el motor solo entrega la forma y las direcciones.
8
+ */
9
+
10
+ import type { SchemaValue } from "../kernel/types";
11
+ import { routeHref } from "./match";
12
+ import { flattenRoutes, joinRoutePath, type RouteNavSchema, type RouteSchema } from "./routes";
13
+
14
+ /** Entrada del árbol de navegación. */
15
+ export interface NavNode {
16
+ /** Id de la ruta (o del grupo) de origen. */
17
+ id: string;
18
+ /** Texto de la entrada; puede ser una referencia `{ i18n }` que resuelve el host. */
19
+ label: SchemaValue;
20
+ /** Icono, tal como lo declara el JSON. */
21
+ icon?: string;
22
+ /** Contador de la entrada. */
23
+ badge?: number | string;
24
+ /** Dirección a la que lleva la entrada. Los grupos apuntan a su primera hija con dirección. */
25
+ href?: string;
26
+ /** Entradas hijas. Presentes solo en los grupos. */
27
+ children?: NavNode[];
28
+ }
29
+
30
+ /**
31
+ * Construye el árbol de navegación a partir de las rutas declaradas.
32
+ *
33
+ * @example
34
+ * buildNavigationTree(routes.routes)[0]; // { id: "inicio", label: {...}, href: "/inicio" }
35
+ */
36
+ export function buildNavigationTree(routes: RouteSchema[]): NavNode[] {
37
+ // Las direcciones de las hojas se calculan una vez; un grupo usa las de sus hijas.
38
+ const hrefs = new Map(flattenRoutes(routes).map((route) => [route.id, routeHref(route)]));
39
+
40
+ const visit = (route: RouteSchema, parentPath: string): NavNode[] => {
41
+ const full = joinRoutePath(parentPath, route.path);
42
+
43
+ if (route.children) {
44
+ const children = route.children.flatMap((child) => visit(child, full));
45
+ if (!route.nav) return children;
46
+ return [toNode(route.id, route.nav, children, children.find((child) => child.href)?.href)];
47
+ }
48
+
49
+ if (!route.nav) return [];
50
+ return [toNode(route.id, route.nav, undefined, hrefs.get(route.id))];
51
+ };
52
+
53
+ return routes.flatMap((route) => visit(route, ""));
54
+ }
55
+
56
+ /** Crea una entrada a partir de su `nav` declarado. */
57
+ function toNode(id: string, nav: RouteNavSchema, children: NavNode[] | undefined, href: string | undefined): NavNode {
58
+ return {
59
+ id,
60
+ label: nav.label,
61
+ icon: nav.icon,
62
+ badge: nav.badge,
63
+ href,
64
+ ...(children ? { children } : {}),
65
+ };
66
+ }
67
+
68
+ /** `true` cuando la entrada, o alguno de sus hijos, es la ruta indicada. */
69
+ export function navContainsRoute(entry: NavNode, routeId: string): boolean {
70
+ return entry.id === routeId || (entry.children?.some((child) => navContainsRoute(child, routeId)) ?? false);
71
+ }