Skip to content
Merged
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
91 changes: 91 additions & 0 deletions src/components/MarketplaceVoteButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DirectoryVoteButton } from "@/components/directory/DirectoryVoteButton";
import { McpVoteButton } from "@/components/mcp/McpVoteButton";
import { PromptVoteButton } from "@/components/prompts/PromptVoteButton";
import { SkillVoteButton } from "@/components/skills/SkillVoteButton";

const forms = [
{
name: "directory",
render: () => (
<DirectoryVoteButton
listingId="listing-1"
initialUpvotes={0}
initialDownvotes={0}
initialScore={0}
initialUserVote={null}
/>
),
},
{
name: "MCP",
render: () => (
<McpVoteButton
slug="listing-1"
initialUpvotes={0}
initialDownvotes={0}
initialScore={0}
initialUserVote={null}
/>
),
},
{
name: "prompt",
render: () => (
<PromptVoteButton
slug="listing-1"
initialUpvotes={0}
initialDownvotes={0}
initialScore={0}
initialUserVote={null}
/>
),
},
{
name: "skill",
render: () => (
<SkillVoteButton
slug="listing-1"
initialUpvotes={0}
initialDownvotes={0}
initialScore={0}
initialUserVote={null}
/>
),
},
];

describe("marketplace vote errors", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});

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

it.each(forms)("shows API errors when the $name vote request fails", async ({ render: renderForm }) => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ error: "Vote denied" }), { status: 403 })
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

render(renderForm());
await userEvent.click(screen.getByTitle("Upvote"));

expect(await screen.findByRole("alert")).toHaveTextContent("Vote denied");
expect(screen.getByTitle("Upvote")).toBeEnabled();
});

it("shows a fallback error when a vote request fails at the network layer", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("offline"));

render(forms[0].render());
await userEvent.click(screen.getByTitle("Upvote"));

expect(await screen.findByRole("alert")).toHaveTextContent("Failed to update vote");
expect(screen.getByTitle("Upvote")).toBeEnabled();
});
});
88 changes: 50 additions & 38 deletions src/components/directory/DirectoryVoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,60 +23,72 @@ export function DirectoryVoteButton({
const [score, setScore] = useState(initialScore);
const [userVote, setUserVote] = useState<number | null>(initialUserVote);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleVote(voteType: 1 | -1) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/directory/${listingId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote_type: voteType }),
});

if (res.ok) {
const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "Failed to update vote");
return;
}

const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
} catch {
// silently fail
setError("Failed to update vote");
} finally {
setLoading(false);
}
setLoading(false);
}

return (
<div className="flex items-center gap-1">
<button
onClick={() => handleVote(1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
userVote === 1
? "text-green-500 bg-green-500/10"
: "text-muted-foreground hover:text-green-500 hover:bg-green-500/10"
} disabled:opacity-50`}
title="Upvote"
>
<ThumbsUp className="h-4 w-4" />
</button>
<span className={`text-sm font-medium min-w-[2ch] text-center ${
score > 0 ? "text-green-500" : score < 0 ? "text-red-500" : "text-muted-foreground"
}`}>
{score}
</span>
<button
onClick={() => handleVote(-1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
userVote === -1
? "text-red-500 bg-red-500/10"
: "text-muted-foreground hover:text-red-500 hover:bg-red-500/10"
} disabled:opacity-50`}
title="Downvote"
>
<ThumbsDown className="h-4 w-4" />
</button>
<div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleVote(1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
userVote === 1
? "text-green-500 bg-green-500/10"
: "text-muted-foreground hover:text-green-500 hover:bg-green-500/10"
} disabled:opacity-50`}
title="Upvote"
>
<ThumbsUp className="h-4 w-4" />
</button>
<span className={`text-sm font-medium min-w-[2ch] text-center ${
score > 0 ? "text-green-500" : score < 0 ? "text-red-500" : "text-muted-foreground"
}`}>
{score}
</span>
<button
type="button"
onClick={() => handleVote(-1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
userVote === -1
? "text-red-500 bg-red-500/10"
: "text-muted-foreground hover:text-red-500 hover:bg-red-500/10"
} disabled:opacity-50`}
title="Downvote"
>
<ThumbsDown className="h-4 w-4" />
</button>
</div>
{error && <p role="alert" className="mt-1 text-xs text-destructive">{error}</p>}
</div>
);
}
30 changes: 21 additions & 9 deletions src/components/mcp/McpVoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,41 @@ export function McpVoteButton({
const [score, setScore] = useState(initialScore);
const [userVote, setUserVote] = useState<number | null>(initialUserVote);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleVote(voteType: 1 | -1) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/mcp/${slug}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote_type: voteType }),
});

if (res.ok) {
const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "Failed to update vote");
return;
}

const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
} catch {
// silently fail
setError("Failed to update vote");
} finally {
setLoading(false);
}
setLoading(false);
}

return (
<div className="flex items-center gap-1">
<div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleVote(1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
Expand All @@ -66,6 +75,7 @@ export function McpVoteButton({
{score}
</span>
<button
type="button"
onClick={() => handleVote(-1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
Expand All @@ -77,6 +87,8 @@ export function McpVoteButton({
>
<ThumbsDown className="h-4 w-4" />
</button>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</div>
{error && <p role="alert" className="mt-1 text-xs text-destructive">{error}</p>}
</div>
);
}
30 changes: 21 additions & 9 deletions src/components/prompts/PromptVoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,41 @@ export function PromptVoteButton({
const [score, setScore] = useState(initialScore);
const [userVote, setUserVote] = useState<number | null>(initialUserVote);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleVote(voteType: 1 | -1) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/prompts/${slug}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote_type: voteType }),
});

if (res.ok) {
const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "Failed to update vote");
return;
}

const data = await res.json();
setUpvotes(data.upvotes);
setDownvotes(data.downvotes);
setScore(data.score);
setUserVote(data.user_vote);
} catch {
// silently fail
setError("Failed to update vote");
} finally {
setLoading(false);
}
setLoading(false);
}

return (
<div className="flex items-center gap-1">
<div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleVote(1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
Expand All @@ -66,6 +75,7 @@ export function PromptVoteButton({
{score}
</span>
<button
type="button"
onClick={() => handleVote(-1)}
disabled={loading}
className={`p-1.5 rounded-md transition-colors ${
Expand All @@ -77,6 +87,8 @@ export function PromptVoteButton({
>
<ThumbsDown className="h-4 w-4" />
</button>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
</div>
{error && <p role="alert" className="mt-1 text-xs text-destructive">{error}</p>}
</div>
);
}
Loading
Loading