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
12 changes: 7 additions & 5 deletions src/components/vote/suggestion_vote_box/SuggestionVoteBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const SuggestionVoteBox = ({
}) => {
const intl = useIntl();
const { isLoggedIn } = useAuth();
const { activeVote, voteSide, handleVote } = useVote(
const { activeVote, voteSide, handleVote, isVoteReady } = useVote(
voteableType,
voteableId,
totalUpvote,
Expand All @@ -27,6 +27,8 @@ export const SuggestionVoteBox = ({
);
const { showModal } = useModal();

const buttonDisabled = disabled || !isVoteReady;

const handleDownvote = () => {
if (isLoggedIn) {
if (!(activeVote && !voteSide)) {
Expand Down Expand Up @@ -64,9 +66,9 @@ export const SuggestionVoteBox = ({
<div className={styles.voteButtonsContainer}>
<Button
type="button"
onClick={disabled ? null : () => handleVote(true)}
onClick={buttonDisabled ? null : () => handleVote(true)}
data-testid="upvote-button"
disabled={disabled}
disabled={buttonDisabled}
rightIcon={
<Icon
name="check"
Expand All @@ -87,8 +89,8 @@ export const SuggestionVoteBox = ({
</Button>
<Button
type="button"
onClick={disabled ? null : () => handleDownvote()}
disabled={disabled}
onClick={buttonDisabled ? null : () => handleDownvote()}
disabled={buttonDisabled}
rightIcon={
<Icon name="close" width={10} height={10} aria-hidden="true" />
}
Expand Down
12 changes: 7 additions & 5 deletions src/components/vote/up_down_vote_box/UpDownVoteBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ export const UpDownVoteBox = ({
disabled = false,
hideDownvotes = false,
}) => {
const { totalUpvotes, totalDownvotes, activeVote, voteSide, handleVote } =
const { totalUpvotes, totalDownvotes, activeVote, voteSide, handleVote, isVoteReady } =
useVote(voteableType, voteableId, totalUpvote, totalDownvote);
const intl = useIntl();

const buttonDisabled = disabled || !isVoteReady;

return (
<div className={styles.container}>
<div
className={cx(styles.upvoteContainer, {
[styles.disabled]: disabled,
[styles.disabled]: buttonDisabled,
[styles.active]: activeVote && voteSide,
})}
onClick={disabled ? null : () => handleVote(true)}
onClick={buttonDisabled ? null : () => handleVote(true)}
data-testid="upvote-button"
>
<Icon
Expand All @@ -40,10 +42,10 @@ export const UpDownVoteBox = ({
</div>
<div
className={cx(styles.downvoteContainer, {
[styles.disabled]: disabled,
[styles.disabled]: buttonDisabled,
[styles.active]: activeVote && !voteSide,
})}
onClick={disabled ? null : () => handleVote(false)}
onClick={buttonDisabled ? null : () => handleVote(false)}
data-testid="downvote-button"
>
<Icon
Expand Down
33 changes: 24 additions & 9 deletions src/components/vote/use_vote/useVote.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useAuth } from "@logora/debate/auth/use_auth";
import { useDataProvider } from "@logora/debate/data/data_provider";
import { useAuthRequired } from "@logora/debate/hooks/use_auth_required";
import { VoteContext } from "@logora/debate/vote/vote_provider";
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";

export const useVote = (
voteableType,
Expand All @@ -11,7 +11,7 @@ export const useVote = (
downvotes,
onVote = null,
) => {
const { votes } = useContext(VoteContext);
const { votes, voteableIds, votesLoading } = useContext(VoteContext);
const { isLoggedIn } = useAuth();
const api = useDataProvider();

Expand All @@ -22,8 +22,14 @@ export const useVote = (
const [totalDownvotes, setTotalDownvotes] = useState(downvotes);
const [voteDisabled, setVoteDisabled] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
const voteRequestInProgress = useRef(false);
const requireAuthentication = useAuthRequired();

// The voteable is managed by a VoteProvider: we must not create a vote
// before its existing vote has been loaded, otherwise we could duplicate it.
const isVoteableInProvider = Array.isArray(voteableIds) && voteableIds.includes(voteableId);
const isVoteReady = !isVoteableInProvider || !votesLoading;

useEffect(() => {
if (!hasVoted && isLoggedIn) {
if (Object.keys(votes).length > 0 && voteableId in votes) {
Expand Down Expand Up @@ -66,7 +72,12 @@ export const useVote = (
};

const voteAction = (isUpvote) => {
const releaseVoteLock = () => {
voteRequestInProgress.current = false;
setVoteDisabled(false);
};
setHasVoted(true);
voteRequestInProgress.current = true;
if (activeVote) {
if (voteSide === isUpvote) {
deactivateVote(isUpvote);
Expand All @@ -78,11 +89,11 @@ export const useVote = (
} else {
activateVote(isUpvote);
}
setVoteDisabled(false);
releaseVoteLock();
},
(error) => {
activateVote(isUpvote);
setVoteDisabled(false);
releaseVoteLock();
},
);
} else {
Expand All @@ -98,12 +109,12 @@ export const useVote = (
deactivateVote(isUpvote);
activateVote(!isUpvote);
}
setVoteDisabled(false);
releaseVoteLock();
},
(error) => {
deactivateVote(isUpvote);
activateVote(!isUpvote);
setVoteDisabled(false);
releaseVoteLock();
},
);
}
Expand All @@ -122,19 +133,22 @@ export const useVote = (
} else {
deactivateVote(isUpvote);
}
setVoteDisabled(false);
releaseVoteLock();
},
(error) => {
deactivateVote(isUpvote);
setVoteDisabled(false);
releaseVoteLock();
},
);
}
};

const handleVote = (isUpvote) => {
if (!voteDisabled) {
if (!voteDisabled && !voteRequestInProgress.current) {
if (isLoggedIn) {
if (!isVoteReady) {
return;
}
voteAction(isUpvote);
} else {
requireAuthentication({ loginAction: "vote" });
Expand All @@ -148,5 +162,6 @@ export const useVote = (
activeVote,
voteSide,
handleVote,
isVoteReady,
};
};
123 changes: 123 additions & 0 deletions src/components/vote/use_vote/useVote.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,127 @@ describe("useVote", () => {
expect(screen.getByText("ActiveVote : true"));
expect(screen.getByText("Upvotes : 10")); // no change to count, just status
});

it("should not create a vote while the existing votes are still loading", async () => {
const voteableId = faker.number.int();
const voteableType = "message";

const postMock = vi.fn(() =>
Promise.resolve({
data: { success: true, data: { resource: { id: 1 } } },
}),
);
const loadingData = dataProvider(
{ ...httpClient, post: postMock },
"https://mock.example.api",
);

const ControllableVoteWrapper = ({ children }) => {
const [votes, setVotes] = useState({});
const [votesLoading, setVotesLoading] = useState(true);
return (
<ConfigProvider config={{}}>
<DataProviderContext.Provider value={{ dataProvider: loadingData }}>
<AuthContext.Provider value={{ currentUser, isLoggedIn: true }}>
<ModalProvider>
<VoteContext.Provider
value={{
votes,
voteableIds: [voteableId],
votesLoading,
addVoteableIds: vi.fn(),
}}
>
{children}
<button
data-testid="finish-loading"
onClick={() => act(() => setVotesLoading(false))}
/>
</VoteContext.Provider>
</ModalProvider>
</AuthContext.Provider>
</DataProviderContext.Provider>
</ConfigProvider>
);
};

const VoteButton = () => {
const { totalUpvotes, handleVote } = useVote(
voteableType,
voteableId,
10,
5,
);
return (
<>
<button onClick={() => handleVote(true)} data-testid="upvote" />
<span>Upvotes : {totalUpvotes}</span>
</>
);
};

const { getByTestId } = render(
<ControllableVoteWrapper>
<VoteButton />
</ControllableVoteWrapper>,
);

// Votes are still loading: a click must not create a vote
await userEvent.click(getByTestId("upvote"));
expect(postMock).not.toHaveBeenCalled();

// Once the votes are loaded, the vote is allowed
await userEvent.click(getByTestId("finish-loading"));
await userEvent.click(getByTestId("upvote"));
expect(postMock).toHaveBeenCalledTimes(1);
});

it("should not create multiple votes on rapid clicks", async () => {
const voteableId = faker.number.int();
const voteableType = "message";

let resolveCreate;
const postMock = vi.fn(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
const rapidData = dataProvider(
{ ...httpClient, post: postMock },
"https://mock.example.api",
);

const VoteButton = () => {
const { totalUpvotes, handleVote } = useVote(
voteableType,
voteableId,
10,
5,
);
return (
<>
<button onClick={() => handleVote(true)} data-testid="upvote" />
<span>Upvotes : {totalUpvotes}</span>
</>
);
};

const { getByTestId } = render(
<VoteWrapper data={rapidData}>
<VoteButton />
</VoteWrapper>,
);

await userEvent.click(getByTestId("upvote"));
await userEvent.click(getByTestId("upvote"));

expect(postMock).toHaveBeenCalledTimes(1);

await act(async () => {
resolveCreate({
data: { success: true, data: { resource: { id: 1 } } },
});
});
});
});
8 changes: 5 additions & 3 deletions src/components/vote/vote_button/VoteButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ export const VoteButton = ({
activeClassName,
disabled = false,
}) => {
const { totalUpvotes, activeVote, handleVote } = useVote(
const { totalUpvotes, activeVote, handleVote, isVoteReady } = useVote(
voteableType,
voteableId,
totalUpvote,
totalDownvote,
);
const intl = useIntl();

const buttonDisabled = disabled || !isVoteReady;

return (
<button
type="button"
className={cx(styles.voteButton, {
[styles.active]: activeVote,
[activeClassName]: activeVote,
[styles.disabled]: disabled,
[styles.disabled]: buttonDisabled,
})}
onClick={disabled ? null : () => handleVote(true)}
onClick={buttonDisabled ? null : () => handleVote(true)}
data-testid="vote-button"
data-tid="action_vote_argument"
aria-pressed={activeVote}
Expand Down
Loading