Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx

416 lines
13 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 hide-blocked/label/etc. filters are all 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 state persists via the shared `useLocalSetting` hook (same
// plumbing the theme/motion overrides use) — mara: "local storage" over
// URL params. The label filter used to be this page's own bespoke
// checkbox row — folded into the "labels" column's own `Table` filter
// (`filterMode: "multiselect"`) per mara's "fold filters into table"
// request, one mechanism instead of two.
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 SORT_KEY_KEY = "swarm-ui:issue-report:sort-key";
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
// `Table`'s own filter state (repo/labels/etc. are handled separately
// above/below — this key is just for the generic per-column filters,
// e.g. title/assignees/blocked).
const TABLE_FILTERS_KEY = "swarm-ui:issue-report:table-filters";
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 [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, the table's own per-column filters) 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]);
const visibleRows = useMemo(() => {
let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked);
return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey);
return sortDir === "asc" ? c : -c;
});
}, [rows, hideBlocked, sortKey, sortDir]);
function onSort(key: SortKey) {
if (key === sortKey) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir("asc");
}
}
// `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(", ") : "—"),
filterValues: (r) => r.labels,
filterMode: "multiselect",
},
{
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(", ") : "—"),
// Multiselect, same machinery the "labels" column above already
// uses — `r.assignees` is already the per-row array this mode
// wants, so there's no reason to fall back to a joined-string
// substring search here.
filterValues: (r) => r.assignees,
filterMode: "multiselect",
},
{
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 set (not the raw
// boolean) — multiselect, same as every other enum-shaped column,
// rather than the single-value `"select"` mode.
filterValues: (r) => [r.blocked ? "blocked" : "not blocked"],
filterMode: "multiselect",
},
{
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>
{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"
storageKey={TABLE_FILTERS_KEY}
/>
) : null}
</Panel>
);
}