swarm-ui: sortable + filterable table columns
hyperhive#4020, mara. TableColumn gets two new optional extractors, each one the whole signal for its capability (mara: 'why separate selector and flag?' — dropped the sortable/filterable booleans that would've said the same thing twice and could disagree with the extractor's presence): - sortBy: (row) => string | number — column is click-to-sort iff present. Table owns the sort state (one active column, header click cycles none -> ascending -> descending), since a rendered cell often isn't the sortable value itself (e.g. AgentsPage's status column renders a Badge, not a plain string). - filterValue: (row) => string — column is filterable iff present. A text input row under the headers, substring match case-insensitive. Client-side only, no backend change - every consuming page already fetches its full row set. Wired into AgentsPage and HivesPage, the two pages with a real per-row Table. Left IssueReportPage alone (already has its own purpose-built sort + label/hide-blocked filters, predates this and covers its own domain better than a generic per-column text filter would) and JobsPage alone (renders an indented state tree via JobqGraph, no column table at all despite what my original scoping comment assumed). Verified: typecheck + build clean, nix fmt clean, static render of the actual built CSS confirms the new sort-header + filter-row markup doesn't break table layout.
This commit is contained in:
parent
9065898e08
commit
247ff4498d
4 changed files with 225 additions and 13 deletions
|
|
@ -207,8 +207,20 @@ export function AgentsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumn<AgentRow>[] = [
|
const columns: TableColumn<AgentRow>[] = [
|
||||||
{ key: "name", header: "name", render: (a) => a.name },
|
{
|
||||||
{ key: "hive", header: "hive", render: (a) => a.hive ?? "—" },
|
key: "name",
|
||||||
|
header: "name",
|
||||||
|
render: (a) => a.name,
|
||||||
|
sortBy: (a) => a.name,
|
||||||
|
filterValue: (a) => a.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hive",
|
||||||
|
header: "hive",
|
||||||
|
render: (a) => a.hive ?? "—",
|
||||||
|
sortBy: (a) => a.hive ?? "",
|
||||||
|
filterValue: (a) => a.hive ?? "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
header: "status",
|
header: "status",
|
||||||
|
|
@ -217,6 +229,8 @@ export function AgentsPage() {
|
||||||
// stuffed a full sentence into a pill meant for a short discrete
|
// stuffed a full sentence into a pill meant for a short discrete
|
||||||
// label and blew the row out (mara: "looks messy"). That string
|
// label and blew the row out (mara: "looks messy"). That string
|
||||||
// now lives in its own "message" column below.
|
// now lives in its own "message" column below.
|
||||||
|
sortBy: (a) => FRESHNESS[a.freshness].label,
|
||||||
|
filterValue: (a) => FRESHNESS[a.freshness].label,
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const { tone, label } = FRESHNESS[a.freshness];
|
const { tone, label } = FRESHNESS[a.freshness];
|
||||||
return (
|
return (
|
||||||
|
|
@ -252,6 +266,7 @@ export function AgentsPage() {
|
||||||
// `status_text: null` (the wire contract's own rule), so a stopped
|
// `status_text: null` (the wire contract's own rule), so a stopped
|
||||||
// agent just shows an em dash here rather than a stale message.
|
// agent just shows an em dash here rather than a stale message.
|
||||||
render: (a) => a.snapshot?.status_text ?? "—",
|
render: (a) => a.snapshot?.status_text ?? "—",
|
||||||
|
filterValue: (a) => a.snapshot?.status_text ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "wanted",
|
key: "wanted",
|
||||||
|
|
@ -261,6 +276,8 @@ export function AgentsPage() {
|
||||||
// control" shape (see its header comment, which names pause/resume
|
// control" shape (see its header comment, which names pause/resume
|
||||||
// as the exact motivating case), not a separate status chip next
|
// as the exact motivating case), not a separate status chip next
|
||||||
// to a separate button.
|
// to a separate button.
|
||||||
|
sortBy: (a) => a.wanted ?? "",
|
||||||
|
filterValue: (a) => a.wanted ?? "no declaration",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const pending = pendingAgents.has(a.name);
|
const pending = pendingAgents.has(a.name);
|
||||||
const impliedCurrent =
|
const impliedCurrent =
|
||||||
|
|
@ -295,6 +312,8 @@ export function AgentsPage() {
|
||||||
{
|
{
|
||||||
key: "config-pr",
|
key: "config-pr",
|
||||||
header: "config PR",
|
header: "config PR",
|
||||||
|
sortBy: (a) => a.config_pr?.pr_number ?? 0,
|
||||||
|
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
|
||||||
render: (a) =>
|
render: (a) =>
|
||||||
a.config_pr ? (
|
a.config_pr ? (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,13 @@ const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: TableColumn<HiveStatus>[] = [
|
const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
{ key: "name", header: "name", render: (h) => h.name },
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "name",
|
||||||
|
render: (h) => h.name,
|
||||||
|
sortBy: (h) => h.name,
|
||||||
|
filterValue: (h) => h.name,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "domain",
|
key: "domain",
|
||||||
header: "domain",
|
header: "domain",
|
||||||
|
|
@ -61,6 +67,8 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
),
|
),
|
||||||
|
sortBy: (h) => h.domain ?? "",
|
||||||
|
filterValue: (h) => h.domain ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|
@ -82,6 +90,8 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
sortBy: (h) => FRESHNESS[h.freshness].label,
|
||||||
|
filterValue: (h) => FRESHNESS[h.freshness].label,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,49 @@
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sortable header — the whole header cell is the click target, not a
|
||||||
|
separate icon-only button next to it, same "control lives on the
|
||||||
|
thing it affects" reasoning as `Badge`'s own click-to-toggle shape. */
|
||||||
|
.ui-table-sort-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-table-sort-button:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.ui-table-sort-glyph {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.ui-table-sort-button:hover .ui-table-sort-glyph {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The per-column filter inputs' row — same cell padding as a header row,
|
||||||
|
no bottom border of its own (the header row above already has one). */
|
||||||
|
.ui-table-filter-row th {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ui-table-filter-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
// a `<table>` doesn't shrink below its content's natural width on its
|
// a `<table>` doesn't shrink below its content's natural width on its
|
||||||
// own, so without this the page itself would break, not just look
|
// own, so without this the page itself would break, not just look
|
||||||
// cramped.
|
// cramped.
|
||||||
|
import { useMemo, useState } from "preact/hooks";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
import "./Table.css";
|
import "./Table.css";
|
||||||
|
|
||||||
|
|
@ -29,12 +30,44 @@ export interface TableColumn<T> {
|
||||||
* `'descending'` while this is the active sort column, `'none'` while
|
* `'descending'` while this is the active sort column, `'none'` while
|
||||||
* sortable but not active, omitted entirely for a non-sortable column
|
* sortable but not active, omitted entirely for a non-sortable column
|
||||||
* (no `aria-sort` attribute at all, the correct value for a column
|
* (no `aria-sort` attribute at all, the correct value for a column
|
||||||
* that can never be the active sort). A screen reader announces which
|
* that can never be the active sort). Only consulted for a column with
|
||||||
* column/direction is active from this attribute; the ▲/▼ glyph a
|
* no `sortBy` — one with `sortBy` gets its `aria-sort` computed from
|
||||||
* sortable header renders is `aria-hidden` and carries no information
|
* `Table`'s own sort state instead. Kept for the issue-report page's
|
||||||
* on its own.
|
* pre-existing hand-rolled sortable headers (real `<button>` markup as
|
||||||
|
* `header`, its own click handling), which predate `sortBy` and don't
|
||||||
|
* need to be ported to it just because it now exists.
|
||||||
*/
|
*/
|
||||||
ariaSort?: "ascending" | "descending" | "none";
|
ariaSort?: "ascending" | "descending" | "none";
|
||||||
|
/**
|
||||||
|
* Column is click-to-sort when present — extracts the comparable
|
||||||
|
* value from a row (a rendered cell is often not that value itself,
|
||||||
|
* e.g. a status column rendering a `Badge`). `Table` owns the actual
|
||||||
|
* sort state (one active column, cycling none → ascending →
|
||||||
|
* descending on header click); the column only says how to compare
|
||||||
|
* its own rows. String values compare case-insensitively, numbers
|
||||||
|
* numerically — deliberately no separate `sortable` flag alongside
|
||||||
|
* this: the extractor's presence already answers whether the column
|
||||||
|
* sorts, so there's no second flag that could disagree with it.
|
||||||
|
*/
|
||||||
|
sortBy?: (row: T) => string | number;
|
||||||
|
/**
|
||||||
|
* Column is filterable (a text input in its own row under the
|
||||||
|
* headers) when present — extracts the substring to match a case-
|
||||||
|
* insensitive filter query against. Same one-signal reasoning as
|
||||||
|
* `sortBy`: no separate `filterable` flag, the extractor's presence
|
||||||
|
* is the flag.
|
||||||
|
*/
|
||||||
|
filterValue?: (row: T) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
function compareValues(a: string | number, b: string | number): number {
|
||||||
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||||
|
return String(a).localeCompare(String(b), undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
numeric: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Table<T>({
|
export function Table<T>({
|
||||||
|
|
@ -53,17 +86,112 @@ export function Table<T>({
|
||||||
// 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;
|
||||||
}) {
|
}) {
|
||||||
|
// 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>>({});
|
||||||
|
|
||||||
|
const filterableColumns = columns.filter((c) => c.filterValue);
|
||||||
|
const activeFilters = Object.entries(filters).filter(([, v]) => v !== "");
|
||||||
|
|
||||||
|
const visibleRows = useMemo(() => {
|
||||||
|
let out = rows;
|
||||||
|
if (activeFilters.length > 0) {
|
||||||
|
out = out.filter((row) =>
|
||||||
|
activeFilters.every(([key, query]) => {
|
||||||
|
const col = columns.find((c) => c.key === key);
|
||||||
|
const value = col?.filterValue?.(row) ?? "";
|
||||||
|
return value.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sort) {
|
||||||
|
const col = columns.find((c) => c.key === sort.key);
|
||||||
|
const sortBy = col?.sortBy;
|
||||||
|
if (sortBy) {
|
||||||
|
out = [...out].sort((a, b) => {
|
||||||
|
const cmp = compareValues(sortBy(a), sortBy(b));
|
||||||
|
return sort.dir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
||||||
|
// a fresh array every render (built inline by every caller); keying
|
||||||
|
// off its identity would recompute every render regardless, so this
|
||||||
|
// depends on the values that actually determine the output instead.
|
||||||
|
}, [rows, sort, JSON.stringify(activeFilters)]);
|
||||||
|
|
||||||
|
function toggleSort(key: string) {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev?.key !== key) return { key, dir: "asc" };
|
||||||
|
if (prev.dir === "asc") return { key, dir: "desc" };
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSortFor(
|
||||||
|
c: TableColumn<T>,
|
||||||
|
): "ascending" | "descending" | "none" | undefined {
|
||||||
|
if (!c.sortBy) return c.ariaSort;
|
||||||
|
if (sort?.key !== c.key) return "none";
|
||||||
|
return sort.dir === "asc" ? "ascending" : "descending";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="ui-table-scroll">
|
<div class="ui-table-scroll">
|
||||||
<table class="ui-table">
|
<table class="ui-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c) => (
|
{columns.map((c) =>
|
||||||
<th key={c.key} aria-sort={c.ariaSort}>
|
c.sortBy ? (
|
||||||
{c.header}
|
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
||||||
</th>
|
<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>
|
||||||
|
</th>
|
||||||
|
) : (
|
||||||
|
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
||||||
|
{c.header}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
{filterableColumns.length > 0 ? (
|
||||||
|
<tr class="ui-table-filter-row">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c.key}>
|
||||||
|
{c.filterValue ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="ui-table-filter-input"
|
||||||
|
placeholder="filter…"
|
||||||
|
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
|
||||||
|
value={filters[c.key] ?? ""}
|
||||||
|
onInput={(e) => {
|
||||||
|
const v = (e.target as HTMLInputElement).value;
|
||||||
|
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 && emptyMessage ? (
|
{rows.length === 0 && emptyMessage ? (
|
||||||
|
|
@ -72,8 +200,17 @@ export function Table<T>({
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
visibleRows.map((row) => (
|
||||||
<tr key={rowKey(row)}>
|
<tr key={rowKey(row)}>
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<td key={c.key} class={c.cellClass}>
|
<td key={c.key} class={c.cellClass}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue