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}
|
rows={rows}
|
||||||
rowKey={(a) => a.name}
|
rowKey={(a) => a.name}
|
||||||
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
|
||||||
|
storageKey="swarm-ui:agents:table-filters"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
|
||||||
|
|
@ -223,10 +223,16 @@ export function ComponentsPage() {
|
||||||
columns={TABLE_COLUMNS}
|
columns={TABLE_COLUMNS}
|
||||||
rows={TABLE_ROWS}
|
rows={TABLE_ROWS}
|
||||||
rowKey={(r) => r.name}
|
rowKey={(r) => r.name}
|
||||||
|
storageKey="swarm-ui:components-demo:table-filters"
|
||||||
/>
|
/>
|
||||||
</Sample>
|
</Sample>
|
||||||
<Sample label="empty">
|
<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>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,12 @@ export function HivesPage() {
|
||||||
) : null}
|
) : null}
|
||||||
{!error && hives === null ? <p>loading…</p> : null}
|
{!error && hives === null ? <p>loading…</p> : null}
|
||||||
{hives ? (
|
{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}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
/* <IssueReportPage> — the repo-picker + hide-blocked toggle sit in one
|
/* <IssueReportPage> — the repo-picker + hide-blocked toggle sit in one
|
||||||
row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow
|
row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow
|
||||||
viewports stack instead of overflowing), the label chips get their
|
viewports stack instead of overflowing). The label filter used to
|
||||||
own row below since the set is open-ended and shouldn't fight the
|
have its own chip row here; folded into the "labels" column's own
|
||||||
controls row for width. Sort buttons live inside the table's own
|
`Table` filter (`ui-table-filter-multiselect` in Table.css) instead,
|
||||||
`<th>` cells (Table.tsx's `header` now accepts real markup for
|
so there's nothing page-specific left to style for it. Sort buttons
|
||||||
exactly this) so they inherit the table's header styling for free —
|
live inside the table's own `<th>` cells (Table.tsx's `header` now
|
||||||
only the pointer cursor + no-underline reset is this page's to own. */
|
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 {
|
.issue-report-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -19,19 +21,6 @@
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
cursor: pointer;
|
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 {
|
.issue-report-sort-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
// `transitively_blocks_count` all arrive pre-resolved per row, so
|
// `transitively_blocks_count` all arrive pre-resolved per row, so
|
||||||
// nothing here does its own dependency-graph walk.
|
// nothing here does its own dependency-graph walk.
|
||||||
//
|
//
|
||||||
// Sorting and the label/hide-blocked filters are client-side over
|
// Sorting and the hide-blocked/label/etc. filters are all client-side
|
||||||
// whatever's currently loaded — only the repo selection re-fetches.
|
// over whatever's currently loaded — only the repo selection re-fetches.
|
||||||
// Default sort is `depended_on_by_count` descending: mara's own framing
|
// 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")
|
// ("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.
|
// 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
|
// be instant), so it fetches once per repo-selection change instead of
|
||||||
// polling.
|
// 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
|
// plumbing the theme/motion overrides use) — mara: "local storage" over
|
||||||
// URL params. `labelFilter` is `string[]`, not `Set<string>`: a `Set`
|
// URL params. The label filter used to be this page's own bespoke
|
||||||
// serializes to `"{}"` through `JSON.stringify` and silently loses its
|
// checkbox row — folded into the "labels" column's own `Table` filter
|
||||||
// contents, which is exactly what this hook round-trips through.
|
// (`filterMode: "multiselect"`) per mara's "fold filters into table"
|
||||||
|
// request, one mechanism instead of two.
|
||||||
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";
|
||||||
|
|
@ -71,9 +72,12 @@ const ALL_REPOS = "";
|
||||||
// page — collides with these by accident.
|
// page — collides with these by accident.
|
||||||
const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter";
|
const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter";
|
||||||
const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked";
|
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_KEY_KEY = "swarm-ui:issue-report:sort-key";
|
||||||
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
|
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 {
|
function splitRepo(repo: string): { org: string; name: string } | null {
|
||||||
const i = repo.indexOf("/");
|
const i = repo.indexOf("/");
|
||||||
|
|
@ -145,10 +149,6 @@ export function IssueReportPage() {
|
||||||
HIDE_BLOCKED_KEY,
|
HIDE_BLOCKED_KEY,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
|
|
||||||
LABEL_FILTER_KEY,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
|
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
|
||||||
SORT_KEY_KEY,
|
SORT_KEY_KEY,
|
||||||
"depended_on_by_count",
|
"depended_on_by_count",
|
||||||
|
|
@ -180,7 +180,8 @@ export function IssueReportPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The one fetch this page re-runs on state change — everything else
|
// 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -210,26 +211,14 @@ export function IssueReportPage() {
|
||||||
};
|
};
|
||||||
}, [repoFilter]);
|
}, [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(() => {
|
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.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;
|
||||||
});
|
});
|
||||||
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]);
|
}, [rows, hideBlocked, sortKey, sortDir]);
|
||||||
|
|
||||||
function onSort(key: SortKey) {
|
function onSort(key: SortKey) {
|
||||||
if (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
|
// `aria-sort` for a sortable column's `<th>` — 'none' while sortable
|
||||||
// but not the active column, the real direction while it is. Screen
|
// but not the active column, the real direction while it is. Screen
|
||||||
// readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden`
|
// readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden`
|
||||||
|
|
@ -315,6 +296,8 @@ export function IssueReportPage() {
|
||||||
key: "labels",
|
key: "labels",
|
||||||
header: "labels",
|
header: "labels",
|
||||||
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
|
||||||
|
filterValues: (r) => r.labels,
|
||||||
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assignees",
|
key: "assignees",
|
||||||
|
|
@ -414,20 +397,6 @@ export function IssueReportPage() {
|
||||||
hide blocked (open dependency)
|
hide blocked (open dependency)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 ? (
|
{error ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
context="failed to load the issue report"
|
context="failed to load the issue report"
|
||||||
|
|
@ -441,6 +410,7 @@ export function IssueReportPage() {
|
||||||
rows={visibleRows}
|
rows={visibleRows}
|
||||||
rowKey={(r) => `${r.repo}#${r.number}`}
|
rowKey={(r) => `${r.repo}#${r.number}`}
|
||||||
emptyMessage="no issues match the current filters"
|
emptyMessage="no issues match the current filters"
|
||||||
|
storageKey={TABLE_FILTERS_KEY}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,60 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `filterMode: "multiselect"` — a scrollable checkbox list rather than
|
||||||
|
a `<select multiple>`, which needs a modifier key to pick more than
|
||||||
|
one option and reads as unfamiliar to most operators; a plain
|
||||||
|
checkbox list needs neither. `max-height` + scroll keeps a
|
||||||
|
long option set (many labels) from pushing the popover off-screen. */
|
||||||
|
.ui-table-filter-multiselect {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
max-height: 12em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ui-table-filter-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The negate toggle sits under every filter mode's own control (mara:
|
||||||
|
"you should be able to change if the filter is negated or not") —
|
||||||
|
a hairline rule separates it from the control above so "exclude"
|
||||||
|
doesn't read as one more option belonging to that control. */
|
||||||
|
.ui-table-filter-negate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One button clears every column's filter on this table at once (mara:
|
||||||
|
"have a small reset filters btn") — text-button styling matching
|
||||||
|
`.ui-table-sort-button`'s "no chrome, just a clickable label"
|
||||||
|
treatment, not a full `Badge`/bordered-button — this is a quiet
|
||||||
|
secondary action, not a primary one. */
|
||||||
|
.ui-table-reset-filters {
|
||||||
|
display: inline-block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-table-reset-filters:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { createPortal } from "preact/compat";
|
import { createPortal } from "preact/compat";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
import { FilterIcon } from "@hive/shared/icons.js";
|
import { FilterIcon } from "@hive/shared/icons.js";
|
||||||
|
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
||||||
import "./Table.css";
|
import "./Table.css";
|
||||||
|
|
||||||
export interface TableColumn<T> {
|
export interface TableColumn<T> {
|
||||||
|
|
@ -65,6 +66,14 @@ export interface TableColumn<T> {
|
||||||
* `.ui-table-filter-icon` for the actual rule.
|
* `.ui-table-filter-icon` for the actual rule.
|
||||||
*/
|
*/
|
||||||
filterValue?: (row: T) => string;
|
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>` —
|
* `"text"` (default): free substring, case-insensitive `<input>` —
|
||||||
* today's only behavior, unaffected if you don't set this.
|
* 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
|
* live rows rather than a hardcoded enum means the list never offers
|
||||||
* a choice that would match zero rows. Ignored unless `filterValue`
|
* a choice that would match zero rows. Ignored unless `filterValue`
|
||||||
* is also set.
|
* 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";
|
type SortDir = "asc" | "desc";
|
||||||
|
|
@ -97,6 +136,7 @@ export function Table<T>({
|
||||||
rows,
|
rows,
|
||||||
rowKey,
|
rowKey,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
storageKey,
|
||||||
}: {
|
}: {
|
||||||
columns: TableColumn<T>[];
|
columns: TableColumn<T>[];
|
||||||
rows: T[];
|
rows: T[];
|
||||||
|
|
@ -107,14 +147,27 @@ export function Table<T>({
|
||||||
// table entirely) has nothing useful to add here, so this stays
|
// table entirely) has nothing useful to add here, so this stays
|
||||||
// opt-in rather than every table growing a mandatory default string.
|
// opt-in rather than every table growing a mandatory default string.
|
||||||
emptyMessage?: ComponentChildren;
|
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
|
// One active sort column at a time, same as any spreadsheet — a
|
||||||
// multi-column sort is real complexity (tie-break order, a UI to
|
// multi-column sort is real complexity (tie-break order, a UI to
|
||||||
// express it) nothing here has asked for yet.
|
// express it) nothing here has asked for yet.
|
||||||
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
||||||
// Filter text per filterable column key, only populated for a column
|
// Filter state per filterable column key, only populated for a column
|
||||||
// whose input the operator has actually typed into.
|
// the operator has actually touched. Persisted (see `storageKey`
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
// 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
|
// 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
|
// time (opening a second closes the first) so the header row never
|
||||||
// shows more than one panel at once.
|
// shows more than one panel at once.
|
||||||
|
|
@ -173,7 +226,33 @@ export function Table<T>({
|
||||||
};
|
};
|
||||||
}, [openFilterKey]);
|
}, [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
|
// Close on outside click or Escape — same contract `Dropdown` gives
|
||||||
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
||||||
|
|
@ -205,11 +284,20 @@ export function Table<T>({
|
||||||
let out = rows;
|
let out = rows;
|
||||||
if (activeFilters.length > 0) {
|
if (activeFilters.length > 0) {
|
||||||
out = out.filter((row) =>
|
out = out.filter((row) =>
|
||||||
activeFilters.every(([key, query]) => {
|
activeFilters.every(([key, f]) => {
|
||||||
const col = columns.find((c) => c.key === key);
|
const col = columns.find((c) => c.key === key);
|
||||||
const value = col?.filterValue?.(row) ?? "";
|
let matches: boolean;
|
||||||
if (col?.filterMode === "select") return value === query;
|
if (col?.filterMode === "multiselect") {
|
||||||
return value.toLowerCase().includes(query.toLowerCase());
|
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
|
// 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
|
// per-column ref map) is enough to focus whichever control just
|
||||||
// mounted — typing immediately after the click that opened it,
|
// 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}`;
|
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilterControl(c: TableColumn<T>) {
|
// mara: "you should be able to change if the filter is negated or not
|
||||||
if (c.filterMode === "select") {
|
// (eg search for this text vs exclude it)" — one toggle, consulted the
|
||||||
return (
|
// same way regardless of which control above it produced the match.
|
||||||
<select
|
function renderNegateToggle(c: TableColumn<T>) {
|
||||||
ref={setPopoverControlRef}
|
const f = getFilter(c.key);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<input
|
<label class="ui-table-filter-negate">
|
||||||
ref={setPopoverControlRef}
|
<input
|
||||||
type="text"
|
type="checkbox"
|
||||||
class="ui-table-filter-input"
|
checked={f.negate}
|
||||||
placeholder="filter…"
|
onChange={(e) =>
|
||||||
aria-label={filterLabel(c)}
|
updateFilter(c.key, {
|
||||||
value={filters[c.key] ?? ""}
|
negate: (e.target as HTMLInputElement).checked,
|
||||||
onInput={(e) => {
|
})
|
||||||
const v = (e.target as HTMLInputElement).value;
|
}
|
||||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
/>
|
||||||
}}
|
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 (
|
return (
|
||||||
<div class="ui-table-scroll">
|
<>
|
||||||
<table class="ui-table">
|
{anyActiveFilter ? (
|
||||||
<thead>
|
<button
|
||||||
<tr>
|
type="button"
|
||||||
{columns.map((c) => {
|
class="ui-table-reset-filters"
|
||||||
const hasFilterValue = (filters[c.key] ?? "") !== "";
|
onClick={resetFilters}
|
||||||
const filterOpen = openFilterKey === c.key;
|
>
|
||||||
const headerContent = c.sortBy ? (
|
reset filters
|
||||||
<button
|
</button>
|
||||||
type="button"
|
) : null}
|
||||||
class="ui-table-sort-button"
|
<div class="ui-table-scroll">
|
||||||
onClick={() => toggleSort(c.key)}
|
<table class="ui-table">
|
||||||
>
|
<thead>
|
||||||
{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 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ui-table-empty" colSpan={columns.length}>
|
{columns.map((c) => {
|
||||||
{emptyMessage}
|
const hasFilterValue = isFilterActive(filters[c.key]);
|
||||||
</td>
|
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>
|
</tr>
|
||||||
) : rows.length > 0 && visibleRows.length === 0 ? (
|
</thead>
|
||||||
// Distinct from `emptyMessage` — real rows exist, the active
|
<tbody>
|
||||||
// filter(s) just matched none of them, not "there's nothing
|
{rows.length === 0 && emptyMessage ? (
|
||||||
// here at all".
|
<tr>
|
||||||
<tr>
|
<td class="ui-table-empty" colSpan={columns.length}>
|
||||||
<td class="ui-table-empty" colSpan={columns.length}>
|
{emptyMessage}
|
||||||
no rows match the current filter
|
</td>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
visibleRows.map((row) => (
|
|
||||||
<tr key={rowKey(row)}>
|
|
||||||
{columns.map((c) => (
|
|
||||||
<td key={c.key} class={c.cellClass}>
|
|
||||||
{c.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
) : rows.length > 0 && visibleRows.length === 0 ? (
|
||||||
)}
|
// Distinct from `emptyMessage` — real rows exist, the active
|
||||||
</tbody>
|
// filter(s) just matched none of them, not "there's nothing
|
||||||
</table>
|
// 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
|
{openFilterKey !== null && popoverPos
|
||||||
? createPortal(
|
? createPortal(
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -437,6 +598,6 @@ export function Table<T>({
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue