f4f94981f8
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.
232 lines
8.3 KiB
JavaScript
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);
|
|
});
|