mejora de la ficha de plantillas de activos

This commit is contained in:
David Arranz 2026-10-10 18:49:11 +02:00
parent 108e810c3b
commit 853011cec3
10 changed files with 201 additions and 66 deletions

View File

@ -332,3 +332,9 @@ Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de
## IconPicker ## IconPicker
`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. El catálogo de plantillas de activos aporta los iconos Lucide y valida sus claves; el componente admite teclado y distribución responsive. `@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. El catálogo de plantillas de activos aporta los iconos Lucide y valida sus claves; el componente admite teclado y distribución responsive.
## Consulta compacta de plantillas
Las fichas de plantillas de activos y recursos usan `FieldSections layout="rows"` y `TemplateFieldSummary`. Secciones plegables abiertas inicialmente, con títulos en mayúsculas de tamaño pequeño y color `muted-foreground`, sin tarjetas ni rellenos anidados. El indicador ocupa una anchura fija y los campos se alinean con el inicio del texto del título; filas compactas con nombre, tipo/unidad y obligatoriedad. Los campos desactivados conservan su aviso y los campos sin sección siguen visibles. En pantallas amplias se muestran dos columnas de secciones; en anchos menores, una. Cada fila adapta sus metadatos al ancho real de su sección.
La ayuda de consulta usa `ContextHelp variant="plain"`: separación superior discreta, sin caja exterior ni fondo en el desplegable. Los formularios mantienen la presentación existente por defecto. `FormDialog.title` admite contenido React para colocar un icono decorativo junto al título sin una segunda cabecera repetida.

View File

@ -1,5 +1,6 @@
import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { TemplateFieldSummary } from "@repo/rdx-ui/components/template-field-summary";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common"; import type { CatalogItemDTO } from "../../../../common";
@ -31,16 +32,21 @@ export function CatalogDefinitionDialog({
} }
}} }}
open open
title={t("viewDefinition", { name: values.name })} title={
<span className="flex items-center gap-2">
{values.kind === "template" && (
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={values.icon}
/>
)}
{t("viewDefinition", { name: values.name })}
</span>
}
wide={values.kind === "template"} wide={values.kind === "template"}
> >
<div className="space-y-4"> <div className="space-y-4">
{values.kind === "template" && values.icon && ( {item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AssetTemplateIcon icon={values.icon} />
{t(`templateIcon.options.${values.icon}`)}
</div>
)}
{values.description && ( {values.description && (
<p className="text-sm text-muted-foreground">{values.description}</p> <p className="text-sm text-muted-foreground">{values.description}</p>
)} )}
@ -71,36 +77,29 @@ export function CatalogDefinitionDialog({
) : ( ) : (
<FieldSections <FieldSections
items={values.fields} items={values.fields}
layout="rows"
renderItem={(binding) => { renderItem={(binding) => {
const field = items.find((entry) => entry.id === binding.fieldId); const field = items.find((entry) => entry.id === binding.fieldId);
return ( return (
<div <TemplateFieldSummary
className="flex justify-between gap-4 rounded-md border p-3 text-sm"
key={binding.fieldId} key={binding.fieldId}
> metadata={
<div> field?.values.kind === "field"
<p className="font-medium">{field?.values.name}</p> ? [t(field.values.type), field.values.unit].filter(Boolean).join(" · ")
{field?.values.kind === "field" && ( : ""
<p className="text-muted-foreground"> }
{t(field.values.type)} name={field?.values.name ?? binding.fieldId}
{field.values.unit ? ` · ${field.values.unit}` : ""} notice={field && !field.effectiveActive ? t("disabledField") : undefined}
</p> requirement={t(binding.required ? "required" : "optional")}
)} />
{field && !field.effectiveActive && (
<p className="text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</div>
); );
}} }}
sections={values.sections} sections={values.sections}
ungroupedLabel={t("sections.ungrouped")} ungroupedLabel={t("sections.ungrouped")}
/> />
)} )}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>} <CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} variant="plain" />
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />
</div> </div>
</FormDialog> </FormDialog>
); );

View File

