refactor(channels): share useSortState across the manager + discovery (R13 S1)
ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing (useState<SortState> + parseSortState + a persist key). Both now use the shared useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary passed it parses without healing (these owners heal for display via ColumnSortControl against the live columns), so the un-healed provider store is preserved. plexSortKeys made module-private (knip). Closes R13 S1: feed, Plex, and the two channel tables all sort through one SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 94.
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { formatCountOrDash } from "../lib/format";
|
||||
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -44,13 +45,7 @@ export default function ChannelDiscovery({
|
||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||
const layout = useChannelLayout();
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelDiscoverySort, null)),
|
||||
);
|
||||
const setSort = (s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelDiscoverySort, s);
|
||||
};
|
||||
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.discoveredChannels(),
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
||||
|
||||
@@ -117,9 +118,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
||||
);
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelSort, null)),
|
||||
);
|
||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
@@ -157,11 +158,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
setLayoutState(l);
|
||||
setAccountRaw(LS.channelLayout, l);
|
||||
}, []);
|
||||
const setSort = useCallback((s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelSort, s);
|
||||
}, []);
|
||||
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
setFocusName(name);
|
||||
|
||||
@@ -169,4 +169,14 @@ describe("hydrateSort", () => {
|
||||
dir: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses without healing when no options are supplied (caller heals for display)", () => {
|
||||
// A table/provider that owns no vocabulary keeps whatever was stored, even an unknown key.
|
||||
expect(hydrateSort(JSON.stringify({ key: "anything", dir: "desc" }), [])).toEqual({
|
||||
key: "anything",
|
||||
dir: "desc",
|
||||
});
|
||||
// A malformed stored value still coerces to the fallback.
|
||||
expect(hydrateSort(JSON.stringify({ key: 3, dir: "up" }), [], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,16 +78,20 @@ export function healSort(
|
||||
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
|
||||
}
|
||||
|
||||
/** Hydrate a persisted raw string into a healed `SortState`. Accepts the JSON `{key,dir}` shape and
|
||||
* tolerates a legacy bare-key string (older builds stored just the key), falling back otherwise. */
|
||||
/** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates
|
||||
* a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is
|
||||
* parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display
|
||||
* elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */
|
||||
export function hydrateSort(
|
||||
raw: string | null,
|
||||
options: SortOption[],
|
||||
fallback: SortState = null,
|
||||
): SortState {
|
||||
if (!raw) return fallback;
|
||||
const heal = (s: SortState): SortState =>
|
||||
options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback);
|
||||
try {
|
||||
return healSort(parseSortState(JSON.parse(raw)), options, fallback);
|
||||
return heal(parseSortState(JSON.parse(raw)));
|
||||
} catch {
|
||||
const opt = options.find((o) => o.key === raw);
|
||||
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
|
||||
|
||||
@@ -8,7 +8,7 @@ const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"]
|
||||
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||
|
||||
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
|
||||
export function plexSortKeys(scope: string): string[] {
|
||||
function plexSortKeys(scope: string): string[] {
|
||||
return [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { hydrateSort, type SortOption, type SortState } from "./columnSort";
|
||||
// key). One home so a new sortable surface (Plex, a future table) gets persistence for free.
|
||||
export function useSortState(
|
||||
storageBase: string,
|
||||
options: SortOption[],
|
||||
options: SortOption[] = [],
|
||||
fallback: SortState = null,
|
||||
): [SortState, (next: SortState) => void] {
|
||||
const [sort, setSort] = useState<SortState>(() =>
|
||||
|
||||
Reference in New Issue
Block a user