feat: implement MariaDB integration testing suite and related fixtures

- Updated `capitulos-esquema.md` to reflect the current state of models and integration testing setup.
- Added `jest.mariadb.config.js` for MariaDB specific test configurations.
- Introduced `mariadb.fixture.mock.ts` to handle isolated MariaDB connections for testing.
- Created integration tests for quote chapters in `quoteChapters.mariadb.test.ts` to validate business logic against MariaDB.
- Implemented `mariadbSafety.test.ts` to ensure safety checks and error handling during integration tests.
- Added `useChapterCollapse.ts` hook for managing chapter collapse animations in the client.
- Introduced `empty.tsx` components for consistent empty state UI across the application.
- Updated `jest.config.js` to ignore MariaDB test files in the standard test run.
- Enhanced `package.json` with a new script for running MariaDB tests.
This commit is contained in:
David Arranz 2026-10-19 16:38:44 +02:00
parent 64bd7f743d
commit bc34a073ad
22 changed files with 1291 additions and 253 deletions

View File

@ -1,4 +1,5 @@
import {
Badge,
Button,
Card,
CardContent,
@ -11,6 +12,12 @@ import {
DialogHeader,
DialogTitle,
Label,
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
EmptyContent,
Select,
SelectContent,
SelectItem,
@ -19,7 +26,7 @@ import {
Switch,
Textarea,
} from "@/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table";
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/ui/table";
import {
DndContext,
closestCenter,
@ -41,8 +48,14 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDownIcon, ChevronRightIcon, CopyIcon, GripVerticalIcon, Trash2Icon } from "lucide-react";
import { Fragment, ReactNode, useState } from "react";
import {
CopyIcon,
FolderIcon,
FolderOpenIcon,
GripVerticalIcon,
Trash2Icon,
} from "lucide-react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
@ -57,11 +70,16 @@ import {
ungroupChapters,
} from "../chapterEditor";
import { ChapterEditorController } from "../hooks/useChapterEditor";
import { useChapterCollapse } from "../hooks/useChapterCollapse";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
import { RowIdData } from "./QuoteItemsSortableDataTable";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
type Selection = { chapters: string[]; indexes: number[] };
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
@ -91,13 +109,51 @@ const keyboardCoordinates: KeyboardCoordinateGetter = (
};
};
function EmptyChapterTarget({ id, columns, hidden }: { id: string; columns: number; hidden: boolean }) {
function ChapterContent({ children }: { children: ReactNode }) {
return <div data-chapter-content className='min-w-0'>{children}</div>;
}
// Reserve the expand column in detail rows so all controls stay aligned.
function ChapterSpacerCell() {
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
}
function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArticle, onAddBlock }: {
id: string;
columns: number;
hidden: boolean;
collapsed: boolean;
onAddRow: () => void;
onAddArticle: () => void;
onAddBlock: () => void;
}) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ id: `empty:${id}`, disabled: hidden });
const { setNodeRef, isOver } = useDroppable({ id: `empty:${id}`, disabled: collapsed });
return (
<TableRow ref={setNodeRef} hidden={hidden} className={isOver ? "bg-accent" : ""}>
<TableCell colSpan={columns} className='h-16 text-center text-muted-foreground'>
{t("quotes.chapters.empty")}
<TableRow
ref={setNodeRef}
hidden={hidden}
data-empty-chapter={id}
className={isOver ? "bg-accent" : ""}
>
<ChapterSpacerCell />
<TableCell colSpan={columns - 1} 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='max-w-none flex-row flex-wrap justify-center gap-2'>
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
</EmptyContent>
</Empty>
</ChapterContent>
</TableCell>
</TableRow>
);
@ -119,7 +175,7 @@ function ChapterGroup({
chapter: EditorChapter;
index: number;
columns: number;
children: (collapsed: boolean) => ReactNode;
children: (collapsed: boolean, hidden: boolean) => ReactNode;
selected: boolean;
onSelect: (selected: boolean) => void;
onActivate: () => void;
@ -130,7 +186,7 @@ function ChapterGroup({
}) {
const { t } = useTranslation();
const { control } = useFormContext();
const [collapsed, setCollapsed] = useState(false);
const { ref, collapsed, hidden, toggle } = useChapterCollapse();
const {
attributes,
listeners,
@ -142,20 +198,56 @@ function ChapterGroup({
} = useSortable({ id: `chapter:${chapter.id}`, disabled: mixed });
return (
<TableBody
ref={setNodeRef}
ref={(node) => {
setNodeRef(node);
ref.current = node;
}}
className='bg-background [&>tr:not([data-chapter-gap])>td]:border-b [&>tr:not([data-chapter-gap])>td]:border-b-border'
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<TableRow className='bg-accent hover:bg-accent' data-chapter-id={chapter.id}>
<TableCell className='px-2'>
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelect(Boolean(value))}
aria-label={t("quotes.chapters.select", { number: index + 1 })}
/>
{index > 0 && (
<TableRow data-chapter-gap aria-hidden='true' className='border-0 bg-card hover:bg-card'>
<TableCell colSpan={columns} className='h-5 p-0' />
</TableRow>
)}
<TableRow
className='bg-muted hover:bg-muted [&>td]:border-t [&>td]:py-3 [&>td:first-child]:rounded-tl-md [&>td:last-child]:rounded-tr-md'
data-chapter-id={chapter.id}
>
<TableCell className='px-1'>
<div className='relative h-8 w-8'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 text-muted-foreground'
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
})}
onClick={() => {
toggle();
onActivate();
}}
>
{collapsed ? (
<FolderIcon className='h-5 w-5' aria-hidden='true' />
) : (
<FolderOpenIcon className='h-5 w-5' aria-hidden='true' />
)}
</Button>
<Badge
aria-hidden='true'
variant='secondary'
className='pointer-events-none absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center border-background bg-zinc-700 px-1 py-0 text-[10px] leading-none text-white hover:bg-zinc-700'
>
{index + 1}
</Badge>
</div>
</TableCell>
<TableCell className='px-2'>
<Button
@ -166,35 +258,22 @@ function ChapterGroup({
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='touch-none cursor-grab'
className='h-8 w-4 touch-none cursor-grab'
onFocus={onActivate}
aria-label={t("quotes.chapters.move", { number: index + 1 })}
>
<GripVerticalIcon className='h-4 w-4' />
</Button>
</TableCell>
<TableCell colSpan={columns - 2} className='px-2'>
<TableCell className='px-2'>
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelect(Boolean(value))}
aria-label={t("quotes.chapters.select", { number: index + 1 })}
/>
</TableCell>
<TableCell colSpan={columns - 3} className='px-2'>
<div className='flex items-start gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
})}
onClick={() => {
setCollapsed((previous) => !previous);
onActivate();
}}
>
{collapsed ? (
<ChevronRightIcon className='h-4 w-4' />
) : (
<ChevronDownIcon className='h-4 w-4' />
)}
</Button>
<span className='py-2 font-semibold'>{index + 1}.</span>
<Controller
key={`${chapter.id}:${index}`}
control={control}
@ -204,7 +283,7 @@ function ChapterGroup({
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
}}
render={({ field, fieldState }) => (
<div className='flex-1'>
<div className='min-w-0 flex-1'>
<Textarea
{...field}
value={field.value ?? ""}
@ -212,7 +291,7 @@ function ChapterGroup({
onFocus={onActivate}
aria-label={t("quotes.chapters.title", { number: index + 1 })}
aria-invalid={Boolean(fieldState.error)}
className='font-semibold'
className='min-h-10 border-transparent bg-transparent font-semibold shadow-none hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
/>
{fieldState.error && (
<p role='alert' className='text-sm text-destructive'>
@ -243,12 +322,16 @@ function ChapterGroup({
</div>
</TableCell>
</TableRow>
{children(collapsed)}
<TableRow className='bg-accent/70 font-semibold' data-chapter-footer={chapter.id}>
<TableCell colSpan={columns - 1}>
{children(collapsed, hidden)}
<TableRow
className='bg-muted/60 font-semibold hover:bg-muted/60 [&>td]:border-t-2 [&>td]:py-3 [&>td:first-child]:rounded-bl-md [&>td:last-child]:rounded-br-md'
data-chapter-footer={chapter.id}
>
<ChapterSpacerCell />
<TableCell colSpan={columns - 2} className='pl-4 text-sm text-muted-foreground'>
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
</TableCell>
<TableCell className='text-right whitespace-nowrap'>{subtotal}</TableCell>
<TableCell className='whitespace-nowrap text-right text-base'>{subtotal}</TableCell>
</TableRow>
</TableBody>
);
@ -276,8 +359,8 @@ export function QuoteChaptersDataTable({
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
onAddChapter: () => void;
pickCatalogArticle: () => void;
pickBlock: () => void;
pickCatalogArticle: (chapterId?: string) => void;
pickBlock: (chapterId?: string) => void;
}) {
const { t } = useTranslation();
const { watch, setValue } = useFormContext();
@ -361,9 +444,20 @@ export function QuoteChaptersDataTable({
)
);
};
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
{
id: "chapter_tree",
header: () => null,
cell: () => null,
size: 44,
enableHiding: false,
enableSorting: false,
},
...columns,
], [columns]);
const table = useReactTable({
data,
columns,
columns: chapterColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
state: { rowSelection, columnVisibility: { discount: showLineDiscountColumn } },
@ -490,26 +584,28 @@ export function QuoteChaptersDataTable({
}}
extraControls={
<>
<Button type='button' variant='link' onClick={onAddChapter}>
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
{t("quotes.chapters.add")}
</Button>
<Button type='button' variant='link' onClick={() => setUngrouping(true)}>
<Button type='button' variant='ghost' size='sm' onClick={() => setUngrouping(true)}>
{t("quotes.chapters.ungroup")}
</Button>
<div className='flex items-center gap-2'>
<Label htmlFor='chapter-line-amounts'>
{t("quotes.chapters.show_amounts")}
</Label>
<Switch
id='chapter-line-amounts'
checked={watch("show_chapter_line_amounts") ?? true}
onCheckedChange={(value) =>
setValue("show_chapter_line_amounts", value, { shouldDirty: true })
}
/>
</div>
</>
}
viewControls={
<div className='flex min-h-9 items-center gap-2'>
<Label htmlFor='chapter-line-amounts'>
{t("quotes.chapters.show_amounts")}
</Label>
<Switch
id='chapter-line-amounts'
checked={watch("show_chapter_line_amounts") ?? true}
onCheckedChange={(value) =>
setValue("show_chapter_line_amounts", value, { shouldDirty: true })
}
/>
</div>
}
/>
{mixed && (
<p role='status' className='text-sm'>
@ -518,24 +614,12 @@ export function QuoteChaptersDataTable({
)}
</CardHeader>
<CardContent>
<Table className='table-fixed'>
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className='px-2'
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
<Table className='table-fixed border-separate border-spacing-0'>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</TableHeader>
</colgroup>
<SortableContext
items={chapters.map((chapter) => `chapter:${chapter.id}`)}
strategy={verticalListSortingStrategy}
@ -564,49 +648,116 @@ export function QuoteChaptersDataTable({
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
subtotal={chapterSubtotal(items, chapter.id, currencyCode).toFormat()}
>
{(collapsed) => (
<SortableContext
items={collapsed ? [] : rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<Fragment key={row.id}>
<QuoteItemsSortableTableRow
id={row.id}
hidden={collapsed}
disabled={collapsed || mixed || chapterSelection.length > 0}
isActive={activeRow === row.id}
onActivate={(event) => {
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
return;
setActiveRow(row.id);
controller.setActiveId(chapter.id);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='px-2 py-2 align-top'>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{activeRow === row.id && (
<TableRow hidden={collapsed}>
<TableCell colSpan={countColumns}>
<QuoteItemsInlineRowActions
confirmDelete={false}
row={row}
table={table}
onDelete={() => setActiveRow(null)}
/>
</TableCell>
</TableRow>
)}
</Fragment>
{(collapsed, hidden) => (
<>
{rows.length > 0 && table.getHeaderGroups().map((group) => (
<TableRow
key={group.id}
hidden={hidden}
data-chapter-columns={chapter.id}
className='bg-muted/30 hover:bg-muted/30'
>
{group.headers.map((header) => (
<TableHead key={header.id} scope='col' className='px-2'>
<ChapterContent>
{header.column.id === "select" ? (
<Checkbox
aria-label={t("quotes.chapters.select_details", {
number: chapterIndex + 1,
})}
checked={
rows.every((row) => row.getIsSelected()) ||
(rows.some((row) => row.getIsSelected()) && "indeterminate")
}
onCheckedChange={(checked) => {
setRowSelection((previous) => {
const next = { ...previous };
for (const row of rows) {
if (checked) next[row.id] = true;
else delete next[row.id];
}
return next;
});
}}
/>
) : header.isPlaceholder ? null : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</ChapterContent>
</TableHead>
))}
</TableRow>
))}
{!rows.length && (
<EmptyChapterTarget id={chapter.id} columns={countColumns} hidden={collapsed} />
)}
</SortableContext>
<SortableContext
items={collapsed ? [] : rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<Fragment key={row.id}>
<QuoteItemsSortableTableRow
id={row.id}
hidden={hidden}
disabled={collapsed || mixed || chapterSelection.length > 0}
isActive={activeRow === row.id}
onActivate={(event) => {
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
return;
setActiveRow(row.id);
controller.setActiveId(chapter.id);
}}
>
{row.getVisibleCells().map((cell) => cell.column.id === "chapter_tree" ? (
<ChapterSpacerCell key={cell.id} />
) : (
<TableCell
key={cell.id}
className={cell.column.id === "description" ? "py-2 pl-4 pr-2 align-top" : "px-2 py-2 align-top"}
>
<ChapterContent>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ChapterContent>
</TableCell>
))}
</QuoteItemsSortableTableRow>
{activeRow === row.id && (
<TableRow hidden={hidden}>
<ChapterSpacerCell />
<TableCell colSpan={countColumns - 1}>
<ChapterContent>
<QuoteItemsInlineRowActions
confirmDelete={false}
row={row}
table={table}
onDelete={() => setActiveRow(null)}
/>
</ChapterContent>
</TableCell>
</TableRow>
)}
</Fragment>
))}
{!rows.length && (
<EmptyChapterTarget
id={chapter.id}
columns={countColumns}
hidden={hidden}
collapsed={collapsed}
onAddRow={() => {
controller.setActiveId(chapter.id);
apply(insertChapterItem(controller.read(), defaultItem, chapter.id));
}}
onAddArticle={() => {
controller.setActiveId(chapter.id);
pickCatalogArticle(chapter.id);
}}
onAddBlock={() => {
controller.setActiveId(chapter.id);
pickBlock(chapter.id);
}}
/>
)}
</SortableContext>
</>
)}
</ChapterGroup>
);

View File

@ -1,13 +1,10 @@
import { act } from "react";
import type { Table } from "@tanstack/react-table";
import { createRoot } from "react-dom/client";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("./AppendBlockRowButton", () => ({ AppendBlockRowButton: () => null }));
@ -41,7 +38,7 @@ jest.mock("@/ui", () => {
const table = {
getSelectedRowModel: () => ({ rows: [] }),
options: { meta: {} },
} as any;
} as unknown as Table<unknown>;
describe("QuoteItemsSortableDataTableToolbar", () => {
let container: HTMLDivElement;
@ -106,3 +103,46 @@ describe("QuoteItemsSortableDataTableToolbar", () => {
expect(onChange).toHaveBeenCalledWith(false);
});
});
describe("grouped toolbar selection actions", () => {
it.each([true, false])("preserves action handlers and display controls when selecting chapters=%s", (chapters) => {
const chapterActions = { duplicate: jest.fn(), remove: jest.fn(), clear: jest.fn() };
const lineActions = { duplicate: jest.fn(), remove: jest.fn(), clear: jest.fn() };
const selectedTable = {
getSelectedRowModel: () => ({ rows: [{}, {}] }),
options: { meta: { duplicateItems: lineActions.duplicate, deleteItems: lineActions.remove } },
resetRowSelection: lineActions.clear,
} as unknown as typeof table;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
try {
act(() => root.render(
<QuoteItemsSortableDataTableToolbar
table={selectedTable}
selectionActions={chapters ? { count: 2, ...chapterActions } : undefined}
extraControls={<button type='button'>chapter-action</button>}
viewControls={<span data-testid='pdf-settings'>PDF</span>}
showLineDiscountColumn={true}
hasLineDiscounts={false}
onShowLineDiscountColumnChange={jest.fn()}
/>
));
for (const key of ["duplicate_selected_rows", "remove_selected_rows", "reset_selected_rows"]) {
const button = Array.from(container.querySelectorAll("button"))
.find((entry) => entry.textContent === `common.${key}`)!;
act(() => button.click());
}
const expected = chapters ? chapterActions : lineActions;
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);
expect(container.querySelector('[data-testid="pdf-settings"]')!.closest('[role="group"]')).toBeNull();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
} finally {
act(() => root.unmount());
container.remove();
}
});
});

View File

@ -1,3 +1,4 @@
import { ButtonGroup } from "@/components/ButtonGroup";
import {
Button,
Dialog,
@ -6,7 +7,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Separator,
Switch,
Label,
Tooltip,
@ -27,10 +27,12 @@ export const QuoteItemsSortableDataTableToolbar = ({
hasLineDiscounts,
onShowLineDiscountColumnChange,
extraControls,
viewControls,
selectionActions,
}: {
table: Table<any>;
extraControls?: ReactNode;
viewControls?: ReactNode;
selectionActions?: { count: number; duplicate: () => void; remove: () => void; clear: () => void };
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
@ -50,7 +52,7 @@ export const QuoteItemsSortableDataTableToolbar = ({
const discountColumnControl = (
<>
<div className='flex items-center gap-2 ml-auto'>
<div className='flex min-h-9 items-center gap-2'>
<div className='grid gap-0.5'>
<Label htmlFor="show-line-discount-column">{t("quotes.discount_column.label")}</Label>
</div>
@ -87,104 +89,92 @@ export const QuoteItemsSortableDataTableToolbar = ({
</>
);
if (selectedRowsCount) {
return (
<nav className='flex items-center min-h-12 flex-wrap gap-2 p-1 rounded-md text-muted-foreground bg-muted '>
<div className='flex flex-wrap items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.duplicate() : table.options.meta?.duplicateItems()}
>
<CopyPlusIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.remove() : table.options.meta?.deleteItems()}
>
<Trash2Icon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.clear() : table.resetRowSelection()}
>
<ScanIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.reset_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.reset_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Separator orientation='vertical' className='h-6 ml-1 mr-4' />
<p className='text-sm font-normal'>
{t("common.rows_selected", { count: selectedRowsCount })}
</p>
{extraControls}
{discountColumnControl}
</div>
</nav>
);
}
return (
<nav className='flex items-center min-h-12 flex-wrap gap-2 p-1 rounded-md bg-accent/75 text-muted-foreground'>
<div className='flex space-x-2'>
<Tooltip>
<TooltipTrigger asChild>
<AppendEmptyRowButton variant='link' onClick={() => table.options.meta?.appendItem()} />
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendCatalogArticleRowButton
variant='link'
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_article_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendBlockRowButton
variant='link'
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
</Tooltip>
<nav className='flex flex-wrap items-center gap-3 rounded-lg border border-border bg-muted/40 p-2'>
<ButtonGroup role='group' className='flex-wrap gap-1 rounded-md border border-border bg-background p-1 shadow-sm'>
{selectedRowsCount > 0 ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => selectionActions ? selectionActions.duplicate() : table.options.meta?.duplicateItems()}
>
<CopyPlusIcon className='h-4 w-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.duplicate_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:bg-destructive/10 hover:text-destructive'
onClick={() => selectionActions ? selectionActions.remove() : table.options.meta?.deleteItems()}
>
<Trash2Icon className='h-4 w-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => selectionActions ? selectionActions.clear() : table.resetRowSelection()}
>
<ScanIcon className='h-4 w-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.reset_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.reset_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<AppendEmptyRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.appendItem()} />
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendCatalogArticleRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.pickCatalogArticle?.()} />
</TooltipTrigger>
<TooltipContent>{t("common.append_article_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendBlockRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.pickBlock?.()} />
</TooltipTrigger>
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
</Tooltip>
</>
)}
</ButtonGroup>
{selectedRowsCount > 0 && (
<p className='whitespace-nowrap px-1 text-sm text-muted-foreground'>
{t("common.rows_selected", { count: selectedRowsCount })}
</p>
)}
{extraControls && (
<ButtonGroup role='group' className='flex-wrap gap-1 rounded-md border border-border bg-background p-1 shadow-sm'>
{extraControls}
</ButtonGroup>
)}
<div className='flex w-full flex-wrap items-center gap-x-4 gap-y-2 border-t border-border pt-2 xl:ml-auto xl:w-auto xl:border-t-0 xl:pt-0'>
{viewControls}
{discountColumnControl}
</div>
{extraControls}
{discountColumnControl}
</nav>
);
};

View File

@ -1,4 +1,5 @@
import { act } from "react";
import type { Header } from "@tanstack/react-table";
import { createRoot } from "react-dom/client";
import { FormProvider, useForm, FieldValues, UseFormReturn } from "react-hook-form";
import { TooltipProvider } from "@/ui";
@ -149,7 +150,10 @@ jest.mock("@/components", () => {
FormQuantityField: Amount,
FormPercentageField: Amount,
ButtonGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DataTableColumnHeader: () => null,
DataTableColumnHeader: ({ header }: { header: Header<unknown, unknown> }) => {
const { flexRender } = jest.requireActual<typeof import("@tanstack/react-table")>("@tanstack/react-table");
return flexRender(header.column.columnDef.header, header.getContext());
},
DataTableRowActions: () => null,
};
});
@ -181,7 +185,16 @@ jest.mock("./CatalogPickerDialog", () => ({
</button>
) : null,
}));
jest.mock("./BlocksPickerDialog/BlocksPickerDialog", () => ({ BlocksPickerDialog: () => null }));
jest.mock("./BlocksPickerDialog/BlocksPickerDialog", () => ({
BlocksPickerDialog: ({ isOpen, onSelect }: {
isOpen: boolean;
onSelect: (block: { title: string; body: string }) => void;
}) => isOpen ? (
<button type='button' onClick={() => onSelect({ title: "Bloque", body: "Texto" })}>
Elegir bloque de prueba
</button>
) : null,
}));
const chapterA = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
const chapterB = "35ea9365-f3d3-4127-ab31-7be82dcdd006";
@ -510,3 +523,203 @@ it("keeps folding attached to the chapter when reordered and opens duplicates in
expect(screen.getByLabelText("Plegar capítulo 3").getAttribute("aria-expanded")).toBe("true");
await act(async () => { await Promise.resolve(); });
});
it.each([true, false])("keeps the hierarchy column aligned with headers, details, actions and subtotals (discounts=%s)", async (discounts) => {
render(<Harness grouped />);
await act(async () => {
currentForm.setValue("show_line_discount_column", discounts);
});
const before = quoteUpdatePayload(snapshot() as IGetQuote_Response_DTO);
const table = screen.getByLabelText("items.0.description").closest("table")!;
const columns = table.querySelector<HTMLTableRowElement>("[data-chapter-columns]")!.cells.length;
const coveredColumns = (row: HTMLTableRowElement) =>
Array.from(row.cells).reduce((count, cell) => count + cell.colSpan, 0);
expect(table.tBodies).toHaveLength(2);
expect(screen.getByLabelText("items.0.description").closest("tr")!.cells[0].dataset.chapterColumn).toBe("chapter_tree");
fireEvent.focus(screen.getByLabelText("items.0.description"));
expect(Array.from(table.rows).map(coveredColumns)).toEqual(Array(table.rows.length).fill(columns));
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
expect(Array.from(table.rows).map(coveredColumns)).toEqual(Array(table.rows.length).fill(columns));
expect(quoteUpdatePayload(snapshot() as IGetQuote_Response_DTO)).toEqual(before);
expect(ensureUpdateQuote_Request_DTOIsValid(before).isSuccess).toBe(true);
});
it.each([true, false])("orders expand, drag and select controls with the expand column only in chapter mode (grouped=%s)", (grouped) => {
render(<Harness grouped={grouped} />);
const field = screen.getByLabelText("items.0.description");
const row = field.closest("tr")!;
const table = row.closest("table")!;
const dragIndex = grouped ? 1 : 0;
const selectIndex = dragIndex + 1;
expect(row.cells[dragIndex].textContent).toContain(i18n.t("common.move_row"));
expect(row.cells[selectIndex].querySelector('[role="checkbox"]')).toBeTruthy();
const header = table.querySelector<HTMLTableRowElement>("[data-chapter-columns]") ?? table.tHead!.rows[0];
expect(header.cells[selectIndex].querySelector('[role="checkbox"]')).toBeTruthy();
expect(field.closest("td")).toBe(row.cells[selectIndex + 1]);
expect(screen.getByLabelText("items.0.quantity").closest("td")).toBe(row.cells[selectIndex + 2]);
expect(table.querySelectorAll('[data-chapter-column="chapter_tree"]').length > 0).toBe(grouped);
if (grouped) {
const heading = table.querySelector<HTMLTableRowElement>(`[data-chapter-id="${chapterA}"]`)!;
expect(screen.getByLabelText("Plegar capítulo 1").closest("td")).toBe(heading.cells[0]);
expect(screen.getByLabelText("Mover capítulo 1").closest("td")).toBe(heading.cells[1]);
expect(screen.getByLabelText("Seleccionar capítulo 1").closest("td")).toBe(heading.cells[2]);
}
});
it("renders column headers only inside populated chapters and hides them when folded", () => {
render(<Harness grouped />);
const table = screen.getByLabelText("items.0.description").closest("table")!;
const header = table.querySelector<HTMLTableRowElement>(`[data-chapter-columns="${chapterA}"]`)!;
expect(table.tHead).toBeNull();
expect(header.previousElementSibling).toBe(table.querySelector(`[data-chapter-id="${chapterA}"]`));
expect(header.nextElementSibling).toBe(screen.getByLabelText("items.0.description").closest("tr"));
expect(header.textContent).toContain("Descripción");
expect(table.querySelector(`[data-chapter-columns="${chapterB}"]`)).toBeNull();
const empty = table.querySelector<HTMLTableRowElement>(`[data-empty-chapter="${chapterB}"]`)!;
expect(empty.querySelector('[data-slot="empty"]')).toBeTruthy();
expect(empty.textContent).toContain(i18n.t("quotes.chapters.empty_title"));
for (const label of ["common.append_empty_row", "common.append_article", "common.append_block"]) {
expect(within(empty).getByText(i18n.t(label)).tagName).toBe("BUTTON");
}
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
expect(header.hidden).toBe(true);
fireEvent.click(screen.getByLabelText("Desplegar capítulo 1"));
expect(header.hidden).toBe(false);
fireEvent.click(screen.getByLabelText("Plegar capítulo 2"));
expect(empty.hidden).toBe(true);
});
it("adds a blank detail directly to its empty chapter and replaces Empty with column headers", () => {
render(<Harness grouped />);
fireEvent.focus(screen.getByLabelText("Título del capítulo 1"));
const before = snapshot().items[0];
const empty = document.querySelector<HTMLElement>(`[data-empty-chapter="${chapterB}"]`)!;
fireEvent.click(within(empty).getByText(i18n.t("common.append_empty_row")));
expect(snapshot().items).toHaveLength(2);
expect(snapshot().items[0]).toEqual(before);
expect(snapshot().items[1].chapter_id).toBe(chapterB);
expect(document.querySelector(`[data-empty-chapter="${chapterB}"]`)).toBeNull();
expect(document.querySelector(`[data-chapter-columns="${chapterB}"]`)).toBeTruthy();
});
it.each([
["common.append_article", "Elegir artículo de prueba", "Catálogo"],
["common.append_block", "Elegir bloque de prueba", "Bloque\nTexto"],
])("keeps the empty chapter as picker destination when focus changes (%s)", (label, choice, description) => {
render(<Harness grouped />);
fireEvent.focus(screen.getByLabelText("Título del capítulo 1"));
const empty = document.querySelector<HTMLElement>(`[data-empty-chapter="${chapterB}"]`)!;
fireEvent.click(within(empty).getByText(i18n.t(label)));
fireEvent.focus(screen.getByLabelText("Título del capítulo 1"));
fireEvent.click(screen.getByText(choice));
expect(snapshot().items).toHaveLength(2);
expect(snapshot().items[1]).toMatchObject({ chapter_id: chapterB, description });
expect(document.querySelector(`[data-empty-chapter="${chapterB}"]`)).toBeNull();
});
it("selects only the details of the chapter whose column header checkbox is clicked", async () => {
render(<Harness grouped />);
await act(async () => {
currentForm.setValue("items", [item(chapterA), item(chapterA), item(chapterB)]);
});
fireEvent.click(screen.getByLabelText("Seleccionar los detalles del capítulo 1"));
const selected = [0, 1, 2].map((index) =>
within(screen.getByLabelText(`items.${index}.description`).closest("tr")!)
.getByLabelText("Seleccionar fila").getAttribute("aria-checked")
);
expect(selected).toEqual(["true", "true", "false"]);
expect(screen.getByLabelText("Seleccionar los detalles del capítulo 2").getAttribute("aria-checked")).toBe("false");
fireEvent.click(screen.getByLabelText("Seleccionar los detalles del capítulo 1"));
expect(screen.getByLabelText("Seleccionar los detalles del capítulo 1").getAttribute("aria-checked")).toBe("false");
});
it("shows chapter numbers as badges on the folder controls, including after reordering", () => {
render(<Harness grouped />);
const assertBadge = (id: string, number: number) => {
const heading = document.querySelector<HTMLTableRowElement>(`[data-chapter-id="${id}"]`)!;
const toggle = heading.cells[0].querySelector("button")!;
const badge = heading.cells[0].querySelector('[aria-hidden="true"]:not(svg)')!;
expect(badge.textContent).toBe(String(number));
expect(toggle.querySelector("svg")).toBeTruthy();
expect(toggle.getAttribute("aria-label")).toContain(String(number));
expect(heading.cells[3].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
expect(heading.cells[3].textContent).not.toMatch(/^\s*\d+/);
};
assertBadge(chapterA, 1);
assertBadge(chapterB, 2);
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
assertBadge(chapterA, 1);
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
assertBadge(chapterB, 1);
assertBadge(chapterA, 2);
});
it("keeps fields mounted until the closing animation ends and cancels stale transitions on reversal", async () => {
const original = HTMLElement.prototype.animate;
const transitions: { finish: () => void; cancel: jest.Mock }[] = [];
const animate = jest.fn(() => {
let finish!: () => void;
const finished = new Promise<void>((resolve) => { finish = resolve; });
const cancel = jest.fn();
transitions.push({ finish, cancel });
return { finished, cancel } as unknown as Animation;
});
HTMLElement.prototype.animate = animate;
try {
render(<Harness grouped />);
const field = screen.getByLabelText("items.0.description");
const row = field.closest("tr")!;
const before = JSON.stringify(snapshot());
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
expect(row.hidden).toBe(false);
expect(row.hasAttribute("inert")).toBe(true);
expect(row.getAttribute("aria-hidden")).toBe("true");
expect(animate).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ height: "0px", opacity: 0 })]),
expect.objectContaining({ duration: 200 })
);
const closing = transitions.splice(0);
fireEvent.click(screen.getByLabelText("Desplegar capítulo 1"));
expect(closing.every(({ cancel }) => cancel.mock.calls.length === 1)).toBe(true);
expect(row.hasAttribute("inert")).toBe(false);
await act(async () => { closing.forEach(({ finish }) => finish()); });
expect(row.hidden).toBe(false);
await act(async () => { transitions.splice(0).forEach(({ finish }) => finish()); });
expect(row.hidden).toBe(false);
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
await act(async () => { transitions.splice(0).forEach(({ finish }) => finish()); });
expect(row.hidden).toBe(true);
expect(field.isConnected).toBe(true);
expect(JSON.stringify(snapshot())).toBe(before);
fireEvent.click(screen.getByLabelText("Desplegar capítulo 1"));
expect(row.hidden).toBe(false);
await act(async () => { transitions.splice(0).forEach(({ finish }) => finish()); });
expect(row.hidden).toBe(false);
expect(JSON.stringify(snapshot())).toBe(before);
} finally {
HTMLElement.prototype.animate = original;
}
});
it("folds immediately without animation when reduced motion is preferred", () => {
const originalAnimate = HTMLElement.prototype.animate;
const originalMatchMedia = window.matchMedia;
const animate = jest.fn();
HTMLElement.prototype.animate = animate;
window.matchMedia = jest.fn(() => ({ matches: true } as MediaQueryList));
try {
render(<Harness grouped />);
const row = screen.getByLabelText("items.0.description").closest("tr")!;
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
expect(row.hidden).toBe(true);
fireEvent.click(screen.getByLabelText("Desplegar capítulo 1"));
expect(row.hidden).toBe(false);
expect(animate).not.toHaveBeenCalled();
} finally {
HTMLElement.prototype.animate = originalAnimate;
window.matchMedia = originalMatchMedia;
}
});

View File

@ -266,8 +266,8 @@ export const QuoteDetailsCardEditor = ({
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
const pickCatalogArticle = () => { chapterEditor.capturePickerTarget(); setArticlePickerDialogOpen(true); };
const pickBlock = () => { chapterEditor.capturePickerTarget(); setBlockPickerDialogOpen(true); };
const pickCatalogArticle = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setArticlePickerDialogOpen(true); };
const pickBlock = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setBlockPickerDialogOpen(true); };
const onShowLineDiscountColumnChange = (value: boolean) => setValue("show_line_discount_column", value, { shouldDirty: true });
const table = chapterEditor.chapters.length ? (
<QuoteChaptersDataTable controller={chapterEditor} columns={columns} data={fields}
@ -278,7 +278,7 @@ export const QuoteDetailsCardEditor = ({
) : (
<QuoteItemsSortableDataTable actions={{ ...fieldActions, getItem: (rowIndex) => getValues(`items.${rowIndex}`), pickCatalogArticle, pickBlock }}
columns={columns} data={fields} defaultValues={defaultValues}
extraControls={<Button type="button" variant="link" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
extraControls={<Button type="button" variant="ghost" size="sm" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange} />
);

View File

@ -0,0 +1,96 @@
import { useLayoutEffect, useRef, useState } from "react";
const duration = 200;
// Table rows cannot shrink below their contents. Animate cell contents and
// spacing together, then hide the rows without unmounting form fields.
export function useChapterCollapse() {
const ref = useRef<HTMLTableSectionElement | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [hidden, setHidden] = useState(false);
const mounted = useRef(false);
const animations = useRef<Animation[]>([]);
const revision = useRef(0);
useLayoutEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
const currentRevision = ++revision.current;
const contents = Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []);
const rows = Array.from(new Set(contents.map((content) => content.closest("tr")!)));
for (const row of rows) {
row.toggleAttribute("inert", collapsed);
if (collapsed) row.setAttribute("aria-hidden", "true");
else row.removeAttribute("aria-hidden");
}
const cells = rows.flatMap((row) => Array.from(row.cells));
// Measure before cancelling so reversing a running animation starts at its
// current position rather than jumping to a fully open or closed state.
const contentStarts = contents.map((content) => ({
height: content.getBoundingClientRect().height,
opacity: getComputedStyle(content).opacity,
}));
const cellStarts = cells.map((cell) => {
const style = getComputedStyle(cell);
return {
height: style.height,
paddingTop: style.paddingTop,
paddingBottom: style.paddingBottom,
borderBottomWidth: style.borderBottomWidth,
};
});
animations.current.forEach((animation) => animation.cancel());
animations.current = [];
if (!contents.length || typeof contents[0].animate !== "function" ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
setHidden(collapsed);
return;
}
const contentEnds = contents.map((content) => content.getBoundingClientRect().height);
const cellEnds = cells.map((cell) => {
const style = getComputedStyle(cell);
return {
height: collapsed ? "0px" : style.height,
paddingTop: collapsed ? "0px" : style.paddingTop,
paddingBottom: collapsed ? "0px" : style.paddingBottom,
borderBottomWidth: collapsed ? "0px" : style.borderBottomWidth,
};
});
const options: KeyframeAnimationOptions = { duration, easing: "ease-in-out", fill: "both" };
animations.current = [
...contents.map((content, index) => content.animate([
{ height: `${contentStarts[index].height}px`, opacity: contentStarts[index].opacity, overflow: "hidden" },
{ height: `${collapsed ? 0 : contentEnds[index]}px`, opacity: collapsed ? 0 : 1, overflow: "hidden" },
], options)),
...cells.map((cell, index) => cell.animate([cellStarts[index], cellEnds[index]], options)),
];
void Promise.all(animations.current.map((animation) => animation.finished)).then(() => {
if (revision.current !== currentRevision) return;
setHidden(collapsed);
if (!collapsed) {
animations.current.forEach((animation) => animation.cancel());
animations.current = [];
}
}).catch(() => {
// Cancellation is expected when toggling again or unmounting.
});
}, [collapsed]);
useLayoutEffect(() => () => {
++revision.current;
animations.current.forEach((animation) => animation.cancel());
}, []);
return {
ref,
collapsed,
hidden,
toggle: () => {
setHidden(false);
setCollapsed((value) => !value);
},
};
}

View File

@ -42,8 +42,8 @@ export function useChapterEditor(replace: UseFieldArrayReturn<FieldValues, "item
);
replace(state.items);
};
const capturePickerTarget = () => {
pickerTarget.current = destination();
const capturePickerTarget = (chapterId?: string) => {
pickerTarget.current = chapterId ?? destination();
};
const appendPickedItem = (item: EditorItem) =>
apply(insertChapterItem(read(), item, pickerTarget.current));

View File

@ -31,20 +31,6 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
return useMemo(() => {
const result = [...columns];
if (enableDragHandleColumn) {
result.unshift({
id: "row_drag_handle",
/*header: () => (
<UnfoldVertical aria-label='Mover fila' className='items-center justify-center w-4 h-4' />
),*/
header: () => null,
cell: () => <QuoteItemsSortableDragHandleCell />,
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableSelectionColumn) {
result.unshift({
id: "select",
@ -77,6 +63,20 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
});
}
if (enableDragHandleColumn) {
result.unshift({
id: "row_drag_handle",
/*header: () => (
<UnfoldVertical aria-label='Mover fila' className='items-center justify-center w-4 h-4' />
),*/
header: () => null,
cell: () => <QuoteItemsSortableDragHandleCell />,
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableActionsColumn) {
result.push({
id: "row_actions",

View File

@ -56,7 +56,9 @@
"duplicate": "Duplicar capítol {{number}}",
"remove": "Eliminar capítol {{number}}",
"subtotal": "Subtotal capítol {{number}}. {{description}}",
"empty": "Capítol buit. Afegeix detalls o arrossega’ls aquí.",
"empty_title": "Aquest capítol no té detalls",
"empty": "Afegeix una fila, articles o un bloc de text, o arrossega detalls aquí.",
"select_details": "Seleccionar els detalls del capítol {{number}}",
"show_amounts": "Mostrar imports dels detalls al PDF",
"mixed_selection": "Per moure la selecció, selecciona només capítols o només detalls.",
"drag_instructions": "Prem espai per iniciar el moviment, utilitza les fletxes amunt i avall i torna a prémer espai per deixar anar. Escapament cancel·la.",

View File

@ -512,7 +512,9 @@
"duplicate": "Duplicate chapter {{number}}",
"remove": "Delete chapter {{number}}",
"subtotal": "Subtotal chapter {{number}}. {{description}}",
"empty": "Empty chapter. Add details or drag them here.",
"empty_title": "This chapter has no details",
"empty": "Add a row, articles or a text block, or drag details here.",
"select_details": "Select details in chapter {{number}}",
"show_amounts": "Show detail amounts in the PDF",
"mixed_selection": "To move the selection, select only chapters or only details.",
"drag_instructions": "Press space to start moving, use the up and down arrows, then press space again to drop. Escape cancels.",

View File

@ -512,7 +512,9 @@
"duplicate": "Duplicar capítulo {{number}}",
"remove": "Eliminar capítulo {{number}}",
"subtotal": "Subtotal capítulo {{number}}. {{description}}",
"empty": "Capítulo vacío. Añade detalles o arrástralos aquí.",
"empty_title": "Este capítulo no tiene detalles",
"empty": "Añade una fila, artículos o un bloque de texto, o arrastra detalles aquí.",
"select_details": "Seleccionar los detalles del capítulo {{number}}",
"show_amounts": "Mostrar importes de los detalles en el PDF",
"mixed_selection": "Para mover la selección, selecciona solo capítulos o solo detalles.",
"drag_instructions": "Pulsa espacio para iniciar el movimiento, usa las flechas arriba y abajo y vuelve a pulsar espacio para soltar. Escape cancela.",

View File

@ -512,7 +512,9 @@
"duplicate": "Dupliquer le chapitre {{number}}",
"remove": "Supprimer le chapitre {{number}}",
"subtotal": "Sous-total du chapitre {{number}}. {{description}}",
"empty": "Chapitre vide. Ajoutez des détails ou faites-les glisser ici.",
"empty_title": "Ce chapitre ne contient aucun détail",
"empty": "Ajoutez une ligne, des articles ou un bloc de texte, ou glissez des détails ici.",
"select_details": "Sélectionner les détails du chapitre {{number}}",
"show_amounts": "Afficher les montants des détails dans le PDF",
"mixed_selection": "Pour déplacer la sélection, sélectionnez uniquement des chapitres ou uniquement des détails.",
"drag_instructions": "Appuyez sur espace pour commencer, utilisez les flèches haut et bas, puis appuyez à nouveau sur espace pour déposer. Échap annule.",

27
client/src/ui/empty.tsx Normal file
View File

@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";
// shadcn/ui Empty composition, adapted to the project's React 18/Tailwind 3 primitives.
export function Empty({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot='empty' className={cn("flex min-w-0 flex-col items-center justify-center gap-6 rounded-lg border border-dashed border-transparent p-6 text-center md:p-8", className)} {...props} />;
}
export function EmptyHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot='empty-header' className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)} {...props} />;
}
export function EmptyMedia({ className, variant = "default", ...props }: React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "icon" }) {
return <div data-slot='empty-media' data-variant={variant} className={cn("flex shrink-0 items-center justify-center", variant === "icon" && "h-10 w-10 rounded-lg bg-muted text-foreground [&_svg]:h-6 [&_svg]:w-6", className)} {...props} />;
}
export function EmptyTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot='empty-title' className={cn("text-lg font-medium tracking-tight", className)} {...props} />;
}
export function EmptyDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p data-slot='empty-description' className={cn("text-sm leading-relaxed text-muted-foreground", className)} {...props} />;
}
export function EmptyContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot='empty-content' className={cn("flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm", className)} {...props} />;
}

View File

@ -17,6 +17,7 @@ export * from "./context-menu";
export * from "./dialog";
export * from "./drawer";
export * from "./dropdown-menu";
export * from "./empty";
export * from "./form";
export * from "./hover-card";
export * from "./input";

View File

@ -217,6 +217,12 @@ Cada etapa tendrá pruebas de su comportamiento; la funcionalidad no se consider
## Validación realizada y hallazgos previos
### Integración MariaDB: preparación y acceso pendiente
- Nueva suite explícita `cd server && yarn test:mariadb`, separada de los tests habituales. Catorce casos preparados para esquema histórico, guardado/recarga, presentador API/HTML, movimiento, duplicado, desagrupación, rollback real mediante triggers, bloqueo concurrente, segundo arranque y FK compuesta.
- El fixture exige MariaDB y acceso específico por variables `UECKO_MARIADB_TEST_*`. Crea y elimina únicamente un esquema nuevo aleatorio con datos ficticios, sin usar la configuración ni las tablas de la aplicación. Seis pruebas unitarias de seguridad del fixture.
- No se da por completada esta fase: el acceso facilitado a `localhost:3306` funciona, pero el servidor se identifica como MySQL 8.0.42. La suite rechaza ese motor antes del DDL y deja intacta `uecko`. Falta un destino MariaDB o autorización para cambiar el motor de validación. Procedimiento, permisos y límites en `doc/capitulos-esquema.md`.
### Entrega de fase 5
- `ReportQuote.reporter.ts`: proyección ordenada de cabeceras, detalles y pies. Los subtotales proceden de `Quote.getChapterSubtotal`, después del descuento de línea y antes del descuento general e impuestos. Sin capítulos se ignora `show_chapter_line_amounts`. Con capítulos y preferencia desactivada, los importes individuales tampoco se incorporan a la proyección del informe.
@ -311,3 +317,50 @@ Inconsistencias observadas, sin cambios fuera del alcance:
- `QuoteItemsSortableTableRow.tsx`: soporte para filas ocultas. Los campos permanecen montados para conservar valores y validación; los detalles y destinos vacíos plegados se excluyen del movimiento de filas. Los capítulos completos siguen pudiendo desplazarse.
- `QuoteChapterEditor.test.tsx`: plegar/desplegar sin alterar datos ni payload, conservar campos editados y subtotal, ocultar acciones de fila, mantener plegado al reordenar y desplegar copias de forma independiente.
- Validación: 22 pruebas de editor y operaciones de capítulos, cinco comprobaciones de traducciones, TypeScript sin emisión y ESLint acotado (dos advertencias previas de contexto/`any` en la fila reutilizable). La suite de editor emite un aviso de actualización asíncrona de paginación fuera de `act`; las dos pruebas nuevas de plegado se ejecutan también aisladas y pasan sin ese aviso. No se modifican ni repiten las pruebas de servidor/PDF, ni se ejecutan E2E o builds de distribución: el cambio se limita al estado visual del cliente.
### Ampliación: agrupación visual de capítulos
- `QuoteChaptersDataTable.tsx`: estilos mediante clases TailwindCSS inline, utilizando exclusivamente los colores existentes y grises `zinc`. No se añade CSS externo ni se introducen tonos azul/verde. Se mantiene la paleta de los controles actuales.
- Columna estructural `chapter_tree` de 44 px, exclusiva del editor con capítulos. En la cabecera contiene el control de plegado; en detalles, acciones y subtotal queda vacía para mantener la alineación, sin guías ni bordes verticales. Esta columna no forma parte del formulario, DTO ni PDF.
- Numeración destacada, icono de carpeta, cabecera gris y subtotal diferenciado, con separadores horizontales y sin marco lateral. Separación de 20 px entre capítulos y sangría de las descripciones. La distinción de capítulos se apoya en límites, espacio y numeración, sin colores alternos. Las cantidades e importes conservan sus tamaños y alineación.
- Ajustados todos los `colspan`: cabeceras, detalles, acciones de fila, capítulos vacíos y pies. Los campos permanecen montados al plegar; no cambian selección, traslado, duplicado ni cálculos.
- Dos comprobaciones nuevas verifican que las filas cubran las mismas columnas al mostrar acciones, plegar o tener capítulos vacíos, con descuentos visibles y ocultos; también verifican que no cambie el payload. No se añaden pruebas que reproduzcan el CSS.
- Validación: 24 pruebas entre editor y operaciones de capítulos; TypeScript sin emisión y ESLint del componente/tests sin errores ni advertencias. Tailwind compila las clases del componente hacia `/tmp/uecko-chapters-neutral.css`, sin modificar `dist` ni instalar dependencias. Persiste el aviso previo de React/TanStack sobre una actualización de paginación fuera de `act` en la suite de editor.
- Inspección con Chromium de muestra DOM del harness en ambos estados, verificando guía, separación, fondos neutros y subtotal visible. Los campos y sensores de la muestra son dobles de test, por lo que no es un E2E contra la API autenticada. El fixture temporal se retiró; las muestras permanecen en `/tmp/uecko-chapters-neutral-*`.
- No se ejecutan servidor, base de datos, pruebas PDF ni builds de distribución para este cambio visual. Se conservan los cambios previos de integración MariaDB sin modificación funcional.
### Ampliación: orden de columnas del editor
- Orden con capítulos: despliegue/plegado, arrastre, selección y resto de columnas existentes. El control de despliegue se encuentra únicamente en la cabecera del capítulo; los detalles reservan esa columna vacía, sin líneas verticales.
- Orden sin capítulos: arrastre, selección y resto de columnas; no se añade la columna de despliegue.
- `useDetailColumns.tsx` establece arrastre antes de selección y `QuoteChaptersDataTable.tsx` alinea las cabeceras de capítulo con ese orden. El control de la primera columna muestra `FolderIcon` al estar plegado y `FolderOpenIcon` al estar desplegado; se elimina la carpeta decorativa del título. Se mantienen las clases Tailwind inline existentes.
- Dos casos de prueba comprueban posición de controles en ambos modos, cabecera de selección, alineación de descripción/cantidad y ausencia de la columna de despliegue en cotizaciones libres. El doble de cabecera del formulario ahora renderiza su contenido, para verificar también la tabla sin capítulos.
- Validación: 19 pruebas de editor y asa de arrastre, TypeScript sin emisión, ESLint acotado y `git diff --check` correctos. Persiste el aviso previo de paginación React/TanStack fuera de `act`. No se repiten PDF, base de datos ni E2E, al limitarse el cambio al orden de columnas del editor.
### Ajuste visual: sin líneas verticales
- Se retiran las guías de ramificación, el borde lateral izquierdo, el borde derecho y la franja vertical de fondo. Se conserva vacía la columna de despliegue en las filas de detalle para respetar el orden y alineación de controles.
- Los capítulos se distinguen por separación de bloques, numeración, fondos grises de título/subtotal y separadores horizontales. Se mantienen los iconos de carpeta abierta/cerrada.
- Las muestras visuales de `/tmp/uecko-chapters-neutral-*` corresponden a la revisión anterior, con guías; no representan este ajuste. La validación de este cambio se limita a las seis pruebas existentes de plegado, orden y alineación, ESLint acotado y `git diff --check`; no se repiten E2E/PDF ni se modifica persistencia por tratarse de una retirada de decoración.
### Ampliación: barra de acciones con grupos de botones
- `QuoteItemsSortableDataTableToolbar.tsx` reutiliza `ButtonGroup` existente (importación directa) y los `Button` de la UI. Agrupa altas de fila/artículo/bloque o acciones de selección en un bloque compacto; las acciones de capítulos forman otro grupo. Botones `ghost` pequeños, marcos neutros y clases Tailwind inline; no se instalan componentes ni dependencias.
- Unifica la estructura de la barra con y sin selección. Se conservan tooltips, textos accesibles, contador seleccionado y los callbacks de tabla o de selección de capítulos. Eliminar mantiene el color destructivo existente y sus confirmaciones siguen en los flujos actuales.
- La nueva prop opcional `viewControls` coloca el interruptor de importes PDF junto al de descuentos, fuera de los grupos de comandos. `QuoteChaptersDataTable.tsx` y `QuoteDetailsCardEditor.tsx` adaptan los botones de capítulos para mantener una presentación uniforme, también antes de crear el primer capítulo.
- Disposición con salto de línea: grupos de botones y controles se adaptan al ancho. Chromium verifica las muestras de alta y selección a 1440 y 375 px sin desbordamiento de la barra, con ambos interruptores visibles. HTML generado desde el harness del editor; esta revisión no constituye un E2E con la API autenticada. Artefactos temporales en `/tmp/uecko-toolbar-*`; el fixture temporal se retiró.
- Dos pruebas nuevas verifican delegación de duplicar/eliminar/deseleccionar en modo líneas y capítulos, y conservación de opciones PDF/descuento fuera de los grupos de botones. Se mantienen las pruebas de confirmación al ocultar descuentos.
- Validación: 22 pruebas de barra y editor; TypeScript sin emisión; compilación Tailwind hacia `/tmp`; ESLint acotado sin errores, con cuatro advertencias `any` previas en los componentes de barra/editor; `git diff --check` correcto. Permanece el aviso previo de paginación React/TanStack fuera de `act` en la suite de editor.
- No se ejecutan base de datos, PDF, builds de distribución ni E2E contra la aplicación para este cambio de presentación. No se alteran los datos ni los cálculos de la cotización.
### Ampliación: cabeceras por capítulo, estado vacío y numeración en carpeta
- `QuoteChaptersDataTable.tsx` muestra las cabeceras de columnas después del título de cada capítulo con contenido, dentro de su grupo de filas. Se ocultan al plegarlo. Un `colgroup` común mantiene la alineación; las cotizaciones sin capítulos conservan su cabecera global.
- La casilla de la cabecera selecciona o deselecciona únicamente los detalles de ese capítulo. La casilla del título sigue seleccionando el capítulo completo.
- Los capítulos vacíos muestran `Empty` con mensaje, icono y acciones para añadir fila vacía, artículo o bloque. Se añade `ui/empty.tsx` siguiendo la composición de [Empty de shadcn/ui](https://ui.shadcn.com/docs/components/empty), adaptada a React 18 y Tailwind 3 sin dependencias nuevas. Los textos están traducidos en `es/en/ca/fr`.
- Las acciones usan el identificador explícito del capítulo vacío. `QuoteDetailsCardEditor.tsx` y `useChapterEditor.ts` conservan ese destino al abrir un selector, aunque cambie el foco antes de elegir contenido. Al añadir el primer detalle, el estado vacío se sustituye por las cabeceras y la fila.
- El número de orden sale del título y aparece como `Badge` gris superpuesto al icono de carpeta de la primera columna. Se mantiene al plegar y se actualiza al reordenar; no altera la descripción ni el PDF. Las etiquetas accesibles del botón siguen incluyendo el número.
- Validación: 28 pruebas de editor/barra y cinco de traducciones; TypeScript sin emisión; ESLint acotado sin errores (tres advertencias `any` previas del editor); Tailwind compilado en `/tmp`; `git diff --check`. Persisten los avisos previos de paginación fuera de `act` y de Browserslist desactualizado.
- Revisión local con Chromium a 1440 y 375 px sobre HTML del harness: badge sobre carpeta, una cabecera para el capítulo con contenido y tres acciones en el vacío. En móvil la tabla mantiene su desplazamiento horizontal. Artefactos temporales `/tmp/uecko-chapter-empty-*`; el fixture temporal se retiró. No es una prueba E2E con API autenticada.
- No se ejecutan base de datos, PDF ni builds de distribución: esta ampliación afecta a la presentación e inserción de detalles del editor, sin cambios en persistencia o informes.

View File

@ -1,6 +1,6 @@
# Capítulos: cambio de esquema preparado para revisión
Estado: modelos implementados en fase 3; cambios no aplicados a ninguna base de datos. No se ha ejecutado una migración ni `sync`. Este documento describe la estructura requerida y las comprobaciones para su futura aplicación autorizada.
Estado: modelos implementados en fase 3; suite de integración MariaDB preparada. No se han aplicado cambios a una base de datos ni certificado el esquema con MariaDB: el servidor facilitado se identifica como MySQL 8.0.42 y falta un destino MariaDB o una decisión explícita de cambiar el motor validado. Este documento describe la estructura y su verificación aislada.
## Cambios concretos
@ -45,3 +45,52 @@ Es una definición para revisión, no un script de migración ejecutado. Antes d
8. Desplegar el backend compatible antes del editor. Una vez guardados capítulos, no volver a un backend que los ignore ni retirar columnas/tablas sin conservar esos datos.
Las pruebas de fase 3 usan metadatos Sequelize y dobles transaccionales, sin abrir conexiones. Verifican contrato, mapeo, orden de operaciones, propagación de transacción y errores; no certifican el DDL, el bloqueo concurrente ni el rollback real de MariaDB. Estas comprobaciones quedan para el entorno autorizado de integración.
## Suite de integración preparada
Comando explícito desde `server/`: `yarn test:mariadb`. Usa `jest.mariadb.config.js` y `src/contexts/sales/infrastructure/__integration__/quoteChapters.mariadb.test.ts`. La suite habitual de Jest excluye los tests `*.mariadb.test.ts` y conserva las comprobaciones unitarias de seguridad del entorno de pruebas.
### Conexión y aislamiento
La suite no importa la configuración de conexión de la aplicación ni lee `stack.env` o archivos `.env`. Requiere estas variables específicas:
| Variable | Uso |
| --- | --- |
| `UECKO_MARIADB_TEST_USER` | Usuario de pruebas obligatorio |
| `UECKO_MARIADB_TEST_PASSWORD` | Contraseña opcional; no se imprime |
| `UECKO_MARIADB_TEST_HOST` | Host, por defecto `127.0.0.1` |
| `UECKO_MARIADB_TEST_PORT` | Puerto, por defecto `3306` |
| `UECKO_MARIADB_TEST_SOCKET` | Socket local opcional en lugar de conexión TCP |
El comando establece `UECKO_MARIADB_INTEGRATION=1` mediante `cross-env` ya instalado. No instala paquetes ni regenera lockfiles. Ejemplo sin contraseña en la línea de comandos, cuando las variables de acceso ya están disponibles en el entorno:
```bash
cd server
yarn test:mariadb
```
El usuario de pruebas necesita crear y eliminar esquemas temporales y sus tablas, índices, claves y triggers; para comprobar la espera real del bloqueo debe poder consultar `information_schema.INNODB_LOCK_WAITS` e `INNODB_TRX` (`PROCESS`). Usar una instancia de pruebas con permisos apropiados, no ampliar los permisos del usuario de producción para ejecutar esta suite.
Antes de cualquier DDL se exige que `SELECT VERSION()` identifique MariaDB; MySQL se rechaza expresamente. Se crea un nombre aleatorio `uecko_chapters_it_<uuid>` sin `IF NOT EXISTS`. Solo se elimina ese esquema si su creación en esta ejecución tuvo éxito. Se cierran los pools y se elimina el esquema en la limpieza de Jest, también si un test falla. Si se mata el proceso con SIGKILL, el esquema puede quedar para limpieza manual tras comprobar su nombre; no se hace limpieza por prefijos sobre otros esquemas.
### Cobertura preparada (14 casos; pendientes de ejecución real)
- Registra los cinco modelos reales de usuarios, distribuidores, cotizaciones, capítulos y detalles, con las asociaciones y convenciones de persistencia del adaptador. No inicia la API, los seeds ni los otros servicios.
- Construye la forma anterior a capítulos retirando exclusivamente las tres incorporaciones nuevas del esquema temporal. Inserta usuario/distribuidor ficticios y una cotización libre con tres detalles antes de ejecutar `sync({ force: false, alter: true })`.
- Comprueba defaults, PK compuesta, índice, contenido histórico, orden y totales; edita y duplica la cotización histórica mediante los casos de uso reales.
- Crea y recarga capítulos con texto mediante casos de uso, repositorio, transacciones, mappers y modelos reales. Contrasta la salida del presentador API y el HTML del reporter con los datos leídos de MariaDB.
- Reordena capítulos, traslada detalles y desagrupa; verifica subtotales, posiciones canónicas y totales. Duplica y verifica identidades nuevas y pertenencias remapeadas.
- Cuatro fallos de escritura reales mediante triggers `SIGNAL`: inserción de capítulos y detalles, tanto al crear como al actualizar. Compara las tres tablas tras rollback, incluyendo la cabecera y los timestamps.
- Una actualización mantiene el bloqueo de la cotización mientras confirma capítulos. Una segunda actualización de cliente antiguo espera realmente en InnoDB; después del commit debe rechazar la omisión de capítulos sin borrarlos.
- Ejecuta un segundo `sync({ alter: true })` con datos guardados; comprueba que no se pierdan datos ni asociaciones.
- Instala la FK compuesta solo en el esquema temporal y comprueba pertenencias desconocidas, pertenencias cruzadas, null y reemplazo transaccional. El último caso comprueba un nuevo `sync` con esa FK manual: su compatibilidad aún no se da por demostrada ni se traslada DDL a la aplicación.
Las seis comprobaciones unitarias del fixture verifican la autorización explícita, el usuario obligatorio, el rechazo de MySQL antes del DDL, el rechazo de un esquema ya existente y la limpieza del esquema creado. Estas comprobaciones usan dobles y no sustituyen los catorce casos contra MariaDB.
### Estado de esta entrega
Se ha detectado MySQL 8.0.42 local, sin binarios MariaDB ni Docker. El usuario facilitó acceso a `localhost:3306`: la conexión funciona, pero la consulta de metadatos devuelve `8.0.42-0ubuntu0.20.04.1`, producto Ubuntu/MySQL. La ejecución explícita `yarn test:mariadb` se detiene en la comprobación de motor antes de crear el esquema: no ejecuta DDL ni escribe en `uecko`. Los catorce casos no han sido verificados contra MariaDB y la fase permanece pendiente de un servidor MariaDB o de autorización para cambiar el motor de la validación. La incompatibilidad detectada es de entorno, no un resultado de las pruebas funcionales.
Validación sin base de datos: `cd server && yarn test --runInBand src/contexts/sales` (11 suites, 78 pruebas correctas), TypeScript con `yarn tsc --noEmit --pretty false`, lint acotado a los tres archivos de integración con `--parser-options '{"project":null}'` y `git diff --check`. Las seis pruebas nuevas son de aislamiento y limpieza; no certifican MariaDB. La suite real también ha sido comprobada por ts-jest antes de detenerse en la precondición de motor.
No se han instalado dependencias, arrancado el backend, aplicado cambios al esquema de la aplicación ni ejecutado operaciones Docker. La inspección de la interfaz con API autenticada y la generación PDF desde esa API permanecen fuera de esta ejecución de persistencia, aunque el contenido HTML sí está incluido en la suite preparada.

View File

@ -8,5 +8,5 @@ module.exports = {
"^@shared/(.*)$": "<rootDir>/../shared/lib/$1",
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts"],
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts", "\\.mariadb\\.test\\.ts$"],
};

View File

@ -0,0 +1,9 @@
const base = require("./jest.config.js");
module.exports = {
...base,
testMatch: ["<rootDir>/src/contexts/sales/infrastructure/__integration__/*.mariadb.test.ts"],
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts"],
testTimeout: 30000,
maxWorkers: 1,
};

View File

@ -12,6 +12,7 @@
"lint": "eslint --ignore-path .gitignore . --ext .ts",
"lint:fix": "npm run lint -- --fix",
"test": "jest --config=./jest.config.js --verbose",
"test:mariadb": "cross-env UECKO_MARIADB_INTEGRATION=1 jest --config=./jest.mariadb.config.js --runInBand --verbose",
"clean": "rm -rf node_modules"
},
"devDependencies": {

View File

@ -0,0 +1,52 @@
import { randomUUID } from "crypto";
import { QueryTypes, Sequelize } from "sequelize";
/** Never loads application config or reuses an existing database. */
export async function isolatedMariaDB() {
if (process.env.UECKO_MARIADB_INTEGRATION !== "1") {
throw new Error("Run the MariaDB integration suite explicitly with yarn test:mariadb");
}
const username = process.env.UECKO_MARIADB_TEST_USER;
if (!username) throw new Error("UECKO_MARIADB_TEST_USER is required");
const options = {
dialect: "mysql" as const,
host: process.env.UECKO_MARIADB_TEST_HOST ?? "127.0.0.1",
port: Number(process.env.UECKO_MARIADB_TEST_PORT ?? "3306"),
dialectOptions: process.env.UECKO_MARIADB_TEST_SOCKET
? { socketPath: process.env.UECKO_MARIADB_TEST_SOCKET }
: {},
logging: false as const,
pool: { max: 5, min: 0, acquire: 10000, idle: 1000 },
define: { underscored: true, timestamps: true, charset: "utf8mb4", collate: "utf8mb4_unicode_ci" },
};
const password = process.env.UECKO_MARIADB_TEST_PASSWORD ?? "";
const admin = new Sequelize("", username, password, options);
const database = `uecko_chapters_it_${randomUUID().replace(/-/g, "")}`;
let created = false;
let connection: Sequelize | undefined;
const close = async () => {
try {
await connection?.close();
} finally {
try {
if (created) await admin.query(`DROP DATABASE \`${database}\``);
} finally {
await admin.close();
}
}
};
try {
const [{ version }] = await admin.query<{ version: string }>("SELECT VERSION() AS version", { type: QueryTypes.SELECT });
if (!version.toLowerCase().includes("mariadb")) {
throw new Error(`MariaDB is required; server identifies as ${version}. A MySQL run cannot certify this integration`);
}
// No IF NOT EXISTS: an existing schema must never be adopted or deleted.
await admin.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
created = true;
connection = new Sequelize(database, username, password, options);
return { connection, version, close };
} catch (error) {
await close();
throw error;
}
}

View File

@ -0,0 +1,68 @@
import { Sequelize } from "sequelize";
import { isolatedMariaDB } from "./mariadb.fixture.mock";
jest.mock("sequelize", () => ({ Sequelize: jest.fn(), QueryTypes: { SELECT: "SELECT" } }));
const constructor = jest.mocked(Sequelize);
const saved = { flag: process.env.UECKO_MARIADB_INTEGRATION, user: process.env.UECKO_MARIADB_TEST_USER };
const admin = { query: jest.fn(), close: jest.fn() };
const connection = { close: jest.fn() };
beforeEach(() => {
jest.resetAllMocks();
process.env.UECKO_MARIADB_INTEGRATION = "1";
process.env.UECKO_MARIADB_TEST_USER = "synthetic-test-user";
admin.query.mockImplementation(async (sql: string) => sql.startsWith("SELECT VERSION") ? [{ version: "11.4-MariaDB" }] : undefined);
admin.close.mockResolvedValue(undefined);
connection.close.mockResolvedValue(undefined);
constructor
.mockImplementationOnce(() => admin as unknown as Sequelize)
.mockImplementationOnce(() => connection as unknown as Sequelize);
});
afterAll(() => {
for (const [key, value] of [["UECKO_MARIADB_INTEGRATION", saved.flag], ["UECKO_MARIADB_TEST_USER", saved.user]]) {
if (value === undefined) delete process.env[key!];
else process.env[key!] = value;
}
});
it("requires explicit execution before constructing any connection", async () => {
delete process.env.UECKO_MARIADB_INTEGRATION;
await expect(isolatedMariaDB()).rejects.toThrow("explicitly");
expect(constructor).not.toHaveBeenCalled();
});
it("requires a dedicated user before constructing any connection", async () => {
delete process.env.UECKO_MARIADB_TEST_USER;
await expect(isolatedMariaDB()).rejects.toThrow("USER is required");
expect(constructor).not.toHaveBeenCalled();
});
it("rejects MySQL before issuing any DDL", async () => {
admin.query.mockResolvedValue([{ version: "8.0.42 MySQL" }]);
await expect(isolatedMariaDB()).rejects.toThrow("MariaDB is required");
expect(admin.query).toHaveBeenCalledTimes(1);
expect(admin.close).toHaveBeenCalledTimes(1);
});
it("does not adopt or drop an existing schema if CREATE fails", async () => {
admin.query.mockImplementation(async (sql: string) => {
if (sql.startsWith("CREATE")) throw new Error("Database exists");
return [{ version: "11.4-MariaDB" }];
});
await expect(isolatedMariaDB()).rejects.toThrow("Database exists");
expect(admin.query.mock.calls.some(([sql]) => sql.startsWith("DROP"))).toBe(false);
expect(constructor).toHaveBeenCalledTimes(1);
});
it("creates a random schema and drops exactly that schema on cleanup", async () => {
const sandbox = await isolatedMariaDB();
const sql = admin.query.mock.calls[1][0] as string;
expect(sql).toMatch(/^CREATE DATABASE `uecko_chapters_it_[a-f0-9]{32}`/);
expect(sql).not.toContain("IF NOT EXISTS");
const name = sql.match(/`([^`]+)`/)![1];
await sandbox.close();
expect(admin.query).toHaveBeenLastCalledWith(`DROP DATABASE \`${name}\``);
expect(connection.close).toHaveBeenCalledTimes(1);
expect(admin.close).toHaveBeenCalledTimes(1);
});
it("still drops its own schema and closes the admin connection if closing the pool fails", async () => {
const sandbox = await isolatedMariaDB();
connection.close.mockRejectedValue(new Error("Pool close failed"));
await expect(sandbox.close()).rejects.toThrow("Pool close failed");
expect(admin.query.mock.calls[2][0]).toMatch(/^DROP DATABASE `uecko_chapters_it_/);
expect(admin.close).toHaveBeenCalledTimes(1);
});

View File

@ -0,0 +1,280 @@
import { randomUUID } from "crypto";
import { QueryTypes, Sequelize, Transaction } from "sequelize";
import { Language, UniqueID } from "@shared/contexts";
import { RepositoryManager } from "@/contexts/common/domain/repositories/RepositoryManager";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { SequelizeBusinessTransaction } from "@/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction";
import { CreateQuoteUseCase } from "@/contexts/sales/application/Quote/CreateQuote.useCase";
import { UpdateQuoteUseCase } from "@/contexts/sales/application/Quote/UpdateQuote.useCase";
import { DuplicateQuoteUseCase } from "@/contexts/sales/application/Quote/DuplicateQuote.useCase";
import { GetQuoteUseCase } from "@/contexts/sales/application/Quote/GetQuote.useCase";
import { QuoteRepository, registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { chapterA, chapterB, createDTO, dealer, dealerId, mapper, quoteId, rawQuote, updateDTO } from "@/contexts/sales/infrastructure/__fixtures__/quoteChapters.mock";
import initUser from "@/contexts/users/infrastructure/sequelize/user.model";
import initDealer from "@/contexts/sales/infrastructure/sequelize/dealer.model";
import initQuote from "@/contexts/sales/infrastructure/sequelize/quote.model";
import initItem from "@/contexts/sales/infrastructure/sequelize/quoteItem.model";
import initChapter from "@/contexts/sales/infrastructure/sequelize/quoteChapter.model";
import { GetQuotePresenter } from "@/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter";
import { ReportQuotePresenter } from "@/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter";
import { isolatedMariaDB } from "./mariadb.fixture.mock";
let sandbox: Awaited<ReturnType<typeof isolatedMariaDB>> | undefined;
let db: Sequelize;
let context: ISalesContext;
let adapter: ISequelizeAdapter;
let historicalItems: Record<string, unknown>[];
const id = (value: string) => UniqueID.create(value).object;
const rows = <T extends object = Record<string, unknown>>(sql: string, replacements?: Record<string, unknown>) =>
db.query<T>(sql, { type: QueryTypes.SELECT, replacements });
const repository = (transaction: Transaction) => new QuoteRepository({ mapper, adapter, transaction });
const read = async (quoteId: string) => {
const result = await new GetQuoteUseCase(context).execute({ id: id(quoteId) });
if (result.isFailure) throw result.error;
return result.object;
};
const create = async (grouped = true) => {
const result = await new CreateQuoteUseCase(context).execute({ ...createDTO(grouped), id: randomUUID() });
if (result.isFailure) throw result.error;
return read(result.object.id.toString());
};
const physical = async (quoteId: string) => ({
header: await rows("SELECT * FROM quotes WHERE id = :id", { id: quoteId }),
chapters: await rows("SELECT * FROM quote_chapters WHERE quote_id = :id ORDER BY position", { id: quoteId }),
items: await rows("SELECT * FROM quote_items WHERE quote_id = :id ORDER BY position", { id: quoteId }),
});
beforeAll(async () => {
sandbox = await isolatedMariaDB();
db = sandbox.connection;
console.info(`MariaDB integration: ${sandbox.version}; disposable synthetic schema`);
const models = [initUser(db), initDealer(db), initQuote(db), initItem(db), initChapter(db)];
models.forEach((model) => model.associate(db));
await db.sync({ force: false });
const qi = db.getQueryInterface();
// Recreate the exact pre-chapter shape from the real model definitions,
// then insert a historical quote before the application alters that shape.
await qi.removeIndex("quote_items", "quote_items_chapter_idx");
await qi.removeColumn("quote_items", "chapter_id");
await qi.removeColumn("quotes", "show_chapter_line_amounts");
await qi.dropTable("quote_chapters");
const now = new Date("2026-10-19T10:00:00Z");
await qi.bulkInsert("users", [{ id: chapterB, name: "Integration user", email: "chapters@example.invalid", password: "synthetic-unused", created_at: now, updated_at: now }]);
await qi.bulkInsert("dealers", [{ id: dealerId, user_id: chapterB, name: "Integration dealer", status: "actived", created_at: now, updated_at: now }]);
const { chapters, items, show_chapter_line_amounts, ...header } = mapper.mapToPersistence(mapper.mapToDomain(rawQuote(false)));
if (chapters.length || show_chapter_line_amounts !== false) throw new Error("Unexpected legacy fixture");
await qi.bulkInsert("quotes", [{ ...header, created_at: now, updated_at: now }]);
await qi.bulkInsert("quote_items", items.map(({ chapter_id, ...item }) => {
if (chapter_id !== null) throw new Error("Legacy fixture must be ungrouped");
return item;
}));
historicalItems = await rows("SELECT * FROM quote_items ORDER BY position");
await db.sync({ force: false, alter: true });
adapter = {
getModel: (name: string) => db.models[name],
startTransaction: () => new SequelizeBusinessTransaction(db),
} as unknown as ISequelizeAdapter;
context = { dealer, adapter, defaults: {}, repositoryManager: RepositoryManager.getInstance() };
registerQuoteRepository(context);
}, 30000);
afterAll(async () => { await sandbox?.close(); });
it("upgrades the legacy schema without assigning chapters or changing old amounts/order", async () => {
const fields = await db.getQueryInterface().describeTable("quotes");
expect(fields.show_chapter_line_amounts.allowNull).toBe(false);
const quote = await read(quoteId);
expect(quote.chapters).toEqual([]);
expect(quote.showChapterLineAmounts).toBe(true);
expect(quote.totalPrice.toPrimitive()).toBe(16335);
const persisted = await physical(quoteId);
expect(persisted.items.map(({ chapter_id: chapter, ...item }) => {
expect(chapter).toBeNull();
return item;
})).toEqual(historicalItems);
const chapterColumns = await db.getQueryInterface().describeTable("quote_chapters");
expect(chapterColumns.quote_id.primaryKey).toBe(true);
expect(chapterColumns.chapter_id.primaryKey).toBe(true);
expect(chapterColumns.description.allowNull).toBe(false);
const indexes = await rows<{ INDEX_NAME: string; COLUMN_NAME: string }>(
"SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'quote_items' ORDER BY INDEX_NAME, SEQ_IN_INDEX"
);
expect(indexes.filter((entry) => entry.INDEX_NAME === "quote_items_chapter_idx").map((entry) => entry.COLUMN_NAME)).toEqual(["quote_id", "chapter_id"]);
});
it("edits and duplicates a historical quote through the real use cases", async () => {
const dto = updateDTO(false);
delete dto.chapters;
delete dto.show_chapter_line_amounts;
dto.notes = "Updated historical quote";
const updated = await new UpdateQuoteUseCase(context).execute({ id: id(quoteId), quoteDTO: dto });
expect(updated.isSuccess).toBe(true);
const copy = await new DuplicateQuoteUseCase(context).execute({ sourceId: id(quoteId) });
expect(copy.isSuccess).toBe(true);
const loaded = await read(copy.object.id.toString());
expect(loaded.chapters).toEqual([]);
expect(loaded.items.toArray().every((item) => item.chapterId === null)).toBe(true);
expect(loaded.totalPrice.toPrimitive()).toBe(16335);
expect(loaded.notes.toString()).toBe(dto.notes);
});
it("reloads chapter structure, subtotals, text and saved PDF visibility via the API presenter", async () => {
const quote = await create();
const dto = await GetQuotePresenter.map(quote, context, Language.createFromCode("es").object);
expect(dto.chapters.map((chapter) => chapter.id)).toEqual([chapterA, chapterB]);
expect(dto.chapters.map((chapter) => chapter.subtotal_price.amount)).toEqual([9000, 6000]);
expect(dto.show_chapter_line_amounts).toBe(false);
expect(dto.items.map((item) => item.chapter_id)).toEqual([chapterA, chapterB, chapterB]);
expect(dto.items[2].total_price.amount).toBeNull();
const html = await ReportQuotePresenter.toHTML(quote, context);
expect(html).toContain("Subtotal capítulo 1. Armario");
expect(html).not.toContain("Prec. Unitario");
expect(html).toContain(quote.totalPrice.toFormat());
});
it("reorders chapters and transfers details with canonical persisted positions and unchanged totals", async () => {
const quote = await create();
const dto = updateDTO();
dto.chapters!.reverse();
dto.items[0].chapter_id = chapterB;
delete dto.show_chapter_line_amounts;
const result = await new UpdateQuoteUseCase(context).execute({ id: quote.id, quoteDTO: dto });
expect(result.isSuccess).toBe(true);
const loaded = await read(quote.id.toString());
expect(loaded.chapters.map((chapter) => chapter.id.toString())).toEqual([chapterB, chapterA]);
expect(loaded.getChapterSubtotal(id(chapterB)).object.toPrimitive()).toBe(15000);
expect(loaded.getChapterSubtotal(id(chapterA)).object.toPrimitive()).toBe(0);
expect(loaded.totalPrice.toPrimitive()).toBe(quote.totalPrice.toPrimitive());
expect(loaded.showChapterLineAmounts).toBe(false);
expect((await physical(quote.id.toString())).items.map((item) => item.position)).toEqual([0, 1, 2]);
});
it("duplicates chapters with new identities and can explicitly return to free mode", async () => {
const quote = await create();
const result = await new DuplicateQuoteUseCase(context).execute({ sourceId: quote.id });
expect(result.isSuccess).toBe(true);
const copy = await read(result.object.id.toString());
expect(copy.chapters).toHaveLength(2);
expect(copy.chapters.every((chapter) => !quote.chapters.some((original) => original.id.equals(chapter.id)))).toBe(true);
expect(copy.items.toArray().every((item) => copy.chapters.some((chapter) => chapter.id.equals(item.chapterId!)))).toBe(true);
expect(copy.totalPrice.toPrimitive()).toBe(quote.totalPrice.toPrimitive());
const ungroup = await new UpdateQuoteUseCase(context).execute({ id: copy.id, quoteDTO: updateDTO(false) });
expect(ungroup.isSuccess).toBe(true);
expect((await read(copy.id.toString())).chapters).toEqual([]);
expect((await physical(copy.id.toString())).items.every((item) => item.chapter_id === null)).toBe(true);
});
it.each([
["create", "quote_chapters"], ["create", "quote_items"],
["update", "quote_chapters"], ["update", "quote_items"],
])("rolls back actual MariaDB writes during %s after a failure in %s", async (operation, table) => {
const existing = await create();
const targetId = operation === "create" ? randomUUID() : existing.id.toString();
const before = await physical(targetId);
await db.query(`CREATE TRIGGER integration_fault BEFORE INSERT ON ${table}
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration write failure'`);
try {
const result = operation === "create"
? await new CreateQuoteUseCase(context).execute({ ...createDTO(), id: targetId })
: await new UpdateQuoteUseCase(context).execute({
id: id(targetId), quoteDTO: { ...updateDTO(), notes: "This header must roll back" },
});
expect(result.isFailure).toBe(true);
expect(result.error.code).toBe("REPOSITORY_ERROR");
expect(await physical(targetId)).toEqual(before);
} finally {
await db.query("DROP TRIGGER integration_fault");
}
});
it("rejects an invalid chapter membership without changing persisted data", async () => {
const quote = await create();
const before = await physical(quote.id.toString());
const dto = updateDTO();
dto.items[0].chapter_id = randomUUID();
const result = await new UpdateQuoteUseCase(context).execute({ id: quote.id, quoteDTO: dto });
expect(result.isFailure).toBe(true);
expect(await physical(quote.id.toString())).toEqual(before);
});
it("blocks an old concurrent PUT until grouping commits, then rejects its omitted chapters", async () => {
const quote = await create(false);
const transaction = await db.transaction();
let committed = false;
let pending: ReturnType<UpdateQuoteUseCase["execute"]> | undefined;
try {
const repo = repository(transaction);
await repo.getById(quote.id, { forUpdate: true });
const grouped = mapper.mapToDomain(rawQuote());
// Obtain the grouped entity for this quote without invoking a second transaction.
const persistence = mapper.mapToPersistence(grouped);
persistence.id = quote.id.toString();
persistence.chapters.forEach((chapter) => { chapter.quote_id = persistence.id; });
// Reuse the mapper so the owner invariants are checked before writing.
const record = <T extends object>(data: T) => ({ ...data, dataValues: data });
const replacement = mapper.mapToDomain({
...record(persistence),
chapters: persistence.chapters.map(record), items: persistence.items.map(record),
} as unknown as Parameters<typeof mapper.mapToDomain>[0]);
await repo.update(replacement);
const dto = updateDTO(false);
delete dto.chapters;
pending = new UpdateQuoteUseCase(context).execute({ id: quote.id, quoteDTO: dto });
const deadline = Date.now() + 5000;
let blocked = false;
while (!blocked && Date.now() < deadline) {
const waits = await rows<{ count: number }>(
"SELECT COUNT(*) AS count FROM information_schema.INNODB_LOCK_WAITS w JOIN information_schema.INNODB_TRX t ON t.trx_id = w.requesting_trx_id WHERE t.trx_query LIKE :query",
{ query: `%${quote.id.toString()}%FOR UPDATE%` }
);
blocked = Number(waits[0].count) > 0;
if (!blocked) await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(blocked).toBe(true);
await transaction.commit();
committed = true;
const result = await pending;
expect(result.isFailure).toBe(true);
expect(result.error.code).toBe("INVALID_INPUT_DATA");
expect((await read(quote.id.toString())).chapters).toHaveLength(2);
} finally {
if (!committed) await transaction.rollback();
await pending;
}
});
it("preserves data, defaults, indexes and associations through a second startup sync", async () => {
const quote = await create();
const before = await physical(quote.id.toString());
await db.sync({ force: false, alter: true });
expect(await physical(quote.id.toString())).toEqual(before);
expect((await read(quote.id.toString())).items.totalCount).toBe(3);
});
it("enforces a composite FK, allows null memberships and supports repository replacement", async () => {
await db.query(`ALTER TABLE quote_items ADD CONSTRAINT quote_items_chapter_fk
FOREIGN KEY (quote_id, chapter_id) REFERENCES quote_chapters (quote_id, chapter_id)
ON DELETE RESTRICT ON UPDATE CASCADE`);
const quote = await create();
const other = await new DuplicateQuoteUseCase(context).execute({ sourceId: quote.id });
const otherQuote = await read(other.object.id.toString());
const insert = (chapter: string | null) => db.getQueryInterface().bulkInsert("quote_items", [{
item_id: randomUUID(), quote_id: quote.id.toString(), position: 99, chapter_id: chapter, description: "FK probe",
}]);
await expect(insert(randomUUID())).rejects.toMatchObject({ parent: { errno: 1452 } });
await expect(insert(otherQuote.chapters[0].id.toString())).rejects.toMatchObject({ parent: { errno: 1452 } });
await insert(null);
// Remove the physical probe before asking the domain to load the grouped quote.
await db.query("DELETE FROM quote_items WHERE quote_id = :id AND position = 99", { replacements: { id: quote.id.toString() } });
await db.transaction((transaction) => repository(transaction).update(quote));
expect((await read(quote.id.toString())).items.totalCount).toBe(3);
});
it("checks startup sync with the manually installed composite FK", async () => {
await db.sync({ force: false, alter: true });
const references = await rows<{ CONSTRAINT_NAME: string }>(
"SELECT CONSTRAINT_NAME FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'quote_items_chapter_fk'"
);
expect(references).toHaveLength(1);
});