Añadir funcionalidad de selección y gestión de cantidades en CatalogPickerDataTable, incluyendo pruebas unitarias.

This commit is contained in:
David Arranz 2026-09-16 16:51:31 +02:00
parent 612355bcec
commit 463c0662ed
3 changed files with 243 additions and 70 deletions

View File

@ -0,0 +1,124 @@
import { DataTableContext, IDataTableContextState } from "@/lib/hooks/useDataTable/DataTableContext";
import { act, Simulate } from "react-dom/test-utils";
import { render as renderDom, unmountComponentAtNode } from "react-dom";
import { MemoryRouter } from "react-router-dom";
import type { ButtonHTMLAttributes, InputHTMLAttributes } from "react";
import { CatalogPickerDataTable } from "./CatalogPickerDataTable";
const useCatalogList = jest.fn();
jest.mock("@/app/catalog/hooks", () => ({ useCatalogList: (...args: unknown[]) => useCatalogList(...args) }));
jest.mock("@/app/catalog/components/CatalogDataTableFilter", () => ({
CatalogDataTableFilter: () => null,
}));
jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("@/ui", () => {
const mockReact = require("react") as typeof import("react");
return {
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />,
Input: mockReact.forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => <input ref={ref} {...props} />
),
};
});
jest.mock("@/components", () => {
const { flexRender } = require("@tanstack/react-table");
return {
DataTableSkeleton: () => null,
ErrorOverlay: () => null,
SimpleEmptyState: () => null,
getDataTableSelectionColumn: () => ({}),
DataTable: ({ table }: { table: any }) => (
<table>
<tbody>
{table.getRowModel().rows.map((row: any) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell: any) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
),
};
});
const dataTableContext: IDataTableContextState = {
pagination: { pageIndex: 0, pageSize: 5 },
setPagination: jest.fn(),
sorting: [],
setSorting: jest.fn(),
globalFilter: [],
setGlobalFilter: jest.fn(),
resetGlobalFilter: jest.fn(),
isFiltered: false,
};
describe("CatalogPickerDataTable quantity", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
useCatalogList.mockReturnValue({
data: {
items: [{ id: "article-1", description: "Article", points: 1, retail_price: { amount: 1, scale: 2 } }],
total_pages: 1,
total_items: 1,
},
isPending: false,
isError: false,
});
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
});
it("keeps the same focused input while entering 30 and adds the selected quantity", () => {
const onSelect = jest.fn();
act(() => {
renderDom(
<MemoryRouter>
<DataTableContext.Provider value={dataTableContext}>
<CatalogPickerDataTable onSelect={onSelect} />
</DataTableContext.Provider>
</MemoryRouter>,
container
);
});
const inputBefore = container.querySelector("input[name='quantity']") as HTMLInputElement;
inputBefore.focus();
act(() => {
inputBefore.value = "";
Simulate.change(inputBefore);
inputBefore.value = "3";
Simulate.change(inputBefore);
});
const inputAfterFirstCharacter = container.querySelector("input[name='quantity']");
expect(inputAfterFirstCharacter).toBe(inputBefore);
expect(document.activeElement).toBe(inputBefore);
act(() => {
inputBefore.value = "30";
Simulate.change(inputBefore);
});
const inputAfter = container.querySelector("input[name='quantity']") as HTMLInputElement;
expect(inputAfter).toBe(inputBefore);
expect(document.activeElement).toBe(inputAfter);
expect(inputAfter.value).toBe("30");
act(() => {
Simulate.click(container.querySelector("button") as HTMLButtonElement);
});
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "article-1" }), 30);
});
});

View File

@ -6,12 +6,73 @@ import { DataTable } from "@/components";
import { useDataTable, useDataTableContext } from "@/lib/hooks"; import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { Button, Input } from "@/ui"; import { Button, Input } from "@/ui";
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts"; import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
import { createColumnHelper, Row } from "@tanstack/react-table"; import { CellContext, ColumnDef, createColumnHelper, Row, RowData } from "@tanstack/react-table";
import { t } from "i18next"; import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react"; import { PackagePlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
type QuantityValue = number | "";
type CatalogPickerTableMeta = {
quantities: QuantityValue[];
setQuantity: (index: number, quantity: QuantityValue) => void;
selectArticle: (article: IListArticles_Response_DTO, index: number) => void;
};
type CatalogPickerMeta = {
catalogPicker: CatalogPickerTableMeta;
};
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
catalogPicker?: CatalogPickerTableMeta;
}
}
const columnHelper = createColumnHelper<IListArticles_Response_DTO>();
function QuantityCell({ row, table }: CellContext<IListArticles_Response_DTO, unknown>) {
const catalogPicker = table.options.meta?.catalogPicker;
if (!catalogPicker) return null;
const quantity = catalogPicker.quantities[row.index] ?? 1;
return (
<Input
type='number'
name='quantity'
min={1}
className='w-24'
value={quantity}
onChange={(event) => {
const value = event.target.value;
catalogPicker.setQuantity(row.index, value === "" ? "" : Number(value));
}}
/>
);
}
function AddArticleCell({ row, table }: CellContext<IListArticles_Response_DTO, unknown>) {
const catalogPicker = table.options.meta?.catalogPicker;
return (
<Button
size='sm'
variant='outline'
className='h-8 gap-1'
onClick={(event) => {
event.preventDefault();
catalogPicker?.selectArticle(row.original, row.index);
}}
>
<PackagePlusIcon className='h-3.5 w-3.5' />
<span className='sr-only xl:not-sr-only xl:whitespace-nowrap'>{t("common.add")}</span>
</Button>
);
}
export const CatalogPickerDataTable = ({ export const CatalogPickerDataTable = ({
onSelect, onSelect,
}: { }: {
@ -20,15 +81,15 @@ export const CatalogPickerDataTable = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext(); const { pagination, globalFilter, isFiltered } = useDataTableContext();
const [quantities, setQuantities] = useState<number[]>([]); const [quantities, setQuantities] = useState<QuantityValue[]>([]);
const handleQuantity = (index: number, quantity: number) => { const handleQuantity = useCallback((index: number, quantity: QuantityValue) => {
setQuantities((prev) => { setQuantities((prev) => {
const newQuantities = [...prev]; const newQuantities = [...prev];
newQuantities[index] = quantity; newQuantities[index] = quantity;
return newQuantities; return newQuantities;
}); });
}; }, []);
const resetQuantities = () => { const resetQuantities = () => {
setQuantities([]); setQuantities([]);
@ -46,73 +107,56 @@ export const CatalogPickerDataTable = ({
resetQuantities(); resetQuantities();
}, [pagination, globalFilter, isFiltered]); }, [pagination, globalFilter, isFiltered]);
console.log(quantities); const tableMeta = useMemo<CatalogPickerMeta>(
() => ({
const columnHelper = createColumnHelper<IListArticles_Response_DTO>(); catalogPicker: {
quantities,
const columns = [ setQuantity: handleQuantity,
columnHelper.accessor("description", { selectArticle: (article, index) =>
id: "description", onSelect(article, quantities[index] === "" ? 1 : (quantities[index] ?? 1)),
header: () => <>{t("catalog.list.columns.description")}</>,
}),
columnHelper.accessor("points", {
id: "points",
header: () => <div className='text-right'>{t("catalog.list.columns.points")}</div>,
cell: ({ renderValue }: { renderValue: () => any }) => (
<div className='text-right'>{renderValue()}</div>
),
}),
columnHelper.accessor("retail_price", {
id: "retail_price",
header: () => <div className='text-right'>{t("catalog.list.columns.retail_price")}</div>,
cell: ({ row }: { row: Row<IListArticles_Response_DTO> }) => {
const price = MoneyValue.create(row.original.retail_price).object;
return <div className='text-right'>{price.toFormat()}</div>;
}, },
}), }),
columnHelper.display({ [handleQuantity, onSelect, quantities]
id: "quantity", );
header: () => (
<div className='font-medium text-right text-foreground'> const columns = useMemo<Array<ColumnDef<IListArticles_Response_DTO, any>>>(
{t("catalog.list.columns.quantity")} () => [
</div> columnHelper.accessor("description", {
), id: "description",
cell: ({ row: { index } }) => { header: () => <>{t("catalog.list.columns.description")}</>,
return ( }),
<Input columnHelper.accessor("points", {
type='number' id: "points",
name='quantity' header: () => <div className='text-right'>{t("catalog.list.columns.points")}</div>,
defaultValue={1} cell: ({ renderValue }: { renderValue: () => any }) => (
min={1} <div className='text-right'>{renderValue()}</div>
className='w-24' ),
value={quantities[index]} }),
onChange={(event) => { columnHelper.accessor("retail_price", {
event.preventDefault(); id: "retail_price",
handleQuantity(index, parseInt(event.target.value)); header: () => <div className='text-right'>{t("catalog.list.columns.retail_price")}</div>,
}} cell: ({ row }: { row: Row<IListArticles_Response_DTO> }) => {
/> const price = MoneyValue.create(row.original.retail_price).object;
); return <div className='text-right'>{price.toFormat()}</div>;
}, },
}), }),
columnHelper.display({ columnHelper.display({
id: "row-actions", id: "quantity",
header: () => null, header: () => (
cell: ({ row }: { row: Row<IListArticles_Response_DTO> }) => ( <div className='font-medium text-right text-foreground'>
<Button {t("catalog.list.columns.quantity")}
size='sm' </div>
variant='outline' ),
className='h-8 gap-1' cell: QuantityCell,
onClick={(event) => { }),
event.preventDefault(); columnHelper.display({
onSelect && onSelect(row.original, quantities[row.index]); id: "row-actions",
}} header: () => null,
> cell: AddArticleCell,
<PackagePlusIcon className='h-3.5 w-3.5' /> }),
<span className='sr-only xl:not-sr-only xl:whitespace-nowrap'>{t("common.add")}</span> ],
</Button> []
), );
}),
];
/*const columns2 = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(() => { /*const columns2 = useMemo<ColumnDef<IListArticles_Response_DTO>[]>(() => {
return [ return [
@ -188,6 +232,7 @@ export const CatalogPickerDataTable = ({
data: [...(data?.items || [])], data: [...(data?.items || [])],
columns: columns, columns: columns,
pageCount: data?.total_pages ?? -1, pageCount: data?.total_pages ?? -1,
meta: tableMeta,
}); });
if (isError) { if (isError) {

View File

@ -2,6 +2,7 @@ import {
InitialTableState, InitialTableState,
OnChangeFn, OnChangeFn,
PaginationState, PaginationState,
TableMeta,
getCoreRowModel, getCoreRowModel,
getFacetedRowModel, getFacetedRowModel,
getFacetedUniqueValues, getFacetedUniqueValues,
@ -120,6 +121,7 @@ interface UseDataTableProps<TData, TValue> {
initialState?: InitialTableState; initialState?: InitialTableState;
onPaginationChange?: OnChangeFn<PaginationState>; onPaginationChange?: OnChangeFn<PaginationState>;
meta?: object;
} }
export function useDataTable<TData, TValue>({ export function useDataTable<TData, TValue>({
@ -131,6 +133,7 @@ export function useDataTable<TData, TValue>({
enableRowSelection = false, enableRowSelection = false,
initialState, initialState,
onPaginationChange, onPaginationChange,
meta,
}: UseDataTableProps<TData, TValue>) { }: UseDataTableProps<TData, TValue>) {
const { pagination, setPagination, sorting } = useDataTableContext(); const { pagination, setPagination, sorting } = useDataTableContext();
@ -171,6 +174,7 @@ export function useDataTable<TData, TValue>({
columns: getTableColumns(), columns: getTableColumns(),
//autoResetPageIndex: true, //autoResetPageIndex: true,
pageCount: pageCount ?? -1, pageCount: pageCount ?? -1,
meta: meta as TableMeta<TData>,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
initialState, initialState,