feat(tags): property-table Tags column + inline assign popover

Chips with "+N" overflow in a default-visible, toggle-able column; the
cell doubles as an assign popover that toggles membership via
POST …/assignments {mode:"properties"}, optimistic across every cached
property-table page and reconciled on settle. Freeform-hex chips get an
auto-contrast foreground (new pure colour util, TDD). Shared usePortfolioTags
hook keeps settings/table/filter in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 16:52:48 +00:00
parent c5d58dbc96
commit b9d7bb4bba
7 changed files with 434 additions and 1 deletions

View file

@ -322,7 +322,8 @@ export default function PropertyTable({
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
// Tags ship visible by default (the headline of ADR-0013); the rest hidden.
init[id] = id === "tags";
});
return init;
},

View file

@ -0,0 +1,50 @@
"use client";
import { X } from "lucide-react";
import { tagChipStyle } from "@/lib/tags/colour";
/**
* A Tag chip: solid fill in the Tag's freeform hex, with auto-contrast text so
* any colour stays legible (ADR-0013). Shared across the property table, the
* tags filter, and the run UI so every surface reads as one system.
*/
export function TagChip({
name,
colour,
size = "sm",
onRemove,
title,
}: {
name: string;
colour: string;
size?: "xs" | "sm";
onRemove?: () => void;
title?: string;
}) {
const sizing =
size === "xs"
? "px-1.5 py-0.5 text-[10px]"
: "px-2 py-0.5 text-[11px]";
return (
<span
className={`inline-flex max-w-[9rem] items-center gap-1 rounded-full border font-semibold leading-none ${sizing}`}
style={tagChipStyle(colour)}
title={title ?? name}
>
<span className="truncate">{name}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="-mr-0.5 shrink-0 rounded-full opacity-80 transition hover:opacity-100"
aria-label={`Remove ${name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
);
}

View file

@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, Plus, Tag as TagIcon } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { PropertyWithRelations, PropertyTag } from "@/app/db/schema/property";
import { tagChipStyle } from "@/lib/tags/colour";
import { TagChip } from "./TagChip";
import {
Tag,
usePortfolioTags,
portfolioTagsKey,
} from "./useTags";
import type { PropertiesResponse } from "./useProperties";
/** How many chips to show inline before collapsing the rest into "+N". */
const MAX_VISIBLE_CHIPS = 2;
type AssignVars = { tag: Tag; action: "add" | "remove" };
/**
* The property table's Tags cell (ADR-0013): shows the property's Tags as chips
* with a "+N" overflow, and an assign popover that toggles membership against
* POST /assignments {mode:"properties"}. Writes are optimistic across every
* cached property-table query for this portfolio, reconciled on settle.
*/
export function TagsCell({ property }: { property: PropertyWithRelations }) {
const portfolioId = String(property.portfolioId);
const propertyId = String(property.id);
const tags = property.tags ?? [];
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { data: allTags = [], isLoading, isError } = usePortfolioTags(portfolioId);
const assignedIds = new Set(tags.map((t) => t.id));
const assign = useMutation<unknown, Error, AssignVars, { snapshot: [readonly unknown[], PropertiesResponse | undefined][] }>({
mutationFn: async ({ tag, action }) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "properties",
action,
propertyIds: [propertyId],
}),
},
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Couldn't update tags");
}
return res.json();
},
// Optimistically add/remove the chip on every cached property-table page.
onMutate: async ({ tag, action }) => {
await queryClient.cancelQueries({ queryKey: ["properties", portfolioId] });
const snapshot = queryClient.getQueriesData<PropertiesResponse>({
queryKey: ["properties", portfolioId],
});
const chip: PropertyTag = { id: tag.id, name: tag.name, colour: tag.colour };
queryClient.setQueriesData<PropertiesResponse>(
{ queryKey: ["properties", portfolioId] },
(prev) =>
prev
? {
...prev,
data: prev.data.map((row) =>
String(row.id) === propertyId
? { ...row, tags: nextTags(row.tags ?? [], chip, action) }
: row,
),
}
: prev,
);
return { snapshot };
},
onError: (_e, _vars, context) => {
context?.snapshot.forEach(([key, data]) =>
queryClient.setQueryData(key, data),
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
},
});
const visible = tags.slice(0, MAX_VISIBLE_CHIPS);
const overflow = tags.length - visible.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="group flex min-h-[1.75rem] max-w-[13rem] flex-wrap items-center gap-1 rounded-lg px-1 py-0.5 text-left transition hover:bg-slate-50"
title="Edit tags"
>
{tags.length === 0 ? (
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-slate-300 px-2 py-0.5 text-[10px] font-semibold text-slate-400 group-hover:border-slate-400 group-hover:text-slate-600">
<Plus className="h-3 w-3" />
Tag
</span>
) : (
<>
{visible.map((t) => (
<TagChip key={t.id} name={t.name} colour={t.colour} size="xs" />
))}
{overflow > 0 && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold text-slate-500">
+{overflow}
</span>
)}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="border-b border-slate-100 px-3 py-2">
<p className="text-xs font-bold text-slate-700">Assign tags</p>
<p className="truncate text-[11px] text-slate-400">
{property.address ?? "This property"}
</p>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{isLoading ? (
<p className="px-2 py-3 text-xs text-slate-400">Loading tags</p>
) : isError ? (
<p className="px-2 py-3 text-xs text-red-500">Couldn&apos;t load tags.</p>
) : allTags.length === 0 ? (
<div className="px-2 py-3 text-xs text-slate-400">
No tags yet.{" "}
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="font-semibold text-brandmidblue hover:underline"
>
Create one
</a>
.
</div>
) : (
allTags.map((t) => {
const on = assignedIds.has(t.id);
return (
<button
key={t.id}
type="button"
onClick={() =>
assign.mutate({ tag: t, action: on ? "remove" : "add" })
}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition hover:bg-slate-50"
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagChipStyle(t.colour).backgroundColor }}
/>
<span className="flex-1 truncate text-xs text-slate-700">
{t.name}
</span>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
on
? "border-brandmidblue bg-brandmidblue text-white"
: "border-slate-300"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
</button>
);
})
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 px-3 py-2">
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-500 hover:text-brandmidblue"
>
<TagIcon className="h-3 w-3" />
Manage tags
</a>
{assign.isError && (
<span className="text-[11px] text-red-500">{assign.error.message}</span>
)}
</div>
</PopoverContent>
</Popover>
);
}
/** Add or remove a chip from a property's tag list, keeping it name-ordered. */
function nextTags(
current: PropertyTag[],
chip: PropertyTag,
action: "add" | "remove",
): PropertyTag[] {
if (action === "remove") return current.filter((t) => t.id !== chip.id);
if (current.some((t) => t.id === chip.id)) return current;
return [...current, chip].sort((a, b) => a.name.localeCompare(b.name));
}

View file

@ -23,6 +23,7 @@ import {
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
import { TagsCell } from "./TagsCell";
/* -----------------------------------------------------------------------
Helpers
@ -166,6 +167,7 @@ export function DataTableFilterHeader<TData, TValue>({
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
"tags",
"propertyType",
"builtForm",
"tenure",
@ -178,6 +180,7 @@ export const OPTIONAL_COLUMN_IDS = [
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
tags: "Tags",
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
@ -235,6 +238,14 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
</div>
),
},
{
id: "tags",
// Toggle-able (in OPTIONAL_COLUMN_IDS) but default-visible — see PropertyTable.
// Not sortable/filterable here; the tags filter lives in PropertyFilters.
enableSorting: false,
header: () => <div className="text-xs">Tags</div>,
cell: ({ row }) => <TagsCell property={row.original} />,
},
{
accessorKey: "currentEpc",
header: () => (

View file

@ -0,0 +1,45 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
/** A portfolio Tag as returned by GET /api/portfolio/[id]/tags (ADR-0013). */
export interface Tag {
id: string;
name: string;
colour: string;
propertyCount: number;
}
/** Shared query key so every tag surface (settings, table, filter) stays in sync. */
export function portfolioTagsKey(portfolioId: string) {
return ["portfolio-tags", portfolioId] as const;
}
export async function fetchTags(portfolioId: string): Promise<Tag[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/tags`);
if (!res.ok) throw new Error("Failed to load tags");
return res.json();
}
/**
* The portfolio's Tags, deduped across every component that reads them (chips,
* assign popover, filter, run UI) via a shared query key. TanStack v4 reads
* with `isLoading`.
*/
export function usePortfolioTags(portfolioId: string) {
return useQuery({
queryKey: portfolioTagsKey(portfolioId),
queryFn: () => fetchTags(portfolioId),
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
/** Invalidate the tags list + the property table so freshly-assigned chips show. */
export function useInvalidateTagSurfaces(portfolioId: string) {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
};
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { readableTextColour, tagChipStyle } from "./colour";
describe("readableTextColour", () => {
it("returns dark text on a light background", () => {
expect(readableTextColour("#ffffff")).toBe("#1f2937");
expect(readableTextColour("#fde047")).toBe("#1f2937"); // bright yellow
});
it("returns white text on a dark background", () => {
expect(readableTextColour("#000000")).toBe("#ffffff");
expect(readableTextColour("#7c3aed")).toBe("#ffffff"); // violet
});
it("accepts 3-digit hex", () => {
expect(readableTextColour("#fff")).toBe("#1f2937");
expect(readableTextColour("#000")).toBe("#ffffff");
});
it("is case-insensitive and tolerates surrounding whitespace", () => {
expect(readableTextColour(" #7C3AED ")).toBe("#ffffff");
});
it("falls back to dark text for an unparseable colour", () => {
expect(readableTextColour("not-a-colour")).toBe("#1f2937");
});
});
describe("tagChipStyle", () => {
it("builds a solid chip with an auto-contrast foreground", () => {
expect(tagChipStyle("#7c3aed")).toEqual({
backgroundColor: "#7c3aed",
color: "#ffffff",
borderColor: "#7c3aed",
});
});
it("normalises the hex it echoes back (trim + lowercase)", () => {
expect(tagChipStyle(" #FDE047 ")).toEqual({
backgroundColor: "#fde047",
color: "#1f2937",
borderColor: "#fde047",
});
});
});

73
src/lib/tags/colour.ts Normal file
View file

@ -0,0 +1,73 @@
/**
* Pure colour helpers for rendering Tag chips (ADR-0013). A Tag's colour is a
* freeform hex, so the foreground text must be chosen per-colour to stay legible
* on both pale and vivid backgrounds. Kept DB-free + side-effect-free so it unit
* tests cleanly and is shared by every chip surface (table, filter, run UI).
*/
/** Slate-800 — the dark foreground used on light chip backgrounds. */
const DARK_TEXT = "#1f2937";
const LIGHT_TEXT = "#ffffff";
/** Parse `#rgb` / `#rrggbb` (any case, surrounding space) → [r,g,b] 0-255, or null. */
function parseHex(input: string): [number, number, number] | null {
const hex = input.trim().toLowerCase().replace(/^#/, "");
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
if (!/^[0-9a-f]{6}$/.test(full)) return null;
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16),
];
}
/** Normalise a hex to lowercase `#rrggbb`, or null if unparseable. */
export function normaliseHex(input: string): string | null {
const rgb = parseHex(input);
if (!rgb) return null;
return "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
}
/**
* Relative luminance (WCAG) of a hex colour, 0 (black) 1 (white). Unparseable
* colours are treated as light so the caller falls back to dark text.
*/
export function luminance(input: string): number {
const rgb = parseHex(input);
if (!rgb) return 1;
const [r, g, b] = rgb.map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* The text colour to lay over a `background` chip: dark on light colours, white
* on dark ones. 0.5 is a practical threshold for readable both-ways contrast.
*/
export function readableTextColour(background: string): string {
return luminance(background) > 0.5 ? DARK_TEXT : LIGHT_TEXT;
}
export interface ChipStyle {
backgroundColor: string;
color: string;
borderColor: string;
}
/** Inline style for a solid Tag chip filled with its colour + auto-contrast text. */
export function tagChipStyle(colour: string): ChipStyle {
const bg = normaliseHex(colour) ?? colour.trim().toLowerCase();
return {
backgroundColor: bg,
color: readableTextColour(bg),
borderColor: bg,
};
}