b5b55f89fb
Lifted the per-row missing-time drag block (originally #492). Meetings without a (startTime, endTime) tuple are a normal state and now rotate freely alongside timed rows. Server (artifacts/api-server/src/routes/executive-meetings.ts): - Dropped the `no_time_window` early-return guard in /api/executive-meetings/rotate-content. - Sort + slot construction now tolerate null start times (NULLS LAST, tie-break by id) so a null tuple rotates as a real slot. - All other safeguards (cancelled_in_rotate, optimistic lock, renumberDayByStartTime, audit logging) remain intact. Client (artifacts/tx-os/src/pages/executive-meetings.tsx): - Removed missingTimeCount / dayRotatable memos, the dayRotatable prop wiring, dragBlockedByMissingTime + effectiveDragEnabled composition, the row's data-drag-blocked / native title / cursor-not-allowed-opacity-80 / aria-disabled overrides, the entire DragBlockedTooltipButton component + its render site, and the no_time_window errorToast branch in rotateContent. Pruned now-dead Tooltip + AlertTriangle imports. i18n: removed the `executiveMeetings.rotate.needsTimeWindow` block (tooltip + errorToast) from en.json and ar.json. Tests: deleted `executive-meetings-rotate-needs-time-window.spec.mjs` and added `executive-meetings-rotate-allows-missing-times.spec.mjs`, which inserts 3 meetings (one with null times), verifies no drag-blocked warning button / aria-disabled, drags the top row to the bottom slot, asserts the rotate-content POST succeeds, and confirms exactly one row still has a null tuple after rotation. Verified: row-drag, row-quick-actions, and the new spec all pass (8/8). Architect code review PASSED.
254 lines
8.9 KiB
JavaScript
254 lines
8.9 KiB
JavaScript
// #497: e2e for the new rotation behaviour. Originally (#492) the
|
|
// client + server hard-blocked row-drag rotation if ANY meeting on
|
|
// the day was missing a (start_time, end_time) tuple — surfaced via
|
|
// an amber per-row warning button, dimmed/aria-disabled rows, and a
|
|
// `code: "no_time_window"` 400 from the server. Per product feedback
|
|
// that block was lifted: meetings without a time tuple are a normal
|
|
// state and rotate freely alongside timed rows. This spec verifies:
|
|
// * no per-row drag-blocked warning button is rendered;
|
|
// * the row is NOT aria-disabled and has no `data-drag-blocked`;
|
|
// * dragging a row past an untimed sibling fires a successful
|
|
// POST /api/executive-meetings/rotate-content;
|
|
// * the untimed slot rotates with the rest — its NULL tuple ends
|
|
// up on whichever physical row dnd-kit dropped it on.
|
|
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 rotate-allows-missing-times 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 = pickFutureDate(124);
|
|
|
|
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 setLangEn(page) {
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem("tx-lang", "en");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
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("row-drag rotation succeeds even when one meeting on the day has no time tuple", async ({
|
|
page,
|
|
}) => {
|
|
const timedId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "MissingTimes Timed",
|
|
startTime: "09:00:00",
|
|
endTime: "09:30:00",
|
|
});
|
|
const untimedId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "MissingTimes Untimed",
|
|
startTime: null,
|
|
endTime: null,
|
|
});
|
|
const otherId = await insertMeeting({
|
|
date: DATE,
|
|
titleEn: "MissingTimes Other",
|
|
startTime: "11:00:00",
|
|
endTime: "11:30:00",
|
|
});
|
|
|
|
await setLangEn(page);
|
|
await loginViaUi(page, "admin", "admin123");
|
|
await gotoTestDate(page, DATE);
|
|
|
|
// Force EN regardless of the admin user's stored preferredLanguage.
|
|
await page.evaluate(async () => {
|
|
const w = window;
|
|
if (w.i18next && typeof w.i18next.changeLanguage === "function") {
|
|
await w.i18next.changeLanguage("en");
|
|
}
|
|
});
|
|
|
|
const timedRow = page.getByTestId(`em-row-${timedId}`);
|
|
const untimedRow = page.getByTestId(`em-row-${untimedId}`);
|
|
const otherRow = page.getByTestId(`em-row-${otherId}`);
|
|
await expect(timedRow).toBeVisible({ timeout: 10_000 });
|
|
await expect(untimedRow).toBeVisible();
|
|
await expect(otherRow).toBeVisible();
|
|
|
|
// No row is aria-disabled or marked drag-blocked, and the per-row
|
|
// amber warning button is gone (the underlying drag gate was
|
|
// lifted in #497).
|
|
for (const row of [timedRow, untimedRow, otherRow]) {
|
|
const aria = await row.getAttribute("aria-disabled");
|
|
expect(aria === null || aria === "false").toBe(true);
|
|
expect(await row.getAttribute("data-drag-blocked")).toBeNull();
|
|
}
|
|
for (const id of [timedId, untimedId, otherId]) {
|
|
await expect(
|
|
page.getByTestId(`em-row-drag-blocked-info-${id}`),
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
// Drag the timed row down past the untimed row. The rotate-content
|
|
// POST must fire and succeed, proving the server no longer rejects
|
|
// payloads that include a NULL-time row.
|
|
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: 20_000 },
|
|
);
|
|
// Display order is chronological with NULL start times last (the
|
|
// server's renumber + the page sort both honour NULLS LAST), so
|
|
// the on-screen rows are: timedRow (09:00), otherRow (11:00),
|
|
// untimedRow (null). Drag the BOTTOM row (untimed) UP to the top
|
|
// slot. Whether dnd-kit lands the drop at slot 1 or slot 2, the
|
|
// untimed row's null tuple is GUARANTEED to leave its original
|
|
// physical slot — which is exactly what we need to prove.
|
|
const numCell = page
|
|
.locator(`[data-testid="em-row-${untimedId}"] td`)
|
|
.first();
|
|
await numCell.scrollIntoViewIfNeeded();
|
|
const numBox = await numCell.boundingBox();
|
|
const topBox = await timedRow.boundingBox();
|
|
if (!numBox || !topBox) throw new Error("rows must be visible");
|
|
const startX = numBox.x + numBox.width / 2;
|
|
const startY = numBox.y + numBox.height / 2;
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(startX, startY - 12, { steps: 5 });
|
|
await page.mouse.move(startX, topBox.y + 4, { steps: 15 });
|
|
await page.mouse.up();
|
|
await rotatePromise;
|
|
|
|
// Per-meeting assertions (not a multiset invariant). The untimed
|
|
// row was originally the only one with a null tuple; after the
|
|
// rotation it MUST have a real time tuple (whichever slot it
|
|
// landed in), proving the null tuple moved to a different physical
|
|
// row. Exactly one of the other two rows now carries the null
|
|
// tuple — that row's daily_number must sort to the end (the
|
|
// server's renumber uses NULLS LAST).
|
|
await expect
|
|
.poll(async () => (await readMeeting(untimedId)).start_time, {
|
|
timeout: 10_000,
|
|
})
|
|
.not.toBeNull();
|
|
const timedAfter = await readMeeting(timedId);
|
|
const untimedAfter = await readMeeting(untimedId);
|
|
const otherAfter = await readMeeting(otherId);
|
|
// Untimed row gained a real time tuple (the null moved off it).
|
|
expect(untimedAfter.start_time).not.toBeNull();
|
|
expect(untimedAfter.end_time).not.toBeNull();
|
|
// Exactly one of the OTHER two rows now carries the null tuple.
|
|
const allAfter = [timedAfter, otherAfter];
|
|
const nullCarriers = allAfter.filter(
|
|
(m) => m.start_time === null && m.end_time === null,
|
|
);
|
|
expect(nullCarriers).toHaveLength(1);
|
|
// The two real time tuples (09:00 and 11:00) are still both
|
|
// present somewhere in the day — slots stay anchored to physical
|
|
// rows even as content rotates between them.
|
|
const allRows = [timedAfter, untimedAfter, otherAfter];
|
|
const realStarts = allRows
|
|
.map((m) => m.start_time)
|
|
.filter((t) => t !== null)
|
|
.sort();
|
|
expect(realStarts).toEqual(["09:00:00", "11:00:00"]);
|
|
// Sanity: each row still has a daily_number > 0 and the null-tuple
|
|
// carrier sorted to the end (renumber honours NULLS LAST).
|
|
for (const m of allRows) {
|
|
expect(typeof m.daily_number).toBe("number");
|
|
expect(m.daily_number).toBeGreaterThan(0);
|
|
}
|
|
const nullCarrier = nullCarriers[0];
|
|
for (const m of allRows) {
|
|
if (m === nullCarrier) continue;
|
|
expect(nullCarrier.daily_number).toBeGreaterThan(m.daily_number);
|
|
}
|
|
});
|