From 7dd50db9b7287136d0837c663e70c7044fbc7090 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 20:07:13 +0100 Subject: [PATCH] fix(plex): the rail says once that its facets failed, with Retry A failed facets load put the same muted line in up to five sections with nothing to press (the query never retries by itself), and only the Year section announced it, so a reader who hid that section heard nothing. The rail now shows one StateMessage with Retry, in the Year section or in its foot when Year is hidden; the facet sections wait for their data as before. --- frontend/src/modules/plex/PlexSidebar.tsx | 68 +++++++++---------- .../plex/PlexSidebar.year.dom.test.tsx | 27 +++----- 2 files changed, 42 insertions(+), 53 deletions(-) diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index a8eafbe7..e567fe2a 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -29,6 +29,7 @@ import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; import YearPicker from "../../components/ui/YearPicker"; +import { StateMessage } from "../../components/ui/QueryState"; import { useRail } from "../../lib/useRail"; import { msg } from "../../lib/notifications"; @@ -85,26 +86,21 @@ export default function PlexSidebar() { // just lists them as removable chips. Studios now have their own picker section (below). const peopleActive = filters.directors.length + filters.actors.length > 0; - // A section the facets fill, shown when its facet has values. While the facets are not here, it - // says why it is empty: a first load or a failed one must not read as a library with no genres - // (honest states). The Year section announces it; these only show it, so it is read once. - const facetsState: FacetsState = facets - ? "ready" - : facetsQ.isError - ? "error" - : facetsQ.isLoading - ? "loading" - : "ready"; + // A section the facets fill, shown when its facet has values. const facetSection = ( id: string, title: string, has: (f: PlexFacets) => boolean, body: (f: PlexFacets) => ReactNode, - ): PanelItem[] => { - if (facets) return has(facets) ? [{ id, title, body: body(facets) }] : []; - if (facetsState === "ready") return []; - return [{ id, title, body: }]; - }; + ): PanelItem[] => (facets && has(facets) ? [{ id, title, body: body(facets) }] : []); + // While the facets are not here, the rail says so ONCE — a first load or a failed one must not + // read as a library with no genres (honest states) — with a way to try again. In the Year section, + // which every library has; in the rail's foot if the reader hid it. + const facetsStatus = + facets || (!facetsQ.isError && !facetsQ.isLoading) ? null : ( + void facetsQ.refetch()} /> + ); + const yearHidden = !!layout.hidden.year; // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved @@ -199,7 +195,12 @@ export default function PlexSidebar() { id: "year", title: t("plex.filter.year"), body: ( - + ), }, ...(scope === "movie" @@ -319,7 +320,9 @@ export default function PlexSidebar() { iconExpanded={} />} onTitleClick={openModal} titleActionLabel={t("views.manageTitle")} - /> + > + {yearHidden && facetsStatus} + {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} {modalOpen && } @@ -496,18 +499,19 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi export function PlexYearFilter({ filters, facets, - facetsState = "ready", + status, patch, }: { filters: PlexFilters; facets: PlexFacets | undefined; - facetsState?: FacetsState; + /** The rail's word on facets still loading or failed, when it shows here. */ + status?: ReactNode; patch: (p: Partial) => void; }) { const { t } = useTranslation(); return (
- {facetsState !== "ready" && } + {status} {facets && facets.years.length > 0 && ( ; - announce?: boolean; -}) { +/** The facets still loading, or failed with a way to try again — said once for the whole rail. */ +export function FacetsStatus({ failed, onRetry }: { failed: boolean; onRetry: () => void }) { const { t } = useTranslation(); - const role = announce ? (state === "error" ? "alert" : "status") : undefined; + if (failed) + return ( + + {t("common.loadError")} + + ); return ( -

- {t(state === "error" ? "common.loadError" : "common.loading")} +

+ {t("common.loading")}

); } diff --git a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx index 056d4de8..7f88849e 100644 --- a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx +++ b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setPlaylistOpen: vi.fn() }), })); -import { PlexYearFilter } from "./PlexSidebar"; +import { FacetsStatus, PlexYearFilter } from "./PlexSidebar"; import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api"; afterEach(cleanup); @@ -64,25 +64,14 @@ describe("the Plex rail's Year section", () => { expect(screen.queryByRole("button", { pressed: true })).toBeNull(); }); - it("says the years are loading, or failed to load, rather than showing none", () => { - const { rerender } = render( - {}} - />, - ); + it("says the facets are loading, or failed with a way to try again, rather than showing none", () => { + const retry = vi.fn(); + const { rerender } = render(); expect(screen.getByRole("status").textContent).toBe("common.loading"); - rerender( - {}} - />, - ); - expect(screen.getByRole("alert").textContent).toBe("common.loadError"); + rerender(); + expect(screen.getByRole("alert").textContent).toContain("common.loadError"); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + expect(retry).toHaveBeenCalledOnce(); }); it("names the range fields", () => {