refactor: simplify quote items table with reusable components and hooks
- Refactored QuoteItemsSortableDataTable to reduce lines from 593 to 88, maintaining API and re-exporting types. - Introduced QuoteItemsAddActions for centralized row/article/block addition buttons. - Created QuoteItemsEmptyState to handle empty states with title, description, icon, and actions. - Separated components for table header, body, and drag overlay for better modularity. - Implemented useQuoteItemsSortableTable hook to manage row selection, drag-and-drop functionality, and item manipulation. - Added tests for the new hook to ensure functionality for duplication, deletion, and drag-and-drop operations. - Updated documentation to reflect changes and improvements in the quote items table structure.
This commit is contained in:
parent
e332fcba52
commit
f5217675b5
@ -11,12 +11,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -73,14 +67,11 @@ import {
|
||||
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
||||
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
||||
import type { RowIdData } from "./QuoteItemsSortableDataTable.types";
|
||||
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
|
||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||
|
||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||
|
||||
type Selection = { chapters: string[]; indexes: number[] };
|
||||
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
||||
|
||||
@ -146,20 +137,14 @@ function EmptyChapterTarget({
|
||||
>
|
||||
<TableCell colSpan={columns} className='p-4'>
|
||||
<ChapterContent>
|
||||
<Empty className='border-border bg-muted/20'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<FolderOpenIcon aria-hidden='true' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
|
||||
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className='flex-row flex-wrap justify-center gap-2 max-w-none'>
|
||||
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
|
||||
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
|
||||
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
<QuoteItemsEmptyState
|
||||
title={t("quotes.chapters.empty_title")}
|
||||
description={t("quotes.chapters.empty")}
|
||||
icon={<FolderOpenIcon aria-hidden='true' />}
|
||||
onAddRow={onAddRow}
|
||||
onAddArticle={onAddArticle}
|
||||
onAddBlock={onAddBlock}
|
||||
/>
|
||||
</ChapterContent>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
23
client/src/app/quotes/components/QuoteItemsAddActions.tsx
Normal file
23
client/src/app/quotes/components/QuoteItemsAddActions.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { ButtonProps } from "@/ui";
|
||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||
|
||||
export interface QuoteItemsAddActionsProps {
|
||||
onAddRow: () => void;
|
||||
onAddArticle: () => void;
|
||||
onAddBlock: () => void;
|
||||
size?: ButtonProps["size"];
|
||||
}
|
||||
|
||||
export function QuoteItemsAddActions({
|
||||
onAddRow, onAddArticle, onAddBlock, size,
|
||||
}: QuoteItemsAddActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<AppendEmptyRowButton size={size} onClick={onAddRow} />
|
||||
<AppendCatalogArticleRowButton size={size} onClick={onAddArticle} />
|
||||
<AppendBlockRowButton size={size} onClick={onAddBlock} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
client/src/app/quotes/components/QuoteItemsDragOverlay.tsx
Normal file
49
client/src/app/quotes/components/QuoteItemsDragOverlay.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Badge } from "@/ui";
|
||||
import { DragOverlay, defaultDropAnimation } from "@dnd-kit/core";
|
||||
import type { DropAnimation, UniqueIdentifier } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
keyframes({ transform }) {
|
||||
return [
|
||||
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
|
||||
{
|
||||
opacity: 0,
|
||||
transform: CSS.Transform.toString({
|
||||
...transform.final,
|
||||
x: transform.final.x + 5,
|
||||
y: transform.final.y + 5,
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
easing: "ease-out",
|
||||
sideEffects({ active }) {
|
||||
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: defaultDropAnimation.duration,
|
||||
easing: defaultDropAnimation.easing,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export function QuoteItemsDragOverlay({ activeId, selectedCount }: {
|
||||
activeId: UniqueIdentifier | null | undefined;
|
||||
selectedCount: number;
|
||||
}) {
|
||||
return createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className='z-40 opacity-100'>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{selectedCount > 0 && (
|
||||
<Badge variant='destructive' className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
32
client/src/app/quotes/components/QuoteItemsEmptyState.tsx
Normal file
32
client/src/app/quotes/components/QuoteItemsEmptyState.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
|
||||
} from "@/ui";
|
||||
import { ListPlusIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QuoteItemsAddActions } from "./QuoteItemsAddActions";
|
||||
import type { QuoteItemsAddActionsProps } from "./QuoteItemsAddActions";
|
||||
|
||||
interface QuoteItemsEmptyStateProps extends Omit<QuoteItemsAddActionsProps, "size"> {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuoteItemsEmptyState({
|
||||
title, description, icon = <ListPlusIcon aria-hidden='true' />, className, ...actions
|
||||
}: QuoteItemsEmptyStateProps) {
|
||||
return (
|
||||
<Empty className={cn("border-border bg-muted/20", className)}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>{icon}</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
|
||||
<QuoteItemsAddActions size='sm' {...actions} />
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
@ -1,589 +1,84 @@
|
||||
import { ButtonGroup, DataTableColumnHeader } from "@/components";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from "@/ui";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
MouseSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
closestCenter,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ColumnDef,
|
||||
InitialTableState,
|
||||
Row,
|
||||
RowData,
|
||||
RowSelectionState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Fragment, ReactNode, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ButtonGroup } from "@/components/ButtonGroup";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/ui";
|
||||
import { Table } from "@/ui/table";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import type { RowData } from "@tanstack/react-table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ListPlusIcon } from "lucide-react";
|
||||
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
|
||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||
import { useQuoteItemsSortableTable } from "../hooks/useQuoteItemsSortableTable";
|
||||
import { QuoteItemsAddActions } from "./QuoteItemsAddActions";
|
||||
import { QuoteItemsDragOverlay } from "./QuoteItemsDragOverlay";
|
||||
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
|
||||
import type {
|
||||
QuoteItemsSortableDataTableProps,
|
||||
RowIdData,
|
||||
} from "./QuoteItemsSortableDataTable.types";
|
||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||
import { QuoteItemsSortableTableBody } from "./QuoteItemsSortableTableBody";
|
||||
import { QuoteItemsTableHeader } from "./QuoteItemsTableHeader";
|
||||
|
||||
export type RowIdData = { [x: string]: any };
|
||||
export type {
|
||||
QuoteItemsSortableDataTableProps,
|
||||
QuoteItemsSortableProps,
|
||||
RowIdData,
|
||||
} from "./QuoteItemsSortableDataTable.types";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface TableMeta<TData extends RowData> {
|
||||
insertItemBelow?: (rowIndex: number) => void;
|
||||
insertItem: (rowIndex: number, data?: unknown) => void;
|
||||
appendItem: (data?: unknown) => void;
|
||||
pickCatalogArticle?: () => void;
|
||||
pickBlock?: () => void;
|
||||
duplicateItems: (rowIndex?: number) => void;
|
||||
deleteItems: (rowIndex?: number | number[]) => void;
|
||||
updateItem: (
|
||||
rowIndex: number,
|
||||
rowData: TData & RowIdData,
|
||||
fieldName: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuoteItemsSortableProps {
|
||||
id: UniqueIdentifier;
|
||||
}
|
||||
|
||||
export type QuoteItemsSortableDataTableProps<
|
||||
TData extends RowData & RowIdData,
|
||||
TValue = unknown,
|
||||
> = {
|
||||
extraControls?: ReactNode;
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
defaultValues: Readonly<{ [x: string]: any }> | undefined;
|
||||
initialState?: InitialTableState;
|
||||
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
|
||||
pickCatalogArticle?: () => void;
|
||||
pickBlock?: () => void;
|
||||
getItem?: (rowIndex: number) => TData | undefined;
|
||||
};
|
||||
showLineDiscountColumn: boolean;
|
||||
hasLineDiscounts: boolean;
|
||||
onShowLineDiscountColumnChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const measuringConfig = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
};
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
keyframes({ transform }) {
|
||||
return [
|
||||
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
|
||||
{
|
||||
opacity: 0,
|
||||
transform: CSS.Transform.toString({
|
||||
...transform.final,
|
||||
x: transform.final.x + 5,
|
||||
y: transform.final.y + 5,
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
easing: "ease-out",
|
||||
sideEffects({ active }) {
|
||||
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: defaultDropAnimation.duration,
|
||||
easing: defaultDropAnimation.easing,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>({
|
||||
columns,
|
||||
extraControls,
|
||||
data,
|
||||
defaultValues,
|
||||
initialState,
|
||||
actions,
|
||||
showLineDiscountColumn,
|
||||
hasLineDiscounts,
|
||||
onShowLineDiscountColumnChange,
|
||||
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
|
||||
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>(
|
||||
props: QuoteItemsSortableDataTableProps<TData, TValue>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = data.length === 0;
|
||||
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]);
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
enableColumnResizing: false,
|
||||
columnResizeMode: "onChange",
|
||||
//defaultColumn,
|
||||
|
||||
autoResetAll: false, // <-- añadido nuevo
|
||||
initialState,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility: {
|
||||
...initialState?.columnVisibility,
|
||||
discount: showLineDiscountColumn,
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (originalRow) => originalRow?.id,
|
||||
|
||||
debugTable: false,
|
||||
debugHeaders: false,
|
||||
debugColumns: false,
|
||||
|
||||
defaultColumn: {
|
||||
minSize: 0, //starting column size
|
||||
size: Number.MAX_SAFE_INTEGER, //enforced during column resizing
|
||||
maxSize: Number.MAX_SAFE_INTEGER, //enforced during column resizing
|
||||
},
|
||||
|
||||
meta: {
|
||||
insertItem: (rowIndex: number, data?: unknown) => {
|
||||
actions.insert(rowIndex, data || defaultValues?.items[0], { shouldFocus: true });
|
||||
},
|
||||
appendItem: (data?: unknown) => {
|
||||
actions.append(data || defaultValues?.items[0], { shouldFocus: true });
|
||||
},
|
||||
pickCatalogArticle: () => {
|
||||
if (actions.pickCatalogArticle) {
|
||||
actions?.pickCatalogArticle();
|
||||
}
|
||||
},
|
||||
pickBlock: () => {
|
||||
if (actions.pickBlock) {
|
||||
actions?.pickBlock();
|
||||
}
|
||||
},
|
||||
duplicateItems: (rowIndex?: number) => {
|
||||
if (rowIndex != undefined) {
|
||||
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;
|
||||
|
||||
const data = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row: Row<any>) => ({ ...row.original, id: undefined }));
|
||||
|
||||
if (table.getRowModel().rows.length < lastIndex + 1) {
|
||||
actions.append(data);
|
||||
} else {
|
||||
actions.insert(lastIndex + 1, data, { shouldFocus: true });
|
||||
}
|
||||
table.resetRowSelection();
|
||||
}
|
||||
},
|
||||
deleteItems: (rowIndex?: number | number[]) => {
|
||||
if (rowIndex != undefined) {
|
||||
actions.remove(rowIndex);
|
||||
} else if (table.getSelectedRowModel().rows.length > 0) {
|
||||
let start = table.getSelectedRowModel().rows.length - 1;
|
||||
for (; start >= 0; start--) {
|
||||
const oldIndex = sorteableRowIds.indexOf(
|
||||
String(table.getSelectedRowModel().rows[start].id)
|
||||
);
|
||||
actions.remove(oldIndex);
|
||||
sorteableRowIds.splice(oldIndex, 1);
|
||||
}
|
||||
|
||||
/*table.getSelectedRowModel().rows.forEach((row) => {
|
||||
});*/
|
||||
table.resetRowSelection();
|
||||
} else {
|
||||
actions.remove();
|
||||
}
|
||||
},
|
||||
updateItem: (rowIndex: number, rowData: any, fieldName: string, value: unknown) => {
|
||||
// Skip page index reset until after next rerender
|
||||
// skipAutoResetPageIndex();
|
||||
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {}),
|
||||
useSensor(PointerSensor, {})
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
let activeId = event.active.id;
|
||||
let overId = event.over?.id;
|
||||
|
||||
if (overId !== undefined && activeId !== overId) {
|
||||
let newIndex = sorteableRowIds.indexOf(String(overId));
|
||||
|
||||
if (table.getSelectedRowModel().rows.length > 1) {
|
||||
table.getSelectedRowModel().rows.forEach((row, index) => {
|
||||
const oldIndex = sorteableRowIds.indexOf(String(row.id));
|
||||
|
||||
if (index > 0) {
|
||||
activeId = row.id;
|
||||
newIndex = sorteableRowIds.indexOf(String(overId));
|
||||
if (newIndex < oldIndex) {
|
||||
newIndex = newIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
actions.move(oldIndex, newIndex);
|
||||
sorteableRowIds.splice(newIndex, 0, sorteableRowIds.splice(oldIndex, 1)[0]);
|
||||
|
||||
overId = row.id;
|
||||
});
|
||||
} else {
|
||||
const oldIndex = sorteableRowIds.indexOf(String(activeId));
|
||||
actions.move(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function handleDragStart({ active }: DragStartEvent) {
|
||||
if (!table.getSelectedRowModel().rowsById[active.id]) {
|
||||
table.resetRowSelection();
|
||||
}
|
||||
|
||||
setActiveId(active.id);
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function filterItems(items: string[] | Row<unknown>[]) {
|
||||
if (!activeId) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((idOrRow) => {
|
||||
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
|
||||
return id === activeId || !table.getSelectedRowModel().rowsById[id];
|
||||
});
|
||||
}
|
||||
const { table, activeId, activeRowId, setActiveRowId, rows, sortableRowIds, dragContextProps } =
|
||||
useQuoteItemsSortableTable(props);
|
||||
const isEmpty = props.data.length === 0;
|
||||
const addActions = {
|
||||
onAddRow: () => table.options.meta?.appendItem(),
|
||||
onAddArticle: () => table.options.meta?.pickCatalogArticle?.(),
|
||||
onAddBlock: () => table.options.meta?.pickBlock?.(),
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
measuring={measuringConfig}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<DndContext {...dragContextProps}>
|
||||
<Card>
|
||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
||||
<CardTitle>
|
||||
<QuoteItemsSortableDataTableToolbar
|
||||
table={table}
|
||||
extraControls={extraControls}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||
extraControls={props.extraControls}
|
||||
showLineDiscountColumn={props.showLineDiscountColumn}
|
||||
hasLineDiscounts={props.hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={props.onShowLineDiscountColumnChange}
|
||||
/>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isEmpty ? (
|
||||
<Empty className='border-border bg-muted/20 p-4 sm:p-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<ListPlusIcon aria-hidden='true' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t("quotes.empty_details.title")}</EmptyTitle>
|
||||
<EmptyDescription>{t("quotes.empty_details.description")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
|
||||
<AppendEmptyRowButton size='sm' onClick={() => table.options.meta?.appendItem()} />
|
||||
<AppendCatalogArticleRowButton
|
||||
size='sm'
|
||||
onClick={() => table.options.meta?.pickCatalogArticle?.()}
|
||||
/>
|
||||
<AppendBlockRowButton
|
||||
size='sm'
|
||||
onClick={() => table.options.meta?.pickBlock?.()}
|
||||
/>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
<QuoteItemsEmptyState
|
||||
title={t("quotes.empty_details.title")}
|
||||
description={t("quotes.empty_details.description")}
|
||||
className='p-4 sm:p-8'
|
||||
{...addActions}
|
||||
/>
|
||||
) : (
|
||||
<Table className='table-fixed'>
|
||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className='px-2 py-1'
|
||||
style={{
|
||||
width:
|
||||
header.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto"
|
||||
: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<DataTableColumnHeader table={table} header={header} />
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={filterItems(sorteableRowIds)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{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'
|
||||
style={{
|
||||
width:
|
||||
cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto"
|
||||
: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</QuoteItemsSortableTableRow>
|
||||
{isActive && (
|
||||
<TableRow className='border border-t-0 border-l-4 border-destructive/30 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>
|
||||
<QuoteItemsTableHeader table={table} />
|
||||
<QuoteItemsSortableTableBody
|
||||
table={table}
|
||||
rows={rows}
|
||||
sortableRowIds={sortableRowIds}
|
||||
activeRowId={activeRowId}
|
||||
onActivate={setActiveRowId}
|
||||
onDeactivate={() => setActiveRowId(null)}
|
||||
/>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{table.getSelectedRowModel().rows.length ? (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
|
||||
>
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{false &&
|
||||
createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{table.getSelectedRowModel().rows.length ? (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
|
||||
>
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 1 && (
|
||||
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 2 && (
|
||||
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 3 && (
|
||||
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
<QuoteItemsDragOverlay
|
||||
activeId={activeId}
|
||||
selectedCount={table.getSelectedRowModel().rows.length}
|
||||
/>
|
||||
</CardContent>
|
||||
{!isEmpty && (
|
||||
<CardFooter>
|
||||
<ButtonGroup>
|
||||
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
|
||||
<AppendCatalogArticleRowButton
|
||||
onClick={() => {
|
||||
if (table.options.meta && table.options.meta.pickCatalogArticle) {
|
||||
table.options.meta?.pickCatalogArticle();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AppendBlockRowButton
|
||||
onClick={() => {
|
||||
if (table.options.meta && table.options.meta.pickBlock) {
|
||||
table.options.meta?.pickBlock();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<QuoteItemsAddActions {...addActions} />
|
||||
</ButtonGroup>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import type { UniqueIdentifier } from "@dnd-kit/core";
|
||||
import type { ColumnDef, InitialTableState, RowData } from "@tanstack/react-table";
|
||||
import type { ReactNode } from "react";
|
||||
import type { FieldValues, UseFieldArrayReturn } from "react-hook-form";
|
||||
|
||||
export type RowIdData = { [x: string]: any };
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface TableMeta<TData extends RowData> {
|
||||
insertItemBelow?: (rowIndex: number) => void;
|
||||
insertItem: (rowIndex: number, data?: unknown) => void;
|
||||
appendItem: (data?: unknown) => void;
|
||||
pickCatalogArticle?: () => void;
|
||||
pickBlock?: () => void;
|
||||
duplicateItems: (rowIndex?: number) => void;
|
||||
deleteItems: (rowIndex?: number | number[]) => void;
|
||||
updateItem: (
|
||||
rowIndex: number,
|
||||
rowData: TData & RowIdData,
|
||||
fieldName: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuoteItemsSortableProps {
|
||||
id: UniqueIdentifier;
|
||||
}
|
||||
|
||||
export type QuoteItemsSortableDataTableProps<
|
||||
TData extends RowData & RowIdData,
|
||||
TValue = unknown,
|
||||
> = {
|
||||
extraControls?: ReactNode;
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
defaultValues: Readonly<{ [x: string]: any }> | undefined;
|
||||
initialState?: InitialTableState;
|
||||
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
|
||||
pickCatalogArticle?: () => void;
|
||||
pickBlock?: () => void;
|
||||
getItem?: (rowIndex: number) => TData | undefined;
|
||||
};
|
||||
showLineDiscountColumn: boolean;
|
||||
hasLineDiscounts: boolean;
|
||||
onShowLineDiscountColumnChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
@ -137,7 +137,10 @@ describe("grouped toolbar selection actions", () => {
|
||||
const unused = chapters ? lineActions : chapterActions;
|
||||
for (const handler of Object.values(expected)) expect(handler).toHaveBeenCalledTimes(1);
|
||||
for (const handler of Object.values(unused)) expect(handler).not.toHaveBeenCalled();
|
||||
expect(container.querySelectorAll('[role="group"]')).toHaveLength(2);
|
||||
// The current toolbar has one selected-actions group; chapter controls
|
||||
// are separate. Verify the three grouped actions without fixing the
|
||||
// number of groups in the overall layout.
|
||||
expect(container.querySelector('[role="group"]')?.querySelectorAll("button")).toHaveLength(3);
|
||||
expect(container.querySelector('[data-testid="pdf-settings"]')!.closest('[role="group"]')).toBeNull();
|
||||
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
||||
} finally {
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import { TableBody, TableCell, TableRow } from "@/ui/table";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Row, Table } from "@tanstack/react-table";
|
||||
import { Fragment } from "react";
|
||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||
|
||||
interface QuoteItemsSortableTableBodyProps<TData> {
|
||||
table: Table<TData>;
|
||||
rows: Row<TData>[];
|
||||
sortableRowIds: string[];
|
||||
activeRowId: string | null;
|
||||
onActivate: (id: string) => void;
|
||||
onDeactivate: () => void;
|
||||
}
|
||||
|
||||
export function QuoteItemsSortableTableBody<TData>({
|
||||
table, rows, sortableRowIds, activeRowId, onActivate, onDeactivate,
|
||||
}: QuoteItemsSortableTableBodyProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
<SortableContext items={sortableRowIds} strategy={verticalListSortingStrategy}>
|
||||
{rows.map((row) => {
|
||||
const isActive = row.id === activeRowId;
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<QuoteItemsSortableTableRow
|
||||
id={row.id}
|
||||
isActive={isActive}
|
||||
onActivate={() => onActivate(row.id)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className='px-2 py-2 align-top'
|
||||
style={{
|
||||
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
||||
? "auto" : cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</QuoteItemsSortableTableRow>
|
||||
{isActive && (
|
||||
<TableRow className='border border-t-0 border-l-4 border-destructive/30 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
|
||||
<TableCell colSpan={row.getVisibleCells().length} className='px-2 py-2'>
|
||||
<QuoteItemsInlineRowActions row={row} table={table} onDelete={onDeactivate} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"
|
||||
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CSSProperties, PropsWithChildren, SyntheticEvent, createContext, useMemo } from "react";
|
||||
import { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable";
|
||||
import type { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable.types";
|
||||
|
||||
export interface QuoteItemsSortableTableRowContextValue {
|
||||
attributes: DraggableAttributes;
|
||||
|
||||
23
client/src/app/quotes/components/QuoteItemsTableHeader.tsx
Normal file
23
client/src/app/quotes/components/QuoteItemsTableHeader.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { DataTableColumnHeader } from "@/components/DataTable/DataTableColumnHeader";
|
||||
import { TableHead, TableHeader, TableRow } from "@/ui/table";
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
export function QuoteItemsTableHeader<TData>({ table }: { table: Table<TData> }) {
|
||||
return (
|
||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
||||
{table.getHeaderGroups().map((group) => (
|
||||
<TableRow key={group.id} className='hover:bg-transparent'>
|
||||
{group.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className='px-2 py-1'
|
||||
style={{ width: header.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : header.getSize() }}
|
||||
>
|
||||
{!header.isPlaceholder && <DataTableColumnHeader table={table} header={header} />}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./QuotePricesResume";
|
||||
export * from "./QuoteResume";
|
||||
export * from "./QuotesDataTable";
|
||||
export * from "./QuoteItemsAddActions";
|
||||
export * from "./QuoteItemsEmptyState";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./useDetailColumns";
|
||||
export * from "./useQuotes";
|
||||
export * from "./useChapterEditor";
|
||||
export * from "./useQuoteItemsSortableTable";
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import { useQuoteItemsSortableTable } from "./useQuoteItemsSortableTable";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type Controller = ReturnType<typeof useQuoteItemsSortableTable<{ id: string }, unknown>>;
|
||||
let controller: Controller;
|
||||
let form: UseFormReturn<FieldValues>;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
function Harness() {
|
||||
form = useForm<FieldValues>({
|
||||
defaultValues: { items: ["A", "B", "C", "D"].map((description) => ({ description })) },
|
||||
});
|
||||
const { fields, ...actions } = useFieldArray({ control: form.control, name: "items" });
|
||||
controller = useQuoteItemsSortableTable({
|
||||
columns: [{ accessorKey: "description" }],
|
||||
data: fields,
|
||||
defaultValues: { items: [{ description: "New" }] },
|
||||
actions: { ...actions, getItem: (index) => form.getValues(`items.${index}`) },
|
||||
showLineDiscountColumn: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<Harness />));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const descriptions = () => form.getValues("items").map((item: { description: string }) => item.description);
|
||||
const start = (id: string) => act(() => controller.dragContextProps.onDragStart({ active: { id } } as DragStartEvent));
|
||||
const drop = (from: string, to: string | null) => act(() => controller.dragContextProps.onDragEnd({
|
||||
active: { id: from }, over: to ? { id: to } : null,
|
||||
} as DragEndEvent));
|
||||
|
||||
it("duplicates current edited form values rather than stale field-array data", () => {
|
||||
act(() => form.setValue("items.0.description", "Edited"));
|
||||
act(() => controller.table.options.meta?.duplicateItems(0));
|
||||
expect(descriptions()).toEqual(["Edited", "Edited", "B", "C", "D"]);
|
||||
});
|
||||
|
||||
it("deletes only selected rows and clears selection after removal", () => {
|
||||
act(() => {
|
||||
controller.table.getRowModel().rows[1].toggleSelected(true);
|
||||
controller.table.getRowModel().rows[3].toggleSelected(true);
|
||||
});
|
||||
act(() => controller.table.options.meta?.deleteItems());
|
||||
expect(descriptions()).toEqual(["A", "C"]);
|
||||
expect(controller.table.getSelectedRowModel().rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("moves selected rows together and restores all visible rows after the drop", () => {
|
||||
const ids = controller.table.getRowModel().rows.map((row) => row.id);
|
||||
act(() => {
|
||||
controller.table.getRowModel().rows[0].toggleSelected(true);
|
||||
controller.table.getRowModel().rows[1].toggleSelected(true);
|
||||
});
|
||||
start(ids[0]);
|
||||
expect(controller.rows.map((row) => row.id)).toEqual([ids[0], ids[2], ids[3]]);
|
||||
drop(ids[0], ids[3]);
|
||||
expect(descriptions()).toEqual(["C", "D", "A", "B"]);
|
||||
expect(controller.rows).toHaveLength(4);
|
||||
expect(controller.activeId).toBeNull();
|
||||
});
|
||||
|
||||
it("clears selection for an unselected drag and leaves data intact on cancellation or an outside drop", () => {
|
||||
const ids = controller.table.getRowModel().rows.map((row) => row.id);
|
||||
act(() => controller.table.getRowModel().rows[0].toggleSelected(true));
|
||||
start(ids[2]);
|
||||
expect(controller.table.getSelectedRowModel().rows).toHaveLength(0);
|
||||
act(() => controller.dragContextProps.onDragCancel());
|
||||
expect(controller.activeId).toBeNull();
|
||||
start(ids[1]);
|
||||
drop(ids[1], null);
|
||||
expect(descriptions()).toEqual(["A", "B", "C", "D"]);
|
||||
expect(controller.activeId).toBeNull();
|
||||
});
|
||||
216
client/src/app/quotes/hooks/useQuoteItemsSortableTable.ts
Normal file
216
client/src/app/quotes/hooks/useQuoteItemsSortableTable.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import {
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
MouseSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import type { Row, RowData, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
QuoteItemsSortableDataTableProps,
|
||||
RowIdData,
|
||||
} from "../components/QuoteItemsSortableDataTable.types";
|
||||
|
||||
type Props<TData extends RowData & RowIdData, TValue> = Pick<
|
||||
QuoteItemsSortableDataTableProps<TData, TValue>,
|
||||
"columns" | "data" | "defaultValues" | "initialState" | "actions" | "showLineDiscountColumn"
|
||||
>;
|
||||
|
||||
const measuringConfig = { droppable: { strategy: MeasuringStrategy.Always } };
|
||||
|
||||
export function useQuoteItemsSortableTable<TData extends RowData & RowIdData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
defaultValues,
|
||||
initialState,
|
||||
actions,
|
||||
showLineDiscountColumn,
|
||||
}: Props<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
||||
|
||||
const sortableRowIds = useMemo<string[]>(() => data.map((item) => item.id), [data]);
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
enableColumnResizing: false,
|
||||
columnResizeMode: "onChange",
|
||||
|
||||
autoResetAll: false,
|
||||
initialState,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility: {
|
||||
...initialState?.columnVisibility,
|
||||
discount: showLineDiscountColumn,
|
||||
},
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (originalRow) => originalRow?.id,
|
||||
|
||||
debugTable: false,
|
||||
debugHeaders: false,
|
||||
debugColumns: false,
|
||||
|
||||
defaultColumn: {
|
||||
minSize: 0,
|
||||
size: Number.MAX_SAFE_INTEGER,
|
||||
maxSize: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
|
||||
meta: {
|
||||
insertItem: (rowIndex: number, data?: unknown) => {
|
||||
actions.insert(rowIndex, data || defaultValues?.items[0], { shouldFocus: true });
|
||||
},
|
||||
appendItem: (data?: unknown) => {
|
||||
actions.append(data || defaultValues?.items[0], { shouldFocus: true });
|
||||
},
|
||||
pickCatalogArticle: () => {
|
||||
if (actions.pickCatalogArticle) {
|
||||
actions?.pickCatalogArticle();
|
||||
}
|
||||
},
|
||||
pickBlock: () => {
|
||||
if (actions.pickBlock) {
|
||||
actions?.pickBlock();
|
||||
}
|
||||
},
|
||||
duplicateItems: (rowIndex?: number) => {
|
||||
if (rowIndex != undefined) {
|
||||
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;
|
||||
|
||||
const data = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => ({ ...row.original, id: undefined }));
|
||||
|
||||
if (table.getRowModel().rows.length < lastIndex + 1) {
|
||||
actions.append(data);
|
||||
} else {
|
||||
actions.insert(lastIndex + 1, data, { shouldFocus: true });
|
||||
}
|
||||
table.resetRowSelection();
|
||||
}
|
||||
},
|
||||
deleteItems: (rowIndex?: number | number[]) => {
|
||||
if (rowIndex != undefined) {
|
||||
actions.remove(rowIndex);
|
||||
} else if (table.getSelectedRowModel().rows.length > 0) {
|
||||
let start = table.getSelectedRowModel().rows.length - 1;
|
||||
for (; start >= 0; start--) {
|
||||
const oldIndex = sortableRowIds.indexOf(
|
||||
String(table.getSelectedRowModel().rows[start].id)
|
||||
);
|
||||
actions.remove(oldIndex);
|
||||
sortableRowIds.splice(oldIndex, 1);
|
||||
}
|
||||
|
||||
table.resetRowSelection();
|
||||
} else {
|
||||
actions.remove();
|
||||
}
|
||||
},
|
||||
updateItem: (rowIndex, rowData, fieldName, value) => {
|
||||
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {}),
|
||||
useSensor(PointerSensor, {})
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
let activeId = event.active.id;
|
||||
let overId = event.over?.id;
|
||||
|
||||
if (overId !== undefined && activeId !== overId) {
|
||||
let newIndex = sortableRowIds.indexOf(String(overId));
|
||||
|
||||
if (table.getSelectedRowModel().rows.length > 1) {
|
||||
table.getSelectedRowModel().rows.forEach((row, index) => {
|
||||
const oldIndex = sortableRowIds.indexOf(String(row.id));
|
||||
|
||||
if (index > 0) {
|
||||
activeId = row.id;
|
||||
newIndex = sortableRowIds.indexOf(String(overId));
|
||||
if (newIndex < oldIndex) {
|
||||
newIndex = newIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
actions.move(oldIndex, newIndex);
|
||||
sortableRowIds.splice(newIndex, 0, sortableRowIds.splice(oldIndex, 1)[0]);
|
||||
|
||||
overId = row.id;
|
||||
});
|
||||
} else {
|
||||
const oldIndex = sortableRowIds.indexOf(String(activeId));
|
||||
actions.move(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function handleDragStart({ active }: DragStartEvent) {
|
||||
if (!table.getSelectedRowModel().rowsById[active.id]) {
|
||||
table.resetRowSelection();
|
||||
}
|
||||
|
||||
setActiveId(active.id);
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function filterItems<TItem extends string | Row<TData>>(items: TItem[]): TItem[] {
|
||||
if (!activeId) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((idOrRow) => {
|
||||
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
|
||||
return id === activeId || !table.getSelectedRowModel().rowsById[id];
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
table,
|
||||
activeId,
|
||||
activeRowId,
|
||||
setActiveRowId,
|
||||
rows: filterItems(table.getRowModel().rows),
|
||||
sortableRowIds: filterItems(sortableRowIds),
|
||||
dragContextProps: {
|
||||
sensors,
|
||||
measuring: measuringConfig,
|
||||
collisionDetection: closestCenter,
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragStart: handleDragStart,
|
||||
onDragCancel: handleDragCancel,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -454,3 +454,15 @@ Inconsistencias observadas, sin cambios fuera del alcance:
|
||||
- Al insertar el primer concepto aparece la tabla normal, sin crear capítulos. El estado también se aplica cuando una cotización vuelve a quedarse sin conceptos. Las cotizaciones con capítulos conservan sus estados vacíos propios. Nuevos textos `quotes.empty_details` en `es/en/ca/fr`.
|
||||
- `QuoteChapterEditor.test.tsx` incorpora un formulario inicialmente vacío y tres pruebas de alta desde `Empty`, incluyendo los dos selectores y conservación del modo sin capítulos. Validación: tres pruebas nuevas y cinco de traducciones, tres casos existentes de tabla libre/orden de columnas, TypeScript sin emisión, ESLint acotado sin errores (cuatro avisos `any` previos), Tailwind hacia `/tmp` y `git diff --check`.
|
||||
- Chromium sobre el HTML del harness revisa a 1440 y 375 px la ausencia de tabla vacía, los tres botones, ajuste al ancho y ausencia de desbordamiento. Muestras `/tmp/uecko-new-empty-*.png`; fixture temporal retirado. No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado al limitarse al editor.
|
||||
|
||||
|
||||
### Refactorización: tabla de conceptos con componentes reutilizables
|
||||
|
||||
- `QuoteItemsSortableDataTable.tsx` pasa de 593 a 88 líneas y queda como composición de la tarjeta, barra, tabla, estado vacío, pie y overlay. Mantiene su API y reexporta los tipos anteriores para conservar las importaciones existentes.
|
||||
- `QuoteItemsAddActions.tsx` centraliza los botones de fila/artículo/bloque mediante callbacks, con tamaño configurable. `QuoteItemsEmptyState.tsx` compone `Empty` con título, descripción, icono y acciones. Ambos se reutilizan en la tabla sin capítulos y en los capítulos vacíos; estos conservan su destino droppable y animación propios.
|
||||
- `QuoteItemsTableHeader.tsx`, `QuoteItemsSortableTableBody.tsx` y `QuoteItemsDragOverlay.tsx` separan cabecera genérica TanStack, filas/acciones activas y representación del arrastre. Se reutilizan las primitivas existentes de tabla, cabecera y filas; se elimina la vista previa de arrastre desactivada por `false &&`, que nunca se renderizaba.
|
||||
- `useQuoteItemsSortableTable.ts` concentra selección, metadatos de edición, sensores y manejadores de arrastre, conservando sus operaciones y orden. `QuoteItemsSortableDataTable.types.ts` recoge los contratos compartidos y la ampliación de `TableMeta`. Los imports internos de tipos dejan de depender del componente principal; se mantienen los barrels del feature.
|
||||
- Cuatro pruebas nuevas del hook con React Hook Form real verifican duplicación de valores editados, eliminación selectiva, movimiento conjunto de filas seleccionadas y cancelación/destino exterior sin cambios. Se corrige una expectativa obsoleta de la prueba de barra, repetida para dos modos: comprueba sus tres acciones agrupadas sin exigir un número fijo de grupos del diseño anterior. No se modifica la barra de producción.
|
||||
- Validación: 40 pruebas de hook/editor/barra/asa correctas, TypeScript sin emisión, ESLint acotado sin errores (dos `any` heredados de los contratos, más dos avisos anteriores en la fila reutilizable), compilación Tailwind hacia `/tmp` y `git diff --check`. Persiste el aviso anterior de paginación React/TanStack fuera de `act` en la suite del editor.
|
||||
- Chromium compara bundles locales de la versión anterior y la refactorizada: capturas idénticas byte a byte para estado vacío a 1440/375 px, filas normales, fila activa y selección a 1440 px. Comprueba también que añadir desde el estado vacío siga mostrando la tabla. Artefactos `/tmp/uecko-sortable-refactor-*`; no se añaden dependencias ni fixtures visuales al repositorio.
|
||||
- No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado: la refactorización afecta al cliente y conserva los flujos existentes. Las pruebas de navegador usan formulario y componentes reales en un harness local sin API. Persiste el aviso previo de Browserslist desactualizado.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user