Skip to content
59 changes: 59 additions & 0 deletions app/src/components/__tests__/mobileDraftSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MobileDraftSwitcher } from "@/components/sidebar/MobileDraftSwitcher";
import { TooltipProvider } from "@/components/ui/tooltip";

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

const guides = [
{ localDraftId: "a", title: "Intro to Limits" },
{ localDraftId: "b", title: "" },
];

const renderSwitcher = (onSelectGuide = vi.fn()) =>
render(
<TooltipProvider>
<MobileDraftSwitcher
guides={guides}
activeGuideId="b"
onSelectGuide={onSelectGuide}
onAddGuide={() => {}}
onDeleteGuide={() => {}}
/>
</TooltipProvider>
);

describe("MobileDraftSwitcher", () => {
it("shows the active draft position and a fallback title", () => {
renderSwitcher();

const trigger = screen.getByRole("button", {
name: "Drafts: guide 2 of 2, Guide 2",
});
expect(trigger.textContent).toContain("2 / 2");
});

it("selects a draft from the sheet and closes it", () => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
const onSelectGuide = vi.fn();
renderSwitcher(onSelectGuide);

fireEvent.click(screen.getByRole("button", { name: /^Drafts:/ }));
expect(screen.getByRole("dialog")).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: /Intro to Limits/ }));

expect(onSelectGuide).toHaveBeenCalledWith("a");
expect(screen.queryByRole("dialog")).toBeNull();
});
});
95 changes: 95 additions & 0 deletions app/src/components/__tests__/rejectionFeedback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RejectionFeedback } from "@/components/review/RejectionFeedback";

vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
}));

vi.mock("@/lib/api/guideRevisions", () => ({
getRevision: vi.fn(() => Promise.resolve({ revised_from_case_id: "case-1" })),
}));

vi.mock("@/lib/api/reviews", () => ({
getReviewCase: vi.fn(() =>
Promise.resolve({
decisions: [
{
id: "d1",
decision: "reject",
notes: "Needs a worked example.",
reasons: ["clarity_issue"],
created_at: "2026-10-01T00:00:00Z",
member_username: "reviewer",
},
],
})
),
}));

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

const stubScreen = (isDesktop: boolean) => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
vi.stubGlobal("matchMedia", (query: string) => ({
matches: isDesktop,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
};

describe("RejectionFeedback", () => {
it("opens the panel feedback in a sheet on mobile", async () => {
stubScreen(false);
render(<RejectionFeedback draftId="draft-1" />);

const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("View the closed case")).toBeDefined();
expect(within(dialog).getByText("Needs a worked example.")).toBeDefined();

fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();

fireEvent.click(
screen.getByRole("button", { name: "Show panel feedback" })
);
expect(screen.getByRole("dialog")).toBeDefined();
});

it("reopens the side column instead of a sheet on desktop", async () => {
stubScreen(true);
render(<RejectionFeedback draftId="draft-1" />);

fireEvent.click(
await screen.findByRole("button", { name: "Hide panel feedback" })
);
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(
screen.getByRole("button", { name: "Show panel feedback" })
);

expect(screen.queryByRole("dialog")).toBeNull();
// the column is back, so the reopen button hides again
expect(
screen.getByLabelText("Show panel feedback").getAttribute("aria-hidden")
).toBe("true");
});
});
2 changes: 1 addition & 1 deletion app/src/components/contribute/StepperActionHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export const StepperActionHeader = ({
onClick={toggleGuidelineModal}
>
<Scroll className="size-4 shrink-0" />
<span className="hidden min-[400px]:inline">
<span className="hidden min-[450px]:inline">
View Guidelines
</span>
</button>
Expand Down
11 changes: 10 additions & 1 deletion app/src/components/contribute/steps/guide/GuideInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Content } from "@/components/contribute/steps/Content";
import { CustomTabs } from "@/components/Tabs";
import { GuideDetails } from "@/components/contribute/steps/guide/GuideDetails";
import { EditorSidebar } from "@/components/sidebar/EditorSidebar";
import { MobileDraftSwitcher } from "@/components/sidebar/MobileDraftSwitcher";

type SubjectOption = {
id: string;
Expand Down Expand Up @@ -137,7 +138,15 @@ export const GuideInfo = ({
onDeleteGuide={onDeleteGuide}
/>

<div className="min-h-[calc(100vh-65px)] min-w-0 flex-1 pt-4 md:border-l md:pl-5">
<div className="min-h-[calc(100vh-65px)] min-w-0 flex-1 pt-4 md:block md:border-l md:pl-5">
<MobileDraftSwitcher
guides={guides}
activeGuideId={activeGuideId}
onSelectGuide={onSelectGuide}
onAddGuide={onAddGuide}
onDeleteGuide={onDeleteGuide}
/>

<CustomTabs tabs={tabs} />
</div>
</div>
Expand Down
133 changes: 96 additions & 37 deletions app/src/components/review/RejectionFeedback.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,76 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import { MessageSquareWarning, PanelRightClose } from "lucide-react";

import type { PanelDecision } from "@/components/review/DecisionList";
import { DecisionList } from "@/components/review/DecisionList";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getRevision } from "@/lib/api/guideRevisions";
import { getReviewCase } from "@/lib/api/reviews";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/lib/useMediaQuery";

type MobileProps = {
caseId: string;
decisions: Array<PanelDecision>;
open: boolean;
onOpenChange: (open: boolean) => void;
};

const CaseLink = ({ caseId }: { caseId: string }) => (
<Link
to="/review/$caseId"
params={{ caseId }}
className="mono-micro text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
View the closed case
</Link>
);

/**
* mobile replacement for the feedback column
* panel feedback slides in from the right as Sheet
*/
const MobileRejectionFeedback = ({
caseId,
decisions,
open,
onOpenChange,
}: MobileProps) => (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="gap-0">
<SheetHeader>
<SheetTitle className="font-mono tracking-[0.08em] uppercase">
Panel Feedback
</SheetTitle>
<SheetDescription className="sr-only">
Reviewer decisions from the case that sent this draft back.
</SheetDescription>
</SheetHeader>

<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 pb-6">
<CaseLink caseId={caseId} />
<DecisionList decisions={decisions} />
</div>
</SheetContent>
</Sheet>
);

export const RejectionFeedback = ({ draftId }: { draftId: string }) => {
const [caseId, setCaseId] = useState<string | null>(null);
const [decisions, setDecisions] = useState<Array<PanelDecision>>([]);

const [open, setOpen] = useState(true);

const isDesktop = useMediaQuery("(min-width: 60rem)");

useEffect(() => {
const controller = new AbortController();
const opts = { signal: controller.signal };
Expand All @@ -35,12 +92,18 @@ export const RejectionFeedback = ({ draftId }: { draftId: string }) => {

return (
<>
<MobileRejectionFeedback
caseId={caseId}
decisions={decisions}
open={!isDesktop && open}
onOpenChange={setOpen}
/>

<Button
variant="ghost"
size="icon"
aria-label="Show panel feedback"
aria-hidden={open}
tabIndex={open ? -1 : 0}
className={cn(
"absolute top-[39px] right-7 transition-opacity",
open
Expand All @@ -49,45 +112,41 @@ export const RejectionFeedback = ({ draftId }: { draftId: string }) => {
)}
onClick={() => setOpen(true)}
>
<PanelRightOpen className="size-5" />
<MessageSquareWarning className="size-5" />
</Button>

<div
className={cn(
"-mt-8 -mr-8 -mb-8 shrink-0 transition-[width] duration-400 ease-out [clip-path:inset(0)] lg:-mr-16",
open ? "w-[320px] border-l" : "w-0"
)}
>
<aside className="sticky top-[65px] max-h-[calc(100vh-65px)] w-[320px] space-y-4 overflow-y-auto px-6 pt-[39px] pb-8">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-[11px] font-bold tracking-[0.08em] uppercase">
Panel Feedback
</p>

<Button
variant="ghost"
size="icon"
aria-label="Hide panel feedback"
className="-mr-1"
onClick={() => setOpen(false)}
>
<PanelRightClose className="size-5" />
</Button>
{isDesktop && (
<div
className={cn(
"-mt-8 -mr-8 -mb-8 shrink-0 transition-[width] duration-400 ease-out [clip-path:inset(0)] lg:-mr-16",
open ? "w-[320px] border-l" : "w-0"
)}
>
<aside className="sticky top-[65px] max-h-[calc(100vh-65px)] w-[320px] space-y-4 overflow-y-auto px-6 pt-[39px] pb-8">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-[11px] font-bold tracking-[0.08em] uppercase">
Panel Feedback
</p>

<Button
variant="ghost"
size="icon"
aria-label="Hide panel feedback"
className="-mr-1"
onClick={() => setOpen(false)}
>
<PanelRightClose className="size-5" />
</Button>
</div>

<CaseLink caseId={caseId} />
</div>

<Link
to="/review/$caseId"
params={{ caseId }}
className="mono-micro text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
View the closed case
</Link>
</div>

<DecisionList decisions={decisions} />
</aside>
</div>
<DecisionList decisions={decisions} />
</aside>
</div>
)}
</>
);
};
Loading
Loading