hyperhive/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx
iris 17d22f9e16 swarm-ui: filter the issue report by title, assignees, blocked
Three columns gain Table's filterValue/filterMode (the mechanism
hyperhive#4088 added): title (text, substring search -- there was no
way to search by title text at all), assignees (text, not select --
a row can carry more than one assignee and Table's select mode
matches one whole string per row exactly, so substring search over
the joined string is the shape that actually fits multi-value data),
and blocked (select, synthesized "blocked"/"not blocked" strings --
distinct from the existing "hide blocked" toggle, which only hides
blocked issues and has no way to show only them).

Deliberately not touched: repo (redundant with the existing repo
SelectField), labels (redundant with the existing label chip
multi-select -- chips are the better UI for a bounded label set
anyway), the three numeric columns (no clean filter shape, already
sortable). The existing hand-rolled sort (SortHeader, useLocalSetting-
persisted) is untouched too -- migrating it onto Table's own sortBy
would drop the localStorage persistence this page specifically wants,
and Table doesn't expose controlled sort state to a caller today. The
two layers compose without conflict: Table's own filter/sort runs
over whatever rows it's handed, which is already this page's own
filtered+sorted array.

Scoped on the issue first, including this exact reasoning, before
writing any code.
2026-09-08 12:14:42 +02:00

448 lines
14 KiB
TypeScript

