Añadir acciones en línea para las filas de cotización y mejorar la gestión de filas activas

This commit is contained in:
David Arranz 2026-10-03 15:32:48 +02:00
parent 6f77eaad97
commit 2527c2c785
5 changed files with 158 additions and 54 deletions

View File

@ -0,0 +1,56 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ButtonHTMLAttributes } from "react";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("@/ui", () => ({
Button: (props: ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />,
}));
describe("QuoteItemsInlineRowActions", () => {
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("reuses table meta actions for the active row index", () => {
const insertItem = jest.fn();
const duplicateItems = jest.fn();
const deleteItems = jest.fn();
const onDelete = jest.fn();
const row = { index: 2 } as any;
const table = { options: { meta: { insertItem, duplicateItems, deleteItems } } } as any;
act(() => {
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());
expect(insertItem).toHaveBeenNthCalledWith(1, 2);
expect(insertItem).toHaveBeenNthCalledWith(2, 3);
expect(duplicateItems).toHaveBeenCalledWith(2);
expect(deleteItems).toHaveBeenCalledWith(2);
expect(onDelete).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,46 @@
import { 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,
}: {
row: Row<TData>;
table: Table<TData>;
onDelete: () => void;
}) => (
<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")}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => table.options.meta?.insertItem(row.index + 1)}
>
<ChevronDownIcon className='w-4 h-4 mr-2' />
{t("common.insert_row_below")}
</Button>
<Button type='button' variant='outline' size='sm' onClick={() => table.options.meta?.duplicateItems(row.index)}>
<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>
);

View File

@ -30,13 +30,14 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
export type RowIdData = { [x: string]: any };
@ -120,6 +121,7 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
@ -337,9 +339,18 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
items={filterItems(sorteableRowIds)}
strategy={verticalListSortingStrategy}
>
{filterItems(table.getRowModel().rows).map((row) => (
<QuoteItemsSortableTableRow key={(row as Row<any>).id} id={(row as Row<any>).id}>
{(row as Row<any>).getVisibleCells().map((cell) => (
{filterItems(table.getRowModel().rows).map((row) => {
const typedRow = row as Row<TData>;
const isActive = typedRow.id === activeRowId;
return (
<Fragment key={typedRow.id}>
<QuoteItemsSortableTableRow
id={typedRow.id}
isActive={isActive}
onActivate={() => setActiveRowId(typedRow.id)}
>
{typedRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
@ -354,7 +365,20 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
</TableCell>
))}
</QuoteItemsSortableTableRow>
))}
{isActive && (
<TableRow className='border border-t-0 border-destructive/30 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
<TableCell colSpan={typedRow.getVisibleCells().length} className='px-2 py-2'>
<QuoteItemsInlineRowActions
row={typedRow}
table={table}
onDelete={() => setActiveRowId(null)}
/>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</SortableContext>
</TableBody>
</Table>

View File

@ -12,6 +12,11 @@ export interface QuoteItemsSortableTableRowContextValue {
ref(node: HTMLElement | null): void;
isDragging: boolean;
}
export interface QuoteItemsSortableTableRowProps extends QuoteItemsSortableProps {
isActive?: boolean;
onActivate?: () => void;
}
export const QuoteItemsSortableTableRowContext = createContext<QuoteItemsSortableTableRowContextValue>({
attributes: {
role: "button",
@ -37,7 +42,9 @@ function animateLayoutChanges(args: any) {
export function QuoteItemsSortableTableRow({
id,
children,
}: PropsWithChildren<QuoteItemsSortableProps>) {
isActive = false,
onActivate,
}: PropsWithChildren<QuoteItemsSortableTableRowProps>) {
const {
attributes,
isDragging,
@ -73,10 +80,15 @@ export function QuoteItemsSortableTableRow({
id={String(id)}
className={cn(
isDragging ? "opacity-60" : "opacity-100",
"m-0 hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent"
isActive
? "border border-destructive/30 border-b-0 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5"
: "hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent",
"m-0"
)}
ref={setNodeRef}
style={style}
onClick={onActivate}
onFocusCapture={onActivate}
>
{children}
</TableRow>

View File

@ -12,7 +12,6 @@ import { useToast } from "@/ui/use-toast";
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
import { ColumnDef } from "@tanstack/react-table";
import { t } from "i18next";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useCallback, useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useDetailColumns } from "../../hooks";
@ -192,39 +191,6 @@ export const QuoteDetailsCardEditor = ({
{
enableDragHandleColumn: true,
enableSelectionColumn: true,
enableActionsColumn: true,
rowActionFn: (props) => {
const { table, row } = props;
return [
{
label: t("common.duplicate_row"),
icon: <CopyIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.duplicateItems(row.index),
},
{
label: t("common.insert_row_above"),
icon: <ChevronUpIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.insertItem(row.index),
},
{
label: t("common.insert_row_below"),
icon: <ChevronDownIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.insertItem(row.index + 1),
},
{
label: "-",
},
{
label: t("common.remove_row"),
//shortcut: "⌘⌫",
icon: <Trash2Icon className='w-4 h-4 mr-2' />,
onClick: () => {
table.options.meta?.deleteItems(row.index);
},
},
];
},
}
);