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 (
+
+ )
+}
+
+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'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ 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
+
+
+
+
+
+
+
+
+
+ )
+}
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 (
+
+ )
+}
\ 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