Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions __tests__/components/pr/PRList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock("@/components/ui/button", () => ({

vi.mock("lucide-react", () => ({
GitPullRequest: () => <span data-testid="icon-git-pr" />,
Lightbulb: () => <span data-testid="icon-lightbulb" />,
}));

// Mock PRListItem
Expand Down Expand Up @@ -345,4 +346,151 @@ describe("PRList", () => {
expect(screen.queryByText("Previous")).toBeNull();
expect(screen.queryByText("Next")).toBeNull();
});

// ── Mode-aware labels (#65) ─────────────────────────────────────
describe("standard mode", () => {
it("relabels the Merged/Closed tabs as Accepted/Rejected", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" mode="standard" />);
});
expect(screen.getByText("Accepted")).toBeDefined();
expect(screen.getByText("Rejected")).toBeDefined();
expect(screen.queryByText("Merged")).toBeNull();
expect(screen.queryByText("Closed")).toBeNull();
});

it("keeps developer wording in developer mode", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" mode="developer" />);
});
expect(screen.getByText("Merged")).toBeDefined();
expect(screen.getByText("Closed")).toBeDefined();
});

it("counts items as suggestions", async () => {
mockList.mockResolvedValue(makeListResponse([makePR()], 3));
await act(async () => {
render(<PRList projectId="proj-1" mode="standard" />);
});
expect(screen.getByText("3 suggestions")).toBeDefined();
});

it("uses the singular noun for a single suggestion", async () => {
mockList.mockResolvedValue(makeListResponse([makePR()], 1));
await act(async () => {
render(<PRList projectId="proj-1" mode="standard" />);
});
expect(screen.getByText("1 suggestion")).toBeDefined();
});
});

// ── Author scoping (#65, admin/owner view) ──────────────────────
describe("author scoping", () => {
it("forwards authorId to the API so a 'My …' list only contains the viewer's", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" authorId="user-42" />);
});
expect(mockList).toHaveBeenCalledWith(
"proj-1",
undefined,
"open",
"user-42",
0,
20
);
});

it("omits authorId for reviewers so they see every contributor's", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" />);
});
expect(mockList).toHaveBeenCalledWith(
"proj-1",
undefined,
"open",
undefined,
0,
20
);
});

it("addresses the viewer directly in the empty state when scoped", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" authorId="user-42" mode="standard" />);
});
expect(screen.getByText("You have no open suggestions.")).toBeDefined();
});

it("speaks about the project in the empty state when unscoped", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" mode="standard" />);
});
expect(
screen.getByText("There are no open suggestions for this project.")
).toBeDefined();
});

it("refetches when the author scope changes", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
const { rerender } = render(<PRList projectId="proj-1" />);
await waitFor(() => expect(mockList).toHaveBeenCalledTimes(1));
await act(async () => {
rerender(<PRList projectId="proj-1" authorId="user-42" />);
});
await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2));
expect(mockList).toHaveBeenLastCalledWith(
"proj-1",
undefined,
"open",
"user-42",
0,
20
);
});
});

// ── Mode-aware empty-state icon ─────────────────────────────────
it("uses the lightbulb icon in the standard-mode empty state", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" mode="standard" />);
});
expect(screen.getByTestId("icon-lightbulb")).toBeDefined();
expect(screen.queryByTestId("icon-git-pr")).toBeNull();
});

it("keeps the pull-request icon in the developer-mode empty state", async () => {
mockList.mockResolvedValue(makeListResponse([], 0));
await act(async () => {
render(<PRList projectId="proj-1" mode="developer" />);
});
expect(screen.getByTestId("icon-git-pr")).toBeDefined();
expect(screen.queryByTestId("icon-lightbulb")).toBeNull();
});

it("resets pagination when the author scope changes", async () => {
mockList.mockResolvedValue(makeListResponse([makePR()], 40));
const { rerender } = render(<PRList projectId="proj-1" />);
await waitFor(() => expect(mockList).toHaveBeenCalledTimes(1));

await act(async () => {
fireEvent.click(screen.getByText("Next"));
});
await waitFor(() =>
expect(mockList).toHaveBeenLastCalledWith("proj-1", undefined, "open", undefined, 20, 20)
);

await act(async () => {
rerender(<PRList projectId="proj-1" authorId="user-42" />);
});
await waitFor(() =>
expect(mockList).toHaveBeenLastCalledWith("proj-1", undefined, "open", "user-42", 0, 20)
);
});
});
42 changes: 20 additions & 22 deletions app/projects/[id]/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -927,15 +927,26 @@ export default function EditorPage() {
</Button>
)}

{/* Suggestions link */}
{isSuggestionMode && (
<Link href={`/projects/${projectId}/suggestions`}>
<Button variant="ghost" size="sm" className="gap-2 text-amber-600 dark:text-amber-400">
<Lightbulb className="h-4 w-4" />
<span className="hidden sm:inline">My Suggestions</span>
</Button>
</Link>
)}
{/* Suggestions / PRs link — unified, mode-aware */}
<Link href={`/projects/${projectId}/pull-requests`}>
<Button
variant="ghost"
size="sm"
className={editorMode === "standard" ? "gap-2 text-amber-600 dark:text-amber-400" : "gap-2"}
>
{editorMode === "standard"
? <Lightbulb className="h-4 w-4" />
: <GitPullRequest className="h-4 w-4" />}
<span className="hidden sm:inline">
{editorMode === "standard" ? "My Suggestions" : "My Pull Requests"}
</span>
{editorMode === "developer" && openPRCount > 0 && (
<span className="rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{openPRCount}
</span>
)}
</Button>
</Link>