// <IssueReportPage> — mara's custom issue report: browse open issues
// across every repo that has one, sortable/filterable her own way, with
// "hide blocked (open dependency)" as the headline filter and a rank
// column for how many other open issues depend on each one. Backed by
// swarm-controller's `GET /api/repos` (dropdown source, only repos with
// an open issue), `GET /api/issue-report` (default — every repo
// combined) and `GET /api/repos/{org}/{repo}/issue-report` (once a
// specific repo is picked) — `blocked`, `depended_on_by_count` and
// `transitively_blocks_count` all arrive pre-resolved per row, so
// nothing here does its own dependency-graph walk.
//
// Sorting and the label/hide-blocked filters are client-side over
// whatever's currently loaded — only the repo selection re-fetches.
// Default sort is `depended_on_by_count` descending: mara's own framing
// ("i would also like to rank them by how many issues depend on them")
// reads as the report's headline ordering, not just one more column.
//
// No `RefreshIntervalPicker`/auto-poll like the roster pages — this
// reads as a "generate a report" action rather than a live status view
// (mara: a "generating report" spinner sub-5s is fine, doesn't need to
// be instant), so it fetches once per repo-selection change instead of
// polling.
//
// Sort/filter state persists via the shared `useLocalSetting` hook (same
// plumbing the theme/motion overrides use) — mara: "local storage" over
// URL params. `labelFilter` is `string[]`, not `Set<string>`: a `Set`
// serializes to `"{}"` through `JSON.stringify` and silently loses its
// contents, which is exactly what this hook round-trips through.
import { useEffect, useMemo, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import { Panel } from "../ui/panel/Panel.js";
import { SelectField } from "../ui/select-field/SelectField.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import "./IssueReportPage.css";
interface IssueReportRow {
repo: string;
number: number;
title: string;
labels: string[];
// `string[]`, not a single `assignee` — forge's own `assignee` field is
// the legacy single-value one; `assignees` is the real multi-assignee
// list, and this repo actually uses multiple (caught against an
// earlier draft of this row shape that had it singular).
assignees: string[];
html_url: string | null;
blocked: boolean;
depended_on_by_count: number;
transitively_blocks_count: number;
}
type SortKey =
| "repo"
| "number"
| "title"
| "assignees"
| "blocked"
| "depended_on_by_count"
| "transitively_blocks_count";
type SortDir = "asc" | "desc";
// Sentinel `<select>` value for "every repo" — `''` can't collide with a
// real `owner/name` value, which always contains a slash.
const ALL_REPOS = "";
// One key per persisted control, namespaced like the theme/motion keys
// (`swarm-ui:issue-report:…`) so nothing else on the page — or a future
// page — collides with these by accident.
const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter";
const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked";
const LABEL_FILTER_KEY = "swarm-ui:issue-report:label-filter";
const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key";
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
function splitRepo(repo: string): { org: string; name: string } | null {
const i = repo.indexOf("/");
if (i < 0) return null;
return { org: repo.slice(0, i), name: repo.slice(i + 1) };
}
function compareRows(
a: IssueReportRow,
b: IssueReportRow,
key: SortKey,
): number {
switch (key) {
case "repo":
return a.repo.localeCompare(b.repo);
case "number":
return a.number - b.number;
case "title":
return a.title.localeCompare(b.title);
case "assignees":
return a.assignees.join(", ").localeCompare(b.assignees.join(", "));
case "blocked":
return Number(a.blocked) - Number(b.blocked);
case "depended_on_by_count":
return a.depended_on_by_count - b.depended_on_by_count;
case "transitively_blocks_count":
return a.transitively_blocks_count - b.transitively_blocks_count;
}
}
function SortHeader({
label,
sortKey,
activeKey,
dir,
onSort,
}: {
label: string;
sortKey: SortKey;
activeKey: SortKey;
dir: SortDir;
onSort: (key: SortKey) => void;
}) {
const active = sortKey === activeKey;
return (
<button
type="button"
class="issue-report-sort-btn"
onClick={() => onSort(sortKey)}
>
{label}
{active ? (
<span aria-hidden="true"> {dir === "asc" ? "▲" : "▼"}</span>
) : null}
</button>
);
}
export function IssueReportPage() {
const [repos, setRepos] = useState<string[] | null>(null);
const [repoFilter, setRepoFilter] = useLocalSetting(
REPO_FILTER_KEY,
ALL_REPOS,
);
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [loading, setLoading] = useState(false);
const [hideBlocked, setHideBlocked] = useLocalSetting(
HIDE_BLOCKED_KEY,
false,
);
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
LABEL_FILTER_KEY,
[],
);
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
SORT_KEY_KEY,
"depended_on_by_count",
);
const [sortDir, setSortDir] = useLocalSetting<SortDir>(SORT_DIR_KEY, "desc");
// Repo dropdown source, fetched once — this page has no refresh
// cadence, and a repo gaining/losing its first/last open issue between
// visits is rare enough that a manual reload covers it. Same
// `cancelled` guard as the repo-filter effect below, so an unmount
// mid-flight (navigating away before this resolves) doesn't call
// `setRepos`/`setError` on a component that's already gone.
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch("/api/repos");
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as string[];
if (!cancelled) setRepos(data);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, []);
// The one fetch this page re-runs on state change — everything else
// (sort, hide-blocked, label filter) works over what's already loaded.
useEffect(() => {
let cancelled = false;
setLoading(true);
const split = repoFilter ? splitRepo(repoFilter) : null;
const url = split
? `/api/repos/${split.org}/${split.name}/issue-report`
: "/api/issue-report";
(async () => {
const r = await fetch(url);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as IssueReportRow[];
if (cancelled) return;
setRows(data);
setError(null);
})()
.catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [repoFilter]);
// Label choices are derived from whatever's currently loaded, not a
// separate endpoint — naturally scoped to "all repos" or one repo
// depending on the current selection, and never asks for a label that
// isn't actually present in the report.
const allLabels = useMemo(() => {
const s = new Set<string>();
for (const row of rows ?? []) for (const l of row.labels) s.add(l);
return [...s].sort();
}, [rows]);
const visibleRows = useMemo(() => {
let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked);
if (labelFilter.length > 0)
out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey);
return sortDir === "asc" ? c : -c;
});
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]);
function onSort(key: SortKey) {
if (key === sortKey) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir("asc");
}
}
function toggleLabel(label: string) {
setLabelFilter(
labelFilter.includes(label)
? labelFilter.filter((l) => l !== label)
: [...labelFilter, label],
);
}
// `aria-sort` for a sortable column's `<th>` — 'none' while sortable
// but not the active column, the real direction while it is. Screen
// readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden`
// and carries no information without it.
function ariaSortFor(key: SortKey): "ascending" | "descending" | "none" {
if (sortKey !== key) return "none";
return sortDir === "asc" ? "ascending" : "descending";
}
const columns: TableColumn<IssueReportRow>[] = [
// Shown even with one repo selected (every row shares the value) —
// a fixed column set means the table's shape doesn't shift under
// sort/filter state, and it's a free confirmation of what's loaded.
{
key: "repo",
header: (
<SortHeader
label="repo"
sortKey="repo"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("repo"),
render: (r) => r.repo,
},
{
key: "number",
header: (
<SortHeader
label="issue"
sortKey="number"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("number"),
render: (r) =>
r.html_url ? (
<a href={r.html_url} target="_blank" rel="noreferrer">
#{r.number}
</a>
) : (
`#${r.number}`
),
},
{
key: "title",
header: (
<SortHeader
label="title"
sortKey="title"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("title"),
render: (r) => r.title,
filterValue: (r) => r.title,
},
{
key: "labels",
header: "labels",
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
},
{
key: "assignees",
header: (
<SortHeader
label="assignees"
sortKey="assignees"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("assignees"),
render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"),
// Text, not `"select"` — a row can carry more than one assignee,
// and `Table`'s select mode matches one whole string per row
// exactly, so a real per-assignee dropdown would need `Table`
// itself to grow multi-value filtering. Substring search over
// the joined string still finds "iris" inside "damocles, iris"
// correctly, which covers the actual gap (no way to filter by
// assignee at all today).
filterValue: (r) => r.assignees.join(", "),
},
{
key: "blocked",
header: (
<SortHeader
label="blocked"
sortKey="blocked"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("blocked"),
render: (r) =>
r.blocked ? <Badge tone="warning" value="blocked" /> : "—",
// A distinct gap from the existing "hide blocked" toggle above:
// that one only *hides* blocked issues, there was no way to see
// *only* the blocked ones. Synthesized two-value string (not the
// raw boolean) — `Table`'s select mode needs a string to match,
// same as `AgentsPage`'s `wanted ?? "no declaration"` pattern.
filterValue: (r) => (r.blocked ? "blocked" : "not blocked"),
filterMode: "select",
},
{
key: "depended_on_by_count",
header: (
<SortHeader
label="depended on by"
sortKey="depended_on_by_count"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("depended_on_by_count"),
render: (r) => r.depended_on_by_count,
},
{
key: "transitively_blocks_count",
header: (
<SortHeader
label="transitively blocks"
sortKey="transitively_blocks_count"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("transitively_blocks_count"),
render: (r) => r.transitively_blocks_count,
},
];
return (
<Panel title="issue report" icon="📋">
<div class="issue-report-controls">
<SelectField
id="issue-report-repo"
label="repo"
value={repoFilter}
onChange={setRepoFilter}
options={[
{ value: ALL_REPOS, label: "all repos" },
...(repos ?? []).map((r) => ({ value: r, label: r })),
]}
/>
<label class="issue-report-toggle">
<input
type="checkbox"
checked={hideBlocked}
onChange={(e) =>
setHideBlocked((e.target as HTMLInputElement).checked)
}
/>
hide blocked (open dependency)
</label>
</div>
{allLabels.length ? (
<div class="issue-report-labels">
{allLabels.map((l) => (
<label key={l} class="issue-report-label-chip">
<input
type="checkbox"
checked={labelFilter.includes(l)}
onChange={() => toggleLabel(l)}
/>
{l}
</label>
))}
</div>
) : null}
{error ? (
<ApiErrorPanel
context="failed to load the issue report"
problem={error}
/>
) : null}
{!error && loading ? <p>generating report</p> : null}
{!loading && rows ? (
<Table
columns={columns}
rows={visibleRows}
rowKey={(r) => `${r.repo}#${r.number}`}
emptyMessage="no issues match the current filters"
/>
) : null}
</Panel>
);
}