@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.
- package/dist/index.cjs +1845 -721
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +386 -15
- package/dist/index.d.ts +386 -15
- package/dist/index.js +1532 -418
- 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/button/button.tsx +7 -0
- package/src/components/atoms/flex/flex.props.ts +3 -1
- package/src/components/atoms/flex/flex.tsx +11 -3
- package/src/components/atoms/input/input.tsx +5 -0
- package/src/components/atoms/segmented-control/segmented-control.tsx +22 -5
- 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 +14 -0
- package/src/components/molecules/card/card.tsx +66 -23
- package/src/components/molecules/chip-input/chip-input.tsx +2 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
- package/src/components/molecules/multi-select/multi-select.tsx +2 -0
- package/src/components/molecules/tabs/tabs.tsx +22 -8
- package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
- package/src/components/organisms/dropfile/dropfile.tsx +17 -0
- package/src/components/organisms/page-header/page-header.tsx +22 -6
- package/src/components/organisms/sidebar/sidebar.tsx +8 -5
- package/src/components/organisms/topbar/topbar.props.ts +2 -0
- package/src/components/organisms/topbar/topbar.tsx +56 -34
- package/src/components/templates/layout/layout.tsx +3 -2
- package/src/components/theme-provider/themeProvider.props.ts +1 -1
- package/src/components/theme-provider/themeProvider.tsx +54 -1
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +7 -0
- package/src/index.ts +35 -7
- 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/catalog/axzyCatalog.ts +5 -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/axzyConfig.test.ts +4 -0
- package/src/sandbox/axzyConfig.ts +1 -1
- package/src/sandbox/i18n.ts +44 -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/FeedbackShowcases.tsx +2 -1
- package/src/showcases/JsonScreenShowcase.tsx +43 -53
- package/src/showcases/StructureShowcases.tsx +17 -2
- 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
- package/src/theme/theme-context.ts +10 -2
- package/src/theme/theme.ts +1 -1
- package/src/theme/theme.types.ts +2 -1
|
@@ -4,6 +4,8 @@ import { FiHome } from "react-icons/fi";
|
|
|
4
4
|
import { AxzyLogo } from "../sandbox/AxzyLogo";
|
|
5
5
|
import {
|
|
6
6
|
ITAlert,
|
|
7
|
+
ITConfirmDialog,
|
|
8
|
+
ITLineBarChart,
|
|
7
9
|
ITButton,
|
|
8
10
|
ITDialog,
|
|
9
11
|
ITFlex,
|
|
@@ -26,6 +28,18 @@ import {
|
|
|
26
28
|
parsePartialSchema,
|
|
27
29
|
useITTranslation,
|
|
28
30
|
validateSchema,
|
|
31
|
+
type DataHandler,
|
|
32
|
+
type EffectRegistry,
|
|
33
|
+
type ITLineBarDatum,
|
|
34
|
+
type ITDataTableFetchParams,
|
|
35
|
+
type NavNode,
|
|
36
|
+
type RoutesSchema,
|
|
37
|
+
buildNavigationTree,
|
|
38
|
+
flattenRoutes,
|
|
39
|
+
matchRoute,
|
|
40
|
+
navContainsRoute,
|
|
41
|
+
type SlotComponent,
|
|
42
|
+
type SlotRegistry,
|
|
29
43
|
type JsonEngineIssue,
|
|
30
44
|
type NodeSchema,
|
|
31
45
|
type SchemaValue,
|
|
@@ -36,16 +50,16 @@ import {
|
|
|
36
50
|
actualizarUsuario,
|
|
37
51
|
crearUsuario,
|
|
38
52
|
createDemoFetcher,
|
|
53
|
+
PEDIDOS,
|
|
39
54
|
setEstadoFiltro,
|
|
40
55
|
eliminarUsuario,
|
|
41
|
-
isDemoSource,
|
|
42
56
|
obtenerUsuario,
|
|
43
57
|
type DemoUsuario,
|
|
44
58
|
type DemoUsuarioInput,
|
|
45
59
|
} from "./demoApp/demoData";
|
|
46
60
|
|
|
47
61
|
/** Páginas del dashboard, en orden de navegación. */
|
|
48
|
-
export const DEMO_APP_PAGES = ["inicio", "usuarios", "pedidos", "productos", "reportes", "docs"] as const;
|
|
62
|
+
export const DEMO_APP_PAGES = ["inicio", "usuarios", "pedidos", "productos", "reportes", "docs", "usuario-detalle"] as const;
|
|
49
63
|
|
|
50
64
|
/** Nombre de una página del dashboard. */
|
|
51
65
|
export type DemoAppPage = (typeof DEMO_APP_PAGES)[number];
|
|
@@ -58,6 +72,7 @@ const PAGE_LABEL: Record<DemoAppPage, string> = {
|
|
|
58
72
|
productos: "sandbox.app.nav.productos",
|
|
59
73
|
reportes: "sandbox.app.nav.reportes",
|
|
60
74
|
docs: "sandbox.app.nav.docs",
|
|
75
|
+
"usuario-detalle": "sandbox.app.detail.title",
|
|
61
76
|
};
|
|
62
77
|
|
|
63
78
|
/** Registro de iconos: el JSON nombra, el host decide qué componente es. */
|
|
@@ -70,48 +85,117 @@ const ICONOS: Record<string, React.ReactNode> = {
|
|
|
70
85
|
book: <FaBook size={12} />,
|
|
71
86
|
};
|
|
72
87
|
|
|
73
|
-
/** Módulo del sidebar tal como lo declara `layout.json`. */
|
|
74
|
-
interface LayoutModule {
|
|
75
|
-
id: string;
|
|
76
|
-
label: string | { i18n: string };
|
|
77
|
-
icon?: string;
|
|
78
|
-
page: string;
|
|
79
|
-
badge?: number | string;
|
|
80
|
-
subitems?: { id: string; label: string | { i18n: string }; page: string; query?: Record<string, string> }[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Shell declarado en JSON. */
|
|
84
|
-
interface LayoutFile {
|
|
85
|
-
layout: { brand?: { logo?: string; text?: string }; topBarNav?: string[]; modules: LayoutModule[] };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
88
|
/** Estado del formulario de usuario. */
|
|
89
89
|
const ROLES = ["Admin", "Operador", "Auditor", "Soporte"];
|
|
90
90
|
const ESTADOS = ["Activo", "Inactivo"];
|
|
91
91
|
const EMPRESAS = ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud", "Ferretería Sur"];
|
|
92
92
|
const FORM_VACIO: DemoUsuarioInput = { nombre: "", correo: "", rol: "Operador", estado: "Activo", edad: 30, empresa: "CheckApp", turno: "09:00", alta: new Date().toISOString().slice(0, 10) };
|
|
93
93
|
|
|
94
|
-
/** `true` cuando el valor es una referencia de fuente (`{ $source }`). */
|
|
95
|
-
function isSourceRef(value: unknown): value is { $source: string } {
|
|
96
|
-
return Boolean(value) && typeof value === "object" && typeof (value as { $source?: unknown }).$source === "string";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
94
|
/** `true` cuando el valor es una clave de traducción (`{ i18n }`). */
|
|
100
95
|
function isI18nRef(value: unknown): value is { i18n: string; params?: Record<string, SchemaValue> } {
|
|
101
96
|
return Boolean(value) && typeof value === "object" && typeof (value as { i18n?: unknown }).i18n === "string";
|
|
102
97
|
}
|
|
103
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Slot del host: un bloque JSX que el JSON nombra (`{ "type": "slot", "props": { "name": "avisoInicio" } }`).
|
|
101
|
+
* Los textos llegan ya traducidos por `resolveValue`.
|
|
102
|
+
*/
|
|
103
|
+
const AvisoInicio: SlotComponent = ({ titulo, mensaje }) => (
|
|
104
|
+
<ITAlert variant="info" title={String(titulo ?? "")}>
|
|
105
|
+
{String(mensaje ?? "")}
|
|
106
|
+
</ITAlert>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Slot del host para `/usuarios/:id`: busca el usuario por id y pinta sus datos.
|
|
111
|
+
* El id llega ya resuelto desde `$param`; el JSON no puede elegir qué usuario mostrar más allá de la dirección.
|
|
112
|
+
*/
|
|
113
|
+
const UsuarioDetalle: SlotComponent = ({ id }) => {
|
|
114
|
+
const { t } = useITTranslation();
|
|
115
|
+
const usuario = obtenerUsuario(Number(id));
|
|
116
|
+
if (!usuario) {
|
|
117
|
+
return <ITAlert variant="warning" title={t("sandbox.app.detail.notFound")} />;
|
|
118
|
+
}
|
|
119
|
+
const campos: [string, unknown][] = [
|
|
120
|
+
[t("sandbox.app.form.email"), usuario.correo],
|
|
121
|
+
[t("sandbox.app.form.role"), usuario.rol],
|
|
122
|
+
[t("sandbox.app.form.status"), usuario.estado],
|
|
123
|
+
[t("sandbox.app.form.company"), usuario.empresa],
|
|
124
|
+
[t("sandbox.app.form.since"), usuario.alta],
|
|
125
|
+
];
|
|
126
|
+
return (
|
|
127
|
+
<ITStack direction="column" spacing={3}>
|
|
128
|
+
<ITText as="span" className="text-lg font-semibold">
|
|
129
|
+
{String(usuario.nombre)}
|
|
130
|
+
</ITText>
|
|
131
|
+
{campos.map(([etiqueta, valor]) => (
|
|
132
|
+
<ITFlex key={etiqueta} gap={3} align="center">
|
|
133
|
+
<ITText as="span" muted className="w-28">
|
|
134
|
+
{etiqueta}
|
|
135
|
+
</ITText>
|
|
136
|
+
<ITText as="span">{String(valor)}</ITText>
|
|
137
|
+
</ITFlex>
|
|
138
|
+
))}
|
|
139
|
+
</ITStack>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Gráfica de ingresos y pedidos por mes, calculada sobre los pedidos del demo. */
|
|
144
|
+
const GraficaIngresos: SlotComponent = () => {
|
|
145
|
+
const { t, formatCurrency } = useITTranslation();
|
|
146
|
+
const datos = useMemo<ITLineBarDatum[]>(() => {
|
|
147
|
+
const meses = new Map<string, ITLineBarDatum>();
|
|
148
|
+
for (const pedido of PEDIDOS) {
|
|
149
|
+
const mes = String(pedido.fecha).slice(0, 7);
|
|
150
|
+
const acumulado = meses.get(mes) ?? { label: mes.slice(5), line: 0, bar: 0 };
|
|
151
|
+
acumulado.line += Number(pedido.total);
|
|
152
|
+
acumulado.bar += 1;
|
|
153
|
+
meses.set(mes, acumulado);
|
|
154
|
+
}
|
|
155
|
+
return [...meses.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, dato]) => dato);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<ITStack direction="column" spacing={2}>
|
|
160
|
+
<ITText as="span" className="font-semibold">
|
|
161
|
+
{t("sandbox.app.reportes.chartTitle")}
|
|
162
|
+
</ITText>
|
|
163
|
+
<ITLineBarChart
|
|
164
|
+
data={datos}
|
|
165
|
+
ariaLabel={t("sandbox.app.reportes.chartAria")}
|
|
166
|
+
lineLabel={t("sandbox.app.reportes.chartLine")}
|
|
167
|
+
barLabel={t("sandbox.app.reportes.chartBar")}
|
|
168
|
+
formatValue={(valor) => formatCurrency(valor)}
|
|
169
|
+
height={220}
|
|
170
|
+
/>
|
|
171
|
+
</ITStack>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Slots que registra el host para las páginas del demo. Constante: identidad estable. */
|
|
176
|
+
const DEMO_SLOTS: SlotRegistry = {
|
|
177
|
+
avisoInicio: AvisoInicio,
|
|
178
|
+
usuarioDetalle: UsuarioDetalle,
|
|
179
|
+
graficaIngresos: GraficaIngresos,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Nombres de slot que registra el host. El JSON solo puede nombrar estos. */
|
|
183
|
+
export const DEMO_SLOT_NAMES = Object.keys(DEMO_SLOTS);
|
|
184
|
+
|
|
185
|
+
/** Acciones que el host resuelve por nombre. El JSON solo puede usar estas. */
|
|
186
|
+
export const DEMO_ACTIONS = ["ir-pagina", "nuevo-registro", "ver-usuario", "editar-usuario", "exportar-reporte", "enviar-contacto"] as const;
|
|
187
|
+
|
|
104
188
|
/** Props del dashboard. */
|
|
105
189
|
export interface DemoJsonDashboardProps {
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
|
|
190
|
+
/** Dirección dentro del dashboard (`/usuarios`). La casa `routes.json`. */
|
|
191
|
+
path: string;
|
|
192
|
+
/** Query de la dirección (`{ estado: "Activo" }`). */
|
|
193
|
+
query: Record<string, string>;
|
|
194
|
+
/** Navega a una dirección del árbol de navegación (`/usuarios?estado=Activo`). */
|
|
195
|
+
onNavigate: (href: string) => void;
|
|
110
196
|
/** Vuelve al portafolio de demostración. */
|
|
111
197
|
onExit: () => void;
|
|
112
|
-
/**
|
|
113
|
-
filterEstado: string;
|
|
114
|
-
/** Escribe el filtro en la URL. */
|
|
198
|
+
/** Escribe el filtro de estado en la URL. */
|
|
115
199
|
onFilterChange: (estado: string) => void;
|
|
116
200
|
}
|
|
117
201
|
|
|
@@ -123,16 +207,50 @@ export interface DemoJsonDashboardProps {
|
|
|
123
207
|
* 1. Carga el JSON de la página (`/demo/app/<página>.json`).
|
|
124
208
|
* 2. Lo valida y muestra el veredicto.
|
|
125
209
|
* 3. Resuelve textos (`{ i18n }`).
|
|
126
|
-
* 4.
|
|
127
|
-
* 5. Resuelve acciones por nombre (`ir-pagina`, `nuevo-registro`, `editar-usuario`).
|
|
210
|
+
* 4. Registra los handlers de datos; el motor resuelve `{ $source }` contra `data`.
|
|
211
|
+
* 5. Resuelve acciones por nombre (`ir-pagina`, `nuevo-registro`, `ver-usuario`, `editar-usuario`).
|
|
128
212
|
* 6. Guarda: el formulario es del host, porque escribir es autoridad del host.
|
|
129
213
|
*
|
|
130
214
|
* @example
|
|
131
|
-
* // Ruta: #demo/app/usuarios
|
|
132
|
-
* <DemoJsonDashboard
|
|
215
|
+
* // Ruta: #demo/app/usuarios?estado=Activo
|
|
216
|
+
* <DemoJsonDashboard path="/usuarios" query={{ estado: "Activo" }} onNavigate={go} onExit={goToDemo} />
|
|
133
217
|
*/
|
|
134
|
-
export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({
|
|
218
|
+
export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ path, query, onNavigate, onExit, onFilterChange }) => {
|
|
135
219
|
const { t } = useITTranslation();
|
|
220
|
+
|
|
221
|
+
/** Rutas declaradas en JSON: de ellas salen el menú y la página de cada dirección. */
|
|
222
|
+
const [rutas, setRutas] = useState<RoutesSchema | null>(null);
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
fetch("/demo/app/routes.json")
|
|
225
|
+
.then((respuesta) => respuesta.json() as Promise<RoutesSchema>)
|
|
226
|
+
.then(setRutas)
|
|
227
|
+
.catch(() => setRutas(null));
|
|
228
|
+
}, []);
|
|
229
|
+
const planas = useMemo(() => (rutas ? flattenRoutes(rutas.routes) : []), [rutas]);
|
|
230
|
+
const arbol = useMemo(() => (rutas ? buildNavigationTree(rutas.routes) : []), [rutas]);
|
|
231
|
+
const coincidencia = useMemo(() => matchRoute(planas, path.split("/").filter(Boolean), query), [planas, path, query]);
|
|
232
|
+
/** Parámetros de la dirección (`:id`), que el motor expone como `$param`. */
|
|
233
|
+
const parametros = useMemo(() => coincidencia?.params ?? {}, [coincidencia]);
|
|
234
|
+
/** Id de la ruta activa. Una dirección que no casa muestra el inicio. */
|
|
235
|
+
const rutaActiva = coincidencia?.route.id ?? "inicio";
|
|
236
|
+
const pagina: DemoAppPage = DEMO_APP_PAGES.includes(coincidencia?.route.page as DemoAppPage)
|
|
237
|
+
? (coincidencia!.route.page as DemoAppPage)
|
|
238
|
+
: "inicio";
|
|
239
|
+
/** Filtro de estado: lo marca la query de la dirección. */
|
|
240
|
+
const filterEstado = query.estado ?? "Todos";
|
|
241
|
+
|
|
242
|
+
/** Etiqueta de una entrada: `{ i18n }` se traduce, el texto literal pasa tal cual. */
|
|
243
|
+
const etiqueta = (valor: unknown) => (isI18nRef(valor) ? t(valor.i18n) : String(valor));
|
|
244
|
+
/** Entrada del sidebar a partir del árbol. Solo el primer nivel lleva icono. */
|
|
245
|
+
const aItemSidebar = (entrada: NavNode, primerNivel: boolean) => ({
|
|
246
|
+
id: entrada.id,
|
|
247
|
+
label: etiqueta(entrada.label),
|
|
248
|
+
icon: primerNivel ? ICONOS[entrada.icon ?? "box"] : undefined,
|
|
249
|
+
isActive: navContainsRoute(entrada, rutaActiva),
|
|
250
|
+
badge: entrada.badge,
|
|
251
|
+
action: () => onNavigate(entrada.href ?? "/inicio"),
|
|
252
|
+
subitems: entrada.children?.map((hijo) => aItemSidebar(hijo, false)),
|
|
253
|
+
});
|
|
136
254
|
const [esquema, setEsquema] = useState<ScreenSchema | null>(null);
|
|
137
255
|
const [error, setError] = useState<string | null>(null);
|
|
138
256
|
const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
|
|
@@ -140,43 +258,35 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
140
258
|
// CRUD: el esquema pide; el host escribe.
|
|
141
259
|
const [formulario, setFormulario] = useState<DemoUsuarioInput | null>(null);
|
|
142
260
|
// El sidebar se colapsa solo en pantallas estrechas y se puede alternar.
|
|
143
|
-
|
|
261
|
+
// El sidebar empieza colapsado (solo logo) y se abre al pasar el cursor.
|
|
262
|
+
const [colapsado, setColapsado] = useState(true);
|
|
144
263
|
const [filtroEstado, setFiltroEstado] = useState<string>(filterEstado);
|
|
145
264
|
// La URL manda: si cambia el `?estado=`, la tabla se ajusta.
|
|
146
265
|
useEffect(() => {
|
|
147
266
|
setFiltroEstado(filterEstado);
|
|
148
267
|
}, [filterEstado]);
|
|
149
|
-
const [shell, setShell] = useState<LayoutFile["layout"] | null>(null);
|
|
150
268
|
const [aviso, setAviso] = useState<{ mensaje: string; tipo: "success" | "danger" } | null>(null);
|
|
269
|
+
// Diálogo de confirmación que abre la acción `exportar-reporte` del JSON.
|
|
270
|
+
const [exportando, setExportando] = useState(false);
|
|
151
271
|
const [editandoId, setEditandoId] = useState<number | null>(null);
|
|
152
272
|
const [version, setVersion] = useState(0);
|
|
153
273
|
|
|
154
|
-
const pagina: DemoAppPage = DEMO_APP_PAGES.includes(page) ? page : "inicio";
|
|
155
|
-
|
|
156
|
-
/** Auto-colapso: en cuanto el ancho baja de 1100px, el rail pasa a iconos. */
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
if (typeof window === "undefined") return;
|
|
159
|
-
const media = window.matchMedia("(max-width: 1100px)");
|
|
160
|
-
const aplicar = () => setColapsado(media.matches);
|
|
161
|
-
aplicar();
|
|
162
|
-
media.addEventListener("change", aplicar);
|
|
163
|
-
return () => media.removeEventListener("change", aplicar);
|
|
164
|
-
}, []);
|
|
165
274
|
|
|
166
275
|
/** Muestra un aviso y lo retira solo. */
|
|
167
276
|
const toast = useCallback((mensaje: string, tipo: "success" | "danger" = "success") => {
|
|
168
277
|
setAviso({ mensaje, tipo });
|
|
169
278
|
window.setTimeout(() => setAviso(null), 3000);
|
|
170
279
|
}, []);
|
|
280
|
+
// Efectos que cumple el host cuando una acción del JSON lo pide (`then`, `toast`, `navigate`).
|
|
281
|
+
const efectosDemo = useMemo<EffectRegistry>(
|
|
282
|
+
() => ({
|
|
283
|
+
toast: ({ variant, message }) => toast(message, variant === "error" || variant === "warning" ? "danger" : "success"),
|
|
284
|
+
navigate: (href) => onNavigate(href),
|
|
285
|
+
}),
|
|
286
|
+
[toast, onNavigate]
|
|
287
|
+
);
|
|
171
288
|
const url = `/demo/app/${pagina}.json`;
|
|
172
289
|
|
|
173
|
-
/** 0. El shell también es JSON: se carga una vez. */
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
fetch("/demo/app/layout.json")
|
|
176
|
-
.then((r) => r.json() as Promise<LayoutFile>)
|
|
177
|
-
.then((archivo) => setShell(archivo.layout))
|
|
178
|
-
.catch(() => setShell(null));
|
|
179
|
-
}, []);
|
|
180
290
|
|
|
181
291
|
/** 1. Carga del JSON de la página. */
|
|
182
292
|
useEffect(() => {
|
|
@@ -253,24 +363,24 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
253
363
|
/** El filtro triple se aplica en el fetcher y se remonta la tabla. */
|
|
254
364
|
useEffect(() => {
|
|
255
365
|
setEstadoFiltro(pagina === "docs" ? null : filtroEstado);
|
|
366
|
+
setVersion((n) => n + 1);
|
|
256
367
|
}, [filtroEstado, pagina]);
|
|
257
368
|
|
|
258
|
-
/**
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
369
|
+
/** Handlers estables: el motor los referencia por nombre desde `data` del esquema. */
|
|
370
|
+
const dataHandlers = useMemo<Record<string, DataHandler>>(() => {
|
|
371
|
+
const usuarios = createDemoFetcher("usuarios");
|
|
372
|
+
const pedidos = createDemoFetcher("pedidos");
|
|
373
|
+
const productos = createDemoFetcher("productos");
|
|
374
|
+
return {
|
|
375
|
+
usuarios: (query) => usuarios(query as ITDataTableFetchParams),
|
|
376
|
+
pedidos: (query) => pedidos(query as ITDataTableFetchParams),
|
|
377
|
+
productos: (query) => productos(query as ITDataTableFetchParams),
|
|
378
|
+
};
|
|
379
|
+
}, []);
|
|
267
380
|
|
|
268
|
-
/** 3
|
|
381
|
+
/** 3. Textos. Las fuentes de datos (`$source`) las resuelve el motor. */
|
|
269
382
|
const resolveValue = useCallback(
|
|
270
383
|
(value: unknown): unknown => {
|
|
271
|
-
if (isSourceRef(value)) {
|
|
272
|
-
return isDemoSource(value.$source) ? fetchers[value.$source] : () => Promise.resolve({ data: [], total: 0 });
|
|
273
|
-
}
|
|
274
384
|
if (isI18nRef(value)) {
|
|
275
385
|
const params = value.params
|
|
276
386
|
? Object.fromEntries(Object.entries(value.params).map(([clave, dato]) => [clave, String(dato)]))
|
|
@@ -279,7 +389,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
279
389
|
}
|
|
280
390
|
return value;
|
|
281
391
|
},
|
|
282
|
-
[t
|
|
392
|
+
[t]
|
|
283
393
|
);
|
|
284
394
|
|
|
285
395
|
/** 5. Acciones por nombre. La navegación y la escritura son del host. */
|
|
@@ -287,22 +397,36 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
287
397
|
(node: NodeSchema, _prop: string, action: unknown) => {
|
|
288
398
|
const declarada = action as { action?: string; args?: Record<string, SchemaValue> } | undefined;
|
|
289
399
|
const nombre = declarada?.action;
|
|
290
|
-
|
|
400
|
+
// Solo las acciones de DEMO_ACTIONS existen: lo demás avisa con JR040 en lugar de no hacer nada.
|
|
401
|
+
if (!nombre || !(DEMO_ACTIONS as readonly string[]).includes(nombre)) return undefined;
|
|
291
402
|
|
|
292
403
|
return (...args: unknown[]) => {
|
|
293
404
|
switch (nombre) {
|
|
294
405
|
case "ir-pagina": {
|
|
295
406
|
const destino = String(declarada?.args?.pagina ?? "");
|
|
296
|
-
if (DEMO_APP_PAGES.includes(destino as DemoAppPage)) onNavigate(destino
|
|
407
|
+
if (DEMO_APP_PAGES.includes(destino as DemoAppPage)) onNavigate(`/${destino}`);
|
|
297
408
|
return;
|
|
298
409
|
}
|
|
410
|
+
case "exportar-reporte":
|
|
411
|
+
setExportando(true);
|
|
412
|
+
return;
|
|
413
|
+
case "enviar-contacto":
|
|
414
|
+
// Los campos ya los validó el motor, y el aviso de éxito lo declara el JSON (`then`).
|
|
415
|
+
return;
|
|
299
416
|
case "nuevo-registro":
|
|
300
417
|
abrirFormulario();
|
|
301
418
|
return;
|
|
419
|
+
case "ver-usuario": {
|
|
420
|
+
// La tabla pasa la fila como primer argumento: su detalle vive en la dirección /usuarios/:id.
|
|
421
|
+
const fila = args[0] as DemoUsuario | undefined;
|
|
422
|
+
if (fila && typeof fila.id === "number") onNavigate(`/usuarios/${fila.id}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
302
425
|
case "editar-usuario": {
|
|
303
|
-
//
|
|
426
|
+
// Desde la tabla llega la fila; desde el detalle, el id de la dirección.
|
|
304
427
|
const fila = args[0] as DemoUsuario | undefined;
|
|
305
|
-
const
|
|
428
|
+
const id = fila && typeof fila.id === "number" ? fila.id : Number(parametros.id);
|
|
429
|
+
const usuario = Number.isFinite(id) ? obtenerUsuario(id) : undefined;
|
|
306
430
|
if (usuario) abrirFormulario(usuario);
|
|
307
431
|
return;
|
|
308
432
|
}
|
|
@@ -311,7 +435,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
311
435
|
}
|
|
312
436
|
};
|
|
313
437
|
},
|
|
314
|
-
[onNavigate, abrirFormulario, pagina, onFilterChange]
|
|
438
|
+
[onNavigate, abrirFormulario, pagina, onFilterChange, parametros, toast, t]
|
|
315
439
|
);
|
|
316
440
|
|
|
317
441
|
const cuerpo = error ? (
|
|
@@ -340,6 +464,10 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
340
464
|
<ITSchemaRenderer
|
|
341
465
|
key={version}
|
|
342
466
|
schema={esquema}
|
|
467
|
+
params={parametros}
|
|
468
|
+
dataHandlers={dataHandlers}
|
|
469
|
+
slots={DEMO_SLOTS}
|
|
470
|
+
effects={efectosDemo}
|
|
343
471
|
resolveValue={resolveValue}
|
|
344
472
|
resolveAction={resolveAction}
|
|
345
473
|
onIssue={(aviso) =>
|
|
@@ -356,42 +484,16 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
356
484
|
<ITLayout
|
|
357
485
|
topBar={{
|
|
358
486
|
logo: <AxzyLogo variante="icono" tamano={20} />,
|
|
359
|
-
logoText: shell?.brand?.text ?? "AXZY · demo",
|
|
360
|
-
navItems: DEMO_APP_PAGES.map((item) => ({
|
|
361
|
-
id: item,
|
|
362
|
-
label: t(PAGE_LABEL[item]),
|
|
363
|
-
isActive: item === pagina,
|
|
364
|
-
action: () => onNavigate(item),
|
|
365
|
-
})),
|
|
366
487
|
children: (
|
|
367
488
|
<ITButton label={t("sandbox.app.exit")} size="sm" variant="outlined" color="gray" onClick={onExit} />
|
|
368
489
|
),
|
|
369
490
|
}}
|
|
370
491
|
sidebar={{
|
|
371
492
|
isCollapsed: colapsado,
|
|
493
|
+
expandOnHover: true,
|
|
372
494
|
onToggleCollapse: () => setColapsado((previo) => !previo),
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
text: shell?.brand?.text ?? "AXZY · demo",
|
|
376
|
-
},
|
|
377
|
-
navigationItems: (shell?.modules ?? []).map((modulo) => ({
|
|
378
|
-
id: modulo.id,
|
|
379
|
-
label: isI18nRef(modulo.label) ? t(modulo.label.i18n) : String(modulo.label),
|
|
380
|
-
icon: ICONOS[modulo.icon ?? "box"],
|
|
381
|
-
isActive: modulo.page === pagina,
|
|
382
|
-
badge: modulo.badge,
|
|
383
|
-
action: () => onNavigate(modulo.page as DemoAppPage),
|
|
384
|
-
// Los sub-items llevan `query`: filtran de verdad, no son decoración.
|
|
385
|
-
subitems: modulo.subitems?.map((sub) => ({
|
|
386
|
-
id: sub.id,
|
|
387
|
-
label: isI18nRef(sub.label) ? t(sub.label.i18n) : String(sub.label),
|
|
388
|
-
action: () => {
|
|
389
|
-
if (sub.page !== pagina) onNavigate(sub.page as DemoAppPage);
|
|
390
|
-
onFilterChange(sub.query?.estado ?? "Todos");
|
|
391
|
-
setVersion((n) => n + 1);
|
|
392
|
-
},
|
|
393
|
-
})),
|
|
394
|
-
})),
|
|
495
|
+
// Los sub-items son rutas con `query`: navegar a ellas filtra de verdad.
|
|
496
|
+
navigationItems: arbol.map((entrada) => aItemSidebar(entrada, true)),
|
|
395
497
|
}}
|
|
396
498
|
>
|
|
397
499
|
<ITPage
|
|
@@ -403,7 +505,7 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
403
505
|
]}
|
|
404
506
|
>
|
|
405
507
|
<ITStack direction="column" spacing={4}>
|
|
406
|
-
{pagina
|
|
508
|
+
{(pagina === "usuarios" || pagina === "pedidos") && (
|
|
407
509
|
<ITTripleFilter
|
|
408
510
|
value={filtroEstado}
|
|
409
511
|
onChange={(valor) => {
|
|
@@ -473,6 +575,19 @@ export const DemoJsonDashboard: React.FC<DemoJsonDashboardProps> = ({ page, onNa
|
|
|
473
575
|
)}
|
|
474
576
|
</ITDialog>
|
|
475
577
|
|
|
578
|
+
<ITConfirmDialog
|
|
579
|
+
isOpen={exportando}
|
|
580
|
+
onClose={() => setExportando(false)}
|
|
581
|
+
onConfirm={() => {
|
|
582
|
+
setExportando(false);
|
|
583
|
+
toast(t("sandbox.app.export.done"));
|
|
584
|
+
}}
|
|
585
|
+
title={t("sandbox.app.export.title")}
|
|
586
|
+
message={t("sandbox.app.export.message")}
|
|
587
|
+
confirmLabel={t("sandbox.app.export.confirm")}
|
|
588
|
+
cancelLabel={t("sandbox.app.form.cancel")}
|
|
589
|
+
/>
|
|
590
|
+
|
|
476
591
|
{aviso && (
|
|
477
592
|
<ITToast
|
|
478
593
|
message={aviso.mensaje}
|
|
@@ -45,7 +45,7 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
|
|
|
45
45
|
parrafos: [
|
|
46
46
|
"El host (TSX, dev-only) es dueño del chrome: monta ITLayout e ITPage, traduce etiquetas, resuelve iconos, datos y acciones. Vive en src/showcases/DemoJsonDashboard.tsx.",
|
|
47
47
|
"El motor (src/json-renderer/) pinta el cuerpo de cada página desde JSON. Nunca toca red, funciones ni nodos React.",
|
|
48
|
-
"El JSON describe: public/demo/app/layout.json (
|
|
48
|
+
"El JSON describe: public/demo/app/layout.json (marca), public/demo/app/routes.json (rutas y menú) y public/demo/app/<página>.json (cuerpo).",
|
|
49
49
|
],
|
|
50
50
|
},
|
|
51
51
|
{
|
|
@@ -53,25 +53,25 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
|
|
|
53
53
|
parrafos: [
|
|
54
54
|
"DemoJsonDashboard.tsx:356 monta <ITLayout topBar sidebar>; :398 monta <ITPage>; :340 monta <ITSchemaRenderer>.",
|
|
55
55
|
"ITLayout no puede ser un nodo del esquema: topBar.logo es un ReactNode, navigationItems[].action es una función y icon es un componente. El motor prohíbe las tres.",
|
|
56
|
-
"Por eso el JSON nombra (icon: \"users\", page: \"usuarios\", query: {estado: \"Activo\"}) y el host resuelve con el registro ICONOS y
|
|
56
|
+
"Por eso el JSON nombra (icon: \"users\", page: \"usuarios\", query: {estado: \"Activo\"}) y el host resuelve con el registro ICONOS y el árbol que sale de routes.json.",
|
|
57
57
|
],
|
|
58
|
-
codigo: '<ITLayout\n topBar={{ logo: <AxzyLogo/>, logoText: shell.brand.text }}\n sidebar={{
|
|
58
|
+
codigo: '<ITLayout\n topBar={{ logo: <AxzyLogo/>, logoText: shell.brand.text }}\n sidebar={{ navigationItems: arbol.map(\u2026) }}\n>',
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
|
-
titulo: "3 · Añadir
|
|
61
|
+
titulo: "3 · Añadir una ruta",
|
|
62
62
|
parrafos: [
|
|
63
|
-
"Edita public/demo/app/
|
|
64
|
-
"Si
|
|
63
|
+
"Edita public/demo/app/routes.json y añade una ruta con path, page y nav. Sin nav, la ruta existe pero no aparece en el menú. No hay que tocar TSX.",
|
|
64
|
+
"Si la página no tiene JSON propio, apunta page a una existente: la verás navegar igual.",
|
|
65
65
|
],
|
|
66
|
-
codigo: '{\n "id": "ajustes",\n "label": { "i18n": "sandbox.app.nav.ajustes" }
|
|
66
|
+
codigo: '{\n "id": "ajustes",\n "path": "/ajustes",\n "page": "ajustes",\n "nav": { "label": { "i18n": "sandbox.app.nav.ajustes" }, "icon": "box" }\n}',
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
titulo: "4 · Sub-items que filtran de verdad",
|
|
70
70
|
parrafos: [
|
|
71
|
-
"
|
|
72
|
-
"Sin query, el sub-item
|
|
71
|
+
"Un sub-item es una ruta hija con query. Navegar a ella cambia la dirección (?estado=) y la tabla se refiltra.",
|
|
72
|
+
"Sin query, el sub-item es la ruta base del grupo. Nada de botones decorativos.",
|
|
73
73
|
],
|
|
74
|
-
codigo: '{\n "
|
|
74
|
+
codigo: '{\n "id": "u-activos",\n "page": "usuarios",\n "query": { "estado": "Activo" },\n "nav": { "label": { "i18n": "sandbox.app.sub.activos" } }\n}',
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
titulo: "5 · Iconos y marca",
|
|
@@ -85,7 +85,7 @@ const GUIA: { titulo: string; parrafos: string[]; codigo?: string }[] = [
|
|
|
85
85
|
titulo: "6 · Una pantalla que NO es JSON",
|
|
86
86
|
parrafos: [
|
|
87
87
|
"Soportado a propósito: hay pantallas que necesitan código (un editor, un mapa, un flujo con estado propio). El motor no las debe impedir.",
|
|
88
|
-
"Ruta 1: página del host. Crea el componente, regístralo en DEMO_APP_PAGES y en
|
|
88
|
+
"Ruta 1: página del host. Crea el componente, regístralo en DEMO_APP_PAGES y en routes.json, y renderízalo en lugar de ITSchemaRenderer cuando la página sea esa.",
|
|
89
89
|
"Ruta 2: híbrido. El esquema pinta el 90% y un nodo con acción abre la parte TSX (diálogo, editor, visor). Es lo que hace el CRUD con su formulario.",
|
|
90
90
|
"Ruta 3: contenido embebido. El host expone un componente del catálogo que internamente ya es TSX (ITDataTable, ITJSONEditor). El esquema solo lo nombra.",
|
|
91
91
|
"La regla: si la pantalla necesita funciones, es del host; si solo describe, es JSON.",
|
|
@@ -362,6 +362,7 @@ export const ThemeProviderShowcase = () => {
|
|
|
362
362
|
'<ITThemeProvider><App /></ITThemeProvider>',
|
|
363
363
|
'<ITThemeProvider theme={{ primary: "#3b82f6", layout: { sidebarBg: "#0f172a" } }} showFab={false}><App /></ITThemeProvider>',
|
|
364
364
|
'<ITThemeProvider density={0.9} radius={8} shadow={1} appearance="flat"><App /></ITThemeProvider>',
|
|
365
|
+
'<ITThemeProvider appearance="bento"><App /></ITThemeProvider>',
|
|
365
366
|
],
|
|
366
367
|
props: [
|
|
367
368
|
{ name: "theme", type: "Partial<ITThemePalette>", description: "Overrides de paleta fusionados (deep merge) con el tema por defecto: `primary`, `secondary`, `ternary`, `danger`, `success`, `info`, `alert`, `warning`, `layout` y `table`." },
|
|
@@ -370,7 +371,7 @@ export const ThemeProviderShowcase = () => {
|
|
|
370
371
|
{ name: "density", type: "number", default: "1", description: "Factor de densidad global: escala el `font-size` raíz para compactar todo lo basado en `rem`. Se limita a `0.5`–`1.5`." },
|
|
371
372
|
{ name: "radius", type: "number", default: "—", description: "Radio global en px aplicado a toda la escala `--radius-*`; `0` deja las esquinas totalmente cuadradas." },
|
|
372
373
|
{ name: "shadow", type: "number", default: "—", description: "Fuerza global de sombras: `0` ninguna, `1` sutiles, `2` look actual, `3` marcadas." },
|
|
373
|
-
{ name: "appearance", type: '"soft" | "flat"', default: '"soft"', description: "Lenguaje visual del sistema; `\"flat\"` usa superficies planas con bordes de 1 px y
|
|
374
|
+
{ name: "appearance", type: '"soft" | "flat" | "bento"', default: '"soft"', description: "Lenguaje visual del sistema; `\"flat\"` usa superficies planas con bordes de 1 px; `\"bento\"` usa lienzo gris claro, tarjetas blancas sin borde con sombra difusa y radio de 24 px, controles en píldora y navegación con píldora tinta. Fija `data-it-appearance` en `<html>`; un `radius`/`shadow` explícito gana sobre los valores bento." },
|
|
374
375
|
],
|
|
375
376
|
notes: [
|
|
376
377
|
"`useITTheme()` lanza error si se usa fuera del provider; `useITThemeSafe()` devuelve `undefined` en ese caso.",
|