L03.1: Persistencia y restauración del contexto de listados (V05)
This commit is contained in:
parent
9fc7d3a2e0
commit
79dfa59936
@ -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 |
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export type { PeopleListDataState } from "./controllers/use-people-list-context";
|
||||
export { PeopleSection } from "./ui";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@ -13,9 +13,12 @@ export {
|
||||
isCreateNavigation,
|
||||
isInternalPath,
|
||||
navigateTo,
|
||||
returnToList,
|
||||
useBrowserLocation,
|
||||
useBrowserSearchParams,
|
||||
useCreateNavigation,
|
||||
useEntityNavigation,
|
||||
useListLocation,
|
||||
useListReturn,
|
||||
} from "./navigation";
|
||||
export { HomePage } from "./workspace";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
122
modules/core/src/web/navigation/shared/hooks/use-list-return.ts
Normal file
122
modules/core/src/web/navigation/shared/hooks/use-list-return.ts
Normal 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]);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
54
packages/rdx-ui/src/lib/entity-table-navigation.ts
Normal file
54
packages/rdx-ui/src/lib/entity-table-navigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
200
scripts/list-context-ui.test.py
Normal file
200
scripts/list-context-ui.test.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user