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.
|
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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)}</>,
|
||||||
|
|||||||
@ -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)}</>,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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={[
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user