mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
fc5fc80d88
commit
99025ce4b3
2 changed files with 115 additions and 39 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue