feat(managers): shared sort across the table and card layouts

Cards have no column headers to sort by, so lift the sort out of DataTable into
one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds
the comparator + click-cycle (one source of truth); DataTable gains an optional
controlled sort (sort + onSortChange) and falls back to its internal persistKey
sort when they're omitted, so every other table is untouched. The subscribed
sort lives in ChannelsProvider, discovery keeps its own (its columns differ);
a new ColumnSortControl dropdown (options derived from the columns' sortable
metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account,
persisted, per-tab.
This commit is contained in:
2026-07-19 23:09:54 +02:00
parent 875c7ad741
commit 9c4dd0163a
9 changed files with 195 additions and 31 deletions
+18 -18
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import { PageToolbar } from "./PageShell";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
@@ -50,7 +51,6 @@ export interface Column<T> {
cardLabel?: boolean;
}
type SortState = { key: string; dir: "asc" | "desc" } | null;
type FilterMap = Record<string, string | string[]>;
interface Persisted {
sort: SortState;
@@ -89,6 +89,8 @@ export default function DataTable<T>({
controlsBandClassName,
externalFilter,
resetFiltersToken,
sort: sortProp,
onSortChange,
}: {
rows: T[];
columns: Column<T>[];
@@ -112,10 +114,20 @@ export default function DataTable<T>({
// set" (e.g. the header's "without full history" link), so a stale persisted filter can't
// hide the rows the user was just sent to see.
resetFiltersToken?: number;
// CONTROLLED sort (optional): when `onSortChange` is given, the caller owns the sort — the table
// reflects `sort` and reports header clicks instead of keeping its own — so another view of the
// same rows (the channel manager's cards) can share one sort. Omit both for the default internal,
// persistKey-backed sort.
sort?: SortState;
onSortChange?: (next: SortState) => void;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
const [sort, setSort] = useState<SortState>(initial.sort);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
const sort = controlledSort ? sortProp ?? null : internalSort;
const [filters, setFilters] = useState<FilterMap>(initial.filters);
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
@@ -161,19 +173,7 @@ export default function DataTable<T>({
})
);
const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;
const sorted =
sort && sortCol?.sortValue
? [...filtered].sort((a, b) => {
const av = sortCol.sortValue!(a);
const bv = sortCol.sortValue!(b);
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sort.dir === "asc" ? cmp : -cmp;
})
: filtered;
const sorted = sortRows(filtered, columns, sort);
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
@@ -189,9 +189,9 @@ export default function DataTable<T>({
}
function toggleSort(key: string) {
setSort((s) =>
!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null
);
const next = cycleSort(sort, key);
if (controlledSort) onSortChange!(next);
else setInternalSort(next);
setPage(0);
}
function applyFilter(key: string, value: string | string[]) {