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 { createRoot } from "react-dom/client";
import type { ButtonHTMLAttributes } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
declare global {
@ -12,6 +12,15 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("@/ui", () => ({
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", () => {
@ -29,7 +38,7 @@ describe("QuoteItemsInlineRowActions", () => {
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 duplicateItems = jest.fn();
const deleteItems = jest.fn();
@ -41,15 +50,22 @@ describe("QuoteItemsInlineRowActions", () => {
root.render(<QuoteItemsInlineRowActions row={row} table={table} onDelete={onDelete} />);
});
const buttons = Array.from(container.querySelectorAll("button"));
act(() => buttons[0].click());
act(() => buttons[1].click());
act(() => buttons[2].click());
act(() => buttons[3].click());
const getButton = (label: string) =>
Array.from(container.querySelectorAll("button")).find((button) => button.textContent === label) as HTMLButtonElement;
act(() => getButton("common.insert_row_above").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(2, 3);
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(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 { t } from "i18next";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
export const QuoteItemsInlineRowActions = <TData extends RowData>({
row,
table,
onDelete,
}: {
export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table, onDelete }: {
row: Row<TData>;
table: Table<TData>;
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)}>
<ChevronUpIcon className='w-4 h-4 mr-2' />
{t("common.insert_row_above")}
@ -30,17 +43,30 @@ export const QuoteItemsInlineRowActions = <TData extends RowData>({
<CopyIcon className='w-4 h-4 mr-2' />
{t("common.duplicate_row")}
</Button>
<Button
type='button'
variant='destructive'
size='sm'
onClick={() => {
table.options.meta?.deleteItems(row.index);
onDelete();
}}
>
<Trash2Icon className='w-4 h-4 mr-2' />
{t("common.remove_row")}
</Button>
</div>
);
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type='button' variant='destructive' size='sm'>
<Trash2Icon className='w-4 h-4 mr-2' />
{t("common.remove_row")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("quotes.inline_row_actions.delete_confirmation.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("quotes.inline_row_actions.delete_confirmation.description")}
</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"> & {
pickCatalogArticle?: () => void;
pickBlock?: () => void;
getItem?: (rowIndex: number) => TData | undefined;
};
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
@ -179,8 +180,8 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
},
duplicateItems: (rowIndex?: number) => {
if (rowIndex != undefined) {
const originalData = table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, originalData, { shouldFocus: true });
const originalData = actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, { ...originalData, id: undefined }, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) {
const lastIndex =
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;

View File

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

View File

@ -284,6 +284,13 @@
"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": {
"draft": "Draft",
"ready": "Ready",

View File

@ -293,6 +293,13 @@
"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": {
"draft": "Borrador",
"ready": "Preparado",