feat(customers): implementar pruebas y lógica de ordenación para la lista de clientes
This commit is contained in:
parent
9c7dfc8645
commit
e7e82d95ad
@ -0,0 +1,44 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CUSTOMER_LIST_SORT_FIELDS,
|
||||||
|
DEFAULT_SORT,
|
||||||
|
isCustomerListSortField,
|
||||||
|
normalizeCustomerListSort,
|
||||||
|
toCustomerListTableSort,
|
||||||
|
} from "./customer-list-sorting";
|
||||||
|
|
||||||
|
test("acepta customer emitido por el grid y conserva ASC/DESC en el criterio API y la URL", () => {
|
||||||
|
for (const direction of ["asc", "desc"] as const) {
|
||||||
|
const sort = normalizeCustomerListSort({ field: "customer", direction });
|
||||||
|
assert.ok(sort);
|
||||||
|
assert.deepEqual(sort, { field: "name", direction });
|
||||||
|
assert.deepEqual(
|
||||||
|
{ orderBy: CUSTOMER_LIST_SORT_FIELDS[sort.field], order: sort.direction },
|
||||||
|
{ orderBy: "name", order: direction }
|
||||||
|
);
|
||||||
|
assert.equal(isCustomerListSortField(sort.field), true);
|
||||||
|
assert.deepEqual(toCustomerListTableSort(sort), { field: "customer", direction });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mantiene URLs name y preferencias antiguas NAME con el ID de columna estable", () => {
|
||||||
|
for (const field of ["name", "NAME"]) {
|
||||||
|
const sort = normalizeCustomerListSort({ field, direction: "asc" });
|
||||||
|
assert.ok(sort);
|
||||||
|
assert.deepEqual(sort, { field: "name", direction: "asc" });
|
||||||
|
assert.deepEqual(toCustomerListTableSort(sort), { field: "customer", direction: "asc" });
|
||||||
|
}
|
||||||
|
assert.deepEqual(toCustomerListTableSort(DEFAULT_SORT), { field: "customer", direction: "desc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserva el mapping de los demás campos semánticos y rechaza campos ajenos", () => {
|
||||||
|
const sort = normalizeCustomerListSort({ field: "tradeName", direction: "desc" });
|
||||||
|
assert.ok(sort);
|
||||||
|
assert.equal(CUSTOMER_LIST_SORT_FIELDS[sort.field], "trade_name");
|
||||||
|
assert.deepEqual(toCustomerListTableSort(sort), sort);
|
||||||
|
for (const field of ["contact", "address", "trade_name", "toString"]) {
|
||||||
|
assert.equal(normalizeCustomerListSort({ field, direction: "asc" }), undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components";
|
||||||
|
|
||||||
|
// Contrato semántico existente de URL y preferencias → columnas de API.
|
||||||
|
export const CUSTOMER_LIST_SORT_FIELDS = {
|
||||||
|
name: "name",
|
||||||
|
tradeName: "trade_name",
|
||||||
|
reference: "reference",
|
||||||
|
tin: "tin",
|
||||||
|
emailPrimary: "email_primary",
|
||||||
|
mobilePrimary: "mobile_primary",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CustomerListSortField = keyof typeof CUSTOMER_LIST_SORT_FIELDS;
|
||||||
|
|
||||||
|
export const DEFAULT_SORT = {
|
||||||
|
field: "name",
|
||||||
|
direction: "desc",
|
||||||
|
} as const satisfies DataTableSort;
|
||||||
|
|
||||||
|
export const isCustomerListSortField = (value: string | null): value is CustomerListSortField => {
|
||||||
|
return value !== null && Object.hasOwn(CUSTOMER_LIST_SORT_FIELDS, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSortDirection = (value: string | null): value is DataTableSortDirection => {
|
||||||
|
return value === "asc" || value === "desc";
|
||||||
|
};
|
||||||
|
|
||||||
|
// customer es el ID estable de la columna compuesta; NAME procede de preferencias antiguas.
|
||||||
|
const SORT_FIELD_ALIASES: Readonly<Record<string, string>> = {
|
||||||
|
customer: "name",
|
||||||
|
NAME: "name",
|
||||||
|
};
|
||||||
|
const SORT_COLUMN_IDS: Readonly<Record<string, string>> = { name: "customer" };
|
||||||
|
|
||||||
|
export function normalizeCustomerListSort(sort: DataTableSort) {
|
||||||
|
const field = SORT_FIELD_ALIASES[sort.field] ?? sort.field;
|
||||||
|
if (!(isCustomerListSortField(field) && isSortDirection(sort.direction))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { field, direction: sort.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCustomerListTableSort(sort: DataTableSort): DataTableSort {
|
||||||
|
return { ...sort, field: SORT_COLUMN_IDS[sort.field] ?? sort.field };
|
||||||
|
}
|
||||||
@ -1,10 +1,7 @@
|
|||||||
import { useDebounce } from "@erp/core/hooks";
|
import { useDebounce } from "@erp/core/hooks";
|
||||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
import {
|
import type { DataTableSort } from "@repo/rdx-ui/components";
|
||||||
type DataTableSort,
|
import { useDataTablePreferences } from "@repo/rdx-ui/components";
|
||||||
type DataTableSortDirection,
|
|
||||||
useDataTablePreferences,
|
|
||||||
} from "@repo/rdx-ui/components";
|
|
||||||
import { NumberHelper } from "@repo/rdx-utils";
|
import { NumberHelper } from "@repo/rdx-utils";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
@ -16,6 +13,15 @@ import {
|
|||||||
useCustomersListQuery,
|
useCustomersListQuery,
|
||||||
} from "../../shared";
|
} from "../../shared";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CUSTOMER_LIST_SORT_FIELDS,
|
||||||
|
DEFAULT_SORT,
|
||||||
|
isCustomerListSortField,
|
||||||
|
isSortDirection,
|
||||||
|
normalizeCustomerListSort,
|
||||||
|
toCustomerListTableSort,
|
||||||
|
} from "./customer-list-sorting";
|
||||||
|
|
||||||
type CustomerListStatusFilter = "all" | CustomerStatus;
|
type CustomerListStatusFilter = "all" | CustomerStatus;
|
||||||
|
|
||||||
// Datos por defecto mientras se carga la consulta o en caso de error.
|
// Datos por defecto mientras se carga la consulta o en caso de error.
|
||||||
@ -27,33 +33,6 @@ const EMPTY_CUSTOMERS_LIST: CustomerList = {
|
|||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Campos que se permiten ordenar para la lista de proformas (consulta).
|
|
||||||
const CUSTOMER_LIST_SORT_FIELDS = {
|
|
||||||
name: "name",
|
|
||||||
tradeName: "trade_name",
|
|
||||||
reference: "reference",
|
|
||||||
tin: "tin",
|
|
||||||
emailPrimary: "email_primary",
|
|
||||||
mobilePrimary: "mobile_primary",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type CustomerListSortField = keyof typeof CUSTOMER_LIST_SORT_FIELDS;
|
|
||||||
|
|
||||||
const DEFAULT_SORT = {
|
|
||||||
field: "NAME",
|
|
||||||
direction: "desc",
|
|
||||||
} satisfies DataTableSort;
|
|
||||||
|
|
||||||
const DEFAULT_API_SORT_FIELD = CUSTOMER_LIST_SORT_FIELDS.name;
|
|
||||||
|
|
||||||
const isCustomerListSortField = (value: string | null): value is CustomerListSortField => {
|
|
||||||
return value !== null && value in CUSTOMER_LIST_SORT_FIELDS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSortDirection = (value: string | null): value is DataTableSortDirection => {
|
|
||||||
return value === "asc" || value === "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useListCustomersController = () => {
|
export const useListCustomersController = () => {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<CustomerListStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<CustomerListStatusFilter>("all");
|
||||||
@ -102,10 +81,12 @@ export const useListCustomersController = () => {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const sort = urlSort ?? preferencesSorting ?? DEFAULT_SORT;
|
const sort =
|
||||||
|
urlSort ??
|
||||||
|
(preferencesSorting ? normalizeCustomerListSort(preferencesSorting) : undefined) ??
|
||||||
|
DEFAULT_SORT;
|
||||||
|
|
||||||
const orderBy =
|
const orderBy = CUSTOMER_LIST_SORT_FIELDS[sort.field];
|
||||||
CUSTOMER_LIST_SORT_FIELDS[sort.field as CustomerListSortField] ?? DEFAULT_API_SORT_FIELD;
|
|
||||||
|
|
||||||
const order = sort.direction;
|
const order = sort.direction;
|
||||||
|
|
||||||
@ -200,8 +181,9 @@ export const useListCustomersController = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setSortValue = useCallback(
|
const setSortValue = useCallback(
|
||||||
(nextSort: DataTableSort) => {
|
(tableSort: DataTableSort) => {
|
||||||
if (!isCustomerListSortField(nextSort.field)) {
|
const nextSort = normalizeCustomerListSort(tableSort);
|
||||||
|
if (!nextSort) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +228,7 @@ export const useListCustomersController = () => {
|
|||||||
search,
|
search,
|
||||||
setSearchValue,
|
setSearchValue,
|
||||||
|
|
||||||
sort,
|
sort: toCustomerListTableSort(sort),
|
||||||
setSort: setSortValue,
|
setSort: setSortValue,
|
||||||
|
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
//className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
|
//className="data-[state=open]:bg-accent -ml-4 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
|
||||||
className="-ml-3 h-8 data-[state=open]:bg-accent cursor-pointer text-foreground"
|
className="-ml-3 h-8 data-[state=open]:bg-accent cursor-pointer text-foreground"
|
||||||
|
data-testid={`data-table-sort-${column.id}`}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -54,11 +55,17 @@ export function DataTableColumnHeader<TData, TValue>({
|
|||||||
/>
|
/>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
<DropdownMenuItem
|
||||||
|
data-testid={`data-table-sort-${column.id}-asc`}
|
||||||
|
onClick={() => column.toggleSorting(false)}
|
||||||
|
>
|
||||||
<ArrowUpIcon />
|
<ArrowUpIcon />
|
||||||
{t("components.datatable.asc")}
|
{t("components.datatable.asc")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
<DropdownMenuItem
|
||||||
|
data-testid={`data-table-sort-${column.id}-desc`}
|
||||||
|
onClick={() => column.toggleSorting(true)}
|
||||||
|
>
|
||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
{t("components.datatable.desc")}
|
{t("components.datatable.desc")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
29
tests/e2e/customers/customer-list.spec.ts
Normal file
29
tests/e2e/customers/customer-list.spec.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { loginViaUi } from "../support/login-via-ui";
|
||||||
|
|
||||||
|
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||||
|
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||||
|
|
||||||
|
test("CUSTOMERS-LIST-001: muestra un cliente persistido y permite acceder a su edición", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await loginViaUi(page);
|
||||||
|
const token = `e2elist${randomUUID().replaceAll("-", "")}`;
|
||||||
|
const customer = await createCustomerViaUi(page, testInfo, `E2E LIST ${token}`);
|
||||||
|
|
||||||
|
await page.goto("/customers");
|
||||||
|
// Aísla el cliente propio sin depender de su posición entre datos de otras ejecuciones.
|
||||||
|
await searchCustomersViaUi(page, token);
|
||||||
|
const editAction = page.getByTestId(`customer-edit-${customer.id}`);
|
||||||
|
const row = page.getByRole("row").filter({ has: editAction });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row).toContainText(customer.name);
|
||||||
|
await expect(row).toContainText(customer.email);
|
||||||
|
await editAction.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL((url) => url.pathname === `/customers/${customer.id}/edit`);
|
||||||
|
await expect(page.locator('input[name="name"]')).toHaveValue(customer.name);
|
||||||
|
});
|
||||||
27
tests/e2e/customers/customer-search.spec.ts
Normal file
27
tests/e2e/customers/customer-search.spec.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { loginViaUi } from "../support/login-via-ui";
|
||||||
|
|
||||||
|
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||||
|
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||||
|
|
||||||
|
test("CUSTOMERS-SEARCH-001: buscar por nombre incluye el cliente correcto y excluye el otro", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await loginViaUi(page);
|
||||||
|
const group = `e2esearch${randomUUID().replaceAll("-", "")}`;
|
||||||
|
const alpha = await createCustomerViaUi(page, testInfo, `E2E Alpha ${group}`);
|
||||||
|
const beta = await createCustomerViaUi(page, testInfo, `E2E Beta ${group}`);
|
||||||
|
await page.goto("/customers");
|
||||||
|
await searchCustomersViaUi(page, group);
|
||||||
|
await expect(page.getByTestId(`customer-edit-${alpha.id}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`customer-edit-${beta.id}`)).toBeVisible();
|
||||||
|
|
||||||
|
await searchCustomersViaUi(page, `Alpha ${group}`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`customer-edit-${alpha.id}`)).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`customer-edit-${beta.id}`)).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid^="customer-edit-"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
43
tests/e2e/customers/customer-sort.spec.ts
Normal file
43
tests/e2e/customers/customer-sort.spec.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { loginViaUi } from "../support/login-via-ui";
|
||||||
|
|
||||||
|
import { createCustomerViaUi } from "./support/create-customer-via-ui";
|
||||||
|
import { searchCustomersViaUi } from "./support/search-customers-via-ui";
|
||||||
|
|
||||||
|
test("CUSTOMERS-SORT-001: ordenar por nombre desde UI devuelve ASC y DESC desde el servidor", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await loginViaUi(page);
|
||||||
|
const group = `e2esort${randomUUID().replaceAll("-", "")}`;
|
||||||
|
const middle = await createCustomerViaUi(page, testInfo, `E2E SORT MMM ${group}`);
|
||||||
|
const last = await createCustomerViaUi(page, testInfo, `E2E SORT ZZZ ${group}`);
|
||||||
|
const first = await createCustomerViaUi(page, testInfo, `E2E SORT AAA ${group}`);
|
||||||
|
await page.goto("/customers");
|
||||||
|
await searchCustomersViaUi(page, group);
|
||||||
|
const rows = page.locator('[data-testid^="customer-edit-"]');
|
||||||
|
await expect(rows).toHaveCount(3);
|
||||||
|
|
||||||
|
for (const [direction, customers] of [
|
||||||
|
["asc", [first, middle, last]],
|
||||||
|
["desc", [last, middle, first]],
|
||||||
|
] as const) {
|
||||||
|
await page.getByTestId("data-table-sort-customer").click();
|
||||||
|
await page.getByTestId(`data-table-sort-customer-${direction}`).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
(url) =>
|
||||||
|
url.searchParams.get("sortField") === "name" &&
|
||||||
|
url.searchParams.get("sortDirection") === direction
|
||||||
|
);
|
||||||
|
// Orden de filas visibles, con paginación y ordenación manuales en la tabla real.
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rows.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute("data-testid"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toEqual(customers.map((customer) => `customer-edit-${customer.id}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -3,9 +3,13 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { Page, TestInfo } from "@playwright/test";
|
import type { Page, TestInfo } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
export async function createCustomerViaUi(page: Page, testInfo: TestInfo) {
|
export async function createCustomerViaUi(
|
||||||
|
page: Page,
|
||||||
|
testInfo: TestInfo,
|
||||||
|
namePrefix = "zzzz E2E customer"
|
||||||
|
) {
|
||||||
const suffix = `${Date.now()}-${testInfo.workerIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
const suffix = `${Date.now()}-${testInfo.workerIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
||||||
const name = `zzzz E2E customer ${suffix}`;
|
const name = `${namePrefix} ${suffix}`;
|
||||||
const email = `e2e-${suffix}@example.invalid`;
|
const email = `e2e-${suffix}@example.invalid`;
|
||||||
|
|
||||||
await page.goto("/customers/create");
|
await page.goto("/customers/create");
|
||||||
|
|||||||
16
tests/e2e/customers/support/search-customers-via-ui.ts
Normal file
16
tests/e2e/customers/support/search-customers-via-ui.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function searchCustomersViaUi(page: Page, search: string) {
|
||||||
|
const responsePromise = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return (
|
||||||
|
response.request().method() === "GET" &&
|
||||||
|
/\/customers\/?$/.test(url.pathname) &&
|
||||||
|
url.searchParams.get("q") === search
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.locator('input[inputmode="search"]').fill(search);
|
||||||
|
const response = await responsePromise;
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user