feat: implementar ordenación inicial en tablas y mejorar la configuración de columnas

This commit is contained in:
David Arranz 2026-10-15 11:36:42 +02:00
parent 3f0effd571
commit 542361cd2a
9 changed files with 147 additions and 32 deletions

View File

@ -151,6 +151,8 @@ El estándar se define en [Patrón de listados de entidades](patron-de-listados-
EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio. EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio.
La primera columna con `value`, excluida `actions`, se ordena inicialmente en ascendente. Una columna puede declarar `defaultSort: "asc" | "desc"` para elegir otro criterio; declarar como máximo una por tabla. Tickets e intervenciones lo usan en última actualización descendente. La ordenación se aplica antes de paginar, conserva empates y se refleja en la cabecera y el selector, incluso sin filas. «Orden inicial» restaura este criterio y las cabeceras alternan dirección sin eliminar la ordenación. Las tablas sin columnas comparables conservan el orden recibido.
La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida. La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida.
Los inventarios de activos y recursos componen las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado. Los inventarios de activos y recursos componen las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.

View File

@ -26,7 +26,7 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
- Búsqueda y filtros se combinan; filtrar o cambiar tamaño de página vuelve a la primera página. Si una actualización reduce resultados, se ajusta la página para no mostrar un rango inexistente. - Búsqueda y filtros se combinan; filtrar o cambiar tamaño de página vuelve a la primera página. Si una actualización reduce resultados, se ajusta la página para no mostrar un rango inexistente.
- Contar los resultados filtrados antes de paginar. Cero resultados se expresa como 0-0 de 0. La ordenación actúa sobre todos los resultados filtrados antes de paginar y mantiene un orden estable para empates. - Contar los resultados filtrados antes de paginar. Cero resultados se expresa como 0-0 de 0. La ordenación actúa sobre todos los resultados filtrados antes de paginar y mantiene un orden estable para empates.
- Columnas configurables mediante controles con etiquetas; identificación y acciones no se ocultan. No se pierde acceso a una acción por ocultar otra columna. La configuración es local a la vista y no se guarda en datos de negocio. - Columnas configurables mediante controles con etiquetas; identificación y acciones no se ocultan. No se pierde acceso a una acción por ocultar otra columna. La configuración es local a la vista y no se guarda en datos de negocio.
- Solo ofrecer ordenación para valores con significado comparable. Cabeceras ordenables son botones accesibles y expresan aria-sort. Permitir recuperar el orden inicial, respetando los órdenes existentes, por ejemplo última actualización en tickets e intervenciones. - Solo ofrecer ordenación para valores con significado comparable. Todo listado ordenable comienza con una columna ordenada e indicada mediante `aria-sort` y el selector de ordenación. El criterio común es la primera columna ordenable en ascendente: Nombre en usuarios, activos y catálogos; Persona en teletrabajo y accesos remotos; Departamento en asignaciones. Tickets e intervenciones usan última actualización descendente. Las cabeceras alternan ascendente/descendente y «Orden inicial» restaura el criterio del listado.
- El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas. - El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas.
- Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual. - Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual.
- Las relaciones son navegables cuando el destino ya ofrece navegación adecuada; no introducir enlaces ficticios ni nuevas rutas. - Las relaciones son navegables cuando el destino ya ofrece navegación adecuada; no introducir enlaces ficticios ni nuevas rutas.

View File

@ -176,6 +176,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
}, },
{ {
id: "updated", id: "updated",
defaultSort: "desc",
label: t("tickets.updated"), label: t("tickets.updated"),
value: (intervention) => intervention.history.at(-1)?.recordedAt, value: (intervention) => intervention.history.at(-1)?.recordedAt,
render: (intervention) => <>{date(intervention.history.at(-1)?.recordedAt)}</>, render: (intervention) => <>{date(intervention.history.at(-1)?.recordedAt)}</>,

View File

@ -148,6 +148,7 @@ export function TicketsPage(props: TicketsPageProps) {
}, },
{ {
id: "updated", id: "updated",
defaultSort: "desc",
label: t("tickets.updated"), label: t("tickets.updated"),
value: (ticket) => ticket.history.at(-1)?.recordedAt, value: (ticket) => ticket.history.at(-1)?.recordedAt,
render: (ticket) => <>{date(ticket.history.at(-1)?.recordedAt)}</>, render: (ticket) => <>{date(ticket.history.at(-1)?.recordedAt)}</>,

View File

@ -90,22 +90,12 @@ export function WorkspaceSidebar({
{area === "catalogs" && ( {area === "catalogs" && (
<ChevronRightIcon <ChevronRightIcon
aria-hidden="true" aria-hidden="true"
className="ml-auto size-[18px] shell:group-data-[collapsed=true]/sidebar:hidden" className="ml-auto size-4.5 shell:group-data-[collapsed=true]/sidebar:hidden"
/> />
)} )}
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
))} ))}
{canManageUsers && (
<WorkspaceNavigationButton
aria-label={identityTranslation("users")}
aria-pressed={usersActive}
href="/settings/users"
title={identityTranslation("users")}
>
<UsersIcon aria-hidden="true" className="size-5 shell:size-6" />
<span>{identityTranslation("users")}</span>
</WorkspaceNavigationButton>
)}
{current.area === "catalogs" && ( {current.area === "catalogs" && (
<nav <nav
aria-label={t("globalCatalogs")} aria-label={t("globalCatalogs")}
@ -119,7 +109,7 @@ export function WorkspaceSidebar({
level="catalog" level="catalog"
title={accessTranslation("catalogTitle")} title={accessTranslation("catalogTitle")}
> >
<KeyRoundIcon aria-hidden="true" className="size-[18px]" /> <KeyRoundIcon aria-hidden="true" className="size-4.5" />
<span>{accessTranslation("catalogTitle")}</span> <span>{accessTranslation("catalogTitle")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
@ -130,10 +120,21 @@ export function WorkspaceSidebar({
level="catalog" level="catalog"
title={assetTranslation("title")} title={assetTranslation("title")}
> >
<BoxIcon aria-hidden="true" className="size-[18px]" /> <BoxIcon aria-hidden="true" className="size-4.5" />
<span>{assetTranslation("title")}</span> <span>{assetTranslation("title")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={resourceTranslation("title")}
aria-pressed={catalog === "resources"}
href="/catalogs/resources"
level="catalog"
title={resourceTranslation("title")}
>
<DatabaseIcon aria-hidden="true" className="size-4.5" />
<span>{resourceTranslation("title")}</span>
</WorkspaceNavigationButton>
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={t("interventionTemplates.title")} aria-label={t("interventionTemplates.title")}
aria-pressed={catalog === "intervention-templates"} aria-pressed={catalog === "intervention-templates"}
@ -141,7 +142,7 @@ export function WorkspaceSidebar({
level="catalog" level="catalog"
title={t("interventionTemplates.title")} title={t("interventionTemplates.title")}
> >
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" /> <LayoutTemplateIcon aria-hidden="true" className="size-4.5" />
<span>{t("interventionTemplates.title")}</span> <span>{t("interventionTemplates.title")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
@ -152,20 +153,10 @@ export function WorkspaceSidebar({
level="catalog" level="catalog"
title={assetTranslation("brands.title")} title={assetTranslation("brands.title")}
> >
<LayersIcon aria-hidden="true" className="size-[18px]" /> <LayersIcon aria-hidden="true" className="size-4.5" />
<span>{assetTranslation("brands.title")}</span> <span>{assetTranslation("brands.title")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={resourceTranslation("title")}
aria-pressed={catalog === "resources"}
href="/catalogs/resources"
level="catalog"
title={resourceTranslation("title")}
>
<DatabaseIcon aria-hidden="true" className="size-[18px]" />
<span>{resourceTranslation("title")}</span>
</WorkspaceNavigationButton>
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={t("processes.templates")} aria-label={t("processes.templates")}
aria-pressed={catalog === "process-templates"} aria-pressed={catalog === "process-templates"}
@ -173,12 +164,23 @@ export function WorkspaceSidebar({
level="catalog" level="catalog"
title={t("processes.templates")} title={t("processes.templates")}
> >
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" /> <LayoutTemplateIcon aria-hidden="true" className="size-4.5" />
<span>{t("processes.templates")}</span> <span>{t("processes.templates")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
</> </>
</nav> </nav>
)} )}
{canManageUsers && (
<WorkspaceNavigationButton
aria-label={identityTranslation("users")}
aria-pressed={usersActive}
href="/settings/users"
title={identityTranslation("users")}
>
<UsersIcon aria-hidden="true" className="size-5 shell:size-6" />
<span>{identityTranslation("users")}</span>
</WorkspaceNavigationButton>
)}
<div className="my-1.5 h-px shrink-0 basis-full bg-white/[0.12] shell:mb-[25px] shell:mt-[26px] shell:basis-auto" /> <div className="my-1.5 h-px shrink-0 basis-full bg-white/[0.12] shell:mb-[25px] shell:mt-[26px] shell:basis-auto" />
{companies.map((company) => ( {companies.map((company) => (
<WorkspaceNavigationButton <WorkspaceNavigationButton

View File

@ -42,6 +42,7 @@ export type EntityColumn<T> = {
label: string; label: string;
render: (item: T) => ReactNode; render: (item: T) => ReactNode;
value?: (item: T) => string | number | boolean | null | undefined; value?: (item: T) => string | number | boolean | null | undefined;
defaultSort?: "asc" | "desc";
hideable?: boolean; hideable?: boolean;
}; };

View File

@ -40,9 +40,11 @@ export function EntityTableConfiguration<T>({
onValueChange={(value) => { onValueChange={(value) => {
const [column, direction] = value.split(":"); const [column, direction] = value.split(":");
setSorting( if (column && column !== "initial") {
column && column !== "initial" ? [{ id: column, desc: direction === "desc" }] : [] setSorting([{ id: column, desc: direction === "desc" }]);
); } else {
table.resetSorting();
}
onSort(); onSort();
}} }}
options={[ options={[

View File

@ -60,6 +60,105 @@ test("la búsqueda local reduce el conjunto completo antes de paginar y permite
assert.equal(html.includes("Persona 01"), false); assert.equal(html.includes("Persona 01"), false);
}); });
test("el orden inicial ascendente se indica en la cabecera y se aplica antes de paginar", () => {
const html = renderToStaticMarkup(
<EntityTable columns={columns} items={[...items].reverse()} title="Personas" />
);
assert.match(html, /<th[^>]*aria-sort="ascending"[^>]*>[\s\S]*?Nombre/);
assert.ok(html.indexOf("Persona 01") < html.indexOf("Persona 02"));
assert.ok(html.includes("Persona 10"));
assert.equal(html.includes("Persona 11"), false);
assert.equal(html.includes("Persona 21"), false);
});
test("una columna explícita puede establecer un orden inicial descendente", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
...columns,
{
id: "updated",
label: "Actualización",
defaultSort: "desc",
value: (item) => item.updated,
render: (item) => item.updated,
},
]}
items={[
{ id: "old", name: "Ana", updated: "2026-01-01T12:00:00Z" },
{ id: "new", name: "Zoe", updated: "2026-02-01T12:00:00Z" },
]}
title="Actividad"
/>
);
assert.match(html, /<th[^>]*aria-sort="descending"[^>]*>[\s\S]*?Actualización/);
assert.equal(html.includes('aria-sort="ascending"'), false);
assert.ok(html.indexOf(">Zoe<") < html.indexOf(">Ana<"));
});
test("el orden inicial ignora acciones y columnas sin valor comparable", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{ id: "logo", label: "Logo", render: () => "Imagen" },
{
id: "actions",
label: "Acciones",
defaultSort: "desc",
value: (item) => item.name,
render: () => "Editar",
},
...columns,
]}
items={items.slice(0, 2).reverse()}
title="Personas"
/>
);
assert.match(html, /<th[^>]*aria-sort="ascending"[^>]*>[\s\S]*?Nombre/);
assert.equal(html.includes('aria-sort="descending"'), false);
assert.ok(html.indexOf("Persona 01") < html.indexOf("Persona 02"));
});
test("las tablas sin columnas ordenables conservan el orden recibido", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[{ id: "name", label: "Nombre", render: (item) => item.name }]}
items={[...items].reverse()}
title="Personas"
/>
);
assert.equal(html.includes("aria-sort="), false);
assert.ok(html.indexOf("Persona 21") < html.indexOf("Persona 20"));
});
test("el orden inicial mantiene la estabilidad en empates y se indica con el listado vacío", () => {
const tied = renderToStaticMarkup(
<EntityTable
columns={[
{
id: "name",
label: "Nombre",
value: (item) => item.name,
render: (item) => item.id,
},
]}
items={[
{ id: "second", name: "Igual" },
{ id: "first", name: "Igual" },
]}
title="Personas"
/>
);
const empty = renderToStaticMarkup(<EntityTable columns={columns} items={[]} title="Personas" />);
assert.ok(tied.indexOf(">second<") < tied.indexOf(">first<"));
assert.ok(empty.includes('aria-sort="ascending"'));
});
test("una búsqueda controlada conserva los resultados filtrados por la pantalla", () => { test("una búsqueda controlada conserva los resultados filtrados por la pantalla", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<EntityTable <EntityTable

View File

@ -46,7 +46,12 @@ export function useEntityTable<T extends { id: string }>({
}) { }) {
const [localSearch, setLocalSearch] = useState(""); const [localSearch, setLocalSearch] = useState("");
const currentSearch = search ?? localSearch; const currentSearch = search ?? localSearch;
const [sorting, setSorting] = useState<SortingState>([]); const sortableColumns = columns.filter((column) => column.id !== "actions" && column.value);
const defaultColumn = sortableColumns.find((column) => column.defaultSort) ?? sortableColumns[0];
const initialSorting: SortingState = defaultColumn
? [{ id: defaultColumn.id, desc: defaultColumn.defaultSort === "desc" }]
: [];
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const [visibility, setVisibility] = useState<VisibilityState>({}); const [visibility, setVisibility] = useState<VisibilityState>({});
const [pagination, setPagination] = useState<PaginationState>(() => ({ const [pagination, setPagination] = useState<PaginationState>(() => ({
pageIndex: 0, pageIndex: 0,
@ -82,6 +87,8 @@ export function useEntityTable<T extends { id: string }>({
const table = useReactTable({ const table = useReactTable({
data: [...items], data: [...items],
columns: definitions, columns: definitions,
initialState: { sorting: initialSorting },
enableSortingRemoval: false,
getRowId: (item) => item.id, getRowId: (item) => item.id,
state: { state: {
sorting, sorting,