diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 5b05368c..2fc317cf 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 46499799..38673e4a 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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(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({
{date}
- {/* 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 && (
- {t("executiveMeetings.schedule.bulkSelectionCount") .replace("{{n}}", String(selectedMeetingIds.size)) .replace("{{total}}", String(orderedMeetings.length))} - {selectedMeetingIds.size > 0 && ( -
- - -
- )} +
+ + +
)} @@ -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 ? ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + setAllMeetingsSelected(v === true) + } + aria-label={t( + "executiveMeetings.schedule.bulkSelectAll", + )} + data-testid="em-bulk-select-all" + className="h-4 w-4 bg-white shadow-sm" + /> +
+ ) : undefined + } /> ))} @@ -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 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 && ( diff --git a/artifacts/tx-os/tests/executive-meetings-bulk-actions.spec.mjs b/artifacts/tx-os/tests/executive-meetings-bulk-actions.spec.mjs index ad15b82b..d3cc307c 100644 --- a/artifacts/tx-os/tests/executive-meetings-bulk-actions.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-bulk-actions.spec.mjs @@ -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(); });