@axzydev/axzy_ui_system 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/index.cjs +1845 -721
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +386 -15
  5. package/dist/index.d.ts +386 -15
  6. package/dist/index.js +1532 -418
  7. package/dist/index.js.map +1 -1
  8. package/dist/layered.css +1 -1
  9. package/package.json +4 -2
  10. package/src/App.tsx +6 -8
  11. package/src/components/atoms/button/button.tsx +7 -0
  12. package/src/components/atoms/flex/flex.props.ts +3 -1
  13. package/src/components/atoms/flex/flex.tsx +11 -3
  14. package/src/components/atoms/input/input.tsx +5 -0
  15. package/src/components/atoms/segmented-control/segmented-control.tsx +22 -5
  16. package/src/components/atoms/textarea/textarea.props.ts +4 -0
  17. package/src/components/atoms/textarea/textarea.tsx +13 -3
  18. package/src/components/molecules/card/card.props.ts +14 -0
  19. package/src/components/molecules/card/card.tsx +66 -23
  20. package/src/components/molecules/chip-input/chip-input.tsx +2 -0
  21. package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
  22. package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
  23. package/src/components/molecules/multi-select/multi-select.tsx +2 -0
  24. package/src/components/molecules/tabs/tabs.tsx +22 -8
  25. package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
  26. package/src/components/organisms/dropfile/dropfile.tsx +17 -0
  27. package/src/components/organisms/page-header/page-header.tsx +22 -6
  28. package/src/components/organisms/sidebar/sidebar.tsx +8 -5
  29. package/src/components/organisms/topbar/topbar.props.ts +2 -0
  30. package/src/components/organisms/topbar/topbar.tsx +56 -34
  31. package/src/components/templates/layout/layout.tsx +3 -2
  32. package/src/components/theme-provider/themeProvider.props.ts +1 -1
  33. package/src/components/theme-provider/themeProvider.tsx +54 -1
  34. package/src/i18n/locales/en.ts +7 -0
  35. package/src/i18n/locales/es.ts +7 -0
  36. package/src/index.ts +35 -7
  37. package/src/json-renderer/actions/effects.test.tsx +115 -0
  38. package/src/json-renderer/actions/effects.ts +291 -0
  39. package/src/json-renderer/actions/effectsChain.test.ts +117 -0
  40. package/src/json-renderer/binding/binding.test.tsx +168 -0
  41. package/src/json-renderer/binding/resolveValue.ts +193 -0
  42. package/src/json-renderer/catalog/axzyCatalog.ts +5 -0
  43. package/src/json-renderer/estado-acciones.test.tsx +166 -0
  44. package/src/json-renderer/form/FormNode.tsx +92 -0
  45. package/src/json-renderer/form/form.test.tsx +148 -0
  46. package/src/json-renderer/form/formLength.test.tsx +99 -0
  47. package/src/json-renderer/form/rules.ts +162 -0
  48. package/src/json-renderer/index.ts +21 -0
  49. package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
  50. package/src/json-renderer/kernel/capabilities.ts +30 -0
  51. package/src/json-renderer/kernel/renderNode.tsx +256 -3
  52. package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
  53. package/src/json-renderer/kernel/slots.test.tsx +104 -0
  54. package/src/json-renderer/kernel/slots.ts +34 -0
  55. package/src/json-renderer/kernel/types.ts +4 -0
  56. package/src/json-renderer/routing/match.ts +82 -0
  57. package/src/json-renderer/routing/navigation.ts +71 -0
  58. package/src/json-renderer/routing/routes.ts +179 -0
  59. package/src/json-renderer/routing/routing.test.ts +147 -0
  60. package/src/json-renderer/seguridad.test.tsx +299 -0
  61. package/src/json-renderer/shared/errors.ts +1 -0
  62. package/src/json-renderer/stream/tolerantParse.ts +5 -0
  63. package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
  64. package/src/json-renderer/stream/useSchemaStream.ts +227 -0
  65. package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
  66. package/src/json-renderer/validation/jsonSchema.ts +317 -0
  67. package/src/json-renderer/validation/validate.ts +17 -0
  68. package/src/sandbox/axzyConfig.test.ts +4 -0
  69. package/src/sandbox/axzyConfig.ts +1 -1
  70. package/src/sandbox/i18n.ts +44 -0
  71. package/src/sandbox/useHashRoute.ts +2 -1
  72. package/src/showcases/DemoJsonDashboard.tsx +218 -103
  73. package/src/showcases/DemoJsonDocs.tsx +11 -11
  74. package/src/showcases/FeedbackShowcases.tsx +2 -1
  75. package/src/showcases/JsonScreenShowcase.tsx +43 -53
  76. package/src/showcases/StructureShowcases.tsx +17 -2
  77. package/src/showcases/demoApp/demoJson.test.tsx +241 -0
  78. package/src/showcases/demoApp/routes.test.ts +63 -0
  79. package/src/showcases/demoApp/schemas.test.ts +6 -17
  80. package/src/theme/theme-context.ts +10 -2
  81. package/src/theme/theme.ts +1 -1
  82. package/src/theme/theme.types.ts +2 -1
