swarm-ui: bounded-domain table filters become dropdowns

Table columns whose value only ever comes from a small closed set
(freshness, wanted, hive) get a <select> in their filter-row cell
instead of a free-text input, populated from the distinct values
present in the currently-loaded rows plus an "any" option, matched by
exact equality instead of substring. Free-text columns (name, the
agent's own status message, config PR, hive domain) are unchanged.
This commit is contained in:
iris 2026-09-08 00:23:07 +02:00 committed by mara
commit 4544c458eb
4 changed files with 63 additions and 8 deletions

View file

@ -310,7 +310,11 @@ export function AgentsPage() {
header: "hive",
render: (a) => a.hive ?? "—",
sortBy: (a) => a.hive ?? "",
filterValue: (a) => a.hive ?? "",
// "—" (not "") so the missing-hive option in the filter dropdown
// reads the same as the cell itself, rather than showing a blank
// choice.
filterValue: (a) => a.hive ?? "—",
filterMode: "select",
},
{
key: "status",
@ -322,6 +326,7 @@ export function AgentsPage() {
// now lives in its own "message" column below.
sortBy: (a) => FRESHNESS[a.freshness].label,
filterValue: (a) => FRESHNESS[a.freshness].label,
filterMode: "select",
render: (a) => {
const { tone, label } = FRESHNESS[a.freshness];
return (
@ -367,6 +372,7 @@ export function AgentsPage() {
// callbacks to the page's own state/handlers.
sortBy: (a) => a.wanted ?? "",
filterValue: (a) => a.wanted ?? "no declaration",
filterMode: "select",
render: (a) => {
const err = actionErrors.get(a.name);
return (

View file

@ -92,6 +92,7 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
},
sortBy: (h) => FRESHNESS[h.freshness].label,
filterValue: (h) => FRESHNESS[h.freshness].label,
filterMode: "select",
},
];

View file

@ -70,7 +70,8 @@
padding-bottom: 0.5em;
font-weight: 400;
}
.ui-table-filter-input {
.ui-table-filter-input,
.ui-table-filter-select {
width: 100%;
box-sizing: border-box;
background: var(--bg);

View file

@ -51,13 +51,28 @@ export interface TableColumn<T> {
*/
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.
* Column is filterable (a control in its own row under the headers)
* when present extracts the string to filter this column against.
* Same one-signal reasoning as `sortBy`: no separate `filterable`
* flag, the extractor's presence is the flag. How the extracted
* string is matched against the operator's input is `filterMode`.
*/
filterValue?: (row: T) => string;
/**
* `"text"` (default): free substring, case-insensitive `<input>`
* today's only behavior, unaffected if you don't set this.
* `"select"`: a `<select>` populated from the *distinct* `filterValue`
* results across the currently-loaded rows (deduped, case-insensitive
* sort) plus a leading "any" option, matched by exact equality. Pick
* this for a column whose value only ever comes from a small
* bounded/closed set (an enum label, a hive name) typing an exact
* substring is friction a picker removes, and a substring query can
* accidentally straddle two distinct values. Deriving options from
* 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.
*/
filterMode?: "text" | "select";
}
type SortDir = "asc" | "desc";
@ -104,6 +119,7 @@ export function Table<T>({
activeFilters.every(([key, query]) => {
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());
}),
);
@ -141,6 +157,20 @@ export function Table<T>({
return sort.dir === "asc" ? "ascending" : "descending";
}
// Distinct values currently present for a `"select"`-mode column, so
// the dropdown never offers an option that would match zero rows.
// Computed off `rows` (pre-filter) — every column's own option list
// stays stable while a sibling column's filter narrows `visibleRows`,
// matching how a spreadsheet's column filters don't hide each other's
// choices.
function selectOptionsFor(c: TableColumn<T>): string[] {
if (!c.filterValue) return [];
const values = new Set(rows.map(c.filterValue));
return Array.from(values).sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
);
}
return (
<div class="ui-table-scroll">
<table class="ui-table">
@ -175,7 +205,24 @@ export function Table<T>({
<tr class="ui-table-filter-row">
{columns.map((c) => (
<th key={c.key}>
{c.filterValue ? (
{c.filterValue && c.filterMode === "select" ? (
<select
class="ui-table-filter-select"
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
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>
) : c.filterValue ? (
<input
type="text"
class="ui-table-filter-input"