feat(resources): implement resource list preferences and sorting persistence

This commit is contained in:
David Arranz 2026-10-26 11:54:46 +01:00
parent 0708200161
commit 6992a4ac74
13 changed files with 332 additions and 28 deletions

View File

@ -1444,6 +1444,13 @@ listado de entidades y la configuración accesible de columnas. El consumidor ap
ordenación, búsqueda, filtros, estado, traducciones y callbacks de página; el componente no ordenación, búsqueda, filtros, estado, traducciones y callbacks de página; el componente no
conoce HTTP, DTO ni entidades de Metis. conoce HTTP, DTO ni entidades de Metis.
La prop opcional `columnVisibilityStorageKey` recupera y guarda la visibilidad en
localStorage con el mismo contrato que EntityTable. El consumidor define la clave y su
ámbito; Nombre (si declara `hideable: false`) y Acciones permanecen visibles, las nuevas
columnas respetan `defaultVisible` y restaurar elimina las preferencias de la tabla actual.
El listado sigue funcionando con almacenamiento bloqueado o datos locales inválidos.
Recursos de compañía proporciona una clave independiente por compañía y pestaña.
La ordenación y la paginación son manuales en TanStack Table: no filtra ni reordena únicamente La ordenación y la paginación son manuales en TanStack Table: no filtra ni reordena únicamente
las filas de una página remota. `index` y `size` describen la página visible, `hasNext` procede las filas de una página remota. `index` y `size` describen la página visible, `hasNext` procede
del cursor devuelto por el servidor. La pantalla conserva los cursores anteriores y los del cursor devuelto por el servidor. La pantalla conserva los cursores anteriores y los

View File

@ -308,12 +308,17 @@ actuales de los recursos, identificados por UUID, además de notas, fechas y aut
alta/última modificación y los campos de integración de buzones, accesos y dominios. alta/última modificación y los campos de integración de buzones, accesos y dominios.
Las columnas adicionales comienzan ocultas y se agrupan en Datos generales, Integración, Las columnas adicionales comienzan ocultas y se agrupan en Datos generales, Integración,
Registro y cambios y Características. La búsqueda admite también nombres de plantillas, Registro y cambios y Características. La búsqueda admite también nombres de plantillas,
secciones, descripciones y unidades. Filtrar por plantilla o por texto no reduce las secciones, descripciones y unidades. Filtrar por texto no reduce las columnas disponibles. Cada pestaña, incluida Todo,
columnas disponibles ni reinicia su selección. Las celdas sin valor muestran «—»; guarda sus propias columnas y ordenación en localStorage, separadas por compañía y por
UUID de plantilla. Cambiar de pestaña recupera su configuración y reinicia la página;
recargar o volver a entrar conserva las preferencias. Una ordenación explícita en la URL
prevalece sobre la guardada. Un almacenamiento bloqueado o inválido permite seguir usando
el listado sin persistencia. Las celdas sin valor muestran «—»;
cero, booleanos, fechas y unidades conservan su representación semántica. cero, booleanos, fechas y unidades conservan su representación semántica.
La pestaña Aplicaciones mantiene su listado remoto especializado de resúmenes globales y La pestaña Aplicaciones mantiene su listado remoto especializado de resúmenes globales y
locales; su contrato no incluye características ni revisiones completas. El historial locales; comparte la persistencia por compañía y pestaña de columnas y ordenación,
respetando la ordenación remota. Su contrato no incluye características ni revisiones completas. El historial
completo y las relaciones de cada recurso siguen en su ficha. completo y las relaciones de cada recurso siguen en su ficha.
### Adopción proporcional en catálogos y configuraciones — L04.2 ### Adopción proporcional en catálogos y configuraciones — L04.2

View File

@ -11,7 +11,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/application/application-coincidence.test.ts src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/list/helpers/resource-columns.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-domain.test.ts src/api/domain/network-share.test.ts src/api/domain/resource-scope.test.ts src/api/infrastructure/persistence/resource-scope-reader.test.ts src/api/application/resource-scope.test.ts src/api/application/resource-reference-contracts.test.ts src/api/domain/global-resource.test.ts src/web/global-applications/shared/global-applications.test.tsx src/api/domain/operating-system-identity.test.ts src/api/application/global-operating-system-contracts.test.ts src/api/application/global-operating-system-policy.test.ts src/web/resources/shared/ui/resource-characteristic-control.test.tsx" "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/application/application-coincidence.test.ts src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/list/helpers/resource-columns.test.tsx src/web/resources/list/helpers/resource-list-preferences.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-domain.test.ts src/api/domain/network-share.test.ts src/api/domain/resource-scope.test.ts src/api/infrastructure/persistence/resource-scope-reader.test.ts src/api/application/resource-scope.test.ts src/api/application/resource-reference-contracts.test.ts src/api/domain/global-resource.test.ts src/web/global-applications/shared/global-applications.test.tsx src/api/domain/operating-system-identity.test.ts src/api/application/global-operating-system-contracts.test.ts src/api/application/global-operating-system-policy.test.ts src/web/resources/shared/ui/resource-characteristic-control.test.tsx"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*", "@metis/core": "workspace:*",

View File

@ -7,9 +7,22 @@ import {
applicationReferencesApi, applicationReferencesApi,
} from "../../../application-references/shared/api/application-references-api"; } from "../../../application-references/shared/api/application-references-api";
import { applicationReferenceKeys } from "../../../application-references/shared/queries/application-reference-keys"; import { applicationReferenceKeys } from "../../../application-references/shared/queries/application-reference-keys";
import {
readResourceListSorting,
resourceListStorageKey,
writeResourceListSorting,
} from "../helpers/resource-list-preferences";
export function useCompanyApplicationsController(companyId: string) { export function useCompanyApplicationsController(companyId: string) {
const { searchParams } = useBrowserSearchParams(); const { searchParams } = useBrowserSearchParams();
const storageKey = resourceListStorageKey(companyId, searchParams.get("template"));
const storedSorting = ListGlobalApplicationsRequestSchema.pick({
sort: true,
direction: true,
}).safeParse(readResourceListSorting(storageKey));
const preferredSorting = storedSorting.success ? storedSorting.data : undefined;
const hasUrlSorting = searchParams.has("sort") || searchParams.has("direction");
const sorting = hasUrlSorting ? undefined : preferredSorting;
const search = searchParams.get("q") ?? ""; const search = searchParams.get("q") ?? "";
const state = searchParams.get("active") ?? "all"; const state = searchParams.get("active") ?? "all";
const pageIndex = Math.max(0, Number(searchParams.get("page") ?? "1") - 1); const pageIndex = Math.max(0, Number(searchParams.get("page") ?? "1") - 1);
@ -17,8 +30,8 @@ export function useCompanyApplicationsController(companyId: string) {
const parsed = ListGlobalApplicationsRequestSchema.safeParse({ const parsed = ListGlobalApplicationsRequestSchema.safeParse({
search, search,
...(state === "all" ? {} : { active: state }), ...(state === "all" ? {} : { active: state }),
sort: searchParams.get("sort") ?? "name", sort: searchParams.get("sort") ?? sorting?.sort,
direction: searchParams.get("direction") ?? "asc", direction: searchParams.get("direction") ?? sorting?.direction,
pageSize: searchParams.get("pageSize") ?? "25", pageSize: searchParams.get("pageSize") ?? "25",
...(searchParams.get("cursor") ? { cursor: searchParams.get("cursor") } : {}), ...(searchParams.get("cursor") ? { cursor: searchParams.get("cursor") } : {}),
}); });
@ -60,6 +73,7 @@ export function useCompanyApplicationsController(companyId: string) {
}; };
return { return {
columnVisibilityStorageKey: `${storageKey}:columns`,
query, query,
denied: denied:
query.error instanceof ApplicationReferenceApiError && query.error instanceof ApplicationReferenceApiError &&
@ -71,6 +85,10 @@ export function useCompanyApplicationsController(companyId: string) {
state, state,
pageIndex, pageIndex,
update, update,
changeSorting: (sorting: { sort: string; direction: "asc" | "desc" }) => {
writeResourceListSorting(storageKey, sorting);
update(sorting);
},
nextPage: () => { nextPage: () => {
if (query.data?.nextCursor && !query.isPlaceholderData) { if (query.data?.nextCursor && !query.isPlaceholderData) {
setPrevious([...previous, queryData?.cursor]); setPrevious([...previous, queryData?.cursor]);

View File

@ -0,0 +1,34 @@
import { useListLocation } from "@metis/core/client";
import {
readResourceListSorting,
resourceListStorageKey,
writeResourceListSorting,
} from "../helpers/resource-list-preferences";
/** La URL explícita prevalece; sin ella se recupera el orden preferido de esta compañía y pestaña. */
export function useResourceListNavigation(
companyId: string,
templateId: string | null,
ready: boolean
) {
const location = useListLocation({ sort: "sort", direction: "direction", page: "page" }, ready);
const storageKey = resourceListStorageKey(companyId, templateId);
const storedSorting = readResourceListSorting(storageKey);
const hasUrlSorting = location.values.sort !== null || location.values.direction !== null;
return {
...location,
values:
!hasUrlSorting && storedSorting
? { ...location.values, sort: storedSorting.sort, direction: storedSorting.direction }
: location.values,
onChange: (value: Parameters<typeof location.onChange>[0]) => {
writeResourceListSorting(storageKey, {
sort: value.sort ?? "name",
direction: value.direction ?? "asc",
});
location.onChange(value);
},
};
}

View File

@ -15,6 +15,7 @@ import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryApi, inventoryKeys } from "../../shared/api"; import { inventoryApi, inventoryKeys } from "../../shared/api";
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys"; import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget"; import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import { applyResourceListTab } from "../helpers/resource-list-preferences";
import { resourceListQueryDenied } from "../helpers/resource-list-query-state"; import { resourceListQueryDenied } from "../helpers/resource-list-query-state";
import type { ResourcesPageProps } from "../types"; import type { ResourcesPageProps } from "../types";
@ -151,24 +152,13 @@ export function useResourcesPageController({
const setTemplateId = (value: string) => const setTemplateId = (value: string) =>
setSearchParams((params) => { setSearchParams((params) => {
params.delete("page"); applyResourceListTab(params, value === "all" ? null : value);
params.delete("cursor");
params.delete("active");
params.delete("sort");
params.delete("direction");
params.delete("pageSize");
if (value === "all") {
params.delete("template");
} else {
params.set("template", value);
}
}); });
const clearFilters = () => const clearFilters = () =>
setSearchParams((params) => { setSearchParams((params) => {
params.delete("page");
params.delete("q"); params.delete("q");
params.delete("template"); applyResourceListTab(params, null);
}); });
const denied = resourceListQueryDenied(query.error); const denied = resourceListQueryDenied(query.error);
@ -188,6 +178,7 @@ export function useResourcesPageController({
}); });
return { return {
companyId,
returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching), returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching),
canChangeStatus: query.isSuccess && !statusMutation.isPending, canChangeStatus: query.isSuccess && !statusMutation.isPending,
deleteId, deleteId,

View File

@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { useResourceListNavigation } from "../controllers/use-resource-list-navigation";
import {
applyResourceListTab,
readResourceListSorting,
resourceListStorageKey,
writeResourceListSorting,
} from "./resource-list-preferences";
function withStorage(run: (storage: Map<string, string>) => void, search = "") {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { pathname: "/companies/one/resources", search },
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
},
});
try {
run(storage);
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "window", descriptor);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
}
function NavigationProbe({ templateId }: { templateId: string | null }) {
const navigation = useResourceListNavigation("one", templateId, true);
return <output>{JSON.stringify(navigation.values)}</output>;
}
test("orden de recursos: aislamiento por compañía y pestaña, incluida Todo", () => {
withStorage(() => {
const all = resourceListStorageKey("one", null);
const folders = resourceListStorageKey("one", "folders");
const other = resourceListStorageKey("two", "folders");
writeResourceListSorting(all, { sort: "name", direction: "asc" });
writeResourceListSorting(folders, { sort: "state", direction: "desc" });
assert.deepEqual(readResourceListSorting(all), { sort: "name", direction: "asc" });
assert.deepEqual(readResourceListSorting(folders), { sort: "state", direction: "desc" });
assert.equal(readResourceListSorting(other), undefined);
const markup = renderToStaticMarkup(<NavigationProbe templateId="folders" />);
assert.ok(markup.includes("state"));
assert.ok(markup.includes("desc"));
});
});
test("orden de recursos: una URL explícita prevalece sobre la preferencia sin sobrescribirla", () => {
withStorage(() => {
const key = resourceListStorageKey("one", "folders");
writeResourceListSorting(key, { sort: "state", direction: "desc" });
const markup = renderToStaticMarkup(<NavigationProbe templateId="folders" />);
assert.ok(markup.includes("notes"));
assert.ok(markup.includes("asc"));
assert.deepEqual(readResourceListSorting(key), { sort: "state", direction: "desc" });
}, "?sort=notes&direction=asc");
});
test("preferencias opcionales: datos inválidos y almacenamiento bloqueado no impiden listar", () => {
withStorage((storage) => {
const key = resourceListStorageKey("one", null);
for (const serialized of [
"{",
"null",
"[]",
'{"sort":true,"direction":"asc"}',
'{"sort":"name","direction":"up"}',
]) {
storage.set(`${key}:sorting`, serialized);
assert.equal(readResourceListSorting(key), undefined);
}
Object.defineProperty(window, "localStorage", {
get: () => {
throw new Error("Bloqueado");
},
});
assert.equal(readResourceListSorting(key), undefined);
assert.doesNotThrow(() => writeResourceListSorting(key, { sort: "name", direction: "asc" }));
});
});
test("cambiar pestaña limpia el orden y la paginación anteriores conservando la búsqueda", () => {
const params = new URLSearchParams(
"q=Administración&template=old&sort=state&direction=desc&page=3&cursor=old&active=true&pageSize=50"
);
applyResourceListTab(params, "folders");
assert.equal(params.toString(), "q=Administraci%C3%B3n&template=folders");
applyResourceListTab(params, null);
assert.equal(params.toString(), "q=Administraci%C3%B3n");
});

View File

@ -0,0 +1,51 @@
import { z } from "zod/v4";
const ResourceListSortingSchema = z.strictObject({
sort: z.string().min(1).max(100),
direction: z.enum(["asc", "desc"]),
});
export function resourceListStorageKey(companyId: string, templateId: string | null) {
return `resource-inventory:${companyId}:${templateId ?? "all"}`;
}
export function readResourceListSorting(storageKey: string) {
if (typeof window === "undefined") {
return undefined;
}
try {
const serialized = window.localStorage.getItem(`${storageKey}:sorting`);
const parsed = ResourceListSortingSchema.safeParse(serialized ? JSON.parse(serialized) : null);
return parsed.success ? parsed.data : undefined;
} catch {
// Las preferencias son opcionales si el navegador impide leerlas o contiene JSON inválido.
return undefined;
}
}
export function writeResourceListSorting(
storageKey: string,
sorting: z.infer<typeof ResourceListSortingSchema>
) {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(`${storageKey}:sorting`, JSON.stringify(sorting));
} catch {
// La ordenación sigue operativa en la URL aunque no pueda persistirse.
}
}
/** Prepara el cambio de colección sin arrastrar orden explícito, cursores o página de otra pestaña. */
export function applyResourceListTab(params: URLSearchParams, templateId: string | null) {
for (const key of ["page", "cursor", "active", "sort", "direction", "pageSize"]) {
params.delete(key);
}
if (templateId) {
params.set("template", templateId);
} else {
params.delete("template");
}
}

View File

@ -139,6 +139,7 @@ export function CompanyApplicationsList({
<p className="text-sm text-muted-foreground">{t("companyApplications.description")}</p> <p className="text-sm text-muted-foreground">{t("companyApplications.description")}</p>
<CursorEntityTable <CursorEntityTable
columns={columns} columns={columns}
columnVisibilityStorageKey={controller.columnVisibilityStorageKey}
emptyMessage={t("applicationSelection.empty")} emptyMessage={t("applicationSelection.empty")}
filters={ filters={
<SelectWithAction <SelectWithAction
@ -175,7 +176,7 @@ export function CompanyApplicationsList({
onRetry={() => query.refetch()} onRetry={() => query.refetch()}
onSearchChange={(q) => controller.update({ q })} onSearchChange={(q) => controller.update({ q })}
onSortingChange={(sorting) => onSortingChange={(sorting) =>
controller.update({ controller.changeSorting({
sort: sorting[0]?.id ?? "name", sort: sorting[0]?.id ?? "name",
direction: sorting[0]?.desc ? "desc" : "asc", direction: sorting[0]?.desc ? "desc" : "asc",
}) })

View File

@ -15,6 +15,7 @@ import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resou
import type { useResourcesPageController } from "../controllers"; import type { useResourcesPageController } from "../controllers";
import { resourceAdditionalColumns } from "../helpers/resource-additional-columns"; import { resourceAdditionalColumns } from "../helpers/resource-additional-columns";
import { resourceCharacteristicColumns } from "../helpers/resource-characteristic-columns"; import { resourceCharacteristicColumns } from "../helpers/resource-characteristic-columns";
import { resourceListStorageKey } from "../helpers/resource-list-preferences";
export function ResourceInventoryTable({ export function ResourceInventoryTable({
controller, controller,
@ -172,6 +173,7 @@ export function ResourceInventoryTable({
}, },
]} ]}
columnsDisabled={!catalog.data} columnsDisabled={!catalog.data}
columnVisibilityStorageKey={`${resourceListStorageKey(controller.companyId, templateId)}:columns`}
emptyMessage={t("empty")} emptyMessage={t("empty")}
filterKey={tabValue} filterKey={tabValue}
filtersApplied={templateId !== null} filtersApplied={templateId !== null}

View File

@ -1,4 +1,4 @@
import { EntityNotFound, useListLocation, useListReturn } from "@metis/core/client"; import { EntityNotFound, useListReturn } from "@metis/core/client";
import { EntityListHeader } from "@repo/rdx-ui/components/entity-list"; import { EntityListHeader } from "@repo/rdx-ui/components/entity-list";
import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status"; import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list"; import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
@ -17,6 +17,7 @@ import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail";
import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog"; import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog";
import { ResourceEditorDialog } from "../../shared"; import { ResourceEditorDialog } from "../../shared";
import { useResourcesPageController } from "../controllers"; import { useResourcesPageController } from "../controllers";
import { useResourceListNavigation } from "../controllers/use-resource-list-navigation";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget"; import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import { resourceListQueryState } from "../helpers/resource-list-query-state"; import { resourceListQueryState } from "../helpers/resource-list-query-state";
import type { ResourcesPageProps } from "../types"; import type { ResourcesPageProps } from "../types";
@ -61,10 +62,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
const inventoryState = resourceListQueryState(query, "inventory", t); const inventoryState = resourceListQueryState(query, "inventory", t);
const catalogState = resourceListQueryState(catalog, "catalog", t); const catalogState = resourceListQueryState(catalog, "catalog", t);
const canMutate = query.isSuccess && catalog.isSuccess; const canMutate = query.isSuccess && catalog.isSuccess;
const navigation = useListLocation( const navigation = useResourceListNavigation(companyId, templateId, canMutate);
{ sort: "sort", direction: "direction", page: "page" },
canMutate
);
useListReturn({ useListReturn({
scope: `/companies/${companyId}/resources`, scope: `/companies/${companyId}/resources`,

View File

@ -3,8 +3,12 @@ import { Input } from "@repo/shadcn-ui/components/input";
import type { SortingState } from "@tanstack/react-table"; import type { SortingState } from "@tanstack/react-table";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import {
readColumnVisibility,
writeColumnVisibility,
} from "../lib/entity-column-visibility-storage";
import type { EntityListState } from "../lib/entity-list-state"; import type { EntityListState } from "../lib/entity-list-state";
import type { EntityColumn } from "./entity-list"; import type { EntityColumn } from "./entity-list";
@ -17,6 +21,7 @@ import { SelectWithAction } from "./select-with-action";
export function CursorEntityTable<T extends { id: string }>({ export function CursorEntityTable<T extends { id: string }>({
items, items,
columns, columns,
columnVisibilityStorageKey,
title, title,
locale, locale,
search, search,
@ -35,6 +40,7 @@ export function CursorEntityTable<T extends { id: string }>({
}: { }: {
items: readonly T[]; items: readonly T[];
columns: readonly EntityColumn<T>[]; columns: readonly EntityColumn<T>[];
columnVisibilityStorageKey?: string | undefined;
title: string; title: string;
locale: string; locale: string;
search: string; search: string;
@ -68,7 +74,18 @@ export function CursorEntityTable<T extends { id: string }>({
}) { }) {
const prefix = useId(); const prefix = useId();
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const [visibility, setVisibility] = useState({}); const [visibility, setVisibility] = useState(() =>
readColumnVisibility(columnVisibilityStorageKey)
);
const [previousStorageKey, setPreviousStorageKey] = useState(columnVisibilityStorageKey);
if (previousStorageKey !== columnVisibilityStorageKey) {
setPreviousStorageKey(columnVisibilityStorageKey);
setVisibility(readColumnVisibility(columnVisibilityStorageKey));
}
useEffect(() => {
writeColumnVisibility(columnVisibilityStorageKey, visibility);
}, [columnVisibilityStorageKey, visibility]);
const table = useReactTable({ const table = useReactTable({
data: [...items], data: [...items],
columns: columns.map((column) => ({ columns: columns.map((column) => ({
@ -83,7 +100,14 @@ export function CursorEntityTable<T extends { id: string }>({
manualPagination: true, manualPagination: true,
state: { state: {
sorting, sorting,
columnVisibility: visibility, columnVisibility: Object.fromEntries(
columns.map((column) => [
column.id,
column.hideable === false || column.id === "actions"
? true
: (visibility[column.id] ?? column.defaultVisible ?? true),
])
),
pagination: { pageIndex: pagination.index, pageSize: pagination.size }, pagination: { pageIndex: pagination.index, pageSize: pagination.size },
columnPinning: { columnPinning: {
right: columns.some((column) => column.id === "actions") ? ["actions"] : [], right: columns.some((column) => column.id === "actions") ? ["actions"] : [],

View File

@ -3,6 +3,7 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { CursorEntityTable } from "./components/cursor-entity-table";
import { EntityTable } from "./components/entity-list"; import { EntityTable } from "./components/entity-list";
import { groupColumnOptions, normalizeColumnSearch } from "./lib/entity-column-configuration"; import { groupColumnOptions, normalizeColumnSearch } from "./lib/entity-column-configuration";
import { import {
@ -265,3 +266,65 @@ test("los contadores distinguen visibilidad, total y coincidencias en es/en", ()
assert.equal(entityListLabels("es").matchingColumns(1), "1 coincidencia"); assert.equal(entityListLabels("es").matchingColumns(1), "1 coincidencia");
assert.equal(entityListLabels("en").matchingColumns(0), "0 matches"); assert.equal(entityListLabels("en").matchingColumns(0), "0 matches");
}); });
test("listado remoto: recupera columnas por ámbito y protege nombre y acciones", () => {
withStorage(() => {
writeColumnVisibility("applications-one", { provider: false, name: false, actions: false });
const render = (key: string) =>
renderToStaticMarkup(
<CursorEntityTable
columns={[
{
id: "name",
label: "Nombre",
hideable: false,
value: () => "Aplicación",
render: () => "Aplicación",
},
{ id: "provider", label: "Proveedor", render: () => "Proveedor de prueba" },
{ id: "actions", label: "Acciones", render: () => "Editar" },
]}
columnVisibilityStorageKey={key}
emptyMessage="Sin aplicaciones"
filters={null}
filtersApplied={false}
items={[{ id: "one" }]}
labels={{
range: "Una aplicación",
page: "Página 1",
previous: "Anterior",
next: "Siguiente",
pageSize: "Tamaño",
clear: "Limpiar",
retry: "Reintentar",
}}
locale="es"
onClearFilters={() => undefined}
onRetry={async () => undefined}
onSearchChange={() => undefined}
onSortingChange={() => undefined}
pagination={{
index: 0,
size: 25,
hasNext: false,
onPrevious: () => undefined,
onNext: () => undefined,
onSizeChange: () => undefined,
}}
search=""
searchLabel="Buscar"
sorting={[{ id: "name", desc: false }]}
state={{ status: "ready" }}
title="Aplicaciones"
/>
);
const restored = render("applications-one");
assert.ok(restored.includes("Aplicación"));
assert.ok(restored.includes("Editar"));
assert.equal(restored.includes("Proveedor de prueba"), false);
assert.ok(render("applications-two").includes("Proveedor de prueba"));
});
});