issue-report: persist sort/filter state to localStorage

This commit is contained in:
damocles 2026-08-31 22:40:13 +02:00
commit 8a3766c60b

View file

@ -20,10 +20,17 @@
// (mara: a "generating report" spinner sub-5s is fine, doesn't need to // (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 // be instant), so it fetches once per repo-selection change instead of
// polling. // 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 { useEffect, useMemo, useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js'; import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Badge } from '@hive/shared/badge.js'; import { Badge } from '@hive/shared/badge.js';
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';
@ -59,6 +66,15 @@ type SortDir = 'asc' | 'desc';
// real `owner/name` value, which always contains a slash. // real `owner/name` value, which always contains a slash.
const ALL_REPOS = ''; 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 { function splitRepo(repo: string): { org: string; name: string } | null {
const i = repo.indexOf('/'); const i = repo.indexOf('/');
if (i < 0) return null; if (i < 0) return null;
@ -108,14 +124,14 @@ function SortHeader({
export function IssueReportPage() { export function IssueReportPage() {
const [repos, setRepos] = useState<string[] | null>(null); const [repos, setRepos] = useState<string[] | null>(null);
const [repoFilter, setRepoFilter] = useState(ALL_REPOS); const [repoFilter, setRepoFilter] = useLocalSetting(REPO_FILTER_KEY, ALL_REPOS);
const [rows, setRows] = useState<IssueReportRow[] | null>(null); const [rows, setRows] = useState<IssueReportRow[] | 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] = useState(false); const [hideBlocked, setHideBlocked] = useLocalSetting(HIDE_BLOCKED_KEY, false);
const [labelFilter, setLabelFilter] = useState<Set<string>>(new Set()); const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(LABEL_FILTER_KEY, []);
const [sortKey, setSortKey] = useState<SortKey>('depended_on_by_count'); const [sortKey, setSortKey] = useLocalSetting<SortKey>(SORT_KEY_KEY, 'depended_on_by_count');
const [sortDir, setSortDir] = useState<SortDir>('desc'); const [sortDir, setSortDir] = useLocalSetting<SortDir>(SORT_DIR_KEY, 'desc');
// Repo dropdown source, fetched once — this page has no refresh // Repo dropdown source, fetched once — this page has no refresh
// cadence, and a repo gaining/losing its first/last open issue between // cadence, and a repo gaining/losing its first/last open issue between
@ -183,7 +199,7 @@ export function IssueReportPage() {
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
let out = rows ?? []; let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked); if (hideBlocked) out = out.filter((r) => !r.blocked);
if (labelFilter.size > 0) out = out.filter((r) => r.labels.some((l) => labelFilter.has(l))); if (labelFilter.length > 0) out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
return [...out].sort((a, b) => { return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey); const c = compareRows(a, b, sortKey);
return sortDir === 'asc' ? c : -c; return sortDir === 'asc' ? c : -c;
@ -192,7 +208,7 @@ export function IssueReportPage() {
function onSort(key: SortKey) { function onSort(key: SortKey) {
if (key === sortKey) { if (key === sortKey) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else { } else {
setSortKey(key); setSortKey(key);
setSortDir('asc'); setSortDir('asc');
@ -200,12 +216,9 @@ export function IssueReportPage() {
} }
function toggleLabel(label: string) { function toggleLabel(label: string) {
setLabelFilter((prev) => { setLabelFilter(
const next = new Set(prev); labelFilter.includes(label) ? labelFilter.filter((l) => l !== label) : [...labelFilter, label],
if (next.has(label)) next.delete(label); );
else next.add(label);
return next;
});
} }
// `aria-sort` for a sortable column's `<th>` — 'none' while sortable // `aria-sort` for a sortable column's `<th>` — 'none' while sortable
@ -321,7 +334,7 @@ export function IssueReportPage() {
<div class="issue-report-labels"> <div class="issue-report-labels">
{allLabels.map((l) => ( {allLabels.map((l) => (
<label key={l} class="issue-report-label-chip"> <label key={l} class="issue-report-label-chip">
<input type="checkbox" checked={labelFilter.has(l)} onChange={() => toggleLabel(l)} /> <input type="checkbox" checked={labelFilter.includes(l)} onChange={() => toggleLabel(l)} />
{l} {l}
</label> </label>
))} ))}