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", () => {