diff --git a/domain_model/Election.ts b/domain_model/Election.ts index a929b5d1..340cf473 100644 --- a/domain_model/Election.ts +++ b/domain_model/Election.ts @@ -19,21 +19,21 @@ export interface Election { races: Race[]; // one or more race definitions settings: ElectionSettings; auth_key?: string; - } +} + - export function electionValidation(obj:Election): string | null { if (!obj){ return "Election is null"; } const election_id = obj.election_id; if (!election_id || typeof election_id !== 'string'){ - return "Invalid Election ID"; + return "Invalid Election ID"; } if (typeof obj.title !== 'string'){ - return "Invalid Title"; + return "Invalid Title"; } - if (obj.title.length < 3 || obj.title.length > 256){ + if (obj.state !== 'draft' && (obj.title.length < 3 || obj.title.length > 256)) { return "invalid Title length"; } //TODO... etc @@ -45,7 +45,7 @@ export function removeHiddenFields(obj:Election, electionRoll: ElectionRoll|null if (obj.state==='open' && electionRoll?.precinct){ // If election is open and precinct is defined, remove races that don't include precinct obj.races = getApprovedRaces(obj, electionRoll.precinct) - + } } // Where should this belong.. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8871a210..44fdbd53 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "react-scripts": "^5.0.1", "recharts": "^2.6.2", "typescript": "^3.9.10", + "uuid": "^9.0.1", "web-vitals": "^1.1.2" } }, @@ -5251,9 +5252,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001352", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001352.tgz", - "integrity": "sha512-GUgH8w6YergqPQDGWhJGt8GDRnY0L/iJVQcU3eJ46GYf52R8tk0Wxp0PymuFVZboJYXGiCqwozAYZNRjVj6IcA==", + "version": "1.0.30001525", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001525.tgz", + "integrity": "sha512-/3z+wB4icFt3r0USMwxujAqRvaD/B7rvGTsKhbhSQErVrJvkZCLhgNLJxU8MevahQVH6hCU9FsHdNUFbiwmE7Q==", "funding": [ { "type": "opencollective", @@ -5262,6 +5263,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -13613,6 +13618,14 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -14631,9 +14644,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -19189,9 +19206,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001352", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001352.tgz", - "integrity": "sha512-GUgH8w6YergqPQDGWhJGt8GDRnY0L/iJVQcU3eJ46GYf52R8tk0Wxp0PymuFVZboJYXGiCqwozAYZNRjVj6IcA==" + "version": "1.0.30001525", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001525.tgz", + "integrity": "sha512-/3z+wB4icFt3r0USMwxujAqRvaD/B7rvGTsKhbhSQErVrJvkZCLhgNLJxU8MevahQVH6hCU9FsHdNUFbiwmE7Q==" }, "case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -25085,6 +25102,13 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "source-list-map": { @@ -25837,9 +25861,9 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/frontend/package.json b/frontend/package.json index 7cdc7217..d2a6b98e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react-scripts": "^5.0.1", "recharts": "^2.6.2", "typescript": "^3.9.10", + "uuid": "^9.0.1", "web-vitals": "^1.1.2" }, "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2275fc48..26d93056 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,8 @@ import { ThemeProvider } from '@mui/material/styles' import Header from './components/Header' import Elections from './components/Elections' import Login from './components/Login' -import AddElection from './components/ElectionForm/AddElection' +import CreateElectionTemplates from './components/ElectionForm/CreateElectionTemplates' +// import AddElection from './components/ElectionForm/AddElection' import Election from './components/Election/Election' import DuplicateElection from './components/ElectionForm/DuplicateElection' import Sandbox from './components/Sandbox' @@ -55,7 +56,7 @@ const App = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Election/Admin/Admin.tsx b/frontend/src/components/Election/Admin/Admin.tsx index ea2bdc81..69743c8d 100644 --- a/frontend/src/components/Election/Admin/Admin.tsx +++ b/frontend/src/components/Election/Admin/Admin.tsx @@ -9,7 +9,7 @@ const Admin = ({ authSession, election, permissions, fetchElection }) => { return ( - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Election/Admin/AdminHome.tsx b/frontend/src/components/Election/Admin/AdminHome.tsx index 139b4a42..422c6246 100644 --- a/frontend/src/components/Election/Admin/AdminHome.tsx +++ b/frontend/src/components/Election/Admin/AdminHome.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useContext } from 'react' import Grid from "@mui/material/Grid"; import { Box, Divider, Paper } from "@mui/material"; import { Typography } from "@mui/material"; @@ -9,17 +9,14 @@ import ShareButton from "../ShareButton"; import { useArchiveEleciton, useFinalizeEleciton, useSetPublicResults } from "../../../hooks/useAPI"; import { formatDate } from '../../util'; import useConfirm from '../../ConfirmationDialogProvider'; +import useElection from '../../ElectionContextProvider'; +import ElectionDetailsInlineForm from '../../ElectionForm/Details/ElectionDetailsInlineForm'; +import Races from '../../ElectionForm/Races/Races'; +import ElectionSettings from '../../ElectionForm/ElectionSettings'; const hasPermission = (permissions: string[], requiredPermission: string) => { return (permissions && permissions.includes(requiredPermission)) } - -type Props = { - election: Election, - permissions: string[], - fetchElection: Function, -} - type SectionProps = { Description: any Button: any @@ -359,7 +356,8 @@ const ShareSection = ({ election, permissions }: { election: Election, permissio /> } -const AdminHome = ({ election, permissions, fetchElection }: Props) => { +const AdminHome = () => { + const { election, refreshElection: fetchElection, permissions } = useElection() const { makeRequest } = useSetPublicResults(election.election_id) const togglePublicResults = async () => { const public_results = !election.settings.public_results @@ -373,7 +371,7 @@ const AdminHome = ({ election, permissions, fetchElection }: Props) => { const finalizeElection = async () => { console.log("finalizing election") - const confirmed = await confirm( +const confirmed = await confirm( { title: 'Confirm Finalize Election', message: "Are you sure you want to finalize your election? Once finalized you won't be able to edit it." @@ -395,13 +393,13 @@ const AdminHome = ({ election, permissions, fetchElection }: Props) => { message: "Are you sure you wish to archive this election? This action cannot be undone." }) if (!confirmed) return - console.log('confirmed') - try { - await archive() - await fetchElection() - } catch (err) { - console.log(err) - } + console.log('confirmed') + try { + await archive() + await fetchElection() + } catch (err) { + console.log(err) + } } return ( @@ -412,36 +410,25 @@ const AdminHome = ({ election, permissions, fetchElection }: Props) => { sx={{ width: '100%' }}> - - - {election.title} - + + - - - Admin Page - + + + + + {election.state === 'draft' && <> - - - Your election is still in the draft phase - - - - - Before finalizing your election you can... - - - - + {/* + */} diff --git a/frontend/src/components/ElectionForm/AddCandidate.tsx b/frontend/src/components/ElectionForm/AddCandidate.tsx deleted file mode 100644 index 4f13053a..00000000 --- a/frontend/src/components/ElectionForm/AddCandidate.tsx +++ /dev/null @@ -1,314 +0,0 @@ -// import Button from "./Button" -import { useRef, useState, useCallback } from 'react' -import { Candidate } from "../../../../domain_model/Candidate" -import React from 'react' -import Grid from "@mui/material/Grid"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import Typography from '@mui/material/Typography'; -import Box from '@mui/material/Box'; -import Cropper from 'react-easy-crop'; -import getCroppedImg from './PhotoCropper'; - -type CandidateProps = { - onEditCandidate: Function, - candidate: Candidate, - index: number -} - -const AddCandidate = ({ onEditCandidate, candidate, index }: CandidateProps) => { - - const [editCandidate, setEditCandidate] = useState(false) - const [candidatePhotoFile, setCandidatePhotoFile] = useState(null) - const inputRef = useRef(null) - - const onApplyEditCandidate = (updateFunc) => { - const newCandidate = { ...candidate } - console.log(newCandidate) - updateFunc(newCandidate) - onEditCandidate(newCandidate) - } - const handleEnter = (e) => { - // Go to next entry instead of submitting form - const form = e.target.form; - const index = Array.prototype.indexOf.call(form, e.target); - form.elements[index + 3].focus(); - e.preventDefault(); - } - - const handleDragOver = (e) => { - e.preventDefault() - } - const handleOnDrop = (e) => { - e.preventDefault() - setCandidatePhotoFile(URL.createObjectURL(e.dataTransfer.files[0])) - } - - const [zoom, setZoom] = useState(1) - const [crop, setCrop] = useState({ x: 0, y: 0 }) - const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) - const onCropChange = (crop) => { setCrop(crop) } - const onZoomChange = (zoom) => { setZoom(zoom) } - const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { - setCroppedAreaPixels(croppedAreaPixels) - }, []) - - const postImage = async (image) => { - const url = '/API/images' - - var fileOfBlob = new File([image], 'image.jpg', { type: "image/jpeg" }); - var formData = new FormData() - formData.append('file', fileOfBlob) - const options = { - method: 'post', - body: formData - } - const response = await fetch(url, options) - if (!response.ok) { - return false - } - const data = await response.json() - onApplyEditCandidate((candidate) => { candidate.photo_filename = data.photo_filename }) - return true - } - const saveImage = async () => { - const image = await getCroppedImg( - candidatePhotoFile, - croppedAreaPixels - ) - if (await postImage(image)) { - setCandidatePhotoFile(null) - } - } - - - return ( - <> - - onApplyEditCandidate((candidate) => { candidate.candidate_name = e.target.value })} - onKeyPress={(e) => { - if (e.key === 'Enter') { - handleEnter(e) - } - }} - /> - - {editCandidate ? - - - - : - - {(process.env.REACT_APP_FF_CANDIDATE_DETAILS === 'true') && <> - - } - } - {editCandidate && - - {(process.env.REACT_APP_FF_CANDIDATE_PHOTOS === 'true') && <> - - {!candidatePhotoFile && - <> - - {/* NOTE: setting width in px is a bad habit, but I change the flex direction to column on smaller screens to account for this */} - - {candidate.photo_filename && - - } - - Candidate Photo - - - Drag and Drop - - - Or - - setCandidatePhotoFile(URL.createObjectURL(e.target.files[0]))} - hidden - ref={inputRef} /> - {!candidate.photo_filename && - - } - - {candidate.photo_filename && - - } - - - - } - {candidatePhotoFile && - - - - - - - } - - } - - - onApplyEditCandidate((candidate) => { candidate.full_name = e.target.value })} - /> - - - onApplyEditCandidate((candidate) => { candidate.bio = e.target.value })} - /> - - - onApplyEditCandidate((candidate) => { candidate.candidate_url = e.target.value })} - /> - - - onApplyEditCandidate((candidate) => { candidate.party = e.target.value })} - /> - - - onApplyEditCandidate((candidate) => { candidate.partyUrl = e.target.value })} - /> - - - - } - - ) -} - -export default AddCandidate diff --git a/frontend/src/components/ElectionForm/Candidates/AddCandidate.tsx b/frontend/src/components/ElectionForm/Candidates/AddCandidate.tsx new file mode 100644 index 00000000..a66c4177 --- /dev/null +++ b/frontend/src/components/ElectionForm/Candidates/AddCandidate.tsx @@ -0,0 +1,405 @@ +import { useRef, useState, useCallback } from 'react' +import { Candidate } from "../../../../../domain_model/Candidate" +import React from 'react' +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import Button from "@mui/material/Button"; +import Typography from '@mui/material/Typography'; +import { Box, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Paper } from '@mui/material'; +import Cropper from 'react-easy-crop'; +import getCroppedImg from './PhotoCropper'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { StyledButton } from '../../styles'; +import useConfirm from '../../ConfirmationDialogProvider'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; + +type CandidateProps = { + onEditCandidate: Function, + candidate: Candidate, + index: number +} + +const CandidateDialog = ({ onEditCandidate, candidate, index, onSave, open, handleClose }) => { + + const onApplyEditCandidate = (updateFunc) => { + const newCandidate = { ...candidate } + console.log(newCandidate) + updateFunc(newCandidate) + onEditCandidate(newCandidate) + } + + const [candidatePhotoFile, setCandidatePhotoFile] = useState(null) + const inputRef = useRef(null) + + const handleDragOver = (e) => { + e.preventDefault() + } + const handleOnDrop = (e) => { + e.preventDefault() + setCandidatePhotoFile(URL.createObjectURL(e.dataTransfer.files[0])) + } + + const [zoom, setZoom] = useState(1) + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) + const onCropChange = (crop) => { setCrop(crop) } + const onZoomChange = (zoom) => { setZoom(zoom) } + const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { + setCroppedAreaPixels(croppedAreaPixels) + }, []) + + const postImage = async (image) => { + const url = '/API/images' + + var fileOfBlob = new File([image], 'image.jpg', { type: "image/jpeg" }); + var formData = new FormData() + formData.append('file', fileOfBlob) + const options = { + method: 'post', + body: formData + } + const response = await fetch(url, options) + if (!response.ok) { + return false + } + const data = await response.json() + onApplyEditCandidate((candidate) => { candidate.photo_filename = data.photo_filename }) + return true + } + + const saveImage = async () => { + const image = await getCroppedImg( + candidatePhotoFile, + croppedAreaPixels + ) + if (await postImage(image)) { + setCandidatePhotoFile(null) + } + } + + return ( + + Edit Candidate + + + + + onApplyEditCandidate((candidate) => { candidate.candidate_name = e.target.value })} + /> + + + {(process.env.REACT_APP_FF_CANDIDATE_PHOTOS === 'true') && <> + + {!candidatePhotoFile && + <> + + {/* NOTE: setting width in px is a bad habit, but I change the flex direction to column on smaller screens to account for this */} + + {candidate.photo_filename && + + } + + Candidate Photo + + + Drag and Drop + + + Or + + setCandidatePhotoFile(URL.createObjectURL(e.target.files[0]))} + hidden + ref={inputRef} /> + {!candidate.photo_filename && + + } + + {candidate.photo_filename && + + } + + + + } + {candidatePhotoFile && + + + + + + + } + + } + + + onApplyEditCandidate((candidate) => { candidate.full_name = e.target.value })} + /> + + + onApplyEditCandidate((candidate) => { candidate.bio = e.target.value })} + /> + + + onApplyEditCandidate((candidate) => { candidate.candidate_url = e.target.value })} + /> + + + onApplyEditCandidate((candidate) => { candidate.party = e.target.value })} + /> + + + onApplyEditCandidate((candidate) => { candidate.partyUrl = e.target.value })} + /> + + + + + + + + + + + + onSave()}> + Close + + + + ) +} + +export const CandidateForm = ({ onEditCandidate, candidate, index, onDeleteCandidate, moveCandidateUp, moveCandidateDown }) => { + + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const onSave = () => { handleClose() } + + return ( + + + + {candidate.candidate_name} + + + + + + + + + + + + + + + + + ) +} + +const AddCandidate = ({ onAddNewCandidate }) => { + + const handleEnter = (e) => { + saveNewCandidate() + e.preventDefault(); + } + const saveNewCandidate = () => { + if (newCandidateName.length > 0) { + onAddNewCandidate(newCandidateName) + setNewCandidateName('') + } + } + + const [newCandidateName, setNewCandidateName] = useState('') + + return ( + + + setNewCandidateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleEnter(e) + } + }} + /> + + + + ) +} + +export default AddCandidate + diff --git a/frontend/src/components/ElectionForm/PhotoCropper.js b/frontend/src/components/ElectionForm/Candidates/PhotoCropper.js similarity index 100% rename from frontend/src/components/ElectionForm/PhotoCropper.js rename to frontend/src/components/ElectionForm/Candidates/PhotoCropper.js diff --git a/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx b/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx new file mode 100644 index 00000000..b6a232e3 --- /dev/null +++ b/frontend/src/components/ElectionForm/CreateElectionTemplates.tsx @@ -0,0 +1,219 @@ +import React from 'react' +import { useNavigate } from "react-router" +import { IAuthSession } from '../../hooks/useAuthSession'; +import { Election } from '../../../../domain_model/Election'; +import { usePostElection } from '../../hooks/useAPI'; +import { DateTime } from 'luxon' +import { Card, CardActionArea, CardMedia, CardContent, Typography, Box, Grid } from '@mui/material'; + + + +const CreateElectionTemplates = ({ authSession }: { authSession: IAuthSession }) => { + + const navigate = useNavigate() + const { error, isPending, makeRequest: postElection } = usePostElection() + + const defaultElection: Election = { + title: '', + election_id: '0', + description: '', + state: 'draft', + frontend_url: '', + owner_id: '', + races: [], + settings: { + voter_access: 'open', + voter_authentication: { + ip_address: true, + }, + ballot_updates: false, + public_results: true, + time_zone: DateTime.now().zone.name, + } + } + + const onAddElection = async (updateFunc: (election: Election) => any) => { + const election = defaultElection + updateFunc(election) + // calls post election api, throws error if response not ok + election.frontend_url = '' + election.owner_id = authSession.getIdField('sub') + election.state = 'draft' + + const newElection = await postElection( + { + Election: election, + }) + if ((!newElection)) { + throw Error("Error submitting election"); + } + navigate(`/Election/${newElection.election.election_id}/admin`) + } + const cardHeight = 220 + return ( + < > + {!authSession.isLoggedIn() &&
Must be logged in to create elections
} + {authSession.isLoggedIn() && + + + + + + onAddElection(election => { + election.settings.voter_access = 'open' + election.settings.voter_authentication.voter_id = false + election.settings.voter_authentication.email = false + election.settings.voter_authentication.ip_address = false + election.settings.invitation = undefined + })}> + + + Open Access, No Vote Limit + + + Useful for demonstrations + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'open' + election.settings.voter_authentication.voter_id = false + election.settings.voter_authentication.email = false + election.settings.voter_authentication.ip_address = true + election.settings.invitation = undefined + })}> + + + Open Access, One Vote Per Person + + + For Quick Polls + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'open' + election.settings.voter_authentication.voter_id = false + election.settings.voter_authentication.email = true + election.settings.voter_authentication.ip_address = false + election.settings.invitation = undefined + })}> + + + Open Access, Login Required + + + Open to all voters that create a star.vote account + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'registration' + election.settings.voter_authentication.voter_id = false + election.settings.voter_authentication.email = true + election.settings.voter_authentication.ip_address = false + election.settings.invitation = undefined + })}> + + + Open Access With Custom Registration + + + Voters must register and be approved by election admins + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'closed' + election.settings.voter_authentication.voter_id = true + election.settings.voter_authentication.email = false + election.settings.voter_authentication.ip_address = false + election.settings.invitation = 'email' + })}> + + + Closed voter list with unique email invites + + + Voters receive unique email invitations, no log in required + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'closed' + election.settings.voter_authentication.voter_id = false + election.settings.voter_authentication.email = true + election.settings.voter_authentication.ip_address = false + election.settings.invitation = 'email' + })}> + + + Closed voter list with login required + + + Voters receive email invitations but must create star.vote account in order to vote + + + + + + + + onAddElection(election => { + election.settings.voter_access = 'closed' + election.settings.voter_authentication.voter_id = true + election.settings.voter_authentication.email = false + election.settings.voter_authentication.ip_address = false + election.settings.invitation = undefined + })}> + + + Closed voter id list with voter-IDs + + + Election provide list of valid voter IDs and distribute them to voters + + + + + + + + } + {isPending &&
Submitting...
} + + ) +} + +export default CreateElectionTemplates diff --git a/frontend/src/components/ElectionForm/ElectionDetails.tsx b/frontend/src/components/ElectionForm/Details/ElectionDetails.tsx similarity index 99% rename from frontend/src/components/ElectionForm/ElectionDetails.tsx rename to frontend/src/components/ElectionForm/Details/ElectionDetails.tsx index d6fd4730..de55bc7b 100644 --- a/frontend/src/components/ElectionForm/ElectionDetails.tsx +++ b/frontend/src/components/ElectionForm/Details/ElectionDetails.tsx @@ -4,11 +4,11 @@ import Grid from "@mui/material/Grid"; import TextField from "@mui/material/TextField"; import FormControl from "@mui/material/FormControl"; import { Checkbox, Divider, FormControlLabel, FormGroup, FormHelperText, InputLabel, MenuItem, Select } from "@mui/material" -import { StyledButton } from '../styles'; +import { StyledButton } from '../../styles'; import { Input } from '@mui/material'; import { DateTime } from 'luxon' import { timeZones } from './TimeZones' -import { Election } from '../../../../domain_model/Election'; +import { Election } from '../../../../../domain_model/Election'; type Props = { diff --git a/frontend/src/components/ElectionForm/Details/ElectionDetailsDialog.tsx b/frontend/src/components/ElectionForm/Details/ElectionDetailsDialog.tsx new file mode 100644 index 00000000..c21a60f4 --- /dev/null +++ b/frontend/src/components/ElectionForm/Details/ElectionDetailsDialog.tsx @@ -0,0 +1,98 @@ +import React, { useContext } from 'react' +import Grid from "@mui/material/Grid"; +import { Box, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Paper, Typography } from "@mui/material" +import { StyledButton } from '../../styles'; + +import useElection from '../../ElectionContextProvider'; +import { formatDate } from '../../util'; +import EditIcon from '@mui/icons-material/Edit'; +import ElectionDetailsForm from './ElectionDetailsForm'; +import { useEditElectionDetails } from './useEditElectionDetails'; + +export default function ElectionDetails3() { + const { editedElection, applyUpdate, onSave, errors, setErrors } = useEditElectionDetails() + const { election } = useElection() + + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const handleSave = async () => { + const success = await onSave() + if (success) handleClose() + } + + return ( + + + + + Election Title: {election.title} + + + + + Description: {election.description} + + + + + Start Time: {election.start_time ? formatDate(election.start_time, election.settings.time_zone) : 'none'} + + + + + End Time: {election.end_time ? formatDate(election.end_time, election.settings.time_zone) : 'none'} + + + + + + + + + + + + Edit Election Details + + + + + + Cancel + + handleSave()}> + Save + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ElectionForm/Details/ElectionDetailsForm.tsx b/frontend/src/components/ElectionForm/Details/ElectionDetailsForm.tsx new file mode 100644 index 00000000..a181e60e --- /dev/null +++ b/frontend/src/components/ElectionForm/Details/ElectionDetailsForm.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import { useState } from "react" +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import FormControl from "@mui/material/FormControl"; +import { Checkbox, Divider, FormControlLabel, FormGroup, FormHelperText, InputLabel, MenuItem, Select } from "@mui/material" +import { Input } from '@mui/material'; +import { DateTime } from 'luxon' +import { timeZones } from './TimeZones' +import { isValidDate } from '../../util'; +import { dateToLocalLuxonDate } from './useEditElectionDetails'; + + +export default function ElectionDetailsForm({editedElection, applyUpdate, errors, setErrors}) { + + const timeZone = editedElection.settings.time_zone ? editedElection.settings.time_zone : DateTime.now().zone.name + + const [enableStartEndTime, setEnableStartEndTime] = useState(isValidDate(editedElection.start_time) || isValidDate(editedElection.end_time)) + const [defaultStartTime, setDefaultStartTime] = useState(isValidDate(editedElection.start_time) ? editedElection.start_time : DateTime.now().setZone(timeZone, { keepLocalTime: true }).toJSDate()) + const [defaultEndTime, setDefaultEndTime] = useState(isValidDate(editedElection.end_time) ? editedElection.end_time : DateTime.now().plus({ days: 1 }).setZone(timeZone, { keepLocalTime: true }).toJSDate()) + + return ( + + + { + setErrors({ ...errors, title: '' }) + applyUpdate(election => { election.title = e.target.value }) + }} + /> + + {errors.title} + + + + { + setErrors({ ...errors, description: '' }) + applyUpdate(election => { election.description = e.target.value }) + }} + /> + + {errors.description} + + + + + + + { + setEnableStartEndTime(e.target.checked) + if (e.target.checked) { + applyUpdate(election => { election.start_time = defaultStartTime }) + applyUpdate(election => { election.end_time = defaultEndTime }) + } + else { + applyUpdate(election => { election.start_time = undefined }) + applyUpdate(election => { election.end_time = undefined }) + } + } + } + /> + } + label="Enable Start/End Times?" /> + + + + {enableStartEndTime && + <> + + + Time Zone + + + + + + + + Start Date + + { + setErrors({ ...errors, startTime: '' }) + if (e.target.value == null || e.target.value == '') { + applyUpdate(election => { election.start_time = undefined }) + } else { + applyUpdate(election => { election.start_time = DateTime.fromISO(e.target.value).setZone(timeZone, { keepLocalTime: true }).toJSDate()}) + setDefaultStartTime(DateTime.fromISO(e.target.value).setZone(timeZone, { keepLocalTime: true }).toJSDate()) + } + + }} + /> + + {errors.startTime} + + + + + + + Stop Date + + { + setErrors({ ...errors, endTime: '' }) + if (e.target.value == null || e.target.value == '') { + applyUpdate(election => { election.end_time = undefined}) + } else { + applyUpdate(election => { election.end_time = DateTime.fromISO(e.target.value).setZone(timeZone, { keepLocalTime: true }).toJSDate()}) + setDefaultEndTime(DateTime.fromISO(e.target.value).setZone(timeZone, { keepLocalTime: true }).toJSDate()) + } + }} + /> + + {errors.endTime} + + + + + + + } + + ) +} diff --git a/frontend/src/components/ElectionForm/Details/ElectionDetailsInlineForm.tsx b/frontend/src/components/ElectionForm/Details/ElectionDetailsInlineForm.tsx new file mode 100644 index 00000000..b622f749 --- /dev/null +++ b/frontend/src/components/ElectionForm/Details/ElectionDetailsInlineForm.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react' +import Grid from "@mui/material/Grid"; +import { Box, IconButton, Paper, Typography } from "@mui/material" +import { StyledButton } from '../../styles'; +import useElection from '../../ElectionContextProvider'; +import { formatDate } from '../../util'; +import EditIcon from '@mui/icons-material/Edit'; +import ElectionDetailsForm from './ElectionDetailsForm'; +import { useEditElectionDetails } from './useEditElectionDetails'; + +export default function ElectionDetailsInlineForm() { + const { editedElection, applyUpdate, onSave, errors, setErrors } = useEditElectionDetails() + const { election } = useElection() + + const [open, setOpen] = useState(election.title.length==0); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const handleSave = async () => { + const success = await onSave() + if (success) handleClose() + } + + return ( + + {!open && + + + + Election Title: {election.title} + + + + + Description: {election.description} + + + + + Start Time: {election.start_time ? formatDate(election.start_time, election.settings.time_zone) : 'none'} + + + + + End Time: {election.end_time ? formatDate(election.end_time, election.settings.time_zone) : 'none'} + + + + + + + + + + + + } + {open && <> + + + + + Cancel + + + + handleSave()}> + Save + + + + + } + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ElectionForm/TimeZones.ts b/frontend/src/components/ElectionForm/Details/TimeZones.ts similarity index 100% rename from frontend/src/components/ElectionForm/TimeZones.ts rename to frontend/src/components/ElectionForm/Details/TimeZones.ts diff --git a/frontend/src/components/ElectionForm/Details/useEditElectionDetails.tsx b/frontend/src/components/ElectionForm/Details/useEditElectionDetails.tsx new file mode 100644 index 00000000..4baf3792 --- /dev/null +++ b/frontend/src/components/ElectionForm/Details/useEditElectionDetails.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { useState } from "react" +import { DateTime } from 'luxon' +import useElection from '../../ElectionContextProvider'; +import { isValidDate } from '../../util'; +import structuredClone from '@ungap/structured-clone'; + +export const dateToLocalLuxonDate = (date: Date | string | null | undefined, timeZone: string) => { + // NOTE: we don't want to use the util function here since we want to omit the timezone + + // Converts either string date or date object to ISO string in input time zone + if (date == null || date == '') return '' + date = new Date(date) + // Convert to luxon date and apply time zone offset, then convert to ISO string for input component + return DateTime.fromJSDate(date) + .setZone(timeZone) + .startOf("minute") + .toISO({ includeOffset: false, suppressSeconds: true, suppressMilliseconds: true }) +} + +export const useEditElectionDetails = () => { + const { election, refreshElection, permissions, updateElection } = useElection() + + + const [editedElection, setEditedElection] = useState(election) + + const [errors, setErrors] = useState({ + title: '', + description: '', + startTime: '', + endTime: '', + }) + + const applyUpdate = (updateFunc: (settings) => any) => { + const settingsCopy = structuredClone(editedElection) + updateFunc(settingsCopy) + setEditedElection(settingsCopy) + }; + + + + + const validatePage = () => { + let isValid = 1 + let newErrors = { ...errors } + + if (!editedElection.title) { + newErrors.title = 'Election title required'; + isValid = 0; + } + else if (editedElection.title.length < 3 || editedElection.title.length > 256) { + newErrors.title = 'Election title must be between 3 and 256 characters'; + isValid = 0; + } + if (editedElection.description && editedElection.description.length > 1000) { + newErrors.description = 'Description must be less than 1000 characters'; + isValid = 0; + } + if (editedElection.start_time) { + if (!isValidDate(editedElection.start_time)) { + newErrors.startTime = 'Invalid date'; + isValid = 0; + } + } + + if (editedElection.end_time) { + if (!isValidDate(editedElection.end_time)) { + newErrors.endTime = 'Invalid date'; + isValid = 0; + } + else if (editedElection.end_time < new Date()) { + newErrors.endTime = 'Start date must be in the future'; + isValid = 0; + } + else if (editedElection.start_time && newErrors.startTime === '') { + // If start date exists, has no errors, and is after the end date + if (editedElection.start_time >= editedElection.end_time) { + newErrors.endTime = 'End date must be after the start date'; + isValid = 0; + } + } + } + setErrors(errors => ({ ...errors, ...newErrors })) + return isValid + } + + const onSave = async () => { + console.log('saving') + if (!validatePage()) { + console.log('Invalid') + return + } + + const success = await updateElection(election => { + election.title = editedElection.title + election.description = editedElection.description + election.start_time = editedElection.start_time + election.end_time = editedElection.end_time + election.settings.time_zone = editedElection.settings.time_zone + }) + + if (!success) return + await refreshElection() + return true + } + + return { editedElection, applyUpdate, validatePage, onSave, errors, setErrors } +} \ No newline at end of file diff --git a/frontend/src/components/ElectionForm/ElectionForm.tsx b/frontend/src/components/ElectionForm/ElectionForm.tsx index d4e32782..93d63342 100644 --- a/frontend/src/components/ElectionForm/ElectionForm.tsx +++ b/frontend/src/components/ElectionForm/ElectionForm.tsx @@ -5,10 +5,10 @@ import React from 'react' import Grid from "@mui/material/Grid"; import structuredClone from '@ungap/structured-clone'; // import Settings from "./Settings"; -import Races from "./Races"; +import Races from "./Races/Races"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { Box, Paper, Fade } from "@mui/material"; -import ElectionDetails from "./ElectionDetails"; +import ElectionDetails from "./Details/ElectionDetails"; import { DateTime } from 'luxon' import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; @@ -296,7 +296,7 @@ const ElectionForm = ({ authSession, onSubmitElection, prevElectionData, submitT
- setPage('ElectionDetails')} onNext={() => setPage('Open?')} /> + {/* setPage('ElectionDetails')} onNext={() => setPage('Open?')} /> */}
any) => { + const settingsCopy = structuredClone(editedElectionSettings) + updateFunc(settingsCopy) + setEditedElectionSettings(settingsCopy) + }; + + const validatePage = () => { + // Placeholder function + return true + } + + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const onSave = async () => { + if (!validatePage()) return + const success = await updateElection(election => { + election.settings = editedElectionSettings + }) + if (!success) return false + await refreshElection() + handleClose() + } + + return ( + + + + Extra Settings + + + + + + + + + Election Settings + + + + + applySettingsUpdate(settings => { settings.ballot_updates = e.target.checked })} + />} + label="Ballot Updates" + /> + + Allow voters to update their ballots while election is still open (currently not supported) + + applySettingsUpdate(settings => { settings.public_results = e.target.checked })} + />} + label="Public Results" + /> + + Allow voters to view preliminary results. (Administrators can make results public at any time.) + + } + label="Enable Random Tie-Breakers" + /> + + While ties are unlikely, yada yada yada, Link to info on ties. + + } + label="Enable Voter Groups" + /> + + Manage which races voters can vote in. + + } + label="Custom Email Invite Text" + /> + + Set greetings and instructions for your email invitations. + + } + label="Require Instruction Confirmations" + /> + + Requires voters to confirm that they have read ballot instructions in order to vote. + + + + + + + + Cancel + + onSave()}> + Save + + + + + ) +} diff --git a/frontend/src/components/ElectionForm/Races.tsx b/frontend/src/components/ElectionForm/Races.tsx deleted file mode 100644 index e853bd84..00000000 --- a/frontend/src/components/ElectionForm/Races.tsx +++ /dev/null @@ -1,466 +0,0 @@ -import React from 'react' -import { useState } from "react" -import { Candidate } from "../../../../domain_model/Candidate" -import AddCandidate from "./AddCandidate" -import Grid from "@mui/material/Grid"; -import TextField from "@mui/material/TextField"; -import FormControl from "@mui/material/FormControl"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import Typography from '@mui/material/Typography'; -import { Box, Checkbox, FormGroup, FormHelperText, FormLabel, InputLabel, Radio, RadioGroup, Tooltip } from "@mui/material" -import { StyledButton } from '../styles'; -import IconButton from '@mui/material/IconButton' -import ExpandLess from '@mui/icons-material/ExpandLess' -import ExpandMore from '@mui/icons-material/ExpandMore' -import { scrollToElement } from '../util'; -// import { useNavigate } from 'react-router-dom'; - -export default function Races({ election, applyElectionUpdate, getStyle, onBack, onNext }) { - // blocks back button and calls onBack function instead - window.history.pushState(null, null, window.location.href); - window.onpopstate = () => { - onBack() - } - const [openRace, setOpenRace] = useState(0) - const [showsAllMethods, setShowsAllMethods] = useState(false) - const [newCandidateName, setNewCandidateName] = useState('') - const onAddCandidate = (race_index) => { - applyElectionUpdate(election => { - election.races[race_index].candidates?.push( - { - candidate_id: String(election.races[race_index].candidates.length), - candidate_name: newCandidateName, // short mnemonic for the candidate - full_name: '', - } - ) - }) - setNewCandidateName('') - } - - const [multipleRaces, setMultipleRaces] = useState(election.races.length > 1) - const [errors, setErrors] = useState({ - raceTitle: '', - raceDescription: '', - raceNumWinners: '', - candidates: '' - }) - const validatePage = () => { - let isValid = 1 - let newErrors: any = {} - let race = election.races[openRace] - if (election.races.length > 1 || multipleRaces) { - if (!race.title) { - newErrors.raceTitle = 'Race title required'; - isValid = 0; - } - else if (race.title.length < 3 || race.title.length > 256) { - newErrors.raceTitle = 'Race title must be between 3 and 256 characters'; - isValid = 0; - } - if (race.description && race.description.length > 1000) { - newErrors.raceDescription = 'Race title must be less than 1000 characters'; - isValid = 0; - } - } - if (race.num_winners < 1) { - newErrors.raceNumWinners = 'Must have at least one winner'; - isValid = 0; - } - const numCandidates = race.candidates.filter(candidate => candidate.candidate_name !== '').length - if (race.num_winners > numCandidates) { - newErrors.raceNumWinners = 'Cannot have more winners than candidates'; - isValid = 0; - } - if (numCandidates < 2) { - newErrors.candidates = 'Must have at least 2 candidates'; - isValid = 0; - } - const uniqueCandidates = new Set(race.candidates.filter(candidate => candidate.candidate_name !== '').map(candidate => candidate.candidate_name)) - if (numCandidates !== uniqueCandidates.size) { - newErrors.candidates = 'Candidates must have unique names'; - isValid = 0; - } - setErrors(errors => ({ ...errors, ...newErrors })) - - // NOTE: I'm passing the element as a function so that we can delay the query until the elements have been updated - scrollToElement(() => document.querySelectorAll('.Mui-error')) - - return isValid - } - - const onAddRace = () => { - if (election.races.length === 1 && !multipleRaces) { - // If there is only one race currently and this is the first time being run, set title required error because that field hasn't been shown yet. - setMultipleRaces(true) - setErrors(errors => ({ ...errors, raceTitle: 'Race title required' })) - validatePage() - return - } - if (validatePage()) { - const currentCount = election.races.length - applyElectionUpdate(election => { - election.races.push( - { - race_id: String(election.races.length), - num_winners: 1, - voting_method: 'STAR', - candidates: [ - { - candidate_id: '0', - candidate_name: '', - }, - ] as Candidate[], - precincts: undefined, - } - ) - }) - setOpenRace(currentCount) - } - - } - - const onEditCandidate = (race_index, candidate: Candidate, index) => { - setErrors({ ...errors, candidates: '', raceNumWinners: '' }) - applyElectionUpdate(election => { - election.races[race_index].candidates[index] = candidate - const candidates = election.races[openRace].candidates - if (index === candidates.length - 1) { - // If last form entry is updated, add another entry to form - candidates.push({ - candidate_id: String(election.races[openRace].candidates.length), - candidate_name: '', - }) - } - while(candidates.length >= 2 && candidates[candidates.length-1].candidate_name == '' && candidates[candidates.length-2].candidate_name == ''){ - candidates.pop(); - } - }) - } - - return ( - - - Race Settings - - {election.races?.map((race, race_index) => ( - <> - {openRace === race_index && - <> - {multipleRaces && - <> - - {`Race ${race_index + 1}`} - - - { - setErrors({ ...errors, raceTitle: '' }) - applyElectionUpdate(election => { election.races[race_index].title = e.target.value }) - }} - /> - - {errors.raceTitle} - - - - - { - setErrors({ ...errors, raceDescription: '' }) - applyElectionUpdate(election => { election.races[race_index].description = e.target.value }) - }} - /> - - {errors.raceDescription} - - - - {process.env.REACT_APP_FF_PRECINCTS === 'true' && election.settings.voter_access !== 'open' && - - applyElectionUpdate(election => { - if (e.target.value === '') { - election.races[race_index].precincts = undefined - } - else { - election.races[race_index].precincts = e.target.value.split('\n') - } - })} - /> - - } - } - {process.env.REACT_APP_FF_MULTI_WINNER === 'true' && - - - Number of Winners - - { - setErrors({ ...errors, raceNumWinners: '' }) - applyElectionUpdate(election => { election.races[race_index].num_winners = e.target.value }) - }} - /> - - {errors.raceNumWinners} - - - } - - - - - Voting Method - - applyElectionUpdate(election => { election.races[race_index].voting_method = e.target.value })} - > - } label="STAR" sx={{ mb: 0, pb: 0 }} /> - - Score candidates 0-5, single winner or multi-winner - - - - {(process.env.REACT_APP_FF_METHOD_STAR_PR === 'true') && <> - } label="Proportional STAR" /> - - Score candidates 0-5, proportional multi-winner - - } - - {(process.env.REACT_APP_FF_METHOD_RANKED_ROBIN === 'true') && <> - } label="Ranked Robin" /> - - Rank candidates in order of preference, single winner or multi-winner - - } - - {(process.env.REACT_APP_FF_METHOD_APPROVAL === 'true') && <> - } label="Approval" /> - - Mark all candidates you approve of, single winner or multi-winner - - } - - - - - - - {!showsAllMethods && - { setShowsAllMethods(true) }}> - - } - {showsAllMethods && - { setShowsAllMethods(false) }}> - - } - - More Options - - - {showsAllMethods && - - - - These voting methods do not guarantee every voter an equally powerful vote if there are more than two candidates. - - - } - {showsAllMethods && - <> - - } label="Plurality" /> - - Mark one candidate only. Not recommended with more than 2 candidates. - - - {(process.env.REACT_APP_FF_METHOD_RANKED_CHOICE === 'true') && <> - } label="Ranked Choice" /> - - Rank candidates in order of preference, single winner, only recommended for educational purposes - - } - - } - - - - - - - - Candidates - - - {errors.candidates} - - - {election.races[race_index].candidates?.map((candidate, index) => ( - <> - onEditCandidate(race_index, newCandidate, index)} - candidate={candidate} - index={index} /> - - ))} - } - - )) - } - - { - election.races.length > 1 && - <> - - { - if (validatePage()) { - setOpenRace(openRace => openRace - 1) - } - }}> - Previous - - - - - = election.races.length - 1} - onClick={() => { - if (validatePage()) { - setOpenRace(openRace => openRace + 1) - } - }}> - Next Race - - - - - } - - - - {(process.env.REACT_APP_FF_METHOD_PLURALITY === 'true') && <> - onAddRace()} > - Add Race - - } - - - { - onBack() - }}> - Back - - - - - { - if (validatePage()) { - onNext() - } - }}> - Next - - - {/* - { - if (validatePage()) { - onSubmit() - } - } - }> - {submitText} - - */} - - ) -} diff --git a/frontend/src/components/ElectionForm/Races/AddRace.tsx b/frontend/src/components/ElectionForm/Races/AddRace.tsx new file mode 100644 index 00000000..bee1369d --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/AddRace.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react' +import { Box } from "@mui/material" +import { StyledButton } from '../../styles'; +import useElection from '../../ElectionContextProvider'; +import RaceDialog from './RaceDialog'; +import RaceForm from './RaceForm'; +import { useEditRace } from './useEditRace'; + +export default function AddRace() { + const { election } = useElection() + + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const { editedRace, errors, setErrors, applyRaceUpdate, onAddRace } = useEditRace(null, election.races.length) + + const onAdd = async () => { + const success = await onAddRace() + if (!success) return + handleClose() + } + + return ( + + + Add + + + + + + ) +} diff --git a/frontend/src/components/ElectionForm/Races/Race.tsx b/frontend/src/components/ElectionForm/Races/Race.tsx new file mode 100644 index 00000000..3f7ae727 --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/Race.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useState } from "react" +import Typography from '@mui/material/Typography'; +import { Box, Paper } from "@mui/material" +import IconButton from '@mui/material/IconButton' +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import RaceDialog from './RaceDialog'; +import { useEditRace } from './useEditRace'; +import RaceForm from './RaceForm'; + +export default function Race({ race, race_index }) { + + const { editedRace, errors, setErrors, applyRaceUpdate, onSaveRace, onDeleteRace } = useEditRace(race, race_index) + + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const onSave = async () => { + const success = await onSaveRace() + if (!success) return + handleClose() + } + + return ( + + + + {race.title} + + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ElectionForm/Races/RaceDialog.tsx b/frontend/src/components/ElectionForm/Races/RaceDialog.tsx new file mode 100644 index 00000000..374c4865 --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/RaceDialog.tsx @@ -0,0 +1,49 @@ +import React from 'react' + +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material" +import { StyledButton } from '../../styles'; + + +export default function RaceDialog({ onSaveRace, open, handleClose, children }) { + + const handleSave = () => { + onSaveRace() + } + + const onClose = (event, reason) => { + if (reason && reason == "backdropClick") + return; + handleClose(); + } + + return ( + + Edit Race + + {children} + + + + Cancel + + handleSave()}> + Save + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ElectionForm/Races/RaceForm.tsx b/frontend/src/components/ElectionForm/Races/RaceForm.tsx new file mode 100644 index 00000000..56d6a73b --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/RaceForm.tsx @@ -0,0 +1,297 @@ +import React from 'react' +import { useState } from "react" +import { Candidate } from "../../../../../domain_model/Candidate" +import AddCandidate, { CandidateForm } from "../Candidates/AddCandidate" +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Typography from '@mui/material/Typography'; +import { Box, FormHelperText, Radio, RadioGroup, Stack } from "@mui/material" +import IconButton from '@mui/material/IconButton' +import ExpandLess from '@mui/icons-material/ExpandLess' +import ExpandMore from '@mui/icons-material/ExpandMore' +// import { scrollToElement } from '../../util'; +import useElection from '../../ElectionContextProvider'; +import { v4 as uuidv4 } from 'uuid'; +import useConfirm from '../../ConfirmationDialogProvider'; + +export default function RaceForm({ race_index, editedRace, errors, setErrors, applyRaceUpdate }) { + const [showsAllMethods, setShowsAllMethods] = useState(false) + const { election } = useElection() + + const confirm = useConfirm(); + + + + const onEditCandidate = (candidate: Candidate, index) => { + setErrors({ ...errors, candidates: '', raceNumWinners: '' }) + applyRaceUpdate(race => { + race.candidates[index] = candidate + const candidates = race.candidates + if (index === candidates.length - 1) { + // If last form entry is updated, add another entry to form + candidates.push({ + candidate_id: String(race.candidates.length), + candidate_name: '', + }) + } + while (candidates.length >= 2 && candidates[candidates.length - 1].candidate_name == '' && candidates[candidates.length - 2].candidate_name == '') { + candidates.pop(); + } + }) + } + + const onAddNewCandidate = (newCandidateName: string) => { + applyRaceUpdate(race => { + race.candidates.push({ + candidate_id: uuidv4(), + candidate_name: newCandidateName, + }) + }) + } + + const moveCandidate = (fromIndex: number, toIndex: number) => { + applyRaceUpdate(race => { + let candidate = race.candidates.splice(fromIndex, 1)[0]; + race.candidates.splice(toIndex, 0, candidate); + }) + } + + const moveCandidateUp = (index: number) => { + if (index > 0) { + moveCandidate(index, index - 1) + } + } + const moveCandidateDown = (index: number) => { + if (index < editedRace.candidates.length - 1) { + moveCandidate(index, index + 1) + } + } + + + const onDeleteCandidate = async (index: number) => { + const confirmed = await confirm({ title: 'Confirm Delete Candidate', message: 'Are you sure?' }) + if (!confirmed) return + applyRaceUpdate(race => { + race.candidates.splice(index, 1) + }) + } + + return ( + <> + + + + { + setErrors({ ...errors, raceTitle: '' }) + applyRaceUpdate(race => { race.title = e.target.value }) + }} + /> + + {errors.raceTitle} + + + + + { + setErrors({ ...errors, raceDescription: '' }) + applyRaceUpdate(race => { race.description = e.target.value }) + }} + /> + + {errors.raceDescription} + + + + { + process.env.REACT_APP_FF_PRECINCTS === 'true' && election.settings.voter_access !== 'open' && + + applyRaceUpdate(race => { + if (e.target.value === '') { + race.precincts = undefined + } + else { + race.precincts = e.target.value.split('\n') + } + })} + /> + + } + { + process.env.REACT_APP_FF_MULTI_WINNER === 'true' && + + + Number of Winners + + { + setErrors({ ...errors, raceNumWinners: '' }) + applyRaceUpdate(race => { race.num_winners = parseInt(e.target.value) }) + }} + /> + + {errors.raceNumWinners} + + + } + + + + + Voting Method + + applyRaceUpdate(race => { race.voting_method = e.target.value })} + > + } label="STAR" sx={{ mb: 0, pb: 0 }} /> + + Score candidates 0-5, single winner or multi-winner + + + {(process.env.REACT_APP_FF_METHOD_STAR_PR === 'true') && <> + } label="Proportional STAR" /> + + Score candidates 0-5, proportional multi-winner + + } + + {(process.env.REACT_APP_FF_METHOD_RANKED_ROBIN === 'true') && <> + } label="Ranked Robin" /> + + Rank candidates in order of preference, single winner or multi-winner + + } + + {(process.env.REACT_APP_FF_METHOD_APPROVAL === 'true') && <> + } label="Approval" /> + + Mark all candidates you approve of, single winner or multi-winner + + } + + + + {!showsAllMethods && + { setShowsAllMethods(true) }}> + + } + {showsAllMethods && + { setShowsAllMethods(false) }}> + + } + + More Options + + + {showsAllMethods && + + + + These voting methods do not guarantee every voter an equally powerful vote if there are more than two candidates. + + + } + {showsAllMethods && + <> + } label="Plurality" /> + + Mark one candidate only. Not recommended with more than 2 candidates. + + + {(process.env.REACT_APP_FF_METHOD_RANKED_CHOICE === 'true') && <> + } label="Ranked Choice" /> + + Rank candidates in order of preference, single winner, only recommended for educational purposes + + } + + } + + + + + + Candidates + + + {errors.candidates} + + + + + { + editedRace.candidates?.map((candidate, index) => ( + onEditCandidate(newCandidate, index)} + candidate={candidate} + index={index} + onDeleteCandidate={() => onDeleteCandidate(index)} + moveCandidateUp={() => moveCandidateUp(index)} + moveCandidateDown={() => moveCandidateDown(index)} /> + )) + } + + + + ) +} diff --git a/frontend/src/components/ElectionForm/Races/Races.tsx b/frontend/src/components/ElectionForm/Races/Races.tsx new file mode 100644 index 00000000..7c11ef50 --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/Races.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import Typography from '@mui/material/Typography'; +import { Stack } from "@mui/material" +import useElection from '../../ElectionContextProvider'; +import Race from './Race'; +import AddRace from './AddRace'; + +export default function Races() { + const { election, refreshElection, permissions, updateElection } = useElection() + + return ( + + Races + + {election.races?.map((race, race_index) => ( + + )) + } + + + ) +} diff --git a/frontend/src/components/ElectionForm/Races/useEditRace.tsx b/frontend/src/components/ElectionForm/Races/useEditRace.tsx new file mode 100644 index 00000000..316f75a9 --- /dev/null +++ b/frontend/src/components/ElectionForm/Races/useEditRace.tsx @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react' +import { useState } from "react" + +import { scrollToElement } from '../../util'; +import useElection from '../../ElectionContextProvider'; +import { Race as iRace } from '../../../../../domain_model/Race'; +import structuredClone from '@ungap/structured-clone'; +import useConfirm from '../../ConfirmationDialogProvider'; +import { v4 as uuidv4 } from 'uuid'; +import { Candidate } from '../../../../../domain_model/Candidate'; + +export const useEditRace = (race, race_index) => { + const { election, refreshElection, permissions, updateElection } = useElection() + const confirm = useConfirm(); + const defaultRace = { + title: '', + race_id: '', + num_winners: 1, + voting_method: 'STAR', + candidates: [] as Candidate[], + precincts: undefined, + } + const [editedRace, setEditedRace] = useState(race !== null ? race : defaultRace) + + const [errors, setErrors] = useState({ + raceTitle: '', + raceDescription: '', + raceNumWinners: '', + candidates: '' + }) + + useEffect(() => { + console.log(race) + setEditedRace(race !== null ? race : defaultRace) + setErrors({ + raceTitle: '', + raceDescription: '', + raceNumWinners: '', + candidates: '' + }) + }, [race_index]) + + const applyRaceUpdate = (updateFunc: (race: iRace) => any) => { + const raceCopy: iRace = structuredClone(editedRace) + updateFunc(raceCopy) + setEditedRace(raceCopy) + }; + + const validatePage = () => { + let isValid = true + let newErrors: any = {} + if (election.races.length > 1) { + if (!editedRace.title) { + newErrors.raceTitle = 'Race title required'; + isValid = false; + } + else if (editedRace.title.length < 3 || editedRace.title.length > 256) { + newErrors.raceTitle = 'Race title must be between 3 and 256 characters'; + isValid = false; + } + if (editedRace.description && editedRace.description.length > 1000) { + newErrors.raceDescription = 'Race title must be less than 1000 characters'; + isValid = false; + } + } + if (editedRace.num_winners < 1) { + newErrors.raceNumWinners = 'Must have at least one winner'; + isValid = false; + } + const numCandidates = editedRace.candidates.filter(candidate => candidate.candidate_name !== '').length + if (editedRace.num_winners > numCandidates) { + newErrors.raceNumWinners = 'Cannot have more winners than candidates'; + isValid = false; + } + if (numCandidates < 2) { + newErrors.candidates = 'Must have at least 2 candidates'; + isValid = false; + } + const uniqueCandidates = new Set(editedRace.candidates.filter(candidate => candidate.candidate_name !== '').map(candidate => candidate.candidate_name)) + if (numCandidates !== uniqueCandidates.size) { + newErrors.candidates = 'Candidates must have unique names'; + isValid = false; + } + setErrors(errors => ({ ...errors, ...newErrors })) + + // NOTE: I'm passing the element as a function so that we can delay the query until the elements have been updated + scrollToElement(() => document.querySelectorAll('.Mui-error')) + + return isValid + } + + const onAddRace = async () => { + if (!validatePage()) return false + const success = await updateElection(election => { + election.races.push({ + ...editedRace, + race_id: uuidv4() + }) + }) + if (!success) return false + await refreshElection() + setEditedRace(defaultRace) + return true + } + + const onSaveRace = async () => { + if (!validatePage()) return false + const success = await updateElection(election => { + election.races[race_index] = editedRace + }) + if (!success) return false + await refreshElection() + return true + } + + const onDeleteRace = async () => { + const confirmed = await confirm({ title: 'Confirm', message: 'Are you sure?' }) + if (!confirmed) return false + const success = await updateElection(election => { + election.races.splice(race_index, 1) + }) + if (!success) return true + await refreshElection() + return true + } + + return { editedRace, errors, setErrors, applyRaceUpdate, onSaveRace, onDeleteRace, onAddRace } + +} \ No newline at end of file diff --git a/frontend/src/components/util.js b/frontend/src/components/util.js index 1a8bde5a..b3d8d633 100644 --- a/frontend/src/components/util.js +++ b/frontend/src/components/util.js @@ -103,4 +103,10 @@ export const formatDate = (time, displayTimezone=null) => { return DateTime.fromJSDate(new Date(time)) .setZone(displayTimezone) .toLocaleString(DateTime.DATETIME_FULL) +} + +export const isValidDate = (d) => { + if (d instanceof Date) return !isNaN(d.valueOf()) + if (typeof (d) === 'string') return !isNaN(new Date(d).valueOf()) + return false } \ No newline at end of file