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
// 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';
@ -59,6 +66,15 @@ type SortDir = 'asc' | 'desc';
// 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;
@ -108,14 +124,14 @@ function SortHeader({
export function IssueReportPage() {
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 [error, setError] = useState<ProblemDetails | null>(null);
const [loading, setLoading] = useState(false);
const [hideBlocked, setHideBlocked] = useState(false);
const [labelFilter, setLabelFilter] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('depended_on_by_count');
const [sortDir, setSortDir] = useState<SortDir>('desc');
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
@ -183,7 +199,7 @@ export function IssueReportPage() {
const visibleRows = useMemo(() => {
let out = rows ?? [];
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) => {
const c = compareRows(a, b, sortKey);
return sortDir === 'asc' ? c : -c;
@ -192,7 +208,7 @@ export function IssueReportPage() {
function onSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
@ -200,12 +216,9 @@ export function IssueReportPage() {
}
function toggleLabel(label: string) {
setLabelFilter((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
setLabelFilter(
labelFilter.includes(label) ? labelFilter.filter((l) => l !== label) : [...labelFilter, label],
);
}
// `aria-sort` for a sortable column's `<th>` — 'none' while sortable
@ -321,7 +334,7 @@ export function IssueReportPage() {
<div class="issue-report-labels">
{allLabels.map((l) => (
<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}
</label>
))}