From 719ae26205f448414af1c794a4de7c1eedd753ff Mon Sep 17 00:00:00 2001 From: Zekbot001 <280472700+Zekbot001@users.noreply.github.com> Date: Sun, 31 May 2026 20:32:06 +0200 Subject: [PATCH 1/3] fix(feed): let users change poll votes --- src/components/feed/PollDisplay.test.tsx | 65 ++++++++++++++++++++++++ src/components/feed/PollDisplay.tsx | 31 ++++++++--- 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/components/feed/PollDisplay.test.tsx diff --git a/src/components/feed/PollDisplay.test.tsx b/src/components/feed/PollDisplay.test.tsx new file mode 100644 index 00000000..753a1499 --- /dev/null +++ b/src/components/feed/PollDisplay.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PollDisplay } from "./PollDisplay"; + +const initialResults = { + options: [ + { id: "option-1", text: "First", votes: 1, percentage: 100 }, + { id: "option-2", text: "Second", votes: 0, percentage: 0 }, + ], + total_votes: 1, + user_vote: "option-1", +}; + +const changedResults = { + options: [ + { id: "option-1", text: "First", votes: 0, percentage: 0 }, + { id: "option-2", text: "Second", votes: 1, percentage: 100 }, + ], + total_votes: 1, + user_vote: "option-2", +}; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("PollDisplay", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("lets an authenticated viewer change an existing vote", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(initialResults)) + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(changedResults)); + + render(); + await userEvent.click(await screen.findByRole("button", { name: /Second/ })); + + expect(fetch).toHaveBeenNthCalledWith( + 2, + "/api/posts/post-1/poll/vote", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ option_id: "option-2" }), + }) + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Second/ })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + }); +}); diff --git a/src/components/feed/PollDisplay.tsx b/src/components/feed/PollDisplay.tsx index 6a28212b..b918b449 100644 --- a/src/components/feed/PollDisplay.tsx +++ b/src/components/feed/PollDisplay.tsx @@ -80,14 +80,8 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { const isWinner = showResults && option.votes === Math.max(...options.map((o) => o.votes)) && option.votes > 0; if (showResults) { - return ( -
+ const result = ( + <> {/* Background bar */}
+ + ); + const className = cn( + "relative rounded-md border overflow-hidden transition-colors", + isSelected ? "border-primary" : "border-border" + ); + + return isLoggedIn ? ( + + ) : ( +
+ {result}
); } From 476e8b309345238944aa3d1fc720588ceb64b9e2 Mon Sep 17 00:00:00 2001 From: Zekbot001 <280472700+Zekbot001@users.noreply.github.com> Date: Sun, 31 May 2026 20:36:23 +0200 Subject: [PATCH 2/3] fix(feed): avoid redundant poll revotes --- src/components/feed/PollDisplay.test.tsx | 15 ++++++++++++--- src/components/feed/PollDisplay.tsx | 11 ++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/feed/PollDisplay.test.tsx b/src/components/feed/PollDisplay.test.tsx index 753a1499..1a4bee33 100644 --- a/src/components/feed/PollDisplay.test.tsx +++ b/src/components/feed/PollDisplay.test.tsx @@ -45,7 +45,7 @@ describe("PollDisplay", () => { .mockResolvedValueOnce(jsonResponse(changedResults)); render(); - await userEvent.click(await screen.findByRole("button", { name: /Second/ })); + await userEvent.click(await screen.findByRole("radio", { name: /Second/ })); expect(fetch).toHaveBeenNthCalledWith( 2, @@ -56,10 +56,19 @@ describe("PollDisplay", () => { }) ); await waitFor(() => { - expect(screen.getByRole("button", { name: /Second/ })).toHaveAttribute( - "aria-pressed", + expect(screen.getByRole("radio", { name: /Second/ })).toHaveAttribute( + "aria-checked", "true" ); }); }); + + it("does not submit the already-selected option again", async () => { + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(initialResults)); + + render(); + await userEvent.click(await screen.findByRole("radio", { name: /First/ })); + + expect(fetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/feed/PollDisplay.tsx b/src/components/feed/PollDisplay.tsx index b918b449..16908414 100644 --- a/src/components/feed/PollDisplay.tsx +++ b/src/components/feed/PollDisplay.tsx @@ -37,7 +37,7 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { }, [postId]); const handleVote = async (optionId: string) => { - if (!isLoggedIn || voting) return; + if (!isLoggedIn || voting || optionId === userVote) return; setVoting(true); try { @@ -74,7 +74,11 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { const showResults = hasVoted || !isLoggedIn; return ( -
e.stopPropagation()}> +
e.stopPropagation()} + role={showResults && isLoggedIn ? "radiogroup" : undefined} + > {options.map((option) => { const isSelected = userVote === option.id; const isWinner = showResults && option.votes === Math.max(...options.map((o) => o.votes)) && option.votes > 0; @@ -114,7 +118,8 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { className={cn(className, "block w-full text-left disabled:opacity-50")} onClick={() => handleVote(option.id)} disabled={voting} - aria-pressed={isSelected} + role="radio" + aria-checked={isSelected} > {result} From 03f68d7a558439bbcfce4b362e49cc88730f4e94 Mon Sep 17 00:00:00 2001 From: Zekbot001 <280472700+Zekbot001@users.noreply.github.com> Date: Sun, 31 May 2026 20:40:51 +0200 Subject: [PATCH 3/3] fix(feed): label poll choice groups --- src/components/feed/PollDisplay.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/feed/PollDisplay.tsx b/src/components/feed/PollDisplay.tsx index 16908414..276c3d47 100644 --- a/src/components/feed/PollDisplay.tsx +++ b/src/components/feed/PollDisplay.tsx @@ -78,6 +78,7 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) { className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()} role={showResults && isLoggedIn ? "radiogroup" : undefined} + aria-label={showResults && isLoggedIn ? "Poll choices" : undefined} > {options.map((option) => { const isSelected = userVote === option.id;