Añadir funcionalidad de arrastre y gestión de filas en QuoteItemsSortable, incluyendo pruebas unitarias y ajustes en el formulario.

This commit is contained in:
David Arranz 2026-09-16 19:13:11 +02:00
parent 463c0662ed
commit 3eb0396cbd
6 changed files with 239 additions and 9 deletions

View File

@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QuoteItemsSortableDragHandleCell } from "./QuoteItemsSortableDragHandleCell";
import {
QuoteItemsSortableTableRowContext,
QuoteItemsSortableTableRowContextValue,
} from "./QuoteItemsSortableTableRow";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
jest.mock("i18next", () => ({ t: (key: string) => key }));
jest.mock("@/ui", () => {
const React = require("react") as typeof import("react");
return {
Button: React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
(props, ref) => <button ref={ref} {...props} />
),
};
});
describe("QuoteItemsSortableDragHandleCell", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("uses the row sortable context as the drag activator", () => {
const listener = jest.fn();
const activatorRef = jest.fn();
const contextValue: QuoteItemsSortableTableRowContextValue = {
attributes: {
role: "button",
tabIndex: 0,
"aria-disabled": false,
"aria-pressed": undefined,
"aria-roledescription": "sortable",
"aria-describedby": "row-1",
},
listeners: { onPointerDown: listener },
ref: activatorRef,
isDragging: false,
};
act(() => {
root.render(
<QuoteItemsSortableTableRowContext.Provider value={contextValue}>
<QuoteItemsSortableDragHandleCell />
</QuoteItemsSortableTableRowContext.Provider>
);
});
const button = container.querySelector("button") as HTMLButtonElement;
expect(button.getAttribute("aria-describedby")).toBe("row-1");
expect(activatorRef).toHaveBeenCalledWith(button);
act(() => {
button.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
});
expect(listener).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
import { Button } from "@/ui";
import { t } from "i18next";
import { GripVerticalIcon } from "lucide-react";
import { useContext } from "react";
import { QuoteItemsSortableTableRowContext } from "./QuoteItemsSortableTableRow";
export interface QuoteItemsSortableDragHandleCellProps {
className?: string;
}
export const QuoteItemsSortableDragHandleCell = ({
className,
}: QuoteItemsSortableDragHandleCellProps) => {
const {
attributes,
isDragging,
listeners,
ref: setActivatorNodeRef,
} = useContext(QuoteItemsSortableTableRowContext);
return (
<Button
ref={setActivatorNodeRef}
onClick={(event) => {
event.preventDefault();
}}
size='icon'
variant='link'
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground",
className
)}
{...attributes}
{...listeners}
>
<GripVerticalIcon className='w-4 h-4' />
<span className='sr-only'>{t("common.move_row")}</span>
</Button>
);
};

View File

@ -1,20 +1,29 @@
import { cn } from "@/lib/utils";
import { TableRow } from "@/ui/table";
import { DraggableSyntheticListeners } from "@dnd-kit/core";
import { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CSSProperties, PropsWithChildren, createContext, useMemo } from "react";
import { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable";
interface Context {
attributes: Record<string, any>;
export interface QuoteItemsSortableTableRowContextValue {
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
ref(node: HTMLElement | null): void;
isDragging: boolean;
}
export const QuoteItemsSortableTableRowContext = createContext<Context>({
attributes: {},
export const QuoteItemsSortableTableRowContext = createContext<QuoteItemsSortableTableRowContextValue>({
attributes: {
role: "button",
tabIndex: 0,
"aria-disabled": false,
"aria-pressed": undefined,
"aria-roledescription": "sortable",
"aria-describedby": "",
},
listeners: undefined,
ref() {},
isDragging: false,
});
function animateLayoutChanges(args: any) {
@ -52,8 +61,9 @@ export function QuoteItemsSortableTableRow({
attributes,
listeners,
ref: setActivatorNodeRef,
isDragging,
}),
[attributes, listeners, setActivatorNodeRef]
[attributes, isDragging, listeners, setActivatorNodeRef]
);
return (

View File

@ -1,12 +1,12 @@
import {
DataTablaRowActionFunction,
DataTableRowActions,
DataTableRowDragHandleCell,
} from "@/components";
import { Checkbox } from "@/ui";
import { ColumnDef, Row } from "@tanstack/react-table";
import { useMemo } from "react";
import { QuoteItemsSortableDragHandleCell } from "../components/QuoteItemsSortableDragHandleCell";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useDetailColumns<TData = unknown, TValue = unknown>(
@ -35,7 +35,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
<UnfoldVertical aria-label='Mover fila' className='items-center justify-center w-4 h-4' />
),*/
header: () => null,
cell: (info) => <DataTableRowDragHandleCell rowId={info.row.id} />,
cell: () => <QuoteItemsSortableDragHandleCell />,
enableSorting: false,
enableHiding: false,
size: 40,

101
client/src/ui/form.test.tsx Normal file
View File

@ -0,0 +1,101 @@
import { act, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { FormProvider, UseFormReturn, useForm } from "react-hook-form";
import { FormField } from "./form";
type TestFormValues = {
items: Array<{ quantity: string }>;
};
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
type DynamicQuantityFieldProps = {
name: "items.0.quantity" | "items.1.quantity";
onMount: () => void;
};
const DynamicQuantityField = ({ name, onMount }: DynamicQuantityFieldProps) => {
const onMountRef = useRef(onMount);
useEffect(() => {
onMountRef.current();
}, []);
return (
<FormField
name={name}
render={({ field }) => <input aria-label='quantity' {...field} />}
/>
);
};
type FormHarnessProps = DynamicQuantityFieldProps & {
onFormReady: (form: UseFormReturn<TestFormValues>) => void;
};
const FormHarness = ({ name, onMount, onFormReady }: FormHarnessProps) => {
const form = useForm<TestFormValues>({
defaultValues: {
items: [{ quantity: "1" }, { quantity: "2" }],
},
});
useEffect(() => {
onFormReady(form);
}, [form, onFormReady]);
return (
<FormProvider {...form}>
<DynamicQuantityField name={name} onMount={onMount} />
</FormProvider>
);
};
describe("FormField", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("resubscribes a Controller when its name changes without remounting its parent", () => {
const onMount = jest.fn();
let form: UseFormReturn<TestFormValues> | undefined;
const renderHarness = (name: DynamicQuantityFieldProps["name"]) => {
root.render(
<FormHarness name={name} onMount={onMount} onFormReady={(value) => (form = value)} />
);
};
act(() => renderHarness("items.0.quantity"));
const inputBeforeRename = container.querySelector("input[aria-label='quantity']") as HTMLInputElement;
expect(inputBeforeRename.value).toBe("1");
expect(onMount).toHaveBeenCalledTimes(1);
act(() => renderHarness("items.1.quantity"));
expect(onMount).toHaveBeenCalledTimes(1);
expect((container.querySelector("input[aria-label='quantity']") as HTMLInputElement).value).toBe("2");
act(() => {
form?.setValue("items.1.quantity", "5");
});
expect((container.querySelector("input[aria-label='quantity']") as HTMLInputElement).value).toBe("5");
expect(form?.getValues("items.1.quantity")).toBe("5");
});
});

View File

@ -34,7 +34,7 @@ const FormField = <
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
<Controller key={props.name} {...props} />
</FormFieldContext.Provider>
);
};