@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.
- package/dist/index.cjs +1630 -589
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +373 -13
- package/dist/index.d.ts +373 -13
- package/dist/index.js +1346 -314
- 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/molecules/stat-card/stat-card.props.ts +7 -0
- package/src/components/molecules/stat-card/stat-card.tsx +18 -0
- 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
|
@@ -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
|
|
13
|
-
import type
|
|
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")
|
|
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 {
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
+
}
|