@ -4,9 +4,11 @@ import { useTranslation } from "react-i18next";
export function CatalogHelp({ export function CatalogHelp({
kind, kind,
scope, scope,
variant = "card",
}: { }: {
kind: "field" | "template" | "relation"; kind: "field" | "template" | "relation";
scope: string; scope: string;
variant?: "card" | "plain";
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
@ -18,6 +20,7 @@ export function CatalogHelp({
guidance={t(`${kind}Guidance`)} guidance={t(`${kind}Guidance`)}
scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")} scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")}
title={t("helpTitle")} title={t("helpTitle")}
variant={variant}
/> />
); );
} }

View File

@ -1,5 +1,6 @@
import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { TemplateFieldSummary } from "@repo/rdx-ui/components/template-field-summary";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common"; import type { CatalogItemDTO } from "../../../../common";
@ -34,6 +35,7 @@ export function CatalogDefinitionDialog({
wide={values.kind === "template"} wide={values.kind === "template"}
> >
<div className="space-y-4"> <div className="space-y-4">
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
{values.description && ( {values.description && (
<p className="text-sm text-muted-foreground">{values.description}</p> <p className="text-sm text-muted-foreground">{values.description}</p>
)} )}
@ -59,36 +61,29 @@ export function CatalogDefinitionDialog({
) : ( ) : (
<FieldSections <FieldSections
items={values.fields} items={values.fields}
layout="rows"
renderItem={(binding) => { renderItem={(binding) => {
const field = items.find((entry) => entry.id === binding.fieldId); const field = items.find((entry) => entry.id === binding.fieldId);
return ( return (
<div <TemplateFieldSummary
className="flex justify-between gap-4 rounded-md border p-3 text-sm"
key={binding.fieldId} key={binding.fieldId}
> metadata={
<div> field?.values.kind === "field"
<p className="font-medium">{field?.values.name}</p> ? [t(field.values.type), field.values.unit].filter(Boolean).join(" · ")
{field?.values.kind === "field" && ( : ""
<p className="text-muted-foreground"> }
{t(field.values.type)} name={field?.values.name ?? binding.fieldId}
{field.values.unit ? ` · ${field.values.unit}` : ""} notice={field && !field.effectiveActive ? t("disabledField") : undefined}
</p> requirement={t(binding.required ? "required" : "optional")}
)} />
{field && !field.effectiveActive && (
<p className="text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</div>
); );
}} }}
sections={values.sections} sections={values.sections}
ungroupedLabel={t("sections.ungrouped")} ungroupedLabel={t("sections.ungrouped")}
/> />
)} )}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>} <CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} variant="plain" />
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />
</div> </div>
</FormDialog> </FormDialog>
); );

View File

@ -1,7 +1,15 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help"; import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) { export function CatalogHelp({
kind,
scope,
variant = "card",
}: {
kind: "field" | "template";
scope: string;
variant?: "card" | "plain";
}) {
const { t } = useTranslation("resources"); const { t } = useTranslation("resources");
return ( return (
@ -12,6 +20,7 @@ export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope
guidance={t(`${kind}Guidance`)} guidance={t(`${kind}Guidance`)}
scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")} scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")}
title={t("helpTitle")} title={t("helpTitle")}
variant={variant}
/> />
); );
} }

View File

