diff --git a/client/src/app/quotes/components/QuoteItemsInlineRowActions.test.tsx b/client/src/app/quotes/components/QuoteItemsInlineRowActions.test.tsx index 3b5f65e..7e9c7ca 100644 --- a/client/src/app/quotes/components/QuoteItemsInlineRowActions.test.tsx +++ b/client/src/app/quotes/components/QuoteItemsInlineRowActions.test.tsx @@ -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) => , + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + 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(); }); - 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); }); diff --git a/client/src/app/quotes/components/QuoteItemsInlineRowActions.tsx b/client/src/app/quotes/components/QuoteItemsInlineRowActions.tsx index bc7391e..91b7713 100644 --- a/client/src/app/quotes/components/QuoteItemsInlineRowActions.tsx +++ b/client/src/app/quotes/components/QuoteItemsInlineRowActions.tsx @@ -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 = ({ - row, - table, - onDelete, -}: { +export const QuoteItemsInlineRowActions = ({ row, table, onDelete }: { row: Row; table: Table; onDelete: () => void; -}) => ( -
+}) => { + const handleDelete = () => { + table.options.meta?.deleteItems(row.index); + onDelete(); + }; + + return ( +
- -
-); + + + + + + + {t("quotes.inline_row_actions.delete_confirmation.title")} + + {t("quotes.inline_row_actions.delete_confirmation.description")} + + + + {t("common.cancel")} + + + + + + +
+ ); +}; diff --git a/client/src/app/quotes/components/QuoteItemsSortableDataTable.test.tsx b/client/src/app/quotes/components/QuoteItemsSortableDataTable.test.tsx new file mode 100644 index 0000000..d57ff3a --- /dev/null +++ b/client/src/app/quotes/components/QuoteItemsSortableDataTable.test.tsx @@ -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 }) =>
{children}
, + DataTableColumnHeader: () => null, +})); +jest.mock("@/ui", () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardFooter: ({ children }: { children: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: ReactNode }) =>
{children}
, +})); +jest.mock("@/ui/table", () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + TableBody: ({ children }: { children: ReactNode }) => {children}, + TableCell: ({ children, ...props }: TdHTMLAttributes) => {children}, + TableHead: ({ children }: { children: ReactNode }) => {children}, + TableHeader: ({ children }: { children: ReactNode }) => {children}, + TableRow: ({ children, ...props }: HTMLAttributes) => {children}, +})); +jest.mock("./QuoteItemsSortableDataTableToolbar", () => ({ QuoteItemsSortableDataTableToolbar: () => null })); +jest.mock("./QuoteItemsSortableTableRow", () => ({ + QuoteItemsSortableTableRow: ({ id, isActive, onActivate, children }: any) => ( + + {children} + + ), +})); +jest.mock("./QuoteItemsInlineRowActions", () => ({ + QuoteItemsInlineRowActions: ({ row, onDelete }: any) => ( + + ), +})); + +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, data: { id: string }[]) => { + act(() => { + root.render( + + ); + }); +}; + +describe("QuoteItemsSortableDataTable active row", () => { + let container: HTMLDivElement; + let root: ReturnType; + + 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( + , + ); + }); + + act(() => latestTableMeta.duplicateItems(0)); + + expect(getItem).toHaveBeenCalledWith(0); + expect(insert).toHaveBeenCalledWith( + 1, + { id: undefined, description: "edited without blurring" }, + { shouldFocus: true }, + ); + }); +}); diff --git a/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx b/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx index 21c5a18..f7a69c5 100644 --- a/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx +++ b/client/src/app/quotes/components/QuoteItemsSortableDataTable.tsx @@ -74,6 +74,7 @@ export type QuoteItemsSortableDataTableProps< actions: Omit, "fields"> & { pickCatalogArticle?: () => void; pickBlock?: () => void; + getItem?: (rowIndex: number) => TData | undefined; }; showLineDiscountColumn: boolean; hasLineDiscounts: boolean; @@ -179,8 +180,8 @@ export function QuoteItemsSortableDataTable { 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; diff --git a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx index bae6675..8207c12 100644 --- a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx +++ b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx @@ -252,6 +252,7 @@ export const QuoteDetailsCardEditor = ({ 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")} > getValues(`items.${rowIndex}`), + }} columns={columns} data={fields} defaultValues={defaultValues} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b9ba7c9..f66ce47 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index a5fef53..c5c1adb 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -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",