diff --git a/quotevote-frontend/src/__tests__/components/VotingComponents/VotingPopup.test.tsx b/quotevote-frontend/src/__tests__/components/VotingComponents/VotingPopup.test.tsx
index 6972903..82494a0 100644
--- a/quotevote-frontend/src/__tests__/components/VotingComponents/VotingPopup.test.tsx
+++ b/quotevote-frontend/src/__tests__/components/VotingComponents/VotingPopup.test.tsx
@@ -140,39 +140,40 @@ describe('VotingPopup', () => {
expect(onAddQuote).toHaveBeenCalled()
})
- it('disables voting buttons when user has already voted', () => {
- render()
+ it('does not disable voting buttons when user has already voted, allowing retraction', async () => {
+ const onDeleteVote = jest.fn()
+ render()
const upvoteButton = screen
.getByTestId('like-icon')
.closest('button')
- expect(upvoteButton).toBeDisabled()
+ expect(upvoteButton).not.toBeDisabled()
+
+ if (upvoteButton) {
+ fireEvent.click(upvoteButton)
+ }
+ await waitFor(() => {
+ expect(onDeleteVote).toHaveBeenCalled()
+ })
})
- it('shows tooltip when user has already voted', async () => {
- render(
- ,
- )
+ it('allows vote switching when user has already voted', async () => {
+ const onDeleteVote = jest.fn()
+ render()
- const upvoteButton = screen
- .getByTestId('like-icon')
+ const downvoteButton = screen
+ .getByTestId('dislike-icon')
.closest('button')
- expect(upvoteButton).toBeInTheDocument()
- expect(upvoteButton).toBeDisabled()
+ expect(downvoteButton).not.toBeDisabled()
- // Tooltip content is rendered but may not be visible until hover
- // In Radix UI, tooltips are rendered in a portal and may need user interaction
- // For this test, we verify the button is disabled and the tooltip structure exists
- const tooltipContent = screen.queryByText(/You have already upvoted this post/)
- // Tooltip may not be visible until hover, but the structure should exist
- // If not found, that's okay - tooltips in Radix UI require proper interaction
- if (tooltipContent) {
- expect(tooltipContent).toBeInTheDocument()
+ if (downvoteButton) {
+ fireEvent.click(downvoteButton)
}
+
+ // Wait for the opposite vote tags to expand
+ await waitFor(() => {
+ expect(screen.getByText('#false')).toBeInTheDocument()
+ })
})
it('calls onVote when downvote option is selected', async () => {
@@ -359,14 +360,16 @@ describe('VotingPopup', () => {
})
})
- it('does not allow voting when hasVoted is true', async () => {
+ it('does not expand tags options when clicking user\'s active vote type', async () => {
const onVote = jest.fn()
+ const onDeleteVote = jest.fn()
render(
,
)
@@ -384,19 +387,36 @@ describe('VotingPopup', () => {
expect(onVote).not.toHaveBeenCalled()
})
- it('shows tooltip for downvote when user has downvoted', () => {
+ it('allows switching vote when user has downvoted and onDeleteVote is provided', async () => {
+ const onDeleteVote = jest.fn()
render(
,
)
- const downvoteButton = screen
- .getByTestId('dislike-icon')
+ const upvoteButton = screen
+ .getByTestId('like-icon')
.closest('button')
- expect(downvoteButton).toBeDisabled()
+ expect(upvoteButton).not.toBeDisabled()
+ })
+
+ it('disables buttons and shows clear restriction state when user has voted but onDeleteVote is not provided', () => {
+ render(
+ ,
+ )
+
+ const upvoteButton = screen
+ .getByTestId('like-icon')
+ .closest('button')
+ expect(upvoteButton).toBeDisabled()
})
it('handles window resize for responsive layout', () => {
@@ -492,5 +512,34 @@ describe('VotingPopup', () => {
.closest('button')
expect(upvoteButton).toBeInTheDocument()
})
+
+ it('does not trigger onDeleteVote when clicking on showUpvoteTooltip/showDownvoteTooltip buttons since hasVoted is false', async () => {
+ const onDeleteVote = jest.fn()
+ const votedBy = [
+ {
+ userId: 'user123',
+ type: 'up' as const,
+ _id: 'vote1',
+ },
+ ]
+
+ render(
+ ,
+ )
+
+ const upvoteButton = screen
+ .getByTestId('like-icon')
+ .closest('button')
+ if (upvoteButton) {
+ fireEvent.click(upvoteButton)
+ }
+
+ expect(onDeleteVote).not.toHaveBeenCalled()
+ })
})
diff --git a/quotevote-frontend/src/components/Post/Post.tsx b/quotevote-frontend/src/components/Post/Post.tsx
index d36819a..a6d4380 100644
--- a/quotevote-frontend/src/components/Post/Post.tsx
+++ b/quotevote-frontend/src/components/Post/Post.tsx
@@ -38,6 +38,7 @@ import {
APPROVE_POST,
REJECT_POST,
DELETE_POST,
+ DELETE_VOTE,
} from '@/graphql/mutations'
import {
GET_POST,
@@ -91,6 +92,14 @@ export default function Post({
],
})
+ const [removeVote] = useMutation(DELETE_VOTE, {
+ update() { refetchPost?.() },
+ refetchQueries: [
+ { query: GET_TOP_POSTS, variables: { limit: 5, offset: 0, searchKey: '' } },
+ { query: GET_POST, variables: { postId: _id } },
+ ],
+ })
+
const [addComment] = useMutation(ADD_COMMENT, {
refetchQueries: [
{ query: GET_TOP_POSTS, variables: { limit: 5, offset: 0, searchKey: '' } },
@@ -168,18 +177,50 @@ export default function Post({
(v) => v.user?._id?.toString() === userIdStr && !(v as { deleted?: boolean }).deleted
)
- const getUserVoteType = () => {
+ const getUserVote = () => {
if (!hasVoted) return null
- const userVote = votedBy.find(
+ return votedBy.find(
(v) => v.user?._id?.toString() === userIdStr && !(v as { deleted?: boolean }).deleted
)
+ }
+
+ const getUserVoteType = () => {
+ const userVote = getUserVote()
return userVote ? userVote.type : null
}
+ const handleDeleteVote = async () => {
+ if (!ensureAuth()) return
+ const userVote = getUserVote()
+ if (!userVote) return
+ try {
+ await removeVote({
+ variables: {
+ voteId: userVote._id,
+ },
+ })
+ toast.success('Vote removed successfully')
+ } catch (err) {
+ toast.error(`Error removing vote: ${err instanceof Error ? err.message : 'Unknown'}`)
+ }
+ }
+
const handleVoting = async (obj: { type: VoteType; tags: VoteOption }) => {
if (!ensureAuth()) return
- if (hasVoted) { toast('You have already voted on this post'); return }
+ const userVote = getUserVote()
try {
+ if (userVote) {
+ if (userVote.type === obj.type) {
+ await handleDeleteVote()
+ return
+ }
+ // Switch vote: synchronously delete existing vote first
+ await removeVote({
+ variables: {
+ voteId: userVote._id,
+ },
+ })
+ }
await addVote({
variables: {
vote: {
@@ -546,6 +587,7 @@ export default function Post({
selectedText={selection}
hasVoted={hasVoted}
userVoteType={getUserVoteType() as VoteType | null}
+ onDeleteVote={handleDeleteVote}
/>
)}
diff --git a/quotevote-frontend/src/components/VotingComponents/VotingPopup.tsx b/quotevote-frontend/src/components/VotingComponents/VotingPopup.tsx
index 3447afb..0490e14 100644
--- a/quotevote-frontend/src/components/VotingComponents/VotingPopup.tsx
+++ b/quotevote-frontend/src/components/VotingComponents/VotingPopup.tsx
@@ -31,6 +31,7 @@ export default function VotingPopup({
selectedText,
hasVoted,
userVoteType,
+ onDeleteVote,
}: VotingPopupProps) {
const user = useAppStore((state) => state.user.data)
const [expand, setExpand] = useState<{ open: boolean; type: string }>({
@@ -153,7 +154,7 @@ export default function VotingPopup({
const isComment = expand.type === 'comment'
const voteTooltipText = hasVoted
- ? `You have already ${userVoteType === 'up' ? 'upvoted' : 'downvoted'} this post`
+ ? `You have already ${userVoteType === 'up' ? 'upvoted' : 'downvoted'} this post${!onDeleteVote ? '. Vote changes are not allowed' : ''}`
: ''
return (
@@ -171,29 +172,72 @@ export default function VotingPopup({
{hasVoted ? (
-
-
-
+ !onDeleteVote ? (
+
+
+
+
+
+
+
+ {voteTooltipText}
+
+
+ ) : userVoteType === 'up' ? (
+
+
-
-
-
- {voteTooltipText}
-
-
+
+
+ Retract upvote
+
+
+ ) : (
+
+
+
+
+
+ Change vote to upvote
+
+
+ )
) : showUpvoteTooltip ? (
@@ -217,12 +261,10 @@ export default function VotingPopup({
aria-label="Upvote"
data-testid="highlight-agree-button"
onClick={() => {
- if (!hasVoted) {
- handleSetExpand({
- open: expand.type !== 'up' || !expand.open,
- type: 'up',
- })
- }
+ handleSetExpand({
+ open: expand.type !== 'up' || !expand.open,
+ type: 'up',
+ })
}}
>
@@ -234,29 +276,72 @@ export default function VotingPopup({
{hasVoted ? (
-
-
-
+ !onDeleteVote ? (
+
+
+
+
+
+
+
+ {voteTooltipText}
+
+
+ ) : userVoteType === 'down' ? (
+
+
-
-
-
- {voteTooltipText}
-
-
+
+
+ Retract downvote
+
+
+ ) : (
+
+
+
+
+
+ Change vote to downvote
+
+
+ )
) : showDownvoteTooltip ? (
@@ -280,12 +365,10 @@ export default function VotingPopup({
aria-label="Downvote"
data-testid="highlight-disagree-button"
onClick={() => {
- if (!hasVoted) {
- handleSetExpand({
- open: expand.type !== 'down' || !expand.open,
- type: 'down',
- })
- }
+ handleSetExpand({
+ open: expand.type !== 'down' || !expand.open,
+ type: 'down',
+ })
}}
>
diff --git a/quotevote-frontend/src/types/voting.ts b/quotevote-frontend/src/types/voting.ts
index b84d788..0de12f7 100644
--- a/quotevote-frontend/src/types/voting.ts
+++ b/quotevote-frontend/src/types/voting.ts
@@ -88,6 +88,10 @@ export interface VotingPopupProps {
* Type of vote the current user has cast (if any)
*/
userVoteType?: VoteType | null
+ /**
+ * Handler function called when a vote is retracted/deleted
+ */
+ onDeleteVote?: () => void
}
/**