Files
TX/artifacts/tx-os/tests/executive-meetings-row-actions-menu.spec.mjs
T
riyadhafraa 9a0935f58e Rename Edit toggle to "تعديل" and tint # cell with row color (Task #193)
Two small UX polish items on the executive-meetings schedule:

1. Arabic label rename
   - artifacts/tx-os/src/locales/ar.json: changed
     editToggle / editToggleAria / editToggleOn / editToggleOff
     from "تحرير" / "وضع التحرير" → "تعديل" / "وضع التعديل".
   - English strings unchanged (user only asked about Arabic
     wording). No other key/UI re-uses these strings.

2. Row color now wraps the # (number) cell
   - In MeetingRow's case "number", added an IIFE-built
     numberCellInlineStyle that applies backgroundColor following
     a strict priority chain:
       (a) highlight || isCancelled → no inline bg, so the
           bg-red-600 utility on numberCellCls keeps winning.
       (b) tintBg (current-meeting wash) → highlightColor + white
           text (unchanged behavior).
       (c) rowBg (user-picked row color) → tints the # cell to
           match the rest of the row. Palette is all light tints,
           so the existing dark text remains readable.
       (d) otherwise → falls back to bg-white from numberCellCls.

Tests
- Added a focused Playwright spec in
  tests/executive-meetings-row-actions-menu.spec.mjs that:
    * picks a non-current, non-cancelled row (CSS
      :not([data-current-meeting="true"]) + a runtime "starts
      white" precondition, so it cannot accidentally target a
      red-badge row),
    * opens the kebab → Row color → red swatch,
    * asserts the # cell's computed background is rgb(254,226,226),
    * resets to "default" and asserts it returns to white.
  test.skip is used (rather than fail) when seed data has no
  eligible row, so the regression guard never produces false
  negatives on a sparse schedule.

Verification
- All 6 specs in executive-meetings-row-actions-menu.spec.mjs +
  executive-meetings-edit-toggle.spec.mjs pass locally.
- Type-check shows no new errors in executive-meetings.tsx.

Code review (architect, evaluate_task) flagged the original test's
filter({ hasNot }) as unreliable for excluding current rows; the
test was rewritten before commit per that feedback.
2026-04-30 06:47:28 +00:00

230 lines
8.8 KiB
JavaScript

// Verifies the consolidated row-actions kebab menu introduced as part
// of task #191 — 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 <tr> 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 <tr>). 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/);
});