feat: enhance chapter and detail row interactions with improved animations and visibility controls
- Adjusted drag handle alignment in QuoteChaptersDataTable for consistent width and positioning. - Implemented height and opacity transitions in useChapterCollapse for smoother chapter collapsing. - Updated QuoteItemsSortableTableRow to identify active rows with data attributes and apply consistent styling. - Refined detail row selection to limit checkboxes to detail rows only, improving user experience. - Enhanced visibility of controls on hover and focus for both chapter titles and detail rows. - Improved spacing and alignment for chapter titles and associated controls, ensuring a cohesive layout. - Added tests to validate new behaviors and interactions, ensuring functionality remains intact.
This commit is contained in:
parent
bc34a073ad
commit
3b660bf27e
@ -11,13 +11,13 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
@ -29,12 +29,12 @@ import {
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/ui/table";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
DragEndEvent,
|
||||
KeyboardCoordinateGetter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@ -48,37 +48,31 @@ import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
CopyIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
GripVerticalIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-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 {
|
||||
chapterSubtotal,
|
||||
duplicateChapterSelection,
|
||||
EditorChapter,
|
||||
EditorItem,
|
||||
chapterSubtotal,
|
||||
duplicateChapterSelection,
|
||||
insertChapterItem,
|
||||
moveChapterItems,
|
||||
moveChapters,
|
||||
removeChapterSelection,
|
||||
ungroupChapters,
|
||||
} from "../chapterEditor";
|
||||
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
||||
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
||||
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
||||
|
||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||
|
||||
type Selection = { chapters: string[]; indexes: number[] };
|
||||
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
||||
@ -110,7 +104,11 @@ const keyboardCoordinates: KeyboardCoordinateGetter = (
|
||||
};
|
||||
|
||||
function ChapterContent({ children }: { children: ReactNode }) {
|
||||
return <div data-chapter-content className='min-w-0'>{children}</div>;
|
||||
return (
|
||||
<div data-chapter-content className='min-w-0'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reserve the expand column in detail rows so all controls stay aligned.
|
||||
@ -118,7 +116,15 @@ function ChapterSpacerCell() {
|
||||
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
|
||||
}
|
||||
|
||||
function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArticle, onAddBlock }: {
|
||||
function EmptyChapterTarget({
|
||||
id,
|
||||
columns,
|
||||
hidden,
|
||||
collapsed,
|
||||
onAddRow,
|
||||
onAddArticle,
|
||||
onAddBlock,
|
||||
}: {
|
||||
id: string;
|
||||
columns: number;
|
||||
hidden: boolean;
|
||||
@ -147,7 +153,7 @@ function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArt
|
||||
<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'>
|
||||
<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} />
|
||||
@ -164,24 +170,18 @@ function ChapterGroup({
|
||||
index,
|
||||
columns,
|
||||
children,
|
||||
selected,
|
||||
onSelect,
|
||||
onActivate,
|
||||
onDuplicate,
|
||||
onRemove,
|
||||
mixed,
|
||||
subtotal,
|
||||
}: {
|
||||
chapter: EditorChapter;
|
||||
index: number;
|
||||
columns: number;
|
||||
children: (collapsed: boolean, hidden: boolean) => ReactNode;
|
||||
selected: boolean;
|
||||
onSelect: (selected: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onDuplicate: () => void;
|
||||
onRemove: () => void;
|
||||
mixed: boolean;
|
||||
subtotal: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@ -195,14 +195,23 @@ function ChapterGroup({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: `chapter:${chapter.id}`, disabled: mixed });
|
||||
} = useSortable({ id: `chapter:${chapter.id}` });
|
||||
return (
|
||||
<TableBody
|
||||
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'
|
||||
className={`bg-background
|
||||
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b
|
||||
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b-border
|
||||
[&>tr[data-active-item]>td]:border-destructive/30
|
||||
[&>tr[data-active-item]>td:first-child]:border-l-4
|
||||
[&>tr[data-active-item]>td:first-child]:border-l-destructive
|
||||
[&>tr[data-active-item]>td:last-child]:border-r
|
||||
[&>tr[data-active-item=detail]>td]:border-t
|
||||
[&>tr[data-active-item=detail]>td]:border-b-0
|
||||
[&>tr[data-active-item=actions]>td]:border-b`}
|
||||
style={{
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
@ -215,16 +224,16 @@ function ChapterGroup({
|
||||
</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'
|
||||
className='group/chapter 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'>
|
||||
<TableCell className='pl-3.5 pr-1'>
|
||||
<div className='relative w-8 h-8'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 text-muted-foreground'
|
||||
className='w-8 h-8 text-muted-foreground'
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
|
||||
number: index + 1,
|
||||
@ -235,9 +244,9 @@ function ChapterGroup({
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<FolderIcon className='h-5 w-5' aria-hidden='true' />
|
||||
<FolderIcon className='w-5 h-5' aria-hidden='true' />
|
||||
) : (
|
||||
<FolderOpenIcon className='h-5 w-5' aria-hidden='true' />
|
||||
<FolderOpenIcon className='w-5 h-5' aria-hidden='true' />
|
||||
)}
|
||||
</Button>
|
||||
<Badge
|
||||
@ -249,30 +258,7 @@ function ChapterGroup({
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='px-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
disabled={mixed}
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
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 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'>
|
||||
<TableCell colSpan={columns - 1} className='px-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<Controller
|
||||
key={`${chapter.id}:${index}`}
|
||||
@ -283,7 +269,7 @@ function ChapterGroup({
|
||||
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='-ml-px flex-1 min-w-0'>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
@ -291,7 +277,7 @@ function ChapterGroup({
|
||||
onFocus={onActivate}
|
||||
aria-label={t("quotes.chapters.title", { number: index + 1 })}
|
||||
aria-invalid={Boolean(fieldState.error)}
|
||||
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'
|
||||
className='px-0 font-semibold bg-transparent border-transparent shadow-none min-h-10 hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p role='alert' className='text-sm text-destructive'>
|
||||
@ -301,23 +287,38 @@ function ChapterGroup({
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className='w-10 h-10 shrink-0 touch-none cursor-grab opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||
onFocus={onActivate}
|
||||
aria-label={t("quotes.chapters.move", { number: index + 1 })}
|
||||
>
|
||||
<GripVerticalIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onDuplicate}
|
||||
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||
aria-label={t("quotes.chapters.duplicate", { number: index + 1 })}
|
||||
>
|
||||
<CopyIcon className='h-4 w-4' />
|
||||
<CopyIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onRemove}
|
||||
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||
aria-label={t("quotes.chapters.remove", { number: index + 1 })}
|
||||
>
|
||||
<Trash2Icon className='h-4 w-4' />
|
||||
<Trash2Icon className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -331,7 +332,7 @@ function ChapterGroup({
|
||||
<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='whitespace-nowrap text-right text-base'>{subtotal}</TableCell>
|
||||
<TableCell className='text-base text-right whitespace-nowrap'>{subtotal}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
);
|
||||
@ -365,19 +366,14 @@ export function QuoteChaptersDataTable({
|
||||
const { t } = useTranslation();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [chapterSelection, setChapterSelection] = useState<string[]>([]);
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<Selection | null>(null);
|
||||
const [ungrouping, setUngrouping] = useState(false);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const { chapters, items } = controller;
|
||||
const selectedIndexes = data.flatMap((row, index) => (rowSelection[row.id] ? [index] : []));
|
||||
const mixed =
|
||||
chapterSelection.length > 0 &&
|
||||
selectedIndexes.some((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""));
|
||||
const clear = () => {
|
||||
setRowSelection({});
|
||||
setChapterSelection([]);
|
||||
setActiveRow(null);
|
||||
};
|
||||
const apply = (state: Parameters<typeof controller.apply>[0]) => {
|
||||
@ -427,11 +423,8 @@ export function QuoteChaptersDataTable({
|
||||
apply(next);
|
||||
setDeleting(null);
|
||||
};
|
||||
const selection = { chapters: chapterSelection, indexes: selectedIndexes };
|
||||
const selectedCount =
|
||||
chapterSelection.length +
|
||||
selectedIndexes.filter((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""))
|
||||
.length;
|
||||
const selection: Selection = { chapters: [], indexes: selectedIndexes };
|
||||
const selectedCount = selectedIndexes.length;
|
||||
const insert = (index: number, after = false) => {
|
||||
const state = controller.read();
|
||||
apply(
|
||||
@ -444,17 +437,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 chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "chapter_tree",
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
size: 56,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
...columns,
|
||||
],
|
||||
[columns]
|
||||
);
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: chapterColumns,
|
||||
@ -494,17 +490,16 @@ export function QuoteChaptersDataTable({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates })
|
||||
);
|
||||
const dragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id || mixed) return;
|
||||
if (!over || active.id === over.id) return;
|
||||
const state = controller.read();
|
||||
if (isChapter(active.id)) {
|
||||
if (!isChapter(over.id)) return;
|
||||
const from = String(active.id).slice(8);
|
||||
const to = String(over.id).slice(8);
|
||||
const moving = chapterSelection.includes(from) ? chapterSelection : [from];
|
||||
apply(
|
||||
moveChapters(
|
||||
state,
|
||||
moving,
|
||||
[from],
|
||||
to,
|
||||
state.chapters.findIndex((chapter) => chapter.id === from) <
|
||||
state.chapters.findIndex((chapter) => chapter.id === to)
|
||||
@ -543,12 +538,7 @@ export function QuoteChaptersDataTable({
|
||||
sensors={sensors}
|
||||
onDragEnd={dragEnd}
|
||||
onDragStart={({ active }) => {
|
||||
if (
|
||||
isChapter(active.id)
|
||||
? !chapterSelection.includes(String(active.id).slice(8))
|
||||
: !rowSelection[active.id]
|
||||
)
|
||||
clear();
|
||||
if (isChapter(active.id) || !rowSelection[active.id]) clear();
|
||||
}}
|
||||
collisionDetection={(args) => {
|
||||
const valid = {
|
||||
@ -587,16 +577,19 @@ export function QuoteChaptersDataTable({
|
||||
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
|
||||
{t("quotes.chapters.add")}
|
||||
</Button>
|
||||
<Button type='button' variant='ghost' size='sm' onClick={() => setUngrouping(true)}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setUngrouping(true)}
|
||||
>
|
||||
{t("quotes.chapters.ungroup")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
viewControls={
|
||||
<div className='flex min-h-9 items-center gap-2'>
|
||||
<Label htmlFor='chapter-line-amounts'>
|
||||
{t("quotes.chapters.show_amounts")}
|
||||
</Label>
|
||||
<div className='flex items-center gap-2 min-h-9'>
|
||||
<Label htmlFor='chapter-line-amounts'>{t("quotes.chapters.show_amounts")}</Label>
|
||||
<Switch
|
||||
id='chapter-line-amounts'
|
||||
checked={watch("show_chapter_line_amounts") ?? true}
|
||||
@ -607,14 +600,9 @@ export function QuoteChaptersDataTable({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{mixed && (
|
||||
<p role='status' className='text-sm'>
|
||||
{t("quotes.chapters.mixed_selection")}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table className='table-fixed border-separate border-spacing-0'>
|
||||
<Table className='border-separate table-fixed border-spacing-0'>
|
||||
<colgroup>
|
||||
{table.getVisibleLeafColumns().map((column) => (
|
||||
<col key={column.id} style={{ width: column.getSize() }} />
|
||||
@ -634,15 +622,6 @@ export function QuoteChaptersDataTable({
|
||||
chapter={chapter}
|
||||
index={chapterIndex}
|
||||
columns={countColumns}
|
||||
mixed={mixed}
|
||||
selected={chapterSelection.includes(chapter.id)}
|
||||
onSelect={(value) =>
|
||||
setChapterSelection((previous) =>
|
||||
value
|
||||
? [...previous, chapter.id]
|
||||
: previous.filter((id) => id !== chapter.id)
|
||||
)
|
||||
}
|
||||
onActivate={() => controller.setActiveId(chapter.id)}
|
||||
onDuplicate={() => duplicate({ chapters: [chapter.id], indexes: [] })}
|
||||
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
|
||||
@ -650,44 +629,49 @@ export function QuoteChaptersDataTable({
|
||||
>
|
||||
{(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 > 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>
|
||||
))}
|
||||
<SortableContext
|
||||
items={collapsed ? [] : rows.map((row) => row.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
@ -697,7 +681,7 @@ export function QuoteChaptersDataTable({
|
||||
<QuoteItemsSortableTableRow
|
||||
id={row.id}
|
||||
hidden={hidden}
|
||||
disabled={collapsed || mixed || chapterSelection.length > 0}
|
||||
disabled={collapsed}
|
||||
isActive={activeRow === row.id}
|
||||
onActivate={(event) => {
|
||||
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
|
||||
@ -706,23 +690,35 @@ export function QuoteChaptersDataTable({
|
||||
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>
|
||||
))}
|
||||
{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}>
|
||||
<TableRow
|
||||
hidden={hidden}
|
||||
data-active-item='actions'
|
||||
className='bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5'
|
||||
>
|
||||
<TableCell colSpan={countColumns} className='px-2 py-2'>
|
||||
<ChapterContent>
|
||||
<QuoteItemsInlineRowActions
|
||||
confirmDelete={false}
|
||||
@ -744,7 +740,9 @@ export function QuoteChaptersDataTable({
|
||||
collapsed={collapsed}
|
||||
onAddRow={() => {
|
||||
controller.setActiveId(chapter.id);
|
||||
apply(insertChapterItem(controller.read(), defaultItem, chapter.id));
|
||||
apply(
|
||||
insertChapterItem(controller.read(), defaultItem, chapter.id)
|
||||
);
|
||||
}}
|
||||
onAddArticle={() => {
|
||||
controller.setActiveId(chapter.id);
|
||||
@ -793,7 +791,8 @@ export function QuoteChaptersDataTable({
|
||||
<SelectContent>
|
||||
{destinations.map((chapter) => (
|
||||
<SelectItem key={chapter.id} value={chapter.id}>
|
||||
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}. {chapter.description}
|
||||
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}.{" "}
|
||||
{chapter.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@ -30,6 +30,8 @@ export const QuoteItemsSortableDragHandleCell = ({
|
||||
className={cn(
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground touch-none",
|
||||
"transition-opacity motion-reduce:transition-none group-hover/detail:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",
|
||||
isDragging ? "opacity-100" : "opacity-0",
|
||||
className
|
||||
)}
|
||||
{...attributes}
|
||||
|
||||
@ -84,12 +84,13 @@ export function QuoteItemsSortableTableRow({
|
||||
key={id}
|
||||
id={String(id)}
|
||||
hidden={hidden}
|
||||
data-active-item={isActive ? "detail" : undefined}
|
||||
className={cn(
|
||||
isDragging ? "opacity-60" : "opacity-100",
|
||||
isActive
|
||||
? "border border-destructive/30 border-b-0 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5"
|
||||
: "hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent",
|
||||
"m-0"
|
||||
"group/detail m-0"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
|
||||
@ -318,11 +318,9 @@ it("moves a whole chapter with its details, and details to an empty chapter", ()
|
||||
);
|
||||
});
|
||||
|
||||
it("duplicates a selected chapter once and can return to free mode preserving every detail", () => {
|
||||
it("duplicates a chapter using its action and can return to free mode preserving every detail", () => {
|
||||
render(<Harness grouped />);
|
||||
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
|
||||
fireEvent.click(screen.getByLabelText("Seleccionar fila"));
|
||||
fireEvent.click(screen.getByText("Duplicar"));
|
||||
fireEvent.click(screen.getByLabelText("Duplicar capítulo 1"));
|
||||
expect(snapshot().chapters).toHaveLength(3);
|
||||
expect(snapshot().items).toHaveLength(2);
|
||||
expect(snapshot().items[1].chapter_id).not.toBe(chapterA);
|
||||
@ -410,22 +408,19 @@ it("deletes chapter content only after explicit confirmation", () => {
|
||||
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
|
||||
});
|
||||
|
||||
it("disables mixed chapter/detail dragging without disabling selection actions", () => {
|
||||
it("selects only detail rows and keeps chapter controls available", () => {
|
||||
render(<Harness grouped />);
|
||||
fireEvent.focus(screen.getByLabelText("Título del capítulo 2"));
|
||||
fireEvent.click(screen.getByText("Añadir fila"));
|
||||
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
|
||||
const row = screen.getByLabelText("items.1.description").closest("tr")!;
|
||||
for (const heading of document.querySelectorAll("[data-chapter-id]")) {
|
||||
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
|
||||
}
|
||||
const row = screen.getByLabelText("items.0.description").closest("tr")!;
|
||||
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
|
||||
expect(
|
||||
screen.getByText("Para mover la selección, selecciona solo capítulos o solo detalles.")
|
||||
).toBeTruthy();
|
||||
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(true);
|
||||
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
|
||||
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([
|
||||
chapterA,
|
||||
chapterB,
|
||||
]);
|
||||
expect(within(row).getByLabelText("Seleccionar fila").getAttribute("aria-checked")).toBe("true");
|
||||
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(screen.getByText("Duplicar"));
|
||||
expect(snapshot().chapters).toHaveLength(2);
|
||||
expect(snapshot().items).toHaveLength(2);
|
||||
expect(snapshot().items.every((entry: { chapter_id: string }) => entry.chapter_id === chapterA)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses valid keyboard destinations and rejects pointer drops outside chapter contents", () => {
|
||||
@ -562,7 +557,7 @@ it.each([true, false])("orders expand, drag and select controls with the expand
|
||||
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]);
|
||||
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@ -644,8 +639,8 @@ it("shows chapter numbers as badges on the folder controls, including after reor
|
||||
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+/);
|
||||
expect(heading.cells[1].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
|
||||
expect(heading.cells[1].textContent).not.toMatch(/^\s*\d+/);
|
||||
};
|
||||
assertBadge(chapterA, 1);
|
||||
assertBadge(chapterB, 2);
|
||||
|
||||
@ -12,14 +12,14 @@ import { useToast } from "@/ui/use-toast";
|
||||
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { addChapter, EditorItem } from "../../chapterEditor";
|
||||
import { useChapterEditor } from "../../hooks/useChapterEditor";
|
||||
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
|
||||
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
|
||||
import { useDetailColumns } from "../../hooks/useDetailColumns";
|
||||
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
|
||||
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
|
||||
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
|
||||
import { QuoteItemsSortableDataTable, RowIdData } from "../QuoteItemsSortableDataTable";
|
||||
import { BlocksPickerDialog } from "./BlocksPickerDialog/BlocksPickerDialog";
|
||||
import { CatalogPickerDialog } from "./CatalogPickerDialog";
|
||||
@ -39,8 +39,8 @@ export const QuoteDetailsCardEditor = ({
|
||||
const documentLanguageCode = watch("lang_code") || Language.DEFAULT_LANGUAGE_CODE;
|
||||
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
|
||||
const hasLineDiscounts = Boolean(
|
||||
getValues("items")?.some((item: { discount?: { amount?: number | null } }) =>
|
||||
Number(item.discount?.amount) > 0
|
||||
getValues("items")?.some(
|
||||
(item: { discount?: { amount?: number | null } }) => Number(item.discount?.amount) > 0
|
||||
)
|
||||
);
|
||||
|
||||
@ -56,12 +56,16 @@ export const QuoteDetailsCardEditor = ({
|
||||
|
||||
const chapterEditor = useChapterEditor(fieldActions.replace);
|
||||
const [createChapterOpen, setCreateChapterOpen] = useState(false);
|
||||
const appendPicked = useCallback((item: unknown) => {
|
||||
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
|
||||
else fieldActions.append(item);
|
||||
}, [chapterEditor, fieldActions]);
|
||||
const appendPicked = useCallback(
|
||||
(item: unknown) => {
|
||||
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
|
||||
else fieldActions.append(item);
|
||||
},
|
||||
[chapterEditor, fieldActions]
|
||||
);
|
||||
|
||||
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
|
||||
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
|
||||
() => [
|
||||
/*{
|
||||
id: "row_id" as const,
|
||||
header: () => (
|
||||
@ -95,17 +99,12 @@ export const QuoteDetailsCardEditor = ({
|
||||
cell: ({ row: { index, original } }) => {
|
||||
if (original?.id_article) {
|
||||
return (
|
||||
<div className='min-h-10 whitespace-pre-wrap px-3 py-2 text-sm'>
|
||||
<div className='px-3 py-2 text-sm whitespace-pre-wrap min-h-10'>
|
||||
{original.localized_description ?? original.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormTextAreaField
|
||||
autoSize
|
||||
name={`items.${index}.description`}
|
||||
/>
|
||||
);
|
||||
return <FormTextAreaField autoSize name={`items.${index}.description`} />;
|
||||
},
|
||||
minSize: 200,
|
||||
size: 400,
|
||||
@ -118,11 +117,7 @@ export const QuoteDetailsCardEditor = ({
|
||||
),
|
||||
cell: ({ row: { index } }) => {
|
||||
return (
|
||||
<FormQuantityField
|
||||
scale={0}
|
||||
className='text-right'
|
||||
name={`items.${index}.quantity`}
|
||||
/>
|
||||
<FormQuantityField scale={0} className='text-right' name={`items.${index}.quantity`} />
|
||||
);
|
||||
},
|
||||
size: 75,
|
||||
@ -205,13 +200,13 @@ export const QuoteDetailsCardEditor = ({
|
||||
},
|
||||
size: 150,
|
||||
},
|
||||
], [currency, language, t]);
|
||||
const columns = useDetailColumns(detailColumns,
|
||||
{
|
||||
enableDragHandleColumn: true,
|
||||
enableSelectionColumn: true,
|
||||
}
|
||||
],
|
||||
[currency, language, t]
|
||||
);
|
||||
const columns = useDetailColumns(detailColumns, {
|
||||
enableDragHandleColumn: true,
|
||||
enableSelectionColumn: true,
|
||||
});
|
||||
|
||||
const handleAppendCatalogArticle = useCallback(
|
||||
(article: any, quantity: number = 1) => {
|
||||
@ -266,29 +261,67 @@ export const QuoteDetailsCardEditor = ({
|
||||
const defaultLayout = [265, 440, 655];
|
||||
const navCollapsedSize = 4;
|
||||
|
||||
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 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}
|
||||
defaultItem={defaultValues?.items[0]} currencyCode={currency.code}
|
||||
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
|
||||
<QuoteChaptersDataTable
|
||||
controller={chapterEditor}
|
||||
columns={columns}
|
||||
data={fields}
|
||||
defaultItem={defaultValues?.items[0]}
|
||||
currencyCode={currency.code}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||
onAddChapter={() => setCreateChapterOpen(true)} pickCatalogArticle={pickCatalogArticle} pickBlock={pickBlock} />
|
||||
onAddChapter={() => setCreateChapterOpen(true)}
|
||||
pickCatalogArticle={pickCatalogArticle}
|
||||
pickBlock={pickBlock}
|
||||
/>
|
||||
) : (
|
||||
<QuoteItemsSortableDataTable actions={{ ...fieldActions, getItem: (rowIndex) => getValues(`items.${rowIndex}`), pickCatalogArticle, pickBlock }}
|
||||
columns={columns} data={fields} defaultValues={defaultValues}
|
||||
extraControls={<Button type="button" variant="ghost" size="sm" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
|
||||
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange} />
|
||||
<QuoteItemsSortableDataTable
|
||||
actions={{
|
||||
...fieldActions,
|
||||
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
|
||||
pickCatalogArticle,
|
||||
pickBlock,
|
||||
}}
|
||||
columns={columns}
|
||||
data={fields}
|
||||
defaultValues={defaultValues}
|
||||
extraControls={
|
||||
<Button type='button' variant='ghost' size='sm' onClick={() => setCreateChapterOpen(true)}>
|
||||
{t("quotes.chapters.add")}
|
||||
</Button>
|
||||
}
|
||||
showLineDiscountColumn={showLineDiscountColumn}
|
||||
hasLineDiscounts={hasLineDiscounts}
|
||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||
/>
|
||||
);
|
||||
const createChapterDialog = (
|
||||
<QuoteChapterCreateDialog
|
||||
open={createChapterOpen}
|
||||
onOpenChange={setCreateChapterOpen}
|
||||
onCreate={(description) => {
|
||||
const before = chapterEditor.read();
|
||||
const next = addChapter(before, description, chapterEditor.activeId);
|
||||
chapterEditor.apply(next);
|
||||
chapterEditor.focusTitle(
|
||||
next.chapters.find(
|
||||
(chapter) => !before.chapters.some((entry) => entry.id === chapter.id)
|
||||
)!.id
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const createChapterDialog = <QuoteChapterCreateDialog open={createChapterOpen} onOpenChange={setCreateChapterOpen}
|
||||
onCreate={(description) => {
|
||||
const before = chapterEditor.read();
|
||||
const next = addChapter(before, description, chapterEditor.activeId);
|
||||
chapterEditor.apply(next);
|
||||
chapterEditor.focusTitle(next.chapters.find((chapter) => !before.chapters.some((entry) => entry.id === chapter.id))!.id);
|
||||
}} />;
|
||||
|
||||
if (pickerMode === "dialog") {
|
||||
return (
|
||||
|
||||
@ -18,7 +18,9 @@ export function useChapterCollapse() {
|
||||
return;
|
||||
}
|
||||
const currentRevision = ++revision.current;
|
||||
const contents = Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []);
|
||||
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);
|
||||
@ -44,8 +46,11 @@ export function useChapterCollapse() {
|
||||
animations.current.forEach((animation) => animation.cancel());
|
||||
animations.current = [];
|
||||
|
||||
if (!contents.length || typeof contents[0].animate !== "function" ||
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
if (
|
||||
!contents.length ||
|
||||
typeof contents[0].animate !== "function" ||
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
setHidden(collapsed);
|
||||
return;
|
||||
}
|
||||
@ -62,8 +67,16 @@ export function useChapterCollapse() {
|
||||
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" },
|
||||
{
|
||||
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)),
|
||||
];
|
||||
|
||||
@ -54,7 +54,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
|
||||
disabled={!row.getCanSelect()}
|
||||
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
|
||||
aria-label={t("common.select_row")}
|
||||
className='mt-2'
|
||||
className='mt-2 opacity-0 data-[state=checked]:opacity-100 transition-opacity group-hover/detail:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
@ -364,3 +364,47 @@ Inconsistencias observadas, sin cambios fuera del alcance:
|
||||
- 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.
|
||||
|
||||
### Ajuste: alineación del arrastre y plegado animado
|
||||
|
||||
- `QuoteChaptersDataTable.tsx`: el botón de arrastre del título usa el mismo ancho de 16 px que el de las líneas. Ambos iconos comparten eje horizontal, conservando el orden de columnas y el badge de la carpeta.
|
||||
- `useChapterCollapse.ts`: transición de altura y opacidad de 200 ms mediante Web Animations API. Se animan el contenido y el espaciado de las celdas antes de ocultar sus filas; incluye cabeceras, detalles, acciones y estado vacío. Los campos permanecen montados; título y subtotal siguen visibles. Se respeta `prefers-reduced-motion` y se pliega inmediatamente cuando no hay soporte para animación.
|
||||
- Durante el cierre, las filas dejan de ser interactivas mediante `inert` y `aria-hidden`; el arrastre de detalles/destinos vacíos se desactiva desde el inicio. Al invertir el sentido, se cancela la transición anterior y se parte de la posición actual. Al desmontar, se cancelan las animaciones pendientes.
|
||||
- Dos pruebas nuevas del editor verifican ocultación tras finalizar, reversión sin ocultaciones obsoletas, conservación de valores y movimiento reducido. Validación: 26 pruebas del editor, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación React/TanStack fuera de `act` en la suite completa; las nuevas pruebas aisladas pasan sin ese aviso.
|
||||
- Chromium local verifica geometría sobre el HTML actual del harness y ejecuta el hook real: centros de ambos iconos en 117,53125 px; altura del grupo de 222 a 117 px con estados intermedios de 170 y 184,6875 px; recuperación de altura/valores originales y ausencia de animación con movimiento reducido. Artefactos en `/tmp/uecko-chapter-animation-*`; fixture temporal retirado. Esta comprobación no es un E2E con la API autenticada.
|
||||
- No se ejecutan builds de distribución, PDF ni base de datos: son cambios de presentación del editor, sin cambios de persistencia o informes. La compilación Tailwind se dirige a `/tmp` y conserva el aviso previo de Browserslist desactualizado.
|
||||
|
||||
### Corrección: resaltado conjunto de detalle activo y acciones
|
||||
|
||||
- `QuoteItemsSortableTableRow.tsx` identifica la fila activa mediante `data-active-item`. `QuoteChaptersDataTable.tsx` aplica el contorno a sus celdas, porque los bordes de las filas no se dibujan en la tabla con `border-separate`.
|
||||
- Detalle y acciones comparten fondo rojo claro, borde izquierdo rojo de 4 px y contorno exterior fino; se retira el separador intermedio. Las acciones ocupan todo el ancho de la tabla y empiezan junto al borde izquierdo, como en la presentación original. El estilo de selección no se aplica a los capítulos ni a otras líneas.
|
||||
- Validación: 26 pruebas existentes del editor, TypeScript sin emisión, ESLint acotado sin errores (dos advertencias previas en el componente de fila) y `git diff --check`. Chromium local verifica igualdad de fondos, continuidad del contorno y ausencia de espacio o borde entre detalle y acciones. Muestra en `/tmp/uecko-active-row.png`, generada desde el harness del editor; fixture temporal retirado.
|
||||
- No se ejecutan base de datos, PDF, builds de distribución ni E2E autenticados: es una corrección de estilos. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
|
||||
|
||||
### Ajuste: selección exclusiva de líneas de detalle
|
||||
|
||||
- Se elimina el checkbox de los títulos de capítulo, manteniendo su celda vacía para alinear las columnas. La selección se limita a los detalles; el checkbox de cabecera de columnas conserva la selección conjunta de los detalles de ese capítulo.
|
||||
- Se retira del editor el estado de selección múltiple de capítulos y su bloqueo por selección mixta. Los capítulos se mueven individualmente con todo su contenido y conservan sus botones propios de duplicar/eliminar. La barra actúa sobre los detalles seleccionados.
|
||||
- Se adaptan las pruebas existentes de duplicación, selección y alineación: 26 pruebas del editor correctas, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación fuera de `act`. No se ejecutan PDF, base de datos ni E2E: el cambio se limita a la selección en el editor.
|
||||
|
||||
### Ajuste: controles visibles al pasar el cursor
|
||||
|
||||
- Las filas de detalle muestran arrastre y selección al pasar el cursor por su propia fila, con y sin capítulos. El checkbox marcado permanece visible aunque el cursor salga; al desmarcarlo vuelve a ocultarse. El asa también permanece visible durante el arrastre.
|
||||
- En el título de capítulo se muestran arrastre, duplicar y eliminar únicamente al pasar por ese título; el hover de los detalles no activa esos botones. Carpeta y badge siguen visibles. Se usan grupos Tailwind con nombre por tipo de fila y opacidad para mantener la alineación.
|
||||
- Cada control se revela también al recibir foco de teclado. En dispositivos sin hover permanecen visibles para permitir su uso táctil. La transición de opacidad respeta movimiento reducido.
|
||||
- Cambios en `QuoteChaptersDataTable.tsx`, `QuoteItemsSortableTableRow.tsx`, `QuoteItemsSortableDragHandleCell.tsx` y `useDetailColumns.tsx`. Validación: 27 pruebas existentes de editor/arrastre; ESLint acotado sin errores (dos advertencias previas de la fila); Tailwind compilado en `/tmp`; `git diff --check`. La última excepción de checkbox marcado se comprueba además con CSS real en Chromium.
|
||||
- Chromium local verifica ocultación, hover por fila, checkbox marcado/desmarcado, foco de teclado y controles táctiles, con y sin capítulos. Para comprobar selectores de escritorio se desactiva solo la regla táctil en el fixture, ya que Chromium sin interfaz informa `hover:none`; la prueba táctil usa la hoja original. Artefactos `/tmp/uecko-row-controls-*`; fixture temporal retirado. No es un E2E autenticado.
|
||||
- No se ejecutan TypeScript, builds de distribución, PDF ni base de datos: el cambio solo añade clases y no altera tipos, datos o informes. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
|
||||
|
||||
### Ajuste: márgenes simétricos de carpeta y eliminar capítulo
|
||||
|
||||
- `QuoteChaptersDataTable.tsx`: aumenta el espacio izquierdo de la carpeta y amplía la columna de despliegue de 44 a 56 px para alojarlo sin invadir la columna de arrastre. Conserva tamaños de iconos y botones, badge y alineación de detalles.
|
||||
- Chromium local mide 20 px tanto entre el borde izquierdo y la carpeta como entre la papelera y el borde derecho, en ambos capítulos a 1440 y 1024 px. El eje de las asas de título/detalle sigue coincidiendo (diferencia 0 px). HTML exportado del harness en `/tmp/uecko-chapter-spacing.html`; fixture temporal retirado.
|
||||
- Validación acotada: cuatro casos existentes de alineación/orden de columnas, ESLint del componente, compilación Tailwind hacia `/tmp` y `git diff --check`. No se ejecutan TypeScript, PDF, base de datos, builds de distribución ni E2E autenticados por ser un ajuste de espaciado. Persiste el aviso previo de Browserslist desactualizado.
|
||||
|
||||
### Ajuste: título alineado con el arrastre de sus conceptos
|
||||
|
||||
- El título de capítulo ocupa las columnas posteriores a la carpeta y empieza en el mismo eje vertical que el icono de arrastre de sus detalles. El asa del capítulo se sitúa a la derecha del título, junto a duplicar y eliminar; conserva eventos de arrastre, foco y visibilidad al pasar el cursor.
|
||||
- `QuoteChaptersDataTable.tsx` ajusta el colspan y espaciado del título. `QuoteChapterEditor.test.tsx` adapta las comprobaciones de estructura y badge a las dos celdas del título. Se mantienen orden de columnas de los detalles y márgenes exteriores de 20 px.
|
||||
- Validación: cinco casos existentes de alineación, orden y numeración (ejecutados también en el fixture temporal), ESLint sin errores, compilación Tailwind en `/tmp` y `git diff --check`. Chromium local mide diferencia de 0 px entre inicio del texto del título y lado izquierdo del icono de concepto, a 1440 y 1024 px, manteniendo márgenes de carpeta/papelera de 20 px. Fixture temporal retirado; muestra `/tmp/uecko-chapter-title.png`.
|
||||
- No se ejecutan TypeScript, PDF, base de datos, build de distribución ni E2E autenticado: es un cambio local de disposición, sin cambios de tipos o datos. Persiste el aviso previo de Browserslist desactualizado.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user