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 Pager from "./Pager";
|
||||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { sortRows } from "../lib/columnSort";
|
import { sortRows } from "../lib/columnSort";
|
||||||
|
import { filterRows } from "./tableHeader";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
@@ -34,12 +35,14 @@ import { useMe } from "../lib/useMe";
|
|||||||
|
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||||
|
|
||||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced";
|
// 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
|
||||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||||
{ id: "all", labelKey: "channels.filters.all" },
|
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||||
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
{ value: "", labelKey: "channels.filters.all" },
|
||||||
|
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||||
|
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||||
@@ -58,7 +61,7 @@ export default function Channels() {
|
|||||||
// callers); this page is their primary editor.
|
// callers); this page is their primary editor.
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
statusFilter,
|
filters,
|
||||||
view,
|
view,
|
||||||
sort,
|
sort,
|
||||||
filtersResetToken,
|
filtersResetToken,
|
||||||
@@ -68,7 +71,7 @@ export default function Channels() {
|
|||||||
const layout = useChannelLayout();
|
const layout = useChannelLayout();
|
||||||
const {
|
const {
|
||||||
setSearch,
|
setSearch,
|
||||||
setStatusFilter,
|
setFilters,
|
||||||
setView,
|
setView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setSort,
|
setSort,
|
||||||
@@ -223,20 +226,13 @@ export default function Channels() {
|
|||||||
setTagFilter([]);
|
setTagFilter([]);
|
||||||
}, [filtersResetToken]);
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
// The sync-status filter is now ONE controlled column filter (`filters.sync`) shared by the top
|
||||||
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
// chips, the Sync column funnel, and the card layout — no separate pre-filter. Hidden channels have
|
||||||
// run over whatever the status chip leaves. Hidden channels have their own tab, so they're
|
// their own tab, so they're excluded from the subscribed table (no more dimmed rows). The name
|
||||||
// excluded from the subscribed table (no more dimmed rows) rather than shown via a status chip.
|
// 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 hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||||
const channels = (channelsQuery.data ?? [])
|
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||||
.filter((c) => !c.hidden)
|
|
||||||
.filter((c) =>
|
|
||||||
statusFilter === "needs_full"
|
|
||||||
? !c.backfill_done
|
|
||||||
: statusFilter === "fully_synced"
|
|
||||||
? c.backfill_done
|
|
||||||
: true,
|
|
||||||
);
|
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const visibleChannels = channels.filter((c) => {
|
const visibleChannels = channels.filter((c) => {
|
||||||
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
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
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||||
// where they act.
|
// where they act.
|
||||||
|
const syncFilter = (filters.sync as string) ?? "";
|
||||||
const statusChips = (
|
const statusChips = (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{STATUS_FILTERS.map((f) => (
|
{SYNC_CHIPS.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
key={c.value || "all"}
|
||||||
onClick={() => setStatusFilter(f.id)}
|
onClick={() => setFilters({ ...filters, sync: c.value })}
|
||||||
aria-pressed={statusFilter === f.id}
|
aria-pressed={syncFilter === c.value}
|
||||||
className="pill text-xs px-2.5 py-1 rounded-full border"
|
className="pill text-xs px-2.5 py-1 rounded-full border"
|
||||||
>
|
>
|
||||||
{t(f.labelKey)}
|
{t(c.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
// 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.
|
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||||
const isCards = layout === "cards";
|
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 {
|
const {
|
||||||
paged: pagedCards,
|
paged: pagedCards,
|
||||||
pagerProps,
|
pagerProps,
|
||||||
@@ -422,7 +423,7 @@ export default function Channels() {
|
|||||||
// handled by the hook's clamp instead.
|
// handled by the hook's clamp instead.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPage();
|
resetPage();
|
||||||
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
}, [filters, search, tagFilter, sort, resetPage]);
|
||||||
const cardPager = <Pager {...pagerProps} />;
|
const cardPager = <Pager {...pagerProps} />;
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
@@ -578,6 +579,8 @@ export default function Channels() {
|
|||||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,33 +12,32 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
|||||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { type SortState } from "../lib/columnSort";
|
import { type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
|
import type { FilterMap } from "./tableHeader";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
import type { ChannelsView } from "./Channels";
|
||||||
|
|
||||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
|
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||||
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||||
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
||||||
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
||||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||||
//
|
//
|
||||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
|
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||||
|
|
||||||
const STATUS_FILTERS: readonly ChannelStatusFilter[] = ["needs_full", "fully_synced", "all"];
|
|
||||||
|
|
||||||
function clampStatus(v: string | null): ChannelStatusFilter {
|
|
||||||
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
|
|
||||||
}
|
|
||||||
function clampView(v: string | null): ChannelsView {
|
function clampView(v: string | null): ChannelsView {
|
||||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsState = {
|
type ChannelsState = {
|
||||||
search: string;
|
search: string;
|
||||||
statusFilter: ChannelStatusFilter;
|
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||||
|
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
|
||||||
|
filters: FilterMap;
|
||||||
view: ChannelsView;
|
view: ChannelsView;
|
||||||
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
@@ -53,7 +52,7 @@ type ChannelsState = {
|
|||||||
|
|
||||||
interface ChannelsActions {
|
interface ChannelsActions {
|
||||||
setSearch: (q: string) => void;
|
setSearch: (q: string) => void;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setFilters: (f: FilterMap) => void;
|
||||||
setView: (v: ChannelsView) => void;
|
setView: (v: ChannelsView) => void;
|
||||||
setLayout: (l: ChannelLayout) => void;
|
setLayout: (l: ChannelLayout) => void;
|
||||||
setSort: (s: SortState) => void;
|
setSort: (s: SortState) => void;
|
||||||
@@ -66,7 +65,7 @@ interface ChannelsActions {
|
|||||||
|
|
||||||
const StateContext = createContext<ChannelsState>({
|
const StateContext = createContext<ChannelsState>({
|
||||||
search: "",
|
search: "",
|
||||||
statusFilter: "all",
|
filters: {},
|
||||||
view: "subscribed",
|
view: "subscribed",
|
||||||
sort: null,
|
sort: null,
|
||||||
filtersResetToken: 0,
|
filtersResetToken: 0,
|
||||||
@@ -79,7 +78,7 @@ const StateContext = createContext<ChannelsState>({
|
|||||||
const LayoutContext = createContext<ChannelLayout>("table");
|
const LayoutContext = createContext<ChannelLayout>("table");
|
||||||
const ActionsContext = createContext<ChannelsActions>({
|
const ActionsContext = createContext<ChannelsActions>({
|
||||||
setSearch: () => {},
|
setSearch: () => {},
|
||||||
setStatusFilter: () => {},
|
setFilters: () => {},
|
||||||
setView: () => {},
|
setView: () => {},
|
||||||
setLayout: () => {},
|
setLayout: () => {},
|
||||||
setSort: () => {},
|
setSort: () => {},
|
||||||
@@ -104,9 +103,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
|
|
||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
|
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||||
clampStatus(getAccountRaw(LS.channelFilter)),
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap);
|
||||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||||
clampView(getAccountRaw(LS.channelsView)),
|
clampView(getAccountRaw(LS.channelsView)),
|
||||||
);
|
);
|
||||||
@@ -127,10 +126,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
|
|
||||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
|
||||||
setStatusFilterState(f);
|
|
||||||
setAccountRaw(LS.channelFilter, f);
|
|
||||||
}, []);
|
|
||||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||||
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||||
// of jumping straight out to the previous module.
|
// of jumping straight out to the previous module.
|
||||||
@@ -163,11 +158,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
[setView, setPage],
|
[setView, setPage],
|
||||||
);
|
);
|
||||||
const goToFullHistory = useCallback(() => {
|
const goToFullHistory = useCallback(() => {
|
||||||
setStatusFilter("needs_full");
|
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||||
setView("subscribed"); // the status filter applies to the subscriptions tab
|
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||||
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
|
setFilters({ sync: "needsFull" });
|
||||||
|
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||||
|
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||||
setPage("channels");
|
setPage("channels");
|
||||||
}, [setStatusFilter, setView, setPage]);
|
}, [setFilters, setView, setPage]);
|
||||||
|
|
||||||
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,20 +193,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const state = useMemo<ChannelsState>(
|
const state = useMemo<ChannelsState>(
|
||||||
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
|
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||||
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
|
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||||
);
|
);
|
||||||
const actions = useMemo<ChannelsActions>(
|
const actions = useMemo<ChannelsActions>(
|
||||||
() => ({
|
() => ({
|
||||||
setSearch,
|
setSearch,
|
||||||
setStatusFilter,
|
setFilters,
|
||||||
setView,
|
setView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setSort,
|
setSort,
|
||||||
focusChannel,
|
focusChannel,
|
||||||
goToFullHistory,
|
goToFullHistory,
|
||||||
}),
|
}),
|
||||||
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export default function DataTable<T>({
|
|||||||
resetFiltersToken,
|
resetFiltersToken,
|
||||||
sort: sortProp,
|
sort: sortProp,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
filters: filtersProp,
|
||||||
|
onFiltersChange,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -82,6 +84,13 @@ export default function DataTable<T>({
|
|||||||
// persistKey-backed sort.
|
// persistKey-backed sort.
|
||||||
sort?: SortState;
|
sort?: SortState;
|
||||||
onSortChange?: (next: SortState) => void;
|
onSortChange?: (next: SortState) => void;
|
||||||
|
// CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the
|
||||||
|
// caller owns the column-filter map (and its persistence) — so the channel manager's top status
|
||||||
|
// chips, the Sync column funnel, and the card layout all share ONE filter state — and the table
|
||||||
|
// reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the
|
||||||
|
// default internal, persistKey-backed filters.
|
||||||
|
filters?: FilterMap;
|
||||||
|
onFiltersChange?: (next: FilterMap) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
const initial = loadPersist(persistKey);
|
||||||
@@ -90,17 +99,31 @@ export default function DataTable<T>({
|
|||||||
const controlledSort = onSortChange !== undefined;
|
const controlledSort = onSortChange !== undefined;
|
||||||
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
||||||
const sort = controlledSort ? (sortProp ?? null) : internalSort;
|
const sort = controlledSort ? (sortProp ?? null) : internalSort;
|
||||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
// Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map);
|
||||||
|
// otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort.
|
||||||
|
const controlledFilters = onFiltersChange !== undefined;
|
||||||
|
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
|
||||||
|
const filters = controlledFilters ? (filtersProp ?? {}) : internalFilters;
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||||
|
|
||||||
|
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
|
||||||
|
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
||||||
|
// `filters` as a dep (which would re-run them on every filter change).
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
const commitFilters = (next: FilterMap) => {
|
||||||
|
if (controlledFilters) onFiltersChange!(next);
|
||||||
|
else setInternalFilters(next);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||||
}, [persistKey, sort, filters, page, size]);
|
}, [persistKey, sort, filters, page, size]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!externalFilter) return;
|
if (!externalFilter) return;
|
||||||
setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
|
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
|
||||||
setPage(0);
|
setPage(0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [externalFilter?.key, externalFilter?.value]);
|
}, [externalFilter?.key, externalFilter?.value]);
|
||||||
@@ -113,8 +136,9 @@ export default function DataTable<T>({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
||||||
resetRef.current = resetFiltersToken;
|
resetRef.current = resetFiltersToken;
|
||||||
setFilters({});
|
commitFilters({});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||||
@@ -132,7 +156,7 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
function applyFilter(key: string, value: string | string[]) {
|
function applyFilter(key: string, value: string | string[]) {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
commitFilters({ ...filters, [key]: value });
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export const LS = {
|
|||||||
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
|
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
|
||||||
// the per-account key can't be read.
|
// the per-account key can't be read.
|
||||||
feedView: "siftlode.feedView",
|
feedView: "siftlode.feedView",
|
||||||
channelFilter: "siftlode.channelFilter",
|
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
||||||
|
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
|
||||||
|
// the old single-string `channelFilter` sync chip.
|
||||||
|
channelsFilterMap: "siftlode.channelsFilterMap",
|
||||||
channelsView: "siftlode.channelsView",
|
channelsView: "siftlode.channelsView",
|
||||||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||||||
channelLayout: "siftlode.channelLayout",
|
channelLayout: "siftlode.channelLayout",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The persistence/hydration wiring of useFilterState. storage is mocked with an in-memory map so the
|
||||||
|
// hook can be exercised without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in
|
||||||
|
// the real module) — mirrors useSortState.dom.test.ts.
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
|
||||||
|
vi.mock("./storage", () => ({
|
||||||
|
getAccountRaw: (k: string) => store.get(k) ?? null,
|
||||||
|
setAccountRaw: (k: string, v: string) => {
|
||||||
|
store.set(k, v);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useFilterState } from "./useFilterState";
|
||||||
|
|
||||||
|
describe("useFilterState", () => {
|
||||||
|
beforeEach(() => store.clear());
|
||||||
|
|
||||||
|
it("hydrates to an empty map when storage is empty", () => {
|
||||||
|
const { result } = renderHook(() => useFilterState("k"));
|
||||||
|
expect(result.current[0]).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates a stored map on mount, keeping string and string[] entries", () => {
|
||||||
|
store.set("k", JSON.stringify({ sync: "needsFull", tags: ["1", "2"] }));
|
||||||
|
const { result } = renderHook(() => useFilterState("k"));
|
||||||
|
expect(result.current[0]).toEqual({ sync: "needsFull", tags: ["1", "2"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => {
|
||||||
|
const first = renderHook(() => useFilterState("k"));
|
||||||
|
act(() => first.result.current[1]({ sync: "fully" }));
|
||||||
|
expect(first.result.current[0]).toEqual({ sync: "fully" });
|
||||||
|
expect(store.get("k")).toBe(JSON.stringify({ sync: "fully" }));
|
||||||
|
const second = renderHook(() => useFilterState("k"));
|
||||||
|
expect(second.result.current[0]).toEqual({ sync: "fully" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals a non-object or malformed stored value to an empty map", () => {
|
||||||
|
store.set("k", "not json");
|
||||||
|
expect(renderHook(() => useFilterState("k")).result.current[0]).toEqual({});
|
||||||
|
store.set("k2", JSON.stringify(["a", "b"]));
|
||||||
|
expect(renderHook(() => useFilterState("k2")).result.current[0]).toEqual({});
|
||||||
|
store.set("k3", JSON.stringify(null));
|
||||||
|
expect(renderHook(() => useFilterState("k3")).result.current[0]).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops entries whose value is neither a string nor a string[] of strings", () => {
|
||||||
|
store.set("k", JSON.stringify({ sync: "needsFull", n: 3, mixed: ["a", 2], obj: {} }));
|
||||||
|
const { result } = renderHook(() => useFilterState("k"));
|
||||||
|
expect(result.current[0]).toEqual({ sync: "needsFull" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||||
|
import type { FilterMap } from "../components/tableHeader";
|
||||||
|
|
||||||
|
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
|
||||||
|
// caller (so a second view of the same rows — the channel manager's card grid, the top status chips
|
||||||
|
// — shares one filter state). The table analogue of useSortState: one home so a controlled-filter
|
||||||
|
// surface gets persistence for free, seeded from and saved to `storageBase`.
|
||||||
|
export function useFilterState(storageBase: string): [FilterMap, (next: FilterMap) => void] {
|
||||||
|
const [filters, setFilters] = useState<FilterMap>(() => hydrate(getAccountRaw(storageBase)));
|
||||||
|
const set = useCallback(
|
||||||
|
(next: FilterMap) => {
|
||||||
|
setFilters(next);
|
||||||
|
setAccountRaw(storageBase, JSON.stringify(next));
|
||||||
|
},
|
||||||
|
[storageBase],
|
||||||
|
);
|
||||||
|
return [filters, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored map is only trusted when it parses to a plain object of string / string[] entries; any
|
||||||
|
// other shape (a legacy value, hand-edited storage) heals to empty rather than throwing downstream.
|
||||||
|
function hydrate(raw: string | null): FilterMap {
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||||
|
const out: FilterMap = {};
|
||||||
|
for (const [key, val] of Object.entries(v)) {
|
||||||
|
if (typeof val === "string") out[key] = val;
|
||||||
|
else if (Array.isArray(val) && val.every((x) => typeof x === "string")) out[key] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user