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:
parent
a204b5f457
commit
4544c458eb
4 changed files with 63 additions and 8 deletions
|
|
@ -310,7 +310,11 @@ export function AgentsPage() {
|
||||||
header: "hive",
|
header: "hive",
|
||||||
render: (a) => a.hive ?? "—",
|
render: (a) => a.hive ?? "—",
|
||||||
sortBy: (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",
|
key: "status",
|
||||||
|
|
@ -322,6 +326,7 @@ export function AgentsPage() {
|
||||||
// now lives in its own "message" column below.
|
// now lives in its own "message" column below.
|
||||||
sortBy: (a) => FRESHNESS[a.freshness].label,
|
sortBy: (a) => FRESHNESS[a.freshness].label,
|
||||||
filterValue: (a) => FRESHNESS[a.freshness].label,
|
filterValue: (a) => FRESHNESS[a.freshness].label,
|
||||||
|
filterMode: "select",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const { tone, label } = FRESHNESS[a.freshness];
|
const { tone, label } = FRESHNESS[a.freshness];
|
||||||
return (
|
return (
|
||||||
|
|
@ -367,6 +372,7 @@ export function AgentsPage() {
|
||||||
// callbacks to the page's own state/handlers.
|
// callbacks to the page's own state/handlers.
|
||||||
sortBy: (a) => a.wanted ?? "",
|
sortBy: (a) => a.wanted ?? "",
|
||||||
filterValue: (a) => a.wanted ?? "no declaration",
|
filterValue: (a) => a.wanted ?? "no declaration",
|
||||||
|
filterMode: "select",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const err = actionErrors.get(a.name);
|
const err = actionErrors.get(a.name);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
},
|
},
|
||||||
sortBy: (h) => FRESHNESS[h.freshness].label,
|
sortBy: (h) => FRESHNESS[h.freshness].label,
|
||||||
filterValue: (h) => FRESHNESS[h.freshness].label,
|
filterValue: (h) => FRESHNESS[h.freshness].label,
|
||||||
|
filterMode: "select",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@
|
||||||
padding-bottom: 0.5em;
|
padding-bottom: 0.5em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.ui-table-filter-input {
|
.ui-table-filter-input,
|
||||||
|
.ui-table-filter-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,28 @@ export interface TableColumn<T> {
|
||||||
*/
|
*/
|
||||||
sortBy?: (row: T) => string | number;
|
sortBy?: (row: T) => string | number;
|
||||||
/**
|
/**
|
||||||
* Column is filterable (a text input in its own row under the
|
* Column is filterable (a control in its own row under the headers)
|
||||||
* headers) when present — extracts the substring to match a case-
|
* when present — extracts the string to filter this column against.
|
||||||
* insensitive filter query against. Same one-signal reasoning as
|
* Same one-signal reasoning as `sortBy`: no separate `filterable`
|
||||||
* `sortBy`: no separate `filterable` flag, the extractor's presence
|
* flag, the extractor's presence is the flag. How the extracted
|
||||||
* is the flag.
|
* string is matched against the operator's input is `filterMode`.
|
||||||
*/
|
*/
|
||||||
filterValue?: (row: T) => string;
|
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";
|
type SortDir = "asc" | "desc";
|
||||||
|
|
@ -104,6 +119,7 @@ export function Table<T>({
|
||||||
activeFilters.every(([key, query]) => {
|
activeFilters.every(([key, query]) => {
|
||||||
const col = columns.find((c) => c.key === key);
|
const col = columns.find((c) => c.key === key);
|
||||||
const value = col?.filterValue?.(row) ?? "";
|
const value = col?.filterValue?.(row) ?? "";
|
||||||
|
if (col?.filterMode === "select") return value === query;
|
||||||
return value.toLowerCase().includes(query.toLowerCase());
|
return value.toLowerCase().includes(query.toLowerCase());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -141,6 +157,20 @@ export function Table<T>({
|
||||||
return sort.dir === "asc" ? "ascending" : "descending";
|
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 (
|
return (
|
||||||
<div class="ui-table-scroll">
|
<div class="ui-table-scroll">
|
||||||
<table class="ui-table">
|
<table class="ui-table">
|
||||||
|
|
@ -175,7 +205,24 @@ export function Table<T>({
|
||||||
<tr class="ui-table-filter-row">
|
<tr class="ui-table-filter-row">
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<th key={c.key}>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="ui-table-filter-input"
|
class="ui-table-filter-input"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue