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
|
|
@ -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 `<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 (
|
||||
<section class="ui-panel">
|
||||
<section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}>
|
||||
{title || icon || actions ? (
|
||||
<div class="ui-panel-header">
|
||||
{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>({
|
||||
columns,
|
||||
rows,
|
||||
|
|
@ -163,13 +258,18 @@ export function Table<T>({
|
|||
// 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<string, ColumnFilterState>
|
||||
>(storageKey, {});
|
||||
// Filter *state* lives in `useTableFilters` now (see its own comment) —
|
||||
// this component only owns the popover-trigger UI below, table-shaped
|
||||
// (a `<th>`-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 +328,6 @@ export function Table<T>({
|
|||
};
|
||||
}, [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
|
||||
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
||||
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
||||
|
|
@ -282,40 +354,23 @@ export function Table<T>({
|
|||
};
|
||||
}, [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 +388,6 @@ export function Table<T>({
|
|||
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
|
||||
// per-column ref map) is enough to focus whichever control just
|
||||
// mounted — typing immediately after the click that opened it,
|
||||
|
|
@ -443,7 +482,7 @@ export function Table<T>({
|
|||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const hasFilterValue = isFilterActive(filters[c.key]);
|
||||
const hasFilterValue = isFilterActive(getFilter(c.key));
|
||||
const filterOpen = openFilterKey === c.key;
|
||||
const headerContent = c.sortBy ? (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in a new issue