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:
@@ -22,7 +22,10 @@ export const LS = {
|
||||
// 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.
|
||||
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",
|
||||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||||
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