swarm-ui/agents: split the page, extract FilterableView per mara's follow-up

Three more asks from the same review thread:

- "agentspage is now giant and deserves a split" - AgentsPage.tsx was
  1047 lines. Split into AgentTypes.ts (AgentRow and friends),
  WantedMenu.tsx, AgentCard.tsx (+ its own CSS), leaving AgentsPage.tsx
  as state/actions/columns/the render tree - 649 lines, and every piece
  it composes is now independently readable.

- "what about the component that represents filtered data ... that the
  card view and table can both use?" - extracted FilterableView
  (ui/filterable-view/): takes columns/rows/rowKey/storageKey/view/
  renderCard, builds its filter bar from *every* filterable column (not
  a hand-picked subset - the old AgentFilterBar only showed 4 of the
  agent columns' 6 filterable fields, an accidental gap the table's own
  popovers didn't have), and renders either the card list or Table.
  AgentsPage now just tells it which view to show; the view toggle
  itself stays page-side since it's Panel-header chrome, not filtering.
  Disclosed side effect: card view's filter bar now also covers
  message/config-PR (text filters), matching table view exactly instead
  of a narrower subset.

- CSS audit: AgentsPage.css now holds only what's genuinely page-specific
  (the view toggle, the detail-panel field grid) - everything else moved
  to its owning component's own colocated CSS.

FilterableView gets a /components demo (view toggle + filter bar + both
render modes, same day per the design guide). Verified: AgentsPage
still renders the same (real screenshot), and the demo's own table
toggle produces a real Table with the same rows.
This commit is contained in:
iris 2026-09-11 22:30:38 +02:00
commit 601068f1eb
9 changed files with 591 additions and 491 deletions

View file

@ -0,0 +1,26 @@
.ui-filterable-view-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-bottom: 0.75em;
}
.ui-filterable-view-search {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35em 0.6em;
font: inherit;
font-size: 0.9em;
}
.ui-filterable-view-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
}
.ui-filterable-view-cards {
display: flex;
flex-direction: column;
gap: 0.5em;
}

View file

@ -0,0 +1,179 @@
// <FilterableView> — a filterable list of rows, switchable between the
// original `Table` and a caller-supplied card renderer, both reading
// the same filter state. Extracted out of `AgentsPage` (design-guide's
// "Component-first design" — mara: "what about the component that
// represents filtered data ... that the card view and table can both
// use?") once that page had its own hand-wired card-view filter bar
// (only 4 of the agent columns' 6 filterable fields) alongside `Table`'s
// own per-column popovers (all 6) — two filter surfaces, one accidentally
// narrower than the other, both driving `useTableFilters` under the hood
// already. This component is the one place that wiring lives now: build
// the filter bar from *every* filterable column, not a hand-picked
// subset, so a caller can't have the card view and table view disagree
// on what's filterable by construction.
//
// `view` is a controlled prop, not owned here — the toggle between
// "cards" and "table" is chrome that typically lives in a `Panel`'s
// title-row actions (`AgentsPage`'s own), not this component's body, so
// the caller keeps that state and just says which one to render.
import type { ComponentChildren } from "preact";
import {
MultiselectFilter,
type MultiselectFilterState,
} from "../multiselect-filter/MultiselectFilter.js";
import { Table, useTableFilters, type TableColumn } from "../table/Table.js";
import "./FilterableView.css";
function isFilterable<T>(c: TableColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
function columnLabel<T>(c: TableColumn<T>): string {
return typeof c.header === "string" ? c.header : c.key;
}
// One control per filterable column — a `MultiselectFilter` for
// `filterMode: "multiselect"`, a plain always-visible `<input>` for the
// `"text"` default. No popover-vs-inline choice to make per column the
// way `Table`'s own icon-triggered popovers do: there's no header row
// to anchor an icon to here, every control is just always on.
function FilterBar<T>({
columns,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
activeFilters,
multiselectOptionsFor,
}: {
columns: TableColumn<T>[];
getFilter: (key: string) => MultiselectFilterState & { value: string };
updateFilter: (
key: string,
patch: Partial<{ value: string; values: string[]; negate: boolean }>,
) => void;
toggleFilterValue: (key: string, value: string) => void;
resetFilters: () => void;
activeFilters: unknown[];
multiselectOptionsFor: (c: TableColumn<T>) => string[];
}) {
const filterable = columns.filter(isFilterable);
if (filterable.length === 0) return null;
return (
<div class="ui-filterable-view-filter-bar">
{filterable.map((c) => {
const label = columnLabel(c);
if (c.filterMode === "multiselect") {
return (
<MultiselectFilter
key={c.key}
label={label}
options={multiselectOptionsFor(c)}
filter={getFilter(c.key)}
onToggle={(v) => toggleFilterValue(c.key, v)}
onNegateChange={(negate) => updateFilter(c.key, { negate })}
/>
);
}
return (
<input
key={c.key}
type="text"
class="ui-filterable-view-search"
placeholder={`filter by ${label}`}
aria-label={`filter by ${label}`}
value={getFilter(c.key).value}
onInput={(e) =>
updateFilter(c.key, {
value: (e.target as HTMLInputElement).value,
})
}
/>
);
})}
{activeFilters.length > 0 ? (
<button
type="button"
class="ui-table-reset-filters"
onClick={resetFilters}
>
reset filters
</button>
) : null}
</div>
);
}
export function FilterableView<T>({
columns,
rows,
rowKey,
storageKey,
view,
renderCard,
emptyMessage,
}: {
columns: TableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
// Shared 1:1 with `Table`'s own filter persistence — pass the same
// key `<Table>` gets when it's the other `view`, so cards↔table never
// lose or hide an active filter (see this file's own comment).
storageKey: string;
view: "cards" | "table";
renderCard: (row: T) => ComponentChildren;
// Shown when `rows` itself is empty. A filter narrowing a non-empty
// `rows` to zero visible rows gets its own fixed message instead
// (same as `Table`'s own "no rows match the current filter") — the
// two cases mean different things, same as `Table`'s own split.
emptyMessage?: ComponentChildren;
}) {
const {
visibleRows,
activeFilters,
getFilter,
updateFilter,
toggleFilterValue,
resetFilters,
multiselectOptionsFor,
} = useTableFilters(columns, rows, storageKey);
if (view === "table") {
return (
<Table
columns={columns}
rows={rows}
rowKey={rowKey}
storageKey={storageKey}
emptyMessage={emptyMessage}
/>
);
}
return (
<>
<FilterBar
columns={columns}
getFilter={getFilter}
updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters}
activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor}
/>
{rows.length === 0 && emptyMessage ? (
<p class="ui-filterable-view-empty">{emptyMessage}</p>
) : null}
{rows.length > 0 && visibleRows.length === 0 ? (
<p class="ui-filterable-view-empty">no rows match the current filter</p>
) : null}
{visibleRows.length > 0 ? (
<div class="ui-filterable-view-cards">
{visibleRows.map((row) => (
<div key={rowKey(row)}>{renderCard(row)}</div>
))}
</div>
) : null}
</>
);
}