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:
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user