Skip to content

Commit

Permalink
Merge pull request #681 from csamuele/663-result-page-layout-download
Browse files Browse the repository at this point in the history
663 result page layout download
  • Loading branch information
ArendPeter authored Sep 25, 2024
2 parents 31ef757 + e1d3d96 commit 8031e94
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 41 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import ServiceLocator from "../ServiceLocator";
import Logger from "../Services/Logging/Logger";
import { BadRequest } from "@curveball/http-errors";
import { expectPermission } from "./controllerUtils";
import { permissions } from '@equal-vote/star-vote-shared/domain_model/permissions';
import { IElectionRequest } from "../IRequest";
import { Response, NextFunction } from 'express';
import { AnonymizedBallot } from "@equal-vote/star-vote-shared/domain_model/Ballot";


const BallotModel = ServiceLocator.ballotsDb();

const getAnonymizedBallotsByElectionID = async (req: IElectionRequest, res: Response, next: NextFunction) => {
var electionId = req.election.election_id;
Logger.debug(req, "getBallotsByElectionID: " + electionId);
const election = req.election;
if (!election.settings.public_results) {
expectPermission(req.user_auth.roles, permissions.canViewBallots)
}

const ballots = await BallotModel.getBallotsByElectionID(String(electionId), req);
if (!ballots) {
const msg = `Ballots not found for Election ${electionId}`;
Logger.info(req, msg);
throw new BadRequest(msg)
}
const anonymizedBallots: AnonymizedBallot[] = ballots.map((ballot) => {
return {
ballot_id: ballot.ballot_id,
votes: ballot.votes
}
});
Logger.debug(req, "ballots = ", ballots);
res.json({ ballots: anonymizedBallots })
}

