feat: unify network and topology table column filters

Replace top filter bars with shared Excel-style column menus, fix select-all and modal interactions, and use plain dropdown indicators. Apply exact-value filters before pagination and export. Isolate pytest database state and cover filter behavior.
This commit is contained in:
oliver 2026-10-12 23:36:00 +08:00
parent ed820126df
commit 3c9e0efd2b
32 changed files with 1429 additions and 645 deletions

View file

@ -0,0 +1,25 @@
import { useState } from "react";
import { ColumnFilterMenu } from "../components/ui/ColumnFilterMenu";
import type { TableColumnFilters } from "../types/tableFilters";
/** For detail tables whose complete displayed dataset is already in memory. */
export function useLocalTableColumnFilters<T>(scope: string, rows: T[], values: (row: T) => Record<string, string>) {
const [filters, setFilters] = useState<TableColumnFilters>({});
return {
items: rows.filter((row) => Object.entries(filters).every(([field, filter]) => {
if (!filter) return true;
const included = filter.values.includes(values(row)[field] ?? "");
return filter.mode === "include" ? included : !included;
})),
reset: () => setFilters({}),
column: (field: string, label: string) => (
<span className="ne-column-heading">
{label}
<ColumnFilterMenu label={label} value={filters[field] ?? null}
queryKey={["localTableFilterOptions", scope, field, JSON.stringify(rows.map((row) => values(row)[field] ?? ""))]}
loadOptions={() => Promise.resolve([...new Set(rows.map((row) => values(row)[field] ?? ""))].sort())}
onApply={(value) => setFilters((current) => ({ ...current, [field]: value }))} />
</span>
),
};
}

View file

@ -0,0 +1,34 @@
import { useState } from "react";
import { ColumnFilterMenu } from "../components/ui/ColumnFilterMenu";
import { fetchTableFilterOptions } from "../services/api/tableFilters";
import type { ColumnValueFilter, TableColumnFilters } from "../types/tableFilters";
export function useTableColumnFilters(endpoint: string, onChange: () => void, scope: Record<string, string> = {}) {
const [filters, setFilters] = useState<TableColumnFilters>({});
const [currentEndpoint, setCurrentEndpoint] = useState(endpoint);
if (currentEndpoint !== endpoint) {
setCurrentEndpoint(endpoint);
setFilters({});
}
const scopeKey = JSON.stringify(scope);
const setFilter = (field: string, value: ColumnValueFilter) => {
setFilters((current) => ({ ...current, [field]: value }));
onChange();
};
return {
filters,
key: JSON.stringify(filters),
active: Object.values(filters).some(Boolean),
setFilter,
reset: () => { setFilters({}); onChange(); },
column: (field: string, label: string, formatValue?: (value: string) => string) => (
<span className="ne-column-heading">
{label}
<ColumnFilterMenu label={label} value={filters[field] ?? null}
queryKey={["tableFilterOptions", endpoint, scopeKey, field]}
loadOptions={() => fetchTableFilterOptions(endpoint, field, scope)}
formatValue={formatValue} onApply={(value) => setFilter(field, value)} />
</span>
),
};
}