From 5ebf1b838dccd54b73486c43a89e30f4e6eec655 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Guhur Date: Thu, 1 Dec 2022 11:59:16 +0100 Subject: [PATCH] fix: result election --- components/WaitingElection.tsx | 4 +- components/admin/ConfirmField.tsx | 6 +- pages/admin/new.tsx | 4 +- pages/ballot/[pid]/[[...tid]].tsx | 4 +- pages/result/[pid]/[[...tid]].tsx | 424 +++++++++++++++++++----------- pages/vote/[pid]/[[...tid]].tsx | 2 +- public/locales/en/resource.json | 7 + public/locales/fr/resource.json | 7 + services/api.ts | 33 +-- services/majorityJudgment.ts | 30 +++ services/routes.ts | 14 +- services/utils.ts | 1 + 12 files changed, 358 insertions(+), 178 deletions(-) create mode 100644 services/majorityJudgment.ts diff --git a/components/WaitingElection.tsx b/components/WaitingElection.tsx index 61470e2..aa79641 100644 --- a/components/WaitingElection.tsx +++ b/components/WaitingElection.tsx @@ -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; } diff --git a/components/admin/ConfirmField.tsx b/components/admin/ConfirmField.tsx index 26582a5..ef50360 100644 --- a/components/admin/ConfirmField.tsx +++ b/components/admin/ConfirmField.tsx @@ -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, diff --git a/pages/admin/new.tsx b/pages/admin/new.tsx index 8a25433..1617e99 100644 --- a/pages/admin/new.tsx +++ b/pages/admin/new.tsx @@ -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(null); + const [payload, setPayload] = useState(null); const [error, setError] = useState(null); const handleSubmit = () => { diff --git a/pages/ballot/[pid]/[[...tid]].tsx b/pages/ballot/[pid]/[[...tid]].tsx index fc53f34..c47ebe0 100644 --- a/pages/ballot/[pid]/[[...tid]].tsx +++ b/pages/ballot/[pid]/[[...tid]].tsx @@ -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, }, }; diff --git a/pages/result/[pid]/[[...tid]].tsx b/pages/result/[pid]/[[...tid]].tsx index 94851d4..a51892e 100644 --- a/pages/result/[pid]/[[...tid]].tsx +++ b/pages/result/[pid]/[[...tid]].tsx @@ -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; + candidates: Array; +} + + +interface ResultInterface extends ElectionInterface { + ranking: {[key: string]: number}; + meritProfile: {[key: number]: Array}; +} 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) => + 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 ; + const numVotes = getNumVotes(result) + + return (
+
+
+ Calendar +

{closedSince > 0 ? `${t('result.has-closed')} {closedSince}` : `${t('result.will-close')} {closedSince}`} {t('common.days')}

+
+
+ Avatar +

{`${numVotes} ${t('common.participants')}`}

+
+
+ +

{result.name}

+ +
+
+ Download +

{t('result.download')}

+
+
+ Share +

{t('result.share')}

+
+
+
+ ) +} + + +const BottomButtonsMobile = () => { + const {t} = useTranslation(); + return ( +
+ + +
+ ) +} + + +interface TitleBannerInterface { + name: string; + electionRef: string; + token?: string; +} + +const TitleBanner = ({name, electionRef, token}: TitleBannerInterface) => { + const {t} = useTranslation(); + return ( +
+
+ +
{name}
+
+ {token ? +
+ + + +
: null + } + +
+ ) +} + + +interface ButtonGradeInterface { + grade: GradeInterface; +} + +const ButtonGrade = ({grade}: ButtonGradeInterface) => { + + const style = { + color: 'white', + backgroundColor: grade.color, + }; + + return ( +
+ {grade.name} +
+ ); +}; + +interface CandidateRankedInterface { + candidate: CandidateInterface; +} + +const CandidateRanked = ({candidate}: CandidateRankedInterface) => { + const isFirst = candidate.rank == 1; + return
+
+ {candidate.rank} +
+
+ {candidate.name} +
+ +
+} + + +interface PodiumInterface { + candidates: Array; +} + + +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 (
+ + +
) + } + + + return (
+ + + +
) +} + +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 ; + } + + if (!result) { + return ; + } - 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) => - 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 ( - {results.name} + {result.name} - + - - - - - -

{newstart}

- -
- - - -

{' ' + numVotes} votants

- -
- - - -

{results.name}

- - - - - - -

Télécharger les résultats

- -
- - - -

Partagez les résultats

- -
- -
- - - -

{results.name}

- - - - -

{newstart}

- - - -

{' ' + numVotes} votants

- -
-
+ + + +
    - { /* {results.candidates.map((candidate, i) => { - const gradeValue = candidate.grade + offsetGrade; + {result.candidates.map((candidate, i) => { return (
  1. {i + 1} @@ -183,38 +335,34 @@ const Results = ({results, err}: ResultsInterface) => { - {allGrades.slice(0).reverse()[gradeValue].label} + {candidate.majorityGrade.name}
  2. ); })} - */}
- {/*
+
{t('Détails des résultats')}
- {candidates.map((candidate, i) => { - const gradeValue = candidate.grade + offsetGrade; + {result.candidates.map((candidate, i) => { return ( setCollapseGraphics(!collapseGraphics)} > + {/*onClick={() => setCollapseGraphics(!collapseGraphics)}*/}

+ {/* className={'m-0 ' + (collapseGraphics ? 'collapsed' : '')}*/} { - {allGrades.slice(0).reverse()[gradeValue].label} + {candidate.majorityGrade.name} {

- + + {/*isOpen={collapseGraphics}*/} @@ -264,7 +412,7 @@ const Results = ({results, err}: ResultsInterface) => {
- {gradeIds + {/* gradeIds .slice(0) .reverse() .map((id, i) => { @@ -289,15 +437,12 @@ const Results = ({results, err}: ResultsInterface) => { } else { return null; } - })} + })*/}
- -

Graph bulles

-
@@ -313,27 +458,12 @@ const Results = ({results, err}: ResultsInterface) => {
); })} - */}
-
- - - - - - -
+
-