Añadir confirmación de eliminación en acciones en línea de filas y mejorar pruebas para la tabla de cotizaciones

This commit is contained in:
David Arranz 2026-10-04 15:06:56 +02:00
parent 2527c2c785
commit ec87ba82f0
7 changed files with 286 additions and 32 deletions

View File

@ -1,6 +1,6 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import type { ButtonHTMLAttributes } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions"; import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
declare global { declare global {
@ -12,6 +12,15 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
jest.mock("i18next", () => ({ t: (key: string) => key })); jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("@/ui", () => ({ jest.mock("@/ui", () => ({
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />, Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />,
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogAction: ({ children }: { children: ReactNode }) => <>{children}</>,
AlertDialogCancel: ({ children }: { children: ReactNode }) => <button>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
AlertDialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
AlertDialogTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
})); }));
describe("QuoteItemsInlineRowActions", () => { describe("QuoteItemsInlineRowActions", () => {
@ -29,7 +38,7 @@ describe("QuoteItemsInlineRowActions", () => {
container.remove(); container.remove();
}); });
it("reuses table meta actions for the active row index", () => { it("reuses table meta actions and only deletes after confirmation", () => {
const insertItem = jest.fn(); const insertItem = jest.fn();
const duplicateItems = jest.fn(); const duplicateItems = jest.fn();
const deleteItems = jest.fn(); const deleteItems = jest.fn();
@ -41,15 +50,22 @@ describe("QuoteItemsInlineRowActions", () => {
root.render(<QuoteItemsInlineRowActions row={row} table={table} onDelete={onDelete} />); root.render(<QuoteItemsInlineRowActions row={row} table={table} onDelete={onDelete} />);
}); });
const buttons = Array.from(container.querySelectorAll("button")); const getButton = (label: string) =>
act(() => buttons[0].click()); Array.from(container.querySelectorAll("button")).find((button) => button.textContent === label) as HTMLButtonElement;
act(() => buttons[1].click());
act(() => buttons[2].click()); act(() => getButton("common.insert_row_above").click());
act(() => buttons[3].click()); act(() => getButton("common.insert_row_below").click());
act(() => getButton("common.duplicate_row").click());
act(() => getButton("common.remove_row").click());
expect(insertItem).toHaveBeenNthCalledWith(1, 2); expect(insertItem).toHaveBeenNthCalledWith(1, 2);
expect(insertItem).toHaveBeenNthCalledWith(2, 3); expect(insertItem).toHaveBeenNthCalledWith(2, 3);
expect(duplicateItems).toHaveBeenCalledWith(2); expect(duplicateItems).toHaveBeenCalledWith(2);
expect(deleteItems).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
act(() => getButton("quotes.inline_row_actions.delete_confirmation.confirm").click());
expect(deleteItems).toHaveBeenCalledWith(2); expect(deleteItems).toHaveBeenCalledWith(2);
expect(onDelete).toHaveBeenCalledTimes(1); expect(onDelete).toHaveBeenCalledTimes(1);
}); });

View File

@ -1,18 +1,31 @@
import { Button } from "@/ui"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@/ui";
import { Row, RowData, Table } from "@tanstack/react-table"; import { Row, RowData, Table } from "@tanstack/react-table";
import { t } from "i18next"; import { t } from "i18next";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
export const QuoteItemsInlineRowActions = <TData extends RowData>({ export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table, onDelete }: {
row,
table,
onDelete,
}: {
row: Row<TData>; row: Row<TData>;
table: Table<TData>; table: Table<TData>;
onDelete: () => void; onDelete: () => void;
}) => ( }) => {
<div className='flex flex-wrap items-center gap-2'> const handleDelete = () => {
table.options.meta?.deleteItems(row.index);
onDelete();
};
return (
<div className='flex flex-wrap items-center gap-2'>
<Button type='button' variant='outline' size='sm' onClick={() => table.options.meta?.insertItem(row.index)}> <Button type='button' variant='outline' size='sm' onClick={() => table.options.meta?.insertItem(row.index)}>
<ChevronUpIcon className='w-4 h-4 mr-2' /> <ChevronUpIcon className='w-4 h-4 mr-2' />
{t("common.insert_row_above")} {t("common.insert_row_above")}
@ -30,17 +43,30 @@ export const QuoteItemsInlineRowActions = <TData extends RowData>({
<CopyIcon className='w-4 h-4 mr-2' /> <CopyIcon className='w-4 h-4 mr-2' />
{t("common.duplicate_row")} {t("common.duplicate_row")}
</Button> </Button>
<Button <AlertDialog>
type='button' <AlertDialogTrigger asChild>
variant='destructive' <Button type='button' variant='destructive' size='sm'>
size='sm' <Trash2Icon className='w-4 h-4 mr-2' />
onClick={() => { {t("common.remove_row")}
table.options.meta?.deleteItems(row.index); </Button>
onDelete(); </AlertDialogTrigger>
}} <AlertDialogContent>
> <AlertDialogHeader>
<Trash2Icon className='w-4 h-4 mr-2' /> <AlertDialogTitle>{t("quotes.inline_row_actions.delete_confirmation.title")}</AlertDialogTitle>
{t("common.remove_row")} <AlertDialogDescription>
</Button> {t("quotes.inline_row_actions.delete_confirmation.description")}
</div> </AlertDialogDescription>
); </AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button type='button' variant='destructive' onClick={handleDelete}>
{t("quotes.inline_row_actions.delete_confirmation.confirm")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -0,0 +1,193 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
ReactNode,
TdHTMLAttributes,
} from "react";
import { QuoteItemsSortableDataTable } from "./QuoteItemsSortableDataTable";
let latestTableMeta: any;
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
jest.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: "always" },
MouseSensor: class {},
TouchSensor: class {},
KeyboardSensor: class {},
closestCenter: jest.fn(),
defaultDropAnimation: { duration: 0 },
useSensor: jest.fn(),
useSensors: jest.fn(() => []),
}));
jest.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: ReactNode }) => <>{children}</>,
verticalListSortingStrategy: "vertical",
}));
jest.mock("@dnd-kit/utilities", () => ({ CSS: { Transform: { toString: () => "" } } }));
jest.mock("@/components", () => ({
ButtonGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DataTableColumnHeader: () => null,
}));
jest.mock("@/ui", () => ({
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
Card: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CardFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CardHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
jest.mock("@/ui/table", () => ({
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
TableCell: ({ children, ...props }: TdHTMLAttributes<HTMLTableCellElement>) => <td {...props}>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableHeader: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
TableRow: ({ children, ...props }: HTMLAttributes<HTMLTableRowElement>) => <tr {...props}>{children}</tr>,
}));
jest.mock("./QuoteItemsSortableDataTableToolbar", () => ({ QuoteItemsSortableDataTableToolbar: () => null }));
jest.mock("./QuoteItemsSortableTableRow", () => ({
QuoteItemsSortableTableRow: ({ id, isActive, onActivate, children }: any) => (
<tr data-row-id={id} data-active={String(isActive)} onClick={onActivate} onFocusCapture={onActivate}>
{children}
</tr>
),
}));
jest.mock("./QuoteItemsInlineRowActions", () => ({
QuoteItemsInlineRowActions: ({ row, onDelete }: any) => (
<button data-inline-row-id={row.id} onClick={onDelete}>delete active</button>
),
}));
jest.mock("@tanstack/react-table", () => ({
flexRender: (value: unknown) => value,
getCoreRowModel: jest.fn(),
useReactTable: (options: any) => {
latestTableMeta = options.meta;
const rows = options.data.map((item: { id: string }, index: number) => ({
id: item.id,
index,
original: item,
getVisibleCells: () => [{ id: `${item.id}-cell`, column: { getSize: () => 100, columnDef: { cell: item.id } }, getContext: () => ({}) }],
}));
const table = {
options: { meta: options.meta },
getHeaderGroups: () => [],
getRowModel: () => ({ rows }),
getSelectedRowModel: () => ({ rows: [], rowsById: {} }),
resetRowSelection: jest.fn(),
};
return table;
},
}));
const actions = {
append: jest.fn(),
insert: jest.fn(),
remove: jest.fn(),
move: jest.fn(),
} as any;
const renderTable = (root: ReturnType<typeof createRoot>, data: { id: string }[]) => {
act(() => {
root.render(
<QuoteItemsSortableDataTable
actions={actions}
columns={[]}
data={data}
defaultValues={{ items: [{}] }}
showLineDiscountColumn={true}
hasLineDiscounts={false}
onShowLineDiscountColumnChange={jest.fn()}
/>
);
});
};
describe("QuoteItemsSortableDataTable active row", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("shows one inline band for the row activated by click or focus", () => {
renderTable(root, [{ id: "a" }, { id: "b" }]);
act(() => (container.querySelector('[data-row-id="a"]') as HTMLTableRowElement).click());
expect(container.querySelector('[data-inline-row-id="a"]')).not.toBeNull();
expect(container.querySelectorAll("[data-inline-row-id]")).toHaveLength(1);
act(() =>
(container.querySelector('[data-row-id="b"]') as HTMLTableRowElement).dispatchEvent(
new FocusEvent("focusin", { bubbles: true }),
),
);
expect(container.querySelector('[data-inline-row-id="a"]')).toBeNull();
expect(container.querySelector('[data-inline-row-id="b"]')).not.toBeNull();
expect(container.querySelectorAll("[data-inline-row-id]")).toHaveLength(1);
});
it("keeps the active row identity after reorder and clears it after deletion", () => {
renderTable(root, [{ id: "a" }, { id: "b" }]);
act(() => (container.querySelector('[data-row-id="a"]') as HTMLTableRowElement).click());
renderTable(root, [{ id: "b" }, { id: "a" }]);
expect(container.querySelector('[data-inline-row-id="a"]')).not.toBeNull();
act(() => (container.querySelector('[data-inline-row-id="a"]') as HTMLButtonElement).click());
expect(container.querySelector('[data-inline-row-id]')).toBeNull();
});
it("duplicates the current form value rather than the useFieldArray field snapshot", () => {
const getItem = jest.fn(() => ({ id: "a", description: "edited without blurring" }));
const insert = jest.fn();
renderTable(root, [{ id: "a" }]);
latestTableMeta = undefined;
act(() => {
root.render(
<QuoteItemsSortableDataTable
actions={{ ...actions, getItem, insert }}
columns={[]}
data={[{ id: "a", description: "stale value" }]}
defaultValues={{ items: [{}] }}
showLineDiscountColumn={true}
hasLineDiscounts={false}
onShowLineDiscountColumnChange={jest.fn()}
/>,
);
});
act(() => latestTableMeta.duplicateItems(0));
expect(getItem).toHaveBeenCalledWith(0);
expect(insert).toHaveBeenCalledWith(
1,
{ id: undefined, description: "edited without blurring" },
{ shouldFocus: true },
);
});
});

View File

@ -74,6 +74,7 @@ export type QuoteItemsSortableDataTableProps<
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & { actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
pickCatalogArticle?: () => void; pickCatalogArticle?: () => void;
pickBlock?: () => void; pickBlock?: () => void;
getItem?: (rowIndex: number) => TData | undefined;
}; };
showLineDiscountColumn: boolean; showLineDiscountColumn: boolean;
hasLineDiscounts: boolean; hasLineDiscounts: boolean;
@ -179,8 +180,8 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
}, },
duplicateItems: (rowIndex?: number) => { duplicateItems: (rowIndex?: number) => {
if (rowIndex != undefined) { if (rowIndex != undefined) {
const originalData = table.getRowModel().rows[rowIndex].original; const originalData = actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, originalData, { shouldFocus: true }); actions.insert(rowIndex + 1, { ...originalData, id: undefined }, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) { } else if (table.getSelectedRowModel().rows.length) {
const lastIndex = const lastIndex =
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index; table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;

View File

@ -252,6 +252,7 @@ export const QuoteDetailsCardEditor = ({
<QuoteItemsSortableDataTable <QuoteItemsSortableDataTable
actions={{ actions={{
...fieldActions, ...fieldActions,
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
pickCatalogArticle: () => setArticlePickerDialogOpen(true), pickCatalogArticle: () => setArticlePickerDialogOpen(true),
pickBlock: () => setBlockPickerDialogOpen(true), pickBlock: () => setBlockPickerDialogOpen(true),
}} }}
@ -300,7 +301,10 @@ export const QuoteDetailsCardEditor = ({
className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")} className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")}
> >
<QuoteItemsSortableDataTable <QuoteItemsSortableDataTable
actions={fieldActions} actions={{
...fieldActions,
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
}}
columns={columns} columns={columns}
data={fields} data={fields}
defaultValues={defaultValues} defaultValues={defaultValues}

View File

@ -284,6 +284,13 @@
"confirm": "Hide column" "confirm": "Hide column"
} }
}, },
"inline_row_actions": {
"delete_confirmation": {
"title": "Remove row",
"description": "Are you sure you want to remove this row? This action cannot be undone.",
"confirm": "Remove row"
}
},
"status": { "status": {
"draft": "Draft", "draft": "Draft",
"ready": "Ready", "ready": "Ready",

View File

@ -293,6 +293,13 @@
"confirm": "Ocultar columna" "confirm": "Ocultar columna"
} }
}, },
"inline_row_actions": {
"delete_confirmation": {
"title": "Eliminar fila",
"description": "¿Seguro que quieres eliminar esta fila? Esta acción no se puede deshacer.",
"confirm": "Eliminar fila"
}
},
"status": { "status": {
"draft": "Borrador", "draft": "Borrador",
"ready": "Preparado", "ready": "Preparado",