Prevent cancelled drags from committing moves
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
This commit is contained in:
parent
e6e08b458c
commit
6bcde57e88
6 changed files with 148 additions and 12 deletions
|
|
@ -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-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-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-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"}}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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 le mode une colonne et deux colonnes.
|
||||||
- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`.
|
- 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.
|
- Vérifier le quota de stockage si l'outil manipule des données lourdes.
|
||||||
|
|
||||||
## Page Jeu
|
## Page Jeu
|
||||||
|
|
|
||||||
48
e2e/fixtures/form-reorder-probe.js
Normal file
48
e2e/fixtures/form-reorder-probe.js
Normal file
|
|
@ -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() };
|
||||||
|
}
|
||||||
92
e2e/form-reorder.spec.js
Normal file
92
e2e/form-reorder.spec.js
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
|
@ -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 storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
|
||||||
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
|
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
|
||||||
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.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 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, /creationTemplate/);
|
||||||
assert.match(toolboxActions, /type: "memos"/);
|
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(storageQuota, /role="progressbar"/);
|
||||||
assert.match(importButton, /export function ImportButton/);
|
assert.match(importButton, /export function ImportButton/);
|
||||||
assert.match(importButton, /accept="application\/json"/);
|
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, /export function useGroupedReorder/);
|
||||||
assert.match(groupedReorderHook, /usePointerReorder/);
|
assert.match(groupedReorderHook, /usePointerReorder/);
|
||||||
assert.match(groupedReorderHook, /export function getGroupedEntries/);
|
assert.match(groupedReorderHook, /export function getGroupedEntries/);
|
||||||
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
|
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
|
||||||
assert.match(groupedReorderHook, /data-reorder-orientation/);
|
assert.match(groupedReorderHook, /data-reorder-orientation/);
|
||||||
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
|
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, /image\/webp/);
|
||||||
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
|
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
|
||||||
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
|
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
|
||||||
|
|
|
||||||
|
|
@ -50,17 +50,21 @@ export function usePointerReorder({
|
||||||
const { onMove } = optionsRef.current;
|
const { onMove } = optionsRef.current;
|
||||||
const target = getDropTarget(event);
|
const target = getDropTarget(event);
|
||||||
if (target.id) onMove?.(draggingId, target.id, target.placement);
|
if (target.id) onMove?.(draggingId, target.id, target.placement);
|
||||||
|
handlePointerCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel() {
|
||||||
setDraggingId("");
|
setDraggingId("");
|
||||||
setDropTarget({ id: "", placement: "before" });
|
setDropTarget({ id: "", placement: "before" });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||||
window.addEventListener("pointercancel", handlePointerUp, { once: true });
|
window.addEventListener("pointercancel", handlePointerCancel, { once: true });
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
window.removeEventListener("pointerup", handlePointerUp);
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
window.removeEventListener("pointercancel", handlePointerUp);
|
window.removeEventListener("pointercancel", handlePointerCancel);
|
||||||
};
|
};
|
||||||
}, [draggingId, targetSelector]);
|
}, [draggingId, targetSelector]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue