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:
parent
3b66f0c8d6
commit
601068f1eb
9 changed files with 591 additions and 491 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue