Merge pull request #392 from Hestia-Homes/feature/tag-select-all-matching

feat(tags): explicit select-all-matching in bulk tag mode
This commit is contained in:
KhalimCK 2026-07-14 13:31:30 +01:00 committed by GitHub
commit 140383f18f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 126 additions and 44 deletions

View file

@ -327,6 +327,21 @@ export default function PropertyTable({
// Row selection for bulk tagging — keyed by property id (see DataTable getRowId).
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
// Deliberate "select all matching" escalation (whole set, not just loaded rows).
const [selectAllMatching, setSelectAllMatching] = useState(false);
const clearSelection = () => {
setRowSelection({});
setSelectAllMatching(false);
};
// Any manual checkbox change drops the all-matching escalation back to the
// explicit (possibly now-smaller) row selection.
const handleRowSelectionChange = (
updater: Updater<RowSelectionState>,
) => {
setSelectAllMatching(false);
setRowSelection(updater);
};
// Bulk-tag flows launched from the Tags menu: a contextual assign/remove mode,
// and the file-upload modal.
@ -456,6 +471,16 @@ export default function PropertyTable({
// loaded rows (cheap; avoids a hook per project convention): exact for a
// fully-loaded portfolio, a floor when more rows lazy-load beyond view.
const unmodelledInView = tableData.filter((p) => p.planId == null).length;
// Bulk-select scope: how many properties match the current view, whether every
// loaded row is ticked, and whether more match than are loaded (so "select all
// matching" is worth offering). matchingCount is filtered-total when a filter
// is active, else the whole portfolio.
const matchingCount = hasActiveFilters ? filteredTotal : totalCount;
const allLoadedSelected =
selectedIds.length > 0 && selectedIds.length === tableData.length;
const moreMatchThanLoaded = tableData.length < matchingCount;
// The not-modelled banner owns the Run-modelling CTA in this state, so the
// toolbar must NOT also show one — otherwise two identical navy buttons appear.
const showModelNudge =
@ -477,7 +502,7 @@ export default function PropertyTable({
if (pagination.pageIndex !== 0)
setPagination((p) => ({ ...p, pageIndex: 0 }));
// The selection is filter-scoped — a changed filter invalidates it.
if (selectedIds.length > 0) setRowSelection({});
if (selectedIds.length > 0 || selectAllMatching) clearSelection();
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
@ -586,11 +611,13 @@ export default function PropertyTable({
properties
</span>
)}
{selectedIds.length > 0 && (
{(selectedIds.length > 0 || selectAllMatching) && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-brandmidblue/30 bg-brandlightblue/50 px-2 py-0.5 text-xs font-semibold text-brandblue">
{selectedIds.length.toLocaleString()} selected
{selectAllMatching
? `All ${matchingCount.toLocaleString()} selected`
: `${selectedIds.length.toLocaleString()} selected`}
<button
onClick={() => setRowSelection({})}
onClick={clearSelection}
className="text-brandmidblue hover:text-brandblue"
>
Clear
@ -709,11 +736,11 @@ export default function PropertyTable({
<TagsMenu
onUpload={() => setTagUploadOpen(true)}
onAssign={() => {
setRowSelection({});
clearSelection();
setTagMode("assign");
}}
onRemove={() => {
setRowSelection({});
clearSelection();
setTagMode("remove");
}}
/>
@ -842,13 +869,17 @@ export default function PropertyTable({
portfolioId={portfolioId}
mode={tagMode}
selectedIds={selectedIds}
selectAllMatching={selectAllMatching}
hasActiveFilters={hasActiveFilters}
filterGroups={allFilterGroups}
filteredTotal={filteredTotal}
totalCount={totalCount}
matchingCount={matchingCount}
allLoadedSelected={allLoadedSelected}
moreMatchThanLoaded={moreMatchThanLoaded}
onSelectAllMatching={() => setSelectAllMatching(true)}
onClearSelection={clearSelection}
onClose={() => {
setTagMode(null);
setRowSelection({});
clearSelection();
}}
/>
)}
@ -979,11 +1010,7 @@ export default function PropertyTable({
setPagination as (updater: Updater<PaginationState>) => void
}
rowSelection={rowSelection}
onRowSelectionChange={
setRowSelection as (
updater: Updater<RowSelectionState>,
) => void
}
onRowSelectionChange={handleRowSelectionChange}
/>
)}
</div>

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { CheckIcon, ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
import {
DropdownMenu,
DropdownMenuContent,
@ -19,28 +19,37 @@ export type TagMode = "assign" | "remove";
/**
* The contextual strip shown while assigning or removing a tag in bulk
* (ADR-0013). Its target follows the live table state: the row selection if any,
* else every property matching the active filter, else the whole portfolio
* so ticking rows or changing filters re-scopes it in place. Picking a tag adds
* (assign) or deletes (remove) memberships server-side and reports the count.
* (ADR-0013). The target is explicit: the ticked rows, or after ticking every
* loaded row "all N matching", which the user escalates to deliberately and
* which resolves the whole matching set server-side (not just the loaded page).
* With nothing selected there is no target, so a tag can't be applied by
* accident. Picking a tag adds/removes memberships and reports the count.
*/
export function TagActionBar({
portfolioId,
mode,
selectedIds,
selectAllMatching,
hasActiveFilters,
filterGroups,
filteredTotal,
totalCount,
matchingCount,
allLoadedSelected,
moreMatchThanLoaded,
onSelectAllMatching,
onClearSelection,
onClose,
}: {
portfolioId: string;
mode: TagMode;
selectedIds: string[];
selectAllMatching: boolean;
hasActiveFilters: boolean;
filterGroups: FilterGroups;
filteredTotal: number;
totalCount: number;
matchingCount: number;
allLoadedSelected: boolean;
moreMatchThanLoaded: boolean;
onSelectAllMatching: () => void;
onClearSelection: () => void;
onClose: () => void;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
@ -51,25 +60,34 @@ export function TagActionBar({
const verb = mode === "assign" ? "Assign" : "Remove";
const prep = mode === "assign" ? "to" : "from";
// Target precedence: explicit selection → active filter set → whole portfolio.
const target =
selectedIds.length > 0
// The current target — explicit selection only (no implicit "all"). Selecting
// all matching is a deliberate escalation that switches to the filter path.
type Target = {
body:
| { mode: "properties"; propertyIds: string[] }
| { mode: "filter"; filterGroups: FilterGroups };
label: string;
};
const target: Target | null = selectAllMatching
? {
body: {
mode: "filter",
filterGroups: hasActiveFilters ? filterGroups : [],
},
label: hasActiveFilters
? `all ${matchingCount.toLocaleString()} matching your filter`
: `all ${matchingCount.toLocaleString()} properties`,
}
: selectedIds.length > 0
? {
body: { mode: "properties" as const, propertyIds: selectedIds },
body: { mode: "properties", propertyIds: selectedIds },
label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`,
}
: hasActiveFilters
? {
body: { mode: "filter" as const, filterGroups },
label: `all ${filteredTotal.toLocaleString()} matching your filters`,
}
: {
body: { mode: "filter" as const, filterGroups: [] as FilterGroups },
label: `all ${totalCount.toLocaleString()} properties`,
};
: null;
const apply = useMutation<{ changed: number }, Error, Tag>({
mutationFn: async (tag) => {
if (!target) throw new Error("Select some properties first");
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
@ -91,19 +109,53 @@ export function TagActionBar({
onError: (e) => setResult(e.message),
});
const showEscalate =
!selectAllMatching && allLoadedSelected && moreMatchThanLoaded;
return (
<div className="mb-3 flex flex-wrap items-center gap-2.5 rounded-lg border border-brandmidblue/30 bg-brandlightblue/40 px-3 py-2">
<span className="text-xs font-bold text-brandblue">{verb} a tag</span>
<span className="text-xs text-slate-600">
{prep} <span className="font-semibold text-brandblue">{target.label}</span>
<span className="text-slate-400"> tick rows or apply filters to change this</span>
{target ? (
<>
{prep}{" "}
<span className="font-semibold text-brandblue">{target.label}</span>
</>
) : (
<span className="text-slate-500">
Tick properties to {mode === "assign" ? "tag" : "untag"} them
</span>
)}
</span>
{/* Deliberate escalation to the whole matching set (Gmail pattern)
a clear secondary button (white fill + brand border) so it stands out
on the tinted bar without competing with the navy "Choose a tag". */}
{showEscalate && (
<button
onClick={onSelectAllMatching}
className="inline-flex items-center gap-1 rounded-lg border border-brandmidblue bg-white px-2.5 py-1 text-xs font-semibold text-brandblue shadow-sm transition hover:bg-brandmidblue hover:text-white"
>
<CheckIcon className="h-3.5 w-3.5" />
Select all {matchingCount.toLocaleString()}
{hasActiveFilters ? " matching" : ""}
</button>
)}
{selectAllMatching && (
<button
onClick={onClearSelection}
className="text-xs font-semibold text-slate-600 underline underline-offset-2 hover:text-brandblue"
>
Clear selection
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={apply.isLoading}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
disabled={apply.isLoading || !target}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{apply.isLoading ? "Applying…" : "Choose a tag"}
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />

View file

@ -215,17 +215,20 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
id: "select",
enableSorting: false,
enableHiding: false,
// Selects ALL loaded rows (not just the paginated page), so on a small page
// size it still covers everything in view. When more properties match than
// are loaded, the TagActionBar offers "Select all N matching" on top of this.
header: ({ table }) => (
<Checkbox
aria-label="Select all on this page"
aria-label="Select all loaded rows"
checked={
table.getIsAllPageRowsSelected()
table.getIsAllRowsSelected()
? true
: table.getIsSomePageRowsSelected()
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
/>
),
cell: ({ row }) => (