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.
448 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|