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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ export function ContextHelp({
examplesLabel,
scopeNote,
icon = <InfoIcon />,
variant = "card",
}: {
title: string;
description: string;
@ -17,16 +18,25 @@ export function ContextHelp({
examplesLabel: string;
scopeNote?: string | undefined;
icon?: ReactNode;
variant?: "card" | "plain";
}) {
return (
<aside
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">
<span
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}
</span>
@ -36,8 +46,16 @@ export function ContextHelp({
{scopeNote && <p className="leading-relaxed text-muted-foreground">{scopeNote}</p>}
</div>
</div>
<details className="group overflow-hidden rounded-lg border border-primary/10">
<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">
<details
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
aria-hidden="true"
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 { useEffect, useRef } from "react";
import { ChevronRightIcon } from "../lib/icons";
export type FieldSection = { id: string; name: string; description: string };
/** Presentación compartida; no conoce catálogos, valores ni endpoints. */
@ -11,6 +13,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
renderItem,
values = false,
hasErrors = false,
layout = "fields",
}: {
sections?: readonly FieldSection[] | undefined;
items: readonly T[];
@ -18,6 +21,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
renderItem: (item: T) => ReactNode;
values?: boolean;
hasErrors?: boolean;
layout?: "fields" | "rows";
}) {
const container = useRef<HTMLDivElement>(null);
@ -33,7 +37,13 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values ? (
<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)}
</div>
);
@ -44,9 +54,32 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
const known = new Set(sections.map((section) => section.id));
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 (
<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) => {
// Abrir antes de que el navegador intente enfocar un control inválido oculto.
if (event.target instanceof HTMLElement) {
@ -67,20 +100,18 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
}
return (
<details
className={values ? "@container" : "@container rounded-md border"}
key={section.id}
open
>
<summary
className={`cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`}
>
<details className={sectionClassName} key={section.id} open>
<summary className={summaryClassName}>
{layout === "rows" && (
<ChevronRightIcon
aria-hidden="true"
className="size-3 shrink-0 transition-transform group-open/field-section:rotate-90 motion-reduce:transition-none"
/>
)}
{section.name}
</summary>
<div className={values ? "space-y-3 pt-1" : "space-y-3 border-t p-4"}>
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
<div className={bodyClassName}>
{section.description && <p className={descriptionClassName}>{section.description}</p>}
{content(fields)}
</div>
</details>
@ -88,7 +119,15 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
})}
{ungrouped.length > 0 && (
<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)}
</section>
)}

View File

@ -23,7 +23,7 @@ export function FormDialog({
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
title: ReactNode;
cancelLabel: string;
children: ReactNode;
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 { FieldSections } from "./components/field-sections";
import { TemplateFieldSummary } from "./components/template-field-summary";
import { copyTemplateLayout, removeTemplateSection } from "./lib/template-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("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<"));
});