fix: result election

pull/89/head
Pierre-Louis Guhur 1 year ago
parent fb57aa8c94
commit 5ebf1b838d

@ -8,7 +8,7 @@ import ButtonCopy from '@components/ButtonCopy';
import Share from '@components/Share';
import ErrorMessage from '@components/Error';
import AdminModalEmail from '@components/admin/AdminModalEmail';
import {ElectionPayload, ErrorPayload} from '@services/api';
import {ElectionCreatedPayload, ErrorPayload} from '@services/api';
import {useAppContext} from '@services/context';
import {getUrlVote, getUrlResults} from '@services/routes';
import urne from '../public/urne.svg'
@ -17,7 +17,7 @@ import {Container} from 'reactstrap';
export interface WaitingBallotInterface {
election?: ElectionPayload;
election?: ElectionCreatedPayload;
error?: ErrorPayload;
}

@ -20,7 +20,7 @@ import Grades from './Grades';
import Private from './Private';
import Order from './Order';
import {useElection, ElectionContextInterface} from '@services/ElectionContext';
import {createElection, ElectionPayload} from '@services/api';
import {createElection, ElectionCreatedPayload} from '@services/api';
import {getUrlVote, getUrlResults} from '@services/routes';
import {GradeItem, CandidateItem} from '@services/type';
import {sendInviteMails} from '@services/mail';
@ -87,14 +87,14 @@ const submitElection = (
election.forceClose,
election.restricted,
election.randomOrder,
async (payload: ElectionPayload) => {
async (payload: ElectionCreatedPayload) => {
const tokens = payload.invites;
if (typeof election.emails !== 'undefined' && election.emails.length > 0) {
if (typeof payload.invites === 'undefined' || payload.invites.length !== election.emails.length) {
throw Error('Can not send invite emails');
}
const urlVotes = payload.invites.map((token: string) => getUrlVote(payload.ref, token));
const urlResult = getUrlResults(electionRef);
const urlResult = getUrlResults(payload.ref);
await sendInviteMails(
election.emails,
election.name,

@ -11,7 +11,7 @@ import {
import {ProgressSteps, creationSteps} from '@components/CreationSteps';
import Blur from '@components/Blur'
import {GetStaticProps} from 'next';
import {ElectionPayload, ErrorPayload} from '@services/api';
import {ElectionCreatedPayload, ErrorPayload} from '@services/api';
export const getStaticProps: GetStaticProps = async ({locale}) => ({
@ -27,7 +27,7 @@ const CreateElectionForm = () => {
* Manage the steps for creating an election
*/
const [wait, setWait] = useState(false);
const [payload, setPayload] = useState<ElectionPayload | null>(null);
const [payload, setPayload] = useState<ElectionCreatedPayload | null>(null);
const [error, setError] = useState<ErrorPayload | null>(null);
const handleSubmit = () => {

@ -21,9 +21,10 @@ export async function getServerSideProps({query: {pid, tid}, locale}) {
if (!pid) {
return {notFound: true}
}
const electionRef = pid.replace("-", "");
const [election, translations] = await Promise.all([
getElection(pid),
getElection(electionRef),
serverSideTranslations(locale, ['resource']),
]);
@ -54,7 +55,6 @@ export async function getServerSideProps({query: {pid, tid}, locale}) {
props: {
...translations,
election,
pid: pid,
token: tid || null,
},
};

@ -1,5 +1,6 @@
import {useState} from 'react';
import Head from 'next/head';
import Image from 'next/image';
import {useTranslation} from 'next-i18next';
import {serverSideTranslations} from 'next-i18next/serverSideTranslations';
import {useRouter} from 'next/router';
@ -15,167 +16,318 @@ import {
Table,
Button,
} from 'reactstrap';
import {getResults, getElection, apiErrors, ResultsPayload} from '@services/api';
import ErrorMessage from '@components/Error';
import config from '../../../next-i18next.config.js';
import Footer from '@components/layouts/Footer';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faChevronDown,
faChevronRight,
faChevronUp,
faGear,
} from '@fortawesome/free-solid-svg-icons';
import ErrorMessage from '@components/Error';
import Logo from '@components/Logo';
import {getResults, getElection, apiErrors, ResultsPayload, CandidatePayload, GradePayload} from '@services/api';
import {getUrlAdmin} from '@services/routes';
import {getMajorityGrade} from '@services/majorityJudgment';
import avatarBlue from '../../../public/avatarBlue.svg'
import calendar from '../../../public/calendar.svg'
import arrowUpload from '../../../public/arrowUpload.svg'
import arrowLink from '../../../public/arrowL.svg'
import {getGradeColor} from '@services/grades';
interface GradeInterface extends GradePayload {
color: string;
}
interface CandidateInterface extends CandidatePayload {
majorityGrade: GradeInterface;
rank: number;
}
interface ElectionInterface {
name: string;
description: string;
ref: string;
dateStart: string;
dateEnd: string;
hideResults: boolean;
forceClose: boolean;
restricted: boolean;
grades: Array<GradeInterface>;
candidates: Array<CandidateInterface>;
}
interface ResultInterface extends ElectionInterface {
ranking: {[key: string]: number};
meritProfile: {[key: number]: Array<number>};
}
export async function getServerSideProps({query, locale}) {
const {pid, tid} = query;
const {pid, tid: token} = query;
const electionRef = pid.replace("-", "");
const [res, details, translations] = await Promise.all([
getResults(pid),
getElection(pid),
serverSideTranslations(locale, [], config),
const [payload, translations] = await Promise.all([
getResults(electionRef),
serverSideTranslations(locale, ["resource"]),
]);
if (typeof res === 'string' || res instanceof String) {
return {props: {err: res.slice(1, -1), ...translations}};
if (typeof payload === 'string' || payload instanceof String) {
return {props: {err: payload, ...translations}};
}
if (typeof details === 'string' || details instanceof String) {
return {props: {err: res.slice(1, -1), ...translations}};
}
const numGrades = payload.grades.length;
const grades = payload.grades.map((g, i) => ({...g, color: getGradeColor(i, numGrades)}));
const gradesByValue: {[key: number]: GradeInterface} = {}
grades.forEach(g => gradesByValue[g.value] = g)
const result: ResultInterface = {
name: payload.name,
description: payload.description,
ref: payload.ref,
dateStart: payload.date_start,
dateEnd: payload.date_end,
hideResults: payload.hide_results,
forceClose: payload.force_close,
restricted: payload.restricted,
grades: grades,
candidates: payload.candidates.map(c => ({
...c,
rank: payload.ranking[c.id],
majorityGrade: gradesByValue[getMajorityGrade(payload.merit_profile[c.id])]
})),
ranking: payload.ranking,
meritProfile: payload.merit_profile,
if (!details.candidates || !Array.isArray(details.candidates)) {
return {props: {err: 'Unknown error', ...translations}};
}
return {
props: {
title: details.name,
numGrades: details.grades.length,
finish: details.date_end,
candidates: res,
pid: pid,
result,
token: token || "",
...translations,
},
};
}
interface ResultsInterface {
results: ResultsPayload;
err: string;
const getNumVotes = (result: ResultInterface) => {
const sum = (seq: Array<number>) =>
Object.values(seq).reduce((a, b) => a + b, 0);
const anyCandidateId = result.candidates[0].id;
const numVotes = sum(result.meritProfile[anyCandidateId]);
Object.values(result.meritProfile).forEach(v => {
if (sum(v) !== numVotes) {
throw Error("The election does not contain the same number of votes for each candidate")
}
})
return numVotes;
}
const Results = ({results, err}: ResultsInterface) => {
interface ResultBanner {
result: ResultsPayload;
}
const ResultBanner = ({result}) => {
const {t} = useTranslation();
const newstart = new Date(results.date_end).toLocaleDateString();
const dateEnd = new Date(result.date_end);
const now = new Date();
const closedSince = +dateEnd - (+now);
if (err && err !== '') {
return <ErrorMessage msg={t(apiErrors(err))} />;
const numVotes = getNumVotes(result)
return (<div className="w-100 bg-white p-5 justify-content-between align-items-center">
<div className="text-muted">
<div className="d-flex align-items-center">
<Image alt="Calendar" src={calendar} />
<p>{closedSince > 0 ? `${t('result.has-closed')} {closedSince}` : `${t('result.will-close')} {closedSince}`} {t('common.days')}</p>
</div>
<div className="d-flex align-items-center">
<Image src={avatarBlue} alt="Avatar" />
<p>{`${numVotes} ${t('common.participants')}`}</p>
</div>
</div>
<h3>{result.name}</h3>
<div className="text-muted">
<div className="d-flex align-items-center">
<Image alt="Download" src={arrowUpload} />
<p>{t('result.download')}</p>
</div>
<div className="d-flex align-items-center">
<Image src={arrowLink} alt="Share" />
<p>{t('result.share')}</p>
</div>
</div>
</div >
)
}
const BottomButtonsMobile = () => {
const {t} = useTranslation();
return (
<div className="d-block d-md-none mt-5">
<Button className="cursorPointer btn-result btn-validation mb-5 btn btn-secondary">
<Image alt="Download" src={arrowUpload} />
<p>{t('result.download')}</p>
</Button>
<Button className="cursorPointer btn-result btn-validation mb-5 btn btn-secondary">
<Image src={arrowLink} alt="Share" />
<p>{t('result.share')}</p>
</Button>
</div>
)
}
interface TitleBannerInterface {
name: string;
electionRef: string;
token?: string;
}
const TitleBanner = ({name, electionRef, token}: TitleBannerInterface) => {
const {t} = useTranslation();
return (
<div className="d-none d-md-flex p-3 justify-content-between text-white">
<div className="d-flex">
<Logo title={false} />
<h5>{name}</h5>
</div>
{token ?
<div className="d-flex">
<Link href={getUrlAdmin(electionRef, token)}>
<Button icon={faGear} position="left">{t('result.go-to-admin')}</Button>
</Link>
</div> : null
}
</div>
)
}
interface ButtonGradeInterface {
grade: GradeInterface;
}
const ButtonGrade = ({grade}: ButtonGradeInterface) => {
const style = {
color: 'white',
backgroundColor: grade.color,
};
return (
<div
style={style}
className="py-2 px-3 m-1 fw-bold rounded-1 d-flex justify-content-between gap-3"
>
{grade.name}
</div>
);
};
interface CandidateRankedInterface {
candidate: CandidateInterface;
}
const CandidateRanked = ({candidate}: CandidateRankedInterface) => {
const isFirst = candidate.rank == 1;
return <div>
<div className={isFirst ? "text-primary bg-white fs-5" : "text-white bg-secondary fs-6"}>
{candidate.rank}
</div>
<div className={`text-white ${isFirst ? "fs-4" : "fs-6"}`}>
{candidate.name}
</div>
<ButtonGrade grade={candidate.majorityGrade} />
</div>
}
interface PodiumInterface {
candidates: Array<CandidateInterface>;
}
const Podium = ({candidates}: PodiumInterface) => {
const {t} = useTranslation();
// get best candidates
const numBest = Math.min(3, candidates.length);
const candidatesByRank = {}
candidates.forEach(c => candidatesByRank[c.rank] = c)
if (numBest < 2) {
throw Error("Can not load enough candidates");
}
if (numBest === 2) {
return (<div>
<CandidateRanked candidate={candidates[0]} />
<CandidateRanked candidate={candidates[1]} />
</div>)
}
return (<div>
<CandidateRanked candidate={candidates[1]} />
<CandidateRanked candidate={candidates[0]} />
<CandidateRanked candidate={candidates[2]} />
</div>)
}
interface ResultPageInterface {
result?: ResultInterface;
token?: string;
err?: string;
}
const ResultPage = ({result, token, err}: ResultPageInterface) => {
const {t} = useTranslation();
const router = useRouter();
const colSizeCandidateLg = 4;
const colSizeCandidateMd = 6;
const colSizeCandidateXs = 12;
const colSizeGradeLg = 1;
const colSizeGradeMd = 1;
const colSizeGradeXs = 1;
if (err && err !== '') {
return <ErrorMessage msg={err} />;
}
if (!result) {
return <ErrorMessage msg="error.catch22" />;
}
const origin =
typeof window !== 'undefined' && window.location.origin
? window.location.origin
: 'http://localhost';
const urlVote = new URL(`/vote/${results.id}`, origin);
if (typeof results.candidates === "undefined" || results.candidates.length === 0) {
if (typeof result.candidates === "undefined" || result.candidates.length === 0) {
throw Error("No candidates were loaded in this election")
}
// const collapsee = results.candidates[0].name;
// const collapsee = result.candidates[0].name;
// const [collapseProfiles, setCollapseProfiles] = useState(false);
const [collapseGraphics, setCollapseGraphics] = useState(false);
// const [collapseGraphics, setCollapseGraphics] = useState(false);
const sum = (seq: Array<number>) =>
Object.values(seq).reduce((a, b) => a + b, 0);
const anyCandidateName = results.candidates[0].name;
const numVotes = sum(results.votes[anyCandidateName]);
// check each vote contains the same number of votes
// TODO move it to a more appropriate location
Object.values(results.votes).forEach(v => {
if (sum(v) !== numVotes) {
throw Error("The election does not contain the same numberof votes for each candidate")
}
})
const gradeIds = results.grades.map(g => g.value);
const numVotes = getNumVotes(result)
return (
<Container className="resultContainer resultPage">
<Head>
<title>{results.name}</title>
<title>{result.name}</title>
<link rel="icon" href="/favicon.ico" />
<meta property="og:title" content={results.name} />
<meta property="og:title" content={result.name} />
</Head>
<Row className="sectionHeaderResult componentDesktop mx-0">
<Col className="col-md-3 sectionHeaderResultLeftCol">
<Row>
<Col className="sectionHeaderResultSideCol">
<img src="/calendar.svg" />
<p>{newstart}</p>
</Col>
</Row>
<Row>
<Col className="sectionHeaderResultSideCol">
<img src="/avatarBlue.svg" />
<p>{' ' + numVotes} votants</p>
</Col>
</Row>
</Col>
<Col className="sectionHeaderResultMiddleCol">
<h3>{results.name}</h3>
</Col>
<Col className="col-md-3 sectionHeaderResultRightCol">
<Row>
<Col className="sectionHeaderResultSideCol">
<img src="/arrowUpload.svg" />
<p>Télécharger les résultats</p>
</Col>
</Row>
<Row>
<Col className="sectionHeaderResultSideCol">
<img src="/arrowL.svg" />
<p>Partagez les résultats</p>
</Col>
</Row>
</Col>
</Row>
<Row className="sectionHeaderResult componentMobile mx-0">
<Col className="px-0">
<h3>{results.name}</h3>
</Col>
<Row>
<Col className="sectionHeaderResultSideCol">
<img src="/calendar.svg" />
<p>{newstart}</p>
</Col>
<Col className="sectionHeaderResultSideCol">
<img src="/avatarBlue.svg" />
<p>{' ' + numVotes} votants</p>
</Col>
</Row>
</Row>
<TitleBanner electionRef={result.ref} token={token} name={result.name} />
<ResultBanner result={result} />
<Podium candidates={result.candidates} />
<section className="sectionContentResult mb-5">
<Row className="mt-5 componentDesktop">
<Col>
<ol className="result px-0">
{ /* {results.candidates.map((candidate, i) => {
const gradeValue = candidate.grade + offsetGrade;
{result.candidates.map((candidate, i) => {
return (
<li key={i} className="mt-2">
<span className="resultPosition">{i + 1}</span>
@ -183,38 +335,34 @@ const Results = ({results, err}: ResultsInterface) => {
<span
className="badge badge-light"
style={{
backgroundColor: grades.slice(0).reverse()[
candidate.grade
].color,
backgroundColor: candidate.majorityGrade.color,
color: '#fff',
}}
>
{allGrades.slice(0).reverse()[gradeValue].label}
{candidate.majorityGrade.name}
</span>
</li>
);
})}
*/}
</ol>
</Col>
</Row>
<Row className="mt-5">
<Col>
{/* <h5>
<h5>
<small>{t('Détails des résultats')}</small>
</h5>
{candidates.map((candidate, i) => {
const gradeValue = candidate.grade + offsetGrade;
{result.candidates.map((candidate, i) => {
return (
<Card className="bg-light text-primary my-3">
<CardHeader
className="pointer"
onClick={() => setCollapseGraphics(!collapseGraphics)}
>
{/*onClick={() => setCollapseGraphics(!collapseGraphics)}*/}
<h4
className={'m-0 ' + (collapseGraphics ? 'collapsed' : '')}
>
{/* className={'m-0 ' + (collapseGraphics ? 'collapsed' : '')}*/}
<span
key={i}
className="d-flex panel-title justify-content-between"
@ -229,13 +377,11 @@ const Results = ({results, err}: ResultsInterface) => {
<span
className="badge badge-light"
style={{
backgroundColor: grades.slice(0).reverse()[
candidate.grade
].color,
backgroundColor: candidate.majorityGrade.color,
color: '#fff',
}}
>
{allGrades.slice(0).reverse()[gradeValue].label}
{candidate.majorityGrade.name}
</span>
<FontAwesomeIcon
icon={faChevronDown}
@ -249,7 +395,9 @@ const Results = ({results, err}: ResultsInterface) => {
</span>
</h4>
</CardHeader>
<Collapse isOpen={collapseGraphics}>
<Collapse
>
{/*isOpen={collapseGraphics}*/}
<CardBody className="pt-5">
<Row className="column">
<Col>
@ -264,7 +412,7 @@ const Results = ({results, err}: ResultsInterface) => {
<div key={i}>
<div style={{width: '100%'}}>
{gradeIds
{/* gradeIds
.slice(0)
.reverse()
.map((id, i) => {
@ -289,15 +437,12 @@ const Results = ({results, err}: ResultsInterface) => {
} else {
return null;
}
})}
})*/}
</div>
</div>
</div>
</div>
</Col>
<Col>
<p>Graph bulles</p>
</Col>
</Row>
<Row className="linkResult my-3">
<Link href="/" className="mx-auto">
@ -313,27 +458,12 @@ const Results = ({results, err}: ResultsInterface) => {
</Card>
);
})}
*/}
</Col>
</Row>
<div className="componentMobile mt-5">
<Row>
<Button className="cursorPointer btn-result btn-validation mb-5 btn btn-secondary">
<img src="/arrowUpload.svg" />
<p>Télécharger les résultats</p>
</Button>
</Row>
<Row>
<Button className="cursorPointer btn-result btn-validation mb-5 btn btn-secondary">
<img src="/arrowL.svg" />
<p>Partagez les résultats</p>
</Button>
</Row>
</div>
<BottomButtonsMobile />
</section>
<Footer />
</Container>
);
};
export default Results;
export default ResultPage;

@ -21,7 +21,7 @@ export async function getServerSideProps({query: {pid, tid}, locale}) {
return {
props: {
...(await serverSideTranslations(locale, ['resource'])),
electionRef: pid,
electionRef: pid.replace("-", ""),
token: tid || null,
},
}

@ -38,6 +38,7 @@
"common.support-us": "Support us",
"common.thumbnail": "Thumbnail",
"common.name": "Name",
"common.participants": "participants",
"common.description": "Description",
"common.cancel": "Cancel",
"common.grades": "Grades",
@ -108,6 +109,12 @@
"admin.success-copy-result": "Copy the result link",
"admin.success-copy-admin": "Copy the admin link",
"admin.go-to-admin": "Manage the vote",
"result.download": "Download results",
"result.go-to-admin": "Manage the election",
"result.has-closed": "Closed since",
"result.result": "Results of the election",
"result.will-close": "Will close in",
"result.share": "Share results",
"vote.discover-mj": "Discover majority judgment",
"vote.discover-mj-desc": "Developed by French researchers, majority judgment is a voting system that improves voter expressiveness and provides the best consensus.",
"vote.form": "Your opinion is important to us",

@ -38,6 +38,7 @@
"common.save": "Sauvegarder",
"common.thumbnail": "Image miniature",
"common.name": "Nom",
"common.participants": "participants",
"common.description": "Description",
"common.cancel": "Annuler",
"common.grades": "Mentions",
@ -108,6 +109,12 @@
"admin.success-copy-result": "Copier le lien des résultats",
"admin.success-copy-admin": "Copier le lien d'administration",
"admin.go-to-admin": "Administrez le vote",
"result.download": "Télécharger les résultats",
"result.go-to-admin": "Administrer le vote",
"result.has-closed": "Closed since",
"result.result": "Résultat du vote",
"result.share": "Partager les résultats",
"result.will-close": "Will close in",
"vote.discover-mj": "Découvrez le jugement majoritaire",
"vote.discover-mj-desc": "Créé par des chercheurs français, le jugement majoritaire est un mode de scrutin qui améliore l'expressivité des électeurs et fournit le meilleur consensus.",
"vote.go-to-results": "Voir les résultats",

@ -79,11 +79,11 @@ export const createElection = async (
};
export const getResults = (
export const getResults = async (
pid: string,
successCallback = null,
failureCallback = null
) => {
): Promise<ResultsPayload | string> => {
/**
* Fetch results from external API
*/
@ -93,15 +93,15 @@ export const getResults = (
api.urlServer
);
return fetch(endpoint.href)
.then((response) => {
if (!response.ok) {
return Promise.reject(response.text());
}
return response.json();
})
.then(successCallback || ((res) => res))
.catch(failureCallback || ((err) => err));
try {
const response = await fetch(endpoint.href)
if (response.status != 200) {
return response.text();
}
return response.json();
} catch (error) {
return new Promise(() => "API errors")
}
};
@ -234,24 +234,25 @@ export interface ElectionPayload {
name: string;
description: string;
ref: string;
date_create: string;
date_modified: string;
num_voters: number;
date_start: string;
date_end: string;
hide_results: boolean;
force_close: boolean;
restricted: boolean;
id: number;
grades: Array<GradePayload>;
candidates: Array<CandidatePayload>;
}
export interface ElectionCreatedPayload extends ElectionPayload {
invites: Array<string>;
admin: string;
num_voters: number;
}
export interface ResultsPayload extends ElectionPayload {
ranking: {[key: string]: number};
votes: {[key: string]: Array<number>};
merit_profile: {[key: number]: Array<number>};
}

@ -0,0 +1,30 @@
/**
* A few useful function for dealing with majority judgment
*/
/**
* Return the index corresponding to the majority grade
*/
export const getMajorityGrade = (votes: Array<number>): number => {
const indices = votes.map((_, i) => i);
const numVotes = votes.reduce((a, b) => a + b, 0)
let majorityGrade = indices[0]
let accBefore = 0
let isBefore = true
for (const gradeId in votes) {
if (isBefore) {
accBefore += votes[gradeId]
}
if (isBefore && accBefore > numVotes / 2) {
majorityGrade = indices[gradeId]
accBefore -= votes[gradeId]
isBefore = false
}
}
return majorityGrade;
}

@ -10,20 +10,24 @@ export const ENDED_VOTE = '/ballot/end';
export const VOTE = '/vote/';
export const RESULTS = '/result/';
export const getUrlVote = (electionRef: string | number, token?: string): URL => {
export const getUrlVote = (electionRef: string | number, token?: string): URL => {
const origin = getWindowUrl();
const ref = typeof electionRef === "string" ? electionRef : electionRef.toString();
if (token)
return new URL(`/${VOTE}/${displayRef(electionRef)}/${token}`, origin);
return new URL(`/${VOTE}/${displayRef(electionRef)}`, origin);
return new URL(`/${VOTE}/${displayRef(ref)}/${token}`, origin);
return new URL(`/${VOTE}/${displayRef(ref)}`, origin);
}
export const getUrlResults = (electionRef: string | number): URL => {
const origin = getWindowUrl();
return new URL(`/${RESULTS}/${displayRef(electionRef)}`, origin);
const ref = typeof electionRef === "string" ? electionRef : electionRef.toString();
return new URL(`/${RESULTS}/${displayRef(ref)}`, origin);
}
export const getUrlAdmin = (electionRef: string | number, adminToken: string): URL => {
const origin = getWindowUrl();
return new URL(`/admin/${displayRef(electionRef)}/${adminToken}`, origin);
const ref = typeof electionRef === "string" ? electionRef : electionRef.toString();
return new URL(`/admin/${displayRef(ref)}/${adminToken}`, origin);
}

@ -31,3 +31,4 @@ export const displayRef = (ref: string): string => {
}
return `${ref.substring(0, 3)}-${ref.substring(3, 6)}-${ref.substring(6)}`
}

Loading…
Cancel
Save