module.exports = {
getAnonymizedBallotsByElectionID
}
2 changes: 2 additions & 0 deletions packages/backend/src/Routes/elections.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { finalizeElection } = require('../Controllers/finalizeElectionController'
const { setPublicResults } = require('../Controllers/setPublicResultsController')
const { getElectionResults } = require('../Controllers/getElectionResultsController')
const { getBallotsByElectionID } = require('../Controllers/getBallotsByElectionIDController')
const { getAnonymizedBallotsByElectionID } = require('../Controllers/getAnonymizedBallotsByElectionIDController')
const { deleteAllBallotsForElectionID } = require('../Controllers/deleteAllBallotsForElectionIDController')
const { getBallotByBallotID } = require('../Controllers/getBallotByBallotID')
const { editElection } = require('../Controllers/editElectionController')
Expand All @@ -35,6 +36,7 @@ router.delete('/Election/:id', asyncHandler(deleteElection))
router.post('/Election/:id/ballot', asyncHandler(electionController.returnElection))
router.post('/Election/:id/register',asyncHandler(registerVoter))
router.get('/Election/:id/ballots', asyncHandler(getBallotsByElectionID))
router.get('/Election/:id/anonymizedBallots', asyncHandler(getAnonymizedBallotsByElectionID))
router.delete('/Election/:id/ballots', asyncHandler(deleteAllBallotsForElectionID))
router.get('/Election/:id/ballot/:ballot_id', asyncHandler(getBallotByBallotID))
router.get('/Election/:id/rolls', asyncHandler(getRollsByElectionID))
Expand Down
37 changes: 2 additions & 35 deletions packages/frontend/src/components/Election/Admin/ViewBallots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import Container from '@mui/material/Container';
import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from "@mui/material";
import PermissionHandler from "../../PermissionHandler";
import ViewBallot from "./ViewBallot";
import { CSVLink } from "react-csv";
import { useGetBallots } from "../../../hooks/useAPI";
import { epochToDateString, getLocalTimeZoneShort, useSubstitutedTranslation } from "../../util";
import useElection from "../../ElectionContextProvider";
import useFeatureFlags from "../../FeatureFlagContextProvider";
import DraftWarning from "../DraftWarning";
import { DownloadCSV } from "../Results/DownloadCSV";

const ViewBallots = () => {
const { election } = useElection()
Expand All @@ -21,8 +21,6 @@ const ViewBallots = () => {
const [isViewing, setIsViewing] = useState(false)
const [addRollPage, setAddRollPage] = useState(false)
const [selectedBallot, setSelectedBallot] = useState(null)
const [csvData, setcsvData] = useState([])
const [csvHeaders, setcsvHeaders] = useState([])
const navigate = useNavigate();
const location = useLocation();

Expand All @@ -49,26 +47,6 @@ const ViewBallots = () => {



const buildCsvData = () => {
let header = [
{ label: 'ballot_id', key: 'ballot_id' },
...data.election.races[0].candidates.map((c) => ({ label: c.candidate_name, key: c.candidate_id }))
]
let tempCsvData = data.ballots.map(ballot => {
let row = {ballot_id: ballot.ballot_id}
ballot.votes[0].scores.forEach(score => {
row[score.candidate_id] = score.score
});
return row
})
setcsvHeaders(header)
setcsvData(tempCsvData)
return false
}
const limit = (string = '', limit = 0) => {
if (!string) return ''
return string.substring(0, limit)
}
return (
<Container>
<DraftWarning />
Expand Down Expand Up @@ -130,18 +108,7 @@ const ViewBallots = () => {
</Table>
</TableContainer>

<CSVLink
data={csvData}
headers={csvHeaders}
target="_blank"
filename={ `Ballot Data - ${limit(data.election.title, 50)}.csv`}
enclosingCharacter={``}
onClick={() => {
buildCsvData()
}}
>
Download CSV
</CSVLink>
<DownloadCSV election={election}/>
</>
}
{isViewing && selectedBallot &&
Expand Down
68 changes: 68 additions & 0 deletions packages/frontend/src/components/Election/Results/DownloadCSV.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { StyledButton } from '~/components/styles';
import { CSVLink } from 'react-csv';
import { useState, useEffect } from 'react';
import { Election } from '@equal-vote/star-vote-shared/domain_model/Election';
import { AnonymizedBallot, Ballot } from '@equal-vote/star-vote-shared/domain_model/Ballot';
import { useGetAnonymizedBallots } from '~/hooks/useAPI';

interface Props {
election: Election;
}

export const DownloadCSV = ({ election }: Props) => {
const [csvData, setCsvData] = useState([]);
const [csvHeaders, setCsvHeaders] = useState([]);
const { data, error, isPending, makeRequest } = useGetAnonymizedBallots(election.election_id);
useEffect(() => { makeRequest() }, [])
const ballots: AnonymizedBallot[] = data?.ballots || [];
const buildCsvData = () => {

let header = [
{ label: 'ballot_id', key: 'ballot_id' },
...election.races.map((race) =>
race.candidates.map((c) => ({
label: `${race.title}!!${c.candidate_name}`,
key: `${race.race_id}-${c.candidate_id}`,
}))
),
];
header = header.flat();
let tempCsvData = ballots.map((ballot) => {
let row = { ballot_id: ballot.ballot_id };
ballot.votes.forEach((vote) =>
vote.scores.forEach((score) => {
row[`${vote.race_id}-${score.candidate_id}`] = score.score;
})
);
return row;
});
setCsvHeaders(header);
setCsvData(tempCsvData);
};

const limit = (string = '', limit = 0) => {
if (!string) return '';
return string.substring(0, limit);
};


return (
<>
{!isPending && (
<StyledButton type="button" variant="contained" fullwidth onClick={buildCsvData}>
<CSVLink
id="csv-download-link"
data={csvData}
headers={csvHeaders}
target="_blank"
filename={`Ballot Data - ${limit(election.title, 50)}-${election.election_id}.csv`}
enclosingCharacter={``}
style={{ textDecoration: 'none', color: 'inherit' }}
>
Download CSV
</CSVLink>
</StyledButton>
)}
</>
);
};
4 changes: 4 additions & 0 deletions packages/frontend/src/components/Election/Results/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ export default function Results({ title, raceIndex, race, result }: ResultsProps
const {t} = useSubstitutedTranslation(election?.settings?.term_type ?? 'poll');
return (
<div>
<hr/>
<Typography variant="h2" component="h2" sx={{marginBottom: 2}}>
{race.title}
</Typography>
<div className="flexContainer" style={{textAlign: 'center'}}>
<Box sx={{pageBreakAfter:'avoid', pageBreakInside:'avoid'}}>
{result.results.summaryData.nValidVotes == 0 && <h2>{t('results.waiting_for_results')}</h2>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import { useParams } from "react-router";
import Results from './Results';
import Box from '@mui/material/Box';
import { Paper, Typography } from "@mui/material";
import { DetailExpanderGroup, useSubstitutedTranslation } from '../../util';
import { useSubstitutedTranslation } from '../../util';
import { useGetResults } from '../../../hooks/useAPI';
import useElection from '../../ElectionContextProvider';
import DraftWarning from '../DraftWarning';
import { StyledButton } from '~/components/styles';
import ShareButton from '../ShareButton';
import { DownloadCSV } from './DownloadCSV';
import { useGetBallots } from '~/hooks/useAPI';

const ViewElectionResults = () => {

const { election } = useElection()

const { data, isPending, error, makeRequest: getResults } = useGetResults(election.election_id)
useEffect(() => { getResults() }, [])

const {t} = useSubstitutedTranslation(election.settings.term_type);

return (<>
<DraftWarning/>
<Box
Expand All @@ -34,7 +36,6 @@ const ViewElectionResults = () => {
</Typography>
{isPending && <div> {t('results.loading_election')} </div>}

<DetailExpanderGroup defaultSelectedIndex={-1} allowMultiple>
{data?.results.map((result, race_index) => (
<Results
key={`results-${race_index}`}
Expand All @@ -44,7 +45,31 @@ const ViewElectionResults = () => {
result={result}
/>
))}
</DetailExpanderGroup>
<hr/>
<Box sx={{width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', p: 2}}>
<Box sx={{ minWidth: 750, display: 'flex', justifyContent: 'space-between', flexDirection: { xs: 'column', sm: 'row' } }} >
{(election.settings.public_results === true) &&
<Box sx={{ width: '100%', p: 1, px:{xs: 5, sm: 1} }}>
<DownloadCSV election={election}/>
</Box>
}

{election.settings.voter_access !== 'closed' &&
<Box sx={{ width: '100%', p: 1, px:{xs: 5, sm: 1} }}>
<ShareButton url={`${window.location.origin}/${election.election_id}`}/>
</Box>
}
<Box sx={{ width: '100%', p: 1, px:{xs: 5, sm: 1} }}>
<StyledButton
type='button'
variant='contained'
fullwidth
href={'https://www.equal.vote/donate'} >
{t('ballot_submitted.donate')}
</StyledButton>
</Box>
</Box>
</Box>

</Paper>
</Box>
Expand Down
6 changes: 5 additions & 1 deletion packages/frontend/src/hooks/useAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/Election
import useFetch from "./useFetch";
import { VotingMethod } from "@equal-vote/star-vote-shared/domain_model/Race";
import { ElectionResults } from "@equal-vote/star-vote-shared/domain_model/ITabulators";
import { Ballot, NewBallot } from "@equal-vote/star-vote-shared/domain_model/Ballot";
import { Ballot, NewBallot, AnonymizedBallot } from "@equal-vote/star-vote-shared/domain_model/Ballot";

export const useGetElection = (electionID: string | undefined) => {
return useFetch<undefined, { election: Election, voterAuth: VoterAuth }>(`/API/Election/${electionID}`, 'get')
Expand Down Expand Up @@ -106,6 +106,10 @@ export const useGetBallots = (election_id: string | undefined) => {
return useFetch<undefined, { election: Election, ballots: Ballot[] }>(`/API/Election/${election_id}/ballots`, 'get')
}

export const useGetAnonymizedBallots = (election_id: string | undefined) => {
return useFetch<undefined, { ballots: AnonymizedBallot[] }>(`/API/Election/${election_id}/anonymizedBallots`, 'get')
}

export const useGetResults = (election_id: string | undefined) => {
return useFetch<undefined, {election: Election, results: ElectionResults[]}>(`/API/ElectionResult/${election_id}`, 'get')
}
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ election_settings:

ballot_updates: Allow Voters To Edit Vote !tip(ballot_updates)


public_results: Show Preliminary Results !tip(public_results)

random_ties: Enable Random Tie-Breakers !tip(random_ties)
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/domain_model/Ballot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export interface Ballot {
head: boolean;// Head version of this object
}

export interface AnonymizedBallot {
ballot_id: string; //ID of ballot
votes: Vote[]; // One per poll
}

export interface BallotAction {
action_type:string;
actor:Uid;
Expand Down

0 comments on commit 8031e94

Please sign in to comment.