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:
parent
2527c2c785
commit
ec87ba82f0
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user