Let a filter rule be dragged into place

Twenty-five rules and two buttons that move one place at a time meant a
rule pushed to the wrong end cost ten clicks to bring back. It can now
be dragged.

A grip on the left of each card arms the drag, so the switch, the name
and the buttons still take a plain click, and the up and down buttons
stay for the keyboard. The card being dragged fades; the one under the
pointer draws a line on the edge the rule would land on, top half or
bottom.

The guard against dropping a rule onto itself reads a ref rather than
state: dragstart and the first dragover can arrive in the same frame,
and a stale read there drew a drop line on the card being dragged. Found
by driving the real thing in a browser, and covered by a test that fires
the two events back to back.
This commit is contained in:
2026-08-25 08:51:28 -07:00
parent b4fd3d3ab4
commit 696b3713ed
5 changed files with 184 additions and 5 deletions
@@ -0,0 +1,98 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { FiltersSettings } from "../FiltersSettings";
import { useSieve } from "@/store/sieve";
import { newRule, rulesToSieve } from "@/lib/sieve";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/** jsdom has no DataTransfer, and no layout — both are faked just enough. */
function dataTransfer() {
const data: Record<string, string> = {};
return {
types: [] as string[],
effectAllowed: "",
dropEffect: "",
setData(k: string, v: string) { data[k] = v; this.types.push(k); },
getData(k: string) { return data[k] ?? ""; },
};
}
function fire(el: Element, type: string, dt: ReturnType<typeof dataTransfer>, clientY = 0) {
const ev = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(ev, "dataTransfer", { value: dt });
Object.defineProperty(ev, "clientY", { value: clientY });
act(() => { el.dispatchEvent(ev); });
}
/** Cards have no size in jsdom, so any positive clientY counts as the lower half. */
const LOWER = 1;
const UPPER = 0;
describe("reordering rules by dragging", () => {
let host: HTMLDivElement;
let root: Root;
const cards = () => Array.from(document.querySelectorAll(".rule-card"));
const names = () => cards().map((c) => c.querySelector('div[style*="font-weight"]')?.textContent);
const press = (i: number) => act(() => { cards()[i]!.querySelector(".drag-handle")!.dispatchEvent(new Event("pointerdown", { bubbles: true })); });
beforeEach(() => {
const rules = ["Newsletters", "From the boss", "Receipts"].map((name, i) => newRule({ id: `r${i}`, name }));
useSieve.setState({
accountId: "a", available: true, loading: false, error: null,
scripts: [{ id: "s1", name: "ihasmail", blobId: "b1", isActive: true }],
contents: { s1: rulesToSieve(rules) },
});
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<FiltersSettings />));
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("arms dragging only from the handle of the rule pressed", () => {
expect(cards()).toHaveLength(3);
expect(cards().map((c) => c.getAttribute("draggable"))).toEqual(["false", "false", "false"]);
press(1);
expect(cards().map((c) => c.getAttribute("draggable"))).toEqual(["false", "true", "false"]);
});
it("drops a rule below the card it was dragged onto", () => {
press(0);
const dt = dataTransfer();
fire(cards()[0]!, "dragstart", dt);
expect(cards()[0]!.className).toContain("dragging");
fire(cards()[2]!, "dragover", dt, LOWER);
expect(cards()[2]!.className).toContain("drop-below");
fire(cards()[2]!, "drop", dt, LOWER);
expect(names()).toEqual(["From the boss", "Receipts", "Newsletters"]);
expect(cards().every((c) => !/dragging|drop-(above|below)/.test(c.className))).toBe(true);
});
it("drops a rule above the card when the pointer is in its top half", () => {
press(2);
const dt = dataTransfer();
fire(cards()[2]!, "dragstart", dt);
fire(cards()[0]!, "dragover", dt, UPPER);
expect(cards()[0]!.className).toContain("drop-above");
fire(cards()[0]!, "drop", dt, UPPER);
expect(names()).toEqual(["Receipts", "Newsletters", "From the boss"]);
});
it("draws no drop line on the rule being dragged, even before React re-renders", () => {
press(1);
const dt = dataTransfer();
// dragstart and dragover back to back: the guard cannot wait for a render.
fire(cards()[1]!, "dragstart", dt);
fire(cards()[1]!, "dragover", dt, LOWER);
expect(cards().some((c) => /drop-(above|below)/.test(c.className))).toBe(false);
fire(cards()[1]!, "drop", dt, LOWER);
expect(names()).toEqual(["Newsletters", "From the boss", "Receipts"]);
});
it("ignores a drag that is not a rule", () => {
const dt = dataTransfer();
dt.setData("text/plain", "hello");
fire(cards()[1]!, "dragover", dt, LOWER);
expect(cards().some((c) => /drop-(above|below)/.test(c.className))).toBe(false);
});
});