mejora de la ficha de plantillas de activos
This commit is contained in:
parent
108e810c3b
commit
853011cec3
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -23,7 +23,7 @@ export function FormDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
cancelLabel: string;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
|
||||
25
packages/rdx-ui/src/components/template-field-summary.tsx
Normal file
25
packages/rdx-ui/src/components/template-field-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<"));
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user