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 0cbebed645
commit 4740ca46eb
5 changed files with 184 additions and 5 deletions
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString, upsertRule } from "../sieve";
import { newRule, reorderRules, rulesToSieve, sieveToRules, testToSieve, sieveString, upsertRule, type SieveRule } from "../sieve";
describe("sieve codec", () => {
it("escapes strings", () => {
@@ -37,3 +37,21 @@ describe("sieve codec", () => {
expect(sieveToRules("")).toEqual([]);
});
});
describe("reordering rules", () => {
const ids = (rs: SieveRule[]) => rs.map((r) => r.id);
const list = ["a", "b", "c", "d"].map((id) => newRule({ id }));
it("drops a rule above or below the card it was dropped on", () => {
expect(ids(reorderRules(list, "a", "c", false))).toEqual(["b", "a", "c", "d"]);
expect(ids(reorderRules(list, "a", "c", true))).toEqual(["b", "c", "a", "d"]);
expect(ids(reorderRules(list, "d", "a", false))).toEqual(["d", "a", "b", "c"]);
expect(ids(reorderRules(list, "b", "d", true))).toEqual(["a", "c", "d", "b"]);
});
it("leaves the list alone when the drop goes nowhere", () => {
expect(reorderRules(list, "a", "a", true)).toBe(list);
expect(reorderRules(list, "a", "zz", true)).toBe(list);
expect(reorderRules(list, "zz", "a", true)).toBe(list);
expect(ids(list)).toEqual(["a", "b", "c", "d"]);
});
});
+14
View File
@@ -210,6 +210,20 @@ export function upsertRule(rules: SieveRule[], rule: SieveRule): SieveRule[] {
return rules.some((x) => x.id === rule.id) ? rules.map((x) => (x.id === rule.id ? rule : x)) : [...rules, rule];
}
/**
* Moves the rule `fromId` to sit either side of `toId`. `below` says which,
* decided by which half of the target card the pointer was over.
*/
export function reorderRules(rules: SieveRule[], fromId: string, toId: string, below: boolean): SieveRule[] {
if (fromId === toId) return rules;
const moved = rules.find((r) => r.id === fromId);
const rest = rules.filter((r) => r.id !== fromId);
const target = rest.findIndex((r) => r.id === toId);
if (!moved || target < 0) return rules;
const at = below ? target + 1 : target;
return [...rest.slice(0, at), moved, ...rest.slice(at)];
}
export function describeRule(r: SieveRule): string {
const tests = r.tests
.map((t) => {