Añadir funcionalidad de selección y gestión de cantidades en CatalogPickerDataTable, incluyendo pruebas unitarias.
This commit is contained in:
parent
612355bcec
commit
463c0662ed
124
client/src/app/quotes/components/CatalogPickerDataTable.test.tsx
Normal file
124
client/src/app/quotes/components/CatalogPickerDataTable.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user