L03.1: Persistencia y restauración del contexto de listados (V05)

This commit is contained in:
David Arranz 2026-10-22 12:40:15 +02:00
parent 9fc7d3a2e0
commit 79dfa59936
30 changed files with 1147 additions and 68 deletions

View File

@ -162,9 +162,9 @@ Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md)
4. Tabla semántica; valores ausentes «—»; acciones fijadas a la derecha. Acción principal mediante icono, tooltip y nombre; secundarias en menú de tres puntos, sin duplicar la principal.
5. Rango repetido y paginación; filtros/tamaño ajustan página válida. No confundir cero resultados filtrados con falta de registros.
[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table, orden, visibilidad y paginación **locales**. Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente.
[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table y visibilidad; orden/página son locales por defecto y controlables en los pilotos de [L03.1](docs/contexto-listados-l03-1.md). Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente.
Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado.
Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. En Activos, Recursos y Personas, [L03.1](docs/contexto-listados-l03-1.md) conserva filtros, sort y página en URL, y scroll/foco de retorno en History. Los demás consumidores mantienen su contrato; V05 no se declara universalmente resuelto. Cancelar un modal debe mantener contexto del listado.
Familias por plantilla usan `ScrollableTabsList`; `min-w-0` y scroll horizontal quedan dentro del listado. `onRowClick` solo selecciona desde zonas no interactivas, conservando enlaces, menús y controles internos. No añadir selección múltiple sin operación consumidora.
@ -269,7 +269,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| V02 | Resuelto: combinación de tokens/estados del inventario claro | Contraste y foco comprobados; no equivale a validar todos los temas o tablas |
| V03 | Resuelto: adopción de patrón y pie estable | Tres pasos de activos/recursos verificados en los cuatro tamaños del sprint |
| V04 | Diseño de ayuda/progreso compacto + componentes | Acordar presupuesto de altura sin perder ayuda junto al pie; verificar 320×640 |
| V05 | Estado/navegación de implementación | Definir restauración completa por vista; orden reproducido, página de activos pendiente por datos |
| V05 | Resuelto en los pilotos y escenarios de L03.1; pendiente en otros consumidores | URL validada, preferencias existentes y retorno de scroll/foco; véase [alcance](docs/contexto-listados-l03-1.md) |
| V06 | Resuelto: integración de foco del inventario | Ratón/Enter, Escape/Cancelar, cabecera/fila, alternativa y selector anidado verificados |
| V07 | Contrato de búsqueda y consumidores | Acordar normalización por idioma y probar tildes/ñ; no confundir con ordenación |
| V08 | Resuelto en los escenarios verificados de L02.2 | Búsqueda, grupos opcionales, contador y restauración con preferencias compatibles |

View File

@ -32,17 +32,29 @@ export function AssetDetailPage({
const c = useAssetDetailPageController(companyId, assetId, backHref);
const { asset } = c;
const template = c.catalog.data?.items.find((item) => item.id === asset?.values.templateId);
const backLink = (
<BrowserLink
className="inline-flex items-center gap-1 text-sm"
href={backHref}
navigation="return"
>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("assetDetail.back")}
</BrowserLink>
);
if (c.inventory.isSuccess && !asset) {
return <EntityNotFound />;
return (
<div className="space-y-5">
{backLink}
<EntityNotFound />
</div>
);
}
return (
<div className="@container min-w-0 space-y-5">
<BrowserLink className="inline-flex items-center gap-1 text-sm" href={backHref}>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("assetDetail.back")}
</BrowserLink>
{backLink}
{c.inventory.isPending && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={c.inventory.error ?? c.catalog.error} />
{(c.inventory.isError || c.catalog.isError) && (

View File

@ -1,7 +1,7 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useContextualDetail } from "@repo/rdx-ui/hooks/use-contextual-detail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import {
@ -41,8 +41,26 @@ export function useAssetInventoryPageController({
? requestedTemplateId
: null;
useEffect(() => {
if (
catalog.isSuccess &&
requestedTemplateId !== null &&
(templateId === null || searchParams.getAll("template").length > 1)
) {
setSearchParams((params) => {
params.delete("page");
if (templateId) {
params.set("template", templateId);
} else {
params.delete("template");
}
});
}
}, [catalog.isSuccess, requestedTemplateId, templateId, searchParams, setSearchParams]);
const setTemplateId = (value: string) =>
setSearchParams((params) => {
params.delete("page");
if (value === "all") {
params.delete("template");
} else {
@ -52,12 +70,14 @@ export function useAssetInventoryPageController({
const clearFilters = () =>
setSearchParams((params) => {
params.delete("page");
params.delete("q");
params.delete("template");
});
const setSearch = (value: string) =>
setSearchParams((params) => {
params.delete("page");
if (value) {
params.set("q", value);
} else {
@ -107,6 +127,7 @@ export function useAssetInventoryPageController({
const deleted = assets.find((asset) => asset.id === deleteId);
return {
returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching),
assets,
denied,
query,

View File

@ -1,4 +1,10 @@
import { BrowserLink, EntityNotFound, navigateTo } from "@metis/core/client";
import {
BrowserLink,
EntityNotFound,
navigateTo,
useListLocation,
useListReturn,
} from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -35,6 +41,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
const { companyId, lookups } = props;
const {
query,
returnReady,
catalog,
infrastructure,
assets,
@ -68,6 +75,17 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
const catalogState = assetListQueryState(catalog, "catalog", t);
const infrastructureState = assetListQueryState(infrastructure, "infrastructure", t);
const canMutate = query.isSuccess && catalog.isSuccess;
const navigation = useListLocation(
{ sort: "sort", direction: "direction", page: "page" },
canMutate
);
useListReturn({
scope: `/companies/${companyId}/assets`,
rootRef: inventoryRef,
ready: returnReady,
recordFocus: query.isSuccess,
});
useLayoutEffect(() => {
if (missingView && document.activeElement === document.body) {
@ -235,6 +253,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
},
]}
infrastructure={infrastructure.data?.items ?? []}
navigation={navigation}
onClearFilters={clearFilters}
onRetry={denied ? undefined : () => query.refetch()}
onRowClick={(asset) => selectAsset(asset.id)}

View File

@ -2,6 +2,7 @@ import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state";
import type { EntityTableNavigation } from "@repo/rdx-ui/lib/entity-table-navigation";
import { Skeleton } from "@repo/shadcn-ui/components/skeleton";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import type { ReactNode } from "react";
@ -38,6 +39,7 @@ export function AssetList({
loading = false,
unavailable = false,
state,
navigation,
catalogState,
onRetry,
}: {
@ -60,6 +62,7 @@ export function AssetList({
loading?: boolean;
unavailable?: boolean;
state?: EntityListState | undefined;
navigation?: EntityTableNavigation | undefined;
catalogState?: EntityListState;
onRetry?: (() => Promise<unknown>) | undefined;
}) {
@ -235,6 +238,7 @@ export function AssetList({
layout={layout}
loading={loading}
locale={i18n.language}
navigation={navigation}
onClearFilters={onClearFilters}
onRetry={onRetry}
onRowClick={onRowClick}

View File

@ -10,6 +10,7 @@ import {
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { PeopleListDataState } from "../../../people";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
import { companyKeys } from "../../shared/queries";
import type { CompanyTab } from "../../shared/types";
@ -54,5 +55,11 @@ export function useCompanyDetailController({
profiles: { count: profiles.data?.profiles.length, failed: profiles.isError },
};
return { query, editOpen, setEditOpen, tab, setTab, company, counts };
const peopleDataState: PeopleListDataState = query.isFetching
? "pending"
: query.isError
? "unverified"
: "ready";
return { peopleDataState, query, editOpen, setEditOpen, tab, setTab, company, counts };
}

View File

@ -25,7 +25,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
initialCreate,
initialConfiguration,
} = props;
const { query, editOpen, setEditOpen, tab, setTab, company, counts } =
const { query, editOpen, setEditOpen, tab, setTab, company, counts, peopleDataState } =
useCompanyDetailController(props);
const { t } = useTranslation("companies");
const navigation = useContext(WorkspaceMobileNavigationContext);
@ -128,6 +128,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
company={company}
embedded={embedded}
initialConfiguration={initialConfiguration}
peopleDataState={peopleDataState}
{...initialValues}
key={tab}
tab={tab}

View File

@ -1,6 +1,7 @@
import { AssetCatalogPage, InfrastructurePage } from "@metis/assets/client";
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import type { ComponentProps } from "react";
import type { CompanyDTO } from "../../../../common";
import { AccessWorkspacePage } from "../../../access";
@ -29,9 +30,11 @@ export function CompanySection({
initialEntityId,
initialCreate,
initialConfiguration,
peopleDataState,
}: {
company: CompanyDTO;
tab: CompanyTab;
peopleDataState?: ComponentProps<typeof PeopleSection>["dataState"];
initialEntityId?: string | undefined;
initialCreate?: boolean | undefined;
initialConfiguration?:
@ -61,7 +64,9 @@ export function CompanySection({
case "telework":
return <TeleworkAuthorizationsPage company={company} initialPersonId={personId} />;
case "people":
return <PeopleSection company={company} initialId={initialEntityId} />;
return (
<PeopleSection company={company} dataState={peopleDataState} initialId={initialEntityId} />
);
case "departments":
return <DepartmentSection company={company} />;
case "sites":

View File

@ -1,3 +1,3 @@
export type { PeopleListDataState } from "./list";
export { PeopleSection } from "./list";
export { PersonEditorDialog, PersonForm, PersonAvatarGroup } from "./shared";
export { PersonAvatarGroup, PersonEditorDialog, PersonForm } from "./shared";

View File

@ -0,0 +1,119 @@
import {
navigateTo,
returnToList,
useBrowserSearchParams,
useEntityNavigation,
useListLocation,
useListReturn,
} from "@metis/core/client";
import { useEffect, useRef, useState } from "react";
export type PeopleListDataState = "pending" | "ready" | "unverified";
/** Consulta navegable de Personas y fallback local de los consumidores embebidos. */
export function usePeopleListContext(
companyId: string,
initialId: string | undefined,
dataState: PeopleListDataState
) {
const {
selectedId,
selectEntity,
entityHref: baseEntityHref,
routed,
} = useEntityNavigation(`/companies/${companyId}/people`, initialId);
const [localSearch, setLocalSearch] = useState("");
const [localContactsOnly, setLocalContactsOnly] = useState(false);
const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = routed ? (searchParams.get("q") ?? "") : localSearch;
const contactsOnly = routed ? searchParams.get("contactsOnly") === "true" : localContactsOnly;
const rootRef = useRef<HTMLDivElement>(null);
const navigation = useListLocation(
{ sort: "sort", direction: "direction", page: "page" },
dataState === "ready"
);
useListReturn({
scope: `/companies/${companyId}/people`,
rootRef,
ready: dataState !== "pending",
recordFocus: dataState === "ready",
});
const entityHref = (id: string) =>
`${baseEntityHref(id)}${searchParams.size ? `?${searchParams.toString()}` : ""}`;
function setSearch(value: string) {
if (!routed) {
setLocalSearch(value);
return;
}
setSearchParams((params) => {
params.delete("page");
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
}
function setContactsOnly(value: boolean) {
if (!routed) {
setLocalContactsOnly(value);
return;
}
setSearchParams((params) => {
params.delete("page");
if (value) {
params.set("contactsOnly", "true");
} else {
params.delete("contactsOnly");
}
});
}
useEffect(() => {
if (
routed &&
searchParams.has("contactsOnly") &&
(searchParams.get("contactsOnly") !== "true" ||
searchParams.getAll("contactsOnly").length > 1)
) {
setSearchParams((params) => {
if (contactsOnly) {
params.set("contactsOnly", "true");
} else {
params.delete("contactsOnly");
}
});
}
}, [routed, searchParams, contactsOnly, setSearchParams]);
function selectPerson(id: string | undefined) {
if (!routed) {
selectEntity(id);
} else if (id) {
navigateTo(entityHref(id));
} else {
returnToList(
`/companies/${companyId}/people${searchParams.size ? `?${searchParams.toString()}` : ""}`
);
}
}
return {
selectedId,
entityHref,
routed,
search,
setSearch,
contactsOnly,
setContactsOnly,
rootRef,
navigation,
selectPerson,
};
}

View File

@ -1 +1,2 @@
export type { PeopleListDataState } from "./controllers/use-people-list-context";
export { PeopleSection } from "./ui";

View File

@ -1,11 +1,5 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import {
BrowserLink,
EntityNotFound,
navigateTo,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { BrowserLink, EntityNotFound, navigateTo, useListQuery } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -25,13 +19,17 @@ import type { CompanyDTO } from "../../../../common";
import { personWorkstations } from "../../../workstations/detail/helpers/person-workstations";
import { PersonDetails } from "../../detail";
import { PersonAvatar, PersonEditorDialog } from "../../shared";
import type { PeopleListDataState } from "../controllers/use-people-list-context";
import { usePeopleListContext } from "../controllers/use-people-list-context";
export function PeopleSection({
company,
initialId,
dataState = "ready",
}: {
company: CompanyDTO;
initialId?: string | undefined;
dataState?: PeopleListDataState | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
@ -39,12 +37,18 @@ export function PeopleSection({
const [selection, setSelection] = useState<
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
>(initialId ? { mode: "detail", id: initialId } : undefined);
const { selectedId, selectEntity, entityHref, routed } = useEntityNavigation(
`/companies/${company.id}/people`,
initialId
);
const [search, setSearch] = useState("");
const [contactsOnly, setContactsOnly] = useState(false);
const {
selectedId,
entityHref,
routed,
search,
setSearch,
contactsOnly,
setContactsOnly,
rootRef,
navigation,
selectPerson,
} = usePeopleListContext(company.id, initialId, dataState);
const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
@ -65,7 +69,7 @@ export function PeopleSection({
}}
onSaved={(id) => {
setSelection({ mode: "detail", id });
selectEntity(id);
selectPerson(id);
}}
open={selection?.mode === "new" || selection?.mode === "edit"}
person={selected}
@ -84,7 +88,7 @@ export function PeopleSection({
key={selected.id}
onBack={() => {
setSelection(undefined);
selectEntity(undefined);
selectPerson(undefined);
}}
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
person={selected}
@ -99,7 +103,7 @@ export function PeopleSection({
);
return (
<div className="space-y-4">
<div className="space-y-4" data-slot="list-context" ref={rootRef}>
{personEditor}
<EntityListHeader
actions={
@ -253,7 +257,7 @@ export function PeopleSection({
icon: <EyeIcon aria-hidden="true" />,
onClick: () => {
setSelection({ mode: "detail", id: person.id });
selectEntity(person.id);
selectPerson(person.id);
},
}}
>
@ -287,6 +291,7 @@ export function PeopleSection({
filtersApplied={contactsOnly}
items={people}
locale={i18n.language}
navigation={routed ? navigation : undefined}
onClearFilters={() => setContactsOnly(false)}
onSearchChange={setSearch}
search={search}

View File

@ -59,9 +59,12 @@ const RouteQuerySchema = z.strictObject({
activeOnly: z.enum(["true", "false"]).optional(),
archived: z.enum(["true", "false"]).optional(),
closed: z.enum(["true", "false"]).optional(),
contactsOnly: z.enum(["true", "false"]).optional(),
contactsOnly: z.string().max(200).optional(),
q: z.string().max(200).optional(),
template: z.uuid().optional(),
template: z.string().max(200).optional(),
sort: z.string().max(200).optional(),
direction: z.string().max(20).optional(),
page: z.string().max(100).optional(),
tab: z.enum(["templates", "fields", "relations"]).optional(),
kind: AccessKindSchema.optional(),
environmentId: z.uuid().optional(),
@ -119,7 +122,14 @@ export function parseWorkspaceUrl(href: string): WorkspaceLocation {
const raw = Object.fromEntries(url.searchParams);
const query = RouteQuerySchema.safeParse(raw);
if (!query.success || Object.keys(raw).some((key) => url.searchParams.getAll(key).length !== 1)) {
if (
!query.success ||
Object.keys(raw).some(
(key) =>
!["sort", "direction", "page", "template", "contactsOnly"].includes(key) &&
url.searchParams.getAll(key).length !== 1
)
) {
return { area: "invalid-route" };
}
const location = parseWorkspacePath(url.pathname.replace(/\/+$/u, ""));
@ -149,7 +159,7 @@ function queryMatchesLocation(
return false;
}
if (
query.template &&
query.template !== undefined &&
!(
location.area === "company" &&
(location.section === "inventory" || location.section === "resources")

View File

@ -68,14 +68,8 @@ test("el filtro de plantilla permite abrir listados y fichas de activos y recurs
);
});
test("el filtro de plantilla rechaza UUID inválidos, parámetros repetidos y otros ámbitos", () => {
test("el filtro de plantilla se limita a los inventarios que conocen sus definiciones", () => {
for (const href of [
`/companies/${COMPANY_ID}/assets?template=invalid`,
`/companies/${COMPANY_ID}/assets?template=`,
`/companies/${COMPANY_ID}/assets?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/resources?template=invalid`,
`/companies/${COMPANY_ID}/resources?template=`,
`/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/catalogs/resources?template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/catalogs/assets?template=${TEMPLATE_ID}`,
`/catalogs/assets?template=${TEMPLATE_ID}`,
@ -212,3 +206,17 @@ test("dominios: enlaces a cuentas y grupos limitados al entorno de la compañía
assert.equal(parseWorkspaceUrl(href).area, "invalid-route");
}
});
test("los parámetros de contexto llegan al consumidor sin crear rutas nuevas ni aceptar claves ajenas", () => {
for (const section of ["assets", "resources", "people"]) {
const base = `/companies/${COMPANY_ID}/${section}`;
assert.equal(parseWorkspaceUrl(`${base}?sort=name&direction=desc&page=3`).area, "company");
assert.equal(
parseWorkspaceUrl(`${base}?sort=missing&direction=invalid&page=-5`).area,
"company"
);
assert.equal(parseWorkspaceUrl(`${base}?page=3&page=4`).area, "company");
assert.equal(parseWorkspaceUrl(`${base}?unknown=value`).area, "invalid-route");
}
});

View File

@ -13,9 +13,12 @@ export {
isCreateNavigation,
isInternalPath,
navigateTo,
returnToList,
useBrowserLocation,
useBrowserSearchParams,
useCreateNavigation,
useEntityNavigation,
useListLocation,
useListReturn,
} from "./navigation";
export { HomePage } from "./workspace";

View File

@ -5,14 +5,12 @@ export {
isCreateNavigation,
isInternalPath,
navigateTo,
returnToList,
} from "./shared/helpers/browser-navigation";
export { useBrowserLocation, useBrowserSearchParams } from "./shared/hooks/use-browser-location";
export { useCreateNavigation } from "./shared/hooks/use-create-navigation";
export { useEntityNavigation } from "./shared/hooks/use-entity-navigation";
export { useListLocation } from "./shared/hooks/use-list-location";
export { useListReturn } from "./shared/hooks/use-list-return";
export { BrowserLink } from "./shared/ui/browser-link";
export { EntityNotFound } from "./shared/ui/entity-not-found";

View File

@ -8,8 +8,11 @@ import {
isCreateNavigation,
isInternalPath,
navigateTo,
returnToList,
subscribeBeforeNavigation,
subscribeToNavigation,
} from "./helpers/browser-navigation";
import { readListReturnContext } from "./helpers/list-return-context";
class BrowserFixture extends EventTarget {
location = new URL("http://localhost/dashboard");
@ -110,3 +113,104 @@ test("las redirecciones internas rechazan destinos externos, barras invertidas y
}
assert.equal(isInternalPath("/companies/019?tab=fields"), true);
});
test("el retorno reutiliza la entrada exacta del listado y conserva el estado ajeno", () => {
const browser = new BrowserFixture();
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { value: browser, configurable: true });
const captured: string[] = [];
const unsubscribe = subscribeBeforeNavigation(() => captured.push(getBrowserLocation()));
try {
navigateTo("/companies/a/assets?sort=name&direction=desc&page=3");
browser.history.replaceState({ foreign: { value: 7 } }, "", getBrowserLocation());
navigateTo("/companies/a/assets?sort=name&direction=desc&page=4", { replace: true });
assert.deepEqual(browser.history.state, { foreign: { value: 7 } });
const list = getBrowserLocation();
navigateTo("/companies/a/assets/id?sort=name&direction=desc&page=4");
assert.equal(captured.at(-1), list);
returnToList(list);
assert.equal(getBrowserLocation(), list);
assert.deepEqual(browser.history.state, { foreign: { value: 7 } });
browser.history.forward();
browser.history.replaceState(null, "", getBrowserLocation());
returnToList(list);
assert.equal(getBrowserLocation(), list);
} finally {
unsubscribe();
if (descriptor) {
Object.defineProperty(globalThis, "window", descriptor);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
test("el contexto de retorno valida versión, coordenadas, ámbito y filtros sin guardar registros", () => {
const value = {
version: 1,
scope: "/companies/a/assets",
location: "/companies/a/assets?q=equipo&page=3",
x: 40,
y: 150,
focus: { row: "asset-id", href: "/companies/a/assets/asset-id" },
};
assert.deepEqual(
readListReturnContext(
{ foreign: 42, metisListReturn: value },
value.scope,
"/companies/a/assets?q=equipo&page=2"
),
value
);
assert.equal(
readListReturnContext({ metisListReturn: value }, "/companies/b/assets", value.location),
undefined
);
assert.equal(
readListReturnContext({ metisListReturn: value }, value.scope, "/companies/a/assets?q=otro"),
undefined
);
for (const invalid of [
{ ...value, version: 2 },
{ ...value, x: -1 },
{ ...value, y: Number.POSITIVE_INFINITY },
{ ...value, location: "http://[" },
]) {
assert.equal(
readListReturnContext({ metisListReturn: invalid }, value.scope, value.location),
undefined
);
}
assert.equal(readListReturnContext(null, value.scope, value.location), undefined);
});
test("las consultas Unicode válidas no pierden contexto al superar 2000 caracteres codificados", () => {
const id = "019b8838-44af-776d-a406-af74452b6f26";
const scope = `/companies/${id}/assets`;
const params = new URLSearchParams({
q: "界".repeat(200),
template: id,
personId: id,
profileId: id,
companyId: id,
sort: `characteristic-${id}`,
direction: "desc",
page: "3",
});
const location = `${scope}?${params.toString()}`;
const value = {
version: 1,
scope,
location,
x: 40,
y: 150,
focus: { row: id, href: `${scope}/${id}?${params.toString()}` },
};
assert.ok(location.length > 2000);
assert.deepEqual(readListReturnContext({ metisListReturn: value }, scope, location), value);
});

View File

@ -1,4 +1,11 @@
const NAVIGATION_EVENT = "metis:navigation";
const BEFORE_NAVIGATION_EVENT = "metis:before-navigation";
export function subscribeBeforeNavigation(listener: () => void): () => void {
window.addEventListener(BEFORE_NAVIGATION_EVENT, listener);
return () => window.removeEventListener(BEFORE_NAVIGATION_EVENT, listener);
}
export function isInternalPath(value: string): boolean {
return (
@ -37,6 +44,9 @@ export function navigateTo(href: string, options: { replace?: boolean; create?:
if (previous === href && !options.create) {
return;
}
if (!options.replace) {
window.dispatchEvent(new Event(BEFORE_NAVIGATION_EVENT));
}
if (options.replace) {
window.history.replaceState(window.history.state, "", href);
} else {
@ -45,6 +55,22 @@ export function navigateTo(href: string, options: { replace?: boolean; create?:
window.dispatchEvent(new Event(NAVIGATION_EVENT));
}
/** Regresa a la entrada del listado solo si coincide con el destino; una URL directa usa su fallback. */
export function returnToList(href: string) {
const state: unknown = typeof window === "undefined" ? null : window.history.state;
if (
typeof state === "object" &&
state !== null &&
"previous" in state &&
state.previous === href
) {
closeRoute(href);
} else {
navigateTo(href, { replace: true });
}
}
export function closeRoute(fallback: string) {
if (typeof window !== "undefined") {
const state: unknown = window.history.state;

View File

@ -0,0 +1,107 @@
import { z } from "zod/v4";
import { getBrowserLocation, isInternalPath } from "./browser-navigation";
const ListReturnSchema = z.object({
version: z.literal(1),
scope: z.string().max(500).refine(isInternalPath),
location: z.string().max(8192).refine(isInternalPath),
x: z.number().finite().nonnegative().max(10_000_000),
y: z.number().finite().nonnegative().max(10_000_000),
focus: z.object({ row: z.string().max(200), href: z.string().max(8192).nullable() }).nullable(),
});
export type ListReturnContext = z.infer<typeof ListReturnSchema>;
export function listReturnLocation(location: string): string {
const url = new URL(location, "http://metis.invalid");
for (const key of ["sort", "direction", "page"]) {
url.searchParams.delete(key);
}
url.searchParams.sort();
return url.pathname + url.search;
}
export function readListReturnContext(state: unknown, scope: string, location: string) {
const value =
typeof state === "object" && state !== null && "metisListReturn" in state
? state.metisListReturn
: undefined;
const parsed = ListReturnSchema.safeParse(value);
return parsed.success &&
parsed.data.scope === scope &&
listReturnLocation(parsed.data.location) === listReturnLocation(location)
? parsed.data
: undefined;
}
export function captureListReturn(root: HTMLElement, scope: string) {
const scroller = root.closest<HTMLElement>('[data-slot="workspace-scroll"]');
const horizontal = root.querySelector<HTMLElement>('[data-slot="table-container"]');
const active = document.activeElement;
const row = active?.closest<HTMLElement>("[data-entity-id]");
const state: unknown = window.history.state;
const previous = typeof state === "object" && state !== null ? state : {};
const saved = readListReturnContext(state, scope, getBrowserLocation());
const context: ListReturnContext = {
version: 1,
scope,
location: getBrowserLocation(),
x: horizontal?.scrollLeft ?? 0,
y: scroller?.scrollTop ?? 0,
focus:
row && root.contains(row) && row.dataset.entityId
? { row: row.dataset.entityId, href: active?.getAttribute("href") ?? null }
: root.contains(active)
? null
: (saved?.focus ?? null),
};
window.history.replaceState({ ...previous, metisListReturn: context }, "", getBrowserLocation());
}
export function restoreListReturn(
root: HTMLElement,
context: ListReturnContext | undefined,
recordFocus: boolean
) {
const scroller = root.closest<HTMLElement>('[data-slot="workspace-scroll"]');
const horizontal = root.querySelector<HTMLElement>('[data-slot="table-container"]');
if (scroller) {
scroller.scrollTop = Math.min(context?.y ?? 0, scroller.scrollHeight - scroller.clientHeight);
}
if (horizontal) {
horizontal.scrollLeft = Math.min(
context?.x ?? 0,
horizontal.scrollWidth - horizontal.clientWidth
);
}
const modal = [
...document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
].some((element) => element.getClientRects().length > 0);
if (modal) {
return;
}
const focus = context?.focus;
const row =
recordFocus && focus
? root.querySelector<HTMLElement>(`[data-entity-id="${CSS.escape(focus.row)}"]`)
: null;
const original =
(focus?.href ? row?.querySelector<HTMLElement>(`a[href="${CSS.escape(focus.href)}"]`) : null) ??
row?.querySelector<HTMLElement>("a, button:enabled");
const fallback = context
? root.querySelector<HTMLElement>("input[data-quick-search]")
: root.querySelector<HTMLElement>("h1, h2");
const target = original ?? fallback ?? root;
if (!target.matches("input, a, button, [tabindex]")) {
target.tabIndex = -1;
}
target.focus({ preventScroll: true });
}

View File

@ -0,0 +1,41 @@
import { useBrowserSearchParams } from "./use-browser-location";
/** Enlaza parámetros elegidos por el consumidor; no conoce columnas, datos ni TanStack Table. */
export function useListLocation(
parameters: { sort: string; direction: string; page: string },
ready: boolean
) {
const { searchParams, setSearchParams } = useBrowserSearchParams();
const read = (key: string) => (searchParams.getAll(key).length > 1 ? "" : searchParams.get(key));
return {
ready,
values: {
sort: read(parameters.sort),
direction: read(parameters.direction),
page: read(parameters.page),
},
onChange: (value: {
sort: string | undefined;
direction: "asc" | "desc" | undefined;
page: number;
}) => {
setSearchParams((params) => {
const changes: readonly [string, string | undefined][] = [
[parameters.sort, value.sort],
[parameters.direction, value.direction],
[parameters.page, value.page > 1 ? String(value.page) : undefined],
];
for (const [key, next] of changes) {
if (next) {
params.set(key, next);
} else {
params.delete(key);
}
}
});
},
};
}

View File

@ -0,0 +1,122 @@
import type { RefObject } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { getBrowserLocation, subscribeBeforeNavigation } from "../helpers/browser-navigation";
import {
captureListReturn,
readListReturnContext,
restoreListReturn,
} from "../helpers/list-return-context";
import { useBrowserLocation } from "./use-browser-location";
/** Guarda exclusivamente coordenadas e identidad de foco por entrada de historial, nunca datos remotos. */
export function useListReturn({
scope,
rootRef,
ready,
recordFocus = true,
contextual = false,
}: {
scope: string;
rootRef: RefObject<HTMLElement | null>;
ready: boolean;
recordFocus?: boolean;
contextual?: boolean;
}) {
const location = useBrowserLocation();
const applied = useRef(false);
const pathname = location.split("?")[0];
const active = pathname === scope || (contextual && pathname?.startsWith(`${scope}/`));
useEffect(
() =>
subscribeBeforeNavigation(() => {
const currentPath = getBrowserLocation().split("?")[0];
if (
rootRef.current &&
(currentPath === scope || (contextual && currentPath?.startsWith(`${scope}/`)))
) {
captureListReturn(rootRef.current, scope);
}
}),
[rootRef, scope, contextual]
);
useLayoutEffect(() => {
if (!(active || rootRef.current)) {
applied.current = false;
}
if (!(active && ready) || applied.current || !rootRef.current) {
return;
}
const root = rootRef.current;
let cancelled = false;
let frame = 0;
const context = readListReturnContext(window.history.state, scope, location);
// Las fuentes y el siguiente layout son señales reales; no hay esperas temporales arbitrarias.
document.fonts.ready
.then(() => {
if (cancelled) {
return;
}
frame = requestAnimationFrame(() => {
if (cancelled || !root.isConnected) {
return;
}
applied.current = true;
if (document.activeElement !== root && root.contains(document.activeElement)) {
return;
}
restoreListReturn(root, context, recordFocus);
});
})
.catch(() => {
// Un fallo de fuentes no debe impedir orientar al usuario con la geometría disponible.
if (!cancelled && root.isConnected) {
applied.current = true;
restoreListReturn(root, context, recordFocus);
}
});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [active, ready, rootRef, scope, location, recordFocus]);
useEffect(() => {
const root = rootRef.current;
if (!(active && ready && root)) {
return;
}
const capture = () => {
const current = getBrowserLocation().split("?")[0];
if (
root.isConnected &&
(current === scope || (contextual && current?.startsWith(`${scope}/`)))
) {
captureListReturn(root, scope);
}
};
const vertical = root.closest('[data-slot="workspace-scroll"]');
const horizontal = root.querySelector('[data-slot="table-container"]');
// También cubre Atrás/Adelante nativos: el navegador cambia de entrada antes de emitir popstate.
vertical?.addEventListener("scrollend", capture);
horizontal?.addEventListener("scrollend", capture);
root.addEventListener("focusin", capture);
return () => {
vertical?.removeEventListener("scrollend", capture);
horizontal?.removeEventListener("scrollend", capture);
root.removeEventListener("focusin", capture);
};
}, [active, ready, rootRef, scope, contextual]);
}

View File

@ -1,13 +1,14 @@
import type { ComponentProps } from "react";
import { isInternalPath, navigateTo } from "../helpers/browser-navigation";
import { isInternalPath, navigateTo, returnToList } from "../helpers/browser-navigation";
export function BrowserLink({
href,
onClick,
className,
navigation = "push",
...props
}: ComponentProps<"a"> & { href: string }) {
}: ComponentProps<"a"> & { href: string; navigation?: "push" | "return" }) {
return (
<a
{...props}
@ -29,7 +30,11 @@ export function BrowserLink({
return;
}
event.preventDefault();
navigateTo(href);
if (navigation === "return") {
returnToList(href);
} else {
navigateTo(href);
}
}}
/>
);

View File

@ -1,4 +1,5 @@
import {
closeRoute,
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
@ -6,7 +7,7 @@ import {
} from "@metis/core/client";
import { useContextualDetail } from "@repo/rdx-ui/hooks/use-contextual-detail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ResourceDTO } from "../../../../common/inventory";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
@ -30,6 +31,7 @@ export function useResourcesPageController({
const setSearch = (value: string) =>
setSearchParams((params) => {
params.delete("page");
if (value) {
params.set("q", value);
} else {
@ -95,7 +97,9 @@ export function useResourcesPageController({
detail.restoreFocus();
} else if (routed) {
// El cierre voluntario compacto conserva el contrato de navegación de Recursos.
selectEntity(undefined);
closeRoute(
`/companies/${companyId}/resources${searchParams.size ? `?${searchParams.toString()}` : ""}`
);
detail.restoreFocus();
} else {
detail.setVisible(false);
@ -121,8 +125,26 @@ export function useResourcesPageController({
? requestedTemplateId
: null;
useEffect(() => {
if (
catalog.isSuccess &&
requestedTemplateId !== null &&
(templateId === null || searchParams.getAll("template").length > 1)
) {
setSearchParams((params) => {
params.delete("page");
if (templateId) {
params.set("template", templateId);
} else {
params.delete("template");
}
});
}
}, [catalog.isSuccess, requestedTemplateId, templateId, searchParams, setSearchParams]);
const setTemplateId = (value: string) =>
setSearchParams((params) => {
params.delete("page");
if (value === "all") {
params.delete("template");
} else {
@ -132,6 +154,7 @@ export function useResourcesPageController({
const clearFilters = () =>
setSearchParams((params) => {
params.delete("page");
params.delete("q");
params.delete("template");
});
@ -154,6 +177,7 @@ export function useResourcesPageController({
});
return {
returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching),
canChangeStatus: query.isSuccess && !statusMutation.isPending,
deleteId,
setDeleteId,

View File

@ -1,4 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { BrowserLink, EntityNotFound, useListLocation, useListReturn } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -37,6 +37,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
openEditor,
setViewId,
query,
returnReady,
catalog,
templates,
templateId,
@ -63,6 +64,18 @@ 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
);
useListReturn({
scope: `/companies/${companyId}/resources`,
rootRef: detail.containerRef,
ready: returnReady,
recordFocus: query.isSuccess,
contextual: true,
});
const tabValue = templateId ?? "all";
const viewedId = viewed?.id;
const previousViewedIdRef = useRef(viewedId);
@ -273,6 +286,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
filtersApplied={templateId !== null}
items={visible}
locale={i18n.language}
navigation={navigation}
onClearFilters={clearFilters}
onRetry={denied ? undefined : () => query.refetch()}
onRowClick={(item) => selectResource(item.id)}

View File

@ -8,6 +8,7 @@ import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-colu
import { entityListLabels } from "../lib/entity-list";
import type { EntityListState } from "../lib/entity-list-state";
import { entityTableAvailability } from "../lib/entity-list-state";
import type { EntityTableNavigation } from "../lib/entity-table-navigation";
import { EntityListPagination } from "./entity-list-controls";
import { EntityListStatus } from "./entity-list-status";
@ -79,6 +80,7 @@ export function EntityTable<T extends { id: string }>({
columnVisibilityStorageKey,
columnGroups,
state,
navigation,
onRetry,
columnsDisabled,
loading = false,
@ -103,6 +105,7 @@ export function EntityTable<T extends { id: string }>({
columnVisibilityStorageKey?: string | undefined;
columnGroups?: readonly EntityColumnGroup[];
state?: EntityListState | undefined;
navigation?: EntityTableNavigation | undefined;
onRetry?: (() => Promise<unknown>) | undefined;
columnsDisabled?: boolean | undefined;
loading?: boolean;
@ -127,6 +130,7 @@ export function EntityTable<T extends { id: string }>({
onSearchChange,
filterKey,
columnVisibilityStorageKey,
navigation,
});
const info = entityTableInfo(
pending,
@ -154,6 +158,12 @@ export function EntityTable<T extends { id: string }>({
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
};
function resetSortPage() {
if (!navigation) {
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}
}
const configuration = (
<EntityTableConfiguration
columnGroups={columnGroups}
@ -161,7 +171,7 @@ export function EntityTable<T extends { id: string }>({
columnsDisabled={columnsDisabled}
locale={locale}
onResetColumns={() => table.setColumnVisibility({})}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
onSort={resetSortPage}
presentation={layout === "fill" ? "picker" : "full"}
table={table}
/>
@ -228,7 +238,7 @@ export function EntityTable<T extends { id: string }>({
loading={pending}
message={message}
onRowClick={onRowClick}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
onSort={resetSortPage}
rowClassName={rowClassName}
state={state}
table={table}

View File

@ -115,6 +115,7 @@ export function EntityTableContent<T extends { id: string }>({
table.getRowModel().rows.map((row) => (
<TableRow
className={`group/entity-row ${onRowClick ? "cursor-pointer" : ""} ${rowClassName?.(row.original) ?? ""}`}
data-entity-id={row.original.id}
key={row.id}
onClick={
onRowClick

View File

@ -14,6 +14,11 @@ import {
import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list";
import type { EntityListState } from "./lib/entity-list-state";
import { entityListState, entityTableAvailability } from "./lib/entity-list-state";
import {
tableNavigationPage,
tableNavigationSorting,
tableNavigationValue,
} from "./lib/entity-table-navigation";
import { PencilIcon } from "./lib/icons";
const items = Array.from({ length: 21 }, (_, index) => ({
@ -669,3 +674,79 @@ test("error inicial, vacío válido y filtros sin coincidencias tienen contenido
assert.ok(filtered.includes(labels.clear));
}
});
test("el contrato de navegación rechaza páginas mal formadas y ordenaciones desconocidas", () => {
for (const value of [
null,
"",
"0",
"-1",
"1.5",
"1e3",
" 3",
"Infinity",
"9999999999999999999",
]) {
assert.equal(tableNavigationPage(value), 0);
}
assert.equal(tableNavigationPage("1"), 0);
assert.equal(tableNavigationPage("3"), 2);
const fallback = [{ id: "name", desc: false }];
const definitions = [
{ id: "name", sortable: true },
{ id: "field:stable-uuid", sortable: true },
{ id: "actions", sortable: false },
];
for (const values of [
{ sort: "missing", direction: "asc", page: null },
{ sort: "actions", direction: "desc", page: null },
{ sort: "name", direction: "other", page: null },
]) {
assert.deepEqual(tableNavigationSorting(values, definitions, fallback), fallback);
}
const dynamic = tableNavigationSorting(
{ sort: "field:stable-uuid", direction: "desc", page: "3" },
definitions,
fallback
);
assert.deepEqual(dynamic, [{ id: "field:stable-uuid", desc: true }]);
assert.deepEqual(tableNavigationValue(dynamic, fallback, 2), {
sort: "field:stable-uuid",
direction: "desc",
page: 3,
});
assert.deepEqual(tableNavigationValue(fallback, fallback, 0), {
sort: undefined,
direction: undefined,
page: 1,
});
});
test("navegación controlada ordena y pagina realmente sin alterar el consumidor no controlado", () => {
const controlled = renderToStaticMarkup(
<EntityTable
columns={columns}
items={items}
navigation={{
ready: true,
values: { sort: "name", direction: "desc", page: "3" },
onChange: () => assert.fail("SSR no modifica navegación"),
}}
title="Personas"
/>
);
assert.ok(controlled.includes('aria-sort="descending"'));
assert.ok(controlled.includes("Persona 01"));
assert.equal(controlled.includes("Persona 21"), false);
assert.ok(controlled.includes("Mostrando 21-21 de 21"));
const local = renderToStaticMarkup(
<EntityTable columns={columns} items={items} title="Personas" />
);
assert.ok(local.includes('aria-sort="ascending"'));
assert.ok(local.includes("Persona 10"));
assert.equal(local.includes("Persona 21"), false);
});

View File

@ -1,5 +1,6 @@
import type {
ColumnDef,
OnChangeFn,
PaginationState,
SortingState,
VisibilityState,
@ -11,7 +12,7 @@ import {
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import type { EntityColumn } from "../components/entity-list";
import {
@ -19,6 +20,12 @@ import {
writeColumnVisibility,
} from "../lib/entity-column-visibility-storage";
import { ENTITY_PAGE_SIZES, entityListRange } from "../lib/entity-list";
import type { EntityTableNavigation } from "../lib/entity-table-navigation";
import {
tableNavigationPage,
tableNavigationSorting,
tableNavigationValue,
} from "../lib/entity-table-navigation";
const ENTITY_PAGE_SIZE_STORAGE_KEY = "entity-page-size";
@ -43,6 +50,9 @@ export function useEntityTable<T extends { id: string }>({
onSearchChange,
filterKey,
columnVisibilityStorageKey,
navigation,
initialSorting: requestedSorting,
initialPagination,
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
@ -51,6 +61,9 @@ export function useEntityTable<T extends { id: string }>({
onSearchChange: ((value: string) => void) | undefined;
filterKey: string;
columnVisibilityStorageKey?: string | undefined;
navigation?: EntityTableNavigation | undefined;
initialSorting?: SortingState;
initialPagination?: PaginationState;
}) {
const [localSearch, setLocalSearch] = useState("");
const currentSearch = search ?? localSearch;
@ -59,10 +72,20 @@ export function useEntityTable<T extends { id: string }>({
sortableColumns.find((column) => column.defaultSort) ??
sortableColumns.find((column) => column.id === "name") ??
sortableColumns[0];
const initialSorting: SortingState = defaultColumn
? [{ id: defaultColumn.id, desc: defaultColumn.defaultSort === "desc" }]
: [];
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const initialSorting: SortingState =
requestedSorting ??
(defaultColumn ? [{ id: defaultColumn.id, desc: defaultColumn.defaultSort === "desc" }] : []);
const [localSorting, setLocalSorting] = useState<SortingState>(initialSorting);
const sorting = navigation
? tableNavigationSorting(
navigation.values,
columns.map((column) => ({
id: column.id,
sortable: column.id !== "actions" && Boolean(column.value),
})),
initialSorting
)
: localSorting;
const [visibility, setVisibility] = useState<VisibilityState>(() =>
readColumnVisibility(columnVisibilityStorageKey)
);
@ -77,10 +100,37 @@ export function useEntityTable<T extends { id: string }>({
writeColumnVisibility(columnVisibilityStorageKey, visibility);
}
}, [columnVisibilityStorageKey, previousStorageKey, visibility]);
const [pagination, setPagination] = useState<PaginationState>(() => ({
pageIndex: 0,
pageSize: storedPageSize(),
}));
const [localPagination, setLocalPagination] = useState<PaginationState>(
() => initialPagination ?? { pageIndex: 0, pageSize: storedPageSize() }
);
const pagination = navigation
? { ...localPagination, pageIndex: tableNavigationPage(navigation.values.page) }
: localPagination;
const setSorting: OnChangeFn<SortingState> = (update) => {
const next = typeof update === "function" ? update(sorting) : update;
if (navigation) {
navigation.onChange(tableNavigationValue(next, initialSorting, 0));
} else {
setLocalSorting(next);
}
};
const setPagination: OnChangeFn<PaginationState> = (update) => {
const next = typeof update === "function" ? update(pagination) : update;
if (!navigation) {
setLocalPagination(next);
return;
}
if (next.pageSize !== localPagination.pageSize) {
// Con control externo solo el tamaño es preferencia local; el índice pertenece al consumidor.
setLocalPagination((previous) => ({ ...previous, pageSize: next.pageSize }));
}
navigation.onChange(tableNavigationValue(sorting, initialSorting, next.pageIndex));
};
useEffect(() => {
try {
@ -94,7 +144,9 @@ export function useEntityTable<T extends { id: string }>({
if (previousKey !== resetKey) {
setPreviousKey(resetKey);
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
if (!navigation) {
setLocalPagination((previous) => ({ ...previous, pageIndex: 0 }));
}
}
const nameCollator = useMemo(
() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }),
@ -156,9 +208,34 @@ export function useEntityTable<T extends { id: string }>({
const range = entityListRange(total, pagination.pageIndex, pagination.pageSize);
// Una actualización puede reducir el conjunto sin cambiar los filtros.
if (pagination.pageIndex !== range.page) {
setPagination({ ...pagination, pageIndex: range.page });
if (!navigation && pagination.pageIndex !== range.page) {
setLocalPagination({ ...pagination, pageIndex: range.page });
}
const {
sort: canonicalSort,
direction: canonicalDirection,
page: canonicalPage,
} = tableNavigationValue(sorting, initialSorting, range.page);
// Sincroniza únicamente la frontera externa (URL), una vez válidos datos y definiciones.
useLayoutEffect(() => {
if (!navigation?.ready) {
return;
}
const { values } = navigation;
if (
values.sort !== (canonicalSort ?? null) ||
values.direction !== (canonicalDirection ?? null) ||
values.page !== (canonicalPage > 1 ? String(canonicalPage) : null)
) {
navigation.onChange({
sort: canonicalSort,
direction: canonicalDirection,
page: canonicalPage,
});
}
}, [navigation, canonicalSort, canonicalDirection, canonicalPage]);
return {
table,

View File

@ -0,0 +1,54 @@
import type { SortingState } from "@tanstack/react-table";
export type EntityTableNavigationValue = {
sort: string | undefined;
direction: "asc" | "desc" | undefined;
page: number;
};
/** Frontera presentacional; el consumidor decide almacenamiento, nombres de parámetros y navegación. */
export type EntityTableNavigation = {
values: { sort: string | null; direction: string | null; page: string | null };
onChange: (value: EntityTableNavigationValue) => void;
ready: boolean;
};
export function tableNavigationPage(value: string | null): number {
if (!(value && /^[1-9]\d*$/.test(value))) {
return 0;
}
const page = Number(value);
return Number.isSafeInteger(page) ? page - 1 : 0;
}
export function tableNavigationSorting(
values: EntityTableNavigation["values"],
columns: readonly { id: string; sortable: boolean }[],
fallback: SortingState
): SortingState {
if (
(values.direction === "asc" || values.direction === "desc") &&
columns.some((column) => column.id === values.sort && column.sortable)
) {
return [{ id: values.sort ?? "", desc: values.direction === "desc" }];
}
return fallback;
}
export function tableNavigationValue(
sorting: SortingState,
fallback: SortingState,
pageIndex: number
): EntityTableNavigationValue {
const first = sorting[0];
const initial = fallback[0];
const isDefault = first?.id === initial?.id && first?.desc === initial?.desc;
return {
sort: isDefault ? undefined : first?.id,
direction: isDefault || !first ? undefined : first.desc ? "desc" : "asc",
page: pageIndex + 1,
};
}

View File

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""L03.1: continuidad URL, History y DOM mediante Playwright MCP autenticado.
Requiere visual-audit.py y su preparador de volumen. No inicia servicios ni escribe entidades.
Las retiradas de registros y las demoras se simulan únicamente en respuestas GET.
"""
import contextlib
import json
import os
from pathlib import Path
import runpy
import tomllib
import unittest
from urllib.parse import urlencode,urlparse
ROOT=Path(__file__).resolve().parent.parent
AUDIT=runpy.run_path(str(ROOT/'scripts/visual-audit.py'))
SIZES=[(1920,1080),(1440,900),(1366,768),(1024,768),(768,1024),(390,844),(320,640)]
class ListContextUiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value=os.environ.get('METIS_PLAYWRIGHT_STORAGE_STATE')
if not value:raise unittest.SkipTest('Requiere sesión sintética activa.')
state=(ROOT/value).resolve()
if not state.is_relative_to(ROOT/'.artifacts/playwright') or state.stat().st_mode&0o077:raise RuntimeError('Requiere sesión privada del preparador.')
report=json.loads((state.parent/'report.json').read_text())
if urlparse(report['origin']).hostname!='localhost' or report.get('ordinaryUser') is not True:raise RuntimeError('Requiere instalación desechable ordinaria.')
cls.origin=report['origin'];cls.company=report['companyId'];cls.base='/companies/'+cls.company
cls.output=ROOT/'.artifacts/playwright/l03-1/after';cls.output.mkdir(parents=True,exist_ok=True);cls.results=[];cls.measurements=cls.output/('measurements-'+str(os.getpid())+'.json')
cls.stack=contextlib.ExitStack();cls.addClassCleanup(cls.stack.close)
config=tomllib.loads((ROOT/'.codex/config.toml').read_text())['mcp_servers']['playwright']
cls.client=cls.stack.enter_context(AUDIT['browser'](config,state))
info=cls.code('const r=await page.request.get('+json.dumps(cls.origin+'/api/companies')+');if(r.status()!==200)throw Error("Sesión no activa");return r.json();')
cls.other=next(c['id']for c in info['companies']if c['id']!=cls.company)
templates=cls.code('return (await (await page.request.get('+json.dumps(cls.origin+'/api/asset-catalog/'+cls.company)+')).json()).items;')
cls.laptop=next(t['id']for t in templates if t['values']['kind']=='template' and t['values']['name']=='Portátil')
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
@classmethod
def code(cls,body):
try:return AUDIT['result_json'](cls.client.tool('browser_run_code_unsafe',{'code':'async(page)=>{'+body+'}'}))
except RuntimeError as error:
(cls.output/('tool-error-'+str(os.getpid())+'.txt')).write_text(str(error));raise
@classmethod
def tearDownClass(cls):
(cls.output/('console-'+str(os.getpid())+'.json')).write_text(json.dumps(AUDIT['tool'](cls.client,'browser_console_messages',{'level':'warning'}),ensure_ascii=False,indent=2)+'\n')
(cls.output/('blocked-writes-'+str(os.getpid())+'.json')).write_text(json.dumps(cls.code('return page.blockedWrites;'))+'\n')
def record(self,name,value):
self.results.append({'scenario':name,'value':value});self.measurements.write_text(json.dumps(self.results,ensure_ascii=False,indent=2)+'\n')
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
def resize(self,w,h):
AUDIT['tool'](self.client,'browser_resize',{'width':w,'height':h});self.settle()
def ready(self,section='assets'):
try:
if section.split('/')[0] in ['assets','resources']:self.code("await page.locator('[data-slot=entity-table][data-query-state=ready]').waitFor();return true;")
else:self.code("await page.locator('main table').waitFor();return true;")
except RuntimeError:
self.record('ready-timeout',self.code("return page.evaluate(()=>({url:location.pathname+location.search,tables:[...document.querySelectorAll('[data-slot=entity-table]')].map(e=>({state:e.dataset.queryState,visible:e.getClientRects().length>0})),alerts:[...document.querySelectorAll('[role=alert]')].map(e=>e.textContent),body:document.body.innerText.slice(0,1800)}));"));self.capture('ready-timeout');raise
self.settle()
def navigate(self,section='assets',params=None):
suffix='?'+urlencode(params)if params else ''
self.code('await page.goto('+json.dumps(self.origin+self.base+'/'+section+suffix)+",{waitUntil:'domcontentloaded'});return true;");self.ready(section)
def asset_query(self):return {'q':'AUDIT-ASSET','template':self.laptop,'sort':'name','direction':'desc','page':'3'}
def metrics(self):
return self.code("""return page.locator('main table').first().evaluate(table=>{
const root=table.closest('[data-slot=entity-table]'),v=table.closest('[data-slot=workspace-scroll]'),x=table.closest('[data-slot=table-container]'),active=document.activeElement;
return {url:location.pathname+location.search,sort:table.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:root.querySelector('[aria-current=page]')?.textContent,search:root.querySelector('input[data-quick-search]')?.value,rows:[...table.querySelectorAll('tbody tr[data-entity-id]')].map(r=>({id:r.dataset.entityId,name:r.querySelector('a')?.textContent})),x:x.scrollLeft,y:v.scrollTop,focus:active.tagName,focusRow:active.closest('[data-entity-id]')?.dataset.entityId,focusSearch:active.hasAttribute('data-quick-search'),activeTab:[...document.querySelectorAll('main [role=tab][aria-selected=true]')].map(e=>e.textContent),contacts:[...root.querySelectorAll('[role=checkbox]')].map(e=>e.getAttribute('aria-checked')),overflow:document.documentElement.scrollWidth>innerWidth};});""")
def capture(self,name):
AUDIT['capture'](self.client,self.output,name)
snapshot=AUDIT['tool'](self.client,'browser_snapshot',{})
(self.output/(name+'.txt')).write_text('\n'.join(c['text']for c in snapshot['content']if c.get('type')=='text'))
def position(self):
self.code("await page.locator('main tbody tr a').first().focus();await page.evaluate(()=>{document.querySelector('[data-slot=workspace-scroll]').scrollTop=150;document.querySelector('main table').closest('[data-slot=table-container]').scrollLeft=40});return true;");self.settle();return self.metrics()
def open_asset(self):
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.locator('main h1').waitFor();await page.locator('[data-slot=workspace-scroll]').evaluate(e=>e.scrollTop=500);return true;");self.settle()
def wait_return_focus(self,identifier):
self.code('await page.waitForFunction(id=>document.activeElement.closest("[data-entity-id]")?.dataset.entityId===id,'+json.dumps(identifier)+');return true;');self.settle()
def test_01_assets_history_and_explicit_return_matrix(self):
for i,(w,h)in enumerate(SIZES):
self.resize(w,h);self.navigate();history_length=self.code("return page.evaluate(()=>history.length);");self.code("await page.getByRole('tab',{name:'Portátil',exact:true}).click();await page.locator('main input[data-quick-search]').fill('AUDIT-ASSET');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 3',exact:true}).click();return true;")
before=self.position();self.assertEqual(self.code('return page.evaluate(()=>history.length);'),history_length);self.record('before-'+str(w),before);self.open_asset()
if i%2:self.code("await page.getByRole('link',{name:'Volver a activos',exact:true}).click();return true;")
else:self.code('await page.goBack();return true;')
self.ready();self.wait_return_focus(before['focusRow']);after=self.metrics();self.record('returned-'+str(w),after)
for key in ['url','sort','page','search','rows','activeTab','x','y','focusRow']:self.assertEqual(before[key],after[key],str(w)+' '+key)
self.assertFalse(after['overflow']);self.assertEqual(after['page'],'3')
if w in [1920,1440,390,320]:self.capture('returned-'+str(w))
if w==1440:
self.code('await page.goForward();await page.locator("main h1").waitFor();await page.goBack();return true;');self.ready();self.wait_return_focus(before['focusRow']);self.assertEqual(self.metrics()['rows'],before['rows'])
def test_02_reload_shared_url_and_preferences(self):
self.resize(1440,900);self.navigate(params=self.asset_query());before=self.metrics();self.assertEqual(before['page'],'3');self.assertEqual(before['sort'],'descending')
self.code("await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('checkbox',{name:'Sede',exact:true}).uncheck();await page.keyboard.press('Escape');await page.reload();return true;");self.ready();after=self.metrics();self.assertEqual(after['rows'],before['rows']);self.assertEqual(after['page'],'3');self.assertEqual(after['sort'],'descending');self.assertEqual(self.code("return page.getByRole('columnheader',{name:'Sede',exact:true}).count();"),0)
shared=self.code('return page.url();');self.code('const other=await page.context().newPage();await other.goto('+json.dumps(shared)+");await other.locator('[data-query-state=ready]').waitFor();page.sharedResult=await other.locator('main table').evaluate(e=>({sort:e.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),first:e.querySelector('tbody a')?.textContent,page:e.closest('[data-slot=entity-table]').querySelector('[aria-current=page]')?.textContent}));await other.close();return page.sharedResult;");self.record('shared-url',self.code('return page.sharedResult;'));self.assertEqual(self.code('return page.sharedResult.page;'),'3');self.assertEqual(self.code('return page.sharedResult.first;'),before['rows'][0]['name']);self.record('reload',after)
def test_03_invalid_parameters_and_reduced_results(self):
self.resize(1366,768)
for params,sort,page in [({'page':'-1','sort':'missing','direction':'other'},'ascending','1'),({'page':'1.5'},'ascending','1'),({'page':'999'},'ascending','6'),({'page':'9999999999999999999'},'ascending','1'),({'template':'invalid','page':'3'},'ascending','1')]:
self.navigate(params=params);value=self.metrics();self.assertEqual(value['sort'],sort);self.assertEqual(value['page'],page);self.assertGreater(len(value['rows']),0);self.record('invalid-'+str(params),value)
self.navigate(params=self.asset_query());length=self.code('return page.evaluate(()=>history.length);');self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-ASSET-01');return true;");self.settle();value=self.metrics();self.assertEqual(value['page'],'1');self.assertEqual(len(value['rows']),1);self.assertEqual(value['sort'],'descending');self.assertEqual(self.code('return page.evaluate(()=>history.length);'),length)
self.code("await page.locator('main input[data-quick-search]').fill('zzzz-sin-resultados');return true;");self.settle();self.assertEqual(len(self.metrics()['rows']),0);self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.settle();self.assertEqual(self.metrics()['page'],'1');self.assertGreater(len(self.metrics()['rows']),0)
def test_04_people_contacts_detail_and_reload(self):
self.resize(390,844);self.navigate('people');self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-PERSON');await page.getByRole('checkbox',{name:'Solo contactos',exact:true}).check();await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 2',exact:true}).click();return true;")
before=self.position();self.record('people-before',before);self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.getByRole('button',{name:'Volver a personas',exact:true}).waitFor();return true;");self.capture('person-detail-390');self.code("await page.getByRole('button',{name:'Volver a personas',exact:true}).click();return true;");self.ready('people');self.wait_return_focus(before['focusRow']);after=self.metrics();self.record('people-returned',after)
for key in ['url','sort','page','search','rows','contacts','focusRow']:self.assertEqual(before[key],after[key],key)
self.code('await page.reload();return true;');self.ready('people');self.assertEqual(self.metrics()['rows'],before['rows']);self.assertEqual(self.metrics()['contacts'],['true']);self.capture('people-returned-390')
def test_05_resources_context_and_explicit_dialog(self):
for w,h in [(1920,1080),(390,844)]:
self.resize(w,h);self.navigate('resources',{'q':'AUDIT-VOLUME','sort':'name','direction':'desc','page':'3'});before=self.position();self.code("await page.evaluate(()=>window.contextTable=document.querySelector('main table'));await page.locator('main tbody tr a').first().evaluate(e=>e.click());return true;");self.settle()
if w==1920:
self.code("await page.locator('[data-slot=selection-panel]').waitFor();await page.locator('[data-slot=selection-panel]').getByRole('button',{name:'Ficha e historial',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
else:self.code("await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.settle();after=self.metrics();self.record('resources-returned-'+str(w),after);self.assertEqual(after['sort'],before['sort']);self.assertEqual(after['page'],before['page']);self.assertEqual(after['rows'],before['rows']);self.assertTrue(self.code('return page.evaluate(()=>window.contextTable===document.querySelector("main table"));'));self.capture('resources-context-'+str(w))
if w==1920:
selected_url=self.code('return page.url();');self.code("await page.getByRole('button',{name:'Cerrar panel del recurso',exact:true}).click();return true;");self.settle();self.assertEqual(self.code('return page.url();'),selected_url);self.assertEqual(self.metrics()['page'],'3')
else:
selected=before['rows'][0]['id'];self.navigate('resources/'+selected,{'q':'AUDIT-VOLUME','sort':'name','direction':'desc','page':'3'});self.code("await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;");self.ready('resources');self.assertEqual(self.metrics()['page'],'3');self.assertEqual(self.metrics()['search'],'AUDIT-VOLUME')
def test_06_removed_record_and_return_during_refetch(self):
self.resize(1440,900);self.navigate(params=self.asset_query());before=self.position();self.open_asset();endpoint=self.origin+'/api/assets/'+self.company
self.code('page.removedId='+json.dumps(before['focusRow'])+';page.removedUrl='+json.dumps(endpoint)+";await page.route(page.removedUrl,async r=>{const response=await r.fetch();const data=await response.json();data.assets=data.assets.filter(a=>a.id!==page.removedId);await r.fulfill({response,json:data})});await page.goBack();return true;");self.ready();self.code("await page.waitForFunction(()=>document.activeElement.hasAttribute('data-quick-search'));return true;");after=self.metrics();self.assertTrue(after['focusSearch']);self.assertFalse(any(r['id']==before['focusRow']for r in after['rows']));self.record('removed-return',after);self.capture('removed-return');self.code('await page.unroute(page.removedUrl);return true;')
self.navigate(params=self.asset_query());before=self.position();self.open_asset()
self.code('page.heldUrl='+json.dumps(endpoint)+";page.held=[];page.started=new Promise(resolve=>page.onStart=resolve);await page.route(page.heldUrl,async r=>{page.onStart();await new Promise(resolve=>page.held.push(resolve));await r.continue()});await page.goBack();await page.started;await page.locator('[data-query-state=refreshing]').waitFor();return true;");pending=self.metrics();self.assertEqual(pending['page'],'3');self.assertEqual(pending['sort'],'descending');self.record('return-refetch-pending',pending)
self.code("const response=page.waitForResponse(page.heldUrl);for(const release of page.held)release();await response;await page.unroute(page.heldUrl);return true;");self.ready();self.wait_return_focus(before['focusRow']);self.assertEqual(self.metrics()['y'],before['y']);self.record('return-refetch-completed',self.metrics())
def test_07_initial_loading_on_reload_preserves_page(self):
self.resize(320,640);self.navigate(params=self.asset_query());before=self.position();self.open_asset();self.code('await page.goBack();return true;');self.ready();self.wait_return_focus(before['focusRow'])
endpoint=self.origin+'/api/assets/'+self.company
self.code('page.heldUrl='+json.dumps(endpoint)+";page.held=[];page.started=new Promise(resolve=>page.onStart=resolve);await page.route(page.heldUrl,async r=>{page.onStart();await new Promise(resolve=>page.held.push(resolve));await r.continue()});await page.reload({waitUntil:'domcontentloaded'});await page.started;await page.locator('[data-query-state=loading]').waitFor();return true;")
pending=self.metrics();self.assertIn('page=3',pending['url']);self.assertGreater(self.code("return page.locator('[data-slot=entity-table-skeleton-row]').count();"),0);self.record('reload-loading',pending);self.capture('reload-loading-320')
self.code("const response=page.waitForResponse(page.heldUrl);for(const release of page.held)release();await response;await page.unroute(page.heldUrl);return true;");self.ready();self.wait_return_focus(before['focusRow']);after=self.metrics();self.assertEqual(after['page'],'3');self.assertEqual(after['x'],before['x']);self.assertEqual(after['y'],before['y']);self.record('reload-restored',after)
def test_08_company_isolation_and_cancelled_editor(self):
self.resize(1920,1080);self.navigate(params=self.asset_query());before=self.position();self.code('await page.locator('+json.dumps('a[href="/companies/'+self.other+'"]')+").first().click();return true;");self.code("await page.getByRole('link',{name:/^Activos/}).click();await page.locator('[data-query-state=ready]').waitFor();return true;");self.settle();other=self.metrics();self.assertEqual(other['page'],'1');self.assertEqual(other['search'],'');self.assertEqual(other['x'],0);self.assertEqual(other['y'],0);self.record('company-isolation',other)
self.code('await page.goBack();await page.goBack();return true;');self.ready();self.wait_return_focus(before['focusRow']);self.assertEqual(self.metrics()['rows'],before['rows'])
self.code("await page.getByRole('button',{name:'Editar',exact:true}).first().focus();await page.evaluate(()=>window.editTrigger=document.activeElement);await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();return true;");url=self.code('return page.url();');self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;");self.settle();self.assertEqual(self.code('return page.url();'),url);self.assertTrue(self.code('return page.evaluate(()=>document.activeElement===window.editTrigger);'));self.assertEqual(self.metrics()['page'],'3')
def test_09_dynamic_sort_and_disappearing_column(self):
self.resize(1440,900)
field=self.code('const inventory=await(await page.request.get('+json.dumps(self.origin+'/api/assets/'+self.company)+')).json();const catalog=await(await page.request.get('+json.dumps(self.origin+'/api/asset-catalog/'+self.company)+")).json();const used=new Set(inventory.assets.flatMap(a=>a.specification.fields.map(f=>f.fieldId)));const field=catalog.items.find(i=>i.values.kind==='field'&&i.effectiveActive&&!used.has(i.id));if(!field)throw Error('Falta campo de catálogo no utilizado en las instantáneas');return {id:field.id,name:field.values.name};")
key='characteristic-'+field['id'];self.navigate(params={'sort':key,'direction':'desc','page':'3'});self.assertEqual(self.code("return new URL(page.url()).searchParams.get('sort');"),key);self.assertEqual(self.metrics()['page'],'3');self.assertIn('Descendente',self.code("return page.getByRole('combobox',{name:'Ordenar por',exact:true}).textContent();"));self.record('dynamic-sort',self.metrics())
self.code('page.catalogUrl='+json.dumps(self.origin+'/api/asset-catalog/'+self.company)+';page.removedField='+json.dumps(field['id'])+";await page.route(page.catalogUrl,async r=>{const response=await r.fetch();const data=await response.json();data.items=data.items.filter(i=>i.id!==page.removedField);await r.fulfill({response,json:data})});await page.reload();return true;");self.ready();self.code("await page.waitForFunction(()=>!new URL(location.href).searchParams.has('sort'));return true;");self.assertEqual(self.metrics()['sort'],'ascending');self.assertEqual(self.metrics()['page'],'3');self.record('dynamic-field-removed',self.metrics());self.code('await page.unroute(page.catalogUrl);return true;')
def test_10_failed_return_does_not_certify_cached_record(self):
self.resize(1440,900);self.navigate(params=self.asset_query());before=self.position();self.open_asset()
self.code('page.failedUrl='+json.dumps(self.origin+'/api/assets/'+self.company)+";await page.route(page.failedUrl,r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));await page.goBack();await page.locator('[data-query-state=stale]').waitFor();await page.waitForFunction(()=>document.activeElement.hasAttribute('data-quick-search'));return true;");after=self.metrics();self.assertTrue(after['focusSearch']);self.assertEqual(after['page'],'3');self.assertEqual(after['rows'],before['rows']);self.record('return-503-unverified',after);self.capture('return-503-unverified');self.code('await page.unroute(page.failedUrl);return true;')
def test_11_shrinking_results_clamp_return(self):
self.resize(390,844);self.navigate(params={'sort':'name','direction':'desc','page':'6'});before=self.position();self.open_asset()
self.code('page.reducedUrl='+json.dumps(self.origin+'/api/assets/'+self.company)+";await page.route(page.reducedUrl,async r=>{const response=await r.fetch();const data=await response.json();data.assets=data.assets.slice(0,8);await r.fulfill({response,json:data})});await page.goBack();return true;");self.ready();self.code("await page.waitForFunction(()=>!new URL(location.href).searchParams.has('page'));return true;");self.settle();after=self.metrics();self.assertEqual(after['page'],'1');self.assertEqual(len(after['rows']),8)
if any(row['id']==before['focusRow']for row in after['rows']):self.wait_return_focus(before['focusRow'])
else:self.code("await page.waitForFunction(()=>document.activeElement.hasAttribute('data-quick-search'));return true;")
self.assertLessEqual(self.metrics()['y'],before['y']);self.record('shrinking-return',self.metrics());self.capture('shrinking-return-390');self.code('await page.unroute(page.reducedUrl);return true;')
def test_12_people_unverified_company_response(self):
self.resize(1440,900);self.navigate('people',{'q':'AUDIT-PERSON','contactsOnly':'true','sort':'name','direction':'desc','page':'2'});before=self.position();self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.getByRole('button',{name:'Volver a personas',exact:true}).waitFor();return true;")
self.code('page.companyUrl='+json.dumps(self.origin+'/api/companies/'+self.company)+";await page.route(page.companyUrl,r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));const response=page.waitForResponse(page.companyUrl);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await response;return true;");self.settle();self.code("await page.getByRole('button',{name:'Volver a personas',exact:true}).click();await page.waitForFunction(()=>document.activeElement.hasAttribute('data-quick-search'));return true;");after=self.metrics();self.assertTrue(after['focusSearch']);self.assertEqual(after['rows'],before['rows']);self.assertEqual(after['page'],'2');self.record('people-unverified-return',after);self.capture('people-unverified-return');self.code('await page.unroute(page.companyUrl);return true;')
def test_13_page_size_preference_and_controlled_page(self):
self.resize(1440,900);self.navigate(params={'sort':'name','direction':'desc','page':'3'})
self.code("await page.getByRole('combobox',{name:'Elementos por página',exact:true}).click();await page.getByRole('option',{name:'25',exact:true}).click();return true;");self.settle();value=self.metrics();self.assertEqual(value['page'],'1');self.assertEqual(len(value['rows']),25);self.assertEqual(self.code("return page.evaluate(()=>localStorage.getItem('entity-page-size'));"),'25')
self.navigate(params={'sort':'name','direction':'desc','page':'3'});self.assertEqual(self.metrics()['page'],'3');self.assertEqual(len(self.metrics()['rows']),1);self.code('await page.reload();return true;');self.ready();self.assertEqual(len(self.metrics()['rows']),1);self.assertEqual(self.metrics()['page'],'3');self.record('page-size-reload',self.metrics())
self.code("await page.getByRole('combobox',{name:'Elementos por página',exact:true}).click();await page.getByRole('option',{name:'50',exact:true}).click();return true;");self.settle();self.assertEqual(self.metrics()['page'],'1');self.assertEqual(len(self.metrics()['rows']),50)
self.code("await page.getByRole('combobox',{name:'Elementos por página',exact:true}).click();await page.getByRole('option',{name:'10',exact:true}).click();return true;");self.settle();self.assertEqual(len(self.metrics()['rows']),10)
def test_14_false_contact_filter_keeps_valid_requested_page(self):
self.resize(1440,900)
for value in ['false','invalid']:
self.navigate('people',{'q':'AUDIT-PERSON','contactsOnly':value,'sort':'name','direction':'desc','page':'3'});self.code("await page.waitForFunction(()=>!new URL(location.href).searchParams.has('contactsOnly'));return true;");self.settle();current=self.metrics();self.assertEqual(current['page'],'3');self.assertEqual(current['sort'],'descending');self.assertEqual(len(current['rows']),10);self.assertEqual(current['contacts'],['false']);self.record('people-normalized-'+value,current)
self.code("await page.getByRole('checkbox',{name:'Solo contactos',exact:true}).check();return true;");self.settle();self.assertEqual(self.metrics()['page'],'1');self.assertEqual(self.metrics()['contacts'],['true'])
if __name__=='__main__':unittest.main(verbosity=2)