swarm-ui: keep the issue report's table, errors and repo filter consistent
A failed report dropped its error under the previous repo's rows, the repo list and the report shared one error slot, a remembered repo that the list lacked left the select on all repos, and the report URL did not encode the repo path. Each request now has its own error, a failed report clears the rows, the remembered repo keeps an option, and the path parts are encoded.
This commit is contained in:
parent
e727774dc8
commit
f6acc1e976
3 changed files with 121 additions and 24 deletions
|
|
@ -35,6 +35,7 @@ import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
||||||
import { Panel } from "../ui/panel/Panel.js";
|
import { Panel } from "../ui/panel/Panel.js";
|
||||||
import { SelectField } from "../ui/select-field/SelectField.js";
|
import { SelectField } from "../ui/select-field/SelectField.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||||
|
import { ALL_REPOS, reportUrl, repoOptions } from "./issue-report-repo.js";
|
||||||
import "./IssueReportPage.css";
|
import "./IssueReportPage.css";
|
||||||
|
|
||||||
interface IssueReportRow {
|
interface IssueReportRow {
|
||||||
|
|
@ -63,10 +64,6 @@ type SortKey =
|
||||||
| "transitively_blocks_count";
|
| "transitively_blocks_count";
|
||||||
type SortDir = "asc" | "desc";
|
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
|
// One key per persisted control, namespaced like the theme/motion keys
|
||||||
// (`swarm-ui:issue-report:…`) so nothing else on the page — or a future
|
// (`swarm-ui:issue-report:…`) so nothing else on the page — or a future
|
||||||
// page — collides with these by accident.
|
// page — collides with these by accident.
|
||||||
|
|
@ -79,12 +76,6 @@ const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
|
||||||
// e.g. title/assignees/blocked).
|
// e.g. title/assignees/blocked).
|
||||||
const TABLE_FILTERS_KEY = "swarm-ui:issue-report:table-filters";
|
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(
|
function compareRows(
|
||||||
a: IssueReportRow,
|
a: IssueReportRow,
|
||||||
b: IssueReportRow,
|
b: IssueReportRow,
|
||||||
|
|
@ -143,6 +134,9 @@ export function IssueReportPage() {
|
||||||
ALL_REPOS,
|
ALL_REPOS,
|
||||||
);
|
);
|
||||||
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
|
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
|
||||||
|
// One slot per request, so a report that loads does not clear a repo
|
||||||
|
// list that failed.
|
||||||
|
const [reposError, setReposError] = useState<ProblemDetails | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hideBlocked, setHideBlocked] = useLocalSetting(
|
const [hideBlocked, setHideBlocked] = useLocalSetting(
|
||||||
|
|
@ -160,19 +154,19 @@ export function IssueReportPage() {
|
||||||
// visits is rare enough that a manual reload covers it. Same
|
// visits is rare enough that a manual reload covers it. Same
|
||||||
// `cancelled` guard as the repo-filter effect below, so an unmount
|
// `cancelled` guard as the repo-filter effect below, so an unmount
|
||||||
// mid-flight (navigating away before this resolves) doesn't call
|
// mid-flight (navigating away before this resolves) doesn't call
|
||||||
// `setRepos`/`setError` on a component that's already gone.
|
// `setRepos`/`setReposError` on a component that's already gone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const r = await fetch("/api/repos");
|
const r = await fetch("/api/repos");
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (!cancelled) setError(await readApiError(r));
|
if (!cancelled) setReposError(await readApiError(r));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await r.json()) as string[];
|
const data = (await r.json()) as string[];
|
||||||
if (!cancelled) setRepos(data);
|
if (!cancelled) setRepos(data);
|
||||||
})().catch((e: unknown) => {
|
})().catch((e: unknown) => {
|
||||||
if (!cancelled) setError({ detail: String(e) });
|
if (!cancelled) setReposError({ detail: String(e) });
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -185,14 +179,17 @@ export function IssueReportPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const split = repoFilter ? splitRepo(repoFilter) : null;
|
// A failed report drops the rows, so the table never shows another
|
||||||
const url = split
|
// repo's issues under this repo's error.
|
||||||
? `/api/repos/${split.org}/${split.name}/issue-report`
|
const fail = (problem: ProblemDetails) => {
|
||||||
: "/api/issue-report";
|
setRows(null);
|
||||||
|
setError(problem);
|
||||||
|
};
|
||||||
(async () => {
|
(async () => {
|
||||||
const r = await fetch(url);
|
const r = await fetch(reportUrl(repoFilter));
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (!cancelled) setError(await readApiError(r));
|
const problem = await readApiError(r);
|
||||||
|
if (!cancelled) fail(problem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await r.json()) as IssueReportRow[];
|
const data = (await r.json()) as IssueReportRow[];
|
||||||
|
|
@ -201,7 +198,7 @@ export function IssueReportPage() {
|
||||||
setError(null);
|
setError(null);
|
||||||
})()
|
})()
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
if (!cancelled) setError({ detail: String(e) });
|
if (!cancelled) fail({ detail: String(e) });
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
|
|
@ -379,10 +376,7 @@ export function IssueReportPage() {
|
||||||
label="repo"
|
label="repo"
|
||||||
value={repoFilter}
|
value={repoFilter}
|
||||||
onChange={setRepoFilter}
|
onChange={setRepoFilter}
|
||||||
options={[
|
options={repoOptions(repos, repoFilter)}
|
||||||
{ value: ALL_REPOS, label: "all repos" },
|
|
||||||
...(repos ?? []).map((r) => ({ value: r, label: r })),
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<label class="issue-report-toggle">
|
<label class="issue-report-toggle">
|
||||||
<input
|
<input
|
||||||
|
|
@ -395,6 +389,12 @@ export function IssueReportPage() {
|
||||||
hide blocked (open dependency)
|
hide blocked (open dependency)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{reposError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="failed to load the repo list"
|
||||||
|
problem={reposError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
context="failed to load the issue report"
|
context="failed to load the issue report"
|
||||||
|
|
|
||||||
42
frontend/packages/swarm-ui/src/pages/issue-report-repo.ts
Normal file
42
frontend/packages/swarm-ui/src/pages/issue-report-repo.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Pure helpers for the issue report's repo filter: the report URL and the
|
||||||
|
// select's options. Kept out of the page so the cases are unit tested.
|
||||||
|
|
||||||
|
/** Select value for "every repo". It cannot collide with a real
|
||||||
|
* `owner/name`, which always has a slash. */
|
||||||
|
export const ALL_REPOS = "";
|
||||||
|
|
||||||
|
export interface RepoOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report URL for a repo filter. The path parts are encoded, so a name
|
||||||
|
* with `?`, `#` or `%` stays one path segment. A filter without a slash
|
||||||
|
* is not a repo, so it reads as all repos. */
|
||||||
|
export function reportUrl(repoFilter: string): string {
|
||||||
|
const i = repoFilter.indexOf("/");
|
||||||
|
if (i < 0) return "/api/issue-report";
|
||||||
|
const org = encodeURIComponent(repoFilter.slice(0, i));
|
||||||
|
const name = encodeURIComponent(repoFilter.slice(i + 1));
|
||||||
|
return `/api/repos/${org}/${name}/issue-report`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The select's options. A remembered repo that the list lacks keeps its
|
||||||
|
* own option, so the select shows what the table is filtered to. Once the
|
||||||
|
* list has loaded, the label says why the repo is not in it. */
|
||||||
|
export function repoOptions(
|
||||||
|
repos: string[] | null,
|
||||||
|
selected: string,
|
||||||
|
): RepoOption[] {
|
||||||
|
const options: RepoOption[] = [
|
||||||
|
{ value: ALL_REPOS, label: "all repos" },
|
||||||
|
...(repos ?? []).map((r) => ({ value: r, label: r })),
|
||||||
|
];
|
||||||
|
if (selected !== ALL_REPOS && !(repos ?? []).includes(selected)) {
|
||||||
|
options.push({
|
||||||
|
value: selected,
|
||||||
|
label: repos === null ? selected : `${selected} (no open issues)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
55
frontend/packages/swarm-ui/test/issue-report-repo.test.mjs
Normal file
55
frontend/packages/swarm-ui/test/issue-report-repo.test.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
ALL_REPOS,
|
||||||
|
reportUrl,
|
||||||
|
repoOptions,
|
||||||
|
} from "../src/pages/issue-report-repo.ts";
|
||||||
|
|
||||||
|
test("reportUrl: no repo means the combined report", () => {
|
||||||
|
assert.equal(reportUrl(ALL_REPOS), "/api/issue-report");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reportUrl: a filter without a slash is not a repo", () => {
|
||||||
|
assert.equal(reportUrl("nope"), "/api/issue-report");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reportUrl: org and name become two path segments", () => {
|
||||||
|
assert.equal(
|
||||||
|
reportUrl("hyperhive/hyperhive"),
|
||||||
|
"/api/repos/hyperhive/hyperhive/issue-report",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reportUrl: reserved characters in a name are encoded", () => {
|
||||||
|
assert.equal(
|
||||||
|
reportUrl("org/a?b#c%d"),
|
||||||
|
"/api/repos/org/a%3Fb%23c%25d/issue-report",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repoOptions: all repos comes first, then the list", () => {
|
||||||
|
assert.deepEqual(repoOptions(["a/x", "b/y"], ALL_REPOS), [
|
||||||
|
{ value: "", label: "all repos" },
|
||||||
|
{ value: "a/x", label: "a/x" },
|
||||||
|
{ value: "b/y", label: "b/y" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repoOptions: a listed selection adds nothing", () => {
|
||||||
|
assert.equal(repoOptions(["a/x"], "a/x").length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repoOptions: a remembered repo keeps an option before the list loads", () => {
|
||||||
|
assert.deepEqual(repoOptions(null, "a/x"), [
|
||||||
|
{ value: "", label: "all repos" },
|
||||||
|
{ value: "a/x", label: "a/x" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repoOptions: a remembered repo missing from the list says why", () => {
|
||||||
|
assert.deepEqual(repoOptions(["b/y"], "a/x").at(-1), {
|
||||||
|
value: "a/x",
|
||||||
|
label: "a/x (no open issues)",
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue