7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
1061 lines
37 KiB
JavaScript
1061 lines
37 KiB
JavaScript
import { test, expect } from "@playwright/test";
|
|
import pg from "pg";
|
|
|
|
// E2E coverage for Tasks #227 and #230 — "Restructure attendee cell
|
|
// controls around section headings" inside executive-meetings.
|
|
//
|
|
// What we verify:
|
|
// 1. The top "+ عنوان فرعي" chip is NEVER rendered, regardless of
|
|
// cell state (empty / person-only / subheading-only / mixed).
|
|
// The trailing "+ عنوان فرعي" button at the bottom of the cell is
|
|
// the sole "start a heading" affordance, plus the after-section
|
|
// chips between consecutive sections.
|
|
// 2. After-section "+ عنوان فرعي" chip: rendered after the last
|
|
// person of each section that is followed by ANOTHER section
|
|
// (i.e. a subheading comes next). Clicking it inserts a new
|
|
// subheading at the boundary, BEFORE the existing next-section
|
|
// subheading. Also rendered after a subheading whose next item is
|
|
// another subheading (empty-section boundary).
|
|
// 3. Drag-and-drop reordering inside the manage dialog: picking up a
|
|
// row by its grip handle and moving it down with the keyboard
|
|
// reorders the attendee array, and saving persists the new order.
|
|
// All scenarios run in BOTH locales so the RTL/LTR mirror behaves the
|
|
// same way.
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error(
|
|
"DATABASE_URL must be set to run the executive-meetings insert/reorder UI tests",
|
|
);
|
|
}
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
const createdMeetingIds = [];
|
|
|
|
// Pick a far-future, randomised base so we never collide with other
|
|
// specs that also seed in the future.
|
|
const RANDOM_DATE_BASE_DAYS =
|
|
2200 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
|
|
function uniqueFutureDate(offsetDays) {
|
|
const d = new Date();
|
|
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
async function insertMeeting({
|
|
meetingDate,
|
|
dailyNumber,
|
|
titleEn,
|
|
titleAr,
|
|
startTime,
|
|
endTime,
|
|
}) {
|
|
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, $3, $4, $5, $6, 'scheduled')
|
|
RETURNING id`,
|
|
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
|
|
);
|
|
const id = rows[0].id;
|
|
createdMeetingIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function insertAttendee({
|
|
meetingId,
|
|
name,
|
|
attendanceType,
|
|
sortOrder,
|
|
kind,
|
|
}) {
|
|
await pool.query(
|
|
`INSERT INTO executive_meeting_attendees
|
|
(meeting_id, name, attendance_type, sort_order, kind)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
|
|
);
|
|
}
|
|
|
|
async function getAttendeesOrdered(meetingId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT name, attendance_type, sort_order, kind
|
|
FROM executive_meeting_attendees
|
|
WHERE meeting_id = $1
|
|
ORDER BY sort_order ASC`,
|
|
[meetingId],
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async function loginViaUi(page) {
|
|
await page.goto("/login");
|
|
await page.locator("#username").fill("admin");
|
|
await page.locator("#password").fill("admin123");
|
|
await Promise.all([
|
|
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
|
timeout: 15_000,
|
|
}),
|
|
page.locator('form button[type="submit"]').click(),
|
|
]);
|
|
}
|
|
|
|
let originalAdminPreferredLanguage = null;
|
|
async function setAdminPreferredLanguage(lang) {
|
|
const { rows } = await pool.query(
|
|
`UPDATE users SET preferred_language = $1
|
|
WHERE username = 'admin'
|
|
RETURNING preferred_language`,
|
|
[lang],
|
|
);
|
|
return rows[0]?.preferred_language;
|
|
}
|
|
|
|
// Toggle the global edit-mode toggle so the after-section
|
|
// "+ عنوان فرعي" chips and inline editing affordances render. The
|
|
// toggle lives in the schedule toolbar with
|
|
// data-testid="em-edit-mode-toggle".
|
|
async function ensureEditMode(page) {
|
|
const toggle = page.getByTestId("em-edit-mode-toggle");
|
|
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
|
const pressed = await toggle.getAttribute("aria-pressed");
|
|
if (pressed !== "true") {
|
|
await toggle.click();
|
|
// Wait for the aria-pressed state to actually flip — the localStorage
|
|
// write + state setter is async-ish in React, and clicking the chip
|
|
// before edit mode commits would silently miss the conditional render.
|
|
await expect(toggle).toHaveAttribute("aria-pressed", "true", {
|
|
timeout: 5_000,
|
|
});
|
|
}
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
const { rows } = await pool.query(
|
|
`SELECT preferred_language FROM users WHERE username = 'admin'`,
|
|
);
|
|
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (originalAdminPreferredLanguage) {
|
|
await pool
|
|
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
|
|
originalAdminPreferredLanguage,
|
|
])
|
|
.catch(() => {
|
|
/* best-effort */
|
|
});
|
|
}
|
|
if (createdMeetingIds.length > 0) {
|
|
await pool.query(
|
|
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
|
[createdMeetingIds],
|
|
);
|
|
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_meetings WHERE id = ANY($1::int[])`,
|
|
[createdMeetingIds],
|
|
);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
const langOffsets = { en: 1, ar: 2 };
|
|
const langOffsets2 = { en: 3, ar: 4 };
|
|
const langOffsets3 = { en: 5, ar: 6 };
|
|
const langOffsets4 = { en: 7, ar: 8 };
|
|
const langOffsets5 = { en: 9, ar: 10 };
|
|
|
|
for (const lang of ["en", "ar"]) {
|
|
test(`Schedule [${lang}]: top "+ subheading" chip is NEVER rendered (empty / person-only / subheading-only / mixed)`, async ({
|
|
page,
|
|
}) => {
|
|
// the top chip is gone in every state. We seed two
|
|
// meetings that, between them, exercise all four cell states:
|
|
// meetingEmptyId: a brand-new meeting with zero attendees ⇒
|
|
// the cell is EMPTY.
|
|
// meetingMixedId: three sections covering person-only,
|
|
// subheading-only, and mixed (subheading +
|
|
// persons) by spreading attendees across the
|
|
// three attendance-type groups (internal,
|
|
// virtual, external) which each render their own
|
|
// AttendeeFlow.
|
|
// For each addType we assert the top chip testid is absent. The
|
|
// trailing "+ subheading" button stays as the cell-level fallback.
|
|
const dateEmpty = uniqueFutureDate(langOffsets[lang]);
|
|
const meetingEmptyId = await insertMeeting({
|
|
meetingDate: dateEmpty,
|
|
dailyNumber: 1,
|
|
titleEn: `No Top Empty ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `بدون علوي فارغ ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
|
|
const dateMixed = uniqueFutureDate(langOffsets[lang] + 50);
|
|
const meetingMixedId = await insertMeeting({
|
|
meetingDate: dateMixed,
|
|
dailyNumber: 2,
|
|
titleEn: `No Top Mixed ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `بدون علوي مختلط ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
// internal: person-only (two persons, zero subheadings).
|
|
await insertAttendee({
|
|
meetingId: meetingMixedId,
|
|
name: "InternalPerson1",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId: meetingMixedId,
|
|
name: "InternalPerson2",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
// virtual: subheading-only (one subheading, zero persons).
|
|
await insertAttendee({
|
|
meetingId: meetingMixedId,
|
|
name: "VirtualSoloHeader",
|
|
attendanceType: "virtual",
|
|
sortOrder: 2,
|
|
kind: "subheading",
|
|
});
|
|
// external: mixed (subheading + person).
|
|
await insertAttendee({
|
|
meetingId: meetingMixedId,
|
|
name: "ExternalHeader",
|
|
attendanceType: "external",
|
|
sortOrder: 3,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId: meetingMixedId,
|
|
name: "ExternalPerson",
|
|
attendanceType: "external",
|
|
sortOrder: 4,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
|
|
// EMPTY cell ⇒ default single addType group "internal" renders
|
|
// with no items.
|
|
await page.locator('input[type="date"]').first().fill(dateEmpty);
|
|
const emptyRow = page.getByTestId(`em-row-${meetingEmptyId}`);
|
|
await expect(emptyRow).toBeVisible({ timeout: 15_000 });
|
|
await ensureEditMode(page);
|
|
await expect(
|
|
emptyRow.locator('[data-testid^="em-add-subheading-top-"]'),
|
|
).toHaveCount(0);
|
|
// The trailing "+ subheading" button is still present so the
|
|
// user can begin sectioning the cell.
|
|
await expect(
|
|
emptyRow.getByTestId("em-add-subheading-internal"),
|
|
).toBeVisible();
|
|
|
|
// MIXED-meeting cell ⇒ three AttendeeFlow groups (internal /
|
|
// virtual / external) each in a different state. The top chip
|
|
// must be absent in every group. We prove all three groups
|
|
// mounted by checking each one's per-group trailing "+" person
|
|
// button is present (testid em-add-attendee-{addType}). NOTE:
|
|
// since, the trailing "+ subheading" chip is suppressed
|
|
// per group in split-mode cells and replaced by ONE cell-level
|
|
// chip (testid em-add-subheading-cell-{meetingId}) — so the
|
|
// per-group em-add-subheading-{addType} testids must NOT exist
|
|
// here, and the cell-level chip MUST be visible.
|
|
await page.locator('input[type="date"]').first().fill(dateMixed);
|
|
const mixedRow = page.getByTestId(`em-row-${meetingMixedId}`);
|
|
await expect(mixedRow).toBeVisible({ timeout: 15_000 });
|
|
// Per-group "+" person buttons prove all three groups mounted.
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-attendee-internal"),
|
|
).toBeVisible();
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-attendee-virtual"),
|
|
).toBeVisible();
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-attendee-external"),
|
|
).toBeVisible();
|
|
// Per-group trailing "+ subheading" chips are SUPPRESSED in
|
|
// split mode. The cell-level chip is the sole subheading
|
|
// affordance below all three groups.
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-subheading-internal"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-subheading-virtual"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
mixedRow.getByTestId("em-add-subheading-external"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
mixedRow.getByTestId(`em-add-subheading-cell-${meetingMixedId}`),
|
|
).toBeVisible();
|
|
// Top chip absent in every group.
|
|
await expect(
|
|
mixedRow.locator('[data-testid^="em-add-subheading-top-"]'),
|
|
).toHaveCount(0);
|
|
// Sanity counts (defence-in-depth so the assertion above isn't
|
|
// trivially passing because the groups silently rendered empty).
|
|
await expect(
|
|
mixedRow.locator('[data-testid^="em-attendee-row-"]'),
|
|
).toHaveCount(3);
|
|
await expect(
|
|
mixedRow.locator('[data-testid^="em-attendee-subheading-"]'),
|
|
).toHaveCount(2);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: after-section "+ subheading" chip inserts a subheading between two existing sections`, async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(langOffsets[lang] + 100);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 2,
|
|
titleEn: `Mid Subheading ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `عنوان فرعي وسطي ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
// Seed: [Sec1Header, Alpha, Beta, Sec2Header, Gamma]. The
|
|
// after-section chip is rendered after the last person of each
|
|
// section that has another section right after it. Beta sits at
|
|
// attendees idx 2 with Sec2Header at idx 3 → chip testid is
|
|
// `em-add-subheading-after-2`. The trailing section (Gamma) has
|
|
// nothing after it → no after-section chip there.
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Sec1Header",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonAlpha",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonBeta",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Sec2Header",
|
|
attendanceType: "internal",
|
|
sortOrder: 3,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonGamma",
|
|
attendanceType: "internal",
|
|
sortOrder: 4,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await ensureEditMode(page);
|
|
|
|
const chip = row.getByTestId("em-add-subheading-after-2");
|
|
await expect(chip).toBeVisible({ timeout: 10_000 });
|
|
// The trailing section's last person (Gamma at idx 4) must NOT
|
|
// have an after-section chip — the trailing "+ subheading"
|
|
// button below already covers that slot.
|
|
await expect(row.getByTestId("em-add-subheading-after-4")).toHaveCount(0);
|
|
await chip.click();
|
|
|
|
const pending = row.getByTestId("em-add-subheading-pending-internal");
|
|
await expect(pending).toBeVisible({ timeout: 5_000 });
|
|
const editor = pending.locator('[contenteditable="true"]').first();
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
await editor.click();
|
|
await editor.fill("MidSection");
|
|
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
|
|
|
await expect(
|
|
row.locator('[data-testid^="em-attendee-row-"]'),
|
|
).toHaveCount(3, { timeout: 10_000 });
|
|
await expect(
|
|
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
|
).toHaveCount(3);
|
|
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
|
const orderedNames = persisted.map((r) => stripTags(r.name));
|
|
// The new subheading must land BEFORE Sec2Header (the existing
|
|
// next-section subheading), at slot 3.
|
|
expect(orderedNames).toEqual([
|
|
"Sec1Header",
|
|
"PersonAlpha",
|
|
"PersonBeta",
|
|
"MidSection",
|
|
"Sec2Header",
|
|
"PersonGamma",
|
|
]);
|
|
expect(persisted.map((r) => r.kind)).toEqual([
|
|
"subheading",
|
|
"person",
|
|
"person",
|
|
"subheading",
|
|
"subheading",
|
|
"person",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: after-section "+ subheading" chip renders after an EMPTY section (consecutive subheadings)`, async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(langOffsets[lang] + 200);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 4,
|
|
titleEn: `Empty Section ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `قسم فارغ ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "09:00:00",
|
|
endTime: "10:00:00",
|
|
});
|
|
// Seed: [SubA, SubB, PersonZ]. SubA leads an EMPTY section (next
|
|
// item is also a subheading). The user must be able to insert a
|
|
// new subheading at the boundary between SubA and SubB — the chip
|
|
// is rendered AFTER SubA (testid em-add-subheading-after-0,
|
|
// insertAtIndex=1).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "SubA",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "SubB",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonZ",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await ensureEditMode(page);
|
|
|
|
// The empty-section chip lives right after SubA (idx 0), since
|
|
// SubB at idx 1 is also a subheading.
|
|
const chip = row.getByTestId("em-add-subheading-after-0");
|
|
await expect(chip).toBeVisible({ timeout: 10_000 });
|
|
await chip.click();
|
|
|
|
const pending = row.getByTestId("em-add-subheading-pending-internal");
|
|
await expect(pending).toBeVisible({ timeout: 5_000 });
|
|
const editor = pending.locator('[contenteditable="true"]').first();
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
await editor.click();
|
|
await editor.fill("InsertedMid");
|
|
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
|
|
|
await expect(
|
|
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
|
).toHaveCount(3, { timeout: 10_000 });
|
|
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
|
const orderedNames = persisted.map((r) => stripTags(r.name));
|
|
// The new subheading must land at idx 1 (between SubA and SubB).
|
|
expect(orderedNames).toEqual([
|
|
"SubA",
|
|
"InsertedMid",
|
|
"SubB",
|
|
"PersonZ",
|
|
]);
|
|
expect(persisted.map((r) => r.kind)).toEqual([
|
|
"subheading",
|
|
"subheading",
|
|
"subheading",
|
|
"person",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Manage [${lang}]: drag handle reorders attendees inside the dialog`, async ({
|
|
page,
|
|
}) => {
|
|
const date = uniqueFutureDate(langOffsets2[lang]);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Drag Reorder ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `سحب وإعادة ترتيب ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "11:00:00",
|
|
endTime: "12:00:00",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Drag-A",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Drag-B",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "Drag-C",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
|
|
// Switch to the Manage section via the page-level nav. The
|
|
// manage dialog is only mounted from inside the ManageSection.
|
|
const manageNav = page.getByTestId("em-nav-manage");
|
|
await expect(manageNav).toBeVisible({ timeout: 10_000 });
|
|
await manageNav.click();
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
// Open the edit dialog for our meeting (the pencil button on the
|
|
// row uses the meeting id as its testid suffix).
|
|
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
|
|
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
|
await editBtn.click();
|
|
|
|
// The dialog renders three sortable rows — verify drag handles
|
|
// are present and aria-labelled.
|
|
const handle0 = page.getByTestId("em-attendee-drag-0");
|
|
await expect(handle0).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Use dnd-kit's KeyboardSensor flow to move row 0 down two
|
|
// slots. This is more reliable across Playwright browser engines
|
|
// than synthesised pointer drags. dnd-kit picks up Space and
|
|
// moves with arrow keys, then drops with Space again.
|
|
await handle0.focus();
|
|
await page.keyboard.press("Space");
|
|
await page.keyboard.press("ArrowDown");
|
|
await page.keyboard.press("ArrowDown");
|
|
await page.keyboard.press("Space");
|
|
|
|
// Verify the visible name inputs reordered to [B, C, A] before we
|
|
// even save — this catches DnD wiring without relying on a save
|
|
// round-trip.
|
|
const nameInputs = page.locator(
|
|
'[data-testid^="em-attendee-row-"] input[placeholder]',
|
|
);
|
|
// Filter to the first input of each row (the name field).
|
|
const firstNames = await page
|
|
.locator('[data-testid^="em-attendee-row-"]')
|
|
.evaluateAll((rows) =>
|
|
rows.map((r) => {
|
|
const inp = r.querySelector("input");
|
|
return inp ? inp.value : "";
|
|
}),
|
|
);
|
|
expect(firstNames).toEqual(["Drag-B", "Drag-C", "Drag-A"]);
|
|
void nameInputs;
|
|
|
|
// Save and verify persistence.
|
|
const saveBtn = page.getByTestId("em-form-save");
|
|
await saveBtn.click();
|
|
// Wait for the dialog to close.
|
|
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
|
|
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
expect(persisted.map((r) => r.name)).toEqual([
|
|
"Drag-B",
|
|
"Drag-C",
|
|
"Drag-A",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Manage [${lang}]: drag-reorder works across mixed person + subheading rows`, async ({
|
|
page,
|
|
}) => {
|
|
// Architect-flagged coverage: a meeting that contains BOTH person
|
|
// rows and a subheading row, then dragged so the subheading lands
|
|
// in a new section position. We verify (a) the kind survives the
|
|
// round-trip, (b) the new order persists, (c) numbering on the
|
|
// schedule view recomputes correctly after the kinds shift.
|
|
const date = uniqueFutureDate(langOffsets3[lang]);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 1,
|
|
titleEn: `Mixed Drag ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `سحب مختلط ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "13:00:00",
|
|
endTime: "14:00:00",
|
|
});
|
|
// Seed: [PersonM, SubX, PersonN, PersonO]. We will pick up
|
|
// SubX (index 1) and move it down past PersonN (one ArrowDown).
|
|
// Expected after drag: [PersonM, PersonN, SubX, PersonO].
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonM",
|
|
attendanceType: "internal",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "MixSubX-K3",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "subheading",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonN",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "PersonO",
|
|
attendanceType: "internal",
|
|
sortOrder: 3,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
|
|
const manageNav = page.getByTestId("em-nav-manage");
|
|
await expect(manageNav).toBeVisible({ timeout: 10_000 });
|
|
await manageNav.click();
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
|
|
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
|
await editBtn.click();
|
|
|
|
// Pick up the subheading row (index 1) and move it down once.
|
|
const handle1 = page.getByTestId("em-attendee-drag-1");
|
|
await expect(handle1).toBeVisible({ timeout: 5_000 });
|
|
await handle1.focus();
|
|
await page.keyboard.press("Space");
|
|
await page.keyboard.press("ArrowDown");
|
|
await page.keyboard.press("Space");
|
|
|
|
// Pre-save check: the row at index 1 is now PersonN (was the
|
|
// subheading), and the row at index 2 is the subheading.
|
|
const kinds = await page
|
|
.locator('[data-testid^="em-attendee-row-"]')
|
|
.evaluateAll((rows) =>
|
|
rows.map((r) => r.getAttribute("data-attendee-kind")),
|
|
);
|
|
expect(kinds).toEqual(["person", "person", "subheading", "person"]);
|
|
|
|
const saveBtn = page.getByTestId("em-form-save");
|
|
await saveBtn.click();
|
|
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
|
|
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
expect(persisted.map((r) => r.name)).toEqual([
|
|
"PersonM",
|
|
"PersonN",
|
|
"MixSubX-K3",
|
|
"PersonO",
|
|
]);
|
|
expect(persisted.map((r) => r.kind)).toEqual([
|
|
"person",
|
|
"person",
|
|
"subheading",
|
|
"person",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: split-mode cell shows ONE cell-level "+ subheading" chip and routes to the LAST visible group`, async ({
|
|
page,
|
|
}) => {
|
|
// in a split-mode cell (≥2 attendance groups visible),
|
|
// the trailing "+ عنوان فرعي" chip must NOT be rendered per group;
|
|
// a single cell-level chip below all groups is rendered instead.
|
|
// Clicking it adds the new subheading to the LAST visible group in
|
|
// render order (virtual → internal → external). Per-group "+"
|
|
// person buttons are unaffected.
|
|
//
|
|
// hasSplit requires `virtual.length > 0 && (internal || external)`,
|
|
// so we seed all three groups. Render order makes EXTERNAL the
|
|
// last visible group, and the new subheading must persist at the
|
|
// end of the external group's rows.
|
|
const date = uniqueFutureDate(langOffsets4[lang]);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 7,
|
|
titleEn: `Cell-Level Subheading ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `عنوان فرعي للخلية ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "14:00:00",
|
|
endTime: "15:00:00",
|
|
});
|
|
// Virtual: 1 person (just to satisfy hasSplit — render order
|
|
// virtual → internal → external means virtual is first).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "VirtualP1",
|
|
attendanceType: "virtual",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
// Internal: 2 persons (mirrors the "الحضور الداخلي" block from
|
|
// the user's screenshot).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "InternalP1",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "InternalP2",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
// External: 2 persons (mirrors the "الخارجي" block — the LAST
|
|
// visible group, so the cell-level chip routes here).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "ExternalP1",
|
|
attendanceType: "external",
|
|
sortOrder: 3,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "ExternalP2",
|
|
attendanceType: "external",
|
|
sortOrder: 4,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await ensureEditMode(page);
|
|
|
|
// Exactly ONE cell-level chip is rendered.
|
|
const cellChip = row.getByTestId(
|
|
`em-add-subheading-cell-${meetingId}`,
|
|
);
|
|
await expect(cellChip).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
row.locator('[data-testid^="em-add-subheading-cell-"]'),
|
|
).toHaveCount(1);
|
|
|
|
// Per-group trailing "+ subheading" chips are SUPPRESSED in all
|
|
// three groups.
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-virtual"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-internal"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-external"),
|
|
).toHaveCount(0);
|
|
|
|
// Per-group "+" person buttons remain unchanged — one per group.
|
|
await expect(row.getByTestId("em-add-attendee-virtual")).toBeVisible();
|
|
await expect(row.getByTestId("em-add-attendee-internal")).toBeVisible();
|
|
await expect(row.getByTestId("em-add-attendee-external")).toBeVisible();
|
|
|
|
// Click the cell-level chip → pending input opens. Because the
|
|
// last visible group is external, the pending ghost lives in
|
|
// the external group's flow (testid uses the addType suffix).
|
|
await cellChip.click();
|
|
const pending = row.getByTestId(
|
|
"em-add-subheading-pending-external",
|
|
);
|
|
await expect(pending).toBeVisible({ timeout: 5_000 });
|
|
const editor = pending.locator('[contenteditable="true"]').first();
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
await editor.click();
|
|
await editor.fill("CellChipSection");
|
|
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
|
|
|
// Persisted: the new subheading lands at the end of the external
|
|
// group (sort_order 5, after the two external persons).
|
|
await expect(
|
|
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
|
).toHaveCount(1, { timeout: 10_000 });
|
|
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
|
expect(persisted.map((r) => stripTags(r.name))).toEqual([
|
|
"VirtualP1",
|
|
"InternalP1",
|
|
"InternalP2",
|
|
"ExternalP1",
|
|
"ExternalP2",
|
|
"CellChipSection",
|
|
]);
|
|
expect(persisted.map((r) => r.kind)).toEqual([
|
|
"person",
|
|
"person",
|
|
"person",
|
|
"person",
|
|
"person",
|
|
"subheading",
|
|
]);
|
|
expect(persisted.map((r) => r.attendance_type)).toEqual([
|
|
"virtual",
|
|
"internal",
|
|
"internal",
|
|
"external",
|
|
"external",
|
|
"external",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test(`Schedule [${lang}]: split-mode WITHOUT external — cell-level "+ subheading" chip routes to INTERNAL (last visible)`, async ({
|
|
page,
|
|
}) => {
|
|
// fallback path. The "last visible group" rule resolves
|
|
// the cell-level chip's target at click-time as
|
|
// external > internal > virtual. The previous test exercises the
|
|
// external-wins path; this one locks the internal-wins fallback by
|
|
// seeding ONLY virtual + internal (no external). The cell-level
|
|
// chip must still be the sole subheading affordance, and clicking
|
|
// it must persist a new subheading at the end of the INTERNAL
|
|
// group (not virtual, even though virtual is the "last seeded"
|
|
// group in sort_order).
|
|
const date = uniqueFutureDate(langOffsets5[lang]);
|
|
const meetingId = await insertMeeting({
|
|
meetingDate: date,
|
|
dailyNumber: 9,
|
|
titleEn: `Cell Chip No External ${lang} ${Date.now().toString(36)}`,
|
|
titleAr: `لا خارجي ${lang} ${Date.now().toString(36)}`,
|
|
startTime: "16:00:00",
|
|
endTime: "17:00:00",
|
|
});
|
|
// Virtual: 1 person (satisfies hasSplit).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "VirtP1",
|
|
attendanceType: "virtual",
|
|
sortOrder: 0,
|
|
kind: "person",
|
|
});
|
|
// Internal: 2 persons (this is the LAST visible group in render
|
|
// order virtual → internal → external when external is empty).
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "IntP1",
|
|
attendanceType: "internal",
|
|
sortOrder: 1,
|
|
kind: "person",
|
|
});
|
|
await insertAttendee({
|
|
meetingId,
|
|
name: "IntP2",
|
|
attendanceType: "internal",
|
|
sortOrder: 2,
|
|
kind: "person",
|
|
});
|
|
|
|
await setAdminPreferredLanguage(lang);
|
|
try {
|
|
await loginViaUi(page);
|
|
await page.goto("/executive-meetings");
|
|
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
|
timeout: 5_000,
|
|
});
|
|
await page.locator('input[type="date"]').first().fill(date);
|
|
|
|
const row = page.getByTestId(`em-row-${meetingId}`);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await ensureEditMode(page);
|
|
|
|
// Exactly one cell-level chip; no per-group subheading chips.
|
|
const cellChip = row.getByTestId(
|
|
`em-add-subheading-cell-${meetingId}`,
|
|
);
|
|
await expect(cellChip).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-virtual"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-internal"),
|
|
).toHaveCount(0);
|
|
// External didn't even render a group, so its subheading testid
|
|
// is also absent (defence-in-depth).
|
|
await expect(
|
|
row.getByTestId("em-add-subheading-external"),
|
|
).toHaveCount(0);
|
|
|
|
// Click → pending input opens in the INTERNAL group's flow.
|
|
await cellChip.click();
|
|
const pending = row.getByTestId(
|
|
"em-add-subheading-pending-internal",
|
|
);
|
|
await expect(pending).toBeVisible({ timeout: 5_000 });
|
|
const editor = pending.locator('[contenteditable="true"]').first();
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
await editor.click();
|
|
await editor.fill("InternalTailSection");
|
|
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
|
|
|
await expect(
|
|
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
|
).toHaveCount(1, { timeout: 10_000 });
|
|
|
|
// Persisted: the new subheading lands at the end of the
|
|
// internal group (sort_order 3, after the two internal persons).
|
|
const persisted = await getAttendeesOrdered(meetingId);
|
|
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
|
expect(persisted.map((r) => stripTags(r.name))).toEqual([
|
|
"VirtP1",
|
|
"IntP1",
|
|
"IntP2",
|
|
"InternalTailSection",
|
|
]);
|
|
expect(persisted.map((r) => r.kind)).toEqual([
|
|
"person",
|
|
"person",
|
|
"person",
|
|
"subheading",
|
|
]);
|
|
expect(persisted.map((r) => r.attendance_type)).toEqual([
|
|
"virtual",
|
|
"internal",
|
|
"internal",
|
|
"internal",
|
|
]);
|
|
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
|
|
} finally {
|
|
if (originalAdminPreferredLanguage) {
|
|
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
|
() => {
|
|
/* best-effort; afterAll also restores */
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|