diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 8255a61c..99380be6 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -2,7 +2,7 @@ // page component under `./pages/`; this file just maps paths to them. import { Route, Switch } from "wouter-preact"; import { Shell } from "./shell/Shell.js"; -import { AgentsPage } from "./pages/AgentsPage.js"; +import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index bb4f3fcf..0e5536cb 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -7,12 +7,19 @@ // swarm-controller's API is up or not. import { useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; +import { Card } from "../ui/card/Card.js"; +import { FilterableView } from "../ui/filterable-view/FilterableView.js"; +import { + MultiselectFilter, + type MultiselectFilterState, +} from "../ui/multiselect-filter/MultiselectFilter.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; +import { SplitView } from "../ui/split-view/SplitView.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { TextField } from "../ui/text-field/TextField.js"; import { @@ -212,6 +219,71 @@ function DialogSample() { ); } +// Own state for the same reason `TextFieldSample` needs it — a +// controlled sample has to actually respond to interaction. +function MultiselectFilterSample() { + const [filter, setFilter] = useState({ + values: [], + negate: false, + }); + return ( + + setFilter((f) => ({ + ...f, + values: f.values.includes(v) + ? f.values.filter((x) => x !== v) + : [...f.values, v], + })) + } + onNegateChange={(negate) => setFilter((f) => ({ ...f, negate }))} + /> + ); +} + +// A `view` toggle here is caller-owned (same contract `AgentsPage` uses) +// — `FilterableView` itself only renders whichever one this sample +// says to. Reuses `TABLE_COLUMNS`/`TABLE_ROWS` from the `Table` section +// above so the filter behavior is directly comparable between the two. +function FilterableViewSample() { + const [view, setView] = useState<"cards" | "table">("cards"); + return ( +
+
+ + +
+ r.name} + storageKey="swarm-ui:components-demo:filterable-view" + view={view} + emptyMessage="no rows" + renderCard={(r) => ( + {}}> + {r.name} +
{r.detail}
+
+ )} + /> +
+ ); +} + function ConfirmDialogSample() { const [open, setOpen] = useState(false); return ( @@ -269,6 +341,23 @@ export function ComponentsPage() { +
+ + + primary content — e.g. AgentsPage's roster + + } + secondary={ + + secondary content — e.g. the selected row + + } + /> + +
+
+
+
+ + + alpha +
plain content, not clickable
+
+
+ + {}}> + beta +
click, or Enter/Space when focused
+
+
+ + {}}> + gamma +
same card, with selected set
+
+
+
+
+ +
+ + + +
+ +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css new file mode 100644 index 00000000..90a81e33 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.css @@ -0,0 +1,24 @@ +/* content layout — the clickable/selectable container + itself is `Card`'s (`ui/card/`); this is just the name/badges/ + message arrangement inside one. */ +.ui-agent-card-line1 { + display: flex; + align-items: center; + gap: 0.6em; +} +.ui-agent-card-name { + font-weight: 600; +} +/* Pushes the wanted-state control to the row's trailing edge regardless + of how wide the status badge next to it ends up. */ +.ui-agent-card-wanted { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin-left: auto; +} +.ui-agent-card-message { + color: var(--muted); + white-space: normal; + word-break: break-word; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx new file mode 100644 index 00000000..e1ea8803 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentCard.tsx @@ -0,0 +1,93 @@ +// — one card per roster agent (mara: "main view: name, +// status, message, wanted" / "message as second line" / "more like +// card per agent") — the FilterableView `view === "cards"` alternative +// to the original `Table`, not its replacement (see `AgentsPage`'s own +// comment). Clicking a card opens a detail panel repeating this same +// info plus what doesn't fit here (hive, config-PR link, matrix +// link-account) — mara: "the info from main list should be included in +// the agent view" too, not just the leftovers. +// +// Built on the shared `Card` primitive (`ui/card/`) for the clickable/ +// selectable-container mechanics — the `stopPropagation` wrapper around +// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, +// `WantedMenu` hosts real ` - - - } - > - {error ? ( - - ) : null} - {!error && rows === null ?

loading…

: null} - {rows ? ( -
a.name} - emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" - storageKey="swarm-ui:agents:table-filters" - /> - ) : null} + <> + + +
+ + +
+ + + } + > + {error ? ( + + ) : null} + {!error && rows === null ?

loading…

: null} + {rows ? ( + a.name} + storageKey="swarm-ui:agents:table-filters" + view={viewMode} + emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive" + renderCard={(a) => ( + setDetailTargetName(row.name)} + /> + )} + /> + ) : null} + + } + // A second on-page `Panel`, not a modal — mara: "why no separate + // panel? ... i mean a second panel on agent page." Always mounted + // (an empty state when nothing's selected) rather than + // conditionally rendered, so picking an agent never causes the + // page's own layout to jump. `SplitView`'s `flex-wrap` stacks + // this below the list on a narrow viewport, same content-driven- + // not-a-fixed-breakpoint approach the shell's own nav already + // uses (`Shell.css`), rather than a new media query. + secondary={ + + {detailTarget ? ( +
+
+
status
+
+ {(() => { + const { tone, label } = FRESHNESS[detailTarget.freshness]; + return ( + + {label} + {detailTarget.last_seen_unix !== null ? ( + <> + {" "} + ( + + ) + + ) : null} + + } + /> + ); + })()} +
+
message
+
{detailTarget.snapshot?.status_text ?? "—"}
+
wanted
+
+ {/* Full menu (destroy included) — mara: "destroy is + already available via wanted state", no separate + button needed. */} + +
+
hive
+
{detailTarget.hive ?? "—"}
+
config PR
+
+ {detailTarget.config_pr ? ( + + #{detailTarget.config_pr.pr_number} + + ) : ( + `#${detailTarget.config_pr.pr_number}` + ) + } + /> + ) : ( + "—" + )} +
+
+
+ } + value="link matrix account" + onClick={ + detailTarget.hive + ? () => setMatrixTarget(detailTarget) + : undefined + } + disabled={!detailTarget.hive} + title={ + detailTarget.hive + ? `link a matrix account to ${detailTarget.name}` + : "no hive on record for this agent — nothing to link against" + } + /> +
+
+ ) : ( +

select an agent to see its details

+ )} +
+ } + /> setCreateOpen(false)} @@ -613,6 +642,6 @@ export function AgentsPage() {

) : null} - + ); } diff --git a/frontend/packages/swarm-ui/src/pages/agents/WantedMenu.tsx b/frontend/packages/swarm-ui/src/pages/agents/WantedMenu.tsx new file mode 100644 index 00000000..e45468e0 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/WantedMenu.tsx @@ -0,0 +1,124 @@ +// — the "wanted" state control: one badge showing the +// current declaration, opening a `Dropdown` with the four explicit +// states — replaces the old toggle-badge-plus-separate-destroy-badge +// pair. Explicit options also fix a real bug the toggle had: for an +// undeclared row, the toggle inferred a target as "the opposite of +// `snapshot.running`", so a click on an undeclared-but-running agent +// silently declared it *offline* rather than making its real state +// explicit — a dropdown just lets "up" be picked directly regardless +// of what's inferred, no flip-to-the-opposite-of-a-guess involved +// (mara: "when no state is declared, i want to set it to online"). +// Presentational only, same split as `StatusChips`'s `Picker`/ +// `StatusMenu`: the caller owns what each selection actually does. +// +// "paused" has no separate resume option here — selecting "up" from a +// paused row is the resume, same PUT either way (`AgentsPage`'s +// `declareState`, `hive-c0re`'s `decide_pause` backend counterpart, +// treats a declared `Up` with the marker still set as "clear it"). A +// dedicated "resume" entry would just be a second spelling of the +// option already above it. +import { useRef, useState } from "preact/hooks"; +import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; +import type { AgentRow } from "./AgentTypes.js"; + +export function WantedMenu({ + row, + pending, + onSelectUp, + onSelectOffline, + onSelectPaused, + onDestroy, + showDestroy = true, + portal = false, +}: { + row: AgentRow; + pending: boolean; + onSelectUp: (row: AgentRow) => void; + onSelectOffline: (row: AgentRow) => void; + onSelectPaused: (row: AgentRow) => void; + onDestroy?: (row: AgentRow) => void; + /** + * The card view (see `AgentCard`) passes `false` here — its + * quick-access menu keeps to the three everyday states, so "destroy" + * can't be reached by an extra click off a state that's already open. + * `AgentsPage`'s detail-panel `WantedMenu` keeps the default (`true`) + * — mara: "destroy is already available via wanted state", i.e. the + * full menu there, not a separate button. + */ + showDestroy?: boolean; + /** + * Forwarded to `Dropdown`'s own `portal` (see its file-top comment) — + * only the table's "wanted" column needs it, to escape + * `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s + * own `portal` prop exists to fix in the first place). + * Neither the card list nor the detail panel has a clipping ancestor, + * and inside the detail panel specifically `portal` is actively wrong: + * a `position: fixed` element appended to `document.body` renders + * *behind* an open native `` (the dialog is promoted to the + * browser's top layer, which composites above ordinary body content + * regardless of z-index) — found while screenshotting this exact + * dropdown open inside the detail panel, only the portion extending + * past the dialog's own edge was visible. + */ + portal?: boolean; +}) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const destroyed = row.wanted === "destroyed"; + const tone: BadgeTone = + row.wanted === "up" + ? "positive" + : row.wanted === "paused" + ? "warning" + : destroyed + ? "negative" + : "neutral"; + const options: DropdownOption[] = [ + { value: "up", label: "up" }, + { value: "paused", label: "paused" }, + { value: "offline", label: "offline" }, + ...(showDestroy + ? [{ value: "destroy", label: "destroy", danger: true }] + : []), + ]; + + return ( +
+ setOpen((o) => !o) : undefined} + expanded={open} + disabled={pending || !row.hive || destroyed} + title={ + destroyed + ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` + : row.hive + ? `declare a new state for ${row.name}` + : "no hive on record for this agent — nothing to declare against" + } + /> + { + setOpen(false); + if (value === "up") onSelectUp(row); + else if (value === "offline") onSelectOffline(row); + else if (value === "paused") onSelectPaused(row); + else onDestroy?.(row); + }} + onClose={() => setOpen(false)} + anchorRef={anchorRef} + /> +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/card/Card.css b/frontend/packages/swarm-ui/src/ui/card/Card.css new file mode 100644 index 00000000..72abb806 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/card/Card.css @@ -0,0 +1,26 @@ +.ui-card { + display: flex; + flex-direction: column; + gap: 0.35em; + padding: 0.75em 1em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg-elev); +} +/* Only an interactive `Card` (has `onClick`) gets pointer/hover + affordance — a plain static one shouldn't look clickable. */ +[role="button"].ui-card { + cursor: pointer; +} +[role="button"].ui-card:hover, +[role="button"].ui-card:focus-visible { + border-color: var(--purple); + outline: none; +} +/* A solid border (not just the hover tint above, which needs to keep + meaning "hovering", not double as "selected") plus a faint fill so a + selected card still reads once the pointer moves away. */ +.ui-card-selected { + border-color: var(--purple); + background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev)); +} diff --git a/frontend/packages/swarm-ui/src/ui/card/Card.tsx b/frontend/packages/swarm-ui/src/ui/card/Card.tsx new file mode 100644 index 00000000..8e3e687b --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/card/Card.tsx @@ -0,0 +1,64 @@ +// — the clickable/selectable-container mechanics (role="button", +// keyboard activation, hover + selected styling) any card-shaped list +// item needs, with zero opinion on its content — same "primitive owns +// the mechanics, caller owns the content" split as `Panel`/`Dialog`. +// Extracted out of `AgentsPage`'s `AgentCard` (design-guide's +// "Component-first design": a page had hand-rolled this rather than +// reaching for a `src/ui/` primitive) — `AgentCard` now wraps agent- +// specific content (name/badges/message) around this instead of owning +// the click/keyboard/selected logic itself. +// +// A `role="button"` `
`, not a real ` + ) : null} +
+ ); +} + +export function FilterableView({ + columns, + rows, + rowKey, + storageKey, + view, + renderCard, + emptyMessage, +}: { + columns: TableColumn[]; + rows: T[]; + rowKey: (row: T) => string; + // Shared 1:1 with `Table`'s own filter persistence — pass the same + // key `
` 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 ( +
+ ); + } + + return ( + <> + + {rows.length === 0 && emptyMessage ? ( +

{emptyMessage}

+ ) : null} + {rows.length > 0 && visibleRows.length === 0 ? ( +

no rows match the current filter

+ ) : null} + {visibleRows.length > 0 ? ( +
+ {visibleRows.map((row) => ( +
{renderCard(row)}
+ ))} +
+ ) : null} + + ); +} diff --git a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css new file mode 100644 index 00000000..698c77e0 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css @@ -0,0 +1,51 @@ +/* Checkbox-list content — same visual treatment as `Table`'s own + popover content (`Table.css`'s `.ui-table-filter-multiselect`/ + `-checkbox`), colocated here instead of reached-across-files since + `Table` no longer renders this markup itself (see this component's + file-top comment). */ +.ui-multiselect-filter-options { + display: flex; + flex-direction: column; + gap: 0.3em; + max-height: 12em; + overflow-y: auto; +} +.ui-multiselect-filter-checkbox { + display: flex; + align-items: center; + gap: 0.35em; + cursor: pointer; + font-size: 0.9em; +} +.ui-multiselect-filter-negate { + display: flex; + align-items: center; + gap: 0.35em; + margin-top: 0.5em; + padding-top: 0.5em; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.85em; + cursor: pointer; +} + +/* The standalone control (trigger + popover) — `position: relative`/ + `absolute` under the trigger, not `Table`'s fixed+portal recipe: + nothing here sits inside a clipping ancestor (see file-top comment), + so the simpler positioning is enough. */ +.ui-multiselect-filter { + position: relative; +} +.ui-multiselect-filter-popover { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.25em; + z-index: 10; + min-width: 12em; + padding: 0.5em; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} diff --git a/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx new file mode 100644 index 00000000..a104ce99 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx @@ -0,0 +1,140 @@ +// / — the checkbox-list +// "pick any of these values" control every multiselect-mode filter in +// this package needs, split into a content-only piece and a full +// standalone control around it. +// +// Extracted out of `Table`'s own per-column filter popover (design- +// guide's "Component-first design") once `AgentsPage`'s card-view filter +// toolbar needed the identical checkbox list and nearly duplicated it — +// exactly the "next contributor duplicates ad-hoc styling, someone has +// to hunt it down and consolidate" scenario that section warns about. +// `Table` keeps its own trigger (an icon button inside a `
`) and its +// own positioning (`position: fixed` + portal, to escape +// `.ui-table-scroll`'s clip — see that file's own comment), genuinely +// different concerns from a plain toolbar's; only the checkbox-list +// *content* moves here, as `MultiselectFilterOptions`. A caller with no +// clipping context and no header row to anchor an icon to — a plain +// toolbar, `AgentsPage`'s first use — reaches for `MultiselectFilter` +// instead: the same content, wrapped in its own `Badge` trigger and a +// much simpler `position: absolute` popover. +import { useEffect, useRef, useState } from "preact/hooks"; +import { Badge } from "@hive/shared/badge.js"; +import "./MultiselectFilter.css"; + +export interface MultiselectFilterState { + values: string[]; + negate: boolean; +} + +export function MultiselectFilterOptions({ + options, + filter, + onToggle, +}: { + options: string[]; + filter: MultiselectFilterState; + onToggle: (value: string) => void; +}) { + return ( +
+ {options.map((v) => ( + + ))} +
+ ); +} + +// The negate toggle — mara: "you should be able to change if the filter +// is negated or not (eg search for this text vs exclude it)", same +// reasoning as `Table`'s own copy. Small enough (one checkbox+label) +// that keeping it inline here rather than a third exported piece isn't +// worth the indirection; `Table` keeps rendering its own copy for text- +// mode filters, which don't otherwise touch this file at all. +function NegateToggle({ + negate, + onChange, +}: { + negate: boolean; + onChange: (negate: boolean) => void; +}) { + return ( + + ); +} + +export function MultiselectFilter({ + label, + options, + filter, + onToggle, + onNegateChange, +}: { + label: string; + options: string[]; + filter: MultiselectFilterState; + onToggle: (value: string) => void; + onNegateChange: (negate: boolean) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handlePointerDown(e: PointerEvent) { + if (e.target instanceof Node && ref.current?.contains(e.target)) return; + setOpen(false); + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
+ 0 + ? `${label} (${filter.values.length})` + : label + } + onClick={() => setOpen((o) => !o)} + expanded={open} + /> + {open ? ( + + ) : null} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx index f90e838a..56afdd7b 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx @@ -34,14 +34,19 @@ export function Panel({ icon, actions, children, + class: extraClass, }: { title?: string; icon?: string; actions?: ComponentChildren; children: ComponentChildren; + /** Extra class on the outer `
` — e.g. flex-sizing a panel + * that's one of several sharing a row (`AgentsPage`'s list/detail + * split). Omit for the plain single-panel case every other caller is. */ + class?: string; }) { return ( -
+
{title || icon || actions ? (
{icon ? ( diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css new file mode 100644 index 00000000..6e70efd4 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css @@ -0,0 +1,14 @@ +.ui-split-view { + display: flex; + flex-wrap: wrap; + gap: 1em; + align-items: flex-start; +} +.ui-split-view-primary { + flex: 2 1 480px; + min-width: 0; +} +.ui-split-view-secondary { + flex: 1 1 320px; + min-width: 0; +} diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx new file mode 100644 index 00000000..09931966 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx @@ -0,0 +1,33 @@ +// — a primary/secondary pane laid out side by side, wrapping +// to stacked on a narrow viewport. Extracted out of `AgentsPage`'s own +// list+detail row (design-guide's "Component-first design": a page had +// built this inline for its own list/detail split, `AgentsPage`'s +// `Panel`s reaching for it is the first real use, per the doc's "build +// the primitive before or alongside the first real page that needs it"). +// +// `flex-wrap`, not a `@media` breakpoint — content-driven stacking, same +// approach the shell's own nav row already uses (`shell/Shell.css`) +// rather than a second, independent magic-number breakpoint. No opinion +// on what's inside either pane (a `Panel`, anything else) — same "own +// the mechanics, not the content" split as `Card`. +import type { ComponentChildren } from "preact"; +import "./SplitView.css"; + +export function SplitView({ + primary, + secondary, +}: { + /** The wider pane — a list, a table, whatever the page's main content + * is. `flex: 2` against `secondary`'s `flex: 1`. */ + primary: ComponentChildren; + /** The narrower pane — a detail view, a summary, anything that reads + * as "more about the thing selected in `primary`." */ + secondary: ComponentChildren; +}) { + return ( +
+
{primary}
+
{secondary}
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css index 6be32e7a..06277b49 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -141,25 +141,9 @@ font-size: 0.9em; } -/* `filterMode: "multiselect"` — a scrollable checkbox list rather than - a ``, split out so a second, differently +// shaped view over the same rows (AgentsPage's card list, switchable +// against its own `Table`) can share one filter state instead of forking +// its own — mara: "separating data and filter from view" lets the choice +// of view (table vs. card) stop mattering to what's filtered. `Table` +// itself calls this internally below; the popover-trigger UI stays here, +// table-shaped (a ` {columns.map((c) => { - const hasFilterValue = isFilterActive(filters[c.key]); + const hasFilterValue = isFilterActive(getFilter(c.key)); const filterOpen = openFilterKey === c.key; const headerContent = c.sortBy ? (
`-anchored icon) — a caller with a different +// layout renders its own trigger against the same `getFilter`/ +// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns. +export function useTableFilters( + columns: TableColumn[], + rows: T[], + storageKey: string, +) { + const [filters, setFilters] = useLocalSetting< + Record + >(storageKey, {}); + + const activeFilters = Object.entries(filters).filter(([, f]) => + isFilterActive(f), + ); + + function getFilter(key: string): ColumnFilterState { + return filters[key] ?? EMPTY_FILTER; + } + function updateFilter(key: string, patch: Partial) { + setFilters({ + ...filters, + [key]: { ...getFilter(key), ...patch }, + }); + } + function toggleFilterValue(key: string, value: string) { + const current = getFilter(key).values; + updateFilter(key, { + values: current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value], + }); + } + // mara: "have a small reset filters btn" — one button clears every + // column's filter at once rather than hunting down each popover + // individually. + function resetFilters() { + setFilters({}); + } + + // Distinct values currently present for a `"multiselect"`-mode column, + // so the checkbox list 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. A row contributes every one of its own + // values, not just one. + function multiselectOptionsFor(c: TableColumn): string[] { + if (!c.filterValues) return []; + const values = new Set(); + for (const row of rows) for (const v of c.filterValues(row)) values.add(v); + return Array.from(values).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }), + ); + } + + const visibleRows = useMemo(() => { + if (activeFilters.length === 0) return rows; + return rows.filter((row) => + activeFilters.every(([key, f]) => { + const col = columns.find((c) => c.key === key); + let matches: boolean; + if (col?.filterMode === "multiselect") { + const rowValues = col.filterValues?.(row) ?? []; + matches = f.values.some((v) => rowValues.includes(v)); + } else { + const value = col?.filterValue?.(row) ?? ""; + matches = value.toLowerCase().includes(f.value.toLowerCase()); + } + return f.negate ? !matches : matches; + }), + ); + // 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, JSON.stringify(activeFilters)]); + + return { + visibleRows, + activeFilters, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + multiselectOptionsFor, + }; +} + export function Table({ columns, rows, @@ -163,13 +259,18 @@ export function Table({ // 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 state per filterable column key, only populated for a column - // the operator has actually touched. Persisted (see `storageKey` - // above), same `useLocalSetting` plumbing `IssueReportPage` already - // used for its own now-folded-in label filter. - const [filters, setFilters] = useLocalSetting< - Record - >(storageKey, {}); + // Filter *state* lives in `useTableFilters` now (see its own comment) — + // this component only owns the popover-trigger UI below, table-shaped + // (a ``-anchored icon). + const { + visibleRows: filteredRows, + activeFilters, + getFilter, + updateFilter, + toggleFilterValue, + resetFilters, + multiselectOptionsFor, + } = useTableFilters(columns, rows, storageKey); // Which column's filter popover is open, if any — at most one at a // time (opening a second closes the first) so the header row never // shows more than one panel at once. @@ -228,34 +329,6 @@ export function Table({ }; }, [openFilterKey]); - const activeFilters = Object.entries(filters).filter(([, f]) => - isFilterActive(f), - ); - - function getFilter(key: string): ColumnFilterState { - return filters[key] ?? EMPTY_FILTER; - } - function updateFilter(key: string, patch: Partial) { - setFilters({ - ...filters, - [key]: { ...getFilter(key), ...patch }, - }); - } - function toggleFilterValue(key: string, value: string) { - const current = getFilter(key).values; - updateFilter(key, { - values: current.includes(value) - ? current.filter((v) => v !== value) - : [...current, value], - }); - } - // mara: "have a small reset filters btn" — one button clears every - // column's filter at once rather than hunting down each popover - // individually. - function resetFilters() { - setFilters({}); - } - // Close on outside click or Escape — same contract `Dropdown` gives // its own popover (../.../shared/src/dropdown/Dropdown.tsx), but // checked by CSS class rather than a ref: unlike `Dropdown`, there's @@ -282,40 +355,23 @@ export function Table({ }; }, [openFilterKey]); + // Sort applies on top of `useTableFilters`'s already-filtered rows — + // sorting stays table-only (no other view has asked for it), so it's + // layered here rather than folded into the shared hook. const visibleRows = useMemo(() => { - let out = rows; - if (activeFilters.length > 0) { - out = out.filter((row) => - activeFilters.every(([key, f]) => { - const col = columns.find((c) => c.key === key); - let matches: boolean; - if (col?.filterMode === "multiselect") { - const rowValues = col.filterValues?.(row) ?? []; - matches = f.values.some((v) => rowValues.includes(v)); - } else { - const value = col?.filterValue?.(row) ?? ""; - matches = value.toLowerCase().includes(f.value.toLowerCase()); - } - return f.negate ? !matches : matches; - }), - ); - } - 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; + if (!sort) return filteredRows; + const col = columns.find((c) => c.key === sort.key); + const sortBy = col?.sortBy; + if (!sortBy) return filteredRows; + return [...filteredRows].sort((a, b) => { + const cmp = compareValues(sortBy(a), sortBy(b)); + return sort.dir === "asc" ? cmp : -cmp; + }); // 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)]); + }, [filteredRows, sort]); function toggleSort(key: string) { setSort((prev) => { @@ -333,22 +389,6 @@ export function Table({ return sort.dir === "asc" ? "ascending" : "descending"; } - // Distinct values currently present for a `"multiselect"`-mode column, - // so the checkbox list 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. A row contributes every one of its own - // values, not just one. - function multiselectOptionsFor(c: TableColumn): string[] { - if (!c.filterValues) return []; - const values = new Set(); - for (const row of rows) for (const v of c.filterValues(row)) values.add(v); - return Array.from(values).sort((a, b) => - a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }), - ); - } - // Only one popover is ever open at a time, so one ref (rather than a // per-column ref map) is enough to focus whichever control just // mounted — typing immediately after the click that opened it, @@ -387,21 +427,16 @@ export function Table({ function renderFilterControl(c: TableColumn) { if (c.filterMode === "multiselect") { - const f = getFilter(c.key); + // Content shared with `AgentsPage`'s card-view filter toolbar — + // see `MultiselectFilter.tsx`'s own file-top comment for why this + // moved out of here. return ( <> -
- {multiselectOptionsFor(c).map((v) => ( - - ))} -
+ toggleFilterValue(c.key, v)} + /> {renderNegateToggle(c)} ); @@ -443,7 +478,7 @@ export function Table({