{/* Review Suggestions link (editors/admins only) */}
{canEdit && pendingSuggestionCount > 0 && (
Expand Down Expand Up @@ -1022,19 +1033,6 @@ export default function EditorPage() {
)}
</Button>

{/* PR Link */}
<Link href={`/projects/${projectId}/pull-requests`}>
<Button variant="ghost" size="sm" className="gap-2">
<GitPullRequest className="h-4 w-4" />
<span className="hidden sm:inline">PRs</span>
{openPRCount > 0 && (
<span className="rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{openPRCount}
</span>
)}
</Button>
</Link>

<Button
variant="ghost"
size="sm"
Expand Down
12 changes: 11 additions & 1 deletion app/projects/[id]/pull-requests/[prNumber]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { PRDetail } from "@/components/pr/PRDetail";
import { projectApi, type Project } from "@/lib/api/projects";
import { derivePermissions } from "@/lib/hooks/useProject";
import { useEditorModeStore } from "@/lib/stores/editorModeStore";

export default function PullRequestDetailPage() {
const { data: session, status } = useSession();
Expand All @@ -19,6 +21,11 @@ export default function PullRequestDetailPage() {
const [project, setProject] = useState<Project | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const editorMode = useEditorModeStore((s) => s.editorMode);
const isSuggestionMode = editorMode === "standard";
// Must match the list page: reviewers browse everyone's, so their breadcrumb
// drops the "My" that would otherwise promise a personal list.
const { canManage } = derivePermissions(project, session?.accessToken);

useEffect(() => {
const fetchProject = async () => {
Expand Down Expand Up @@ -99,7 +106,9 @@ export default function PullRequestDetailPage() {
className="flex items-center gap-1 hover:text-slate-900 dark:hover:text-slate-200"
>
<ArrowLeft className="h-4 w-4" />
Pull Requests
{isSuggestionMode
? canManage ? "Suggestions" : "My Suggestions"
: canManage ? "Pull Requests" : "My Pull Requests"}
</Link>
</div>

Expand All @@ -111,6 +120,7 @@ export default function PullRequestDetailPage() {
accessToken={session?.accessToken}
userRole={project.user_role}
currentUserId={session?.user?.id}
mode={editorMode}
/>
</div>
</div>
Expand Down
36 changes: 29 additions & 7 deletions app/projects/[id]/pull-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { useState } from "react";
import { useSession } from "next-auth/react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Plus, GitPullRequest } from "lucide-react";
import { ArrowLeft, Plus, GitPullRequest, Lightbulb } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { PRList } from "@/components/pr/PRList";
import { PRCreateModal } from "@/components/pr/PRCreateModal";
import { BranchProvider } from "@/lib/context/BranchContext";
import { useProject, derivePermissions } from "@/lib/hooks/useProject";
import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref";
import { useEditorModeStore } from "@/lib/stores/editorModeStore";

export default function PullRequestsPage() {
const { data: session, status } = useSession();
Expand All @@ -20,9 +21,15 @@ export default function PullRequestsPage() {
const projectId = params.id as string;

const { project, isLoading, error } = useProject(projectId, session?.accessToken);
const { canEdit: canCreatePR } = derivePermissions(project, session?.accessToken);
const { canEdit: canCreatePR, canManage } = derivePermissions(project, session?.accessToken);
const projectHomeHref = useProjectHomeHref(projectId);
const [showCreateModal, setShowCreateModal] = useState(false);
const editorMode = useEditorModeStore((s) => s.editorMode);
const isSuggestionMode = editorMode === "standard";
// Reviewers see every contributor's work; everyone else sees only their own,
// which is what makes the "My …" title truthful (#65, "Admin/owner view").
const isReviewer = !!canManage;
const authorId = isReviewer ? undefined : session?.user?.id;

if (isLoading || status === "loading") {
return (
Expand Down Expand Up @@ -86,27 +93,42 @@ export default function PullRequestsPage() {
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-3">
<GitPullRequest className="h-8 w-8 text-slate-600 dark:text-slate-400" />
{isSuggestionMode
? <Lightbulb className="h-8 w-8 text-amber-500 dark:text-amber-400" />
: <GitPullRequest className="h-8 w-8 text-slate-600 dark:text-slate-400" />}
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
Pull Requests
{isSuggestionMode
? isReviewer ? "Suggestions" : "My Suggestions"
: isReviewer ? "Pull Requests" : "My Pull Requests"}
</h1>
<p className="text-sm text-slate-500">
Review and manage proposed changes
{isReviewer
? isSuggestionMode
? "Review and manage suggestions from all contributors"
: "Review and manage proposed changes"
: isSuggestionMode
? "Track the status of your proposed changes"
: "Track the status of your pull requests"}
</p>
</div>
</div>

{canCreatePR && (
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Pull Request
{isSuggestionMode ? "New Suggestion" : "New Pull Request"}
</Button>
)}
</div>

{/* PR List */}
<PRList projectId={projectId} accessToken={session?.accessToken} />
<PRList
projectId={projectId}
accessToken={session?.accessToken}
mode={editorMode}
authorId={authorId}
/>

{/* Create Modal */}
{session?.accessToken && (
Expand Down
Loading
Loading