fix: refactor ballots

pull/89/head
Pierre-Louis Guhur 1 year ago
parent d65056fb2d
commit f3873cbb34

@ -1,38 +1,51 @@
import {useState, useCallback, useEffect, MouseEvent} from 'react';
import Image from 'next/image';
import Head from 'next/head';
import {useRouter} from 'next/router';
import {serverSideTranslations} from 'next-i18next/serverSideTranslations';
import {useTranslation} from 'next-i18next';
import {
Button,
Col,
Container,
Row,
} from 'reactstrap';
import {Col, Row, Container} from 'reactstrap';
// import {toast, ToastContainer} from "react-toastify";
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCalendarDays, faCheck} from '@fortawesome/free-solid-svg-icons';
import {getElection, castBallot, apiErrors, ElectionPayload} from '@services/api';
import ErrorMessage from '@components/Error';
import {getElection, castBallot, apiErrors, ElectionPayload, CandidatePayload, GradePayload} from '@services/api';
import Button from '@components/Button';
import useEmblaCarousel from 'embla-carousel-react';
import {DotButton} from '@components/admin/EmblaCarouselButtons';
import VoteButtonWithConfirm from '@components/admin/VoteButtonWithConfirm';
import {getGradeColor} from '@services/grades';
import {useBallot, BallotTypes, BallotProvider} from '@services/BallotContext';
import {getLocaleShort} from '@services/utils';
import {ENDED_VOTE} from '@services/routes';
import defaultAvatar from '../../../public/avatarBlue.svg';
const shuffle = (array) => array.sort(() => Math.random() - 0.5);
export async function getServerSideProps({query: {pid, tid}, locale}) {
if (!pid) {
return {notFound: true}
}
const [election, translations] = await Promise.all([
getElection(pid),
serverSideTranslations(locale, ['resource']),
]);
if (typeof election === 'string' || election instanceof String) {
return {props: {err: election, ...translations}};
return {notFound: true}
}
const dateEnd = new Date(election.date_end)
if (dateEnd.getDate() > new Date().getDate()) {
return {
redirect: ENDED_VOTE,
permanent: false
}
}
if (!election || !election.candidates || !Array.isArray(election.candidates)) {
return {props: {err: 'Unknown error', ...translations}};
console.log(election);
return {notFound: true}
}
const description = JSON.parse(election.description);
@ -51,21 +64,140 @@ export async function getServerSideProps({query: {pid, tid}, locale}) {
};
}
interface TitleBarInterface {
election: ElectionPayload;
}
const TitleBar = ({election}: TitleBarInterface) => {
const {t} = useTranslation();
const router = useRouter();
const locale = getLocaleShort(router);
return (
<div className="w-100 bg-light p-2 text-black justify-content-center d-flex ">
<div className="me-2">
<FontAwesomeIcon icon={faCalendarDays} />
</div>
<div>
{` ${t("vote.open-until")} ${new Date(election.date_end).toLocaleDateString(locale, {dateStyle: "long"})}`}
</div>
</div>
)
};
interface CandidateCardInterface {
candidate: CandidatePayload;
}
const CandidateCard = ({candidate}: CandidateCardInterface) => {
const {t} = useTranslation();
return (<div className="d-flex align-items-center">
<Image
src={defaultAvatar}
width={32}
height={32}
className="bg-light"
alt={t('common.thumbnail')}
/>
<div className="d-flex lh-sm flex-column justify-content-center ps-3">
<span className="text-black fs-5 m-0 ">{candidate.name}</span>
<br />
<span className="text-muted fs-6 m-0 fw-normal">{t("vote.more-details")}</span>
</div>
</div>)
}
interface GradeInputInterface {
gradeId: number;
candidateId: number;
}
const GradeInput = ({gradeId, candidateId}: GradeInputInterface) => {
const [ballot, dispatch] = useBallot();
if (!ballot) {throw Error("Ensure the election is loaded")}
const grade = ballot.election.grades[gradeId];
const numGrades = ballot.election.grades.length;
const color = getGradeColor(gradeId, numGrades);
const handleClick = (event: MouseEvent<HTMLInputElement>) => {
dispatch({type: BallotTypes.VOTE, candidateId: candidateId, gradeId: gradeId})
};
const active = ballot.votes.some(b => b.gradeId === gradeId && b.candidateId === candidateId)
return (
<div
className="text-lg-center my-1 voteCheck"
onClick={handleClick}
>
<small
className="nowrap d-lg-none bold badge"
style={
active
? {
backgroundColor: color,
color: '#fff',
}
: {
backgroundColor: 'transparent',
color: '#000',
}
}
>
{grade.name}
</small>
<span
className="checkmark candidateGrade fs-6"
style={
active
? {
backgroundColor: color,
color: '#fff',
}
: {
backgroundColor: '#C3BFD8',
color: '#000',
}
}
>
{ /*<small
className="nowrap bold badge"
style={{
backgroundColor: 'transparent',
color: '#fff',
}}
>*/}
{grade.name}
{ /*</small>*/}
</span>
</div>
)
}
interface VoteInterface {
election?: ElectionPayload;
election: ElectionPayload;
err: string;
token?: string;
}
const VoteBallot = ({election, err, token}: VoteInterface) => {
const VoteBallot = ({election, token}: VoteInterface) => {
const {t} = useTranslation();
if (err || !election) {
return <ErrorMessage msg={t(apiErrors(err))} />;
const router = useRouter();
const [ballot, dispatch] = useBallot();
useEffect(() => {
dispatch({
type: BallotTypes.ELECTION,
election: election,
});
}, []);
if (!ballot.election) {
return <div>"Loading..."</div>;
}
const numGrades = election.grades.length;
const [judgments, setJudgments] = useState([]);
const numGrades = ballot.election.grades.length;
const disabled = ballot.votes.length !== ballot.election.candidates.length;
const colSizeCandidateLg = 4;
const colSizeCandidateMd = 6;
const colSizeCandidateXs = 12;
@ -73,21 +205,6 @@ const VoteBallot = ({election, err, token}: VoteInterface) => {
const colSizeGradeMd = Math.floor((12 - colSizeCandidateMd) / numGrades);
const colSizeGradeXs = Math.floor((12 - colSizeCandidateXs) / numGrades);
const router = useRouter();
const handleGradeClick = (event: MouseEvent<HTMLInputElement>) => {
let data = {
id: parseInt(event.currentTarget.getAttribute('data-id')),
value: parseInt(event.currentTarget.value),
};
//remove candidate
const newJudgments = judgments.filter(
(judgment) => judgment.id !== data.id
);
newJudgments.push(data);
setJudgments(newJudgments);
};
const handleSubmitWithoutAllRate = () => {
alert(t('You have to judge every candidate/proposal!'));
};
@ -95,49 +212,49 @@ const VoteBallot = ({election, err, token}: VoteInterface) => {
const handleSubmit = (event) => {
event.preventDefault();
const gradesById = {};
judgments.forEach((c) => {
gradesById[c.id] = c.value;
});
const gradesByCandidate = [];
Object.keys(gradesById).forEach((id) => {
gradesByCandidate.push(gradesById[id]);
});
// const gradesById = {};
// judgments.forEach((c) => {
// gradesById[c.id] = c.value;
// });
// const gradesByCandidate = [];
// Object.keys(gradesById).forEach((id) => {
// gradesByCandidate.push(gradesById[id]);
// });
castBallot(gradesByCandidate, election.id.toString(), token, () => {
router.push(`/vote/${election.id}/confirm`);
});
// castBallot(gradesByCandidate, election.id.toString(), token, () => {
router.push(`/confirm/${election.id}`);
// });
};
const [viewportRef, embla] = useEmblaCarousel({skipSnaps: false});
const [prevBtnEnabled, setPrevBtnEnabled] = useState(false);
const [nextBtnEnabled, setNextBtnEnabled] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState([]);
const scrollPrev = useCallback(() => embla && embla.scrollPrev(), [embla]);
const scrollNext = useCallback(() => embla && embla.scrollNext(), [embla]);
const scrollTo = useCallback(
(index) => embla && embla.scrollTo(index),
[embla]
);
// const [viewportRef, embla] = useEmblaCarousel({skipSnaps: false});
// const [prevBtnEnabled, setPrevBtnEnabled] = useState(false);
// const [nextBtnEnabled, setNextBtnEnabled] = useState(false);
// const [selectedIndex, setSelectedIndex] = useState(0);
// const [scrollSnaps, setScrollSnaps] = useState([]);
const onSelect = useCallback(() => {
if (!embla) return;
setSelectedIndex(embla.selectedScrollSnap());
setPrevBtnEnabled(embla.canScrollPrev());
setNextBtnEnabled(embla.canScrollNext());
}, [embla, setSelectedIndex]);
// const scrollPrev = useCallback(() => embla && embla.scrollPrev(), [embla]);
// const scrollNext = useCallback(() => embla && embla.scrollNext(), [embla]);
// const scrollTo = useCallback(
// (index) => embla && embla.scrollTo(index),
// [embla]
// );
useEffect(() => {
if (!embla) return;
onSelect();
setScrollSnaps(embla.scrollSnapList());
embla.on('select', onSelect);
}, [embla, setScrollSnaps, onSelect]);
// const onSelect = useCallback(() => {
// if (!embla) return;
// setSelectedIndex(embla.selectedScrollSnap());
// setPrevBtnEnabled(embla.canScrollPrev());
// setNextBtnEnabled(embla.canScrollNext());
// }, [embla, setSelectedIndex]);
// useEffect(() => {
// if (!embla) return;
// onSelect();
// setScrollSnaps(embla.scrollSnapList());
// embla.on('select', onSelect);
// }, [embla, setScrollSnaps, onSelect]);
return (
<Container className="homePage">
<>
<Head>
<title>{election.name}</title>
@ -149,173 +266,50 @@ const VoteBallot = ({election, err, token}: VoteInterface) => {
/>
</Head>
<div className="w-100 bg-light text-center">
<FontAwesomeIcon icon={faCalendarDays} />
VOTE OUVERT
</div>
<div className="d-flex justify-content-center align-items-center">
<h1>{election.name}</h1>
<TitleBar election={ballot.election} />
<div className="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
<h1 className="mb-5">{election.name}</h1>
<form onSubmit={handleSubmit} autoComplete="off">
<div className="embla" ref={viewportRef}>
<div className="embla__container">
{election.candidates.map((candidate, candidateId) => {
return (
<div className="embla__slide">
<Row key={candidateId} className="cardVote">
<Col className="cardVoteLabel mb-3">
<h5 className="m-0">{candidate.name}</h5>
<h5 className="m-0">{candidate.id + 1}</h5>
</Col>
<Col className="cardVoteGrades">
{election.grades.map((grade, gradeId) => {
console.assert(gradeId < numGrades);
const gradeValue = grade.value;
const color = getGradeColor(gradeId, numGrades);
return (
<Col
key={gradeId}
className="text-lg-center my-1 voteCheck"
>
<label
htmlFor={
'candidateGrade' +
candidateId +
'-' +
gradeValue
}
className="check"
>
<small
className="nowrap d-lg-none bold badge"
style={
judgments.find((judgment) => {
return (
JSON.stringify(judgment) ===
JSON.stringify({
id: candidate.id,
value: gradeValue,
})
);
})
? {
backgroundColor: color,
color: '#fff',
}
: {
backgroundColor: 'transparent',
color: '#000',
}
}
>
{grade.name}
</small>
<input
type="radio"
name={'candidate' + candidateId}
id={
'candidateGrade' +
candidateId +
'-' +
gradeValue
}
data-index={candidateId}
data-id={candidate.id}
value={grade.value}
onClick={handleGradeClick}
defaultChecked={judgments.find(
(element) => {
return (
JSON.stringify(element) ===
JSON.stringify({
id: candidate.id,
value: gradeValue,
})
);
}
)}
/>
<span
className="checkmark candidateGrade "
style={
judgments.find(function (judgment) {
return (
JSON.stringify(judgment) ===
JSON.stringify({
id: candidate.id,
value: gradeValue,
})
);
})
? {
backgroundColor: color,
color: '#fff',
}
: {
backgroundColor: '#C3BFD8',
color: '#000',
}
}
>
<small
className="nowrap bold badge"
style={{
backgroundColor: 'transparent',
color: '#fff',
}}
>
{grade.name}
</small>
</span>
</label>
</Col>
);
})}
</Col>
</Row>
<div className="d-flex embla__nav">
<div
className="embla__btn embla__prev"
onClick={scrollPrev}
>
{candidate.id + 1}
</div>
<div
className="embla__btn embla__next"
onClick={scrollNext}
>
Next
</div>
</div>
</div>
);
})}
</div>
<div className="embla__dots">
{scrollSnaps.map((_, index) => (
<DotButton
key={index}
selected={index === selectedIndex}
onClick={() => scrollTo(index)}
value={index + 1}
/>
))}
</div>
</div>
</form>
</div>
<Row className="btn-background mx-0">
<Col className="text-center">
{judgments.length !== election.candidates.length ? (
<VoteButtonWithConfirm action={handleSubmitWithoutAllRate} />
) : (
<Button type="submit" className="my-3 btn btn-transparent">
<FontAwesomeIcon icon={faCheck} />
{t('Submit my vote')}
</Button>
)}
</Col>
</Row>
</Container >
{election.candidates.map((candidate, candidateId) => {
return (
<div key={candidateId} className="bg-white justify-content-between d-flex my-4 py-2 px-3">
<CandidateCard candidate={candidate} />
<div className="cardVoteGrades">
{election.grades.map((_, gradeId) => {
console.assert(gradeId < numGrades);
return (
<GradeInput key={gradeId} gradeId={gradeId} candidateId={candidateId} />
);
})}
</div>
</div>
);
})}
</form >
</div >
<Container className="my-5 d-md-flex d-grid justify-content-md-center">
<Button
outline={true}
color="secondary"
className="bg-blue"
onClick={handleSubmit}
disabled={disabled}
icon={faCheck}
position="left"
>
{t('vote.submit')}
</Button>
</Container>
</>
);
};
export default VoteBallot;
const Ballot = (props) => {
return (<BallotProvider>
<VoteBallot {...props} />
</BallotProvider>)
}
export default Ballot;

@ -47,7 +47,7 @@
"common.the-params": "The parameters",
"common.welcome": "Welcome!",
"error.at-least-2-candidates": "At least two candidates are required.",
"error.catch22": "Erreur inconnue...",
"error.catch22": "Unknown error...",
"error.help": "Ask for our help",
"error.no-title": "Please add a title to your election.",
"grades.very-good": "Very good",
@ -107,5 +107,8 @@
"admin.success-copy-admin": "Copy the admin link",
"admin.go-to-admin": "Manage the vote",
"vote.home-desc": "Participate in the vote and discover majority judgment",
"vote.home-start": "I participate"
"vote.home-start": "I participate",
"vote.open-until": "Vote open until",
"vote.more-details": "More details...",
"vote.submit": "Cast my ballot"
}

@ -107,5 +107,8 @@
"admin.success-copy-admin": "Copier le lien d'administration",
"admin.go-to-admin": "Administrez le vote",
"vote.home-desc": "Participez au vote et découvrez le jugement majoritaire.",
"vote.home-start": "Je participe"
"vote.home-start": "Je participe",
"vote.open-until": "Vote ouvert jusqu'au",
"vote.more-details": "Cliquez ici pour en savoir plus",
"vote.submit": "Déposer mon bulletin de vote"
}

@ -0,0 +1,97 @@
/**
* This file provides a context and a reducer to manage a ballot
*/
import {createContext, useContext, useReducer, Dispatch} from 'react';
import {ElectionPayload} from './api';
export interface Vote {
candidateId: number;
gradeId: number;
}
export interface BallotContextInterface {
election: ElectionPayload | null;
votes: Array<Vote>;
}
const defaultBallot: BallotContextInterface = {
election: null,
votes: []
};
export enum BallotTypes {
ELECTION = 'ELECTION',
VOTE = 'VOTE',
}
export type ElectionAction = {
type: BallotTypes.ELECTION;
election: ElectionPayload;
}
export type VoteAction = {
type: BallotTypes.VOTE;
candidateId: number;
gradeId: number;
}
export type BallotActionTypes = ElectionAction | VoteAction;
function reducer(ballot: BallotContextInterface, action: BallotActionTypes) {
/**
* Manage all types of action doable on an election
*/
switch (action.type) {
case BallotTypes.ELECTION: {
return {...ballot, election: action.election}
}
case BallotTypes.VOTE: {
const votes = [...ballot.votes];
const voteForCandidate = votes.filter(v => v.candidateId === action.candidateId);
if (voteForCandidate.length > 1) {
throw Error("Inconsistent ballot")
}
if (voteForCandidate.length === 1) {
voteForCandidate[0].candidateId = action.candidateId;
voteForCandidate[0].gradeId = action.gradeId;
} else {
votes.push({
candidateId: action.candidateId,
gradeId: action.gradeId,
})
}
return {...ballot, votes};
}
default: {
return ballot
}
}
}
type DispatchType = Dispatch<BallotActionTypes>;
const BallotContext = createContext<[BallotContextInterface, DispatchType]>([defaultBallot, () => {}]);
// const BallotContext = createContext([defaultBallot, () => {}]);
export function BallotProvider({children}) {
/**
* Provide the election and the dispatch to all children components
*/
const [ballot, dispatch] = useReducer(reducer, defaultBallot);
return (
<BallotContext.Provider value={[ballot, dispatch]}>
{children}
</BallotContext.Provider>
);
}
export function useBallot() {
/**
* A simple hook to manage ballots
*/
return useContext(BallotContext);
}

@ -144,7 +144,7 @@ function electionReducer(election: ElectionContextInterface, action) {
}
case 'grade-set': {
if (typeof action.position !== 'number') {
throw Error(`Unexpected grade position ${action.value}`);
throw Error(`Unexpected grade position ${action.position}`);
}
const grades = [...election.grades];
const grade = grades[action.position];

@ -107,9 +107,7 @@ export const getResults = (
export const getElection = async (
pid: string,
successCallback = null,
failureCallback = null
): Promise<ElectionPayload> => {
): Promise<ElectionPayload | string> => {
/**
* Fetch data from external API
*/
@ -121,14 +119,13 @@ export const getElection = async (
const res = await fetch(detailsEndpoint.href);
if (!res.ok) {
return failureCallback(res.text())
return res.text()
}
const payload: ElectionPayload = await res.json();
if (successCallback) successCallback(payload);
return payload;
} catch (error) {
return failureCallback && failureCallback(error);
return error;
}
};

@ -6,6 +6,7 @@ import {getWindowUrl} from './utils';
export const CREATE_ELECTION = '/admin/new/';
export const BALLOT = '/ballot/';
export const ENDED_VOTE = '/ballot/end';
export const VOTE = '/vote/';
export const RESULTS = '/result/';

@ -33,6 +33,23 @@ $theme-colors: (
// "very-good": #A0CF1C,
// "excellent": #3A9918,
// );
$font-size-base: 1rem;
$h1-font-size: $font-size-base * 2.5;
$h2-font-size: $font-size-base * 2;
$h3-font-size: $font-size-base * 1.75;
$h4-font-size: $font-size-base * 1.5;
$h5-font-size: $font-size-base * 1.25;
$font-sizes: (
1: $h1-font-size,
2: $h2-font-size,
3: $h3-font-size,
4: $h4-font-size,
5: $font-size-base,
6: $font-size-base * 0.75
);
@import "_bootstrap.scss";

Loading…
Cancel
Save