Files
kandev/apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts
T

320 lines
13 KiB
TypeScript

import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { settledBoundingBox } from "../../helpers/settled-box";
/**
* Drags a sidebar task row's handle onto a nest drop zone using pointer
* events. dnd-kit's MouseSensor needs >8px of travel before the drag
* activates, and the nest zone only renders once the drag is live, so the
* target box is resolved mid-drag.
*/
async function dragRowToNestZone(
page: Page,
handleLocator: ReturnType<Page["locator"]>,
zoneLocator: ReturnType<Page["locator"]>,
) {
const from = await settledBoundingBox(handleLocator);
const startX = from.x + from.width / 2;
const startY = from.y + from.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Exceed the 8px activation distance so the drag starts.
await page.mouse.move(startX + 24, startY, { steps: 3 });
// The zone now exists; aim the pointer at its center.
await expect(zoneLocator).toBeVisible();
const to = await zoneLocator.boundingBox();
if (!to) throw new Error("nest drop zone has no bounding box");
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
await page.mouse.up();
}
test.describe("Subtask re-parenting by drag and drop", () => {
test("re-parents an inherited-workspace subtask onto another root", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const oldParent = await apiClient.createTask(
seedData.workspaceId,
"Reparent old parent",
placement,
);
const newParent = await apiClient.createTask(
seedData.workspaceId,
"Reparent new parent",
placement,
);
const child = await apiClient.createTask(seedData.workspaceId, "Reparent child", {
...placement,
parent_id: oldParent.id,
workspace_mode: "inherit_parent",
});
await testPage.goto(`/t/${newParent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
const childHandle = taskBlock(child.id).getByTestId("sortable-task-handle");
const newParentZone = taskBlock(newParent.id).getByTestId("nest-drop-zone");
await dragRowToNestZone(testPage, childHandle, newParentZone);
// The tree moves live: child now sits inside the new parent's block.
await expect(taskBlock(newParent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
timeout: 10_000,
});
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
// The composite semantics (un-nest then nest) apply: parent re-pointed and
// the inherited workspace mode normalized to shared_group.
await expect
.poll(async () => {
const task = await apiClient.getTask(child.id);
const workspace = task.metadata?.workspace as { mode?: string } | undefined;
return { parentId: task.parent_id ?? "", workspaceMode: workspace?.mode };
})
.toEqual({ parentId: newParent.id, workspaceMode: "shared_group" });
});
test("nests a root task under another root", async ({ testPage, apiClient, seedData }) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, "Nest root parent", placement);
const child = await apiClient.createTask(seedData.workspaceId, "Nest root child", placement);
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "0");
await dragRowToNestZone(
testPage,
taskBlock(child.id).getByTestId("sortable-task-handle"),
taskBlock(parent.id).getByTestId("nest-drop-zone"),
);
await expect(taskBlock(parent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
timeout: 10_000,
});
await expect.poll(async () => (await apiClient.getTask(child.id)).parent_id).toBe(parent.id);
});
test("offers no nest zones for a task that already has children", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parentWithChild = await apiClient.createTask(
seedData.workspaceId,
"Nestless parent",
placement,
);
await apiClient.createTask(seedData.workspaceId, "Nestless child", {
...placement,
parent_id: parentWithChild.id,
});
await testPage.goto(`/t/${parentWithChild.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Start a drag of the parent-with-child; no row may offer a nest zone.
const handle = taskBlock(parentWithChild.id).getByTestId("sortable-task-handle").first();
const box = await handle.boundingBox();
if (!box) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
await testPage.keyboard.press("Escape");
await expect
.poll(async () => (await apiClient.getTask(parentWithChild.id)).parent_id ?? "")
.toBe("");
});
test("ignores a drop on a subtask row (not a valid target)", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parentA = await apiClient.createTask(seedData.workspaceId, "Invalid A parent", placement);
const parentB = await apiClient.createTask(seedData.workspaceId, "Invalid B parent", placement);
const childA = await apiClient.createTask(seedData.workspaceId, "Invalid A child", {
...placement,
parent_id: parentA.id,
});
const childB = await apiClient.createTask(seedData.workspaceId, "Invalid B child", {
...placement,
parent_id: parentB.id,
});
await testPage.goto(`/t/${parentA.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Drag childA toward childB: childB (a subtask) is not a valid target, so
// its row offers no nest zone (parentB's row does — it is a valid root
// candidate), and a drop on childB's row is cross-level: no-op.
const fromBox = await taskBlock(childA.id).getByTestId("sortable-task-handle").boundingBox();
if (!fromBox) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
steps: 3,
});
const zoneFor = (taskId: string) =>
session.sidebar.locator(`[data-testid="nest-drop-zone"][data-task-id="${taskId}"]`);
await expect(zoneFor(childB.id)).toHaveCount(0);
await expect(zoneFor(parentB.id)).toHaveCount(1);
const toBox = await taskBlock(childB.id).boundingBox();
if (!toBox) throw new Error("target row has no bounding box");
await testPage.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 });
await testPage.mouse.up();
await expect.poll(async () => (await apiClient.getTask(childA.id)).parent_id).toBe(parentA.id);
});
test("sibling reorder still works inside a parent (regression)", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, "Reorder parent", placement);
const first = await apiClient.createTask(seedData.workspaceId, "Reorder first child", {
...placement,
parent_id: parent.id,
});
const second = await apiClient.createTask(seedData.workspaceId, "Reorder second child", {
...placement,
parent_id: parent.id,
});
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Children render newest-first by default, so resolve the rendered order
// and drag the lower sibling onto the upper one's row (subtasks are not
// nest targets, so only the reorder path can fire).
const firstY = (await taskBlock(first.id).boundingBox())?.y ?? 0;
const secondY = (await taskBlock(second.id).boundingBox())?.y ?? 0;
const [upperId, lowerId] = firstY < secondY ? [first.id, second.id] : [second.id, first.id];
const upperBox = await taskBlock(upperId).boundingBox();
if (!upperBox) throw new Error("rows have no bounding boxes");
const fromBox = await taskBlock(lowerId).getByTestId("sortable-task-handle").boundingBox();
if (!fromBox) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
steps: 3,
});
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
await testPage.mouse.move(upperBox.x + upperBox.width / 2, upperBox.y + upperBox.height / 2, {
steps: 12,
});
await testPage.mouse.up();
await expect
.poll(async () => {
const upperY = (await taskBlock(upperId).boundingBox())?.y ?? 0;
const lowerY = (await taskBlock(lowerId).boundingBox())?.y ?? 0;
return lowerY < upperY;
})
.toBe(true);
});
test("starting a drag inside the row context menu does not drag the row", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, "Menu drag parent", placement);
const child = await apiClient.createTask(seedData.workspaceId, "Menu drag child", placement);
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
const before = {
parentY: (await taskBlock(parent.id).boundingBox())?.y ?? 0,
childY: (await taskBlock(child.id).boundingBox())?.y ?? 0,
};
// Right-click opens the context menu; the MouseSensor ignores right
// buttons, so opening the menu alone must not start a drag.
await taskBlock(child.id).getByTestId("sortable-task-handle").click({ button: "right" });
const colorItem = testPage.getByRole("menuitem", { name: /color/i });
await expect(colorItem).toBeVisible();
// Press and move inside the menu. The menu's pointer events must not
// reach the drag handle's sensor: a live drag would dim the source row
// and render nest drop zones on candidate rows.
const colorBox = await colorItem.boundingBox();
if (!colorBox) throw new Error("context menu color item has no bounding box");
await testPage.mouse.move(colorBox.x + colorBox.width / 2, colorBox.y + colorBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(
colorBox.x + colorBox.width / 2 + 24,
colorBox.y + colorBox.height / 2,
{
steps: 3,
},
);
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
await expect(taskBlock(child.id)).not.toHaveCSS("opacity", "0.5");
await testPage.mouse.up();
// The row never moved: both rows keep their vertical order.
await expect
.poll(async () => {
return {
parentY: (await taskBlock(parent.id).boundingBox())?.y ?? 0,
childY: (await taskBlock(child.id).boundingBox())?.y ?? 0,
};
})
.toEqual(before);
});
});