Files
TX/artifacts/tx-os/tests/executive-meetings-row-drag.spec.mjs
T
Riyadh 10a469b8dc Task #489 fix: decouple row drag from edit mode
Previous review rejected because row drag was gated on
`effectiveCanMutate = canMutate && editMode`, forcing users to
enter Edit mode to drag. Spec requires drag to work in view mode
for any user with mutate permission.

Changes:
- MeetingRow: new `dragEnabled` prop separate from `canMutate`.
  Used by useSortable.disabled, safeRowDragListeners gate,
  attributes spread, and cursor-grab class.
- Parent passes `dragEnabled={canMutate}` (raw, not gated by
  editMode); `canMutate={effectiveCanMutate}` retained for inline
  cell editing.
- handleRowClick: scope interactive `closest()` to descendants
  (interactive !== e.currentTarget). Required because the row now
  carries dnd-kit's role="button" in view mode and an unscoped
  closest() matched the row itself, swallowing every popover click.
- e2e: removed edit-toggle click from row-drag spec — drag now
  passes in view mode.

Tests: row-drag spec (view mode), popover spec (4/4), schedule-
features drag tests (290, 834), backend rotate-content (6/6) all
pass. 3 unrelated schedule-features failures reference a missing
em-nav-settings testid that does not exist in source — pre-existing
broken tests, not caused by this task.
2026-05-11 12:28:46 +00:00

232 lines
8.3 KiB
JavaScript

