From 8e9b0d89eb8e1df0156e23f8ce0245ebe55506ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20BOIDET?= Date: Wed, 9 Sep 2020 09:01:15 +0200 Subject: [PATCH] updates (#58) --- src/components/views/CreateElection.jsx | 896 ++++++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 src/components/views/CreateElection.jsx diff --git a/src/components/views/CreateElection.jsx b/src/components/views/CreateElection.jsx new file mode 100644 index 0000000..b4940dc --- /dev/null +++ b/src/components/views/CreateElection.jsx @@ -0,0 +1,896 @@ +/* eslint react/prop-types: 0 */ +import React, { Component } from "react"; +import { Redirect, withRouter } from "react-router-dom"; +import { + Collapse, + Container, + Row, + Col, + Input, + Label, + InputGroup, + InputGroupAddon, + Button, + Card, + CardBody +} from "reactstrap"; +import { withTranslation } from "react-i18next"; +import { ReactMultiEmail, isEmail } from "react-multi-email"; +import "react-multi-email/style.css"; +import { toast, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { resolve } from "url"; +import queryString from "query-string"; +import { + arrayMove, + sortableContainer, + sortableElement, + sortableHandle +} from "react-sortable-hoc"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPlus, + faTrashAlt, + faCheck, + faCogs, + faExclamationTriangle +} from "@fortawesome/free-solid-svg-icons"; +import { i18nGrades } from "../../Util"; +import { AppContext } from "../../AppContext"; +import HelpButton from "../form/HelpButton"; +import ButtonWithConfirm from "../form/ButtonWithConfirm"; +import Loader from "../wait"; +import i18n from "../../i18n"; + +// Error messages +const AT_LEAST_2_CANDIDATES_ERROR = "Please add at least 2 candidates."; +const NO_TITLE_ERROR = "Please add a title."; + +const isValidDate = date => date instanceof Date && !isNaN(date); +const getOnlyValidDate = date => (isValidDate(date) ? date : new Date()); + +// Convert a Date object into YYYY-MM-DD +const dateToISO = date => + getOnlyValidDate(date) + .toISOString() + .substring(0, 10); + +// Retrieve the current hour, minute, sec, ms, time into a timestamp +const hours = date => getOnlyValidDate(date).getHours() * 3600 * 1000; +const minutes = date => getOnlyValidDate(date).getMinutes() * 60 * 1000; +const seconds = date => getOnlyValidDate(date).getSeconds() * 1000; +const ms = date => getOnlyValidDate(date).getMilliseconds(); +const time = date => + hours(getOnlyValidDate(date)) + + minutes(getOnlyValidDate(date)) + + seconds(getOnlyValidDate(date)) + + ms(getOnlyValidDate(date)); + +// Retrieve the time part from a timestamp and remove the day. Return a int. +const timeMinusDate = date => time(getOnlyValidDate(date)); + +// Retrieve the day and remove the time. Return a Date +const dateMinusTime = date => + new Date(getOnlyValidDate(date).getTime() - time(getOnlyValidDate(date))); + +const DragHandle = sortableHandle(({ children }) => ( + {children} +)); + +const displayClockOptions = () => + Array(24) + .fill(1) + .map((x, i) => ( + + )); + +const SortableCandidate = sortableElement( + ({ candidate, sortIndex, form, t }) => ( +
  • + + + + + + {sortIndex + 1} + + + form.editCandidateLabel(event, sortIndex)} + onKeyPress={event => + form.handleKeypressOnCandidateLabel(event, sortIndex) + } + placeholder={t("Candidate/proposal name...")} + tabIndex={sortIndex + 1} + innerRef={ref => (form.candidateInputs[sortIndex] = ref)} + maxLength="250" + /> + +
    + +
    +
    {t("Delete?")}
    +
    + {t("Are you sure to delete")}{" "} + {candidate.label !== "" ? ( + "{candidate.label}" + ) : ( + + {t("the row")} {sortIndex + 1} + + )}{" "} + ? +
    +
    form.removeCandidate(sortIndex)} + > + Oui +
    +
    Non
    +
    +
    + + + + {t( + "Enter the name of your candidate or proposal here (250 characters max.)" + )} + + +
    +
  • + ) +); + +const SortableCandidatesContainer = sortableContainer(({ items, form, t }) => { + return ( + + ); +}); + +class CreateElection extends Component { + static contextType = AppContext; + constructor(props) { + super(props); + // default value : start at the last hour + const now = new Date(); + const start = new Date( + now.getTime() - minutes(now) - seconds(now) - ms(now) + ); + const { title } = queryString.parse(this.props.location.search); + + this.state = { + candidates: [{ label: "" }, { label: "" }], + title: title || "", + isVisibleTipsDragAndDropCandidate: true, + numGrades: 7, + waiting: false, + successCreate: false, + redirectTo: null, + isAdvancedOptionsOpen: false, + restrictResult: false, + isTimeLimited: false, + start, + // by default, the election ends in a week + finish: new Date(start.getTime() + 7 * 24 * 3600 * 1000), + electorEmails: [] + }; + this.candidateInputs = []; + this.focusInput = React.createRef(); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleRestrictResultCheck = this.handleRestrictResultCheck.bind(this); + this.handleIsTimeLimited = this.handleIsTimeLimited.bind(this); + } + + handleChangeTitle = event => { + this.setState({ title: event.target.value }); + }; + + handleIsTimeLimited = event => { + this.setState({ isTimeLimited: event.target.value === "1" }); + }; + + handleRestrictResultCheck = event => { + this.setState({ restrictResult: event.target.value === "1" }); + }; + + addCandidate = event => { + let candidates = this.state.candidates; + if (candidates.length < 100) { + candidates.push({ label: "" }); + this.setState({ candidates: candidates }); + } + if (event.type === "keypress") { + setTimeout(() => { + this.candidateInputs[this.state.candidates.length - 1].focus(); + }, 250); + } + }; + + removeCandidate = index => { + let candidates = this.state.candidates; + candidates.splice(index, 1); + if (candidates.length === 0) { + candidates = [{ label: "" }]; + } + this.setState({ candidates: candidates }); + }; + + editCandidateLabel = (event, index) => { + let candidates = this.state.candidates; + candidates[index].label = event.currentTarget.value; + candidates.map(candidate => { + return candidate.label; + }); + this.setState({ + candidates: candidates + }); + }; + + handleKeypressOnCandidateLabel = (event, index) => { + if (event.key === "Enter") { + event.preventDefault(); + if (index + 1 === this.state.candidates.length) { + this.addCandidate(event); + } else { + this.candidateInputs[index + 1].focus(); + } + } + }; + + onCandidatesSortEnd = ({ oldIndex, newIndex }) => { + let candidates = this.state.candidates; + candidates = arrayMove(candidates, oldIndex, newIndex); + this.setState({ candidates: candidates }); + }; + + handleChangeNumGrades = event => { + this.setState({ numGrades: event.target.value }); + }; + + toggleAdvancedOptions = () => { + this.setState({ isAdvancedOptionsOpen: !this.state.isAdvancedOptionsOpen }); + }; + + checkFields() { + const { candidates, title } = this.state; + if (!candidates) { + return { ok: false, msg: AT_LEAST_2_CANDIDATES_ERROR }; + } + + let numCandidates = 0; + candidates.forEach(c => { + if (c.label !== "") numCandidates += 1; + }); + if (numCandidates < 2) { + return { ok: false, msg: AT_LEAST_2_CANDIDATES_ERROR }; + } + + if (!title || title === "") { + return { ok: false, msg: NO_TITLE_ERROR }; + } + + return { ok: true, msg: "OK" }; + } + + handleSubmit() { + const { + candidates, + title, + numGrades, + electorEmails + } = this.state; + + let { + start, + finish, + } = this.state; + + const endpoint = resolve( + this.context.urlServer, + this.context.routesServer.setElection + ); + + if(!this.state.isTimeLimited){ + let now = new Date(); + start = new Date( + now.getTime() - minutes(now) - seconds(now) - ms(now) + ); + finish=new Date(start.getTime() + 10 * 365 * 24 * 3600 * 1000); + } + + const { t } = this.props; + const locale = + i18n.language.substring(0, 2).toLowerCase() === "fr" ? "fr" : "en"; + + const check = this.checkFields(); + if (!check.ok) { + toast.error(t(check.msg), { + position: toast.POSITION.TOP_CENTER + }); + return; + } + + this.setState({ waiting: true }); + + fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + title: title, + candidates: candidates.map(c => c.label).filter(c => c !== ""), + on_invitation_only: electorEmails.length > 0, + num_grades: numGrades, + elector_emails: electorEmails, + start_at: start.getTime() / 1000, + finish_at: finish.getTime() / 1000, + select_language: locale, + front_url: window.location.origin, + restrict_result: this.state.restrictResult + }) + }) + .then(response => response.json()) + .then(result => { + if (result.id) { + const nextPage = + electorEmails && electorEmails.length + ? `/link/${result.id}` + : `/links/${result.id}`; + this.setState(() => ({ + redirectTo: nextPage, + successCreate: true, + waiting: false + })); + } else { + toast.error(t("Unknown error. Try again please."), { + position: toast.POSITION.TOP_CENTER + }); + this.setState({ waiting: false }); + } + }) + .catch(error => error); + } + + handleSendNotReady = msg => { + const { t } = this.props; + toast.error(t(msg), { + position: toast.POSITION.TOP_CENTER + }); + }; + + render() { + const { + successCreate, + redirectTo, + waiting, + title, + start, + finish, + candidates, + numGrades, + isAdvancedOptionsOpen, + electorEmails + } = this.state; + const { t } = this.props; + + const grades = i18nGrades(); + const check = this.checkFields(); + + if (successCreate) return ; + + return ( + + + {waiting ? : ""} +
    + + +

    {t("Start an election")}

    + +
    +
    + + + + + + + + + + {t( + "Write here your question or introduce simple your election (250 characters max.)" + )} +
    + {t("For example:")}{" "} + + {t( + "For the role of my representative, I judge this candidate..." + )} + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + +
    + + + - {t("Starting date")} + + + { + this.setState({ + start: new Date( + timeMinusDate(start) + + new Date(e.target.valueAsNumber).getTime() + ) + }); + }} + /> + + + + + + + + + - {t("Ending date")} + + + { + this.setState({ + finish: new Date( + timeMinusDate(finish) + + new Date(e.target.valueAsNumber).getTime() + ) + }); + }} + /> + + + + + +
    +
    + + + {t("Grades")} + + + + + + + {t( + "You can select here the number of grades for your election" + )} +
    + {t("For example:")}{" "} + + {" "} + {t("5 = Excellent, Very good, Good, Fair, Passable")} + +
    + + + {grades.map((mention, i) => { + return ( + + {mention.label} + + ); + })} + +
    +
    + + + {t("Participants")} + + + { + this.setState({ electorEmails: _emails }); + }} + validateEmail={email => { + return isEmail(email); // return boolean + }} + getLabel={(email, index, removeEmail) => { + return ( +
    + {email} + removeEmail(index)} + > + × + +
    + ); + }} + /> +
    + + {t( + "If you list voters' emails, only them will be able to access the election" + )} + +
    + +
    +
    +
    +
    +
    + + + {check.ok ? ( + +
    + + {t("Validate")} +
    +
    + {t("Confirm your vote")} +
    +
    +
    +
    + {t("Question of the election")} +
    +
    {title}
    +
    + {t("Candidates/Proposals")} +
    +
    +
      + {candidates.map((candidate, i) => { + if (candidate.label !== "") { + return ( +
    • + {candidate.label} +
    • + ); + } else { + return
    • ; + } + })} +
    +
    +
    +
    + {t("Dates")} +
    +
    + {t("The election will take place from")}{" "} + + {start.toLocaleDateString()}, {t("at")}{" "} + {start.toLocaleTimeString()} + {" "} + {t("to")}{" "} + + {finish.toLocaleDateString()}, {t("at")}{" "} + {finish.toLocaleTimeString()} + +
    +
    +
    + {t("Grades")} +
    +
    + {grades.map((mention, i) => { + return i < numGrades ? ( + + {mention.label} + + ) : ( + + ); + })} +
    +
    + {t("Voters' list")} +
    +
    + {electorEmails.length > 0 ? ( + electorEmails.join(", ") + ) : ( +

    + {t("The form contains no address.")} +
    + + {t( + "The election will be opened to anyone with the link" + )} + +

    + )} +
    + {this.state.restrictResult ? ( +
    +
    +
    + + {t("Results available at the close of the vote")} +
    +

    + {electorEmails.length > 0 ? ( + + {t( + "The results page will not be accessible until all participants have voted." + )} + + ) : ( + + {t( + "The results page will not be accessible until the end date is reached." + )}{" "} + ({finish.toLocaleDateString()} {t("at")}{" "} + {finish.toLocaleTimeString()}) + + )} +

    +
    +
    + ) : null} +
    +
    +
    + {t("Start the election")} +
    +
    {t("Cancel")}
    +
    + ) : ( + + )} + +
    +
    +
    + ); + } +} + +export default withTranslation()(withRouter(CreateElection)); \ No newline at end of file