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
24
frontend/packages/swarm-ui/src/pages/AgentCard.css
Normal file
24
frontend/packages/swarm-ui/src/pages/AgentCard.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* <AgentCard> 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;
|
||||
}
|
||||
93
frontend/packages/swarm-ui/src/pages/AgentCard.tsx
Normal file
93
frontend/packages/swarm-ui/src/pages/AgentCard.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// <AgentCard> — 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 `<button>`s) from also firing `Card`'s
|
||||
// `onClick` and opening the detail panel.
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Card } from "../ui/card/Card.js";
|
||||
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentCard.css";
|
||||
|
||||
export function AgentCard({
|
||||
row,
|
||||
pending,
|
||||
error,
|
||||
selected,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
error: ProblemDetails | undefined;
|
||||
/** This row is the one currently shown in the detail panel — a
|
||||
* highlight only (the panel itself is the source of truth), so a
|
||||
* page-refresh landing on a still-valid selection reads as obviously
|
||||
* "that one" rather than a silent selection nothing marks. */
|
||||
selected: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onOpenDetail: (row: AgentRow) => void;
|
||||
}) {
|
||||
const { tone, label } = FRESHNESS[row.freshness];
|
||||
return (
|
||||
<Card selected={selected} onClick={() => onOpenDetail(row)}>
|
||||
<div class="ui-agent-card-line1">
|
||||
<span class="ui-agent-card-name">{row.name}</span>
|
||||
<Badge
|
||||
tone={tone}
|
||||
title={
|
||||
row.freshness === "unknown"
|
||||
? "reported by its hive but not registered in swarm-level identity — needs migration"
|
||||
: undefined
|
||||
}
|
||||
value={
|
||||
<>
|
||||
{label}
|
||||
{row.last_seen_unix !== null ? (
|
||||
<>
|
||||
{" "}
|
||||
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<span class="ui-agent-card-wanted" onClick={(e) => e.stopPropagation()}>
|
||||
<WantedMenu
|
||||
row={row}
|
||||
pending={pending}
|
||||
showDestroy={false}
|
||||
onSelectUp={onSelectUp}
|
||||
onSelectOffline={onSelectOffline}
|
||||
onSelectPaused={onSelectPaused}
|
||||
/>
|
||||
{error ? (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="failed"
|
||||
title={error.detail ?? "the declaration failed"}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-agent-card-message">
|
||||
{row.snapshot?.status_text ?? "—"}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
61
frontend/packages/swarm-ui/src/pages/AgentTypes.ts
Normal file
61
frontend/packages/swarm-ui/src/pages/AgentTypes.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Wire + presentation types shared across AgentsPage and the components
|
||||
// it composes (AgentCard, WantedMenu) — split out once those moved to
|
||||
// their own files (mara: "agentspage is now giant and deserves a
|
||||
// split") so none of them has to import from AgentsPage.tsx itself.
|
||||
import type { BadgeTone } from "@hive/shared/badge.js";
|
||||
|
||||
export interface ConfigPrStatus {
|
||||
pr_number: number;
|
||||
html_url: string | null;
|
||||
}
|
||||
|
||||
export type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||
|
||||
export interface AgentStatusSnapshot {
|
||||
status_text: string | null;
|
||||
status_set_at: number | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
|
||||
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
|
||||
// `string | null` rather than a union, same reason `config_pr`'s shape
|
||||
// isn't re-derived here: this renders whatever the wire sends, it
|
||||
// doesn't validate the enum client-side.
|
||||
export type Wanted = string | null;
|
||||
|
||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is*
|
||||
// the row AgentsPage renders, not a shape assembled from it, so
|
||||
// there's no separate join-result type to keep in sync with the wire
|
||||
// contract by hand.
|
||||
export interface AgentRow {
|
||||
name: string;
|
||||
hive: string | null;
|
||||
freshness: Freshness;
|
||||
// `age_seconds` deliberately unused here, same reason as HivesPage:
|
||||
// `RelativeTime` recomputes age client-side from `last_seen_unix`
|
||||
// rather than rendering a once-computed-at-fetch value.
|
||||
last_seen_unix: number | null;
|
||||
snapshot: AgentStatusSnapshot | null;
|
||||
config_pr: ConfigPrStatus | null;
|
||||
wanted: Wanted;
|
||||
}
|
||||
|
||||
// Same tone pairing as HivesPage — one freshness enum shared by both
|
||||
// endpoints, so the same tone rule applies to both pages — but
|
||||
// `unknown`'s label diverges deliberately: an agent outside the
|
||||
// swarm-identity roster has a concrete next step (register it), while
|
||||
// an unrecognized *hive* on HivesPage doesn't carry the same
|
||||
// actionable meaning.
|
||||
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
|
||||
{
|
||||
fresh: { tone: "positive", label: "fresh" },
|
||||
stale: { tone: "warning", label: "stale" },
|
||||
never_reported: { tone: "neutral", label: "never reported" },
|
||||
// `unknown` means the agent reported into the KV bucket but isn't in
|
||||
// the swarm-identity roster — a real, distinct case ("hive-reported,
|
||||
// not yet in swarm level"), not just a fallback default, so it gets a
|
||||
// label that says what to do about it rather than reusing the enum
|
||||
// name as the label.
|
||||
unknown: { tone: "negative", label: "not in swarm identity" },
|
||||
};
|
||||
|
|
@ -1,18 +1,7 @@
|
|||
/* <AgentCard> list — a vertical stack of per-agent cards replacing the
|
||||
old table body (mara: "more like card per agent"). Full-width rows,
|
||||
not a grid: the second line (the agent's free-form status message)
|
||||
reads better wrapping the card's own width than squeezed into a fixed
|
||||
grid cell. Each card's own clickable/selected/border styling is
|
||||
`Card`'s (`ui/card/`) — this file only lays out agent-specific
|
||||
content inside one. */
|
||||
.ui-agent-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
/* Shared by both views (`Table` has its own `emptyMessage` rendering,
|
||||
but the card view has nothing equivalent — this covers both, and
|
||||
`AgentsPage` only mounts it when `rows` is loaded and empty). */
|
||||
/* Detail panel's "select an agent" empty state. `FilterableView`
|
||||
(`ui/filterable-view/`) has its own equivalent for its two empty
|
||||
cases — colocated there instead of shared with this one, since the
|
||||
two components have no other reason to depend on each other's CSS. */
|
||||
.ui-agents-empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
|
|
@ -47,27 +36,6 @@
|
|||
.ui-agents-view-toggle button:not(.active):hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||
`title` prop (the agent's name), not repeated in here. */
|
||||
|
|
@ -88,25 +56,3 @@
|
|||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
|
||||
table to hang them off of. Each `MultiselectFilter` (`ui/
|
||||
multiselect-filter/`) owns its own trigger/popover styling; this file
|
||||
only lays out the row they sit in plus the name-search input, which
|
||||
has no shared-component equivalent (a plain text input). */
|
||||
.ui-agents-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.ui-agents-filter-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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,28 +18,24 @@
|
|||
// natural home for the action that populates it. The form itself
|
||||
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
|
||||
//
|
||||
// Two interchangeable views over the same `rows`/actions, picked by
|
||||
// `viewMode`: the original `Table` (full columns, per-column sort/
|
||||
// filter) and one `AgentCard` per row (see that component's own
|
||||
// comment for the card/detail-panel split). Mara, after the card view
|
||||
// landed: "still want the filters tho" / "split data component from
|
||||
// the view... switchable between table and specialized card view" —
|
||||
// so the toggle, not a card-only replacement.
|
||||
//
|
||||
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||
// that component's own comment above `AgentsPage` for why.
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
// This file owns the page's own state/actions/`columns` only —
|
||||
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
|
||||
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
|
||||
// (mara: "agentspage is now giant and deserves a split"). Filtering +
|
||||
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
|
||||
// see its own comment) — `viewMode` here just says which one to show.
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { AgentCard } from "./AgentCard.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { Button } from "../ui/button/Button.js";
|
||||
import { Card } from "../ui/card/Card.js";
|
||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||
import { MultiselectFilter } from "../ui/multiselect-filter/MultiselectFilter.js";
|
||||
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
|
||||
import { Panel } from "../ui/panel/Panel.js";
|
||||
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
||||
import {
|
||||
|
|
@ -48,65 +44,12 @@ import {
|
|||
type RefreshIntervalMs,
|
||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||
import { SplitView } from "../ui/split-view/SplitView.js";
|
||||
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
|
||||
import { type TableColumn } from "../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
interface ConfigPrStatus {
|
||||
pr_number: number;
|
||||
html_url: string | null;
|
||||
}
|
||||
|
||||
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||
|
||||
interface AgentStatusSnapshot {
|
||||
status_text: string | null;
|
||||
status_set_at: number | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
|
||||
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
|
||||
// `string | null` rather than a union, same reason
|
||||
// `config_pr`'s shape isn't re-derived here: this page renders whatever
|
||||
// the wire sends, it doesn't validate the enum client-side.
|
||||
type Wanted = string | null;
|
||||
|
||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||
// row this page renders, not a shape assembled from it, so there's no
|
||||
// separate join-result type to keep in sync with the wire contract by
|
||||
// hand.
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
hive: string | null;
|
||||
freshness: Freshness;
|
||||
last_seen_unix: number | null;
|
||||
// `age_seconds` deliberately unused here, same reason as HivesPage:
|
||||
// `RelativeTime` recomputes age client-side from `last_seen_unix`
|
||||
// rather than rendering a once-computed-at-fetch value.
|
||||
snapshot: AgentStatusSnapshot | null;
|
||||
config_pr: ConfigPrStatus | null;
|
||||
wanted: Wanted;
|
||||
}
|
||||
|
||||
// Same tone pairing as HivesPage — one freshness enum shared by both
|
||||
// endpoints, so the same tone rule applies to both pages — but `unknown`'s
|
||||
// label diverges deliberately: an agent outside the swarm-identity roster
|
||||
// has a concrete next step (register it), while an unrecognized *hive* on
|
||||
// HivesPage doesn't carry the same actionable meaning.
|
||||
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||
fresh: { tone: "positive", label: "fresh" },
|
||||
stale: { tone: "warning", label: "stale" },
|
||||
never_reported: { tone: "neutral", label: "never reported" },
|
||||
// `unknown` means the agent reported into the KV bucket but isn't in
|
||||
// the swarm-identity roster — a real, distinct case ("hive-reported,
|
||||
// not yet in swarm level"), not just a fallback default, so it gets a
|
||||
// label that says what to do about it rather than reusing the enum
|
||||
// name as the label.
|
||||
unknown: { tone: "negative", label: "not in swarm identity" },
|
||||
};
|
||||
|
||||
// The one thing that differs between the "offline" and "paused" confirm
|
||||
// dialogs `confirmTarget` drives — everything else (button row, open/close
|
||||
// wiring) is the shared `ConfirmDialog`.
|
||||
|
|
@ -151,304 +94,6 @@ type ViewMode = "cards" | "table";
|
|||
// a chosen view is a standing preference, not a per-visit default.
|
||||
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||
|
||||
// The "wanted" column's 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 (`declareState`'s
|
||||
// backend counterpart, `hive-c0re`'s `decide_pause`, 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.
|
||||
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` below) 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 `<dialog>` (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<HTMLDivElement>(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 (
|
||||
<div class="ui-dropdown-anchor" ref={anchorRef}>
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={pending ? "…" : (row.wanted ?? "no declaration")}
|
||||
// `destroyed` blocks opening the menu at all — same UX-only
|
||||
// guard as before (real enforcement is server-side, see
|
||||
// `declareState`'s caller), just applied to the trigger instead
|
||||
// of a `disabled` toggle badge.
|
||||
onClick={row.hive && !destroyed ? () => 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"
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
portal={portal}
|
||||
options={options}
|
||||
activeValue={row.wanted ?? undefined}
|
||||
label={`declare ${row.name}`}
|
||||
onSelect={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One card per roster agent (mara: "main view: name, status, message,
|
||||
// wanted" / "message as second line" / "more like card per agent") —
|
||||
// the `viewMode === "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. A plain `Dialog`, not the shared `hive-side-panel`
|
||||
// slide-in drawer: that's a shadow-DOM custom element, and swarm-ui's
|
||||
// esbuild config can't consume those yet (same gap `Dialog.tsx`'s own
|
||||
// comment already flags for `hive-dialog`). Revisit once that closes.
|
||||
//
|
||||
// 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 `<button>`s) from also firing `Card`'s
|
||||
// `onClick` and opening the detail panel.
|
||||
function AgentCard({
|
||||
row,
|
||||
pending,
|
||||
error,
|
||||
selected,
|
||||
onSelectUp,
|
||||
onSelectOffline,
|
||||
onSelectPaused,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
row: AgentRow;
|
||||
pending: boolean;
|
||||
error: ProblemDetails | undefined;
|
||||
/** This row is the one currently shown in the detail panel — a
|
||||
* highlight only (the panel itself is the source of truth), so a
|
||||
* page-refresh landing on a still-valid `detailTarget` reads as
|
||||
* obviously "that one" rather than a silent selection nothing marks. */
|
||||
selected: boolean;
|
||||
onSelectUp: (row: AgentRow) => void;
|
||||
onSelectOffline: (row: AgentRow) => void;
|
||||
onSelectPaused: (row: AgentRow) => void;
|
||||
onOpenDetail: (row: AgentRow) => void;
|
||||
}) {
|
||||
const { tone, label } = FRESHNESS[row.freshness];
|
||||
return (
|
||||
<Card selected={selected} onClick={() => onOpenDetail(row)}>
|
||||
<div class="ui-agent-card-line1">
|
||||
<span class="ui-agent-card-name">{row.name}</span>
|
||||
<Badge
|
||||
tone={tone}
|
||||
title={
|
||||
row.freshness === "unknown"
|
||||
? "reported by its hive but not registered in swarm-level identity — needs migration"
|
||||
: undefined
|
||||
}
|
||||
value={
|
||||
<>
|
||||
{label}
|
||||
{row.last_seen_unix !== null ? (
|
||||
<>
|
||||
{" "}
|
||||
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<span class="ui-agent-card-wanted" onClick={(e) => e.stopPropagation()}>
|
||||
<WantedMenu
|
||||
row={row}
|
||||
pending={pending}
|
||||
showDestroy={false}
|
||||
onSelectUp={onSelectUp}
|
||||
onSelectOffline={onSelectOffline}
|
||||
onSelectPaused={onSelectPaused}
|
||||
/>
|
||||
{error ? (
|
||||
<Badge
|
||||
tone="negative"
|
||||
value="failed"
|
||||
title={error.detail ?? "the declaration failed"}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-agent-card-message">
|
||||
{row.snapshot?.status_text ?? "—"}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// One `<input>` (the "name" column's plain-text filter) plus one shared
|
||||
// `MultiselectFilter` (`ui/multiselect-filter/`) per multiselect-mode
|
||||
// filterable column — the card view's answer to `Table`'s own
|
||||
// per-column popover icons, driving the exact same `useTableFilters`
|
||||
// state `AgentsPage` passes down (mara: "same filters for the cards
|
||||
// tho"). Deliberately not a generic "render every filterable column
|
||||
// automatically" loop: the name column's free-text filter needs its own
|
||||
// always-visible `<input>`, not a dropdown, so there's no single shape
|
||||
// that covers every column anyway.
|
||||
function AgentFilterBar({
|
||||
columns,
|
||||
getFilter,
|
||||
updateFilter,
|
||||
toggleFilterValue,
|
||||
resetFilters,
|
||||
activeFilters,
|
||||
multiselectOptionsFor,
|
||||
}: {
|
||||
columns: TableColumn<AgentRow>[];
|
||||
getFilter: (key: string) => {
|
||||
value: string;
|
||||
values: string[];
|
||||
negate: boolean;
|
||||
};
|
||||
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<AgentRow>) => string[];
|
||||
}) {
|
||||
const hiveCol = columns.find((c) => c.key === "hive");
|
||||
const statusCol = columns.find((c) => c.key === "status");
|
||||
const wantedCol = columns.find((c) => c.key === "wanted");
|
||||
return (
|
||||
<div class="ui-agents-filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="ui-agents-filter-search"
|
||||
placeholder="filter by name…"
|
||||
aria-label="filter by name"
|
||||
value={getFilter("name").value}
|
||||
onInput={(e) =>
|
||||
updateFilter("name", {
|
||||
value: (e.target as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{hiveCol ? (
|
||||
<MultiselectFilter
|
||||
label="hive"
|
||||
options={multiselectOptionsFor(hiveCol)}
|
||||
filter={getFilter("hive")}
|
||||
onToggle={(v) => toggleFilterValue("hive", v)}
|
||||
onNegateChange={(negate) => updateFilter("hive", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{statusCol ? (
|
||||
<MultiselectFilter
|
||||
label="status"
|
||||
options={multiselectOptionsFor(statusCol)}
|
||||
filter={getFilter("status")}
|
||||
onToggle={(v) => toggleFilterValue("status", v)}
|
||||
onNegateChange={(negate) => updateFilter("status", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{wantedCol ? (
|
||||
<MultiselectFilter
|
||||
label="wanted"
|
||||
options={multiselectOptionsFor(wantedCol)}
|
||||
filter={getFilter("wanted")}
|
||||
onToggle={(v) => toggleFilterValue("wanted", v)}
|
||||
onNegateChange={(negate) => updateFilter("wanted", { negate })}
|
||||
/>
|
||||
) : null}
|
||||
{activeFilters.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
class="ui-table-reset-filters"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
reset filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -628,9 +273,10 @@ export function AgentsPage() {
|
|||
const selectPaused = (row: AgentRow) =>
|
||||
setConfirmTarget({ row, state: "paused" });
|
||||
|
||||
// `viewMode === "table"`'s columns — the full field set (including
|
||||
// per-column sort/filter, which the card view doesn't have) mara asked
|
||||
// to keep. Table's own `WantedMenu` keeps the default `showDestroy`
|
||||
// `FilterableView`'s `columns` — the full field set (including
|
||||
// per-column sort/filter, which drives both the table's own popovers
|
||||
// and the card view's filter bar now — see that component's own
|
||||
// comment). Table's own `WantedMenu` keeps the default `showDestroy`
|
||||
// (the table's cells have room for the fourth option the card view
|
||||
// moved out to its detail panel).
|
||||
const columns: TableColumn<AgentRow>[] = [
|
||||
|
|
@ -760,21 +406,6 @@ export function AgentsPage() {
|
|||
},
|
||||
];
|
||||
|
||||
// Same hook `Table` itself now uses internally, called again here with
|
||||
// the identical `columns` + `storageKey` — one filter state (in
|
||||
// localStorage), two independent readers. `rows ?? []`: the hook wants
|
||||
// a real array, and there's nothing to filter before the first
|
||||
// `refresh()` resolves anyway.
|
||||
const {
|
||||
visibleRows,
|
||||
activeFilters,
|
||||
getFilter,
|
||||
updateFilter,
|
||||
toggleFilterValue,
|
||||
resetFilters,
|
||||
multiselectOptionsFor,
|
||||
} = useTableFilters(columns, rows ?? [], "swarm-ui:agents:table-filters");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplitView
|
||||
|
|
@ -824,57 +455,26 @@ export function AgentsPage() {
|
|||
/>
|
||||
) : null}
|
||||
{!error && rows === null ? <p>loading…</p> : null}
|
||||
{rows && rows.length === 0 ? (
|
||||
<p class="ui-agents-empty">
|
||||
no agents yet — the swarm-wide identity store has no agents
|
||||
registered on any hive
|
||||
</p>
|
||||
) : null}
|
||||
{/* Card view's own filter toolbar — table view keeps its
|
||||
existing per-column popovers instead (see `columns` above);
|
||||
both read/write the same `useTableFilters` state (same
|
||||
`storageKey`), so switching the view toggle doesn't reset or
|
||||
hide whatever's filtered. */}
|
||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||
<AgentFilterBar
|
||||
columns={columns}
|
||||
getFilter={getFilter}
|
||||
updateFilter={updateFilter}
|
||||
toggleFilterValue={toggleFilterValue}
|
||||
resetFilters={resetFilters}
|
||||
activeFilters={activeFilters}
|
||||
multiselectOptionsFor={multiselectOptionsFor}
|
||||
/>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||
<div class="ui-agent-card-list">
|
||||
{visibleRows.length === 0 ? (
|
||||
<p class="ui-agents-empty">
|
||||
no rows match the current filter
|
||||
</p>
|
||||
) : (
|
||||
visibleRows.map((a) => (
|
||||
<AgentCard
|
||||
key={a.name}
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
selected={detailTargetName === a.name}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{rows && rows.length > 0 && viewMode === "table" ? (
|
||||
<Table
|
||||
{rows ? (
|
||||
<FilterableView
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(a) => 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) => (
|
||||
<AgentCard
|
||||
row={a}
|
||||
pending={pendingAgents.has(a.name)}
|
||||
error={actionErrors.get(a.name)}
|
||||
selected={detailTargetName === a.name}
|
||||
onSelectUp={selectUp}
|
||||
onSelectOffline={selectOffline}
|
||||
onSelectPaused={selectPaused}
|
||||
onOpenDetail={(row) => setDetailTargetName(row.name)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
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,
|
||||
|
|
@ -243,6 +244,46 @@ function MultiselectFilterSample() {
|
|||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
<div class="components-chip-row" style={{ marginBottom: "0.5em" }}>
|
||||
<Button
|
||||
variant={view === "cards" ? "primary" : "default"}
|
||||
onClick={() => setView("cards")}
|
||||
>
|
||||
cards
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === "table" ? "primary" : "default"}
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
table
|
||||
</Button>
|
||||
</div>
|
||||
<FilterableView
|
||||
columns={TABLE_COLUMNS}
|
||||
rows={TABLE_ROWS}
|
||||
rowKey={(r) => r.name}
|
||||
storageKey="swarm-ui:components-demo:filterable-view"
|
||||
view={view}
|
||||
emptyMessage="no rows"
|
||||
renderCard={(r) => (
|
||||
<Card onClick={() => {}}>
|
||||
<strong>{r.name}</strong>
|
||||
<div>{r.detail}</div>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialogSample() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
|
|
@ -365,6 +406,12 @@ export function ComponentsPage() {
|
|||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="FilterableView">
|
||||
<Sample label="filter bar (built from every filterable column) + cards/table, same filter state either way">
|
||||
<FilterableViewSample />
|
||||
</Sample>
|
||||
</Section>
|
||||
|
||||
<Section title="RelativeTime">
|
||||
<div class="components-chip-row">
|
||||
<Sample label="5s ago, ticking">
|
||||
|
|
|
|||
124
frontend/packages/swarm-ui/src/pages/WantedMenu.tsx
Normal file
124
frontend/packages/swarm-ui/src/pages/WantedMenu.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// <WantedMenu> — 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 `<dialog>` (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<HTMLDivElement>(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 (
|
||||
<div class="ui-dropdown-anchor" ref={anchorRef}>
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={pending ? "…" : (row.wanted ?? "no declaration")}
|
||||
// `destroyed` blocks opening the menu at all — same UX-only
|
||||
// guard as before (real enforcement is server-side, see
|
||||
// `declareState`'s caller), just applied to the trigger instead
|
||||
// of a `disabled` toggle badge.
|
||||
onClick={row.hive && !destroyed ? () => 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"
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
portal={portal}
|
||||
options={options}
|
||||
activeValue={row.wanted ?? undefined}
|
||||
label={`declare ${row.name}`}
|
||||
onSelect={(value) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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