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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:*",
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
|
|||||||
@ -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>
|
<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",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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"] : [],
|
||||||
|
|||||||
@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user