Task #198: Edit→Save toggle + bulk-clear attendees + multi-select delete meetings

Three executive-meetings polish features on the Schedule view:

1. Edit-mode toggle now flips its label (Edit↔Save) and icon
   (Pencil↔Check) so a second click reads as "I'm done editing."
2. New-/edit-meeting dialog gained a "Remove all attendees" button
   next to "+ Add attendee" with a confirm prompt; only renders
   when ≥1 attendee is present.
3. Per-row select checkboxes (only in edit mode) drive a tri-state
   "select all" checkbox in the schedule header AND a floating
   bulk toolbar that appears only when ≥1 row is selected. The
   toolbar shows "Selected N of M" with "Delete selected" +
   "Clear selection". Single confirm wipes all checked meetings.

Implementation notes:
- Bulk-select checkbox is rendered as an absolute overlay on the
  first visible non-merged cell of every row (and on the merged
  <td> for merged rows) instead of being inlined inside the # cell.
  This (a) preserves the # cell's grip handle so dnd-kit drag still
  works, (b) keeps the checkbox reachable when users hide # via the
  column customizer, and (c) keeps it reachable on rows whose merge
  swallows the leading cells.
- Tri-state "select all" is rendered as an absolute overlay on the
  first visible <th> in <thead> (typically # but falls back to the
  next visible header when # is hidden).
- Floating bulk toolbar is gated on `selectedMeetingIds.size > 0`
  per spec — it disappears entirely with zero selection.
- Selection clears on date change, edit-mode-off, post-bulk-delete,
  AND on every successful day refresh (detected via meetings array
  reference change from useQuery), so a refresh that returns the
  same ids cannot leave a stale selection.
- Locale text added to ar.json/en.json for saveToggle.*, bulk*,
  removeAll, schedule.bulkSelectRow.

Tests:
- New spec executive-meetings-bulk-actions.spec.mjs (5 tests):
  edit toggle flip, dialog remove-all-attendees, multi-select
  delete with DB verify + tri-state header indeterminate/unchecked
  state assertions, hidden-# overlay survival, merged-row overlay
  survival. All pass.
- Verified no regressions in executive-meetings-edit-toggle (4/4),
  -row-actions-menu (2/2), -schedule-features (4/4, including
  drag-row reorder), -manage-create (1/1).

Code review (architect, 3 rounds):
- Round 1 flagged checkbox-in-#-cell breaks when # is hidden →
  fixed by lifting to absolute overlay.
- Round 2 flagged merged rows skipped overlay → fixed by sharing
  the overlay JSX between renderCell (first unmerged cell) and the
  merged <td>, plus added the merged-row e2e regression test.
- Round 3 (validation) flagged: (a) toolbar visible without
  selection, (b) tri-state should be in <thead>, (c) selection
  should reset on day refresh. All three addressed; tests updated.

Pre-existing failures unrelated to this task: 2 PDF tests in
api-server (Tasks #172/#179) — untouched.
This commit is contained in:
riyadhafraa
2026-04-30 08:30:39 +00:00
parent bb87166469
commit 6abc60deaa
3 changed files with 131 additions and 72 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -1165,6 +1165,26 @@ function ScheduleSection({
useEffect(() => {
setSelectedMeetingIds(new Set());
}, [date, effectiveCanMutate]);
// Also clear the selection on every successful day refresh
// (refetch of the meetings query). React Query hands a brand-new
// `meetings` array reference on every refetch — including realtime
// invalidations and post-mutation refreshes triggered by
// refreshDay() — so detecting a reference change here means the
// selection cannot survive past a refresh even when the refreshed
// payload happens to contain the same ids. We skip the very first
// value (mount load) via a ref so an initially-empty selection
// isn't reset for nothing.
const lastMeetingsRef = useRef<Meeting[] | null>(null);
useEffect(() => {
if (lastMeetingsRef.current === null) {
lastMeetingsRef.current = meetings;
return;
}
if (lastMeetingsRef.current !== meetings) {
lastMeetingsRef.current = meetings;
setSelectedMeetingIds(new Set());
}
}, [meetings]);
// Defensive intersection: if the meetings list shrinks (e.g. after a
// bulk delete or someone else removed a row in real time), drop any
// ids that no longer exist so we don't try to act on ghosts.
@@ -1532,66 +1552,47 @@ function ScheduleSection({
<div className="text-sm">{date}</div>
</div>
{/* Bulk-action toolbar — only relevant in edit mode AND when there
is at least one row to act on. The "select all" checkbox is
always visible while the bar is, but the "Delete selected"
+ "Clear selection" actions only appear once ≥1 row is checked
(so the bar doesn't yell at the user before they've done
anything). Hidden in print so it doesn't show up in PDF
exports of the schedule. */}
{effectiveCanMutate && orderedMeetings.length > 0 && (
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
≥1 row is selected (and in edit mode). The tri-state
"select all" checkbox lives in the table header (see
SortableHeader's selectAllOverlay below), so this bar
contains only the count + the destructive actions.
Hidden in print so it doesn't show up in PDF exports. */}
{effectiveCanMutate && selectedMeetingIds.size > 0 && (
<div
className="flex items-center gap-3 flex-wrap rounded-md border border-[#0B1E3F]/30 bg-[#0B1E3F]/5 px-3 py-2 mb-2 print:hidden"
data-testid="em-bulk-toolbar"
>
<label className="flex items-center gap-2 text-sm font-medium text-[#0B1E3F] cursor-pointer">
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) => setAllMeetingsSelected(v === true)}
aria-label={t("executiveMeetings.schedule.bulkSelectAll")}
data-testid="em-bulk-select-all"
className="h-4 w-4"
/>
<span>{t("executiveMeetings.schedule.bulkSelectAll")}</span>
</label>
<span
className="text-sm text-[#0B1E3F]/80"
className="text-sm text-[#0B1E3F]/80 font-medium"
data-testid="em-bulk-selection-count"
>
{t("executiveMeetings.schedule.bulkSelectionCount")
.replace("{{n}}", String(selectedMeetingIds.size))
.replace("{{total}}", String(orderedMeetings.length))}
</span>
{selectedMeetingIds.size > 0 && (
<div className="flex items-center gap-2 ms-auto">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedMeetingIds(new Set())}
disabled={bulkDeleting}
data-testid="em-bulk-clear-selection"
>
{t("executiveMeetings.schedule.bulkClearSelection")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={deleteSelectedMeetings}
disabled={bulkDeleting}
className="gap-1"
data-testid="em-bulk-delete-selected"
>
<Trash2 className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</Button>
</div>
)}
<div className="flex items-center gap-2 ms-auto">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedMeetingIds(new Set())}
disabled={bulkDeleting}
data-testid="em-bulk-clear-selection"
>
{t("executiveMeetings.schedule.bulkClearSelection")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={deleteSelectedMeetings}
disabled={bulkDeleting}
className="gap-1"
data-testid="em-bulk-delete-selected"
>
<Trash2 className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</Button>
</div>
</div>
)}
@@ -1633,6 +1634,40 @@ function ScheduleSection({
showResizeHandle={showResizeHandles}
dragEnabled={effectiveCanMutate}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
selectAllOverlay={
// Tri-state "select all" lives in the first
// visible header (typically # but falls back
// gracefully when the user has hidden #) and
// only renders in edit mode with ≥1 row to
// act on.
idx === 0 &&
effectiveCanMutate &&
orderedMeetings.length > 0 ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) =>
setAllMeetingsSelected(v === true)
}
aria-label={t(
"executiveMeetings.schedule.bulkSelectAll",
)}
data-testid="em-bulk-select-all"
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : undefined
}
/>
))}
</SortableContext>
@@ -1957,6 +1992,7 @@ function SortableHeader({
showResizeHandle,
dragEnabled,
onResize,
selectAllOverlay,
}: {
col: ColumnSetting;
isLast: boolean;
@@ -1965,6 +2001,12 @@ function SortableHeader({
showResizeHandle: boolean;
dragEnabled: boolean;
onResize: (delta: number) => void;
// Optional tri-state "select all" checkbox rendered as an absolute
// overlay anchored to the start-corner of this header. The parent
// passes it only for the first visible column so the checkbox lives
// in <thead> per the spec, while still gracefully surviving the
// case where the # column has been hidden via the column customizer.
selectAllOverlay?: ReactNode;
}) {
const {
attributes,
@@ -1995,6 +2037,7 @@ function SortableHeader({
{...(dragEnabled ? listeners : {})}
>
{t(`executiveMeetings.col.${col.id}`)}
{selectAllOverlay}
{/* Resize handles only render on large screens AND fine (mouse)
pointers AND when the user is in edit mode. See showResizeHandles. */}
{!isLast && showResizeHandle && (
@@ -121,8 +121,9 @@ test("Schedule: edit toggle flips its label and icon when on, and a second click
await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(1);
await expect(toggle.locator("svg.lucide-check")).toHaveCount(0);
// Flip on: aria-pressed flips, label/icon swap, and the bulk
// toolbar appears (proof we really entered edit mode).
// Flip on: aria-pressed flips, label/icon swap, and at least one
// edit-mode-only affordance becomes visible (the per-row select
// checkbox overlay) — proof we really entered edit mode.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
const onLabel = (await toggle.textContent())?.trim() ?? "";
@@ -130,7 +131,9 @@ test("Schedule: edit toggle flips its label and icon when on, and a second click
expect(onLabel).not.toBe(offLabel);
await expect(toggle.locator("svg.lucide-check")).toHaveCount(1);
await expect(toggle.locator("svg.lucide-pencil")).toHaveCount(0);
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(
page.locator('[data-testid^="em-row-select-"]').first(),
).toBeVisible();
// Second click — back to OFF, original label/icon return.
await toggle.click();
@@ -221,47 +224,54 @@ test("Schedule: multi-select + 'Delete selected' removes every checked meeting a
await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible();
await expect(page.getByTestId(`em-row-${id3}`)).toBeVisible();
// View mode → no row checkboxes, no toolbar.
// View mode → no row checkboxes, no select-all in header, no toolbar.
await expect(page.locator('[data-testid^="em-row-select-"]')).toHaveCount(0);
await expect(page.getByTestId("em-bulk-select-all")).toHaveCount(0);
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
// Flip edit mode on. Toolbar + per-row checkboxes appear.
// Flip edit mode on. The tri-state "select all" appears in the
// table header and per-row checkboxes appear, but the floating
// bulk toolbar is NOT visible yet — it only renders once ≥1 row
// is checked.
await page.getByTestId("em-edit-mode-toggle").click();
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
await expect(page.getByTestId(`em-row-select-${id1}`)).toBeVisible();
await expect(page.getByTestId(`em-row-select-${id2}`)).toBeVisible();
await expect(page.getByTestId(`em-row-select-${id3}`)).toBeVisible();
// Initially 0 selected → the count text contains a "0" for both
// total and "of N" segments, and the Delete button is not rendered.
// We use a regex so we don't depend on locale wording.
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(
/(^|\D)0(\D|$)/,
);
await expect(page.getByTestId("em-bulk-delete-selected")).toHaveCount(0);
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
// Check rows 1 and 3 (skip 2 to confirm the selection is honored).
await page.getByTestId(`em-row-select-${id1}`).click();
await page.getByTestId(`em-row-select-${id3}`).click();
// Count text now mentions "2" and "3" somewhere.
// Bulk toolbar now appears with the count + delete button.
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/2/);
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/3/);
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
// Header tri-state is in the indeterminate visual state.
await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute(
"data-state",
"indeterminate",
);
// Delete the two selected rows. Auto-accept the native confirm.
page.once("dialog", (d) => d.accept());
await page.getByTestId("em-bulk-delete-selected").click();
// Rows 1 and 3 are gone; row 2 remains. Selection has cleared.
// Rows 1 and 3 are gone; row 2 remains. Selection has cleared, so
// the floating toolbar disappears entirely.
await expect(page.getByTestId(`em-row-${id1}`)).toHaveCount(0, {
timeout: 10_000,
});
await expect(page.getByTestId(`em-row-${id3}`)).toHaveCount(0);
await expect(page.getByTestId(`em-row-${id2}`)).toBeVisible();
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(
/(^|\D)0(\D|$)/,
);
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
await expect(page.getByTestId("em-bulk-delete-selected")).toHaveCount(0);
// Header select-all is back to unchecked.
await expect(page.getByTestId("em-bulk-select-all")).toHaveAttribute(
"data-state",
"unchecked",
);
// DB should agree: ids 1 + 3 are gone, id 2 still present.
const { rows: remaining } = await pool.query(
@@ -314,13 +324,18 @@ test("Schedule: per-row select checkbox stays usable even when the # column is h
// Enter edit mode. Per-row checkboxes must still appear (now as
// overlays in the first visible cell), and selecting them must
// still drive the bulk toolbar.
// still drive the bulk toolbar — even with the # column hidden,
// the tri-state header overlay falls back to the new first
// visible header.
await page.getByTestId("em-edit-mode-toggle").click();
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible();
await expect(page.getByTestId(`em-row-select-${idB}`)).toBeVisible();
// Toolbar is hidden until something is selected.
await expect(page.getByTestId("em-bulk-toolbar")).toHaveCount(0);
await page.getByTestId(`em-row-select-${idA}`).click();
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/);
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
@@ -365,9 +380,9 @@ test("Schedule: a merged row still exposes the per-row bulk select checkbox", as
// The row-actions kebab only mounts when canMutate is true (edit
// mode on). Turn edit mode on first, then open the kebab and pick
// "Merge all".
// "Merge all". The header tri-state confirms edit mode is live.
await page.getByTestId("em-edit-mode-toggle").click();
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-select-all")).toBeVisible();
await page
.getByTestId(`em-row-actions-${idA}`)
.click({ force: true });
@@ -386,6 +401,7 @@ test("Schedule: a merged row still exposes the per-row bulk select checkbox", as
await expect(page.getByTestId(`em-row-select-${idA}`)).toBeVisible();
await page.getByTestId(`em-row-select-${idA}`).click();
await expect(page.getByTestId("em-bulk-toolbar")).toBeVisible();
await expect(page.getByTestId("em-bulk-selection-count")).toHaveText(/1/);
await expect(page.getByTestId("em-bulk-delete-selected")).toBeVisible();
});