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,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Empty,
|
|
||||||
EmptyContent,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -73,14 +67,11 @@ import {
|
|||||||
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
||||||
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
||||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||||
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
import type { RowIdData } from "./QuoteItemsSortableDataTable.types";
|
||||||
|
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
|
||||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||||
|
|
||||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
|
||||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
|
||||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
|
||||||
|
|
||||||
type Selection = { chapters: string[]; indexes: number[] };
|
type Selection = { chapters: string[]; indexes: number[] };
|
||||||
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
||||||
|
|
||||||
@ -146,20 +137,14 @@ function EmptyChapterTarget({
|
|||||||
>
|
>
|
||||||
<TableCell colSpan={columns} className='p-4'>
|
<TableCell colSpan={columns} className='p-4'>
|
||||||
<ChapterContent>
|
<ChapterContent>
|
||||||
<Empty className='border-border bg-muted/20'>
|
<QuoteItemsEmptyState
|
||||||
<EmptyHeader>
|
title={t("quotes.chapters.empty_title")}
|
||||||
<EmptyMedia variant='icon'>
|
description={t("quotes.chapters.empty")}
|
||||||
<FolderOpenIcon aria-hidden='true' />
|
icon={<FolderOpenIcon aria-hidden='true' />}
|
||||||
</EmptyMedia>
|
onAddRow={onAddRow}
|
||||||
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
|
onAddArticle={onAddArticle}
|
||||||
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
|
onAddBlock={onAddBlock}
|
||||||
</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>
|
|
||||||
</ChapterContent>
|
</ChapterContent>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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 { ButtonGroup } from "@/components/ButtonGroup";
|
||||||
import {
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/ui";
|
||||||
Badge,
|
import { Table } from "@/ui/table";
|
||||||
Card,
|
import { DndContext } from "@dnd-kit/core";
|
||||||
CardContent,
|
import type { RowData } from "@tanstack/react-table";
|
||||||
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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ListPlusIcon } from "lucide-react";
|
import { useQuoteItemsSortableTable } from "../hooks/useQuoteItemsSortableTable";
|
||||||
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
|
import { QuoteItemsAddActions } from "./QuoteItemsAddActions";
|
||||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
import { QuoteItemsDragOverlay } from "./QuoteItemsDragOverlay";
|
||||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
|
||||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
import type {
|
||||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
QuoteItemsSortableDataTableProps,
|
||||||
|
RowIdData,
|
||||||
|
} from "./QuoteItemsSortableDataTable.types";
|
||||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
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" {
|
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>(
|
||||||
interface TableMeta<TData extends RowData> {
|
props: QuoteItemsSortableDataTableProps<TData, TValue>
|
||||||
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>) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEmpty = data.length === 0;
|
const { table, activeId, activeRowId, setActiveRowId, rows, sortableRowIds, dragContextProps } =
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
useQuoteItemsSortableTable(props);
|
||||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
const isEmpty = props.data.length === 0;
|
||||||
const [activeRowId, setActiveRowId] = useState<string | null>(null);
|
const addActions = {
|
||||||
|
onAddRow: () => table.options.meta?.appendItem(),
|
||||||
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
|
onAddArticle: () => table.options.meta?.pickCatalogArticle?.(),
|
||||||
|
onAddBlock: () => table.options.meta?.pickBlock?.(),
|
||||||
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];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext {...dragContextProps}>
|
||||||
measuring={measuringConfig}
|
|
||||||
sensors={sensors}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragCancel={handleDragCancel}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
<CardHeader className='sticky z-10 top-16 bg-card/90'>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
<QuoteItemsSortableDataTableToolbar
|
<QuoteItemsSortableDataTableToolbar
|
||||||
table={table}
|
table={table}
|
||||||
extraControls={extraControls}
|
extraControls={props.extraControls}
|
||||||
showLineDiscountColumn={showLineDiscountColumn}
|
showLineDiscountColumn={props.showLineDiscountColumn}
|
||||||
hasLineDiscounts={hasLineDiscounts}
|
hasLineDiscounts={props.hasLineDiscounts}
|
||||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
onShowLineDiscountColumnChange={props.onShowLineDiscountColumnChange}
|
||||||
/>
|
/>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Empty className='border-border bg-muted/20 p-4 sm:p-8'>
|
<QuoteItemsEmptyState
|
||||||
<EmptyHeader>
|
title={t("quotes.empty_details.title")}
|
||||||
<EmptyMedia variant='icon'>
|
description={t("quotes.empty_details.description")}
|
||||||
<ListPlusIcon aria-hidden='true' />
|
className='p-4 sm:p-8'
|
||||||
</EmptyMedia>
|
{...addActions}
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Table className='table-fixed'>
|
<Table className='table-fixed'>
|
||||||
<TableHeader className='sticky top-0 z-10 bg-background'>
|
<QuoteItemsTableHeader table={table} />
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<QuoteItemsSortableTableBody
|
||||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
table={table}
|
||||||
{headerGroup.headers.map((header) => {
|
rows={rows}
|
||||||
return (
|
sortableRowIds={sortableRowIds}
|
||||||
<TableHead
|
activeRowId={activeRowId}
|
||||||
key={header.id}
|
onActivate={setActiveRowId}
|
||||||
className='px-2 py-1'
|
onDeactivate={() => setActiveRowId(null)}
|
||||||
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>
|
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
<QuoteItemsDragOverlay
|
||||||
{createPortal(
|
activeId={activeId}
|
||||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
selectedCount={table.getSelectedRowModel().rows.length}
|
||||||
{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
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
|
<QuoteItemsAddActions {...addActions} />
|
||||||
<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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</CardFooter>
|
</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;
|
const unused = chapters ? lineActions : chapterActions;
|
||||||
for (const handler of Object.values(expected)) expect(handler).toHaveBeenCalledTimes(1);
|
for (const handler of Object.values(expected)) expect(handler).toHaveBeenCalledTimes(1);
|
||||||
for (const handler of Object.values(unused)) expect(handler).not.toHaveBeenCalled();
|
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('[data-testid="pdf-settings"]')!.closest('[role="group"]')).toBeNull();
|
||||||
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
||||||
} finally {
|
} 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 { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { CSSProperties, PropsWithChildren, SyntheticEvent, createContext, useMemo } from "react";
|
import { CSSProperties, PropsWithChildren, SyntheticEvent, createContext, useMemo } from "react";
|
||||||
import { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable";
|
import type { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable.types";
|
||||||
|
|
||||||
export interface QuoteItemsSortableTableRowContextValue {
|
export interface QuoteItemsSortableTableRowContextValue {
|
||||||
attributes: DraggableAttributes;
|
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 "./QuotePricesResume";
|
||||||
export * from "./QuoteResume";
|
export * from "./QuoteResume";
|
||||||
export * from "./QuotesDataTable";
|
export * from "./QuotesDataTable";
|
||||||
|
export * from "./QuoteItemsAddActions";
|
||||||
|
export * from "./QuoteItemsEmptyState";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./useDetailColumns";
|
export * from "./useDetailColumns";
|
||||||
export * from "./useQuotes";
|
export * from "./useQuotes";
|
||||||
export * from "./useChapterEditor";
|
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`.
|
- 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`.
|
- `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.
|
- 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