swarm-ui/agents: shared filters across views, real second panel for detail
Two more asks from mara's live review: - "i want the same filters for the cards tho, thats why i suggested separating data and filter from view" - extracted Table's filter *state* (not its popover UI, which stays table-shaped) into a new exported useTableFilters hook. Table calls it internally, unchanged behavior for every existing caller. AgentsPage now calls the same hook with the same storageKey, so card view and table view read/write one shared filter state instead of each having their own (or cards having none at all). Card view gets its own toolbar (AgentFilterBar) - a name search input plus one FilterMultiselect per multiselect column, same checkbox-list markup Table's own popover uses, driving the same state. Switching the view toggle no longer loses or hides whatever's filtered. - "why no separate panel? i mean a second panel on agent page" - replaced the modal Dialog with a real second Panel, always mounted (empty state when nothing's selected, so selecting an agent never shifts the page's own layout). Panel gained an optional `class` prop so the two panels can flex-size themselves in a row. List/detail panels sit side by side in a flex-wrap row that stacks on a narrow viewport - content-driven, same approach the shell's own nav uses, not a second hardcoded breakpoint. Selected card gets a highlight so it's clear which one the detail panel is showing. Verified with real CDP clicks: split layout with nothing selected, selecting a card highlights it and populates the detail panel, opening a card-view filter and checking a value narrows both the card list AND (after switching the toggle) the table to the identical row set.
This commit is contained in:
parent
8786f24111
commit
60d7e8ce84
4 changed files with 586 additions and 263 deletions
|
|
@ -60,6 +60,14 @@
|
||||||
border-color: var(--purple);
|
border-color: var(--purple);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
/* The row currently shown in the detail panel — a solid border (not
|
||||||
|
just the hover tint above, which needs to keep meaning "hovering",
|
||||||
|
not double as "selected") plus a faint fill so it still reads once
|
||||||
|
the pointer moves away. */
|
||||||
|
.ui-agent-card-selected {
|
||||||
|
border-color: var(--purple);
|
||||||
|
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
|
||||||
|
}
|
||||||
.ui-agent-card-line1 {
|
.ui-agent-card-line1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -82,11 +90,8 @@
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail panel (`Dialog` body) — the fields `AgentCard`'s main view
|
/* Detail panel body — its own `Panel`, title supplied by the panel's
|
||||||
doesn't show. */
|
`title` prop (the agent's name), not repeated in here. */
|
||||||
.ui-agent-detail-name {
|
|
||||||
margin: 0 0 0.75em;
|
|
||||||
}
|
|
||||||
.ui-agent-detail-fields {
|
.ui-agent-detail-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|
@ -104,3 +109,55 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75em;
|
gap: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* List panel + detail panel side by side — `flex-wrap`, not a
|
||||||
|
`@media` breakpoint, so a narrow viewport stacks them the same
|
||||||
|
content-driven way the shell's own nav already wraps (`Shell.css`),
|
||||||
|
rather than picking a second, independent magic-number breakpoint. */
|
||||||
|
.ui-agents-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ui-agents-list-panel {
|
||||||
|
flex: 2 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ui-agents-detail-panel {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the
|
||||||
|
table to hang them off of. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.ui-agents-filter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome
|
||||||
|
(background/border/shadow) — this just positions it under the
|
||||||
|
trigger, `position: absolute` rather than the fixed/portal recipe
|
||||||
|
`Table`'s own version needs: nothing here clips this popover, so the
|
||||||
|
simpler positioning is enough (see this component's own comment). */
|
||||||
|
.ui-agents-filter-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
//
|
//
|
||||||
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see
|
||||||
// that component's own comment above `AgentsPage` for why.
|
// that component's own comment above `AgentsPage` for why.
|
||||||
import { useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
|
@ -45,7 +45,7 @@ import {
|
||||||
useRefreshInterval,
|
useRefreshInterval,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from "../ui/refresh-interval/RefreshInterval.js";
|
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
|
||||||
import { CreateAgentForm } from "./CreateAgentForm.js";
|
import { CreateAgentForm } from "./CreateAgentForm.js";
|
||||||
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
|
||||||
import "./AgentsPage.css";
|
import "./AgentsPage.css";
|
||||||
|
|
@ -289,6 +289,7 @@ function AgentCard({
|
||||||
row,
|
row,
|
||||||
pending,
|
pending,
|
||||||
error,
|
error,
|
||||||
|
selected,
|
||||||
onSelectUp,
|
onSelectUp,
|
||||||
onSelectOffline,
|
onSelectOffline,
|
||||||
onSelectPaused,
|
onSelectPaused,
|
||||||
|
|
@ -297,6 +298,11 @@ function AgentCard({
|
||||||
row: AgentRow;
|
row: AgentRow;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
error: ProblemDetails | undefined;
|
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;
|
onSelectUp: (row: AgentRow) => void;
|
||||||
onSelectOffline: (row: AgentRow) => void;
|
onSelectOffline: (row: AgentRow) => void;
|
||||||
onSelectPaused: (row: AgentRow) => void;
|
onSelectPaused: (row: AgentRow) => void;
|
||||||
|
|
@ -305,7 +311,9 @@ function AgentCard({
|
||||||
const { tone, label } = FRESHNESS[row.freshness];
|
const { tone, label } = FRESHNESS[row.freshness];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="ui-agent-card"
|
class={
|
||||||
|
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
|
||||||
|
}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onOpenDetail(row)}
|
onClick={() => onOpenDetail(row)}
|
||||||
|
|
@ -363,6 +371,180 @@ function AgentCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One `<input>` (the "name" column's plain-text filter) plus one
|
||||||
|
// `FilterMultiselect` 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 ? (
|
||||||
|
<FilterMultiselect
|
||||||
|
label="hive"
|
||||||
|
options={multiselectOptionsFor(hiveCol)}
|
||||||
|
filter={getFilter("hive")}
|
||||||
|
onToggle={(v) => toggleFilterValue("hive", v)}
|
||||||
|
onNegateChange={(negate) => updateFilter("hive", { negate })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{statusCol ? (
|
||||||
|
<FilterMultiselect
|
||||||
|
label="status"
|
||||||
|
options={multiselectOptionsFor(statusCol)}
|
||||||
|
filter={getFilter("status")}
|
||||||
|
onToggle={(v) => toggleFilterValue("status", v)}
|
||||||
|
onNegateChange={(negate) => updateFilter("status", { negate })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{wantedCol ? (
|
||||||
|
<FilterMultiselect
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One multiselect filter's trigger + popover — same checkbox-list markup
|
||||||
|
// `Table`'s own popover renders (`.ui-table-filter-checkbox`/
|
||||||
|
// `-negate`, reused rather than duplicated), just `position: absolute`
|
||||||
|
// under a `Badge` trigger instead of a portal: this never sits inside a
|
||||||
|
// clipping scroll container or a `<dialog>` the way `WantedMenu`'s
|
||||||
|
// portal mode has to route around, so the simpler positioning is enough.
|
||||||
|
function FilterMultiselect({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
filter,
|
||||||
|
onToggle,
|
||||||
|
onNegateChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
filter: { values: string[]; negate: boolean };
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
onNegateChange: (negate: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
||||||
|
<div class="ui-agents-filter" ref={ref}>
|
||||||
|
<Badge
|
||||||
|
variant="quiet"
|
||||||
|
value={
|
||||||
|
filter.values.length > 0
|
||||||
|
? `${label} (${filter.values.length})`
|
||||||
|
: label
|
||||||
|
}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
expanded={open}
|
||||||
|
/>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
class="ui-agents-filter-popover ui-table-filter-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`filter by ${label}`}
|
||||||
|
>
|
||||||
|
<div class="ui-table-filter-multiselect">
|
||||||
|
{options.map((v) => (
|
||||||
|
<label key={v} class="ui-table-filter-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filter.values.includes(v)}
|
||||||
|
onChange={() => onToggle(v)}
|
||||||
|
/>
|
||||||
|
{v}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label class="ui-table-filter-negate">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filter.negate}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNegateChange((e.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
exclude
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
@ -666,186 +848,226 @@ 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 (
|
return (
|
||||||
<Panel
|
<>
|
||||||
title="agents"
|
<div class="ui-agents-layout">
|
||||||
icon="👥"
|
<Panel
|
||||||
actions={
|
title="agents"
|
||||||
<>
|
icon="👥"
|
||||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
class="ui-agents-list-panel"
|
||||||
+ agent
|
actions={
|
||||||
</Button>
|
<>
|
||||||
<div class="ui-agents-view-toggle" role="group" aria-label="view">
|
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||||
<button
|
+ agent
|
||||||
type="button"
|
</Button>
|
||||||
aria-pressed={viewMode === "cards"}
|
<div class="ui-agents-view-toggle" role="group" aria-label="view">
|
||||||
class={viewMode === "cards" ? "active" : undefined}
|
<button
|
||||||
onClick={() => setViewMode("cards")}
|
type="button"
|
||||||
>
|
aria-pressed={viewMode === "cards"}
|
||||||
cards
|
class={viewMode === "cards" ? "active" : undefined}
|
||||||
</button>
|
onClick={() => setViewMode("cards")}
|
||||||
<button
|
>
|
||||||
type="button"
|
cards
|
||||||
aria-pressed={viewMode === "table"}
|
</button>
|
||||||
class={viewMode === "table" ? "active" : undefined}
|
<button
|
||||||
onClick={() => setViewMode("table")}
|
type="button"
|
||||||
>
|
aria-pressed={viewMode === "table"}
|
||||||
table
|
class={viewMode === "table" ? "active" : undefined}
|
||||||
</button>
|
onClick={() => setViewMode("table")}
|
||||||
</div>
|
>
|
||||||
<RefreshIntervalPicker
|
table
|
||||||
id="agents-refresh"
|
</button>
|
||||||
value={intervalMs}
|
</div>
|
||||||
onChange={setIntervalMs}
|
<RefreshIntervalPicker
|
||||||
/>
|
id="agents-refresh"
|
||||||
</>
|
value={intervalMs}
|
||||||
}
|
onChange={setIntervalMs}
|
||||||
>
|
/>
|
||||||
{error ? (
|
</>
|
||||||
<ApiErrorPanel
|
}
|
||||||
context="failed to load the agent roster"
|
>
|
||||||
problem={error}
|
{error ? (
|
||||||
/>
|
<ApiErrorPanel
|
||||||
) : null}
|
context="failed to load the agent roster"
|
||||||
{!error && rows === null ? <p>loading…</p> : null}
|
problem={error}
|
||||||
{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}
|
|
||||||
{rows && rows.length > 0 && viewMode === "cards" ? (
|
|
||||||
<div class="ui-agent-card-list">
|
|
||||||
{rows.map((a) => (
|
|
||||||
<AgentCard
|
|
||||||
key={a.name}
|
|
||||||
row={a}
|
|
||||||
pending={pendingAgents.has(a.name)}
|
|
||||||
error={actionErrors.get(a.name)}
|
|
||||||
onSelectUp={selectUp}
|
|
||||||
onSelectOffline={selectOffline}
|
|
||||||
onSelectPaused={selectPaused}
|
|
||||||
onOpenDetail={setDetailTarget}
|
|
||||||
/>
|
/>
|
||||||
))}
|
) : null}
|
||||||
</div>
|
{!error && rows === null ? <p>loading…</p> : null}
|
||||||
) : null}
|
{rows && rows.length === 0 ? (
|
||||||
{rows && rows.length > 0 && viewMode === "table" ? (
|
<p class="ui-agents-empty">
|
||||||
<Table
|
no agents yet — the swarm-wide identity store has no agents
|
||||||
columns={columns}
|
registered on any hive
|
||||||
rows={rows}
|
</p>
|
||||||
rowKey={(a) => a.name}
|
) : null}
|
||||||
storageKey="swarm-ui:agents:table-filters"
|
{/* Card view's own filter toolbar — table view keeps its
|
||||||
/>
|
existing per-column popovers instead (see `columns` above);
|
||||||
) : null}
|
both read/write the same `useTableFilters` state (same
|
||||||
<Dialog
|
`storageKey`), so switching the view toggle doesn't reset or
|
||||||
open={detailTarget !== null}
|
hide whatever's filtered. */}
|
||||||
onClose={() => setDetailTarget(null)}
|
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||||
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
|
<AgentFilterBar
|
||||||
>
|
columns={columns}
|
||||||
{/* Mara: "the info from main list should be included in the
|
getFilter={getFilter}
|
||||||
agent view" — so this repeats status/message/wanted rather
|
updateFilter={updateFilter}
|
||||||
than showing only what `AgentCard`'s main view doesn't, plus
|
toggleFilterValue={toggleFilterValue}
|
||||||
hive/config-PR/matrix-link, which really are panel-only. */}
|
resetFilters={resetFilters}
|
||||||
{detailTarget ? (
|
activeFilters={activeFilters}
|
||||||
<div class="ui-agent-detail">
|
multiselectOptionsFor={multiselectOptionsFor}
|
||||||
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
|
/>
|
||||||
<dl class="ui-agent-detail-fields">
|
) : null}
|
||||||
<dt>status</dt>
|
{rows && rows.length > 0 && viewMode === "cards" ? (
|
||||||
<dd>
|
<div class="ui-agent-card-list">
|
||||||
{(() => {
|
{visibleRows.length === 0 ? (
|
||||||
const { tone, label } = FRESHNESS[detailTarget.freshness];
|
<p class="ui-agents-empty">no rows match the current filter</p>
|
||||||
return (
|
) : (
|
||||||
|
visibleRows.map((a) => (
|
||||||
|
<AgentCard
|
||||||
|
key={a.name}
|
||||||
|
row={a}
|
||||||
|
pending={pendingAgents.has(a.name)}
|
||||||
|
error={actionErrors.get(a.name)}
|
||||||
|
selected={detailTarget?.name === a.name}
|
||||||
|
onSelectUp={selectUp}
|
||||||
|
onSelectOffline={selectOffline}
|
||||||
|
onSelectPaused={selectPaused}
|
||||||
|
onOpenDetail={setDetailTarget}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{rows && rows.length > 0 && viewMode === "table" ? (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(a) => a.name}
|
||||||
|
storageKey="swarm-ui:agents:table-filters"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
{/* 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. `.ui-agents-layout`'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. */}
|
||||||
|
<Panel
|
||||||
|
title={detailTarget ? detailTarget.name : "agent details"}
|
||||||
|
icon="🔎"
|
||||||
|
class="ui-agents-detail-panel"
|
||||||
|
>
|
||||||
|
{detailTarget ? (
|
||||||
|
<div class="ui-agent-detail">
|
||||||
|
<dl class="ui-agent-detail-fields">
|
||||||
|
<dt>status</dt>
|
||||||
|
<dd>
|
||||||
|
{(() => {
|
||||||
|
const { tone, label } = FRESHNESS[detailTarget.freshness];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
tone={tone}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
{detailTarget.last_seen_unix !== null ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
<RelativeTime
|
||||||
|
epochMs={detailTarget.last_seen_unix * 1000}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</dd>
|
||||||
|
<dt>message</dt>
|
||||||
|
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
|
||||||
|
<dt>wanted</dt>
|
||||||
|
<dd>
|
||||||
|
{/* Full menu (destroy included) — mara: "destroy is
|
||||||
|
already available via wanted state", no separate
|
||||||
|
button needed. */}
|
||||||
|
<WantedMenu
|
||||||
|
row={detailTarget}
|
||||||
|
pending={pendingAgents.has(detailTarget.name)}
|
||||||
|
onSelectUp={selectUp}
|
||||||
|
onSelectOffline={selectOffline}
|
||||||
|
onSelectPaused={selectPaused}
|
||||||
|
onDestroy={setDestroyTarget}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
<dt>hive</dt>
|
||||||
|
<dd>{detailTarget.hive ?? "—"}</dd>
|
||||||
|
<dt>config PR</dt>
|
||||||
|
<dd>
|
||||||
|
{detailTarget.config_pr ? (
|
||||||
<Badge
|
<Badge
|
||||||
tone={tone}
|
tone="warning"
|
||||||
value={
|
value={
|
||||||
<>
|
detailTarget.config_pr.html_url ? (
|
||||||
{label}
|
<a
|
||||||
{detailTarget.last_seen_unix !== null ? (
|
href={detailTarget.config_pr.html_url}
|
||||||
<>
|
target="_blank"
|
||||||
{" "}
|
rel="noreferrer"
|
||||||
(
|
>
|
||||||
<RelativeTime
|
#{detailTarget.config_pr.pr_number}
|
||||||
epochMs={detailTarget.last_seen_unix * 1000}
|
</a>
|
||||||
/>
|
) : (
|
||||||
)
|
`#${detailTarget.config_pr.pr_number}`
|
||||||
</>
|
)
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
) : (
|
||||||
})()}
|
"—"
|
||||||
</dd>
|
)}
|
||||||
<dt>message</dt>
|
</dd>
|
||||||
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
|
</dl>
|
||||||
<dt>wanted</dt>
|
<div class="ui-agent-detail-actions">
|
||||||
<dd>
|
<Badge
|
||||||
{/* Full menu (destroy included) — mara: "destroy is
|
variant="quiet"
|
||||||
already available via wanted state", no separate
|
icon={<LinkIcon />}
|
||||||
button needed. */}
|
value="link matrix account"
|
||||||
<WantedMenu
|
onClick={
|
||||||
row={detailTarget}
|
detailTarget.hive
|
||||||
pending={pendingAgents.has(detailTarget.name)}
|
? () => setMatrixTarget(detailTarget)
|
||||||
onSelectUp={selectUp}
|
: undefined
|
||||||
onSelectOffline={selectOffline}
|
}
|
||||||
onSelectPaused={selectPaused}
|
disabled={!detailTarget.hive}
|
||||||
onDestroy={(row) => {
|
title={
|
||||||
setDestroyTarget(row);
|
detailTarget.hive
|
||||||
setDetailTarget(null);
|
? `link a matrix account to ${detailTarget.name}`
|
||||||
}}
|
: "no hive on record for this agent — nothing to link against"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</dd>
|
</div>
|
||||||
<dt>hive</dt>
|
|
||||||
<dd>{detailTarget.hive ?? "—"}</dd>
|
|
||||||
<dt>config PR</dt>
|
|
||||||
<dd>
|
|
||||||
{detailTarget.config_pr ? (
|
|
||||||
<Badge
|
|
||||||
tone="warning"
|
|
||||||
value={
|
|
||||||
detailTarget.config_pr.html_url ? (
|
|
||||||
<a
|
|
||||||
href={detailTarget.config_pr.html_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
#{detailTarget.config_pr.pr_number}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
`#${detailTarget.config_pr.pr_number}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<div class="ui-agent-detail-actions">
|
|
||||||
<Badge
|
|
||||||
variant="quiet"
|
|
||||||
icon={<LinkIcon />}
|
|
||||||
value="link matrix account"
|
|
||||||
onClick={
|
|
||||||
detailTarget.hive
|
|
||||||
? () => {
|
|
||||||
setMatrixTarget(detailTarget);
|
|
||||||
setDetailTarget(null);
|
|
||||||
}
|
|
||||||
: 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"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : null}
|
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||||
</Dialog>
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
|
@ -900,6 +1122,6 @@ export function AgentsPage() {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</Panel>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,19 @@ export function Panel({
|
||||||
icon,
|
icon,
|
||||||
actions,
|
actions,
|
||||||
children,
|
children,
|
||||||
|
class: extraClass,
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
actions?: ComponentChildren;
|
actions?: ComponentChildren;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
|
/** Extra class on the outer `<section>` — 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 (
|
return (
|
||||||
<section class="ui-panel">
|
<section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}>
|
||||||
{title || icon || actions ? (
|
{title || icon || actions ? (
|
||||||
<div class="ui-panel-header">
|
<div class="ui-panel-header">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,101 @@ function compareValues(a: string | number, b: string | number): number {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The filter *state* half of `<Table>`, 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 `<th>`-anchored icon) — a caller with a different
|
||||||
|
// layout renders its own trigger against the same `getFilter`/
|
||||||
|
// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns.
|
||||||
|
export function useTableFilters<T>(
|
||||||
|
columns: TableColumn<T>[],
|
||||||
|
rows: T[],
|
||||||
|
storageKey: string,
|
||||||
|
) {
|
||||||
|
const [filters, setFilters] = useLocalSetting<
|
||||||
|
Record<string, ColumnFilterState>
|
||||||
|
>(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<ColumnFilterState>) {
|
||||||
|
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<T>): string[] {
|
||||||
|
if (!c.filterValues) return [];
|
||||||
|
const values = new Set<string>();
|
||||||
|
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<T>({
|
export function Table<T>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -163,13 +258,18 @@ export function Table<T>({
|
||||||
// multi-column sort is real complexity (tie-break order, a UI to
|
// multi-column sort is real complexity (tie-break order, a UI to
|
||||||
// express it) nothing here has asked for yet.
|
// express it) nothing here has asked for yet.
|
||||||
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);
|
||||||
// Filter state per filterable column key, only populated for a column
|
// Filter *state* lives in `useTableFilters` now (see its own comment) —
|
||||||
// the operator has actually touched. Persisted (see `storageKey`
|
// this component only owns the popover-trigger UI below, table-shaped
|
||||||
// above), same `useLocalSetting` plumbing `IssueReportPage` already
|
// (a `<th>`-anchored icon).
|
||||||
// used for its own now-folded-in label filter.
|
const {
|
||||||
const [filters, setFilters] = useLocalSetting<
|
visibleRows: filteredRows,
|
||||||
Record<string, ColumnFilterState>
|
activeFilters,
|
||||||
>(storageKey, {});
|
getFilter,
|
||||||
|
updateFilter,
|
||||||
|
toggleFilterValue,
|
||||||
|
resetFilters,
|
||||||
|
multiselectOptionsFor,
|
||||||
|
} = useTableFilters(columns, rows, storageKey);
|
||||||
// Which column's filter popover is open, if any — at most one at a
|
// 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
|
// time (opening a second closes the first) so the header row never
|
||||||
// shows more than one panel at once.
|
// shows more than one panel at once.
|
||||||
|
|
@ -228,34 +328,6 @@ export function Table<T>({
|
||||||
};
|
};
|
||||||
}, [openFilterKey]);
|
}, [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<ColumnFilterState>) {
|
|
||||||
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
|
// Close on outside click or Escape — same contract `Dropdown` gives
|
||||||
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
||||||
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
||||||
|
|
@ -282,40 +354,23 @@ export function Table<T>({
|
||||||
};
|
};
|
||||||
}, [openFilterKey]);
|
}, [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(() => {
|
const visibleRows = useMemo(() => {
|
||||||
let out = rows;
|
if (!sort) return filteredRows;
|
||||||
if (activeFilters.length > 0) {
|
const col = columns.find((c) => c.key === sort.key);
|
||||||
out = out.filter((row) =>
|
const sortBy = col?.sortBy;
|
||||||
activeFilters.every(([key, f]) => {
|
if (!sortBy) return filteredRows;
|
||||||
const col = columns.find((c) => c.key === key);
|
return [...filteredRows].sort((a, b) => {
|
||||||
let matches: boolean;
|
const cmp = compareValues(sortBy(a), sortBy(b));
|
||||||
if (col?.filterMode === "multiselect") {
|
return sort.dir === "asc" ? cmp : -cmp;
|
||||||
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;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is
|
||||||
// a fresh array every render (built inline by every caller); keying
|
// a fresh array every render (built inline by every caller); keying
|
||||||
// off its identity would recompute every render regardless, so this
|
// off its identity would recompute every render regardless, so this
|
||||||
// depends on the values that actually determine the output instead.
|
// depends on the values that actually determine the output instead.
|
||||||
}, [rows, sort, JSON.stringify(activeFilters)]);
|
}, [filteredRows, sort]);
|
||||||
|
|
||||||
function toggleSort(key: string) {
|
function toggleSort(key: string) {
|
||||||
setSort((prev) => {
|
setSort((prev) => {
|
||||||
|
|
@ -333,22 +388,6 @@ export function Table<T>({
|
||||||
return sort.dir === "asc" ? "ascending" : "descending";
|
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<T>): string[] {
|
|
||||||
if (!c.filterValues) return [];
|
|
||||||
const values = new Set<string>();
|
|
||||||
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
|
// 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
|
// per-column ref map) is enough to focus whichever control just
|
||||||
// mounted — typing immediately after the click that opened it,
|
// mounted — typing immediately after the click that opened it,
|
||||||
|
|
@ -443,7 +482,7 @@ export function Table<T>({
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c) => {
|
{columns.map((c) => {
|
||||||
const hasFilterValue = isFilterActive(filters[c.key]);
|
const hasFilterValue = isFilterActive(getFilter(c.key));
|
||||||
const filterOpen = openFilterKey === c.key;
|
const filterOpen = openFilterKey === c.key;
|
||||||
const headerContent = c.sortBy ? (
|
const headerContent = c.sortBy ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue