fix(ui): make PageBack destinations deterministic
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
@@ -18,6 +18,11 @@ vi.mock("react-router", async () => {
|
||||
import PageBack from "./PageBack";
|
||||
|
||||
describe("PageBack", () => {
|
||||
afterEach(() => {
|
||||
mocks.navigate.mockClear();
|
||||
window.history.replaceState(null, "");
|
||||
});
|
||||
|
||||
it("renders a button with the default 'Go back' aria-label", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -38,8 +43,21 @@ describe("PageBack", () => {
|
||||
expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls navigate(-1) on click", async () => {
|
||||
mocks.navigate.mockClear();
|
||||
it("falls back to the default route when there is no router history", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
|
||||
|
||||
expect(mocks.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.navigate).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("uses browser history when a router history entry is available", async () => {
|
||||
window.history.replaceState({ idx: 1 }, "");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
@@ -52,6 +70,20 @@ describe("PageBack", () => {
|
||||
expect(mocks.navigate).toHaveBeenCalledWith(-1);
|
||||
});
|
||||
|
||||
it("uses the explicit target when history preference is disabled", async () => {
|
||||
window.history.replaceState({ idx: 1 }, "");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
|
||||
|
||||
expect(mocks.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.navigate).toHaveBeenCalledWith("/collections");
|
||||
});
|
||||
|
||||
it("applies the documented positioning and glass styling", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { type To, useNavigate } from "react-router";
|
||||
|
||||
interface PageBackProps {
|
||||
label?: string;
|
||||
to?: To;
|
||||
preferHistory?: boolean;
|
||||
/**
|
||||
* When true, pins the button to the viewport on lg+ so it stays visible
|
||||
* while scrolling. The offset matches the app sidebar (260px) so the
|
||||
@@ -11,16 +13,33 @@ interface PageBackProps {
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
export default function PageBack({ label = "Go back", floating = false }: PageBackProps) {
|
||||
export default function PageBack({
|
||||
label = "Go back",
|
||||
to = "/",
|
||||
preferHistory = true,
|
||||
floating = false,
|
||||
}: PageBackProps) {
|
||||
const navigate = useNavigate();
|
||||
const position = floating
|
||||
? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]"
|
||||
: "absolute top-4 left-2 sm:top-6";
|
||||
|
||||
function goBack() {
|
||||
const historyIndex = window.history.state?.idx;
|
||||
|
||||
if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(to);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={goBack}
|
||||
className={`glass text-foreground hover:bg-accent ${position} z-20 flex items-center justify-center rounded-full p-1.5 shadow-md transition-colors`}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
|
||||
@@ -32,8 +32,8 @@ export default function CollectionEditor() {
|
||||
if (id && !collection && !isLoading) {
|
||||
return (
|
||||
<div className="page-shell relative space-y-4 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<Card className="surface-panel rounded-[1.7rem] border-0 shadow-none">
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
<Card className="surface-panel mt-10 rounded-[1.7rem] border-0 shadow-none sm:mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle>Collection not found</CardTitle>
|
||||
<CardDescription>The selected collection could not be loaded.</CardDescription>
|
||||
@@ -46,8 +46,8 @@ export default function CollectionEditor() {
|
||||
if (collection && isImportedCollection(collection)) {
|
||||
return (
|
||||
<div className="page-shell relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<div>
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
<div className="mt-10 sm:mt-12">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
|
||||
@@ -68,8 +68,8 @@ export default function CollectionEditor() {
|
||||
if (collection && collection.collection_type === "manual") {
|
||||
return (
|
||||
<div className="page-shell relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<div>
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
<div className="mt-10 sm:mt-12">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Manual collections are curated by adding titles directly.
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function PersonDetail() {
|
||||
{/* Person Header */}
|
||||
<section className="page-shell relative pt-8 pb-6 sm:pt-10 sm:pb-8">
|
||||
<PageBack />
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<div className="mt-10 flex flex-col gap-6 sm:mt-12 lg:flex-row lg:gap-8">
|
||||
{/* Photo */}
|
||||
<div className="shrink-0 self-start">
|
||||
<div className="media-card-image aspect-[2/3] w-[140px] overflow-hidden rounded-lg sm:w-[180px]">
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function ProfileCustomizeHome() {
|
||||
return (
|
||||
<div className="relative mx-auto max-w-3xl p-6">
|
||||
<PageBack />
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
<div className="mt-10 flex items-center justify-between border-b border-white/10 pb-3 sm:mt-12">
|
||||
<h1 className="text-base font-semibold">Customize home</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -77,8 +77,8 @@ export default function RecommendationsSection() {
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
|
||||
<PageBack />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<PageBack to="/recommendations" preferHistory={false} />
|
||||
<div className="mt-10 flex flex-col gap-1.5 sm:mt-12">
|
||||
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{data && data.items.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -84,8 +84,8 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
|
||||
if (browse.isError && (browse.error as { status?: number }).status === 404) {
|
||||
return (
|
||||
<div className="relative space-y-4 py-10 text-center">
|
||||
<PageBack />
|
||||
<p className="text-foreground text-lg font-semibold">
|
||||
<PageBack to="/requests" preferHistory={false} />
|
||||
<p className="text-foreground mt-10 text-lg font-semibold sm:mt-12">
|
||||
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
|
||||
</p>
|
||||
</div>
|
||||
@@ -94,8 +94,8 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6 py-6 sm:py-8">
|
||||
<PageBack />
|
||||
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
|
||||
<PageBack to="/requests" preferHistory={false} />
|
||||
<div className="mt-10 space-y-4 px-4 sm:mt-12 sm:px-6 lg:px-10 xl:px-12">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
|
||||
|
||||
@@ -40,8 +40,10 @@ export default function RequestDetail() {
|
||||
if (detail.isError || !detail.data) {
|
||||
return (
|
||||
<div className="page-shell relative space-y-3 py-12 text-center">
|
||||
<PageBack />
|
||||
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
|
||||
<PageBack to="/requests" />
|
||||
<p className="text-foreground mt-10 text-base font-semibold sm:mt-12">
|
||||
Couldn't load this title.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The TMDB record may be temporarily unavailable.
|
||||
</p>
|
||||
@@ -58,7 +60,7 @@ export default function RequestDetail() {
|
||||
<div>
|
||||
<DetailHero
|
||||
title={item.title}
|
||||
topNav={<PageBack />}
|
||||
topNav={<PageBack to="/requests" />}
|
||||
context={<RequestContext mediaType={mediaType} />}
|
||||
studioLabel={studioLabel}
|
||||
backdropUrl={backdropUrl}
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function SettingsLayout() {
|
||||
return (
|
||||
<div className="min-h-[100dvh]">
|
||||
<main className="page-shell-wide relative flex min-h-[100dvh] flex-col py-4 sm:py-6">
|
||||
<PageBack floating />
|
||||
<PageBack to="/" preferHistory={false} floating />
|
||||
<div className="page-header mt-10 gap-5 sm:mt-12">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
|
||||
|
||||
@@ -118,11 +118,12 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps
|
||||
}, []);
|
||||
|
||||
const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection";
|
||||
const backTarget = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
|
||||
const adminLibraries = wizard.mode === "admin" ? wizard.libraries : [];
|
||||
|
||||
return (
|
||||
<div className="page-shell-wide relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<PageBack to={backTarget} preferHistory={false} />
|
||||
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
|
||||
|
||||
{step === 1 ? (
|
||||
@@ -171,6 +172,12 @@ function smartUserDraft(collection: Collection | null): CollectionBuilderValue {
|
||||
: createCollectionBuilderValue({ ...value, collection_type: "smart" });
|
||||
}
|
||||
|
||||
function adminBackHref(props: AdminModeProps): string {
|
||||
return props.initialLibraryId
|
||||
? `/admin/collections?libraryId=${props.initialLibraryId}`
|
||||
: "/admin/collections";
|
||||
}
|
||||
|
||||
function WizardHeader({
|
||||
title,
|
||||
step,
|
||||
@@ -181,7 +188,7 @@ function WizardHeader({
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="mt-10 flex flex-wrap items-end justify-between gap-4 sm:mt-12">
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user