@axzydev/axzy_ui_system 1.7.0 → 1.7.1
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 +1738 -308
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +739 -3
- package/dist/index.d.ts +739 -3
- package/dist/index.js +1629 -233
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +14 -7
- package/src/App.tsx +60 -2
- package/src/components/atoms/image/image.props.ts +2 -0
- package/src/components/atoms/image/image.tsx +3 -2
- package/src/components/organisms/dialog/dialog.props.ts +2 -0
- package/src/components/organisms/dialog/dialog.tsx +4 -3
- package/src/dev.css +6 -0
- package/src/index.ts +90 -1
- package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
- package/src/json-renderer/catalog/catalog.props.ts +128 -0
- package/src/json-renderer/catalog/resolve.ts +225 -0
- package/src/json-renderer/index.ts +70 -0
- package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
- package/src/json-renderer/kernel/kernel.test.tsx +341 -0
- package/src/json-renderer/kernel/partial.ts +92 -0
- package/src/json-renderer/kernel/renderNode.tsx +113 -0
- package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
- package/src/json-renderer/kernel/types.ts +159 -0
- package/src/json-renderer/shared/errors.ts +92 -0
- package/src/json-renderer/shared/limits.ts +87 -0
- package/src/json-renderer/shared/paths.ts +66 -0
- package/src/json-renderer/shared/shared.test.ts +108 -0
- package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
- package/src/json-renderer/stream/tolerantParse.ts +449 -0
- package/src/json-renderer/test/setup.ts +38 -0
- package/src/json-renderer/validation/migrate.ts +83 -0
- package/src/json-renderer/validation/validate.ts +161 -0
- package/src/json-renderer/validation/validation.test.ts +165 -0
- package/src/json-renderer/vocabulary/buildContract.ts +158 -0
- package/src/sandbox/axzyConfig.test.ts +40 -0
- package/src/sandbox/axzyConfig.ts +77 -0
- package/src/sandbox/i18n.ts +218 -0
- package/src/sandbox/navigation.ts +1 -0
- package/src/sandbox/useHashRoute.ts +39 -9
- package/src/showcases/DemoJsonDashboard.tsx +489 -0
- package/src/showcases/DemoJsonDocs.tsx +339 -0
- package/src/showcases/DemoJsonPortfolio.tsx +257 -0
- package/src/showcases/JsonScreenShowcase.tsx +341 -0
- package/src/showcases/demoApp/demoData.ts +181 -0
- package/src/showcases/demoApp/nodeTree.test.ts +39 -0
- package/src/showcases/demoApp/nodeTree.ts +35 -0
- package/src/showcases/demoApp/schemas.test.ts +54 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { FaBolt, FaBug, FaCheck, FaTrashAlt } from "react-icons/fa";
|
|
3
|
+
import {
|
|
4
|
+
ITAlert,
|
|
5
|
+
ITBadget,
|
|
6
|
+
ITButton,
|
|
7
|
+
ITCard,
|
|
8
|
+
ITFlex,
|
|
9
|
+
ITJSONEditor,
|
|
10
|
+
ITSchemaRenderer,
|
|
11
|
+
ITText,
|
|
12
|
+
type JsonEngineIssue,
|
|
13
|
+
type ScreenSchema,
|
|
14
|
+
parsePartialSchema,
|
|
15
|
+
axzyCatalogTypes,
|
|
16
|
+
} from "../index";
|
|
17
|
+
import { ShowcaseLayout } from "./ShowcaseLayout";
|
|
18
|
+
|
|
19
|
+
/** Esquema de ejemplo: usa el catálogo real y muestra permisos y estado. */
|
|
20
|
+
const EJEMPLO: ScreenSchema = {
|
|
21
|
+
version: 1,
|
|
22
|
+
id: "laboratorio",
|
|
23
|
+
title: "Pantalla de ejemplo",
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: "ITFlex",
|
|
27
|
+
props: { align: "center", justify: "between", wrap: "wrap", gap: 3 },
|
|
28
|
+
children: [
|
|
29
|
+
{
|
|
30
|
+
type: "ITFlex",
|
|
31
|
+
props: { align: "center", gap: 2 },
|
|
32
|
+
children: [
|
|
33
|
+
{ type: "ITBadget", props: { label: "Motor", color: "primary" } },
|
|
34
|
+
{ type: "ITText", props: { muted: true }, text: "Esquema interpretado en vivo" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: "ITButton",
|
|
39
|
+
props: { label: "Acción declarada", color: "primary", size: "sm", onClick: { action: "saludar" } },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "ITGrid",
|
|
45
|
+
props: { container: true, spacing: 3 },
|
|
46
|
+
children: [
|
|
47
|
+
{
|
|
48
|
+
type: "ITGrid",
|
|
49
|
+
props: { item: true, md: 6 },
|
|
50
|
+
children: [
|
|
51
|
+
{
|
|
52
|
+
type: "ITCard",
|
|
53
|
+
props: { title: "Listo para pintar" },
|
|
54
|
+
children: [
|
|
55
|
+
{ type: "ITText", text: "Este árbol viene de un JSON, no de TSX." },
|
|
56
|
+
{ type: "ITProgress", props: { value: 64, max: 100, size: "sm" } },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "ITGrid",
|
|
63
|
+
props: { item: true, md: 6 },
|
|
64
|
+
children: [
|
|
65
|
+
{
|
|
66
|
+
type: "ITAlert",
|
|
67
|
+
props: { variant: "info", title: "Casos cubiertos" },
|
|
68
|
+
children: [{ type: "ITText", text: "Si algo no está en el catálogo, se avisa y no se pinta." }],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Texto inicial del editor: el esquema anterior, formateado. */
|
|
78
|
+
const TEXTO_INICIAL = JSON.stringify(EJEMPLO, null, 2);
|
|
79
|
+
|
|
80
|
+
/** Velocidad del stream simulado (caracteres por tick). */
|
|
81
|
+
const CHUNK = 6;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Laboratorio del motor de pantallas declarativas.
|
|
85
|
+
*
|
|
86
|
+
* Izquierda: el esquema en `ITJSONEditor`. Derecha: lo que pinta el motor. El
|
|
87
|
+
* botón «emitir como stream» escribe el esquema carácter a carácter para ver el
|
|
88
|
+
* render progresivo y comprobar que lo pendiente no se pinta a medias.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // Ruta del sandbox: #ui-system/feedback/jsonscreen
|
|
92
|
+
*/
|
|
93
|
+
export const JsonScreenShowcase: React.FC = () => {
|
|
94
|
+
const [texto, setTexto] = useState(TEXTO_INICIAL);
|
|
95
|
+
const [streaming, setStreaming] = useState(false);
|
|
96
|
+
const [emitido, setEmitido] = useState(TEXTO_INICIAL.length);
|
|
97
|
+
const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
|
|
98
|
+
const timer = useRef<number | null>(null);
|
|
99
|
+
|
|
100
|
+
/** Estado del esquema: mientras se emite, se parsea en modo tolerante. */
|
|
101
|
+
const estado = useMemo(() => {
|
|
102
|
+
if (!streaming) {
|
|
103
|
+
try {
|
|
104
|
+
const completo = JSON.parse(texto) as ScreenSchema;
|
|
105
|
+
return { tree: completo, status: "complete" as const, pending: 0, roto: false };
|
|
106
|
+
} catch {
|
|
107
|
+
return { tree: null, status: "complete" as const, pending: 0, roto: true };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const parcial = parsePartialSchema(texto);
|
|
111
|
+
return {
|
|
112
|
+
tree: parcial.tree,
|
|
113
|
+
status: parcial.status,
|
|
114
|
+
pending: parcial.pendingNodes,
|
|
115
|
+
roto: parcial.broken,
|
|
116
|
+
};
|
|
117
|
+
}, [texto, streaming]);
|
|
118
|
+
|
|
119
|
+
/** Detiene el stream y limpia el temporizador. */
|
|
120
|
+
const detener = useCallback(() => {
|
|
121
|
+
if (timer.current !== null) {
|
|
122
|
+
window.clearInterval(timer.current);
|
|
123
|
+
timer.current = null;
|
|
124
|
+
}
|
|
125
|
+
setStreaming(false);
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
useEffect(() => () => detener(), [detener]);
|
|
129
|
+
|
|
130
|
+
/** Emite el esquema carácter a carácter, como haría un modelo. */
|
|
131
|
+
const emitirStream = useCallback(() => {
|
|
132
|
+
detener();
|
|
133
|
+
setAvisos([]);
|
|
134
|
+
setEmitido(0);
|
|
135
|
+
setTexto("");
|
|
136
|
+
setStreaming(true);
|
|
137
|
+
|
|
138
|
+
const total = TEXTO_INICIAL;
|
|
139
|
+
let cursor = 0;
|
|
140
|
+
|
|
141
|
+
timer.current = window.setInterval(() => {
|
|
142
|
+
cursor = Math.min(total.length, cursor + CHUNK);
|
|
143
|
+
setTexto(total.slice(0, cursor));
|
|
144
|
+
setEmitido(cursor);
|
|
145
|
+
if (cursor >= total.length) {
|
|
146
|
+
window.clearInterval(timer.current as number);
|
|
147
|
+
timer.current = null;
|
|
148
|
+
setStreaming(false);
|
|
149
|
+
}
|
|
150
|
+
}, 24);
|
|
151
|
+
}, [detener]);
|
|
152
|
+
|
|
153
|
+
/** Registro de acciones del host: el esquema solo puede pedir estos nombres. */
|
|
154
|
+
const resolveAction = useCallback(
|
|
155
|
+
() => () => {
|
|
156
|
+
window.alert("Acción declarada ejecutada por el host.");
|
|
157
|
+
},
|
|
158
|
+
[]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const registrarAviso = useCallback((issue: JsonEngineIssue) => {
|
|
162
|
+
setAvisos((previos) =>
|
|
163
|
+
previos.some((item) => item.code === issue.code && item.path === issue.path)
|
|
164
|
+
? previos
|
|
165
|
+
: [...previos, issue].slice(-8)
|
|
166
|
+
);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const codigo = `<ITSchemaRenderer
|
|
170
|
+
schema={parsePartialSchema(texto).tree}
|
|
171
|
+
onIssue={(issue) => registrar(issue)}
|
|
172
|
+
resolveAction={() => actions[accion]}
|
|
173
|
+
pendingFallback={<ITSkeleton />}
|
|
174
|
+
/>`;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<ShowcaseLayout
|
|
178
|
+
title="Motor de pantallas"
|
|
179
|
+
description="ITSchemaRenderer interpreta un esquema JSON contra un catálogo de componentes con lista blanca. Pinta mientras el esquema llega por trozos, avisa con código y ruta, y aísla los nodos que fallan."
|
|
180
|
+
demo={
|
|
181
|
+
<div className="grid w-full gap-4 xl:grid-cols-[minmax(0,380px)_minmax(0,1fr)]">
|
|
182
|
+
<div className="min-w-0">
|
|
183
|
+
<ITCard title="Esquema (JSON)">
|
|
184
|
+
<ITJSONEditor value={texto} onChange={setTexto} height={420} readOnly={streaming} />
|
|
185
|
+
</ITCard>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="min-w-0">
|
|
188
|
+
<ITCard title="Pantalla resultante">
|
|
189
|
+
<div className="min-h-[420px]">
|
|
190
|
+
{estado.roto && !estado.tree ? (
|
|
191
|
+
<ITAlert variant="error" title="El esquema no es JSON válido">
|
|
192
|
+
Corrige el texto de la izquierda para volver a pintar.
|
|
193
|
+
</ITAlert>
|
|
194
|
+
) : (
|
|
195
|
+
<ITSchemaRenderer
|
|
196
|
+
schema={estado.tree}
|
|
197
|
+
onIssue={registrarAviso}
|
|
198
|
+
resolveAction={resolveAction}
|
|
199
|
+
pendingFallback={<ITText muted>·</ITText>}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<ITFlex align="center" gap={2} wrap="wrap" className="mt-4 border-t border-slate-200 pt-3 dark:border-slate-800">
|
|
205
|
+
<ITButton
|
|
206
|
+
label={streaming ? "Emitiendo…" : "Emitir como stream"}
|
|
207
|
+
icon={<FaBolt size={10} />}
|
|
208
|
+
size="sm"
|
|
209
|
+
color="primary"
|
|
210
|
+
onClick={emitirStream}
|
|
211
|
+
disabled={streaming}
|
|
212
|
+
/>
|
|
213
|
+
<ITButton
|
|
214
|
+
label="Documentación del motor"
|
|
215
|
+
icon={<FaBolt size={10} />}
|
|
216
|
+
size="sm"
|
|
217
|
+
variant="text"
|
|
218
|
+
color="gray"
|
|
219
|
+
onClick={() => {
|
|
220
|
+
window.location.hash = "#demo/app/docs";
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
<ITButton
|
|
224
|
+
label="Ver panel (sistema completo)"
|
|
225
|
+
icon={<FaBolt size={10} />}
|
|
226
|
+
size="sm"
|
|
227
|
+
color="primary"
|
|
228
|
+
onClick={() => {
|
|
229
|
+
window.location.hash = "#demo/app/inicio";
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
<ITButton
|
|
233
|
+
label="Ver portafolio de demo"
|
|
234
|
+
icon={<FaBolt size={10} />}
|
|
235
|
+
size="sm"
|
|
236
|
+
variant="outlined"
|
|
237
|
+
color="gray"
|
|
238
|
+
onClick={() => {
|
|
239
|
+
window.location.hash = "#demo";
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<ITButton
|
|
243
|
+
label="Reiniciar"
|
|
244
|
+
icon={<FaTrashAlt size={10} />}
|
|
245
|
+
size="sm"
|
|
246
|
+
variant="outlined"
|
|
247
|
+
color="gray"
|
|
248
|
+
onClick={() => {
|
|
249
|
+
detener();
|
|
250
|
+
setTexto(TEXTO_INICIAL);
|
|
251
|
+
setEmitido(TEXTO_INICIAL.length);
|
|
252
|
+
setAvisos([]);
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
<ITText
|
|
256
|
+
as="span"
|
|
257
|
+
className="font-mono !text-[10px] uppercase !tracking-[0.12em]"
|
|
258
|
+
muted
|
|
259
|
+
>
|
|
260
|
+
{streaming
|
|
261
|
+
? `stream ${emitido}/${TEXTO_INICIAL.length} · ${estado.pending} nodos pendientes`
|
|
262
|
+
: `esquema completo · ${axzyCatalogTypes.length} tipos en el catálogo`}
|
|
263
|
+
</ITText>
|
|
264
|
+
</ITFlex>
|
|
265
|
+
|
|
266
|
+
{avisos.length > 0 && (
|
|
267
|
+
<div className="mt-3 space-y-1.5">
|
|
268
|
+
<ITFlex align="center" gap={1.5}>
|
|
269
|
+
<FaBug size={10} className="text-amber-500" />
|
|
270
|
+
<ITText
|
|
271
|
+
as="span"
|
|
272
|
+
className="font-mono !text-[10px] uppercase !tracking-[0.12em]"
|
|
273
|
+
muted
|
|
274
|
+
>
|
|
275
|
+
Avisos del motor
|
|
276
|
+
</ITText>
|
|
277
|
+
</ITFlex>
|
|
278
|
+
{avisos.map((aviso) => (
|
|
279
|
+
<ITFlex
|
|
280
|
+
key={`${aviso.code}-${aviso.path}`}
|
|
281
|
+
align="start"
|
|
282
|
+
gap={2}
|
|
283
|
+
className="rounded-[6px] border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
|
284
|
+
>
|
|
285
|
+
<ITBadget
|
|
286
|
+
label={aviso.code}
|
|
287
|
+
color={aviso.severity === "error" ? "danger" : "warning"}
|
|
288
|
+
size="sm"
|
|
289
|
+
/>
|
|
290
|
+
<div className="min-w-0">
|
|
291
|
+
<p className="text-[11px] leading-snug text-slate-700 dark:text-slate-300">{aviso.message}</p>
|
|
292
|
+
<p className="font-mono text-[10px] text-slate-500 dark:text-slate-400">{aviso.path || "raíz"}</p>
|
|
293
|
+
</div>
|
|
294
|
+
</ITFlex>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{!streaming && avisos.length === 0 && (
|
|
300
|
+
<ITFlex align="center" gap={1.5} className="mt-3">
|
|
301
|
+
<FaCheck size={10} className="text-emerald-500" />
|
|
302
|
+
<ITText as="span" className="font-mono !text-[10px] uppercase !tracking-[0.12em]" muted>
|
|
303
|
+
Sin avisos: el esquema cabe entero en el catálogo
|
|
304
|
+
</ITText>
|
|
305
|
+
</ITFlex>
|
|
306
|
+
)}
|
|
307
|
+
</ITCard>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
}
|
|
311
|
+
code={codigo}
|
|
312
|
+
doc={{
|
|
313
|
+
summary: "Ejecuta un esquema JSON contra el catálogo de componentes, con lista blanca y avisos.",
|
|
314
|
+
description:
|
|
315
|
+
"El esquema se trata como entrada no confiable: un tipo fuera del catálogo no rompe la pantalla, una prop prohibida se ignora con aviso y un componente que lanza queda aislado. Durante el stream, los nodos pendientes no se pintan a medias.",
|
|
316
|
+
examples: [
|
|
317
|
+
'<ITSchemaRenderer schema={{ version: 1, id: "demo", content: { type: "ITText", text: "Hola" } }} />',
|
|
318
|
+
'<ITSchemaRenderer schema={parsePartialSchema(tokens).tree} pendingFallback={<ITSkeleton />} />',
|
|
319
|
+
'<ITSchemaRenderer schema={schema} onIssue={(issue) => console.warn(issue.code, issue.path)} />',
|
|
320
|
+
],
|
|
321
|
+
props: [
|
|
322
|
+
{ name: "schema", type: "ScreenSchema | null", required: true, description: "Esquema de pantalla; admite árbol parcial." },
|
|
323
|
+
{ name: "catalog", type: "Catalog", default: "axzyCatalog", description: "Componentes y props permitidos." },
|
|
324
|
+
{ name: "limits", type: "Partial<EngineLimits>", default: "—", description: "Presupuesto de nodos, profundidad y tiempos." },
|
|
325
|
+
{ name: "resolveValue", type: "(value, path) => unknown", default: "—", description: "Resuelve referencias ($state, $param…)." },
|
|
326
|
+
{ name: "resolveAction", type: "(node, prop, action, path) => fn", default: "—", description: "Traduce acciones declaradas a handlers del host." },
|
|
327
|
+
{ name: "pendingFallback", type: "ReactNode", default: "—", description: "Qué pintar mientras un nodo está pendiente." },
|
|
328
|
+
{ name: "onIssue", type: "(issue) => void", default: "—", description: "Avisos con código y ruta." },
|
|
329
|
+
{ name: "className", type: "string", default: "—", description: "Clases del contenedor raíz." },
|
|
330
|
+
],
|
|
331
|
+
notes: [
|
|
332
|
+
`El catálogo de esta fase expone ${axzyCatalogTypes.length} tipos con lista blanca de props.`,
|
|
333
|
+
"Códigos de aviso: JR010 tipo fuera del catálogo · JR011 prop ignorada · JR012 nodo aislado · JR013 presupuesto excedido.",
|
|
334
|
+
"Pulsa «Emitir como stream» para ver el render progresivo: lo pendiente no se pinta a medias.",
|
|
335
|
+
],
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export default JsonScreenShowcase;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datos simulados del dashboard de demostración.
|
|
3
|
+
*
|
|
4
|
+
* Hace el papel del backend: el motor **nunca** hace red, así que el host expone
|
|
5
|
+
* un `fetcher` por nombre de fuente (`usuarios`, `pedidos`) y el esquema solo
|
|
6
|
+
* escribe `{ "$source": "usuarios" }`. Es exactamente el contrato de la fase 4:
|
|
7
|
+
* el esquema nombra, el host resuelve.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ITDataTableFetchParams, ITDataTableResponse } from "../../index";
|
|
11
|
+
|
|
12
|
+
/** Un usuario del sistema. */
|
|
13
|
+
export interface DemoUsuario extends Record<string, unknown> {
|
|
14
|
+
id: number;
|
|
15
|
+
nombre: string;
|
|
16
|
+
correo: string;
|
|
17
|
+
rol: string;
|
|
18
|
+
estado: string;
|
|
19
|
+
alta: string;
|
|
20
|
+
edad: number;
|
|
21
|
+
empresa: string;
|
|
22
|
+
turno: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Un pedido del sistema. */
|
|
26
|
+
export interface DemoPedido extends Record<string, unknown> {
|
|
27
|
+
folio: string;
|
|
28
|
+
cliente: string;
|
|
29
|
+
total: number;
|
|
30
|
+
estado: string;
|
|
31
|
+
fecha: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fuentes disponibles, por nombre. */
|
|
35
|
+
export type DemoSource = "usuarios" | "pedidos" | "productos";
|
|
36
|
+
|
|
37
|
+
/** Usuarios de ejemplo. */
|
|
38
|
+
export const USUARIOS: DemoUsuario[] = [
|
|
39
|
+
"Ana Ruiz", "Luis Paredes", "Marcela Ortiz", "Jorge Cano", "Sofía Lara",
|
|
40
|
+
"Diego Núñez", "Paula Ferrer", "Iván Robles", "Nora Salas", "Emilio Tapia",
|
|
41
|
+
"Clara Vidal", "Hugo Medina", "Rita Campos", "Abel Cordero", "Lucía Ponce",
|
|
42
|
+
"Mateo Barrios", "Elena Ríos", "Tomás Ávila", "Inés Lozano", "Bruno Cárdenas",
|
|
43
|
+
"Alba Solís", "Ciro Mena", "Dora Ibáñez", "Fabián Correa", "Gala Nieves",
|
|
44
|
+
].map((nombre, index) => ({
|
|
45
|
+
id: index + 1,
|
|
46
|
+
nombre,
|
|
47
|
+
correo: `${nombre.toLowerCase().replace(/[^a-z]+/g, ".")}@axzy.dev`,
|
|
48
|
+
rol: ["Admin", "Operador", "Auditor", "Soporte"][index % 4],
|
|
49
|
+
estado: index % 7 === 0 ? "Inactivo" : "Activo",
|
|
50
|
+
alta: `2026-0${(index % 9) + 1}-${String((index % 27) + 1).padStart(2, "0")}`,
|
|
51
|
+
edad: 22 + (index % 30),
|
|
52
|
+
empresa: ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud"][index % 5],
|
|
53
|
+
turno: ["08:00", "09:30", "14:00", "22:00"][index % 4],
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const CLIENTES = ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud", "Ferretería Sur"];
|
|
57
|
+
const ESTADOS = ["Pagado", "Pendiente", "Enviado", "Cancelado"];
|
|
58
|
+
|
|
59
|
+
/** Pedidos de ejemplo. */
|
|
60
|
+
export const PEDIDOS: DemoPedido[] = Array.from({ length: 42 }, (_, index) => ({
|
|
61
|
+
folio: `PED-${String(1042 + index).padStart(4, "0")}`,
|
|
62
|
+
cliente: CLIENTES[index % CLIENTES.length],
|
|
63
|
+
total: Math.round((180 + ((index * 137) % 2400)) * 100) / 100,
|
|
64
|
+
estado: ESTADOS[index % ESTADOS.length],
|
|
65
|
+
fecha: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/** Borrador de usuario que envía el formulario del host. */
|
|
69
|
+
export interface DemoUsuarioInput {
|
|
70
|
+
nombre: string;
|
|
71
|
+
correo: string;
|
|
72
|
+
rol: string;
|
|
73
|
+
estado: string;
|
|
74
|
+
edad: number;
|
|
75
|
+
empresa: string;
|
|
76
|
+
turno: string;
|
|
77
|
+
alta: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Crea un usuario y devuelve su id. */
|
|
81
|
+
export function crearUsuario(input: DemoUsuarioInput): number {
|
|
82
|
+
const id = USUARIOS.reduce((max, usuario) => Math.max(max, usuario.id), 0) + 1;
|
|
83
|
+
USUARIOS.unshift({ id, ...input, alta: new Date().toISOString().slice(0, 10) });
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Actualiza un usuario por id. */
|
|
88
|
+
export function actualizarUsuario(id: number, input: DemoUsuarioInput): void {
|
|
89
|
+
const indice = USUARIOS.findIndex((usuario) => usuario.id === id);
|
|
90
|
+
if (indice >= 0) USUARIOS[indice] = { ...USUARIOS[indice], ...input };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Elimina un usuario por id. */
|
|
94
|
+
export function eliminarUsuario(id: number): void {
|
|
95
|
+
const indice = USUARIOS.findIndex((usuario) => usuario.id === id);
|
|
96
|
+
if (indice >= 0) USUARIOS.splice(indice, 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Busca un usuario por id. */
|
|
100
|
+
export function obtenerUsuario(id: number): DemoUsuario | undefined {
|
|
101
|
+
return USUARIOS.find((usuario) => usuario.id === id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Productos de ejemplo. */
|
|
105
|
+
export const PRODUCTOS_DEMO = [
|
|
106
|
+
["AX-1001", "Teclado mecánico", 1290, 24, "Periféricos"],
|
|
107
|
+
["AX-1002", "Monitor 27\"", 6890, 8, "Pantallas"],
|
|
108
|
+
["AX-1003", "Mouse inalámbrico", 590, 61, "Periféricos"],
|
|
109
|
+
["AX-1004", "Docking station", 2450, 12, "Conectividad"],
|
|
110
|
+
["AX-1005", "Webcam 4K", 1890, 0, "Video"],
|
|
111
|
+
["AX-1006", "Audífonos", 990, 37, "Audio"],
|
|
112
|
+
["AX-1007", "Hub USB-C", 790, 44, "Conectividad"],
|
|
113
|
+
["AX-1008", "Silla ergonómica", 5490, 5, "Mobiliario"],
|
|
114
|
+
["AX-1009", "Escritorio eléctrico", 8990, 3, "Mobiliario"],
|
|
115
|
+
["AX-1010", "Lámpara de escritorio", 690, 28, "Iluminación"],
|
|
116
|
+
["AX-1011", "Base para laptop", 450, 52, "Accesorios"],
|
|
117
|
+
["AX-1012", "Cable HDMI 2.1", 320, 90, "Cables"],
|
|
118
|
+
].map(([sku, nombre, precio, stock, categoria]) => ({ sku, nombre, precio, stock, categoria }));
|
|
119
|
+
|
|
120
|
+
/** Colecciones por fuente. */
|
|
121
|
+
const COLECCIONES: Record<DemoSource, Record<string, unknown>[]> = {
|
|
122
|
+
usuarios: USUARIOS as unknown as Record<string, unknown>[],
|
|
123
|
+
pedidos: PEDIDOS as unknown as Record<string, unknown>[],
|
|
124
|
+
productos: PRODUCTOS_DEMO as unknown as Record<string, unknown>[],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** `true` cuando el nombre es una fuente registrada. */
|
|
128
|
+
export function isDemoSource(name: string): name is DemoSource {
|
|
129
|
+
return name in COLECCIONES;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Filtro de estado activo (lo escribe el host). */
|
|
133
|
+
let estadoFiltro: string | null = null;
|
|
134
|
+
|
|
135
|
+
/** Fija el filtro de estado global; `null`/`Todos` lo desactiva. */
|
|
136
|
+
export function setEstadoFiltro(estado: string | null): void {
|
|
137
|
+
estadoFiltro = estado && estado !== "Todos" ? estado : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Compara dos valores para ordenar. */
|
|
141
|
+
function compare(a: unknown, b: unknown): number {
|
|
142
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
143
|
+
return String(a).localeCompare(String(b), "es");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Crea el `fetchData` de una fuente: aplica filtros, orden y paginación.
|
|
148
|
+
*
|
|
149
|
+
* El retardo simula la red para que se vean los estados de carga del motor.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* const fetcher = createDemoFetcher("usuarios");
|
|
153
|
+
* await fetcher({ page: 1, limit: 5, filters: {} });
|
|
154
|
+
*/
|
|
155
|
+
export function createDemoFetcher(source: DemoSource) {
|
|
156
|
+
return async (params: ITDataTableFetchParams): Promise<ITDataTableResponse<Record<string, unknown>>> => {
|
|
157
|
+
// Latencia simulada: hace visible el estado de carga de la tabla.
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 260));
|
|
159
|
+
|
|
160
|
+
let rows = [...COLECCIONES[source]];
|
|
161
|
+
|
|
162
|
+
// Filtros por columna: igualdad para catálogo, subcadena para texto.
|
|
163
|
+
for (const [key, value] of Object.entries(params.filters ?? {})) {
|
|
164
|
+
if (value === undefined || value === null || value === "") continue;
|
|
165
|
+
const needle = String(value).toLowerCase();
|
|
166
|
+
rows = rows.filter((row) => String(row[key] ?? "").toLowerCase().includes(needle));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Filtro triple del host (persistido en la URL).
|
|
170
|
+
if (estadoFiltro) rows = rows.filter((row) => String(row.estado ?? "") === estadoFiltro);
|
|
171
|
+
|
|
172
|
+
if (params.sort) {
|
|
173
|
+
const { key, direction } = params.sort;
|
|
174
|
+
rows.sort((a, b) => (direction === "asc" ? compare(a[key], b[key]) : compare(b[key], a[key])));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const total = rows.length;
|
|
178
|
+
const start = (params.page - 1) * params.limit;
|
|
179
|
+
return { data: rows.slice(start, start + params.limit), total };
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { leerNodo, listarNodos } from "./nodeTree";
|
|
3
|
+
|
|
4
|
+
const esquema = {
|
|
5
|
+
version: 1,
|
|
6
|
+
id: "demo",
|
|
7
|
+
content: [
|
|
8
|
+
{ type: "ITStack", id: "raiz", children: [
|
|
9
|
+
{ type: "ITText", text: "hola" },
|
|
10
|
+
{ type: "ITButton", id: "cta", props: { label: "Ir" } },
|
|
11
|
+
{ type: "ITCard", children: [{ type: "ITChip", props: { label: "x" } }] },
|
|
12
|
+
] },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("inspector de nodos", () => {
|
|
17
|
+
it("lista los nodos con su ruta numérica", () => {
|
|
18
|
+
const nodos = listarNodos(esquema);
|
|
19
|
+
expect(nodos.map((n) => n.tipo)).toEqual(["ITStack", "ITText", "ITButton", "ITCard", "ITChip"]);
|
|
20
|
+
expect(nodos[2].ruta).toEqual([0, 1]);
|
|
21
|
+
expect(nodos[2].etiqueta).toContain("#cta");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("devuelve el subárbol de una ruta", () => {
|
|
25
|
+
expect(leerNodo(esquema, [0, 2])).toMatchObject({ type: "ITCard" });
|
|
26
|
+
expect(leerNodo(esquema, [0, 1])).toMatchObject({ type: "ITButton", id: "cta" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("la ruta vacía es el esquema completo", () => {
|
|
30
|
+
expect(leerNodo(esquema, [])).toBe(esquema);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("corta la lista a 40 nodos y sobrevive a basura", () => {
|
|
34
|
+
const grande = { content: Array.from({ length: 60 }, () => ({ type: "ITText", text: "x" })) };
|
|
35
|
+
expect(listarNodos(grande)).toHaveLength(40);
|
|
36
|
+
expect(listarNodos(null)).toEqual([]);
|
|
37
|
+
expect(leerNodo(esquema, [9, 9])).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Helpers del inspector de nodos de la documentación (puros y testeables). */
|
|
2
|
+
|
|
3
|
+
/** Un nodo seleccionable del árbol, con su ruta numérica. */
|
|
4
|
+
interface NodoRuta {
|
|
5
|
+
ruta: number[];
|
|
6
|
+
etiqueta: string;
|
|
7
|
+
tipo: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Recorre el árbol y lista los nodos seleccionables (hasta 40). */
|
|
11
|
+
export function listarNodos(nodo: unknown, ruta: number[] = [], salida: NodoRuta[] = []): NodoRuta[] {
|
|
12
|
+
if (!nodo || typeof nodo !== "object" || salida.length >= 40) return salida;
|
|
13
|
+
const objeto = nodo as { type?: string; id?: string; children?: unknown; content?: unknown };
|
|
14
|
+
if (typeof objeto.type === "string") {
|
|
15
|
+
salida.push({
|
|
16
|
+
ruta,
|
|
17
|
+
tipo: objeto.type,
|
|
18
|
+
etiqueta: `${objeto.type}${objeto.id ? ` #${objeto.id}` : ""} · ${ruta.join(".")}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const hijos = objeto.children ?? objeto.content;
|
|
22
|
+
const lista = Array.isArray(hijos) ? hijos : hijos ? [hijos] : [];
|
|
23
|
+
lista.forEach((hijo, indice) => listarNodos(hijo, [...ruta, indice], salida));
|
|
24
|
+
return salida;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Devuelve el subárbol de una ruta numérica. */
|
|
28
|
+
export function leerNodo(nodo: unknown, ruta: number[]): unknown {
|
|
29
|
+
return ruta.reduce((actual: unknown, indice) => {
|
|
30
|
+
const objeto = actual as { children?: unknown; content?: unknown } | null;
|
|
31
|
+
const hijos = objeto?.children ?? objeto?.content;
|
|
32
|
+
const lista = Array.isArray(hijos) ? hijos : hijos ? [hijos] : [];
|
|
33
|
+
return lista[indice];
|
|
34
|
+
}, nodo);
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { validateSchema } from "../../json-renderer/validation/validate";
|
|
5
|
+
|
|
6
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
7
|
+
|
|
8
|
+
/** Lee y parsea un archivo del demo. */
|
|
9
|
+
function leerJson(nombre: string): unknown {
|
|
10
|
+
return JSON.parse(readFileSync(join(DIR, nombre), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && archivo !== "layout.json");
|
|
14
|
+
|
|
15
|
+
describe("JSON del demo", () => {
|
|
16
|
+
it("hay páginas y un layout", () => {
|
|
17
|
+
expect(paginas.length).toBeGreaterThanOrEqual(5);
|
|
18
|
+
expect(() => leerJson("layout.json")).not.toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(paginas)("%s es un esquema válido", (archivo) => {
|
|
22
|
+
const informe = validateSchema(leerJson(archivo));
|
|
23
|
+
expect(informe.errors, JSON.stringify(informe.errors)).toHaveLength(0);
|
|
24
|
+
expect(informe.verdict).toBe("valid");
|
|
25
|
+
expect(informe.pendingNodes).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("el layout declara marca, módulos y páginas existentes", () => {
|
|
29
|
+
const layout = leerJson("layout.json") as {
|
|
30
|
+
layout: { brand?: { text?: string }; modules: { id: string; page: string; icon?: string }[] };
|
|
31
|
+
};
|
|
32
|
+
expect(layout.layout.brand?.text).toBeTruthy();
|
|
33
|
+
expect(layout.layout.modules.length).toBeGreaterThanOrEqual(6);
|
|
34
|
+
for (const modulo of layout.layout.modules) {
|
|
35
|
+
expect(modulo.id, "cada módulo necesita id").toBeTruthy();
|
|
36
|
+
expect(modulo.page, `${modulo.id} necesita page`).toBeTruthy();
|
|
37
|
+
}
|
|
38
|
+
// Todo módulo apunta a una página que existe (docs no tiene archivo propio).
|
|
39
|
+
const ids = new Set(layout.layout.modules.map((modulo) => modulo.page));
|
|
40
|
+
expect(ids.has("docs")).toBe(true);
|
|
41
|
+
for (const pagina of ids) {
|
|
42
|
+
if (pagina === "docs") continue;
|
|
43
|
+
expect(paginas, `falta ${pagina}.json`).toContain(`${pagina}.json`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("las páginas declaran título y descripción traducibles", () => {
|
|
48
|
+
for (const archivo of paginas) {
|
|
49
|
+
const esquema = leerJson(archivo) as { title?: { i18n?: string }; description?: { i18n?: string } };
|
|
50
|
+
expect(esquema.title?.i18n, `${archivo} sin title.i18n`).toBeTruthy();
|
|
51
|
+
expect(esquema.description?.i18n, `${archivo} sin description.i18n`).toBeTruthy();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|