@ -9,6 +9,7 @@ export function ContextHelp({
examplesLabel, examplesLabel,
scopeNote, scopeNote,
icon = <InfoIcon />, icon = <InfoIcon />,
variant = "card",
}: { }: {
title: string; title: string;
description: string; description: string;
@ -17,16 +18,25 @@ export function ContextHelp({
examplesLabel: string; examplesLabel: string;
scopeNote?: string | undefined; scopeNote?: string | undefined;
icon?: ReactNode; icon?: ReactNode;
variant?: "card" | "plain";
}) { }) {
return ( return (
<aside <aside
aria-label={title} aria-label={title}
className="space-y-4 rounded-xl border bg-muted/30 p-4 text-sm sm:p-5" className={
variant === "plain"
? "space-y-1 border-t border-border/60 pt-3 text-sm"
: "space-y-4 rounded-xl border bg-muted/30 p-4 text-sm sm:p-5"
}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span <span
aria-hidden="true" aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary [&_svg]:size-5" className={
variant === "plain"
? "flex size-5 shrink-0 items-center justify-center pt-1 text-muted-foreground [&_svg]:size-4"
: "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary [&_svg]:size-5"
}
> >
{icon} {icon}
</span> </span>
@ -36,8 +46,16 @@ export function ContextHelp({
{scopeNote && <p className="leading-relaxed text-muted-foreground">{scopeNote}</p>} {scopeNote && <p className="leading-relaxed text-muted-foreground">{scopeNote}</p>}
</div> </div>
</div> </div>
<details className="group overflow-hidden rounded-lg border border-primary/10"> <details
<summary className="flex cursor-pointer list-none items-center gap-3 rounded-lg bg-primary/5 px-4 py-3 font-semibold text-primary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring [&::-webkit-details-marker]:hidden"> className={
variant === "plain"
? "group"
: "group overflow-hidden rounded-lg border border-primary/10"
}
>
<summary
className={`flex cursor-pointer list-none items-center gap-3 font-semibold text-primary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring [&::-webkit-details-marker]:hidden ${variant === "plain" ? "py-2 text-sm hover:underline" : "rounded-lg bg-primary/5 px-4 py-3"}`}
>
<ChevronDownIcon <ChevronDownIcon
aria-hidden="true" aria-hidden="true"
className="size-4 shrink-0 -rotate-90 transition-transform group-open:rotate-0 motion-reduce:transition-none" className="size-4 shrink-0 -rotate-90 transition-transform group-open:rotate-0 motion-reduce:transition-none"

View File

@ -1,6 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { ChevronRightIcon } from "../lib/icons";
export type FieldSection = { id: string; name: string; description: string }; export type FieldSection = { id: string; name: string; description: string };
/** Presentación compartida; no conoce catálogos, valores ni endpoints. */ /** Presentación compartida; no conoce catálogos, valores ni endpoints. */
@ -11,6 +13,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
renderItem, renderItem,
values = false, values = false,
hasErrors = false, hasErrors = false,
layout = "fields",
}: { }: {
sections?: readonly FieldSection[] | undefined; sections?: readonly FieldSection[] | undefined;
items: readonly T[]; items: readonly T[];
@ -18,6 +21,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
renderItem: (item: T) => ReactNode; renderItem: (item: T) => ReactNode;
values?: boolean; values?: boolean;
hasErrors?: boolean; hasErrors?: boolean;
layout?: "fields" | "rows";
}) { }) {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
@ -33,7 +37,13 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values ? ( values ? (
<dl className="@container">{fields.map(renderItem)}</dl> <dl className="@container">{fields.map(renderItem)}</dl>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3"> <div
className={
layout === "rows"
? "divide-y divide-border/50 pl-5"
: "grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3"
}
>
{fields.map(renderItem)} {fields.map(renderItem)}
</div> </div>
); );
@ -44,9 +54,32 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
const known = new Set(sections.map((section) => section.id)); const known = new Set(sections.map((section) => section.id));
const ungrouped = items.filter((item) => !(item.sectionId && known.has(item.sectionId))); const ungrouped = items.filter((item) => !(item.sectionId && known.has(item.sectionId)));
const sectionClassName =
layout === "rows"
? "@container group/field-section"
: values
? "@container"
: "@container rounded-md border";
const summaryClassName =
layout === "rows"
? "flex cursor-pointer list-none items-center gap-2 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden"
: `cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`;
const bodyClassName =
layout === "rows"
? "border-t border-border/60"
: values
? "space-y-3 pt-1"
: "space-y-3 border-t p-4";
const descriptionClassName =
layout === "rows" ? "pl-5 text-sm text-muted-foreground" : "text-sm text-muted-foreground";
return ( return (
<div <div
className="grid items-start gap-4 @min-[900px]:grid-cols-2" className={
layout === "rows"
? "grid items-start gap-x-8 gap-y-3 @min-[900px]:grid-cols-2"
: "grid items-start gap-4 @min-[900px]:grid-cols-2"
}
onInvalidCapture={(event) => { onInvalidCapture={(event) => {
// Abrir antes de que el navegador intente enfocar un control inválido oculto. // Abrir antes de que el navegador intente enfocar un control inválido oculto.
if (event.target instanceof HTMLElement) { if (event.target instanceof HTMLElement) {
@ -67,20 +100,18 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
} }
return ( return (
<details <details className={sectionClassName} key={section.id} open>
className={values ? "@container" : "@container rounded-md border"} <summary className={summaryClassName}>
key={section.id} {layout === "rows" && (
open <ChevronRightIcon
> aria-hidden="true"
<summary className="size-3 shrink-0 transition-transform group-open/field-section:rotate-90 motion-reduce:transition-none"
className={`cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`} />
> )}
{section.name} {section.name}
</summary> </summary>
<div className={values ? "space-y-3 pt-1" : "space-y-3 border-t p-4"}> <div className={bodyClassName}>
{section.description && ( {section.description && <p className={descriptionClassName}>{section.description}</p>}
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
{content(fields)} {content(fields)}
</div> </div>
</details> </details>
@ -88,7 +119,15 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
})} })}
{ungrouped.length > 0 && ( {ungrouped.length > 0 && (
<section className="col-span-full space-y-3"> <section className="col-span-full space-y-3">
<h4 className="text-sm font-medium">{ungroupedLabel}</h4> <h4
className={
layout === "rows"
? "pl-5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
: "text-sm font-medium"
}
>
{ungroupedLabel}
</h4>
{content(ungrouped)} {content(ungrouped)}
</section> </section>
)} )}

View File

@ -23,7 +23,7 @@ export function FormDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
title: string; title: ReactNode;
cancelLabel: string; cancelLabel: string;
children: ReactNode; children: ReactNode;
wide?: boolean; wide?: boolean;

View File

@ -0,0 +1,25 @@
/** Fila de consulta compacta; los textos y reglas pertenecen al catálogo consumidor. */
export function TemplateFieldSummary({
name,
metadata,
requirement,
notice,
}: {
name: string;
metadata: string;
requirement: string;
notice?: string | undefined;
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-0.5 py-2 text-sm @min-[420px]:grid-cols-[minmax(0,1fr)_minmax(100px,0.8fr)_auto]">
<span className="min-w-0 font-medium [overflow-wrap:anywhere]">{name}</span>
<span className="col-start-1 row-start-2 text-xs text-muted-foreground @min-[420px]:col-start-2 @min-[420px]:row-start-1">
{metadata}
</span>
<span className="col-start-2 row-start-1 text-xs text-muted-foreground @min-[420px]:col-start-3">
{requirement}
</span>
{notice && <p className="col-span-full text-xs text-muted-foreground">{notice}</p>}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { FieldSections } from "./components/field-sections"; import { FieldSections } from "./components/field-sections";
import { TemplateFieldSummary } from "./components/template-field-summary";
import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections"; import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections";
const sections = [ const sections = [
@ -65,3 +66,43 @@ test("secciones visuales ordenadas, abiertas inicialmente y campos sin sección"
assert.equal(plain.includes("<summary"), false); assert.equal(plain.includes("<summary"), false);
assert.equal(plain.includes("Sin sección"), false); assert.equal(plain.includes("Sin sección"), false);
}); });
test("consulta compacta conserva grupos, campos sin sección y avisos sin tarjetas anidadas", () => {
const html = renderToStaticMarkup(
<FieldSections
items={fields}
layout="rows"
renderItem={(field) => (
<TemplateFieldSummary
key={field.fieldId}
metadata="Texto"
name={field.fieldId}
notice={field.fieldId === "ram" ? "Campo desactivado" : undefined}
requirement={field.required ? "Obligatorio" : "Opcional"}
/>
)}
sections={sections}
ungroupedLabel="Sin sección"
/>
);
assert.ok(html.includes("<summary"));
assert.ok(html.includes('open=""'));
assert.ok(html.includes("Campo desactivado"));
assert.ok(html.includes("Obligatorio"));
assert.ok(html.includes("Opcional"));
assert.ok(html.includes("Sin sección"));
assert.equal((html.match(/>ram</g) ?? []).length, 1);
assert.equal(html.includes("rounded-md border"), false);
const withoutSections = renderToStaticMarkup(
<FieldSections
items={fields}
layout="rows"
renderItem={(field) => <span key={field.fieldId}>{field.fieldId}</span>}
ungroupedLabel="Sin sección"
/>
);
assert.equal(withoutSections.includes("<summary"), false);
assert.ok(withoutSections.includes(">notes<"));
});