mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(measurements): shared filter bar and multi-select bulk delete
Extract the duplicated list chrome — SortableHead and the single-row DeleteButton — into a shared src/components/data-list/ module and add a page-scoped multi-select with a selection action bar, used by both the measurements and mood management lists. Filter bar: each list keeps its primary Select (type / mood level) and gains a source Select (measurements sends sourceEq, mood sends source) plus an optional date range wired to the existing from/to params. Every filter resets pagination to page 1 and is baked into the query key via the centralised factory (queryKeys.measurementsList / moodEntriesList) so the cache stays correct per filter combination. Multi-select is scoped to the current page: a leading checkbox column on desktop, a checkbox on the mobile cards, and a select-all-on-page header checkbox. When at least one row is selected an action bar shows the count, a clear affordance, and a destructive Delete (N) behind an AlertDialog confirm that POSTs to the bulk-delete route, then invalidates the same dependent-key bundle the single delete busts and clears the selection. Selection clears on any page / filter / sort change; the selection math lives in a pure, unit-tested helper. The locked-type insights subpage keeps its focused single-metric view (source filter + date range suppressed). New strings land in a dataList namespace plus per-list source-filter and bulk-delete keys, translated across all six locales.
This commit is contained in:
1 parent
ca262d9839
commit
f2bd051fa7
17 files changed
+1515
-254
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
} from "../selection";
|
||||
|
||||
/**
|
||||
* v1.15.13 — pure page-scoped selection math for the measurements + mood
|
||||
* management lists. These helpers back the multi-select chrome; the tests
|
||||
* pin the set algebra so the select-all tri-state + the bulk-delete
|
||||
* payload stay correct without needing a DOM render.
|
||||
*/
|
||||
describe("data-list selection math", () => {
|
||||
describe("toggleId", () => {
|
||||
it("adds an id that is not present", () => {
|
||||
const next = toggleId(new Set(["a"]), "b");
|
||||
expect([...next].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("removes an id that is present", () => {
|
||||
const next = toggleId(new Set(["a", "b"]), "a");
|
||||
expect([...next]).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("returns a new set (does not mutate the input)", () => {
|
||||
const input = new Set(["a"]);
|
||||
const next = toggleId(input, "b");
|
||||
expect(next).not.toBe(input);
|
||||
expect([...input]).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectAllState", () => {
|
||||
it("is none on an empty page", () => {
|
||||
expect(selectAllState(new Set(["a"]), [])).toBe("none");
|
||||
});
|
||||
|
||||
it("is none when nothing on the page is selected", () => {
|
||||
expect(selectAllState(new Set(["x"]), ["a", "b"])).toBe("none");
|
||||
});
|
||||
|
||||
it("is some when at least one but not all are selected", () => {
|
||||
expect(selectAllState(new Set(["a"]), ["a", "b"])).toBe("some");
|
||||
});
|
||||
|
||||
it("is all when every page id is selected", () => {
|
||||
expect(selectAllState(new Set(["a", "b"]), ["a", "b"])).toBe("all");
|
||||
});
|
||||
|
||||
it("ignores selected ids that are not on the page", () => {
|
||||
// `x` is selected but off-page; the page is fully selected.
|
||||
expect(selectAllState(new Set(["a", "b", "x"]), ["a", "b"])).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleSelectAll", () => {
|
||||
it("adds every page id when not all are selected", () => {
|
||||
const next = toggleSelectAll(new Set(["a"]), ["a", "b", "c"]);
|
||||
expect([...next].sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("clears the page ids when all are already selected", () => {
|
||||
const next = toggleSelectAll(new Set(["a", "b"]), ["a", "b"]);
|
||||
expect([...next]).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves off-page selection when clearing the page", () => {
|
||||
const next = toggleSelectAll(new Set(["a", "b", "x"]), ["a", "b"]);
|
||||
expect([...next]).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("is a no-op on an empty page", () => {
|
||||
const next = toggleSelectAll(new Set(["a"]), []);
|
||||
expect([...next]).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedIdsOnPage / selectedCountOnPage", () => {
|
||||
it("returns only selected ids that are present on the page, in page order", () => {
|
||||
const selected = new Set(["c", "a", "x"]);
|
||||
const pageIds = ["a", "b", "c"];
|
||||
expect(selectedIdsOnPage(selected, pageIds)).toEqual(["a", "c"]);
|
||||
expect(selectedCountOnPage(selected, pageIds)).toBe(2);
|
||||
});
|
||||
|
||||
it("never includes off-page ids in the bulk-delete payload", () => {
|
||||
const selected = new Set(["off-page-1", "off-page-2"]);
|
||||
const pageIds = ["a", "b"];
|
||||
expect(selectedIdsOnPage(selected, pageIds)).toEqual([]);
|
||||
expect(selectedCountOnPage(selected, pageIds)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Shared single-row delete button + confirm dialog for the data-
|
||||
* management lists (measurements + mood). Lifted from the two
|
||||
* copy-pasted `DeleteButton` helpers (v1.15.13).
|
||||
*
|
||||
* The confirm copy differs per surface (a measurement vs a mood entry),
|
||||
* so the title/description are passed in rather than reaching for a fixed
|
||||
* translation key. `cancelLabel` / `confirmLabel` default to the shared
|
||||
* `common.*` strings.
|
||||
*/
|
||||
export function DeleteButton({
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
className = "size-11",
|
||||
iconClassName = "h-3.5 w-3.5",
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`text-destructive ${className}`}
|
||||
aria-label={confirmLabel ?? t("common.delete")}
|
||||
>
|
||||
<Trash2 className={iconClassName} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel ?? t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared chrome for the data-management lists (measurements + mood).
|
||||
*
|
||||
* v1.15.13 — the `SortableHead` + single-row `DeleteButton` were copy-
|
||||
* pasted between `measurement-list.tsx` and `mood-list.tsx`; they now
|
||||
* live here once, alongside the new page-scoped multi-select selection
|
||||
* bar and its pure selection math. Each list keeps its own columns +
|
||||
* formatting; only the shared chrome lives in this module.
|
||||
*/
|
||||
export { SortableHead } from "./sortable-head";
|
||||
export { DeleteButton } from "./delete-button";
|
||||
export { SelectionActionBar } from "./selection-action-bar";
|
||||
export {
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
type SelectAllState,
|
||||
} from "./selection";
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Selection action bar for the data-management lists (measurements +
|
||||
* mood). Appears when ≥1 row on the current page is selected; shows
|
||||
* "N selected", a destructive "Delete (N)" button gated behind an
|
||||
* AlertDialog confirm, and a clear-selection affordance.
|
||||
*
|
||||
* Pure / presentational: every behaviour is a callback. The component
|
||||
* renders nothing when `count === 0`. The bar is sticky to the bottom of
|
||||
* the viewport so it stays reachable while scrolling a long page, and the
|
||||
* confirm dialog is focus-trapped by the shared `AlertDialog` primitive.
|
||||
*
|
||||
* The confirm copy is passed in because the title/body differ per surface
|
||||
* (measurements vs mood) and the count is interpolated by the caller.
|
||||
*/
|
||||
export function SelectionActionBar({
|
||||
count,
|
||||
onClear,
|
||||
onConfirmDelete,
|
||||
isDeleting,
|
||||
confirmTitle,
|
||||
confirmBody,
|
||||
}: {
|
||||
count: number;
|
||||
onClear: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
confirmTitle: string;
|
||||
confirmBody: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("dataList.selectionBarLabel")}
|
||||
className="bg-card border-border sticky bottom-2 z-10 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={onClear}
|
||||
disabled={isDeleting}
|
||||
aria-label={t("dataList.clearSelection")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium" aria-live="polite">
|
||||
{t("dataList.selected", { count: String(count) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("dataList.deleteN", { count: String(count) })}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirmBody}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure, framework-free selection math for the page-scoped multi-select on
|
||||
* the measurements + mood management lists (v1.15.13).
|
||||
*
|
||||
* Selection is scoped to the CURRENT PAGE per the v1.15.x list audit
|
||||
* (§E) — no "select across 200k rows". Every helper here operates on a
|
||||
* `Set<string>` of row ids that all belong to the rows currently painted,
|
||||
* so the math stays a few cheap set operations regardless of total count.
|
||||
*
|
||||
* Extracted so the selection logic is unit-testable without a DOM /
|
||||
* React render (`selection.test.ts`).
|
||||
*/
|
||||
|
||||
/** Toggle a single id in/out of the selection set, returning a new Set. */
|
||||
export function toggleId(selected: ReadonlySet<string>, id: string): Set<string> {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header "select all on this page" state.
|
||||
*
|
||||
* - `"all"` — every selectable id on the page is selected.
|
||||
* - `"some"` — at least one but not all are selected (indeterminate).
|
||||
* - `"none"` — nothing on the page is selected.
|
||||
*
|
||||
* A page with no selectable rows is `"none"` (the header checkbox should
|
||||
* be a no-op then).
|
||||
*/
|
||||
export type SelectAllState = "all" | "some" | "none";
|
||||
|
||||
export function selectAllState(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): SelectAllState {
|
||||
if (pageIds.length === 0) return "none";
|
||||
let selectedOnPage = 0;
|
||||
for (const id of pageIds) {
|
||||
if (selected.has(id)) selectedOnPage += 1;
|
||||
}
|
||||
if (selectedOnPage === 0) return "none";
|
||||
if (selectedOnPage === pageIds.length) return "all";
|
||||
return "some";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a "select all on page" toggle. When every selectable id on the
|
||||
* page is already selected, the toggle clears the page's ids; otherwise it
|
||||
* adds the page's ids to the existing selection. Selection from other
|
||||
* (now-unmounted) pages is dropped on a page change by the caller, so in
|
||||
* practice `selected` only ever holds current-page ids — but this stays
|
||||
* correct either way.
|
||||
*/
|
||||
export function toggleSelectAll(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): Set<string> {
|
||||
if (pageIds.length === 0) return new Set(selected);
|
||||
const allSelected = pageIds.every((id) => selected.has(id));
|
||||
const next = new Set(selected);
|
||||
if (allSelected) {
|
||||
for (const id of pageIds) next.delete(id);
|
||||
} else {
|
||||
for (const id of pageIds) next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected ids intersected with the ids actually present on the page.
|
||||
* The bulk-delete request only ever sends rows the user can currently see,
|
||||
* which keeps the payload page-bounded (≤ PAGE_SIZE) and matches the
|
||||
* server's 200-id cap with room to spare.
|
||||
*/
|
||||
export function selectedIdsOnPage(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): string[] {
|
||||
return pageIds.filter((id) => selected.has(id));
|
||||
}
|
||||
|
||||
/** Count of selected ids that are present on the current page. */
|
||||
export function selectedCountOnPage(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const id of pageIds) {
|
||||
if (selected.has(id)) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
|
||||
import { TableHead } from "@/components/ui/table";
|
||||
|
||||
/**
|
||||
* Shared clickable sort header for the data-management tables
|
||||
* (measurements + mood). Lifted verbatim from the two copy-pasted
|
||||
* `SortableHead` definitions (v1.15.13). Domain specifics — which
|
||||
* columns are sortable, their labels — stay in each list; only this
|
||||
* chrome moves.
|
||||
*/
|
||||
export function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user