@@ -0,0 +1,115 @@
1
+ import React from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen as testingScreen } from "@testing-library/react";
4
+ import { ITSchemaRenderer } from "../kernel/SchemaRenderer";
5
+ import type { JsonEngineIssue } from "../shared/errors";
6
+ import type { NodeSchema, ScreenSchema } from "../kernel/types";
7
+ import { wrapAction, type EffectContext, type EffectRegistry } from "./effects";
8
+
9
+ /** Contexto de pruebas: traducción literal y avisos acumulados. */
10
+ function contexto(effects?: EffectRegistry) {
11
+ const avisos: JsonEngineIssue[] = [];
12
+ const context: EffectContext = {
13
+ effects,
14
+ path: ["content", 0],
15
+ resolveText: (value) => (typeof value === "string" ? value : ""),
16
+ report: (aviso) => avisos.push(aviso),
17
+ };
18
+ return { context, avisos };
19
+ }
20
+
21
+ describe("wrapAction", () => {
22
+ it("sin then ni onError devuelve el handler tal cual", () => {
23
+ const handler = () => "ok";
24
+ const { context } = contexto({});
25
+ expect(wrapAction(handler, { action: "x" }, context)).toBe(handler);
26
+ expect(wrapAction(handler, undefined, context)).toBe(handler);
27
+ });
28
+
29
+ it("then se ejecuta después de un handler que termina bien", () => {
30
+ const orden: string[] = [];
31
+ const toast = vi.fn(() => orden.push("toast"));
32
+ const handler = vi.fn(() => orden.push("handler"));
33
+ const { context } = contexto({ toast });
34
+
35
+ const envuelto = wrapAction(handler, { action: "x", then: [{ toast: { variant: "success", message: "Hecho" } }] }, context);
36
+ envuelto("args");
37
+
38
+ expect(orden).toEqual(["handler", "toast"]);
39
+ expect(toast).toHaveBeenCalledWith({ variant: "success", message: "Hecho" });
40
+ });
41
+
42
+ it("los efectos sueltos de la acción cuentan como un then más", () => {
43
+ const navigate = vi.fn();
44
+ const { context } = contexto({ navigate });
45
+ wrapAction(() => undefined, { action: "x", navigate: "/inicio" }, context)();
46
+ expect(navigate).toHaveBeenCalledWith("/inicio");
47
+ });
48
+
49
+ it("onError se ejecuta, y then no, cuando el handler lanza", () => {
50
+ const toast = vi.fn();
51
+ const { context } = contexto({ toast });
52
+ const envuelto = wrapAction(
53
+ () => {
54
+ throw new Error("fallo");
55
+ },
56
+ { action: "x", then: [{ toast: { message: "Bien" } }], onError: { toast: { variant: "error", message: "Mal" } } },
57
+ context
58
+ );
59
+ envuelto();
60
+ expect(toast).toHaveBeenCalledTimes(1);
61
+ expect(toast).toHaveBeenCalledWith({ variant: "error", message: "Mal" });
62
+ });
63
+
64
+ it("con un handler asíncrono espera la promesa: then si resuelve, onError si rechaza", async () => {
65
+ const toast = vi.fn();
66
+ const { context } = contexto({ toast });
67
+ const acciones = { action: "x", then: [{ toast: { message: "Bien" } }], onError: { toast: { variant: "error", message: "Mal" } } };
68
+
69
+ await wrapAction(() => Promise.resolve("ok"), acciones, context)();
70
+ expect(toast).toHaveBeenLastCalledWith({ variant: "info", message: "Bien" });
71
+
72
+ await (wrapAction(() => Promise.reject(new Error("x")), acciones, context)() as Promise<unknown>).catch(() => undefined);
73
+ await new Promise((resolve) => setTimeout(resolve, 0));
74
+ expect(toast).toHaveBeenLastCalledWith({ variant: "error", message: "Mal" });
75
+ });
76
+
77
+ it("un efecto que el host no implementa avisa con JR040 al pintar, y no rompe al ejecutar", () => {
78
+ const { context, avisos } = contexto({});
79
+ const envuelto = wrapAction(() => undefined, { action: "x", then: [{ toast: { message: "Hola" } }] }, context);
80
+ expect(avisos[0]).toMatchObject({ code: "JR040", severity: "warning" });
81
+ expect(() => envuelto()).not.toThrow();
82
+ });
83
+ });
84
+
85
+ describe("efectos en pantalla", () => {
86
+ const pantalla = (action: Record<string, unknown>): ScreenSchema =>
87
+ ({
88
+ version: 1,
89
+ id: "efectos",
90
+ content: { type: "ITButton", props: { label: "Enviar", onClick: action } } as NodeSchema,
91
+ }) as ScreenSchema;
92
+
93
+ it("al pulsar, el host recibe la acción y después se ejecuta el toast del JSON", () => {
94
+ const orden: string[] = [];
95
+ const host = vi.fn(() => orden.push("host"));
96
+ const toast = vi.fn(() => orden.push("toast"));
97
+
98
+ render(
99
+ <ITSchemaRenderer
100
+ schema={pantalla({ action: "guardar", then: [{ toast: { variant: "success", message: { i18n: "k" } } }] })}
101
+ resolveAction={(_node, _prop, action) => {
102
+ const nombre = (action as { action?: string }).action;
103
+ return nombre === "guardar" ? host : undefined;
104
+ }}
105
+ resolveValue={(value) => (value && typeof value === "object" && "i18n" in value ? "Guardado" : value)}
106
+ effects={{ toast }}
107
+ />
108
+ );
109
+
110
+ fireEvent.click(testingScreen.getByRole("button", { name: "Enviar" }));
111
+
112
+ expect(orden).toEqual(["host", "toast"]);
113
+ expect(toast).toHaveBeenCalledWith({ variant: "success", message: "Guardado" });
114
+ });
115
+ });
@@ -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
+ });