swarm-ui: persist table filters, add reset button, multi-select, negate
Per mara's issue: tables should remember their filters (with a reset
button), the labels filter should be multi-select, and any filter should
support negation (search vs exclude).
All in the shared Table component (ui/table/Table.tsx), used by
AgentsPage/HivesPage/IssueReportPage:
- filters now persist via the same useLocalSetting hook IssueReportPage
already used for its own state, keyed by a new required storageKey
prop (required, not optional, so no caller can forget it and every
table gets persistence for free)
- a small 'reset filters' button clears every column's filter at once,
shown only when at least one is active
- new filterMode: "multiselect" (+ a filterValues extractor, alongside
the existing single-value filterValue) renders a checkbox list and
matches on any overlap - IssueReportPage's own bespoke label-checkbox
sidebar is folded into this instead of staying a second, separate
filter mechanism
- a negate toggle ('exclude') sits under every filter mode's control,
applying uniformly to text/select/multiselect
Verified: tsc --noEmit and the esbuild bundle both clean.
This commit is contained in:
parent
948525de09
commit
915c6c6f92
7 changed files with 386 additions and 197 deletions
|
|
@ -482,6 +482,7 @@ export function AgentsPage() {
|
|||
rows={rows}
|
||||
rowKey={(a) => a.name}
|
||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
||||
storageKey="swarm-ui:agents:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -223,10 +223,16 @@ export function ComponentsPage() {
|
|||
columns={TABLE_COLUMNS}
|
||||
rows={TABLE_ROWS}
|
||||
rowKey={(r) => r.name}
|
||||
storageKey="swarm-ui:components-demo:table-filters"
|
||||
/>
|
||||
</Sample>
|
||||
<Sample label="empty">
|
||||
<Table columns={TABLE_COLUMNS} rows={[]} rowKey={(r) => r.name} />
|
||||
<Table
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={[]}
|
||||
rowKey={(r) => r.name}
|
||||
storageKey="swarm-ui:components-demo:table-filters-empty"
|
||||
/>
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,12 @@ export function HivesPage() {
|
|||
) : null}
|
||||
{!error && hives === null ? <p>loading…</p> : null}
|
||||
{hives ? (
|
||||
<Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} />
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
rows={hives}
|
||||
rowKey={(h) => h.name}
|
||||
storageKey="swarm-ui:hives:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/* <IssueReportPage> — the repo-picker + hide-blocked toggle sit in one
|
||||
row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow
|
||||
viewports stack instead of overflowing), the label chips get their
|
||||
own row below since the set is open-ended and shouldn't fight the
|
||||
controls row for width. Sort buttons live inside the table's own
|
||||
`<th>` cells (Table.tsx's `header` now accepts real markup for
|
||||
exactly this) so they inherit the table's header styling for free —
|
||||
only the pointer cursor + no-underline reset is this page's to own. */
|
||||
viewports stack instead of overflowing). The label filter used to
|
||||
have its own chip row here; folded into the "labels" column's own
|
||||
`Table` filter (`ui-table-filter-multiselect` in Table.css) instead,
|
||||
so there's nothing page-specific left to style for it. Sort buttons
|
||||
live inside the table's own `<th>` cells (Table.tsx's `header` now
|
||||
accepts real markup for exactly this) so they inherit the table's
|
||||
header styling for free — only the pointer cursor + no-underline
|
||||
reset is this page's to own. */
|
||||
.issue-report-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -19,19 +21,6 @@
|
|||
gap: 0.4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.issue-report-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.issue-report-label-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.issue-report-sort-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
// `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.
|
||||
// Sorting and the hide-blocked/label/etc. filters are all 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.
|
||||
|
|
@ -21,11 +21,12 @@
|
|||
// be instant), so it fetches once per repo-selection change instead of
|
||||
// polling.
|
||||
//
|
||||
// Sort/filter state persists via the shared `useLocalSetting` hook (same
|
||||
// Sort 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.
|
||||
// URL params. The label filter used to be this page's own bespoke
|
||||
// checkbox row — folded into the "labels" column's own `Table` filter
|
||||
// (`filterMode: "multiselect"`) per mara's "fold filters into table"
|
||||
// request, one mechanism instead of two.
|
||||
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";
|
||||
|
|
@ -71,9 +72,12 @@ const ALL_REPOS = "";
|
|||
// 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";
|
||||
// `Table`'s own filter state (repo/labels/etc. are handled separately
|
||||
// above/below — this key is just for the generic per-column filters,
|
||||
// e.g. title/assignees/blocked).
|
||||
const TABLE_FILTERS_KEY = "swarm-ui:issue-report:table-filters";
|
||||
|
||||
function splitRepo(repo: string): { org: string; name: string } | null {
|
||||
const i = repo.indexOf("/");
|
||||
|
|
@ -145,10 +149,6 @@ export function IssueReportPage() {
|
|||
HIDE_BLOCKED_KEY,
|
||||
false,
|
||||
);
|
||||
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
|
||||
LABEL_FILTER_KEY,
|
||||
[],
|
||||
);
|
||||
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
|
||||
SORT_KEY_KEY,
|
||||
"depended_on_by_count",
|
||||
|
|
@ -180,7 +180,8 @@ export function IssueReportPage() {
|
|||
}, []);
|
||||
|
||||
// The one fetch this page re-runs on state change — everything else
|
||||
// (sort, hide-blocked, label filter) works over what's already loaded.
|
||||
// (sort, hide-blocked, the table's own per-column filters) works over
|
||||
// what's already loaded.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
|
@ -210,26 +211,14 @@ export function IssueReportPage() {
|
|||
};
|
||||
}, [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]);
|
||||
}, [rows, hideBlocked, sortKey, sortDir]);
|
||||
|
||||
function onSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
|
|
@ -240,14 +229,6 @@ export function IssueReportPage() {
|
|||
}
|
||||
}
|
||||
|
||||
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`
|
||||
|
|
@ -315,6 +296,8 @@ export function IssueReportPage() {
|
|||
key: "labels",
|
||||
header: "labels",
|
||||
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
||||
filterValues: (r) => r.labels,
|
||||
filterMode: "multiselect",
|
||||
},
|
||||
{
|
||||
key: "assignees",
|
||||
|
|
@ -414,20 +397,6 @@ export function IssueReportPage() {
|
|||
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"
|
||||
|
|
@ -441,6 +410,7 @@ export function IssueReportPage() {
|
|||
rows={visibleRows}
|
||||
rowKey={(r) => `${r.repo}#${r.number}`}
|
||||
emptyMessage="no issues match the current filters"
|
||||
storageKey={TABLE_FILTERS_KEY}
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
|
|
|||
Loading…
Reference in a new issue