From 9c2bf4de5617aace58098a689a86d71a34b9819e Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:02:40 -0700
Subject: [PATCH 01/10] Adding page of templates to pick from
---
frontend/src/App.tsx | 5 +-
.../ElectionForm/CreateElectionTemplates.tsx | 219 ++++++++++++++++++
2 files changed, 222 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/components/ElectionForm/CreateElectionTemplates.tsx
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/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
From 71ad1deaa4d4a09e83a1d5267ea2b57878aa790d Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:04:18 -0700
Subject: [PATCH 02/10] Removing length validation in draft phase
---
domain_model/Election.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
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..
From 3760f9e13c5ae13b8313a0925e3a5de554f6b237 Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:06:16 -0700
Subject: [PATCH 03/10] adding UUID module
---
frontend/package-lock.json | 48 ++++++++++++++++++++++++++++----------
frontend/package.json | 1 +
2 files changed, 37 insertions(+), 12 deletions(-)
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": {
From 7f3b9b82e49fcac67684dc2d5a25fbe9830ac1ce Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:17:23 -0700
Subject: [PATCH 04/10] Moving candidate files to folder and adding dialog
---
.../components/ElectionForm/AddCandidate.tsx | 314 --------------
.../ElectionForm/Candidates/AddCandidate.tsx | 405 ++++++++++++++++++
.../{ => Candidates}/PhotoCropper.js | 0
3 files changed, 405 insertions(+), 314 deletions(-)
delete mode 100644 frontend/src/components/ElectionForm/AddCandidate.tsx
create mode 100644 frontend/src/components/ElectionForm/Candidates/AddCandidate.tsx
rename frontend/src/components/ElectionForm/{ => Candidates}/PhotoCropper.js (100%)
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
From c9e95c5cce110ad530835eadcca122b5c5e9bb41 Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:19:45 -0700
Subject: [PATCH 05/10] Moving election details files to folder and breaking
into reusable components
---
.../{ => Details}/ElectionDetails.tsx | 4 +-
.../Details/ElectionDetailsDialog.tsx | 98 ++++++++++
.../Details/ElectionDetailsForm.tsx | 177 ++++++++++++++++++
.../Details/ElectionDetailsInlineForm.tsx | 103 ++++++++++
.../ElectionForm/{ => Details}/TimeZones.ts | 0
.../Details/useEditElectionDetails.tsx | 108 +++++++++++
6 files changed, 488 insertions(+), 2 deletions(-)
rename frontend/src/components/ElectionForm/{ => Details}/ElectionDetails.tsx (99%)
create mode 100644 frontend/src/components/ElectionForm/Details/ElectionDetailsDialog.tsx
create mode 100644 frontend/src/components/ElectionForm/Details/ElectionDetailsForm.tsx
create mode 100644 frontend/src/components/ElectionForm/Details/ElectionDetailsInlineForm.tsx
rename frontend/src/components/ElectionForm/{ => Details}/TimeZones.ts (100%)
create mode 100644 frontend/src/components/ElectionForm/Details/useEditElectionDetails.tsx
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
From 16f740912708a7717e8c429715d0cb4c01d1caac Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:21:07 -0700
Subject: [PATCH 06/10] Moving races files to folder, made into lists with pop
up edit dialogs
---
.../src/components/ElectionForm/Races.tsx | 466 ------------------
.../components/ElectionForm/Races/AddRace.tsx | 48 ++
.../components/ElectionForm/Races/Race.tsx | 63 +++
.../ElectionForm/Races/RaceDialog.tsx | 49 ++
.../ElectionForm/Races/RaceForm.tsx | 297 +++++++++++
.../components/ElectionForm/Races/Races.tsx | 22 +
.../ElectionForm/Races/useEditRace.tsx | 129 +++++
7 files changed, 608 insertions(+), 466 deletions(-)
delete mode 100644 frontend/src/components/ElectionForm/Races.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/AddRace.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/Race.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/RaceDialog.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/RaceForm.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/Races.tsx
create mode 100644 frontend/src/components/ElectionForm/Races/useEditRace.tsx
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
From 1a8a4417b8faef0bdb5b249fa978d5905efa8947 Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:21:29 -0700
Subject: [PATCH 07/10] moving func to utils
---
frontend/src/components/util.js | 6 ++++++
1 file changed, 6 insertions(+)
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
From 0a9e4d18c31367867a172be764b56334ef701bd9 Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:22:52 -0700
Subject: [PATCH 08/10] Adding work in progress settings pop up dialog
---
.../ElectionForm/ElectionSettings.tsx | 294 ++++++++++++++++++
1 file changed, 294 insertions(+)
create mode 100644 frontend/src/components/ElectionForm/ElectionSettings.tsx
diff --git a/frontend/src/components/ElectionForm/ElectionSettings.tsx b/frontend/src/components/ElectionForm/ElectionSettings.tsx
new file mode 100644
index 00000000..ddc92177
--- /dev/null
+++ b/frontend/src/components/ElectionForm/ElectionSettings.tsx
@@ -0,0 +1,294 @@
+import React, { useContext, useState } from 'react'
+import Grid from "@mui/material/Grid";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import FormControl from "@mui/material/FormControl";
+import Typography from '@mui/material/Typography';
+import { Checkbox, FormGroup, FormHelperText, FormLabel, InputLabel, Radio, RadioGroup, Tooltip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Box, IconButton } from "@mui/material"
+import { StyledButton } from '../styles';
+import useElection, { ElectionContext } from '../ElectionContextProvider';
+import structuredClone from '@ungap/structured-clone';
+import EditIcon from '@mui/icons-material/Edit';
+
+export default function ElectionSettings() {
+ const { election, refreshElection, permissions, updateElection } = useElection()
+
+ const [editedElectionSettings, setEditedElectionSettings] = useState(election.settings)
+
+ // const updateVoterAccess = (voter_access) => {
+ // applyElectionUpdate(election => {
+ // election.settings.voter_access = voter_access
+ // if (voter_access === 'open') {
+ // election.settings.voter_authentication.voter_id = false
+ // election.settings.invitations = undefined
+ // }
+ // })
+ // }
+
+ const applySettingsUpdate = (updateFunc: (settings) => 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 = () => {
+ if(!validatePage()) return
+ handleClose()
+ }
+
+ return (
+
+
+
+ Extra Settings
+
+
+
+
+
+
+
+
+
+ )
+}
From 6a62f06a60c2626d8aaad9ea95d5ae5b117e6def Mon Sep 17 00:00:00 2001
From: eznarf <41272412+eznarf@users.noreply.github.com>
Date: Sun, 22 Oct 2023 17:23:25 -0700
Subject: [PATCH 09/10] Updating admin pages with new components
---
.../src/components/Election/Admin/Admin.tsx | 2 +-
.../components/Election/Admin/AdminHome.tsx | 61 ++++++++-----------
.../components/ElectionForm/ElectionForm.tsx | 6 +-
3 files changed, 28 insertions(+), 41 deletions(-)
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/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?')} /> */}
Date: Sun, 22 Oct 2023 17:47:39 -0700
Subject: [PATCH 10/10] Setting up settings page to actually save, removing
commented out code
---
.../ElectionForm/ElectionSettings.tsx | 180 +++---------------
1 file changed, 27 insertions(+), 153 deletions(-)
diff --git a/frontend/src/components/ElectionForm/ElectionSettings.tsx b/frontend/src/components/ElectionForm/ElectionSettings.tsx
index ddc92177..e783ff8f 100644
--- a/frontend/src/components/ElectionForm/ElectionSettings.tsx
+++ b/frontend/src/components/ElectionForm/ElectionSettings.tsx
@@ -14,16 +14,6 @@ export default function ElectionSettings() {
const [editedElectionSettings, setEditedElectionSettings] = useState(election.settings)
- // const updateVoterAccess = (voter_access) => {
- // applyElectionUpdate(election => {
- // election.settings.voter_access = voter_access
- // if (voter_access === 'open') {
- // election.settings.voter_authentication.voter_id = false
- // election.settings.invitations = undefined
- // }
- // })
- // }
-
const applySettingsUpdate = (updateFunc: (settings) => any) => {
const settingsCopy = structuredClone(editedElectionSettings)
updateFunc(settingsCopy)
@@ -39,18 +29,23 @@ export default function ElectionSettings() {
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
- const onSave = () => {
- if(!validatePage()) return
+ const onSave = async () => {
+ if (!validatePage()) return
+ const success = await updateElection(election => {
+ election.settings = editedElectionSettings
+ })
+ if (!success) return false
+ await refreshElection()
handleClose()
}
return (
-
+
Extra Settings
@@ -67,97 +62,8 @@ export default function ElectionSettings() {
>
Election Settings
- {/*
- Election Settings
- */}
- {/*
-
-
- Voter Access
-
-
- Who do you want to have access to your election?
-
- applySettingsUpdate(settings => settings.voter_access = e.target.value)}
- >
- } label="Open" sx={{ mb: 0, pb: 0 }} />
-
- Open to authenticated voters
-
- } label="Closed" />
-
- Restricted to a predefined list of voters
-
-
- } label="Registration" />
-
-
-
- Registered and approved
-
-
-
-
-
-
-
-
- Voter Authentication
-
- How do you want to authenticate your voters?
-
-
- applySettingsUpdate(settings => { settings.voter_authentication.ip_address = e.target.checked })}
- />
- }
- label="IP Address"
- />
-
- Limits to one vote per IP address
-
- applySettingsUpdate(settings => { settings.voter_authentication.email = e.target.checked })}
- />}
- label="Email"
- />
-
- Voters must be logged in with a validated email address
-
- applySettingsUpdate(settings => { settings.voter_authentication.voter_id = e.target.checked })}
- />}
- label="Voter ID"
- />
-
- Voters must enter a voter ID code provided to them by election host
-
-
-
-
- */}
- {/* Miscellaneous Settings */}
applySettingsUpdate(settings => { settings.invitation = e.target.checked ? 'email' : undefined })}
/>}
label="Enable Random Tie-Breakers"
/>
@@ -204,7 +109,6 @@ export default function ElectionSettings() {
id="voter-groups"
name="Enable Voter Groups"
checked={false}
- // onChange={(e) => applySettingsUpdate(settings => { settings.invitation = e.target.checked ? 'email' : undefined })}
/>}
label="Enable Voter Groups"
/>
@@ -218,12 +122,11 @@ export default function ElectionSettings() {
id="custom-email-text"
name="Custom Email Invite Text"
checked={false}
- // onChange={(e) => applySettingsUpdate(settings => { settings.invitation = e.target.checked ? 'email' : undefined })}
/>}
label="Custom Email Invite Text"
/>
- Set greetings and instructions for your email invitations.
+ Set greetings and instructions for your email invitations.
applySettingsUpdate(settings => { settings.invitation = e.target.checked ? 'email' : undefined })}
/>}
label="Require Instruction Confirmations"
/>
- Requires voters to confirm that they have read ballot instructions in order to vote.
+ Requires voters to confirm that they have read ballot instructions in order to vote.
- {/*
- {
- if (validatePage()) {
- // setPageNumber(pageNumber => pageNumber - 1)
- }
- }}>
- Back
-
-
-
-
- {
- if (validatePage()) {
- // setPageNumber(pageNumber => pageNumber + 1)
- }
- }}>
- Next
-
- */}
-
-
- Cancel
-
- onSave()}>
- Save
-
-
+
+ Cancel
+
+ onSave()}>
+ Save
+
+
)