feat(channels,datatable): unify sync-status into one controlled DataTable filter (R13 S2a)
The top sync chips and the Sync column's own select filter were two independent states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and subtly different semantics. DataTable gains a controlled filters/onFiltersChange pair (symmetric to the existing controlled sort); ChannelsProvider now owns the filter map via a new useFilterState hook, so the top chips, the Sync column funnel, the card layout, and the header "full history" deep-link all drive ONE filters.sync. The card layout reuses the exported filterRows predicate instead of its own status pre-filter. Vocab unified on the column's fully = recent_synced && backfill_done, which now matches the Sync cell's own "fully synced" badge.
This commit is contained in:
@@ -21,6 +21,7 @@ import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./tableHeader";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
@@ -34,12 +35,14 @@ import { useMe } from "../lib/useMe";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced";
|
||||
|
||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "all", labelKey: "channels.filters.all" },
|
||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "channels.filters.all" },
|
||||
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
@@ -58,7 +61,7 @@ export default function Channels() {
|
||||
// callers); this page is their primary editor.
|
||||
const {
|
||||
search,
|
||||
statusFilter,
|
||||
filters,
|
||||
view,
|
||||
sort,
|
||||
filtersResetToken,
|
||||
@@ -68,7 +71,7 @@ export default function Channels() {
|
||||
const layout = useChannelLayout();
|
||||
const {
|
||||
setSearch,
|
||||
setStatusFilter,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
@@ -223,20 +226,13 @@ export default function Channels() {
|
||||
setTagFilter([]);
|
||||
}, [filtersResetToken]);
|
||||
|
||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
||||
// run over whatever the status chip leaves. Hidden channels have their own tab, so they're
|
||||
// excluded from the subscribed table (no more dimmed rows) rather than shown via a status chip.
|
||||
// The sync-status filter is now ONE controlled column filter (`filters.sync`) shared by the top
|
||||
// chips, the Sync column funnel, and the card layout — no separate pre-filter. Hidden channels have
|
||||
// their own tab, so they're excluded from the subscribed table (no more dimmed rows). The name
|
||||
// search + tag chips still filter here; the column filters (sync + priority/tags/text funnels) are
|
||||
// applied by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
||||
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||
const channels = (channelsQuery.data ?? [])
|
||||
.filter((c) => !c.hidden)
|
||||
.filter((c) =>
|
||||
statusFilter === "needs_full"
|
||||
? !c.backfill_done
|
||||
: statusFilter === "fully_synced"
|
||||
? c.backfill_done
|
||||
: true,
|
||||
);
|
||||
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||
const q = search.trim().toLowerCase();
|
||||
const visibleChannels = channels.filter((c) => {
|
||||
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
||||
@@ -290,16 +286,17 @@ export default function Channels() {
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
// where they act.
|
||||
const syncFilter = (filters.sync as string) ?? "";
|
||||
const statusChips = (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
{SYNC_CHIPS.map((c) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setStatusFilter(f.id)}
|
||||
aria-pressed={statusFilter === f.id}
|
||||
key={c.value || "all"}
|
||||
onClick={() => setFilters({ ...filters, sync: c.value })}
|
||||
aria-pressed={syncFilter === c.value}
|
||||
className="pill text-xs px-2.5 py-1 rounded-full border"
|
||||
>
|
||||
{t(f.labelKey)}
|
||||
{t(c.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -409,7 +406,11 @@ export default function Channels() {
|
||||
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
|
||||
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
|
||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||
const sortedChannels = isCards
|
||||
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
|
||||
: visibleChannels;
|
||||
const {
|
||||
paged: pagedCards,
|
||||
pagerProps,
|
||||
@@ -422,7 +423,7 @@ export default function Channels() {
|
||||
// handled by the hook's clamp instead.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
||||
}, [filters, search, tagFilter, sort, resetPage]);
|
||||
const cardPager = <Pager {...pagerProps} />;
|
||||
|
||||
const tabs = (
|
||||
@@ -578,6 +579,8 @@ export default function Channels() {
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user