@axzydev/axzy_ui_system 1.7.3 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1621 -594
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +366 -12
- package/dist/index.d.ts +366 -12
- package/dist/index.js +1331 -313
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +4 -2
- package/src/App.tsx +6 -8
- package/src/components/atoms/flex/flex.props.ts +3 -1
- package/src/components/atoms/flex/flex.tsx +11 -3
- package/src/components/atoms/textarea/textarea.props.ts +4 -0
- package/src/components/atoms/textarea/textarea.tsx +13 -3
- package/src/components/molecules/card/card.props.ts +2 -0
- package/src/components/molecules/card/card.tsx +15 -2
- package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
- package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
- package/src/components/organisms/dropfile/dropfile.tsx +17 -0
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +7 -0
- package/src/index.ts +31 -3
- package/src/json-renderer/actions/effects.test.tsx +115 -0
- package/src/json-renderer/actions/effects.ts +291 -0
- package/src/json-renderer/actions/effectsChain.test.ts +117 -0
- package/src/json-renderer/binding/binding.test.tsx +168 -0
- package/src/json-renderer/binding/resolveValue.ts +193 -0
- package/src/json-renderer/estado-acciones.test.tsx +166 -0
- package/src/json-renderer/form/FormNode.tsx +92 -0
- package/src/json-renderer/form/form.test.tsx +148 -0
- package/src/json-renderer/form/formLength.test.tsx +99 -0
- package/src/json-renderer/form/rules.ts +162 -0
- package/src/json-renderer/index.ts +21 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
- package/src/json-renderer/kernel/capabilities.ts +30 -0
- package/src/json-renderer/kernel/renderNode.tsx +256 -3
- package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
- package/src/json-renderer/kernel/slots.test.tsx +104 -0
- package/src/json-renderer/kernel/slots.ts +34 -0
- package/src/json-renderer/kernel/types.ts +4 -0
- package/src/json-renderer/routing/match.ts +82 -0
- package/src/json-renderer/routing/navigation.ts +71 -0
- package/src/json-renderer/routing/routes.ts +179 -0
- package/src/json-renderer/routing/routing.test.ts +147 -0
- package/src/json-renderer/seguridad.test.tsx +299 -0
- package/src/json-renderer/shared/errors.ts +1 -0
- package/src/json-renderer/stream/tolerantParse.ts +5 -0
- package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
- package/src/json-renderer/stream/useSchemaStream.ts +227 -0
- package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
- package/src/json-renderer/validation/jsonSchema.ts +317 -0
- package/src/json-renderer/validation/validate.ts +17 -0
- package/src/sandbox/i18n.ts +38 -0
- package/src/sandbox/useHashRoute.ts +2 -1
- package/src/showcases/DemoJsonDashboard.tsx +218 -103
- package/src/showcases/DemoJsonDocs.tsx +11 -11
- package/src/showcases/JsonScreenShowcase.tsx +43 -53
- package/src/showcases/demoApp/demoJson.test.tsx +241 -0
- package/src/showcases/demoApp/routes.test.ts +63 -0
- package/src/showcases/demoApp/schemas.test.ts +6 -17
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Efectos de acción: lo que el esquema pide que pase después de una acción.
|
|
3
|
+
*
|
|
4
|
+
* Una acción declara su efecto en el JSON; el host decide cómo se cumple. El motor:
|
|
5
|
+
*
|
|
6
|
+
* - ejecuta el handler del host (la acción en sí);
|
|
7
|
+
* - si termina bien, aplica `then` (y los efectos sueltos de la acción);
|
|
8
|
+
* - si falla o rechaza, aplica `onError`.
|
|
9
|
+
*
|
|
10
|
+
* Los efectos (`toast`, `navigate`, `refresh`, `openOverlay`, `closeOverlay`) los
|
|
11
|
+
* cumple el registro `effects` que pasa el host. Un efecto también puede ser otra
|
|
12
|
+
* acción (`{ action, args, then, onError }`): el motor la resuelve con el handler del
|
|
13
|
+
* host y aplica sus propios efectos. Como el JSON es finito, el encadenado termina;
|
|
14
|
+
* `MAX_EFFECT_DEPTH` lo limita de todos modos (`JR042`).
|
|
15
|
+
*
|
|
16
|
+
* Los textos y rutas pueden leer el resultado del handler con `{ "$result": "campo" }`
|
|
17
|
+
* o `{campo}` dentro de una dirección.
|
|
18
|
+
*
|
|
19
|
+
* Si el esquema pide un efecto que el host no implementa, o una acción encadenada que
|
|
20
|
+
* no existe, el motor avisa al pintar (`JR040`), nunca al ejecutar.
|
|
21
|
+
*
|
|
22
|
+
* Sin `then`, `onError` ni efectos, el handler se devuelve tal cual: no cambia nada.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { issue, type JsonEngineIssue } from "../shared/errors";
|
|
26
|
+
import { formatPath, type SchemaPath } from "../shared/paths";
|
|
27
|
+
import type { EffectSchema } from "../kernel/types";
|
|
28
|
+
|
|
29
|
+
/** Profundidad máxima de acciones encadenadas desde efectos. */
|
|
30
|
+
export const MAX_EFFECT_DEPTH = 5;
|
|
31
|
+
|
|
32
|
+
/** Variante de un aviso (`toast`). */
|
|
33
|
+
export type ToastVariant = "success" | "error" | "info" | "warning";
|
|
34
|
+
|
|
35
|
+
/** Lo que el host implementa. Cada efecto es opcional: el motor avisa si falta uno que se usa. */
|
|
36
|
+
export interface EffectRegistry {
|
|
37
|
+
/** Muestra un aviso al usuario. */
|
|
38
|
+
toast?: (aviso: { variant: ToastVariant; message: string }) => void;
|
|
39
|
+
/** Navega a una dirección. */
|
|
40
|
+
navigate?: (href: string) => void;
|
|
41
|
+
/** Refresca una fuente de datos. */
|
|
42
|
+
refresh?: (source: string) => void;
|
|
43
|
+
/** Abre un overlay declarado. */
|
|
44
|
+
openOverlay?: (name: string) => void;
|
|
45
|
+
/** Cierra un overlay; sin nombre, el que esté abierto. */
|
|
46
|
+
closeOverlay?: (name?: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Contexto que necesita un efecto para ejecutarse en un nodo concreto. */
|
|
50
|
+
export interface EffectContext {
|
|
51
|
+
/** Registro que implementa el host. */
|
|
52
|
+
effects?: EffectRegistry;
|
|
53
|
+
/** Traduce un valor del esquema (por ejemplo, `{ i18n }`) a texto. */
|
|
54
|
+
resolveText: (value: unknown, path: SchemaPath) => string;
|
|
55
|
+
/** Recibe los avisos del render (`JR040`, `JR042`). */
|
|
56
|
+
report: (aviso: JsonEngineIssue) => void;
|
|
57
|
+
/** Ruta del nodo, para los avisos. */
|
|
58
|
+
path: SchemaPath;
|
|
59
|
+
/** Handler del host para una acción encadenada por nombre, o `undefined` si no existe. */
|
|
60
|
+
resolveChained?: (name: string) => ((...args: unknown[]) => unknown) | undefined;
|
|
61
|
+
/** Profundidad de encadenado. La acción del nodo es 0. */
|
|
62
|
+
depth?: number;
|
|
63
|
+
/** Milisegundos que una promesa del host puede tardar antes de considerarla colgada (`JR041`). */
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** Recibe los avisos que se producen después del render (un timeout, por ejemplo). */
|
|
66
|
+
reportRuntime?: (aviso: JsonEngineIssue) => void;
|
|
67
|
+
/** Escribe en el estado de la pantalla (efecto `set`). */
|
|
68
|
+
setState?: (cambios: Record<string, unknown>) => void;
|
|
69
|
+
/** Resuelve referencias del esquema (`$state`, `$param`…) de un valor. */
|
|
70
|
+
resolveRaw?: (value: unknown, path: SchemaPath) => unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Un paso que se ejecuta cuando la acción termina, con el resultado del handler. */
|
|
74
|
+
type Paso = (resultado: unknown) => void;
|
|
75
|
+
|
|
76
|
+
/** Nombres de efecto que el motor conoce. */
|
|
77
|
+
const EFFECT_KEYS = ["toast", "navigate", "refresh", "openOverlay", "closeOverlay", "set"] as const;
|
|
78
|
+
|
|
79
|
+
/** Efectos que cumple el host. `set` lo cumple el propio motor, así que no se pide al registro. */
|
|
80
|
+
const EFFECT_KEYS_DEL_HOST = ["toast", "navigate", "refresh", "openOverlay", "closeOverlay"] as const;
|
|
81
|
+
|
|
82
|
+
/** Forma de una acción con efectos, tal como la escribe el JSON. */
|
|
83
|
+
interface ActionWithEffects extends EffectSchema {
|
|
84
|
+
/** Efectos que se ejecutan si la acción termina bien. */
|
|
85
|
+
then?: EffectSchema[];
|
|
86
|
+
/** Efecto que se ejecuta si la acción falla. */
|
|
87
|
+
onError?: EffectSchema;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Lee un campo del resultado por ruta (`"usuario.id"`). Sin resultado o sin campo, `undefined`. */
|
|
91
|
+
function leerResultado(ruta: string, resultado: unknown): unknown {
|
|
92
|
+
return ruta.split(".").reduce<unknown>(
|
|
93
|
+
(actual, clave) => (actual && typeof actual === "object" ? (actual as Record<string, unknown>)[clave] : undefined),
|
|
94
|
+
resultado
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Sustituye las referencias `{ $result }` de un valor por el dato real del handler. */
|
|
99
|
+
function resolverResultado(valor: unknown, resultado: unknown): unknown {
|
|
100
|
+
if (!valor || typeof valor !== "object" || Array.isArray(valor)) return valor;
|
|
101
|
+
const objeto = valor as Record<string, unknown>;
|
|
102
|
+
if (typeof objeto.$result === "string") return leerResultado(objeto.$result, resultado) ?? "";
|
|
103
|
+
if (objeto.params && typeof objeto.params === "object") {
|
|
104
|
+
const params = Object.fromEntries(
|
|
105
|
+
Object.entries(objeto.params as Record<string, unknown>).map(([clave, valorParam]) => [
|
|
106
|
+
clave,
|
|
107
|
+
resolverResultado(valorParam, resultado),
|
|
108
|
+
])
|
|
109
|
+
);
|
|
110
|
+
return { ...objeto, params };
|
|
111
|
+
}
|
|
112
|
+
return valor;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Sustituye `{campo}` en una dirección por el campo del resultado. */
|
|
116
|
+
function plantilla(texto: string, resultado: unknown): string {
|
|
117
|
+
return texto.replace(/\{([\w.]+)\}/g, (_, ruta: string) => {
|
|
118
|
+
const valor = leerResultado(ruta, resultado);
|
|
119
|
+
return valor === undefined ? "" : String(valor);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Efectos sueltos de una acción (sin `action`, `args`, `then` ni `onError`), o `null` si no tiene. */
|
|
124
|
+
function efectosSueltos(declarada: ActionWithEffects): EffectSchema | null {
|
|
125
|
+
const sueltos: Record<string, unknown> = {};
|
|
126
|
+
for (const clave of EFFECT_KEYS) {
|
|
127
|
+
if (declarada[clave] !== undefined) sueltos[clave] = declarada[clave];
|
|
128
|
+
}
|
|
129
|
+
return Object.keys(sueltos).length > 0 ? (sueltos as EffectSchema) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Avisa de los efectos de registro que el esquema pide y el host no implementa. Se llama al pintar. */
|
|
133
|
+
function comprobarRegistro(efecto: EffectSchema, context: EffectContext): void {
|
|
134
|
+
for (const clave of EFFECT_KEYS_DEL_HOST) {
|
|
135
|
+
if (efecto[clave] === undefined) continue;
|
|
136
|
+
if (typeof context.effects?.[clave] !== "function") {
|
|
137
|
+
context.report(
|
|
138
|
+
issue("JR040", `El host no implementa el efecto \`${clave}\`.`, formatPath(context.path), {
|
|
139
|
+
severity: "warning",
|
|
140
|
+
hint: "Regístralo en `effects` del renderizador o quita el efecto del esquema.",
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Cumple un efecto de registro con el resultado del handler. */
|
|
148
|
+
function cumplirEfecto(efecto: EffectSchema, context: EffectContext, resultado: unknown): void {
|
|
149
|
+
// `set` lo cumple el motor: escribe en el estado de la pantalla, con el resultado ya aplicado.
|
|
150
|
+
if (efecto.set && context.setState) {
|
|
151
|
+
const cambios: Record<string, unknown> = {};
|
|
152
|
+
for (const [clave, valor] of Object.entries(efecto.set)) {
|
|
153
|
+
const conResultado = resolverResultado(valor, resultado);
|
|
154
|
+
cambios[clave] = context.resolveRaw ? context.resolveRaw(conResultado, context.path) : conResultado;
|
|
155
|
+
}
|
|
156
|
+
context.setState(cambios);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const registro = context.effects;
|
|
160
|
+
if (!registro) return;
|
|
161
|
+
|
|
162
|
+
if (efecto.toast && registro.toast) {
|
|
163
|
+
registro.toast({
|
|
164
|
+
variant: efecto.toast.variant ?? "info",
|
|
165
|
+
message: context.resolveText(resolverResultado(efecto.toast.message, resultado), context.path),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (typeof efecto.navigate === "string" && registro.navigate) {
|
|
169
|
+
registro.navigate(plantilla(efecto.navigate, resultado));
|
|
170
|
+
}
|
|
171
|
+
if (typeof efecto.refresh === "string" && registro.refresh) registro.refresh(efecto.refresh);
|
|
172
|
+
if (typeof efecto.openOverlay === "string" && registro.openOverlay) registro.openOverlay(efecto.openOverlay);
|
|
173
|
+
if (efecto.closeOverlay !== undefined && registro.closeOverlay) {
|
|
174
|
+
registro.closeOverlay(typeof efecto.closeOverlay === "string" ? efecto.closeOverlay : undefined);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convierte cada efecto en un paso. Los efectos de registro se comprueban aquí; una
|
|
180
|
+
* acción encadenada se resuelve aquí y se envuelve con sus propios efectos.
|
|
181
|
+
*/
|
|
182
|
+
function pasosDe(efectos: EffectSchema[], context: EffectContext): Paso[] {
|
|
183
|
+
const profundidad = context.depth ?? 0;
|
|
184
|
+
|
|
185
|
+
return efectos.map((efecto): Paso => {
|
|
186
|
+
if (typeof efecto.action !== "string") {
|
|
187
|
+
comprobarRegistro(efecto, context);
|
|
188
|
+
return (resultado) => cumplirEfecto(efecto, context, resultado);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (profundidad + 1 > MAX_EFFECT_DEPTH) {
|
|
192
|
+
context.report(
|
|
193
|
+
issue("JR042", `El encadenado de acciones supera ${MAX_EFFECT_DEPTH} niveles.`, formatPath(context.path), {
|
|
194
|
+
severity: "error",
|
|
195
|
+
hint: "Reduce los `then` anidados.",
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
return () => undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const handler = context.resolveChained?.(efecto.action);
|
|
202
|
+
if (!handler) {
|
|
203
|
+
context.report(
|
|
204
|
+
issue("JR040", `La acción encadenada \`${efecto.action}\` no está registrada por el host.`, formatPath(context.path), {
|
|
205
|
+
severity: "error",
|
|
206
|
+
hint: "Regístrala en las acciones del renderizador o cambia el nombre en el esquema.",
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
return () => undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const encadenada = wrapAction(handler, efecto, { ...context, depth: profundidad + 1 });
|
|
213
|
+
return () => encadenada(efecto.args);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Envuelve el handler de una acción para que cumpla sus efectos.
|
|
219
|
+
*
|
|
220
|
+
* Los efectos sueltos de la acción cuentan como un `then` más, delante de la lista.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* const handler = wrapAction(hostHandler, { action: "enviar", then: [{ toast: { message: "Hecho" } }] }, context);
|
|
224
|
+
*/
|
|
225
|
+
export function wrapAction(
|
|
226
|
+
handler: (...args: unknown[]) => unknown,
|
|
227
|
+
action: unknown,
|
|
228
|
+
context: EffectContext
|
|
229
|
+
): (...args: unknown[]) => unknown {
|
|
230
|
+
if (!action || typeof action !== "object") return handler;
|
|
231
|
+
const declarada = action as ActionWithEffects;
|
|
232
|
+
const sueltos = efectosSueltos(declarada);
|
|
233
|
+
const alTerminar: EffectSchema[] = [...(sueltos ? [sueltos] : []), ...(Array.isArray(declarada.then) ? declarada.then : [])];
|
|
234
|
+
const alFallar = declarada.onError;
|
|
235
|
+
|
|
236
|
+
if (alTerminar.length === 0 && !alFallar) return handler;
|
|
237
|
+
|
|
238
|
+
const pasosOk = pasosDe(alTerminar, context);
|
|
239
|
+
const pasoError = alFallar ? pasosDe([alFallar], context)[0] : undefined;
|
|
240
|
+
const aplicar = (resultado: unknown) => pasosOk.forEach((paso) => paso(resultado));
|
|
241
|
+
|
|
242
|
+
return (...args: unknown[]) => {
|
|
243
|
+
let resultado: unknown;
|
|
244
|
+
try {
|
|
245
|
+
resultado = handler(...args);
|
|
246
|
+
} catch {
|
|
247
|
+
pasoError?.(undefined);
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (resultado && typeof (resultado as PromiseLike<unknown>).then === "function") {
|
|
252
|
+
// Una promesa que no resuelve a tiempo cuenta como fallo: se aplica onError y el
|
|
253
|
+
// resultado tardío se ignora. Así una acción colgada no deja el botón esperando.
|
|
254
|
+
let resuelta = false;
|
|
255
|
+
const limite = context.timeoutMs;
|
|
256
|
+
const temporizador =
|
|
257
|
+
limite !== undefined && Number.isFinite(limite)
|
|
258
|
+
? setTimeout(() => {
|
|
259
|
+
if (resuelta) return;
|
|
260
|
+
resuelta = true;
|
|
261
|
+
context.reportRuntime?.(
|
|
262
|
+
issue("JR041", `La acción excedió ${limite} ms y se considera fallida.`, formatPath(context.path), {
|
|
263
|
+
severity: "error",
|
|
264
|
+
hint: "El host debe responder o rechazar la acción a tiempo.",
|
|
265
|
+
})
|
|
266
|
+
);
|
|
267
|
+
pasoError?.(undefined);
|
|
268
|
+
}, limite)
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
(resultado as Promise<unknown>).then(
|
|
272
|
+
(valor) => {
|
|
273
|
+
if (resuelta) return;
|
|
274
|
+
resuelta = true;
|
|
275
|
+
clearTimeout(temporizador);
|
|
276
|
+
aplicar(valor);
|
|
277
|
+
},
|
|
278
|
+
() => {
|
|
279
|
+
if (resuelta) return;
|
|
280
|
+
resuelta = true;
|
|
281
|
+
clearTimeout(temporizador);
|
|
282
|
+
pasoError?.(undefined);
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
return resultado;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
aplicar(resultado);
|
|
289
|
+
return resultado;
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { JsonEngineIssue } from "../shared/errors";
|
|
3
|
+
import { MAX_EFFECT_DEPTH, wrapAction, type EffectContext, type EffectRegistry } from "./effects";
|
|
4
|
+
|
|
5
|
+
/** Contexto de pruebas: traduce textos a cadena y acumula avisos. */
|
|
6
|
+
function contexto(overrides: Partial<EffectContext> = {}) {
|
|
7
|
+
const avisos: JsonEngineIssue[] = [];
|
|
8
|
+
const context: EffectContext = {
|
|
9
|
+
path: ["content", 0],
|
|
10
|
+
resolveText: (value) => (typeof value === "string" ? value : JSON.stringify(value)),
|
|
11
|
+
report: (aviso) => avisos.push(aviso),
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
return { context, avisos };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("acciones encadenadas desde un efecto", () => {
|
|
18
|
+
it("un efecto con action llama al handler del host con sus args", () => {
|
|
19
|
+
const otra = vi.fn();
|
|
20
|
+
const { context } = contexto({ resolveChained: (nombre) => (nombre === "otra" ? otra : undefined) });
|
|
21
|
+
|
|
22
|
+
wrapAction(() => undefined, { action: "guardar", then: [{ action: "otra", args: { id: 7 } }] }, context)();
|
|
23
|
+
|
|
24
|
+
expect(otra).toHaveBeenCalledWith({ id: 7 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("la acción encadenada aplica sus propios efectos al terminar", () => {
|
|
28
|
+
const toast = vi.fn();
|
|
29
|
+
const otra = vi.fn();
|
|
30
|
+
const registro: EffectRegistry = { toast };
|
|
31
|
+
const { context } = contexto({
|
|
32
|
+
effects: registro,
|
|
33
|
+
resolveChained: (nombre) => (nombre === "otra" ? otra : undefined),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
wrapAction(
|
|
37
|
+
() => undefined,
|
|
38
|
+
{ action: "guardar", then: [{ action: "otra", then: [{ toast: { variant: "success", message: "Encadenado" } }] }] },
|
|
39
|
+
context
|
|
40
|
+
)();
|
|
41
|
+
|
|
42
|
+
expect(otra).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(toast).toHaveBeenCalledWith({ variant: "success", message: "Encadenado" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("una acción encadenada que el host no registró avisa con JR040 al pintar", () => {
|
|
47
|
+
const { context, avisos } = contexto({ resolveChained: () => undefined });
|
|
48
|
+
wrapAction(() => undefined, { action: "guardar", then: [{ action: "no-existe" }] }, context);
|
|
49
|
+
expect(avisos[0]).toMatchObject({ code: "JR040", severity: "error" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("el encadenado tiene un límite: más de MAX_EFFECT_DEPTH niveles avisa con JR042", () => {
|
|
53
|
+
const { context, avisos } = contexto({ resolveChained: () => () => undefined });
|
|
54
|
+
|
|
55
|
+
// Anidamos MAX_EFFECT_DEPTH + 2 acciones: la última supera el límite.
|
|
56
|
+
let efecto: Record<string, unknown> = { action: "hoja" };
|
|
57
|
+
for (let nivel = 0; nivel < MAX_EFFECT_DEPTH + 2; nivel += 1) {
|
|
58
|
+
efecto = { action: "nivel", then: [efecto] };
|
|
59
|
+
}
|
|
60
|
+
wrapAction(() => undefined, { action: "inicio", then: [efecto] }, context);
|
|
61
|
+
|
|
62
|
+
expect(avisos.some((aviso) => aviso.code === "JR042")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("un encadenado dentro del límite no avisa", () => {
|
|
66
|
+
const { context, avisos } = contexto({ resolveChained: () => () => undefined });
|
|
67
|
+
wrapAction(() => undefined, { action: "a", then: [{ action: "b", then: [{ action: "c" }] }] }, context);
|
|
68
|
+
expect(avisos.filter((aviso) => aviso.code === "JR042")).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("datos del resultado en los efectos", () => {
|
|
73
|
+
it("un parámetro { $result } de i18n toma el dato que devolvió el handler", () => {
|
|
74
|
+
const toast = vi.fn();
|
|
75
|
+
const { context } = contexto({ effects: { toast } });
|
|
76
|
+
|
|
77
|
+
wrapAction(
|
|
78
|
+
() => ({ folio: "PED-1042" }),
|
|
79
|
+
{ action: "crear", then: [{ toast: { message: { i18n: "pedido.creado", params: { folio: { $result: "folio" } } } } }] },
|
|
80
|
+
context
|
|
81
|
+
)();
|
|
82
|
+
|
|
83
|
+
expect(toast).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(toast.mock.calls[0][0].message).toContain("PED-1042");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("navigate sustituye {campo} por el dato del resultado", () => {
|
|
88
|
+
const navigate = vi.fn();
|
|
89
|
+
const { context } = contexto({ effects: { navigate } });
|
|
90
|
+
|
|
91
|
+
wrapAction(() => ({ id: 7 }), { action: "crear", then: [{ navigate: "/usuarios/{id}" }] }, context)();
|
|
92
|
+
|
|
93
|
+
expect(navigate).toHaveBeenCalledWith("/usuarios/7");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("con un handler asíncrono, los efectos reciben el valor resuelto", async () => {
|
|
97
|
+
const navigate = vi.fn();
|
|
98
|
+
const { context } = contexto({ effects: { navigate } });
|
|
99
|
+
|
|
100
|
+
await wrapAction(
|
|
101
|
+
() => Promise.resolve({ id: 9 }),
|
|
102
|
+
{ action: "crear", then: [{ navigate: "/usuarios/{id}" }] },
|
|
103
|
+
context
|
|
104
|
+
)();
|
|
105
|
+
|
|
106
|
+
expect(navigate).toHaveBeenCalledWith("/usuarios/9");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("un campo que el resultado no trae llega vacío, sin romper", () => {
|
|
110
|
+
const toast = vi.fn();
|
|
111
|
+
const { context } = contexto({ effects: { toast } });
|
|
112
|
+
|
|
113
|
+
wrapAction(() => ({}), { action: "crear", then: [{ toast: { message: { $result: "no.existe" } } }] }, context)();
|
|
114
|
+
|
|
115
|
+
expect(toast).toHaveBeenCalledWith({ variant: "info", message: "" });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { render, screen as testingScreen } from "@testing-library/react";
|
|
4
|
+
import { ITSchemaRenderer } from "../kernel/SchemaRenderer";
|
|
5
|
+
import type { ScreenSchema } from "../kernel/types";
|
|
6
|
+
import type { JsonEngineIssue } from "../shared/errors";
|
|
7
|
+
import { createBindingResolver, type BindingScope, type DataLoader } from "./resolveValue";
|
|
8
|
+
|
|
9
|
+
/** Resolutor con avisos acumulados, para leerlos en cada prueba. */
|
|
10
|
+
function setup(scope: Partial<BindingScope> = {}) {
|
|
11
|
+
const avisos: JsonEngineIssue[] = [];
|
|
12
|
+
const resolve = createBindingResolver({ report: (aviso) => avisos.push(aviso), ...scope });
|
|
13
|
+
return { resolve, avisos };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("$source", () => {
|
|
17
|
+
it("devuelve un loader que no llama al handler hasta que el componente lo invoca", async () => {
|
|
18
|
+
const handler = vi.fn(async (query: unknown) => ({ data: [], total: 0, query }));
|
|
19
|
+
const { resolve, avisos } = setup({
|
|
20
|
+
data: { usuarios: { handler: "usuarios" } },
|
|
21
|
+
handlers: { usuarios: handler },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
|
|
25
|
+
expect(typeof loader).toBe("function");
|
|
26
|
+
expect(handler).not.toHaveBeenCalled();
|
|
27
|
+
|
|
28
|
+
const respuesta = await loader({ page: 2 });
|
|
29
|
+
expect(handler).toHaveBeenCalledWith({ page: 2 }, {});
|
|
30
|
+
expect(respuesta).toMatchObject({ total: 0, query: { page: 2 } });
|
|
31
|
+
expect(avisos).toHaveLength(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resuelve los parámetros declarados con $state y $param antes de llamar al handler", async () => {
|
|
35
|
+
const handler = vi.fn(() => []);
|
|
36
|
+
const { resolve } = setup({
|
|
37
|
+
data: { pedidos: { handler: "pedidos", params: { estado: { $state: "estado" }, empresa: { $param: "empresa" }, limite: 5 } } },
|
|
38
|
+
state: { estado: "Pendiente" },
|
|
39
|
+
params: { empresa: "CheckApp" },
|
|
40
|
+
handlers: { pedidos: handler },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await (resolve({ $source: "pedidos" }, ["content", 0]) as DataLoader)();
|
|
44
|
+
expect(handler).toHaveBeenCalledWith(undefined, { estado: "Pendiente", empresa: "CheckApp", limite: 5 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("una fuente no declarada avisa con JR030 y su carga rechaza sin lanzar al resolver", async () => {
|
|
48
|
+
const { resolve, avisos } = setup({ data: {}, handlers: {} });
|
|
49
|
+
|
|
50
|
+
const loader = resolve({ $source: "pedidos" }, ["content", 1]) as DataLoader;
|
|
51
|
+
expect(avisos).toHaveLength(1);
|
|
52
|
+
expect(avisos[0]).toMatchObject({ code: "JR030", severity: "error" });
|
|
53
|
+
await expect(loader()).rejects.toThrow(/no está declarada en `data`/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("un handler que el host no registró avisa con JR030 y rechaza la carga", async () => {
|
|
57
|
+
const { resolve, avisos } = setup({ data: { usuarios: { handler: "usuarios" } }, handlers: {} });
|
|
58
|
+
|
|
59
|
+
const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
|
|
60
|
+
expect(avisos[0]).toMatchObject({ code: "JR030" });
|
|
61
|
+
await expect(loader()).rejects.toThrow(/no está registrado por el host/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("un handler que lanza se convierte en rechazo de la carga, no en error del render", async () => {
|
|
65
|
+
const { resolve, avisos } = setup({
|
|
66
|
+
data: { usuarios: { handler: "usuarios" } },
|
|
67
|
+
handlers: {
|
|
68
|
+
usuarios: () => {
|
|
69
|
+
throw new Error("fallo del host");
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const loader = resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader;
|
|
75
|
+
await expect(loader()).rejects.toThrow("fallo del host");
|
|
76
|
+
expect(avisos).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("un $source usado como parámetro avisa con JR032 y no se resuelve", async () => {
|
|
80
|
+
const handler = vi.fn(() => []);
|
|
81
|
+
const { resolve, avisos } = setup({
|
|
82
|
+
data: { usuarios: { handler: "usuarios", params: { origen: { $source: "pedidos" } } } },
|
|
83
|
+
handlers: { usuarios: handler },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await (resolve({ $source: "usuarios" }, ["content", 0]) as DataLoader)();
|
|
87
|
+
expect(handler).toHaveBeenCalledWith(undefined, {});
|
|
88
|
+
expect(avisos[0]).toMatchObject({ code: "JR032" });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("$state y $param", () => {
|
|
93
|
+
it("$state lee el estado inicial de la pantalla", () => {
|
|
94
|
+
const { resolve, avisos } = setup({ state: { titulo: "Hola" } });
|
|
95
|
+
expect(resolve({ $state: "titulo" }, ["content", 0])).toBe("Hola");
|
|
96
|
+
expect(avisos).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("$state sin valor avisa con JR032 y devuelve undefined", () => {
|
|
100
|
+
const { resolve, avisos } = setup({ state: {} });
|
|
101
|
+
expect(resolve({ $state: "titulo" }, ["content", 0])).toBeUndefined();
|
|
102
|
+
expect(avisos[0]).toMatchObject({ code: "JR032", severity: "warning" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("$param lee el valor que pone el host, no el esquema", () => {
|
|
106
|
+
const { resolve } = setup({ params: { id: "42" } });
|
|
107
|
+
expect(resolve({ $param: "id" }, ["content", 0])).toBe("42");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("$param sin valor avisa con JR032", () => {
|
|
111
|
+
const { resolve, avisos } = setup({ params: {} });
|
|
112
|
+
expect(resolve({ $param: "id" }, ["content", 0])).toBeUndefined();
|
|
113
|
+
expect(avisos[0]).toMatchObject({ code: "JR032" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("no hereda claves de la cadena de prototipos", () => {
|
|
117
|
+
const { resolve, avisos } = setup({ state: {} });
|
|
118
|
+
expect(resolve({ $state: "toString" }, ["content", 0])).toBeUndefined();
|
|
119
|
+
expect(avisos[0]).toMatchObject({ code: "JR032" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("operadores no soportados y valores del host", () => {
|
|
124
|
+
it("un operador de otra fase (`$row`) avisa con JR032 y no llega a la prop", () => {
|
|
125
|
+
const { resolve, avisos } = setup();
|
|
126
|
+
expect(resolve({ $row: "nombre" }, ["content", 0])).toBeUndefined();
|
|
127
|
+
expect(avisos[0]).toMatchObject({ code: "JR032", severity: "warning" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("los textos `{ i18n }` y los literales pasan al host", () => {
|
|
131
|
+
const host = vi.fn((value: unknown) => ({ host: value }));
|
|
132
|
+
const { resolve, avisos } = setup({ host });
|
|
133
|
+
|
|
134
|
+
expect(resolve({ i18n: "sandbox.app.back" }, ["content", 0])).toEqual({ host: { i18n: "sandbox.app.back" } });
|
|
135
|
+
expect(resolve("Hola", ["content", 0])).toEqual({ host: "Hola" });
|
|
136
|
+
expect(host).toHaveBeenCalledTimes(2);
|
|
137
|
+
expect(avisos).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("sin host, los valores que no son referencias se devuelven tal cual", () => {
|
|
141
|
+
const { resolve } = setup();
|
|
142
|
+
expect(resolve(3, ["content", 0])).toBe(3);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("ITSchemaRenderer con binding", () => {
|
|
147
|
+
const pantalla = (estado: Partial<ScreenSchema>, content: unknown): ScreenSchema =>
|
|
148
|
+
({ version: 1, id: "binding", content, ...estado }) as ScreenSchema;
|
|
149
|
+
|
|
150
|
+
it("pinta un $state declarado en la pantalla", () => {
|
|
151
|
+
render(
|
|
152
|
+
<ITSchemaRenderer
|
|
153
|
+
schema={pantalla({ state: { titulo: "Estado de pantalla" } }, { type: "ITText", text: { $state: "titulo" } })}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
expect(testingScreen.getByText("Estado de pantalla")).toBeTruthy();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("pinta un $param que llega por la prop `params`", () => {
|
|
160
|
+
render(
|
|
161
|
+
<ITSchemaRenderer
|
|
162
|
+
schema={pantalla({}, { type: "ITText", text: { $param: "nombre" } })}
|
|
163
|
+
params={{ nombre: "Ruta con parámetro" }}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
expect(testingScreen.getByText("Ruta con parámetro")).toBeTruthy();
|
|
167
|
+
});
|
|
168
|
+
});
|