refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved views; channels, files, playlists their provider), and ModuleStores mounts them at the root in registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's read side is in reach and that no store's static imports leave its own module, so the order cannot matter. Saved views were one provider that switched between the feed and Plex by the active page and reset on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the shared chip, popover and modal read the views their page or rail hands down. A feed view applied and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a module's manage modal still closes when its page is left. The feed's URL-override check applies only on the feed page, where its filter params live. The Channels store's tab type moves from the page into the store's context.
This commit is contained in:
@@ -24,6 +24,12 @@ vi.mock("./plexViews", async (importOriginal) => ({
|
||||
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||
}));
|
||||
|
||||
// The page hands Plex's saved views to its chip row; none are saved here.
|
||||
vi.mock("./plexSavedViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexSavedViews")>()),
|
||||
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import ItemCount from "../../components/ui/ItemCount";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
@@ -101,11 +103,13 @@ export default function PlexBrowse() {
|
||||
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
|
||||
// clears ?v and drops _sub: every plex entry starts at the Discover root).
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
// Plex's saved views reach the shared views UI under this page (the chip) by the scope.
|
||||
const savedViews = usePlexSavedViews();
|
||||
return (
|
||||
<>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
|
||||
<PlexBrowseView sub={sub} />
|
||||
</>
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
||||
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
||||
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
|
||||
// value at all is what tells PlexSavedViewsProvider the account has picked filters — so the account's
|
||||
// default saved view was never applied again.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -142,7 +142,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
||||
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells PlexSavedViewsProvider the
|
||||
// account has picked filters, so the account's default saved view would never be applied again.
|
||||
useEffect(() => {
|
||||
if (!urlSeed) return;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Plex's saved views: the same default-view seed as the feed's (parity — Plex once never applied its
|
||||
// default at all), asked for only while the Plex page is on screen. Plex's filters are mocked so we can
|
||||
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
|
||||
// QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: h.page }),
|
||||
}));
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading } = usePlexSavedViews();
|
||||
return <div data-testid="loading">{String(loading)}</div>;
|
||||
}
|
||||
|
||||
function mount(views: SavedView[]) {
|
||||
const spy = vi
|
||||
.spyOn(api, "savedViews")
|
||||
.mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexSavedViewsProvider>
|
||||
<Probe />
|
||||
</PlexSavedViewsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const r = render(tree());
|
||||
return { spy, rerender: () => r.rerender(tree()) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(ID);
|
||||
h.page = "plex";
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Plex's saved views", () => {
|
||||
it("seeds the starred default into a fresh Plex (parity with the feed)", async () => {
|
||||
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("does NOT seed when Plex filters are already stored", async () => {
|
||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for nothing until the Plex page is on screen", async () => {
|
||||
h.page = "feed";
|
||||
const { spy, rerender } = mount([]);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
h.page = "plex";
|
||||
rerender();
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { coercePlexFilters, plexFiltersSignature, type PlexFilters } from "../../lib/api";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import {
|
||||
storedFiltersPresent,
|
||||
useSavedViewsStore,
|
||||
type SavedViewsConfig,
|
||||
} from "../../features/filters/useSavedViewsStore";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { PlexSavedViewsContextProvider } from "./plexSavedViews";
|
||||
|
||||
const keyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
const asFilters = (raw: Record<string, unknown>) =>
|
||||
coercePlexFilters(raw) as unknown as Record<string, unknown>;
|
||||
// Plex has no transient field to drop before saving.
|
||||
const stripForSave = (f: Record<string, unknown>) => f;
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
const canSeedDefault = () => !storedFiltersPresent(LS.plexFilters);
|
||||
|
||||
// Plex's saved views, over Plex's live filters (useSavedViewsStore holds the logic). Asked for only while
|
||||
// the Plex page is on screen, so an account that never opens Plex never fetches them, and the starred
|
||||
// default seeds on the first visit.
|
||||
export function PlexSavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const { filters } = usePlex();
|
||||
const { setFilters } = usePlexActions();
|
||||
const onScreen = page === "plex";
|
||||
const cfg: SavedViewsConfig = useMemo(
|
||||
() => ({
|
||||
source: "plex",
|
||||
filters: filters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setFilters(f as unknown as PlexFilters),
|
||||
keyOf,
|
||||
asFilters,
|
||||
stripForSave,
|
||||
canSeedDefault,
|
||||
enabled: onScreen,
|
||||
onScreen,
|
||||
}),
|
||||
[filters, setFilters, onScreen],
|
||||
);
|
||||
const value = useSavedViewsStore(cfg);
|
||||
return <PlexSavedViewsContextProvider value={value}>{children}</PlexSavedViewsContextProvider>;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
@@ -47,7 +48,9 @@ export default function PlexSidebar() {
|
||||
const { setFilters } = usePlexActions();
|
||||
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||||
// title opens the full manager modal (same shared components as the feed).
|
||||
const { openModal, modalOpen } = useSavedViews();
|
||||
// They reach the shared views UI in this rail through the scope below.
|
||||
const savedViews = usePlexSavedViews();
|
||||
const { openModal, modalOpen } = savedViews;
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
@@ -293,7 +296,7 @@ export default function PlexSidebar() {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
@@ -308,14 +311,14 @@ export default function PlexSidebar() {
|
||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
|
||||
// Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
// The same shared components as the feed rail, over Plex's own views.
|
||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||
onTitleClick={openModal}
|
||||
titleActionLabel={t("views.manageTitle")}
|
||||
/>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PlexProvider } from "./PlexProvider";
|
||||
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||
|
||||
// The Plex module's store, mounted at the root (features/navigation/ModuleStores): its browse state, and
|
||||
// its saved views over that state's filters.
|
||||
export function PlexStore({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PlexProvider>
|
||||
<PlexSavedViewsProvider>{children}</PlexSavedViewsProvider>
|
||||
</PlexProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
|
||||
export { PlexProvider } from "./PlexProvider";
|
||||
export { usePlexSavedViews } from "./plexSavedViews";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
export { plexModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clapperboard } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { PlexStore } from "./PlexStore";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
@@ -15,4 +16,5 @@ export const plexModule: ModuleDef = {
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
||||
maxWidth: "100rem",
|
||||
Store: PlexStore,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
|
||||
|
||||
// Plex's saved views: filled by PlexSavedViewsProvider, read by the Plex page and rail (which hand them
|
||||
// to the shared views UI) and by the palette.
|
||||
const store = createSavedViewsStore("Plex");
|
||||
export const PlexSavedViewsContextProvider = store.Provider;
|
||||
export const usePlexSavedViews = store.useViews;
|
||||
Reference in New Issue
Block a user