feat: implementar ordenación inicial en tablas y mejorar la configuración de columnas
This commit is contained in:
parent
3f0effd571
commit
542361cd2a
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@ -176,6 +176,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
defaultSort: "desc",
|
||||
label: t("tickets.updated"),
|
||||
value: (intervention) => intervention.history.at(-1)?.recordedAt,
|
||||
render: (intervention) => <>{date(intervention.history.at(-1)?.recordedAt)}</>,
|
||||
|
||||
@ -148,6 +148,7 @@ export function TicketsPage(props: TicketsPageProps) {
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
defaultSort: "desc",
|
||||
label: t("tickets.updated"),
|
||||
value: (ticket) => ticket.history.at(-1)?.recordedAt,
|
||||
render: (ticket) => <>{date(ticket.history.at(-1)?.recordedAt)}</>,
|
||||
|
||||
@ -90,22 +90,12 @@ export function WorkspaceSidebar({
|
||||
{area === "catalogs" && (
|
||||
<ChevronRightIcon
|
||||
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>
|
||||
))}
|
||||
{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" && (
|
||||
<nav
|
||||
aria-label={t("globalCatalogs")}
|
||||
@ -119,7 +109,7 @@ export function WorkspaceSidebar({
|
||||
level="catalog"
|
||||
title={accessTranslation("catalogTitle")}
|
||||
>
|
||||
<KeyRoundIcon aria-hidden="true" className="size-[18px]" />
|
||||
<KeyRoundIcon aria-hidden="true" className="size-4.5" />
|
||||
<span>{accessTranslation("catalogTitle")}</span>
|
||||
</WorkspaceNavigationButton>
|
||||
|
||||
@ -130,10 +120,21 @@ export function WorkspaceSidebar({
|
||||
level="catalog"
|
||||
title={assetTranslation("title")}
|
||||
>
|
||||
<BoxIcon aria-hidden="true" className="size-[18px]" />
|
||||
<BoxIcon aria-hidden="true" className="size-4.5" />
|
||||
<span>{assetTranslation("title")}</span>
|
||||
</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
|
||||
aria-label={t("interventionTemplates.title")}
|
||||
aria-pressed={catalog === "intervention-templates"}
|
||||
@ -141,7 +142,7 @@ export function WorkspaceSidebar({
|
||||
level="catalog"
|
||||
title={t("interventionTemplates.title")}
|
||||
>
|
||||
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
|
||||
<LayoutTemplateIcon aria-hidden="true" className="size-4.5" />
|
||||
<span>{t("interventionTemplates.title")}</span>
|
||||
</WorkspaceNavigationButton>
|
||||
|
||||
@ -152,20 +153,10 @@ export function WorkspaceSidebar({
|
||||
level="catalog"
|
||||
title={assetTranslation("brands.title")}
|
||||
>
|
||||
<LayersIcon aria-hidden="true" className="size-[18px]" />
|
||||
<LayersIcon aria-hidden="true" className="size-4.5" />
|
||||
<span>{assetTranslation("brands.title")}</span>
|
||||
</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
|
||||
aria-label={t("processes.templates")}
|
||||
aria-pressed={catalog === "process-templates"}
|
||||
@ -173,12 +164,23 @@ export function WorkspaceSidebar({
|
||||
level="catalog"
|
||||
title={t("processes.templates")}
|
||||
>
|
||||
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
|
||||
<LayoutTemplateIcon aria-hidden="true" className="size-4.5" />
|
||||
<span>{t("processes.templates")}</span>
|
||||
</WorkspaceNavigationButton>
|
||||
</>
|
||||
</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" />
|
||||
{companies.map((company) => (
|
||||
<WorkspaceNavigationButton
|
||||
|
||||
@ -42,6 +42,7 @@ export type EntityColumn<T> = {
|
||||
label: string;
|
||||
render: (item: T) => ReactNode;
|
||||
value?: (item: T) => string | number | boolean | null | undefined;
|
||||
defaultSort?: "asc" | "desc";
|
||||
hideable?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@ -40,9 +40,11 @@ export function EntityTableConfiguration<T>({
|
||||
onValueChange={(value) => {
|
||||
const [column, direction] = value.split(":");
|
||||
|
||||
setSorting(
|
||||
column && column !== "initial" ? [{ id: column, desc: direction === "desc" }] : []
|
||||
);
|
||||
if (column && column !== "initial") {
|
||||
setSorting([{ id: column, desc: direction === "desc" }]);
|
||||
} else {
|
||||
table.resetSorting();
|
||||
}
|
||||
onSort();
|
||||
}}
|
||||
options={[
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EntityTable
|
||||
|
||||
@ -46,7 +46,12 @@ export function useEntityTable<T extends { id: string }>({
|
||||
}) {
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
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 [pagination, setPagination] = useState<PaginationState>(() => ({
|
||||
pageIndex: 0,
|
||||
@ -82,6 +87,8 @@ export function useEntityTable<T extends { id: string }>({
|
||||
const table = useReactTable({
|
||||
data: [...items],
|
||||
columns: definitions,
|
||||
initialState: { sorting: initialSorting },
|
||||
enableSortingRemoval: false,
|
||||
getRowId: (item) => item.id,
|
||||
state: {
|
||||
sorting,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user