@axzydev/axzy_ui_system 1.6.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 +7842 -4676
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +883 -3
- package/dist/index.d.ts +883 -3
- package/dist/index.js +9118 -5971
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +14 -7
- package/src/App.tsx +70 -2
- package/src/components/atoms/image/image.props.ts +2 -0
- package/src/components/atoms/image/image.tsx +3 -2
- package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
- package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
- package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
- package/src/components/molecules/json-editor/json-editor.tsx +972 -0
- package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
- package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
- package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
- package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
- package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
- package/src/components/organisms/dialog/dialog.props.ts +2 -0
- package/src/components/organisms/dialog/dialog.tsx +4 -3
- package/src/dev.css +30 -1
- package/src/hooks/useJsonRender.tsx +9 -0
- package/src/i18n/locales/en.ts +54 -0
- package/src/i18n/locales/es.ts +54 -0
- package/src/index.css +414 -0
- package/src/index.ts +99 -2
- 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/AxzyLogo.tsx +139 -0
- package/src/sandbox/axzyConfig.test.ts +40 -0
- package/src/sandbox/axzyConfig.ts +77 -0
- package/src/sandbox/i18n.ts +220 -0
- package/src/sandbox/navigation.ts +3 -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/FeedbackShowcases.tsx +16 -5
- package/src/showcases/HomeShowcase.tsx +16 -9
- package/src/showcases/JsonEditorShowcase.tsx +150 -0
- package/src/showcases/JsonRendererShowcase.tsx +70 -0
- package/src/showcases/JsonScreenShowcase.tsx +341 -0
- package/src/showcases/StructureShowcases.tsx +6 -1
- 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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolución de un nodo contra el catálogo.
|
|
3
|
+
*
|
|
4
|
+
* Aquí es donde el esquema deja de ser texto libre y se convierte en props
|
|
5
|
+
* seguras: se comprueba el tipo, se filtran las props permitidas, se aplican los
|
|
6
|
+
* valores por defecto, se coaccionan los tipos y se acumulan los avisos con su
|
|
7
|
+
* ruta exacta. Nada de esto lanza: un nodo mal formado produce avisos y se pinta
|
|
8
|
+
* lo que se pueda.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { JsonEngineError, issue, type JsonEngineIssue } from "../shared/errors";
|
|
12
|
+
import { formatPath, propPath, type SchemaPath } from "../shared/paths";
|
|
13
|
+
import type { NodeSchema } from "../kernel/types";
|
|
14
|
+
import { GLOBALLY_FORBIDDEN_PROPS, isExcludedType, getExclusionReason, type Catalog, type CatalogEntry, type PropSpec } from "./catalog.props";
|
|
15
|
+
|
|
16
|
+
/** Resultado de resolver un nodo: props listas para el componente + avisos. */
|
|
17
|
+
export interface ResolvedNode {
|
|
18
|
+
/** Discriminante: el nodo se pudo resolver. */
|
|
19
|
+
ok: true;
|
|
20
|
+
/** Entrada del catálogo que se va a montar. */
|
|
21
|
+
entry: CatalogEntry;
|
|
22
|
+
/** Props ya filtradas, con defaults aplicados y tipos coaccionados. */
|
|
23
|
+
props: Record<string, unknown>;
|
|
24
|
+
/** Acciones declaradas en el nodo, por nombre de prop (`onClick` → esquema). */
|
|
25
|
+
actions: Record<string, unknown>;
|
|
26
|
+
/** Avisos acumulados (tipo desconocido, prop ignorada, valor inválido…). */
|
|
27
|
+
issues: JsonEngineIssue[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resultado cuando el nodo no se puede resolver. */
|
|
31
|
+
export interface UnresolvedNode {
|
|
32
|
+
/** Discriminante: el nodo no se pudo resolver. */
|
|
33
|
+
ok: false;
|
|
34
|
+
/** Avisos que explican por qué. */
|
|
35
|
+
issues: JsonEngineIssue[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `true` cuando el valor es una **referencia** del esquema (`{ i18n }`,
|
|
40
|
+
* `{ $state }`, `{ $source }`…).
|
|
41
|
+
*
|
|
42
|
+
* Las referencias no se coaccionan: se entregan tal cual para que el host las
|
|
43
|
+
* resuelva (`resolveValue`). Coaccionarlas aquí las convertiría en `undefined` y
|
|
44
|
+
* el valor desaparecería antes de poder traducirlo.
|
|
45
|
+
*/
|
|
46
|
+
export function isSchemaReference(value: unknown): boolean {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
48
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
49
|
+
if (keys.length === 0) return false;
|
|
50
|
+
return keys.some((key) => key === "i18n" || key.startsWith("$"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Coacciona un valor al tipo declarado. Devuelve `undefined` si no es válido. */
|
|
54
|
+
function coerce(value: unknown, spec: PropSpec): unknown {
|
|
55
|
+
switch (spec.kind) {
|
|
56
|
+
case "string":
|
|
57
|
+
if (typeof value === "string") return value;
|
|
58
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
59
|
+
return undefined;
|
|
60
|
+
case "number":
|
|
61
|
+
case "integer": {
|
|
62
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
63
|
+
return spec.kind === "integer" ? Math.trunc(value) : value;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
return spec.kind === "integer" ? Math.trunc(parsed) : parsed;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
case "boolean":
|
|
72
|
+
if (typeof value === "boolean") return value;
|
|
73
|
+
if (value === "true") return true;
|
|
74
|
+
if (value === "false") return false;
|
|
75
|
+
return undefined;
|
|
76
|
+
case "enum":
|
|
77
|
+
if (typeof value !== "string") return undefined;
|
|
78
|
+
return spec.values?.includes(value) ? value : undefined;
|
|
79
|
+
case "array":
|
|
80
|
+
return Array.isArray(value) ? value : undefined;
|
|
81
|
+
case "object":
|
|
82
|
+
return value && typeof value === "object" ? value : undefined;
|
|
83
|
+
default:
|
|
84
|
+
// `action`, `i18n`, `data`, `columns`, `node`, `nodes`: el valor se
|
|
85
|
+
// resuelve más tarde (acciones, binding, hijos). Aquí solo se filtra.
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resuelve un nodo contra el catálogo.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* const resolved = resolveNode({ type: "ITButton", props: { label: "Ir" } }, axzyCatalog, ["content", 0]);
|
|
95
|
+
* resolved.props.label; // "Ir"
|
|
96
|
+
*/
|
|
97
|
+
export function resolveNode(
|
|
98
|
+
node: NodeSchema,
|
|
99
|
+
catalog: Catalog,
|
|
100
|
+
path: SchemaPath
|
|
101
|
+
): ResolvedNode | UnresolvedNode {
|
|
102
|
+
const issues: JsonEngineIssue[] = [];
|
|
103
|
+
const type = node.type;
|
|
104
|
+
|
|
105
|
+
if (typeof type !== "string" || type === "") {
|
|
106
|
+
issues.push(issue("JR021", "El nodo no declara `type`.", formatPath(path), { severity: "error" }));
|
|
107
|
+
return { ok: false, issues };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isExcludedType(catalog, type)) {
|
|
111
|
+
const reason = getExclusionReason(catalog, type);
|
|
112
|
+
issues.push(
|
|
113
|
+
issue("JR010", `El tipo \`${type}\` está excluido del catálogo.`, formatPath(path), {
|
|
114
|
+
severity: "error",
|
|
115
|
+
hint: reason,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
return { ok: false, issues };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entry = catalog.entries[type];
|
|
122
|
+
if (!entry) {
|
|
123
|
+
const known = Object.keys(catalog.entries).slice(0, 8).join(", ");
|
|
124
|
+
issues.push(
|
|
125
|
+
issue("JR010", `El tipo \`${type}\` no está en el catálogo.`, formatPath(path), {
|
|
126
|
+
severity: "error",
|
|
127
|
+
hint: `Tipos disponibles (primeros 8): ${known}…`,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
return { ok: false, issues };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const specs = entry.props ?? {};
|
|
134
|
+
const props: Record<string, unknown> = {};
|
|
135
|
+
const actions: Record<string, unknown> = {};
|
|
136
|
+
const input = node.props ?? {};
|
|
137
|
+
|
|
138
|
+
// 1. Valores por defecto declarados en el catálogo.
|
|
139
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
140
|
+
if (spec.default !== undefined) props[name] = spec.default;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Props aportadas por el esquema, filtradas por la lista blanca.
|
|
144
|
+
for (const [name, raw] of Object.entries(input)) {
|
|
145
|
+
const forbiddenByEntry = entry.forbidden?.includes(name);
|
|
146
|
+
const forbiddenGlobally = GLOBALLY_FORBIDDEN_PROPS.includes(name);
|
|
147
|
+
|
|
148
|
+
if (forbiddenByEntry || forbiddenGlobally) {
|
|
149
|
+
issues.push(
|
|
150
|
+
issue("JR011", `La prop \`${name}\` está prohibida en \`${type}\`.`, formatPath(propPath(path, name)), {
|
|
151
|
+
severity: "warning",
|
|
152
|
+
hint: "Los nodos React se declaran como hijos del esquema, no como props.",
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const spec = specs[name];
|
|
159
|
+
if (!spec) {
|
|
160
|
+
issues.push(
|
|
161
|
+
issue("JR011", `La prop \`${name}\` no está declarada para \`${type}\`.`, formatPath(propPath(path, name)), {
|
|
162
|
+
severity: "warning",
|
|
163
|
+
hint: "Se ignora. Si es necesaria, añádela al catálogo con su tipo.",
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (spec.kind === "action") {
|
|
170
|
+
actions[name] = raw;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Una referencia (`{ i18n: "clave" }`, `{ $state: "x" }`) se pasa tal cual:
|
|
175
|
+
// el host decide su valor final y el catálogo no debe coaccionarla.
|
|
176
|
+
if (isSchemaReference(raw)) {
|
|
177
|
+
props[name] = raw;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const value = coerce(raw, spec);
|
|
182
|
+
if (value === undefined) {
|
|
183
|
+
issues.push(
|
|
184
|
+
issue(
|
|
185
|
+
"JR011",
|
|
186
|
+
`Valor inválido para \`${name}\` en \`${type}\`.`,
|
|
187
|
+
formatPath(propPath(path, name)),
|
|
188
|
+
{ severity: "warning", hint: spec.values ? `Admitidos: ${spec.values.join(", ")}` : `Se esperaba ${spec.kind}.` }
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
props[name] = value;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3. Props obligatorias que faltan.
|
|
197
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
198
|
+
if (spec.required && props[name] === undefined) {
|
|
199
|
+
issues.push(
|
|
200
|
+
issue("JR011", `Falta la prop obligatoria \`${name}\` en \`${type}\`.`, formatPath(propPath(path, name)), {
|
|
201
|
+
severity: "error",
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. Hijos declarados sin que el componente los acepte.
|
|
208
|
+
if (node.children !== undefined && !entry.children) {
|
|
209
|
+
issues.push(
|
|
210
|
+
issue("JR011", `\`${type}\` no acepta \`children\`.`, formatPath([...path, "children"]), {
|
|
211
|
+
severity: "warning",
|
|
212
|
+
hint: "Los hijos se ignoran.",
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { ok: true, entry, props, actions, issues };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Convierte un error del motor en aviso para el informe. */
|
|
221
|
+
export function toIssue(error: unknown, path: SchemaPath, fallback = "Fallo del motor"): JsonEngineIssue {
|
|
222
|
+
if (error instanceof JsonEngineError) return error.toIssue();
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
return issue("JR012", message || fallback, formatPath(path), { severity: "error" });
|
|
225
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motor de pantallas declarativas de AXZY UI System.
|
|
3
|
+
*
|
|
4
|
+
* Convierte un esquema JSON versionado en una pantalla completa, tratándolo
|
|
5
|
+
* siempre como **entrada no confiable**: lista blanca de componentes y props,
|
|
6
|
+
* sin código, sin red y sin HTML. El caso de uso principal es la UI generada por
|
|
7
|
+
* IA, así que el esquema puede llegar **a trozos** y el motor pinta lo que ya
|
|
8
|
+
* está cerrado.
|
|
9
|
+
*
|
|
10
|
+
* Alcance y plan: `docs/json-renderer/ALCANCE.md` y `PLAN-IMPLEMENTACION.md`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const schema = { version: 1, id: "demo", content: { type: "ITText", text: "Hola" } };
|
|
15
|
+
* <ITSchemaRenderer schema={schema} />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/* ── Kernel ─────────────────────────────────────────────────────────────── */
|
|
20
|
+
export { ITSchemaRenderer, default as ITJsonScreen } from "./kernel/SchemaRenderer";
|
|
21
|
+
export { NodeBoundary } from "./kernel/NodeBoundary";
|
|
22
|
+
export { renderNode, childList, hasChildren } from "./kernel/renderNode";
|
|
23
|
+
export { auditTree, countPendingNodes } from "./kernel/partial";
|
|
24
|
+
export type { ITJsonRenderEngineProps } from "./kernel/schemaRenderer.props";
|
|
25
|
+
export type {
|
|
26
|
+
ScreenSchema,
|
|
27
|
+
NodeSchema,
|
|
28
|
+
SchemaValue,
|
|
29
|
+
SchemaReference,
|
|
30
|
+
WhenSchema,
|
|
31
|
+
ActionSchema,
|
|
32
|
+
EffectSchema,
|
|
33
|
+
DataSourceSchema,
|
|
34
|
+
NodeStatus,
|
|
35
|
+
} from "./kernel/types";
|
|
36
|
+
|
|
37
|
+
/* ── Catálogo ──────────────────────────────────────────────────────────── */
|
|
38
|
+
export { axzyCatalog, axzyCatalogTypes, AXZY_CATALOG_ENTRIES, AXZY_EXCLUDED_TYPES } from "./catalog/axzyCatalog";
|
|
39
|
+
export {
|
|
40
|
+
createCatalog,
|
|
41
|
+
getCatalogEntry,
|
|
42
|
+
isExcludedType,
|
|
43
|
+
getExclusionReason,
|
|
44
|
+
GLOBALLY_FORBIDDEN_PROPS,
|
|
45
|
+
} from "./catalog/catalog.props";
|
|
46
|
+
export { resolveNode } from "./catalog/resolve";
|
|
47
|
+
export type { Catalog, CatalogEntry, PropSpec, PropKind } from "./catalog/catalog.props";
|
|
48
|
+
export type { ResolvedNode, UnresolvedNode } from "./catalog/resolve";
|
|
49
|
+
|
|
50
|
+
/* ── Validación y versionado ───────────────────────────────────────────── */
|
|
51
|
+
export { validateSchema, CURRENT_SCHEMA_VERSION } from "./validation/validate";
|
|
52
|
+
export { migrateSchema, DEFAULT_MIGRATIONS } from "./validation/migrate";
|
|
53
|
+
export type { ValidationReport, ValidationVerdict, ValidateOptions } from "./validation/validate";
|
|
54
|
+
export type { SchemaMigration, MigrationRegistry, MigrationResult } from "./validation/migrate";
|
|
55
|
+
|
|
56
|
+
/* ── Contrato de vocabulario (para el emisor) ──────────────────────────── */
|
|
57
|
+
export { buildVocabularyContract, formatVocabularyForPrompt } from "./vocabulary/buildContract";
|
|
58
|
+
export type { VocabularyContract, ComponentContract, PropContract } from "./vocabulary/buildContract";
|
|
59
|
+
|
|
60
|
+
/* ── Streaming ─────────────────────────────────────────────────────────── */
|
|
61
|
+
export { parsePartialSchema, isNodePending } from "./stream/tolerantParse";
|
|
62
|
+
export type { PartialParseResult, StreamState } from "./stream/tolerantParse";
|
|
63
|
+
|
|
64
|
+
/* ── Compartido ────────────────────────────────────────────────────────── */
|
|
65
|
+
export { JsonEngineError, issue } from "./shared/errors";
|
|
66
|
+
export type { JsonEngineIssue, JsonEngineCode, JsonEngineSeverity } from "./shared/errors";
|
|
67
|
+
export { formatPath, parsePath, readPath, hasPath, pushPath, propPath } from "./shared/paths";
|
|
68
|
+
export type { SchemaPath, PathSegment } from "./shared/paths";
|
|
69
|
+
export { DEFAULT_LIMITS, resolveLimits, createLimitTracker } from "./shared/limits";
|
|
70
|
+
export type { EngineLimits, LimitTracker } from "./shared/limits";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { JsonEngineError } from "../shared/errors";
|
|
3
|
+
|
|
4
|
+
/** Props de `NodeBoundary`. */
|
|
5
|
+
export interface NodeBoundaryProps {
|
|
6
|
+
/** Ruta del nodo dentro del esquema, para el aviso. */
|
|
7
|
+
path: string;
|
|
8
|
+
/** Qué pintar si el subárbol falla. */
|
|
9
|
+
fallback?: React.ReactNode;
|
|
10
|
+
/** Se llama con el error y su ruta para acumularlo en el informe. */
|
|
11
|
+
onError?: (error: Error, path: string) => void;
|
|
12
|
+
/** Subárbol del nodo. */
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Estado interno del boundary. */
|
|
17
|
+
interface NodeBoundaryState {
|
|
18
|
+
failed: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Aísla el fallo de un nodo: si un componente del catálogo lanza durante el
|
|
23
|
+
* render, solo se pierde ese subárbol y el resto de la pantalla sigue viva
|
|
24
|
+
* (aviso `JR012`).
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <NodeBoundary path="content[0]" fallback={<p>No se pudo pintar</p>}>
|
|
28
|
+
* <ITCard />
|
|
29
|
+
* </NodeBoundary>
|
|
30
|
+
*/
|
|
31
|
+
export class NodeBoundary extends React.Component<NodeBoundaryProps, NodeBoundaryState> {
|
|
32
|
+
state: NodeBoundaryState = { failed: false };
|
|
33
|
+
|
|
34
|
+
static getDerivedStateFromError(): NodeBoundaryState {
|
|
35
|
+
return { failed: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
componentDidCatch(error: Error): void {
|
|
39
|
+
this.props.onError?.(error, this.props.path);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render(): React.ReactNode {
|
|
43
|
+
if (this.state.failed) return this.props.fallback ?? null;
|
|
44
|
+
return this.props.children;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Extrae un `Error` de cualquier valor lanzado. */
|
|
49
|
+
export function asError(value: unknown): Error {
|
|
50
|
+
if (value instanceof Error) return value;
|
|
51
|
+
if (value instanceof JsonEngineError) return value;
|
|
52
|
+
return new Error(typeof value === "string" ? value : "Fallo desconocido al pintar el nodo");
|
|
53
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import type { Catalog } from "../catalog/catalog.props";
|
|
3
|
+
import { axzyCatalog } from "../catalog/axzyCatalog";
|
|
4
|
+
import type { JsonEngineIssue } from "../shared/errors";
|
|
5
|
+
import { resolveLimits, type EngineLimits } from "../shared/limits";
|
|
6
|
+
import type { SchemaPath } from "../shared/paths";
|
|
7
|
+
import { NodeBoundary } from "./NodeBoundary";
|
|
8
|
+
import { auditTree } from "./partial";
|
|
9
|
+
import { childList, renderNode } from "./renderNode";
|
|
10
|
+
import type { ITJsonRenderEngineProps } from "./schemaRenderer.props";
|
|
11
|
+
import type { NodeSchema } from "./types";
|
|
12
|
+
|
|
13
|
+
/** Contexto que comparten los nodos durante un render. */
|
|
14
|
+
interface RenderContext {
|
|
15
|
+
catalog: Catalog;
|
|
16
|
+
/** Ids declarados por los nodos, para verificar que llegan al DOM. */
|
|
17
|
+
nodeIds: Set<string>;
|
|
18
|
+
limits: EngineLimits;
|
|
19
|
+
issues: JsonEngineIssue[];
|
|
20
|
+
onIssue?: (issue: JsonEngineIssue) => void;
|
|
21
|
+
resolveValue?: ITJsonRenderEngineProps["resolveValue"];
|
|
22
|
+
resolveAction?: ITJsonRenderEngineProps["resolveAction"];
|
|
23
|
+
pendingFallback?: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recorre un nodo y sus hijos, acumulando avisos y aislando fallos.
|
|
28
|
+
*
|
|
29
|
+
* La recursión se hace aquí (y no dentro de `renderNode`) para que el
|
|
30
|
+
* presupuesto y los avisos se compartan en todo el árbol.
|
|
31
|
+
*/
|
|
32
|
+
function renderTree(node: NodeSchema, path: SchemaPath, context: RenderContext, level: number): React.ReactNode {
|
|
33
|
+
if (!node || typeof node !== "object") return null;
|
|
34
|
+
if (level > context.limits.maxDepth) return null;
|
|
35
|
+
|
|
36
|
+
// Todos los avisos del subárbol van a parar al mismo array del contexto: el
|
|
37
|
+
// resolutor escribe ahí directamente y el boundary informa por `onIssue`.
|
|
38
|
+
const issues = context.issues;
|
|
39
|
+
|
|
40
|
+
if (node.id) context.nodeIds.add(node.id);
|
|
41
|
+
|
|
42
|
+
const reportIssue = (issue: JsonEngineIssue) => {
|
|
43
|
+
issues.push(issue);
|
|
44
|
+
context.onIssue?.(issue);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const issuesBefore = issues.length;
|
|
48
|
+
|
|
49
|
+
const element = renderNode(node, {
|
|
50
|
+
catalog: context.catalog,
|
|
51
|
+
path,
|
|
52
|
+
issues,
|
|
53
|
+
resolveValue: context.resolveValue,
|
|
54
|
+
resolveAction: context.resolveAction,
|
|
55
|
+
pendingFallback: context.pendingFallback,
|
|
56
|
+
renderChildren: (current, currentPath) => {
|
|
57
|
+
const children = childList(current);
|
|
58
|
+
if (children.length === 0) return undefined;
|
|
59
|
+
return children.map((child, index) =>
|
|
60
|
+
renderTree(child, [...currentPath, "children", index], context, level + 1)
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// El resolutor acumula en el array compartido; aquí se reenvían los nuevos
|
|
66
|
+
// para que `onIssue` los reciba todos (incluidos los de tipos desconocidos).
|
|
67
|
+
for (let index = issuesBefore; index < issues.length; index += 1) {
|
|
68
|
+
context.onIssue?.(issues[index]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// El fallback declarado por el nodo se usa si algo del subárbol falla.
|
|
72
|
+
const fallback = node.fallback
|
|
73
|
+
? renderTree(node.fallback, [...path, "fallback"], context, level + 1)
|
|
74
|
+
: context.pendingFallback ?? null;
|
|
75
|
+
|
|
76
|
+
const wrapped = (
|
|
77
|
+
<NodeBoundary
|
|
78
|
+
key={String(path.join("-"))}
|
|
79
|
+
path={path.join(".")}
|
|
80
|
+
fallback={fallback}
|
|
81
|
+
onError={(error, errorPath) =>
|
|
82
|
+
reportIssue({
|
|
83
|
+
code: "JR012",
|
|
84
|
+
severity: "error",
|
|
85
|
+
message: error.message,
|
|
86
|
+
path: errorPath,
|
|
87
|
+
hint: "El nodo se ha aislado; el resto de la pantalla sigue pintándose.",
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{element}
|
|
92
|
+
</NodeBoundary>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return wrapped;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pinta una pantalla completa a partir de un esquema.
|
|
100
|
+
*
|
|
101
|
+
* Nunca lanza por culpa del esquema: los problemas se convierten en avisos que
|
|
102
|
+
* se entregan por `onIssue` (y se acumulan en `issuesRef` si quieres leerlos).
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* <ITSchemaRenderer schema={{ version: 1, id: "demo", content: { type: "ITText", text: "Hola" } }} />
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* <ITSchemaRenderer schema={schema} onIssue={(issue) => console.warn(issue.code, issue.path)} />
|
|
109
|
+
*/
|
|
110
|
+
export function ITSchemaRenderer({
|
|
111
|
+
schema,
|
|
112
|
+
catalog = axzyCatalog,
|
|
113
|
+
limits,
|
|
114
|
+
resolveValue,
|
|
115
|
+
resolveAction,
|
|
116
|
+
pendingFallback,
|
|
117
|
+
onIssue,
|
|
118
|
+
className,
|
|
119
|
+
}: ITJsonRenderEngineProps) {
|
|
120
|
+
const resolvedLimits = useMemo(() => resolveLimits(limits), [limits]);
|
|
121
|
+
// Un único array de avisos por render: el resolutor y los boundaries escriben
|
|
122
|
+
// en él, así que no se pierde ninguno por el camino.
|
|
123
|
+
const issues: JsonEngineIssue[] = [];
|
|
124
|
+
// Ids que el esquema declara y que deben acabar en el DOM (anclas, deep links).
|
|
125
|
+
const nodeIds = new Set<string>();
|
|
126
|
+
|
|
127
|
+
// El recuento va antes del render para poder recortar y avisar sin montar nada.
|
|
128
|
+
const audit = useMemo(
|
|
129
|
+
() => (schema ? auditTree(schema, resolvedLimits) : null),
|
|
130
|
+
[schema, resolvedLimits]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const context = useMemo<RenderContext>(
|
|
134
|
+
() => ({
|
|
135
|
+
catalog,
|
|
136
|
+
nodeIds,
|
|
137
|
+
limits: resolvedLimits,
|
|
138
|
+
issues,
|
|
139
|
+
onIssue,
|
|
140
|
+
resolveValue,
|
|
141
|
+
resolveAction,
|
|
142
|
+
pendingFallback,
|
|
143
|
+
}),
|
|
144
|
+
// `issues` es un array nuevo en cada render a propósito: acumular avisos
|
|
145
|
+
// entre renders duplicaría los avisos de esquemas que no han cambiado.
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
[catalog, resolvedLimits, onIssue, resolveValue, resolveAction, pendingFallback]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const content = schema
|
|
151
|
+
? (Array.isArray(schema.content) ? schema.content : [schema.content]).filter(
|
|
152
|
+
(node): node is NodeSchema => Boolean(node) && typeof node === "object"
|
|
153
|
+
)
|
|
154
|
+
: [];
|
|
155
|
+
const children = content
|
|
156
|
+
.slice(0, resolvedLimits.maxNodes)
|
|
157
|
+
.map((node, index) => renderTree(node, ["content", index], context, 0))
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
|
|
160
|
+
// Todos los avisos del render + los del recuento, sin duplicar por código y ruta.
|
|
161
|
+
const collected: JsonEngineIssue[] = [...(audit?.issues ?? []), ...issues];
|
|
162
|
+
const signature = collected.map((issue) => `${issue.code}:${issue.path}`).join("|");
|
|
163
|
+
|
|
164
|
+
// Se entregan después del commit: llamar a `onIssue` (que puede hacer setState
|
|
165
|
+
// en el padre) durante el render sería un error de React.
|
|
166
|
+
const reported = useRef<string>("");
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const all = [...collected];
|
|
169
|
+
|
|
170
|
+
// Un `id` que no existe en el DOM es un fallo silencioso: el componente no
|
|
171
|
+
// reenvía props al elemento, así que el ancla no funcionaría. Se avisa.
|
|
172
|
+
for (const id of nodeIds) {
|
|
173
|
+
if (!document.getElementById(id)) {
|
|
174
|
+
all.push({
|
|
175
|
+
code: "JR014",
|
|
176
|
+
severity: "warning",
|
|
177
|
+
message: `El \`id\` "${id}" no llegó al DOM.`,
|
|
178
|
+
path: id,
|
|
179
|
+
hint: "El componente no reenvía props al elemento. Pon el id en un nodo que sí lo haga (por ejemplo ITText) o en un contenedor del host.",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const firma = all.map((issue) => `${issue.code}:${issue.path}`).join("|");
|
|
185
|
+
if (!onIssue || reported.current === firma) return;
|
|
186
|
+
reported.current = firma;
|
|
187
|
+
for (const issue of all) onIssue(issue);
|
|
188
|
+
// `all` se deriva de la firma; la firma es la dependencia real.
|
|
189
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
190
|
+
}, [signature, onIssue]);
|
|
191
|
+
|
|
192
|
+
if (!schema || children.length === 0) return null;
|
|
193
|
+
|
|
194
|
+
return className ? <div className={className}>{children}</div> : <>{children}</>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default ITSchemaRenderer;
|