// #489: e2e for "drag a meeting from anywhere on its row to a new
// position". The schedule's time column AND daily numbers stay
// anchored to the physical rows; only the meeting CONTENT (title,
// attendees, notes, color, status, merge) rotates through the slots.
//
// We drag row #1 (Alpha @ 09:00) down to row #3 (Charlie @ 11:00).
// Expected DB result on the day:
// - The (start_time, end_time, daily_number) tuples on the date are
// unchanged set-wise — slot[1] = (09:00, 09:30, dn1), slot[2] =
// (10:00, 10:30, dn2), slot[3] = (11:00, 11:30, dn3) all still
// exist.
// - But the Alpha meeting (originally slot[1]) now carries the
// slot[3] tuple and the Bravo + Charlie meetings rotated up.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run the row-drag test");
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
function pickFutureDate(offsetDays) {
const d = new Date(Date.now() + offsetDays * 86_400_000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const DATE_DRAG = pickFutureDate(40);
async function nextDailyNumberForDate(date) {
const { rows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $1`,
[date],
);
return rows[0].n;
}
async function insertMeeting({ date, titleEn, startTime, endTime }) {
const dn = await nextDailyNumberForDate(date);
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $2, $3, $4, $5, 'scheduled')
RETURNING id`,
[dn, titleEn, date, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function readMeeting(id) {
const { rows } = await pool.query(
`SELECT title_en, start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
async function gotoTestDate(page, date) {
await page.goto("/executive-meetings");
const dateInput = page.locator('input[type="date"]').first();
await dateInput.waitFor({ state: "visible", timeout: 10_000 });
await dateInput.fill(date);
await dateInput.dispatchEvent("change");
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("dragging a row from position 1 to position 3 rotates content but anchors time + daily numbers", async ({
page,
}) => {
const alphaId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Alpha",
startTime: "09:00:00",
endTime: "09:30:00",
});
const bravoId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Bravo",
startTime: "10:00:00",
endTime: "10:30:00",
});
const charlieId = await insertMeeting({
date: DATE_DRAG,
titleEn: "Drag Row Charlie",
startTime: "11:00:00",
endTime: "11:30:00",
});
// Snapshot the per-physical-row daily numbers before the drag — they
// must still match (sorted by start_time) after the rotation.
const beforeAlpha = await readMeeting(alphaId);
const beforeBravo = await readMeeting(bravoId);
const beforeCharlie = await readMeeting(charlieId);
const dnSet = new Set([
beforeAlpha.daily_number,
beforeBravo.daily_number,
beforeCharlie.daily_number,
]);
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page, DATE_DRAG);
const alphaRow = page.locator(`[data-testid="em-row-${alphaId}"]`);
const charlieRow = page.locator(`[data-testid="em-row-${charlieId}"]`);
await expect(alphaRow).toBeVisible({ timeout: 10_000 });
await expect(charlieRow).toBeVisible();
// #489: row drag must work for any user with mutate permission
// even when edit mode is OFF — we deliberately do NOT toggle edit
// mode here. The row's useSortable is gated on `dragEnabled`
// (raw canMutate), independent of `effectiveCanMutate` which gates
// inline-cell editing.
// The whole <tr> is the drag handle now (the dedicated grip is
// gone). dnd-kit's PointerSensor activates after 6px of movement.
// safeRowDragListeners skips drags that start over interactive
// descendants (buttons, inputs, time/edit cells, row-actions,
// bulk-select). The # cell only contains a plain <span> with the
// daily number — no skip-listed children — so it is the safest
// drag-start surface.
const alphaNumberCell = page
.locator(`[data-testid="em-row-${alphaId}"] td`)
.first();
const numBox = await alphaNumberCell.boundingBox();
const charlieBox = await charlieRow.boundingBox();
if (!numBox || !charlieBox) {
throw new Error("rows + # cell must have a bounding box for the drag");
}
const startX = numBox.x + numBox.width / 2;
const startY = numBox.y + numBox.height / 2;
const endX = startX;
const endY = charlieBox.y + charlieBox.height - 4;
// Wait for the rotateContent POST so we don't race on the DB poll.
const rotatePromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/rotate-content" &&
resp.request().method() === "POST" &&
resp.ok()
);
},
{ timeout: 15_000 },
);
await page.mouse.move(startX, startY);
await page.mouse.down();
// Trip the 6px activation distance with vertical motion (the row
// axis we actually want to reorder along), then continue to the
// target row's lower edge so dnd-kit's collision detection settles
// on Charlie as the over-target rather than Bravo.
await page.mouse.move(startX, startY + 12, { steps: 5 });
await page.mouse.move(endX, endY, { steps: 15 });
await page.mouse.up();
await rotatePromise;
// Wait for the rotation to land in the DB.
await expect
.poll(async () => (await readMeeting(alphaId)).start_time, {
timeout: 10_000,
})
.toBe("11:00:00");
const alphaAfter = await readMeeting(alphaId);
const bravoAfter = await readMeeting(bravoId);
const charlieAfter = await readMeeting(charlieId);
// Alpha rotated to position 3 → carries the slot[3] tuple.
expect(alphaAfter.start_time).toBe("11:00:00");
expect(alphaAfter.end_time).toBe("11:30:00");
// Titles are GLUED to their meeting rows — the content moves as a
// unit. The user dragged the Alpha CONTENT to the bottom slot.
expect(alphaAfter.title_en).toBe("Drag Row Alpha");
expect(bravoAfter.title_en).toBe("Drag Row Bravo");
expect(charlieAfter.title_en).toBe("Drag Row Charlie");
// Bravo + Charlie rotated up by one slot.
expect(bravoAfter.start_time).toBe("09:00:00");
expect(bravoAfter.end_time).toBe("09:30:00");
expect(charlieAfter.start_time).toBe("10:00:00");
expect(charlieAfter.end_time).toBe("10:30:00");
// Daily numbers — the SET on the day is unchanged (the # column
// stays anchored to physical rows in chronological order). After the
// renumber-by-start-time, Bravo (now 09:00) holds the smallest dn,
// Charlie (10:00) the middle, Alpha (11:00) the largest.
const afterDnSet = new Set([
alphaAfter.daily_number,
bravoAfter.daily_number,
charlieAfter.daily_number,
]);
expect(afterDnSet).toEqual(dnSet);
expect(bravoAfter.daily_number).toBeLessThan(charlieAfter.daily_number);
expect(charlieAfter.daily_number).toBeLessThan(alphaAfter.daily_number);
});