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 } /**