// Verifies the consolidated row-actions kebab menu introduced as part // of — the three previously-separate row overlays (delete, // row color, merge cells) are now collapsed into a single menu so they // no longer collide visually inside narrow cells. Each sub-action // keeps its original testid, so the rest of the suite + any external // tooling that targeted them keeps working without changes. import { test, expect } from "@playwright/test"; 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 ensureEditModeOn(page) { const toggle = page.getByTestId("em-edit-mode-toggle"); await expect(toggle).toBeVisible(); if ((await toggle.getAttribute("aria-pressed")) === "false") { await toggle.click(); } await expect(toggle).toHaveAttribute("aria-pressed", "true"); } test("Schedule: row actions menu exposes delete + color + merge in a single popover and supports back navigation", async ({ page, }) => { await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "en"); } catch { /* localStorage may not be ready before navigation */ } }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); await ensureEditModeOn(page); // Exactly one consolidated kebab trigger per row, replacing what used // to be three separate overlay icons (Trash, Palette, Combine). // We require at least one row to be present — `>= 1` keeps the assertion // resilient to the dev DB seed varying across runs while still confirming // the kebab is mounted at all. const kebabs = page.locator('[data-testid^="em-row-actions-"]'); const kebabCount = await kebabs.count(); expect(kebabCount).toBeGreaterThanOrEqual(1); // Should be exactly one kebab per visible meeting row — rule out // duplicate triggers leaking across multiple cells of the same row. const visibleRows = await page .locator('tr[data-testid^="em-row-"]') .count(); if (visibleRows > 0) { expect(kebabCount).toBe(visibleRows); } const kebab = kebabs.first(); // Closed state: none of the legacy testids are mounted yet. await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0); await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0); await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount( 0, ); // The kebab is opacity-0 until row hover, so force the click. await kebab.click({ force: true }); // Main menu shows all three legacy testids — preserves selectors used // elsewhere in the suite. await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(1); await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(1); await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount( 1, ); // Drill into the Row color sub-view: Delete + Merge get hidden, // a Back affordance + the swatch row appear. await page.getByTestId("em-row-color-trigger").click(); await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0); await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount( 0, ); await expect(page.getByTestId("em-row-actions-back")).toBeVisible(); // Back returns to the main 3-item view. await page.getByTestId("em-row-actions-back").click(); await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(1); await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(1); await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount( 1, ); // Drill into the Merge sub-view — three merge options appear and the // other items disappear. await page.locator('[data-testid^="em-merge-trigger-"]').click(); await expect( page.locator('[data-testid^="em-merge-opt-meeting-attendees-"]'), ).toHaveCount(1); await expect( page.locator('[data-testid^="em-merge-opt-meeting-time-"]'), ).toHaveCount(1); await expect(page.locator('[data-testid^="em-merge-opt-all-"]')).toHaveCount( 1, ); await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0); await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0); // Escape closes the popover entirely. await page.keyboard.press("Escape"); await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0); await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0); await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount( 0, ); }); // Regression guard: when a user picks a row color (e.g. red), the // chosen tint must wrap the *entire* row, including the leading "#" // (number) cell. Previously the # cell was hard-coded to bg-white and // stayed visually disconnected from the rest of the row, which broke // the meaning of the color (e.g. a "danger" red row with a white # // looked half-flagged). This test picks the red swatch via the // consolidated kebab menu, verifies the # cell adopts the same // background as the meeting cell next to it, then resets to "default" // and confirms the # cell returns to white. test("Schedule: picking a row color tints the # cell to match the rest of the row", async ({ page, }) => { await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "en"); } catch { /* ignore */ } }); await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); // Reset persisted row colors so we always start from the default // (white) state, regardless of leftover state from prior runs. await page.evaluate(() => { try { window.localStorage.removeItem("em-schedule-row-colors-v1"); } catch { /* ignore */ } }); await page.reload(); await ensureEditModeOn(page); // We need a row whose # cell starts WHITE — i.e. it's not a current // meeting (data-current-meeting="true" → highlightColor wash) and // not a cancelled / status-flagged row (bg-red-600 badge). Both of // those higher-priority states would mask the regression. // `data-current-meeting` lives on the itself, so the // `:not([data-current-meeting="true"])` CSS selector is the // reliable way to exclude current rows (Playwright's // `filter({ hasNot })` looks at *descendants*, which never matches // an attribute on the parent ). To also exclude the red-badge // rows, we then walk through the candidates and pick the first one // whose # cell's computed background is actually white. await page .locator('tr[data-testid^="em-row-"]') .first() .waitFor({ state: "visible", timeout: 10_000 }); const candidateRows = page.locator( 'tr[data-testid^="em-row-"]:not([data-current-meeting="true"])', ); const candidateCount = await candidateRows.count(); test.skip( candidateCount === 0, "No non-current rows present; cannot exercise the row-color tint regression.", ); let targetRow = null; let numberCell = null; for (let i = 0; i < candidateCount; i++) { const row = candidateRows.nth(i); const cell = row.locator("td").first(); const bg = await cell.evaluate( (el) => getComputedStyle(el).backgroundColor, ); if (/255,\s*255,\s*255/.test(bg)) { targetRow = row; numberCell = cell; break; } } test.skip( targetRow === null, "No row with a default white # cell; every visible row is current or cancelled.", ); await expect(targetRow).toBeVisible(); // Open the row's kebab → Row color → red swatch. const kebab = targetRow.locator('[data-testid^="em-row-actions-"]').first(); await kebab.click({ force: true }); await page.getByTestId("em-row-color-trigger").click(); await page.getByTestId("em-row-color-red").click(); await page.keyboard.press("Escape"); // The red swatch is #fee2e2 → rgb(254, 226, 226). The # cell should // now share that background. We poll because the row tint applies // through React state and may take a frame to settle. await expect .poll( async () => numberCell.evaluate((el) => getComputedStyle(el).backgroundColor), { timeout: 5_000 }, ) .toMatch(/254,\s*226,\s*226/); // Reset to "default" and confirm the # cell returns to white. await kebab.click({ force: true }); await page.getByTestId("em-row-color-trigger").click(); await page.getByTestId("em-row-color-default").click(); await page.keyboard.press("Escape"); await expect .poll( async () => numberCell.evaluate((el) => getComputedStyle(el).backgroundColor), { timeout: 5_000 }, ) .toMatch(/255,\s*255,\s*255/); });