Skip to content
Closed
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
55 changes: 36 additions & 19 deletions src/components/vote/vote_box/VoteBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ProgressBar } from "@logora/debate/progress/progress_bar";
import { useTranslatedContent } from "@logora/debate/translation/translated_content";
import useSessionStorageState from "@rooks/use-sessionstorage-state";
import cx from "classnames";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLocation } from "react-router";
import styles from "./VoteBox.module.scss";
Expand All @@ -36,6 +36,8 @@ export const VoteBox = ({
}) => {
const [isLoadingVote, setIsLoadingVote] = useState(true);
const [currentVote, setCurrentVote] = useState(undefined);
const [voteDisabled, setVoteDisabled] = useState(false);
const voteInProgress = useRef(false);
const [showResults, setShowResults] = useState(false);
const [totalVotes, setTotalVotes] = useState(
Number.parseFloat(numberVotes.total) ||
Expand Down Expand Up @@ -176,6 +178,10 @@ export const VoteBox = ({
};

const voteAction = (positionId) => {
const releaseLock = () => {
voteInProgress.current = false;
setVoteDisabled(false);
};
const data = {
voteable_id: voteableId,
voteable_type: voteableType || "Group",
Expand All @@ -191,7 +197,30 @@ export const VoteBox = ({
updateVote(positionId, currentVote.position_id);
showResults === false && toggleResults();
if (positionId !== currentVote.position_id) {
api.update("votes", currentVote.id, data).then((response) => {
voteInProgress.current = true;
setVoteDisabled(true);
api.update("votes", currentVote.id, data)
.then((response) => {
if (response.data.success) {
setCurrentVote(response.data.data.resource);
toast(
intl.formatMessage({
id: "header.vote_confirm_modal",
defaultMessage: "Your vote has been saved !",
}),
{ type: "success" },
);
}
})
.finally(releaseLock);
}
} else {
addVote(positionId);
toggleResults();
voteInProgress.current = true;
setVoteDisabled(true);
api.create("votes", data)
.then((response) => {
if (response.data.success) {
setCurrentVote(response.data.data.resource);
toast(
Expand All @@ -202,23 +231,8 @@ export const VoteBox = ({
{ type: "success" },
);
}
});
}
} else {
addVote(positionId);
toggleResults();
api.create("votes", data).then((response) => {
if (response.data.success) {
setCurrentVote(response.data.data.resource);
toast(
intl.formatMessage({
id: "header.vote_confirm_modal",
defaultMessage: "Your vote has been saved !",
}),
{ type: "success" },
);
}
});
})
.finally(releaseLock);
}
};

Expand Down Expand Up @@ -277,6 +291,9 @@ export const VoteBox = ({

const handleVote = (positionId) => {
if (isLoggedIn) {
if (isLoadingVote || voteDisabled || voteInProgress.current) {
return;
}
if (Object.keys(votesCount).includes(positionId.toString())) {
voteAction(positionId);
removeSavedVote();
Expand Down
96 changes: 89 additions & 7 deletions src/components/vote/vote_box/VoteBox.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { IconProvider } from "@logora/debate/icons/icon_provider";
import * as regularIcons from "@logora/debate/icons/regular_icons";
import { Location } from "@logora/debate/util/location";
import { VoteProvider } from "@logora/debate/vote/vote_provider";
import { render, within } from "@testing-library/react";
import { act, render, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { IntlProvider } from "react-intl";
Expand Down Expand Up @@ -68,19 +68,19 @@ const votePositions = [

const data = dataProvider(httpClient, "https://mock.example.api");

const VoteBoxWrapper = (props) => {
const VoteBoxWrapper = ({ children, data = dataProvider(httpClient, "https://mock.example.api") }) => {
return (
<BrowserRouter>
<IntlProvider locale="en">
<DataProviderContext.Provider value={{ dataProvider: data }}>
<AuthContext.Provider
value={{ currentUser: currentUser, isLoggedIn: true }}
value={{ currentUser: currentUser, isLoggedIn: true, isLoggingIn: false }}
>
<IconProvider library={regularIcons}>
<ToastProvider>
<ConfigProvider config={{}} routes={{ ...routes }}>
<ModalProvider>
<VoteProvider>{props.children}</VoteProvider>
<VoteProvider>{children}</VoteProvider>
</ModalProvider>
</ConfigProvider>
</ToastProvider>
Expand Down Expand Up @@ -206,6 +206,7 @@ describe("VoteBox Component", () => {
<VoteBoxWrapper>
<VoteBox
voteableId={debate.id}
voteableType={vote.voteable_type}
votePositions={votePositions}
numberVotes={debate.votes_count}
/>
Expand All @@ -231,19 +232,19 @@ describe("VoteBox Component", () => {
});

it("should redirect after vote", async () => {
const { getByTestId, getByTitle, queryAllByRole } = render(
sessionStorage.clear();
const { getByTestId, getByTitle } = render(
<VoteBoxWrapper>
<VoteBox
voteableId={debate.id}
voteableType={vote.voteable_type}
votePositions={votePositions}
numberVotes={debate.votes_count}
redirectUrl={"myUrl"}
/>
</VoteBoxWrapper>,
);

expect(queryAllByRole("button")).toHaveLength(2);

const showResultLink = getByTestId("show-result");
expect(showResultLink).toHaveAttribute("href", "myUrl?initVote=true");

Expand All @@ -265,4 +266,85 @@ describe("VoteBox Component", () => {
"myUrl?initVote=true&positionId=3",
);
});

it("should not create a vote while the existing vote is still loading", async () => {
const loadingHttpClient = {
get: () => new Promise(() => {}),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
const loadingData = dataProvider(
loadingHttpClient,
"https://mock.example.api",
);
const { getByTitle } = render(
<VoteBoxWrapper data={loadingData}>
<VoteBox
voteableId={debate.id}
voteableType={vote.voteable_type}
votePositions={votePositions}
numberVotes={debate.votes_count}
/>
</VoteBoxWrapper>,
);

const position1 = getByTitle("Position 1");
await userEvent.click(position1);
expect(loadingHttpClient.post).not.toHaveBeenCalled();
});

it("should not trigger multiple vote creations on rapid clicks", async () => {
let resolveCreate;
const postMock = vi.fn(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
const controlledHttpClient = {
get: () =>
Promise.resolve({
data: { success: true, data: { resource: null } },
}),
post: postMock,
patch: vi.fn(),
delete: vi.fn(),
};
const controlledData = dataProvider(
controlledHttpClient,
"https://mock.example.api",
);
const { getByTitle } = render(
<VoteBoxWrapper data={controlledData}>
<VoteBox
voteableId={debate.id}
voteableType={vote.voteable_type}
votePositions={votePositions}
numberVotes={debate.votes_count}
/>
</VoteBoxWrapper>,
);

const position1 = getByTitle("Position 1");
await userEvent.click(position1);
await userEvent.click(position1);

expect(postMock).toHaveBeenCalledTimes(1);

await act(async () => {
resolveCreate({
data: {
success: true,
data: {
resource: {
...vote,
voteable_id: debate.id,
voteable_type: vote.voteable_type,
},
},
},
});
});
});
});