Files
TX/artifacts/tx-os/tests/executive-meetings-row-actions-previews.spec.mjs
T
riyadhafraa b54761b166 Task #192: Show row color + merge range previews in row-actions kebab menu
Original task: The Executive Meetings row-actions kebab menu has three sub-views
(delete, color, merge). Users couldn't tell at a glance which colour or merge
range a row already had — they had to drill into the sub-view first. Add subtle
inline indicators to the main menu so the current state is visible without
extra clicks.

Implementation:
- artifacts/tx-os/src/pages/executive-meetings.tsx
  • RowActionsMenu now renders an inline circular swatch next to the "Row
    color" item, reflecting the row's saved colour. "default" shows a hatched
    pattern matching the swatch picker; other keys show the saved tint.
    Decorative (aria-hidden) since the swatch sub-view is the actual control.
    Carries data-testid="em-row-color-indicator-{id}" plus a data-color-key
    attribute for stable, language-agnostic tests.
  • Renders a "Merged: <cols>" badge next to the "Merge cells" item only when
    the row has a stored merge. Column names come from the canonical merge
    order (number, meeting, attendees, time) and are localized via the
    existing executiveMeetings.col.{id} keys.
  • New mergeColumnLabels prop is computed in MeetingRow against
    CANONICAL_MERGE_ORDER (not visibleColumns) so the badge still describes
    the full saved range when a boundary column is hidden. Threaded into
    both call sites — the first-visible-cell anchor and the merged-cell
    anchor.
  • Widened the local t prop type to (k, opts?) so the badge can use the
    {{cols}} interpolation template.

- artifacts/tx-os/src/locales/en.json + ar.json
  • Added executiveMeetings.merge.activeBadge ("Merged: {{cols}}" /
    "مدموج: {{cols}}").

Tests:
- Added artifacts/tx-os/tests/executive-meetings-row-actions-previews.spec.mjs
  with two specs:
    1. Row-color indicator updates from "default" → "red" → "default" via the
       sub-view picker, and the dot's computed background tracks the saved
       colour (#fee2e2 → rgb(254,226,226)).
    2. Plain rows render no merge badge; merged rows render the badge with
       text "<MeetingColLabel> + <AttendeesColLabel>". Column labels are
       resolved from the live header DOM so the assertion works in both
       English and Arabic (admin's preferredLanguage overrides the
       tx-lang=en init seed after login).
  Both pass; existing executive-meetings-row-actions-menu.spec.mjs and
  executive-meetings-merge.spec.mjs continue to pass (5/5 regression).

Notable subtleties:
- A first attempt at the merge spec hit a flake where Escape-then-force-click
  on the next row's kebab landed on the previous popover's Delete item
  (which was still mounted), triggering a stray confirm dialog. Added an
  explicit waitFor on the previous popover's unmount before opening the
  next one.

Follow-up proposed: #276 (next_steps) — show the same cues directly on the
row itself, not only inside the kebab popover.

Replit-Task-Id: 7ff92dcd-f4bd-421d-93c8-4d7d3dbe0077
2026-05-01 13:44:21 +00:00

311 lines
10 KiB
JavaScript

// Coverage for task #192 — quick-preview indicators on the row-actions
// kebab menu's main view:
//
// 1. The "Row color" item shows a tiny swatch reflecting the row's
// currently saved color (or a hatched neutral dot for "default"),
// so users can tell at a glance which color is set without
// drilling into the swatch sub-view.
//
// 2. The "Merge cells" item shows a "Merged: <cols>" badge when the
// row has a stored merge, naming the spanned columns. Hidden
// entirely on rows with no stored merge.
//
// The badge text + dot color must update whenever the underlying state
// changes. To keep selectors stable, the indicators ride along inside
// the existing `em-row-color-trigger` / `em-merge-trigger-{id}` buttons
// rather than introducing new top-level menu items.
//
// Setup follows the same pattern as the merge spec: insert seeded
// meetings on a far-future date directly via pg, drive the UI through
// the schedule's date picker, then clean up via afterAll.
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 row-actions previews UI tests",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
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 setLangEn(page) {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* ignore */
}
});
}
async function resetSchedulePrefs(page) {
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (
k.startsWith("em-schedule-edit-mode-v1") ||
k === "em-schedule-cols-v1" ||
k === "em-schedule-row-colors-v1" ||
k === "em-current-meeting-highlight-v1"
) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
}
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");
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
mergeStartColumn = null,
mergeEndColumn = null,
mergeText = null,
}) {
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time,
status, merge_start_column, merge_end_column, merge_text)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled', $7, $8, $9)
RETURNING id`,
[
dailyNumber,
titleAr,
titleEn,
meetingDate,
startTime,
endTime,
mergeStartColumn,
mergeEndColumn,
mergeText,
],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
const RANDOM_DATE_BASE_DAYS =
365 + 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 * 11);
return d.toISOString().slice(0, 10);
}
test.afterAll(async () => {
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();
});
test("Row actions menu: Row color item shows an inline swatch reflecting the saved color", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(3);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Color preview ${uniq}`,
titleAr: `معاينة اللون ${uniq}`,
startTime: "09:00:00",
endTime: "09:30:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 10_000 });
await ensureEditModeOn(page);
const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
await kebab.click({ force: true });
// First open: no color picked yet → indicator advertises "default".
// The dot itself is decorative (aria-hidden), so we assert via its
// data-color-key attribute rather than scraping background CSS.
const indicator = page.getByTestId(`em-row-color-indicator-${meetingId}`);
await expect(indicator).toHaveCount(1);
await expect(indicator).toHaveAttribute("data-color-key", "default");
// Pick the red swatch via the existing sub-view.
await page.getByTestId("em-row-color-trigger").click();
await page.getByTestId("em-row-color-red").click();
// Picking a color closes the popover (existing behavior).
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
// Re-open the kebab and confirm the inline preview now reflects red.
// The picker uses #fee2e2 → rgb(254, 226, 226), so we also sanity
// check the computed background to make sure the dot is actually
// rendering the chosen tint (not just dataset-tagged correctly).
await kebab.click({ force: true });
await expect(indicator).toHaveAttribute("data-color-key", "red");
const bg = await indicator.evaluate(
(el) => getComputedStyle(el).backgroundColor,
);
expect(bg).toMatch(/254,\s*226,\s*226/);
// Reset back to default and confirm the indicator returns to the
// hatched / "default" state, so future runs against this meeting (or
// its leftover localStorage) don't lock in red.
await page.getByTestId("em-row-color-trigger").click();
await page.getByTestId("em-row-color-default").click();
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
await kebab.click({ force: true });
await expect(indicator).toHaveAttribute("data-color-key", "default");
});
test("Row actions menu: Merge cells item shows a 'Merged: …' badge naming the spanned columns", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(4);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
// Row WITHOUT a stored merge — should not render a merge badge at all.
const plainId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Plain row ${uniq}`,
titleAr: `صف عادي ${uniq}`,
startTime: "09:00:00",
endTime: "09:30:00",
});
// Row WITH a meeting+attendees merge — kebab anchors on the merged
// cell, and the badge should read "Merged: Meeting + Attendees".
const mergedId = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Merged row ${uniq}`,
titleAr: `صف مدموج ${uniq}`,
startTime: "10:00:00",
endTime: "10:30:00",
mergeStartColumn: "meeting",
mergeEndColumn: "attendees",
mergeText: "merged note",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
const plainRow = page.getByTestId(`em-row-${plainId}`);
const mergedRow = page.getByTestId(`em-row-${mergedId}`);
await expect(plainRow).toBeVisible({ timeout: 10_000 });
await expect(mergedRow).toBeVisible({ timeout: 10_000 });
await ensureEditModeOn(page);
// Plain row: open the kebab and confirm there is NO merge indicator.
const plainKebab = plainRow
.locator('[data-testid^="em-row-actions-"]')
.first();
await plainKebab.click({ force: true });
await expect(
page.getByTestId(`em-merge-trigger-${plainId}`),
).toBeVisible();
await expect(
page.getByTestId(`em-merge-indicator-${plainId}`),
).toHaveCount(0);
// Close the popover and WAIT for it to actually unmount before
// poking the next row. Without this, a force:true click on the
// merged-row kebab can land on the still-open Delete item that
// sits over the merged row, triggering an accidental delete confirm
// and never opening the merged-row popover at all.
await page.keyboard.press("Escape");
await expect(
page.getByTestId(`em-merge-trigger-${plainId}`),
).toHaveCount(0);
// Resolve the rendered column-header labels at runtime so the
// assertion is language-agnostic — admin's preferredLanguage gets
// applied by AuthContext after login and may override the
// tx-lang=en init script seed, so we cannot hard-code the English
// strings here.
const meetingColLabel = (
await page.getByTestId("em-col-header-meeting").first().textContent()
)?.trim();
const attendeesColLabel = (
await page.getByTestId("em-col-header-attendees").first().textContent()
)?.trim();
expect(meetingColLabel).toBeTruthy();
expect(attendeesColLabel).toBeTruthy();
// Merged row: open the kebab and confirm the badge appears, naming
// the spanned columns. We check both `data-testid` and that the
// visible text contains both column labels in canonical order
// joined with " + ", which is what the activeBadge i18n template
// produces in either language.
const mergedKebab = mergedRow
.locator('[data-testid^="em-row-actions-"]')
.first();
await mergedKebab.click({ force: true });
// Sanity: the popover actually opened on the merged row.
await expect(
page.getByTestId(`em-merge-trigger-${mergedId}`),
).toBeVisible();
const mergedBadge = page.getByTestId(`em-merge-indicator-${mergedId}`);
await expect(mergedBadge).toBeVisible();
await expect(mergedBadge).toContainText(
`${meetingColLabel} + ${attendeesColLabel}`,
);
});