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:
iris 2026-09-09 19:03:03 +02:00 committed by mara
commit 915c6c6f92
7 changed files with 386 additions and 197 deletions

View file

@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import "./Table.css";
export interface TableColumn<T> {
@ -65,6 +66,14 @@ export interface TableColumn<T> {
* `.ui-table-filter-icon` for the actual rule.
*/
filterValue?: (row: T) => string;
/**
* Like `filterValue`, but for a column whose row value is a *set*
* rather than one string (e.g. issue labels) required for and only
* consulted by `filterMode: "multiselect"`. A row matches when it
* shares at least one value with the operator's current selection
* (OR, not AND "has any of these labels", not "has all of them").
*/
filterValues?: (row: T) => string[];
/**
* `"text"` (default): free substring, case-insensitive `<input>`
* today's only behavior, unaffected if you don't set this.
@ -78,8 +87,38 @@ export interface TableColumn<T> {
* live rows rather than a hardcoded enum means the list never offers
* a choice that would match zero rows. Ignored unless `filterValue`
* is also set.
* `"multiselect"`: a checkbox list, same live-rows-derived option
* source as `"select"` but over `filterValues` and matching on any
* overlap rather than one exact value for a column whose row value
* is itself a set (e.g. labels). Ignored unless `filterValues` is set.
*/
filterMode?: "text" | "select";
filterMode?: "text" | "select" | "multiselect";
}
// One filter's full state: `value` for `"text"`/`"select"`, `values` for
// `"multiselect"` (the other is left at its default and ignored), plus a
// `negate` flag consulted regardless of mode (mara: "you should be able
// to change if the filter is negated or not — search for this text vs
// exclude it"). A column with no entry here behaves identically to the
// all-defaults entry below — `getFilter` returns this same shape either
// way, so callers never need an `undefined` branch.
interface ColumnFilterState {
value: string;
values: string[];
negate: boolean;
}
const EMPTY_FILTER: ColumnFilterState = {
value: "",
values: [],
negate: false,
};
function isFilterActive(f: ColumnFilterState | undefined): boolean {
return f !== undefined && (f.value !== "" || f.values.length > 0);
}
function isFilterable<T>(c: TableColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
type SortDir = "asc" | "desc";
@ -97,6 +136,7 @@ export function Table<T>({
rows,
rowKey,
emptyMessage,
storageKey,
}: {
columns: TableColumn<T>[];
rows: T[];
@ -107,14 +147,27 @@ export function Table<T>({
// table entirely) has nothing useful to add here, so this stays
// opt-in rather than every table growing a mandatory default string.
emptyMessage?: ComponentChildren;
// Namespaces this table's filter state in `localStorage` (mara:
// "tables should remember their filters in general") — required, not
// optional, so every table gets persistence for free and no caller can
// forget it; a stray collision between two tables sharing a key is a
// caller bug worth a distinct, unique string, same as any other
// storage key in this codebase (see `IssueReportPage`'s own
// `swarm-ui:issue-report:…` keys). Sort intentionally stays
// `useState` below, not persisted — only asked for filters.
storageKey: string;
}) {
// One active sort column at a time, same as any spreadsheet — a
// multi-column sort is real complexity (tie-break order, a UI to
// express it) nothing here has asked for yet.
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
// Filter text per filterable column key, only populated for a column
// whose input the operator has actually typed into.
const [filters, setFilters] = useState<Record<string, string>>({});
// Filter state per filterable column key, only populated for a column
// the operator has actually touched. Persisted (see `storageKey`
// above), same `useLocalSetting` plumbing `IssueReportPage` already
// used for its own now-folded-in label filter.
const [filters, setFilters] = useLocalSetting<
Record<string, ColumnFilterState>
>(storageKey, {});
// Which column's filter popover is open, if any — at most one at a
// time (opening a second closes the first) so the header row never
// shows more than one panel at once.
@ -173,7 +226,33 @@ export function Table<T>({
};
}, [openFilterKey]);
const activeFilters = Object.entries(filters).filter(([, v]) => v !== "");
const activeFilters = Object.entries(filters).filter(([, f]) =>
isFilterActive(f),
);
function getFilter(key: string): ColumnFilterState {
return filters[key] ?? EMPTY_FILTER;
}
function updateFilter(key: string, patch: Partial<ColumnFilterState>) {
setFilters({
...filters,
[key]: { ...getFilter(key), ...patch },
});
}
function toggleFilterValue(key: string, value: string) {
const current = getFilter(key).values;
updateFilter(key, {
values: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
});
}
// mara: "have a small reset filters btn" — one button clears every
// column's filter at once rather than hunting down each popover
// individually.
function resetFilters() {
setFilters({});
}
// Close on outside click or Escape — same contract `Dropdown` gives
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
@ -205,11 +284,20 @@ export function Table<T>({
let out = rows;
if (activeFilters.length > 0) {
out = out.filter((row) =>
activeFilters.every(([key, query]) => {
activeFilters.every(([key, f]) => {
const col = columns.find((c) => c.key === key);
const value = col?.filterValue?.(row) ?? "";
if (col?.filterMode === "select") return value === query;
return value.toLowerCase().includes(query.toLowerCase());
let matches: boolean;
if (col?.filterMode === "multiselect") {
const rowValues = col.filterValues?.(row) ?? [];
matches = f.values.some((v) => rowValues.includes(v));
} else if (col?.filterMode === "select") {
const value = col.filterValue?.(row) ?? "";
matches = value === f.value;
} else {
const value = col?.filterValue?.(row) ?? "";
matches = value.toLowerCase().includes(f.value.toLowerCase());
}
return f.negate ? !matches : matches;
}),
);
}
@ -260,6 +348,17 @@ export function Table<T>({
);
}
// Same reasoning as `selectOptionsFor` above, over `filterValues`
// (a row contributes every one of its own values, not just one).
function multiselectOptionsFor(c: TableColumn<T>): string[] {
if (!c.filterValues) return [];
const values = new Set<string>();
for (const row of rows) for (const v of c.filterValues(row)) values.add(v);
return Array.from(values).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
);
}
// Only one popover is ever open at a time, so one ref (rather than a
// per-column ref map) is enough to focus whichever control just
// mounted — typing immediately after the click that opened it,
@ -282,138 +381,200 @@ export function Table<T>({
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
}
function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "select") {
return (
<select
ref={setPopoverControlRef}
class="ui-table-filter-select"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
// mara: "you should be able to change if the filter is negated or not
// (eg search for this text vs exclude it)" — one toggle, consulted the
// same way regardless of which control above it produced the match.
function renderNegateToggle(c: TableColumn<T>) {
const f = getFilter(c.key);
return (
<input
ref={setPopoverControlRef}
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={filterLabel(c)}
value={filters[c.key] ?? ""}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
setFilters((prev) => ({ ...prev, [c.key]: v }));
}}
/>
<label class="ui-table-filter-negate">
<input
type="checkbox"
checked={f.negate}
onChange={(e) =>
updateFilter(c.key, {
negate: (e.target as HTMLInputElement).checked,
})
}
/>
exclude
</label>
);
}
function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "multiselect") {
const f = getFilter(c.key);
return (
<>
<div class="ui-table-filter-multiselect">
{multiselectOptionsFor(c).map((v) => (
<label key={v} class="ui-table-filter-checkbox">
<input
type="checkbox"
checked={f.values.includes(v)}
onChange={() => toggleFilterValue(c.key, v)}
/>
{v}
</label>
))}
</div>
{renderNegateToggle(c)}
</>
);
}
if (c.filterMode === "select") {
return (
<>
<select
ref={setPopoverControlRef}
class="ui-table-filter-select"
aria-label={filterLabel(c)}
value={getFilter(c.key).value}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
updateFilter(c.key, { value: v });
}}
>
<option value="">any</option>
{selectOptionsFor(c).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{renderNegateToggle(c)}
</>
);
}
return (
<>
<input
ref={setPopoverControlRef}
type="text"
class="ui-table-filter-input"
placeholder="filter…"
aria-label={filterLabel(c)}
value={getFilter(c.key).value}
onInput={(e) => {
const v = (e.target as HTMLInputElement).value;
updateFilter(c.key, { value: v });
}}
/>
{renderNegateToggle(c)}
</>
);
}
const anyActiveFilter = activeFilters.length > 0;
return (
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
{columns.map((c) => {
const hasFilterValue = (filters[c.key] ?? "") !== "";
const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? (
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
) : (
c.header
);
return (
<th
key={c.key}
aria-sort={ariaSortFor(c)}
class={c.filterValue ? "ui-table-th-filterable" : undefined}
ref={(el) => {
if (!c.filterValue) return;
if (el) thRefs.current.set(c.key, el);
else thRefs.current.delete(c.key);
}}
>
{headerContent}
{c.filterValue ? (
<button
type="button"
class={
"ui-table-filter-icon" +
(filterOpen || hasFilterValue
? " ui-table-filter-icon-active"
: "")
}
aria-label={filterLabel(c)}
aria-expanded={filterOpen}
onClick={() => {
setOpenFilterKey((k) => {
const next = k === c.key ? null : c.key;
if (next !== null) computePopoverPos(next);
return next;
});
}}
>
<FilterIcon />
</button>
) : null}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (
<>
{anyActiveFilter ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
<div class="ui-table-scroll">
<table class="ui-table">
<thead>
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
{emptyMessage}
</td>
{columns.map((c) => {
const hasFilterValue = isFilterActive(filters[c.key]);
const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? (
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
) : (
c.header
);
return (
<th
key={c.key}
aria-sort={ariaSortFor(c)}
class={
isFilterable(c) ? "ui-table-th-filterable" : undefined
}
ref={(el) => {
if (!isFilterable(c)) return;
if (el) thRefs.current.set(c.key, el);
else thRefs.current.delete(c.key);
}}
>
{headerContent}
{isFilterable(c) ? (
<button
type="button"
class={
"ui-table-filter-icon" +
(filterOpen || hasFilterValue
? " ui-table-filter-icon-active"
: "")
}
aria-label={filterLabel(c)}
aria-expanded={filterOpen}
onClick={() => {
setOpenFilterKey((k) => {
const next = k === c.key ? null : c.key;
if (next !== null) computePopoverPos(next);
return next;
});
}}
>
<FilterIcon />
</button>
) : null}
</th>
);
})}
</tr>
) : rows.length > 0 && visibleRows.length === 0 ? (
// Distinct from `emptyMessage` — real rows exist, the active
// filter(s) just matched none of them, not "there's nothing
// here at all".
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
no rows match the current filter
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((c) => (
<td key={c.key} class={c.cellClass}>
{c.render(row)}
</td>
))}
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
{emptyMessage}
</td>
</tr>
))
)}
</tbody>
</table>
) : rows.length > 0 && visibleRows.length === 0 ? (
// Distinct from `emptyMessage` — real rows exist, the active
// filter(s) just matched none of them, not "there's nothing
// here at all".
<tr>
<td class="ui-table-empty" colSpan={columns.length}>
no rows match the current filter
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((c) => (
<td key={c.key} class={c.cellClass}>
{c.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{openFilterKey !== null && popoverPos
? createPortal(
(() => {
@ -437,6 +598,6 @@ export function Table<T>({
document.body,
)
: null}
</div>
</>
);
}