feat(resources): implement resource list preferences and sorting persistence
This commit is contained in:
parent
0708200161
commit
6992a4ac74
@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@ -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.
|
||||
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,
|
||||
secciones, descripciones y unidades. Filtrar por plantilla o por texto no reduce las
|
||||
columnas disponibles ni reinicia su selección. Las celdas sin valor muestran «—»;
|
||||
secciones, descripciones y unidades. Filtrar por texto no reduce las columnas disponibles. Cada pestaña, incluida Todo,
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Adopción proporcional en catálogos y configuraciones — L04.2
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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": {
|
||||
"@metis/core": "workspace:*",
|
||||
|
||||
@ -7,9 +7,22 @@ import {
|
||||
applicationReferencesApi,
|
||||
} from "../../../application-references/shared/api/application-references-api";
|
||||
import { applicationReferenceKeys } from "../../../application-references/shared/queries/application-reference-keys";
|
||||
import {
|
||||
readResourceListSorting,
|
||||
resourceListStorageKey,
|
||||
writeResourceListSorting,
|
||||
} from "../helpers/resource-list-preferences";
|
||||
|
||||
export function useCompanyApplicationsController(companyId: string) {
|
||||
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 state = searchParams.get("active") ?? "all";
|
||||
const pageIndex = Math.max(0, Number(searchParams.get("page") ?? "1") - 1);
|
||||
@ -17,8 +30,8 @@ export function useCompanyApplicationsController(companyId: string) {
|
||||
const parsed = ListGlobalApplicationsRequestSchema.safeParse({
|
||||
search,
|
||||
...(state === "all" ? {} : { active: state }),
|
||||
sort: searchParams.get("sort") ?? "name",
|
||||
direction: searchParams.get("direction") ?? "asc",
|
||||
sort: searchParams.get("sort") ?? sorting?.sort,
|
||||
direction: searchParams.get("direction") ?? sorting?.direction,
|
||||
pageSize: searchParams.get("pageSize") ?? "25",
|
||||
...(searchParams.get("cursor") ? { cursor: searchParams.get("cursor") } : {}),
|
||||
});
|
||||
@ -60,6 +73,7 @@ export function useCompanyApplicationsController(companyId: string) {
|
||||
};
|
||||
|
||||
return {
|
||||
columnVisibilityStorageKey: `${storageKey}:columns`,
|
||||
query,
|
||||
denied:
|
||||
query.error instanceof ApplicationReferenceApiError &&
|
||||
@ -71,6 +85,10 @@ export function useCompanyApplicationsController(companyId: string) {
|
||||
state,
|
||||
pageIndex,
|
||||
update,
|
||||
changeSorting: (sorting: { sort: string; direction: "asc" | "desc" }) => {
|
||||
writeResourceListSorting(storageKey, sorting);
|
||||
update(sorting);
|
||||
},
|
||||
nextPage: () => {
|
||||
if (query.data?.nextCursor && !query.isPlaceholderData) {
|
||||
setPrevious([...previous, queryData?.cursor]);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -15,6 +15,7 @@ import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { inventoryApi, inventoryKeys } from "../../shared/api";
|
||||
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
|
||||
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 type { ResourcesPageProps } from "../types";
|
||||
|
||||
@ -151,24 +152,13 @@ export function useResourcesPageController({
|
||||
|
||||
const setTemplateId = (value: string) =>
|
||||
setSearchParams((params) => {
|
||||
params.delete("page");
|
||||
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);
|
||||
}
|
||||
applyResourceListTab(params, value === "all" ? null : value);
|
||||
});
|
||||
|
||||
const clearFilters = () =>
|
||||
setSearchParams((params) => {
|
||||
params.delete("page");
|
||||
params.delete("q");
|
||||
params.delete("template");
|
||||
applyResourceListTab(params, null);
|
||||
});
|
||||
|
||||
const denied = resourceListQueryDenied(query.error);
|
||||
@ -188,6 +178,7 @@ export function useResourcesPageController({
|
||||
});
|
||||
|
||||
return {
|
||||
companyId,
|
||||
returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching),
|
||||
canChangeStatus: query.isSuccess && !statusMutation.isPending,
|
||||
deleteId,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -139,6 +139,7 @@ export function CompanyApplicationsList({
|
||||
<p className="text-sm text-muted-foreground">{t("companyApplications.description")}</p>
|
||||
<CursorEntityTable
|
||||
columns={columns}
|
||||
columnVisibilityStorageKey={controller.columnVisibilityStorageKey}
|
||||
emptyMessage={t("applicationSelection.empty")}
|
||||
filters={
|
||||
<SelectWithAction
|
||||
@ -175,7 +176,7 @@ export function CompanyApplicationsList({
|
||||
onRetry={() => query.refetch()}
|
||||
onSearchChange={(q) => controller.update({ q })}
|
||||
onSortingChange={(sorting) =>
|
||||
controller.update({
|
||||
controller.changeSorting({
|
||||
sort: sorting[0]?.id ?? "name",
|
||||
direction: sorting[0]?.desc ? "desc" : "asc",
|
||||
})
|
||||
|
||||
@ -15,6 +15,7 @@ import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resou
|
||||
import type { useResourcesPageController } from "../controllers";
|
||||
import { resourceAdditionalColumns } from "../helpers/resource-additional-columns";
|
||||
import { resourceCharacteristicColumns } from "../helpers/resource-characteristic-columns";
|
||||
import { resourceListStorageKey } from "../helpers/resource-list-preferences";
|
||||
|
||||
export function ResourceInventoryTable({
|
||||
controller,
|
||||
@ -172,6 +173,7 @@ export function ResourceInventoryTable({
|
||||
},
|
||||
]}
|
||||
columnsDisabled={!catalog.data}
|
||||
columnVisibilityStorageKey={`${resourceListStorageKey(controller.companyId, templateId)}:columns`}
|
||||
emptyMessage={t("empty")}
|
||||
filterKey={tabValue}
|
||||
filtersApplied={templateId !== null}
|
||||
|
||||
@ -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 { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status";
|
||||
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 { ResourceEditorDialog } from "../../shared";
|
||||
import { useResourcesPageController } from "../controllers";
|
||||
import { useResourceListNavigation } from "../controllers/use-resource-list-navigation";
|
||||
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
|
||||
import { resourceListQueryState } from "../helpers/resource-list-query-state";
|
||||
import type { ResourcesPageProps } from "../types";
|
||||
@ -61,10 +62,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
const inventoryState = resourceListQueryState(query, "inventory", t);
|
||||
const catalogState = resourceListQueryState(catalog, "catalog", t);
|
||||
const canMutate = query.isSuccess && catalog.isSuccess;
|
||||
const navigation = useListLocation(
|
||||
{ sort: "sort", direction: "direction", page: "page" },
|
||||
canMutate
|
||||
);
|
||||
const navigation = useResourceListNavigation(companyId, templateId, canMutate);
|
||||
|
||||
useListReturn({
|
||||
scope: `/companies/${companyId}/resources`,
|
||||
|
||||
@ -3,8 +3,12 @@ import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
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 { EntityColumn } from "./entity-list";
|
||||
@ -17,6 +21,7 @@ import { SelectWithAction } from "./select-with-action";
|
||||
export function CursorEntityTable<T extends { id: string }>({
|
||||
items,
|
||||
columns,
|
||||
columnVisibilityStorageKey,
|
||||
title,
|
||||
locale,
|
||||
search,
|
||||
@ -35,6 +40,7 @@ export function CursorEntityTable<T extends { id: string }>({
|
||||
}: {
|
||||
items: readonly T[];
|
||||
columns: readonly EntityColumn<T>[];
|
||||
columnVisibilityStorageKey?: string | undefined;
|
||||
title: string;
|
||||
locale: string;
|
||||
search: string;
|
||||
@ -68,7 +74,18 @@ export function CursorEntityTable<T extends { id: string }>({
|
||||
}) {
|
||||
const prefix = useId();
|
||||
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({
|
||||
data: [...items],
|
||||
columns: columns.map((column) => ({
|
||||
@ -83,7 +100,14 @@ export function CursorEntityTable<T extends { id: string }>({
|
||||
manualPagination: true,
|
||||
state: {
|
||||
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 },
|
||||
columnPinning: {
|
||||
right: columns.some((column) => column.id === "actions") ? ["actions"] : [],
|
||||
|
||||
@ -3,6 +3,7 @@ import { test } from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CursorEntityTable } from "./components/cursor-entity-table";
|
||||
import { EntityTable } from "./components/entity-list";
|
||||
import { groupColumnOptions, normalizeColumnSearch } from "./lib/entity-column-configuration";
|
||||
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("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"));
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user