Skip to content

Commit

Permalink
Vote button reducer (#117)
Browse files Browse the repository at this point in the history
* Moved sessionProvider to Bill

* Refactor VoteButtons  to use useReducer instead of useState
  • Loading branch information
mfosterw authored Apr 18, 2024
1 parent 02fb37c commit 3c768f6
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 57 deletions.
19 changes: 12 additions & 7 deletions democrasite-frontend/components/Bill.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"use client";

import { Title, Text, Anchor, Divider, Stack, Container } from "@mantine/core";
import { type Bill } from "@/lib/models";
import { VoteButtons } from "@/components";
import { SessionProvider } from "next-auth/react";

export function Bill({ bill }: { bill: Bill }) {
return (
Expand Down Expand Up @@ -28,13 +31,15 @@ export function Bill({ bill }: { bill: Bill }) {
-{bill.pullRequest.deletions}
</Text>
</Anchor>
<VoteButtons
id={bill.id}
disabled={bill.status !== "Open"}
userSupports={bill.userSupports}
yesVotes={bill.yesVotes}
noVotes={bill.noVotes}
></VoteButtons>
<SessionProvider>
<VoteButtons
id={bill.id}
disabled={bill.status !== "Open"}
userSupports={bill.userSupports}
yesVotes={bill.yesVotes}
noVotes={bill.noVotes}
/>
</SessionProvider>
</Stack>
);
}
14 changes: 1 addition & 13 deletions democrasite-frontend/components/BillList.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
"use client";

// TODO: This has to be a client component because it uses SessionProvider
// This could be avoided by using getServerSession in Bill.tsx but that would
// require making Bill async, which would also force this to be a client component
// due to Mantine. It may be worth it to ugrapde to Auth.js v5 before it leaves beta
// to make sessions more convenient.
import { Card, Grid, GridCol } from "@mantine/core";
import { Bill } from "@/components";
import type { Bill as BillType } from "@/lib/models";
import { SessionProvider } from "next-auth/react";

export function BillList({ bill_list }: { bill_list: BillType[] }) {
const cards = bill_list.map((bill: BillType) => (
Expand All @@ -19,9 +11,5 @@ export function BillList({ bill_list }: { bill_list: BillType[] }) {
</GridCol>
));

return (
<SessionProvider>
<Grid gutter={{ base: 5, xs: "md", xl: "xl" }}>{cards}</Grid>
</SessionProvider>
);
return <Grid gutter={{ base: 5, xs: "md", xl: "xl" }}>{cards}</Grid>;
}
110 changes: 73 additions & 37 deletions democrasite-frontend/components/VoteButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { Reducer, useReducer, useState } from "react";
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
import { useSession } from "next-auth/react";
import { billsVote } from "@/lib/actions";
Expand Down Expand Up @@ -48,6 +48,51 @@ const ThumbIcon: React.FC<ThumbProps> = ({
</svg>
);

type VoteAction =
| { type: "vote"; vote: "yes" | "no" }
| ({ type: "update" } & VoteCounts)
| { type: "openTooltip"; vote: "yes" | "no"; timeout?: NodeJS.Timeout };

type VoteState = {
vote: "yes" | "no" | null;
yesVotes: number;
noVotes: number;
yesTooltipOpenTimeout?: NodeJS.Timeout;
noTooltipOpenTimeout?: NodeJS.Timeout;
};

const voteReducer: Reducer<VoteState, VoteAction> = (state, action) => {
switch (action.type) {
case "vote": {
const yesVotesDiff =
state.vote === "yes" ? -1 : action.vote === "yes" ? 1 : 0;
const noVotesDiff =
state.vote === "no" ? -1 : action.vote === "no" ? 1 : 0;
const newVote = state.vote === action.vote ? null : action.vote;
return {
...state,
yesVotes: state.yesVotes + yesVotesDiff,
noVotes: state.noVotes + noVotesDiff,
vote: newVote,
};
}
case "update":
return { ...state, yesVotes: action.yesVotes, noVotes: action.noVotes };

case "openTooltip":
// set id for state.yesTooltipOpenTimeout or state.no...
if (state[`${action.vote}TooltipOpenTimeout`] !== undefined) {
clearTimeout(state[`${action.vote}TooltipOpenTimeout`]);
}
return {
...state,
[`${action.vote}TooltipOpenTimeout`]: action.timeout,
};
default:
throw Error("Invalid action type");
}
};

export function VoteButtons({
id,
disabled,
Expand All @@ -59,39 +104,41 @@ export function VoteButtons({
disabled: boolean;
userSupports: boolean | null;
} & VoteCounts) {
const [vote, setVote] = useState<"yes" | "no" | null>(
userSupports ? "yes" : userSupports === false ? "no" : null,
);
const [yesVotesCount, setYesVotes] = useState<number>(yesVotes);
const [noVotesCount, setNoVotes] = useState<number>(noVotes);
const { status } = useSession();
const userVote =
userSupports === true ? "yes" : userSupports === false ? "no" : null;
const [state, dispatch] = useReducer(voteReducer, {
vote: userVote,
yesVotes,
noVotes,
});

const { status: authStatus } = useSession();
// const session = await auth();
// See BillList.tsx to understand the difficulty of accessing the session
const [isVoting, setIsVoting] = useState(false);

const handleVote = async (newVote: "yes" | "no") => {
if (status !== "authenticated") {
alert("You must be signed in to vote");
return;
if (authStatus !== "authenticated") {
dispatch({
type: "openTooltip",
vote: newVote,
timeout: setTimeout(() => {
dispatch({ type: "openTooltip", vote: newVote, timeout: undefined });
}, 3000),
});
}

// Disable voting while a vote is being processed
setIsVoting(true);

const yesVotesDiff = vote === "yes" ? -1 : newVote === "yes" ? 1 : 0;
setYesVotes((current) => current + yesVotesDiff);
const noVotesDiff = vote === "no" ? -1 : newVote === "no" ? 1 : 0;
setNoVotes((current) => current + noVotesDiff);

setVote((currentVote) => (currentVote === newVote ? null : newVote));
dispatch({ type: "vote", vote: newVote });

try {
const { yesVotes, noVotes } = await billsVote({
const voteCounts = await billsVote({
id,
vote: { support: newVote === "yes" },
});
setYesVotes(yesVotes);
setNoVotes(noVotes);
dispatch({ type: "update", ...voteCounts });
} catch (error) {
console.error("Failed to cast vote", error);
} finally {
Expand All @@ -101,37 +148,26 @@ export function VoteButtons({
};

function VoteButton({ voteType }: { voteType: "yes" | "no" }) {
const [tooltipOpen, setTooltipOpen] = useState(false);
const loading = !disabled && (isVoting || authStatus === "loading");
return (
<Tooltip
label="You must be logged in to vote"
opened={tooltipOpen}
opened={state[`${voteType}TooltipOpenTimeout`] !== undefined}
color="blue"
withArrow
>
<ActionIcon
color={voteType === "yes" ? "green" : "red"}
variant={vote === voteType ? "filled" : "outline"}
loading={isVoting || status === "loading"}
variant={state.vote === voteType ? "filled" : "outline"}
disabled={disabled}
loading={loading}
onClick={() => {
if (status !== "authenticated") {
setTooltipOpen((current) => {
if (!current) {
setTimeout(() => {
setTooltipOpen(false);
}, 3000);
}
return !current;
});
return;
}
void handleVote(voteType);
}}
>
<ThumbIcon
type={voteType === "yes" ? "up" : "down"}
selected={vote === voteType}
selected={state.vote === voteType}
/>
</ActionIcon>
</Tooltip>
Expand All @@ -142,10 +178,10 @@ export function VoteButtons({
<Group justify="space-between">
<Group c="green">
<VoteButton voteType="yes" />
<Text>{yesVotesCount}</Text>
<Text>{state.yesVotes}</Text>
</Group>
<Group c="red">
<Text>{noVotesCount}</Text>
<Text>{state.noVotes}</Text>
<VoteButton voteType="no" />
</Group>
</Group>
Expand Down

0 comments on commit 3c768f6

Please sign in to comment.