mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
c5d58dbc96
commit
b9d7bb4bba
7 changed files with 434 additions and 1 deletions
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
50
src/app/portfolio/[slug]/components/TagChip.tsx
Normal file
50
src/app/portfolio/[slug]/components/TagChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/app/portfolio/[slug]/components/TagsCell.tsx
Normal file
208
src/app/portfolio/[slug]/components/TagsCell.tsx
Normal 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'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));
|
||||
}
|
||||
|
|
@ -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: () => (
|
||||
|
|
|
|||
45
src/app/portfolio/[slug]/components/useTags.ts
Normal file
45
src/app/portfolio/[slug]/components/useTags.ts
Normal 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] });
|
||||
};
|
||||
}
|
||||
45
src/lib/tags/colour.test.ts
Normal file
45
src/lib/tags/colour.test.ts
Normal 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
73
src/lib/tags/colour.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue