mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(reporting): filter the view by tags (URL-driven)
Some checks are pending
Test Suite / unit-tests (push) Waiting to run
Some checks are pending
Test Suite / unit-tests (push) Waiting to run
Adds a view-wide tag filter to the reporting page. A "Tags" control in the header lists the portfolio's tags; each can be Included (home must carry it) or Excluded (home must not) — mutually exclusive per tag — with an Any/All switch once two or more are included. The filter is URL-driven (?includeTags=…&excludeTags=…&includeMode=all): the server reads it and renders the baseline (current-stock figures) filtered, so it survives refresh and back/forward. Changing it navigates and streams the filtered figures back in via the Suspense boundaries. The scenario overlay, measure allocation and drill-down all send the same tag params (in their react-query keys), so the whole view stays consistent with the headline counts. Scenario selection + scenario filters remain client state for now (instant, no round-trip) — migrating those into the URL too is the documented next step. Verified in a headless browser on #796: including the tag drops the homes count 31,398 → 607 (its exact membership) and writes ?includeTags=13 to the URL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e80332fa64
commit
376627b2d0
7 changed files with 292 additions and 26 deletions
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
import { Suspense, use, useState } from "react";
|
||||
import { useQuery, useIsFetching } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { formatNumber, sapToEpc } from "@/app/utils";
|
||||
import { serializeTagFilter, type TagFilter } from "@/lib/reporting/viewState";
|
||||
import {
|
||||
selectGoalCallout,
|
||||
shapeKpiDelta,
|
||||
|
|
@ -25,6 +27,7 @@ import { FilterChips, type ScenarioFilters } from "./components/FilterChips";
|
|||
import { DrillDownShelf, type DrillTarget } from "./components/DrillDownShelf";
|
||||
import { StockBreakdown } from "./components/StockBreakdown";
|
||||
import { MeasureAllocation } from "./components/MeasureAllocation";
|
||||
import { TagFilterControl } from "./components/TagFilterControl";
|
||||
|
||||
import type {
|
||||
BaselineMetrics,
|
||||
|
|
@ -38,6 +41,8 @@ interface Props {
|
|||
scenariosPromise: Promise<ScenarioSummary[]>;
|
||||
portfolioGoalPromise: Promise<string>;
|
||||
portfolioId: number;
|
||||
/** The tag filter, parsed from the URL on the server (view-wide). */
|
||||
tags: TagFilter;
|
||||
}
|
||||
|
||||
/** Maps the combobox view onto the scenario metrics route's id segment. */
|
||||
|
|
@ -51,6 +56,7 @@ async function fetchScenarioMetrics(
|
|||
portfolioId: number,
|
||||
segment: number | "default",
|
||||
filters: ScenarioFilters,
|
||||
tags: TagFilter,
|
||||
) {
|
||||
const p = new URLSearchParams({
|
||||
hideNonCompliant: String(filters.hideNonCompliant),
|
||||
|
|
@ -60,6 +66,9 @@ async function fetchScenarioMetrics(
|
|||
p.set("complianceDate", filters.complianceWindow.date);
|
||||
p.set("complianceBand", filters.complianceWindow.band);
|
||||
}
|
||||
// View-wide tag filter — the overlay must reflect the same subset as the
|
||||
// baseline it's compared against.
|
||||
serializeTagFilter(tags, p);
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/scenario/${segment}/metrics?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -81,6 +90,7 @@ export function ReportingClientArea({
|
|||
scenariosPromise,
|
||||
portfolioGoalPromise,
|
||||
portfolioId,
|
||||
tags,
|
||||
}: Props) {
|
||||
const [view, setView] = useState<ReportView>("current-stock");
|
||||
const [filters, setFilters] = useState<ScenarioFilters>({
|
||||
|
|
@ -88,9 +98,19 @@ export function ReportingClientArea({
|
|||
useLodgedBaseline: false,
|
||||
complianceWindow: null,
|
||||
});
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isScenario = scenarioIdSegment(view) !== null;
|
||||
|
||||
// The tag filter is URL-driven, so changing it navigates: the server
|
||||
// re-renders the baseline filtered and the metrics stream back in. Scenario
|
||||
// selection + filters stay client state (instant, no round-trip).
|
||||
const onTagsChange = (next: TagFilter) => {
|
||||
const qs = serializeTagFilter(next).toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-reporting>
|
||||
{/* Header — static, paints on first byte */}
|
||||
|
|
@ -102,6 +122,11 @@ export function ReportingClientArea({
|
|||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<TagFilterControl
|
||||
portfolioId={portfolioId}
|
||||
value={tags}
|
||||
onChange={onTagsChange}
|
||||
/>
|
||||
{isScenario && (
|
||||
<a
|
||||
href={`/portfolio/${portfolioId}/reporting/compare`}
|
||||
|
|
@ -142,6 +167,7 @@ export function ReportingClientArea({
|
|||
portfolioId={portfolioId}
|
||||
view={view}
|
||||
filters={filters}
|
||||
tags={tags}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
@ -257,6 +283,7 @@ function MetricsBody({
|
|||
portfolioId,
|
||||
view,
|
||||
filters,
|
||||
tags,
|
||||
}: {
|
||||
baselinePromise: Promise<BaselineMetrics>;
|
||||
propertyTypesPromise: Promise<PropertyTypeCount[]>;
|
||||
|
|
@ -265,6 +292,7 @@ function MetricsBody({
|
|||
portfolioId: number;
|
||||
view: ReportView;
|
||||
filters: ScenarioFilters;
|
||||
tags: TagFilter;
|
||||
}) {
|
||||
const baseline = use(baselinePromise);
|
||||
const propertyTypes = use(propertyTypesPromise);
|
||||
|
|
@ -280,8 +308,9 @@ function MetricsBody({
|
|||
typeof view === "number" ? scenarios.find((s) => s.id === view) : undefined;
|
||||
|
||||
const { data: scenarioData, isFetching } = useQuery({
|
||||
queryKey: ["scenario-report", portfolioId, segment, filters],
|
||||
queryFn: () => fetchScenarioMetrics(portfolioId, segment!, filters),
|
||||
// `tags` is in the key so the overlay refetches when the tag filter changes.
|
||||
queryKey: ["scenario-report", portfolioId, segment, filters, tags],
|
||||
queryFn: () => fetchScenarioMetrics(portfolioId, segment!, filters, tags),
|
||||
enabled: isScenario,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
@ -582,6 +611,7 @@ function MetricsBody({
|
|||
<MeasureAllocation
|
||||
portfolioId={portfolioId}
|
||||
scenarioId={segment}
|
||||
tags={tags}
|
||||
onDrillMeasure={
|
||||
typeof view === "number"
|
||||
? (t) => openDrill({ ...t, scenarioId: view })
|
||||
|
|
@ -605,6 +635,7 @@ function MetricsBody({
|
|||
page={drillPage}
|
||||
onPage={setDrillPage}
|
||||
onClose={() => setDrill(null)}
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { X } from "lucide-react";
|
||||
import { EpcChip } from "./primitives";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
import {
|
||||
serializeTagFilter,
|
||||
emptyTagFilter,
|
||||
type TagFilter,
|
||||
} from "@/lib/reporting/viewState";
|
||||
|
||||
/**
|
||||
* The drill-down shelf (Screen C) — one pattern for every count on the
|
||||
|
|
@ -47,7 +52,12 @@ interface DrillResponse {
|
|||
// A compact preview — the shelf is a first look, not the full table.
|
||||
const DRILL_PAGE_SIZE = 8;
|
||||
|
||||
function buildQuery(portfolioId: number, t: DrillTarget, page: number) {
|
||||
function buildQuery(
|
||||
portfolioId: number,
|
||||
t: DrillTarget,
|
||||
page: number,
|
||||
tags: TagFilter,
|
||||
) {
|
||||
const p = new URLSearchParams({
|
||||
filter: t.filter,
|
||||
page: String(page),
|
||||
|
|
@ -56,6 +66,8 @@ function buildQuery(portfolioId: number, t: DrillTarget, page: number) {
|
|||
if (t.band) p.set("band", t.band);
|
||||
if (t.scenarioId != null) p.set("scenarioId", String(t.scenarioId));
|
||||
if (t.measureType) p.set("measureType", t.measureType);
|
||||
// The drill list must show the same tagged subset as the figure it opened.
|
||||
serializeTagFilter(tags, p);
|
||||
return `/api/portfolio/${portfolioId}/reporting/properties?${p.toString()}`;
|
||||
}
|
||||
|
||||
|
|
@ -65,17 +77,21 @@ export function DrillDownShelf({
|
|||
page,
|
||||
onPage,
|
||||
onClose,
|
||||
tags,
|
||||
}: {
|
||||
portfolioId: number;
|
||||
target: DrillTarget;
|
||||
page: number;
|
||||
onPage: (p: number) => void;
|
||||
onClose: () => void;
|
||||
/** View-wide tag filter; omitted (e.g. data-quality drill) → no tag scoping. */
|
||||
tags?: TagFilter;
|
||||
}) {
|
||||
const tagFilter = tags ?? emptyTagFilter();
|
||||
const { data, isLoading, isError } = useQuery<DrillResponse>({
|
||||
queryKey: ["reporting-drill", portfolioId, target, page],
|
||||
queryKey: ["reporting-drill", portfolioId, target, page, tagFilter],
|
||||
queryFn: () =>
|
||||
fetch(buildQuery(portfolioId, target, page)).then((r) => {
|
||||
fetch(buildQuery(portfolioId, target, page, tagFilter)).then((r) => {
|
||||
if (!r.ok) throw new Error("Failed to load homes");
|
||||
return r.json();
|
||||
}),
|
||||
|
|
@ -120,7 +136,10 @@ export function DrillDownShelf({
|
|||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div role="alert" className="px-5 py-8 text-center text-sm text-red-600">
|
||||
<div
|
||||
role="alert"
|
||||
className="px-5 py-8 text-center text-sm text-red-600"
|
||||
>
|
||||
Could not load these homes.
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { moneyCompact, moneyFull } from "./primitives";
|
||||
import { loadScenarioMeasures } from "../actions";
|
||||
import type { DrillTarget } from "./DrillDownShelf";
|
||||
import type { TagFilter } from "@/lib/reporting/viewState";
|
||||
|
||||
/** Palette for the allocation bar — navy through tan, quiet greys for the tail. */
|
||||
const SEGMENT_COLORS = [
|
||||
|
|
@ -31,10 +32,12 @@ const SEGMENT_COLORS = [
|
|||
export function MeasureAllocation({
|
||||
portfolioId,
|
||||
scenarioId,
|
||||
tags,
|
||||
onDrillMeasure,
|
||||
}: {
|
||||
portfolioId: number;
|
||||
scenarioId: number | "default";
|
||||
tags: TagFilter;
|
||||
/** Undefined disables per-measure drill (e.g. the default pseudo-scenario has no numeric id). */
|
||||
onDrillMeasure?: (t: DrillTarget) => void;
|
||||
}) {
|
||||
|
|
@ -46,8 +49,9 @@ export function MeasureAllocation({
|
|||
const [requested, setRequested] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<Measure[]>({
|
||||
queryKey: ["scenario-measures", portfolioId, scenarioId],
|
||||
queryFn: () => loadScenarioMeasures(portfolioId, scenarioId),
|
||||
// `tags` in the key so the breakdown re-queries when the tag filter changes.
|
||||
queryKey: ["scenario-measures", portfolioId, scenarioId, tags],
|
||||
queryFn: () => loadScenarioMeasures(portfolioId, scenarioId, tags),
|
||||
enabled: requested,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
|
|
@ -119,7 +123,10 @@ export function MeasureAllocation({
|
|||
</div>
|
||||
)}
|
||||
{requested && isError && (
|
||||
<div role="alert" className="flex items-center gap-3 text-sm text-red-600">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-3 text-sm text-red-600"
|
||||
>
|
||||
Could not load the spend breakdown.
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
|
|
@ -277,9 +284,15 @@ function downloadCsv(groups: MeasureGroup[]) {
|
|||
].join(","),
|
||||
);
|
||||
}
|
||||
lines.push([g.category, "Subtotal", g.homesTotal, g.costTotal.toFixed(0), ""].join(","));
|
||||
lines.push(
|
||||
[g.category, "Subtotal", g.homesTotal, g.costTotal.toFixed(0), ""].join(
|
||||
",",
|
||||
),
|
||||
);
|
||||
}
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||
const blob = new Blob([lines.join("\n")], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import { Tag as TagIcon, Check, Ban } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/shadcn_components/ui/popover";
|
||||
import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags";
|
||||
import { TagChip } from "@/app/portfolio/[slug]/components/TagChip";
|
||||
import type { TagFilter, IncludeMode } from "@/lib/reporting/viewState";
|
||||
|
||||
/**
|
||||
* The reporting tag filter (view-wide). Each tag can be Included (home must
|
||||
* carry it) or Excluded (home must not) — mutually exclusive per tag. When two
|
||||
* or more tags are included, an Any/All switch chooses union vs intersection.
|
||||
* Purely presentational + local wiring: it hands a new {@link TagFilter} up to
|
||||
* the parent, which pushes it into the URL.
|
||||
*/
|
||||
export function TagFilterControl({
|
||||
portfolioId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
portfolioId: number;
|
||||
value: TagFilter;
|
||||
onChange: (next: TagFilter) => void;
|
||||
}) {
|
||||
const { data: tags = [], isLoading } = usePortfolioTags(String(portfolioId));
|
||||
const activeCount = value.include.length + value.exclude.length;
|
||||
|
||||
const toggleInclude = (id: string) =>
|
||||
onChange({
|
||||
...value,
|
||||
include: value.include.includes(id)
|
||||
? value.include.filter((x) => x !== id)
|
||||
: [...value.include, id],
|
||||
exclude: value.exclude.filter((x) => x !== id),
|
||||
});
|
||||
|
||||
const toggleExclude = (id: string) =>
|
||||
onChange({
|
||||
...value,
|
||||
exclude: value.exclude.includes(id)
|
||||
? value.exclude.filter((x) => x !== id)
|
||||
: [...value.exclude, id],
|
||||
include: value.include.filter((x) => x !== id),
|
||||
});
|
||||
|
||||
const setMode = (includeMode: IncludeMode) =>
|
||||
onChange({ ...value, includeMode });
|
||||
|
||||
const clear = () =>
|
||||
onChange({ include: [], exclude: [], includeMode: "any" });
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium ${
|
||||
activeCount > 0
|
||||
? "border-brandblue bg-[#f2f3f9] text-brandblue"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" />
|
||||
Tags
|
||||
{activeCount > 0 && (
|
||||
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brandblue px-1 text-[0.62rem] font-bold text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[22rem] p-0">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-3.5 py-2.5">
|
||||
<span className="text-[0.8rem] font-semibold text-brandblue">
|
||||
Filter by tag
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="text-[0.72rem] font-medium text-midblue hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.include.length >= 2 && (
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-3.5 py-2 text-[0.74rem]">
|
||||
<span className="text-gray-500">Match</span>
|
||||
<div className="inline-flex overflow-hidden rounded-md border border-gray-200">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={value.includeMode === m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-2.5 py-1 font-medium ${
|
||||
value.includeMode === m
|
||||
? "bg-brandblue text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{m === "any" ? "any" : "all"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-500">of the included tags</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-72 overflow-y-auto py-1">
|
||||
{isLoading ? (
|
||||
<p className="px-3.5 py-3 text-[0.78rem] text-gray-500">
|
||||
Loading tags…
|
||||
</p>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="px-3.5 py-3 text-[0.78rem] text-gray-500">
|
||||
This portfolio has no tags yet.
|
||||
</p>
|
||||
) : (
|
||||
tags.map((t) => {
|
||||
const included = value.include.includes(t.id);
|
||||
const excluded = value.exclude.includes(t.id);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 px-3.5 py-1.5 hover:bg-gray-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<TagChip name={t.name} colour={t.colour} />
|
||||
<span className="ml-1.5 text-[0.68rem] tabular-nums text-gray-400">
|
||||
{t.propertyCount}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={included}
|
||||
onClick={() => toggleInclude(t.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${
|
||||
included
|
||||
? "border-[#0c6b4a] bg-[#e9f4ef] text-[#0c6b4a]"
|
||||
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Include
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={excluded}
|
||||
onClick={() => toggleExclude(t.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${
|
||||
excluded
|
||||
? "border-[#b91c1c] bg-[#fbeaea] text-[#b91c1c]"
|
||||
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Ban className="h-3 w-3" />
|
||||
Exclude
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,21 +4,28 @@ import {
|
|||
getPortfolioGoal,
|
||||
getReportingScenarios,
|
||||
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
|
||||
import { parseReportingViewState } from "@/lib/reporting/viewState";
|
||||
import { ReportingClientArea } from "./ReportingClientArea";
|
||||
|
||||
export default async function ReportingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const portfolioId = Number(params.slug);
|
||||
|
||||
// The tag filter is URL-driven: read it here so the server-rendered baseline
|
||||
// (current-stock figures) reflects the tagged subset. Changing tags navigates,
|
||||
// re-running these queries and streaming the filtered figures back in.
|
||||
const tags = parseReportingViewState(await props.searchParams).tags;
|
||||
|
||||
// Kick every query off in parallel but DON'T await here — the promises are
|
||||
// handed to the client area, which unwraps each with `use()` inside its own
|
||||
// Suspense boundary. So the page shell + scenario picker paint immediately and
|
||||
// each section streams in as its data lands, instead of the whole page
|
||||
// blocking on the slowest query (the old Promise.all).
|
||||
const baselinePromise = loadBaselineMetrics(portfolioId);
|
||||
const propertyTypesPromise = getCountByPropertyType(portfolioId);
|
||||
const baselinePromise = loadBaselineMetrics(portfolioId, tags);
|
||||
const propertyTypesPromise = getCountByPropertyType(portfolioId, tags);
|
||||
const portfolioGoalPromise = getPortfolioGoal(portfolioId);
|
||||
const scenariosPromise = getReportingScenarios(portfolioId);
|
||||
|
||||
|
|
@ -30,6 +37,7 @@ export default async function ReportingPage(props: {
|
|||
scenariosPromise={scenariosPromise}
|
||||
portfolioGoalPromise={portfolioGoalPromise}
|
||||
portfolioId={portfolioId}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
parseReportingViewState,
|
||||
serializeReportingViewState,
|
||||
serializeTagFilter,
|
||||
isTagFilterActive,
|
||||
emptyViewState,
|
||||
type ReportingViewState,
|
||||
|
|
@ -134,6 +135,20 @@ describe("isTagFilterActive", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("serializeTagFilter", () => {
|
||||
it("is empty for an empty filter", () => {
|
||||
expect(serializeTagFilter(emptyViewState().tags).toString()).toBe("");
|
||||
});
|
||||
it("emits include + exclude, and includeMode only when 'all' with 2+", () => {
|
||||
expect(
|
||||
serializeTagFilter({ include: ["1", "2"], exclude: ["9"], includeMode: "all" }).toString(),
|
||||
).toBe("includeTags=1%2C2&excludeTags=9&includeMode=all");
|
||||
expect(
|
||||
serializeTagFilter({ include: ["1"], exclude: [], includeMode: "all" }).toString(),
|
||||
).toBe("includeTags=1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeReportingViewState — round-trip + clean URLs", () => {
|
||||
it("omits every default (empty query string)", () => {
|
||||
expect(serializeReportingViewState(emptyViewState()).toString()).toBe("");
|
||||
|
|
|
|||
|
|
@ -83,9 +83,7 @@ export function isTagFilterActive(tags: TagFilter): boolean {
|
|||
return tags.include.length > 0 || tags.exclude.length > 0;
|
||||
}
|
||||
|
||||
export function parseReportingViewState(
|
||||
src: ParamSource,
|
||||
): ReportingViewState {
|
||||
export function parseReportingViewState(src: ParamSource): ReportingViewState {
|
||||
const band = readParam(src, "complianceBand");
|
||||
const date = readParam(src, "complianceDate");
|
||||
const complianceWindow =
|
||||
|
|
@ -117,6 +115,20 @@ export function parseReportingViewState(
|
|||
};
|
||||
}
|
||||
|
||||
/** The tag-filter params only — the subset the reporting view currently drives via the URL. */
|
||||
export function serializeTagFilter(
|
||||
tags: TagFilter,
|
||||
into: URLSearchParams = new URLSearchParams(),
|
||||
): URLSearchParams {
|
||||
if (tags.include.length) into.set("includeTags", tags.include.join(","));
|
||||
if (tags.exclude.length) into.set("excludeTags", tags.exclude.join(","));
|
||||
// includeMode only bites with 2+ included tags; omit the default "any".
|
||||
if (tags.includeMode === "all" && tags.include.length >= 2) {
|
||||
into.set("includeMode", "all");
|
||||
}
|
||||
return into;
|
||||
}
|
||||
|
||||
export function serializeReportingViewState(
|
||||
state: ReportingViewState,
|
||||
): URLSearchParams {
|
||||
|
|
@ -128,15 +140,6 @@ export function serializeReportingViewState(
|
|||
p.set("complianceBand", state.complianceWindow.band);
|
||||
p.set("complianceDate", state.complianceWindow.date);
|
||||
}
|
||||
if (state.tags.include.length) {
|
||||
p.set("includeTags", state.tags.include.join(","));
|
||||
}
|
||||
if (state.tags.exclude.length) {
|
||||
p.set("excludeTags", state.tags.exclude.join(","));
|
||||
}
|
||||
// includeMode only bites with 2+ included tags; omit the default "any".
|
||||
if (state.tags.includeMode === "all" && state.tags.include.length >= 2) {
|
||||
p.set("includeMode", "all");
|
||||
}
|
||||
serializeTagFilter(state.tags, p);
|
||||
return p;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue