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
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -1118,7 +1118,8 @@
|
||||
"meetingAttendees": "دمج الاجتماع + الحضور",
|
||||
"meetingTime": "دمج الاجتماع + الحضور + الوقت",
|
||||
"all": "دمج الصف بالكامل",
|
||||
"unmerge": "إلغاء الدمج"
|
||||
"unmerge": "إلغاء الدمج",
|
||||
"activeBadge": "مدموج: {{cols}}"
|
||||
},
|
||||
"nav": {
|
||||
"schedule": "جدول الاجتماعات اليومي",
|
||||
|
||||
@@ -939,7 +939,8 @@
|
||||
"meetingAttendees": "Merge meeting + attendees",
|
||||
"meetingTime": "Merge meeting + attendees + time",
|
||||
"all": "Merge entire row",
|
||||
"unmerge": "Unmerge"
|
||||
"unmerge": "Unmerge",
|
||||
"activeBadge": "Merged: {{cols}}"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Meeting starting soon",
|
||||
|
||||
@@ -2398,6 +2398,7 @@ function RowActionsMenu({
|
||||
onPickRowColor,
|
||||
hasStoredMerge,
|
||||
existingMergeText,
|
||||
mergeColumnLabels,
|
||||
onChangeMerge,
|
||||
t,
|
||||
}: {
|
||||
@@ -2408,12 +2409,20 @@ function RowActionsMenu({
|
||||
onPickRowColor: (key: string) => void;
|
||||
hasStoredMerge: boolean;
|
||||
existingMergeText: string | null;
|
||||
// Localized labels of every column currently spanned by the row's
|
||||
// stored merge (canonical order). Used by the main-menu Merge cells
|
||||
// item to render an inline "Merged: …" badge so users can see the
|
||||
// active range without drilling into the merge sub-view. Null when
|
||||
// the row has no stored merge.
|
||||
mergeColumnLabels: string[] | null;
|
||||
onChangeMerge: (
|
||||
merge:
|
||||
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
|
||||
| null,
|
||||
) => Promise<void>;
|
||||
t: (k: string) => string;
|
||||
// Widened from `(k: string) => string` so the main menu can pass
|
||||
// interpolation opts (e.g. {{cols}}) for the merge badge label.
|
||||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [view, setView] = useState<"main" | "color" | "merge">("main");
|
||||
@@ -2478,7 +2487,33 @@ function RowActionsMenu({
|
||||
data-testid="em-row-color-trigger"
|
||||
>
|
||||
<Palette className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{t("executiveMeetings.rowColor.label")}
|
||||
<span>{t("executiveMeetings.rowColor.label")}</span>
|
||||
{/* Inline preview of the row's currently-saved colour so
|
||||
users can tell at a glance which colour is set without
|
||||
having to expand the swatch sub-view. The "default"
|
||||
state shows a hatched pattern (matching the swatch
|
||||
picker) to read clearly as "no colour". Decorative —
|
||||
the sub-view is the actual control, so this stays
|
||||
aria-hidden. */}
|
||||
{(() => {
|
||||
const opt = ROW_COLOR_OPTIONS.find(
|
||||
(o) => o.key === rowColorKey,
|
||||
);
|
||||
const isDefault = !opt || opt.key === "default";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={`em-row-color-indicator-${meetingId}`}
|
||||
data-color-key={opt?.key ?? "default"}
|
||||
className="ms-auto w-3 h-3 rounded-full border border-gray-300 shrink-0"
|
||||
style={{
|
||||
background: isDefault
|
||||
? "repeating-linear-gradient(45deg,#fff,#fff 3px,#e5e7eb 3px,#e5e7eb 6px)"
|
||||
: opt!.bg,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2487,7 +2522,25 @@ function RowActionsMenu({
|
||||
data-testid={`em-merge-trigger-${meetingId}`}
|
||||
>
|
||||
<Combine className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{t("executiveMeetings.merge.label")}
|
||||
<span>{t("executiveMeetings.merge.label")}</span>
|
||||
{/* Inline indicator of the row's active merge range, so
|
||||
users see which columns are merged without drilling
|
||||
into the merge sub-view. Only shown when a merge is
|
||||
stored in the DB; truncated visually but readable in
|
||||
full via the title tooltip on hover. */}
|
||||
{hasStoredMerge && mergeColumnLabels && mergeColumnLabels.length > 0 && (
|
||||
<span
|
||||
data-testid={`em-merge-indicator-${meetingId}`}
|
||||
title={t("executiveMeetings.merge.activeBadge", {
|
||||
cols: mergeColumnLabels.join(" + "),
|
||||
})}
|
||||
className="ms-auto px-1.5 py-0.5 text-[10px] leading-none rounded bg-[#0B1E3F]/10 text-[#0B1E3F] truncate max-w-[120px] shrink-0"
|
||||
>
|
||||
{t("executiveMeetings.merge.activeBadge", {
|
||||
cols: mergeColumnLabels.join(" + "),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -2723,6 +2776,17 @@ function MeetingRow({
|
||||
visibleMergeIndices.length;
|
||||
const isMerged =
|
||||
hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous;
|
||||
// Localized labels for the canonical columns spanned by the row's
|
||||
// stored merge — passed into RowActionsMenu so the main-menu Merge
|
||||
// cells item can render an inline "Merged: …" preview. Computed
|
||||
// against CANONICAL_MERGE_ORDER (not visibleColumns) so the badge
|
||||
// still describes the full saved range even when one of the
|
||||
// boundary columns is currently hidden.
|
||||
const mergeColumnLabels: string[] | null = hasStoredMerge
|
||||
? CANONICAL_MERGE_ORDER.slice(canonStart, canonEnd + 1).map((id) =>
|
||||
t(`executiveMeetings.col.${id}`),
|
||||
)
|
||||
: null;
|
||||
const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1;
|
||||
const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null;
|
||||
// First visible cell that's NOT part of a merge. The merge trigger
|
||||
@@ -2790,6 +2854,7 @@ function MeetingRow({
|
||||
// the only way the user can clear the stored state.
|
||||
hasStoredMerge={hasStoredMerge}
|
||||
existingMergeText={meeting.mergeText ?? null}
|
||||
mergeColumnLabels={mergeColumnLabels}
|
||||
onChangeMerge={onSaveMerge}
|
||||
t={t}
|
||||
/>
|
||||
@@ -2994,6 +3059,7 @@ function MeetingRow({
|
||||
onPickRowColor={onPickRowColor}
|
||||
hasStoredMerge
|
||||
existingMergeText={meeting.mergeText ?? null}
|
||||
mergeColumnLabels={mergeColumnLabels}
|
||||
onChangeMerge={onSaveMerge}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user