Add browser test for editing attendee names with formatting (Task #186)
Original task: cover the inline attendee-name edit flow in the Executive Meetings schedule. The attendee row uses the same EditableCell + Tiptap toolbar as titles (parent task #122), but until now only the title path had end-to-end coverage. A regression in attendee-name formatting would only have surfaced via manual QA. Changes: - Extended artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs with a new scenario: * seeds a meeting + one plain-text attendee directly in the DB, * logs in as admin, navigates to the schedule, jumps to the seeded date, flips on edit mode, * clicks the inline attendee EditableCell, selects all, applies bold + the red color swatch via the toolbar, and saves via the check button while waiting for PUT /api/executive-meetings/:id/attendees to succeed, * reloads the page, re-navigates to the date, and asserts the rendered HTML in the cell still contains <strong>/<b> and the red color (#dc2626 or rgb(220,38,38)), * additionally queries executive_meeting_attendees.name in the DB to confirm the formatted HTML round-tripped through the server, not just survived in the local Tiptap document. - Added a small insertAttendee helper in the same spec to seed initial state without driving the manage-dialog flow. - The existing afterAll already deletes attendee rows for the created meetings (cascade-safe), so no cleanup changes were needed. Verification: ran the new test in isolation (passed in 9.1s) and the full schedule-features spec (5/5 passed in 40.1s). No code changes outside the test file. Follow-up filed: #270 — browser test for clearing an attendee's name to trigger the delete-row gesture (separate documented behavior path with no end-to-end coverage today).
This commit is contained in:
@@ -76,6 +76,23 @@ async function setLangEn(page) {
|
||||
});
|
||||
}
|
||||
|
||||
// Insert a single attendee row directly. Used by the attendee-name
|
||||
// editing scenario so we don't have to drive the manage-dialog flow
|
||||
// just to seed initial state.
|
||||
async function insertAttendee({
|
||||
meetingId,
|
||||
name,
|
||||
attendanceType,
|
||||
sortOrder,
|
||||
}) {
|
||||
await pool.query(
|
||||
`INSERT INTO executive_meeting_attendees
|
||||
(meeting_id, name, attendance_type, sort_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[meetingId, name, attendanceType, sortOrder],
|
||||
);
|
||||
}
|
||||
|
||||
// Insert a meeting row directly. Returns the new id.
|
||||
async function insertMeeting({
|
||||
meetingDate,
|
||||
@@ -562,3 +579,127 @@ test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no
|
||||
);
|
||||
expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test("Schedule: editing an attendee name with bold + color via the Tiptap toolbar persists after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setLangEn(page);
|
||||
|
||||
// Seed a meeting with one attendee whose name is plain text. The
|
||||
// attendee row uses the SAME EditableCell + Tiptap toolbar as titles
|
||||
// (parent task #122), so applying bold + a color swatch and saving
|
||||
// via the toolbar's check button must round-trip through the PUT
|
||||
// attendees endpoint and survive a full reload, just like titles do.
|
||||
const date = uniqueFutureDate(4);
|
||||
const uniq = `${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
const originalName = `RichAttendee Original ${uniq}`;
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Attendee Host ${uniq}`,
|
||||
titleAr: `مضيف ${uniq}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "10:00:00",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: originalName,
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
await loginViaUi(page, "admin", "admin123");
|
||||
await page.goto("/executive-meetings");
|
||||
await resetSchedulePrefs(page);
|
||||
await page.reload();
|
||||
|
||||
// Jump to the seeded date and wait for the row.
|
||||
const dateInput = page.locator('input[type="date"]').first();
|
||||
await dateInput.fill(date);
|
||||
|
||||
const row = page.getByTestId(`em-row-${meetingId}`);
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Flip on edit mode so the attendee EditableCell becomes interactive.
|
||||
const toggle = page.getByTestId("em-edit-mode-toggle");
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// The testid uses the original index in meeting.attendees (0). Scope
|
||||
// under the seeded row so the selector stays unambiguous.
|
||||
const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
|
||||
await expect(attendeeCell).toBeVisible();
|
||||
await expect(attendeeCell).toContainText(originalName);
|
||||
|
||||
// Click to enter edit mode, then select the entire name and apply
|
||||
// bold + the red color swatch. Same flow as the title test above —
|
||||
// we keep the original text so the assertion is purely about the
|
||||
// toolbar persisting *formatting*, not typing.
|
||||
await attendeeCell.click();
|
||||
|
||||
const toolbar = page.getByTestId("em-edit-toolbar");
|
||||
await expect(toolbar).toBeVisible();
|
||||
|
||||
const editorContent = attendeeCell.locator('[contenteditable="true"]');
|
||||
await expect(editorContent).toBeVisible();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.getByTestId("em-edit-bold").click();
|
||||
await page.getByTestId("em-edit-color-red").click();
|
||||
|
||||
// Watch for the PUT that saves the attendees array before clicking
|
||||
// save. Inline attendee edits go through PUT /attendees, NOT the
|
||||
// meeting-level PATCH used by titles.
|
||||
const savePromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const u = new URL(resp.url());
|
||||
return (
|
||||
u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
|
||||
resp.request().method() === "PUT" &&
|
||||
resp.ok()
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("em-edit-save").click();
|
||||
await savePromise;
|
||||
await expect(toolbar).toBeHidden();
|
||||
|
||||
// Reload to confirm the formatting wasn't just sitting in the local
|
||||
// Tiptap document — it must round-trip via the server.
|
||||
await page.reload();
|
||||
await dateInput.fill(date);
|
||||
const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
|
||||
await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const attendeeAfterReload = rowAfterReload.getByTestId(`em-edit-attendee-0`);
|
||||
await expect(attendeeAfterReload).toContainText(originalName);
|
||||
|
||||
// Inspect the rendered HTML in the cell. The server-sanitized output
|
||||
// must keep the bold tag and the inline color span. Accept either
|
||||
// <strong> (StarterKit's default tag) or <b> (some sanitizers
|
||||
// collapse to <b>) so the assertion stays robust against the
|
||||
// server's exact allowlist serialization.
|
||||
const html = await attendeeAfterReload.innerHTML();
|
||||
expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
|
||||
// The Tiptap "red" swatch is #dc2626. Browsers serialize inline
|
||||
// styles in either hex or rgb form; accept both.
|
||||
expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
||||
|
||||
// And confirm the DB itself stored the formatted HTML, not the plain
|
||||
// text — this catches any frontend-only "looks formatted" regression.
|
||||
// Attendees have a single `name` column (no AR/EN split), so the
|
||||
// assertion is simpler than the title case.
|
||||
const { rows: dbRows } = await pool.query(
|
||||
`SELECT name FROM executive_meeting_attendees
|
||||
WHERE meeting_id = $1 ORDER BY sort_order`,
|
||||
[meetingId],
|
||||
);
|
||||
expect(dbRows.length).toBe(1);
|
||||
const storedName = String(dbRows[0].name).toLowerCase();
|
||||
expect(storedName).toMatch(/<(strong|b)[\s>]/);
|
||||
expect(storedName).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
|
||||
// The original plain text is still readable inside the formatted markup.
|
||||
expect(storedName).toContain(originalName.toLowerCase());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user