feat(tags): explicit select-all-matching in bulk tag mode

The header checkbox only selects loaded rows, so bulk-tagging a portfolio with
more properties than are loaded (250-row window) silently tagged just the page.

Bulk mode is now explicit (Gmail pattern): nothing selected = no target (Choose
a tag is disabled), so a tag can't be applied to everything by accident. Tick
rows to target them; once every loaded row is ticked and more match, a "Select
all N matching" escalation switches the target to the whole matching set,
resolved server-side via the existing assignments filter mode (no backend
change). Any manual checkbox change drops the escalation back to the explicit
selection; changing filters / leaving the mode clears it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 11:34:37 +00:00
parent fc5fc80d88
commit 99025ce4b3
2 changed files with 115 additions and 39 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

@ -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,50 @@ 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). */}
{showEscalate && (
<button
onClick={onSelectAllMatching}
className="text-xs font-semibold text-brandmidblue underline-offset-2 hover:underline"
>
Select all {matchingCount.toLocaleString()}
{hasActiveFilters ? " matching" : ""}
</button>
)}
{selectAllMatching && (
<button
onClick={onClearSelection}
className="text-xs font-semibold text-brandmidblue underline-offset-2 hover:underline"
>
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" />