issue-report: persist sort/filter state to localStorage
This commit is contained in:
parent
361121c7f6
commit
8a3766c60b
1 changed files with 27 additions and 14 deletions
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue