From 6bcde57e88cb18cb388a7871b0afb937242f7530 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sun, 13 Sep 2026 08:37:16 +0200 Subject: [PATCH] Prevent cancelled drags from committing moves --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 1 + e2e/fixtures/form-reorder-probe.js | 48 ++++++++++++++ e2e/form-reorder.spec.js | 92 ++++++++++++++++++++++++++ tests/static-toolboxes.test.mjs | 10 --- website/src/hooks/usePointerReorder.js | 8 ++- 6 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 e2e/fixtures/form-reorder-probe.js create mode 100644 e2e/form-reorder.spec.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 146e164..0805cf0 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -43,3 +43,4 @@ {"id":"int-224b7820169f57116ea4fd87a50560f1","kind":"field_change","created_at":"2026-09-12T21:26:21.88315065Z","actor":"Shinuwa","issue_id":"sokko-g-bhy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented selective images/imageAnnotation hydration with local retry, preserved unread values in atomic snapshots, and serialized complete exports without populating the UI cache. No schema change; timer/calendar remain eager. check:full passed including 52 E2E; four production scenarios profiled alone, five reloads each. 40-media baseline 120.68 to 2.78 MiB JS heap, 610.1 to 445.4 ms median ready. Reports in .local/performance; docs updated. Opened media remain cached until reload. No git commit or push performed."}} {"id":"int-4036248c53312cfaafae4ee37bb145bd","kind":"field_change","created_at":"2026-09-12T21:26:40.724321767Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-37bf322d7d6ae9d06e033d9f16b7f057","kind":"field_change","created_at":"2026-09-12T21:33:50.250082802Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-cc87d51843f7ee62beb6445939aa30d5","kind":"field_change","created_at":"2026-09-13T06:35:52.804580049Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 3382b61..e587465 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -33,6 +33,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. - Vérifier le mode une colonne et deux colonnes. - Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`. +- Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs. - Vérifier le quota de stockage si l'outil manipule des données lourdes. ## Page Jeu diff --git a/e2e/fixtures/form-reorder-probe.js b/e2e/fixtures/form-reorder-probe.js new file mode 100644 index 0000000..af751e4 --- /dev/null +++ b/e2e/fixtures/form-reorder-probe.js @@ -0,0 +1,48 @@ +// Rôle : expose les hooks réels de formulaire et déplacement sous StrictMode pour les tests navigateur. +import { createElement as h, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useDraftForm } from "../../website/src/hooks/useDraftForm.js"; +import { usePointerReorder } from "../../website/src/hooks/usePointerReorder.js"; + +export function mountFormReorderProbe(element, kind) { + const events = []; + const root = createRoot(element); + function Form() { + const form = useDraftForm({ title: "Initial", detail: "Kept" }); + return h("form", { onSubmit: (event) => { + form.handleSubmit(event, (values, { reset }) => { + events.push({ ...values }); + if (!values.title.trim()) return "Required"; + reset(); + }); + events.push({ prevented: event.defaultPrevented }); + } }, + h("input", form.getFieldProps("title", { "aria-label": "Title", onChange: () => events.push("changed") })), + h("input", form.getFieldProps("detail", { "aria-label": "Detail" })), + h("p", { role: "alert" }, form.error), + h("button", { type: "submit" }, "Submit"), + h("button", { type: "button", onClick: () => form.updateValues({ title: "Patched" }) }, "Patch"), + h("button", { type: "button", onClick: () => form.reset({ title: "Replacement", detail: "New" }) }, "Replace")); + } + function Reorder({ revision }) { + const reorder = usePointerReorder({ + targetSelector: "[data-probe-item]", + getTargetId: (element) => element.dataset.probeItem, + canDropOn: (element) => element.dataset.probeItem !== "blocked", + onMove: (source, target, placement) => events.push({ source, target, placement, revision }) + }); + return h("section", null, + h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } }, reorder.draggingId), + ...["a", "b", "blocked"].map((id) => h("div", { + key: id, "data-probe-item": id, + style: { height: 100, width: 300, border: "1px solid", margin: 10 } + }, h("button", { type: "button", onPointerDown: (event) => reorder.startDrag(event, id) }, id)))); + } + const Component = kind === "form" ? Form : Reorder; + function update(revision = 0) { + flushSync(() => root.render(h(StrictMode, null, h(Component, { revision })))); + } + update(); + return { events, update, unmount: () => root.unmount() }; +} diff --git a/e2e/form-reorder.spec.js b/e2e/form-reorder.spec.js new file mode 100644 index 0000000..8118467 --- /dev/null +++ b/e2e/form-reorder.spec.js @@ -0,0 +1,92 @@ +// Rôle : vérifie les brouillons de formulaire et le cycle de déplacement par pointeur sans assertions de code source. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/form-reorder-probe.js", import.meta.url))}`; +async function setup(page, kind) { + await page.goto("/"); + await page.evaluate(async ({ url, kind }) => { + const { mountFormReorderProbe } = await import(url); + document.getElementById("app").style.display = "none"; + const element = document.createElement("div"); + document.body.prepend(element); + window.interactionProbe = mountFormReorderProbe(element, kind); + }, { url: probeUrl, kind }); +} + +test("draft forms retain edits, report validation, prevent navigation and reset after success", async ({ page }) => { + await setup(page, "form"); + const title = page.getByRole("textbox", { name: "Title", exact: true }); + const detail = page.getByRole("textbox", { name: "Detail", exact: true }); + await title.fill(""); + await page.getByRole("button", { name: "Submit", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveText("Required"); + await title.fill("Draft"); + await expect(page.getByRole("alert")).toBeEmpty(); + await page.evaluate(() => window.interactionProbe.update(1)); + await expect(title).toHaveValue("Draft"); + await expect(detail).toHaveValue("Kept"); + await page.getByRole("button", { name: "Patch", exact: true }).click(); + await expect(title).toHaveValue("Patched"); + await expect(detail).toHaveValue("Kept"); + await page.getByRole("button", { name: "Replace", exact: true }).click(); + await expect(title).toHaveValue("Replacement"); + await expect(detail).toHaveValue("New"); + await title.press("Enter"); + await expect(title).toHaveValue("Initial"); + await expect(detail).toHaveValue("Kept"); + expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([ + "changed", { title: "", detail: "Kept" }, { prevented: true }, + "changed", { title: "Replacement", detail: "New" }, { prevented: true } + ]); +}); + +async function start(page) { + await page.getByRole("button", { name: "a", exact: true }).hover(); + await page.mouse.down(); + await expect(page.getByLabel("Dragging")).toHaveText("a"); +} + +test("pointer drops resolve before and after using the latest callback and reject invalid targets", async ({ page }) => { + await setup(page, "reorder"); + await page.getByRole("button", { name: "a", exact: true }).hover(); + await page.mouse.down({ button: "right" }); + await expect(page.getByLabel("Dragging")).toBeEmpty(); + await page.mouse.up({ button: "right" }); + const target = await page.locator('[data-probe-item="b"]').boundingBox(); + for (const [offset, placement] of [[10, "before"], [90, "after"]]) { + await start(page); + await page.evaluate(() => window.interactionProbe.update(1)); + await page.mouse.move(target.x + 200, target.y + offset); + await page.mouse.up(); + await expect(page.getByLabel("Dragging")).toBeEmpty(); + expect(await page.evaluate(() => window.interactionProbe.events.at(-1))).toEqual({ source: "a", target: "b", placement, revision: 1 }); + } + await start(page); + await page.locator('[data-probe-item="blocked"]').hover(); + await page.mouse.up(); + await start(page); + await page.mouse.up(); + expect(await page.evaluate(() => window.interactionProbe.events.length)).toBe(2); +}); + +test("pointer cancellation never commits a drop and unmount removes active listeners", async ({ page }) => { + await setup(page, "reorder"); + const target = await page.locator('[data-probe-item="b"]').boundingBox(); + await start(page); + await page.mouse.move(target.x + 200, target.y + 50); + await page.getByRole("button", { name: "a", exact: true }).dispatchEvent("pointercancel", { clientX: target.x + 200, clientY: target.y + 50, pointerId: 1 }); + await expect(page.getByLabel("Dragging")).toBeEmpty(); + await page.mouse.up(); + expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]); + await start(page); + await page.evaluate(() => { + const remainingTarget = document.querySelector('[data-probe-item="b"]').cloneNode(true); + window.interactionProbe.unmount(); + document.body.prepend(remainingTarget); + }); + await page.locator('[data-probe-item="b"]').hover(); + await page.mouse.up(); + expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index f85a11f..06bb478 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -29,9 +29,7 @@ test("toolbox cards and pages retain their public UI conventions", async () => { const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); - const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); - const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8"); assert.match(toolboxActions, /creationTemplate/); assert.match(toolboxActions, /type: "memos"/); @@ -101,20 +99,12 @@ test("toolbox cards and pages retain their public UI conventions", async () => { assert.match(storageQuota, /role="progressbar"/); assert.match(importButton, /export function ImportButton/); assert.match(importButton, /accept="application\/json"/); - assert.match(reorderHook, /export function usePointerReorder/); - assert.match(reorderHook, /setPointerCapture/); - assert.match(reorderHook, /elementsFromPoint/); - assert.match(reorderHook, /elementFromPoint/); assert.match(groupedReorderHook, /export function useGroupedReorder/); assert.match(groupedReorderHook, /usePointerReorder/); assert.match(groupedReorderHook, /export function getGroupedEntries/); assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/); assert.match(groupedReorderHook, /data-reorder-orientation/); assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/); - assert.match(draftFormHook, /export function useDraftForm/); - assert.match(draftFormHook, /getFieldProps/); - assert.match(draftFormHook, /handleSubmit/); - assert.match(draftFormHook, /preventDefault/); assert.match(toolboxCoverEditor, /image\/webp/); assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/); assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/); diff --git a/website/src/hooks/usePointerReorder.js b/website/src/hooks/usePointerReorder.js index 302f6d7..434a55b 100644 --- a/website/src/hooks/usePointerReorder.js +++ b/website/src/hooks/usePointerReorder.js @@ -50,17 +50,21 @@ export function usePointerReorder({ const { onMove } = optionsRef.current; const target = getDropTarget(event); if (target.id) onMove?.(draggingId, target.id, target.placement); + handlePointerCancel(); + } + + function handlePointerCancel() { setDraggingId(""); setDropTarget({ id: "", placement: "before" }); } window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp, { once: true }); - window.addEventListener("pointercancel", handlePointerUp, { once: true }); + window.addEventListener("pointercancel", handlePointerCancel, { once: true }); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerCancel); }; }, [draggingId, targetSelector]);