aboutsummaryrefslogtreecommitdiffstats
path: root/src/pages/FormPage
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages/FormPage')
-rw-r--r--src/pages/FormPage/ErrorPage.tsx45
-rw-r--r--src/pages/FormPage/FormPage.tsx148
-rw-r--r--src/pages/FormPage/Navigation.tsx107
-rw-r--r--src/pages/FormPage/SuccessPage.tsx45
-rw-r--r--src/pages/FormPage/submit.ts195
5 files changed, 540 insertions, 0 deletions
diff --git a/src/pages/FormPage/ErrorPage.tsx b/src/pages/FormPage/ErrorPage.tsx
new file mode 100644
index 0000000..9a7fad9
--- /dev/null
+++ b/src/pages/FormPage/ErrorPage.tsx
@@ -0,0 +1,45 @@
+/** @jsx jsx */
+import {jsx, css} from "@emotion/react";
+import {Link} from "react-router-dom";
+import React from "react";
+
+import HeaderBar from "../../components/HeaderBar";
+
+import {Form} from "../../api/forms";
+import {selectable, submitStyles, unselectable} from "../../commonStyles";
+
+import Navigation from "./Navigation";
+
+
+interface ErrorProps {
+ form: Form
+ message: string
+}
+
+const refreshStyles = css`
+ padding: 0.55rem 4.25rem;
+`;
+
+
+export default function ErrorPage(props: ErrorProps): JSX.Element {
+ return (
+ <div>
+ <HeaderBar title={props.form.name} description={props.form.description}/>
+ <div css={[unselectable, Navigation.containerStyles]}>
+ <h3 css={selectable}>{props.message}</h3>
+ <div className={ "return_button" }>
+ <Link to="/" css={Navigation.returnStyles}>Return Home</Link>
+ </div>
+ <br css={Navigation.separatorStyles}/>
+ <div css={submitStyles}>
+ <button
+ type="button" css={refreshStyles}
+ onClick={location.reload.bind(window.location)} // TODO: State should probably be saved here
+ >
+ Refresh
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/pages/FormPage/FormPage.tsx b/src/pages/FormPage/FormPage.tsx
new file mode 100644
index 0000000..9f74410
--- /dev/null
+++ b/src/pages/FormPage/FormPage.tsx
@@ -0,0 +1,148 @@
+/** @jsx jsx */
+import {jsx, css} from "@emotion/react";
+
+import React, {createRef, SyntheticEvent, useEffect, useState} from "react";
+import {useParams} from "react-router";
+import {PropagateLoader} from "react-spinners";
+
+import HeaderBar from "../../components/HeaderBar";
+import RenderedQuestion from "../../components/Question";
+import Loading from "../../components/Loading";
+import ScrollToTop from "../../components/ScrollToTop";
+
+import {Form, FormFeatures, getForm} from "../../api/forms";
+import {OAuthScopes} from "../../api/auth";
+import colors from "../../colors";
+import {unselectable} from "../../commonStyles";
+
+import handleSubmit, {FormState} from "./submit";
+import Navigation from "./Navigation";
+import Success from "./SuccessPage";
+import ErrorPage from "./ErrorPage";
+
+
+export type RefMapType = Map<string, React.RefObject<RenderedQuestion>>;
+
+
+const formStyles = css`
+ margin: auto;
+ width: 50%;
+
+ @media (max-width: 800px) {
+ /* Make form larger on mobile and tablet screens */
+ width: 80%;
+ }
+`;
+
+const closedHeaderStyles = css`
+ margin-bottom: 2rem;
+ padding: 1rem 4rem;
+ border-radius: 8px;
+
+ text-align: center;
+ font-size: 1.5rem;
+
+ background-color: ${colors.error};
+
+ @media (max-width: 500px) {
+ padding: 1rem 1.5rem;
+ }
+`;
+
+function FormPage(): JSX.Element {
+ const {id} = useParams<{id: string}>();
+
+ const [form, setForm] = useState<Form>();
+ const [state, setState] = useState<string>(FormState.INITIAL);
+
+ const OAuthRef = createRef<HTMLDivElement>();
+
+ useEffect(() => {
+ // This can't be null due to the routing to get here
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ getForm(id!).then(form => {
+ setForm(form);
+ });
+ }, []);
+
+ if (!form) {
+ return <Loading/>;
+ }
+
+ const refMap: RefMapType = new Map();
+
+ // Authentication Logic
+ const require_auth: boolean = form.features.includes(FormFeatures.RequiresLogin);
+ const scopes: OAuthScopes[] = [];
+ if (require_auth) {
+ scopes.push(OAuthScopes.Identify);
+ if (form.features.includes(FormFeatures.CollectEmail)) {
+ scopes.push(OAuthScopes.Email);
+ }
+ }
+
+ const open: boolean = form.features.includes(FormFeatures.Open);
+ let closed_header = null;
+ if (!open) {
+ closed_header =
+ <div css={closedHeaderStyles}>This form is now closed. You will not be able to submit your response.</div>;
+ }
+
+ // FIXME: Remove this ignore
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const questions: RenderedQuestion[] = form.questions.map((question, index) => {
+ const questionRef = createRef<RenderedQuestion>();
+ refMap.set(question.id, questionRef);
+
+ return <RenderedQuestion
+ question={question}
+ scroll_ref={createRef<HTMLDivElement>()}
+ focus_ref={createRef<any>()} // eslint-disable-line @typescript-eslint/no-explicit-any
+ key={index + Date.now()}
+ selfRef={questionRef}
+ ref={questionRef}
+ />;
+ });
+
+ switch (state) {
+ case FormState.SENT:
+ return <Success form={form}/>;
+ case FormState.SENDING:
+ return (
+ <div>
+ <HeaderBar title={"Submitting..."}/>
+ <div css={{display: "flex", justifyContent: "center", paddingTop: "40px"}}>
+ <PropagateLoader color="white"/>
+ </div>
+ </div>
+ );
+
+ case FormState.UNKNOWN_ERROR:
+ return <ErrorPage
+ form={form}
+ message="An unknown error occurred, please contact the forms team or try again."
+ />;
+ }
+
+ const handler = (event: SyntheticEvent) => handleSubmit(event, form.id, questions, refMap, setState, OAuthRef, scopes);
+
+ return (
+ <div>
+ <HeaderBar title={form.name} description={form.description}/>
+
+ <div>
+ <form id="form" onSubmit={handler} css={[formStyles, unselectable]}>
+ {closed_header}
+ {questions}
+ </form>
+ <Navigation form_state={open} scopes={scopes}/>
+ </div>
+
+ <div css={css`margin-bottom: 10rem`}/>
+ <ScrollToTop/>
+ </div>
+ );
+}
+
+export default FormPage;
diff --git a/src/pages/FormPage/Navigation.tsx b/src/pages/FormPage/Navigation.tsx
new file mode 100644
index 0000000..52cd47e
--- /dev/null
+++ b/src/pages/FormPage/Navigation.tsx
@@ -0,0 +1,107 @@
+/** @jsx jsx */
+import {jsx, css} from "@emotion/react";
+
+import React from "react";
+import {Link} from "react-router-dom";
+
+import colors from "../../colors";
+import {submitStyles, unselectable} from "../../commonStyles";
+
+import {checkScopes, OAuthScopes} from "../../api/auth";
+import OAuth2Button from "../../components/OAuth2Button";
+
+
+interface NavigationProps {
+ form_state: boolean, // Whether the form is open or not
+ scopes: OAuthScopes[]
+}
+
+export default class Navigation extends React.Component<NavigationProps> {
+ static containerStyles = css`
+ margin: auto;
+ width: 50%;
+
+ text-align: center;
+ font-size: 1.5rem;
+
+ > div {
+ display: inline-block;
+ margin: 2rem auto;
+ width: 50%;
+ }
+
+ @media (max-width: 870px) {
+ width: 100%;
+
+ > div {
+ display: flex;
+ justify-content: center;
+
+ margin: 0 auto;
+ }
+ }
+
+ .return_button {
+ text-align: left;
+ }
+
+ .return_button.closed {
+ text-align: center;
+ }
+ `;
+
+ static separatorStyles = css`
+ height: 0;
+ display: none;
+
+ @media (max-width: 870px) {
+ display: block;
+ }
+ `;
+
+ static returnStyles = css`
+ padding: 0.5rem 2.2rem;
+ border-radius: 8px;
+
+ color: white;
+ text-decoration: none;
+ white-space: nowrap;
+
+ background-color: ${colors.greyple};
+ transition: background-color 300ms;
+
+ :hover {
+ background-color: ${colors.darkerGreyple};
+ }
+ `;
+
+ constructor(props: NavigationProps) {
+ super(props);
+ this.state = {"logged_in": false};
+ }
+
+ render(): JSX.Element {
+ let submit = null;
+
+ if (this.props.form_state) {
+ let inner_submit;
+ if (this.props.scopes.includes(OAuthScopes.Identify) && !checkScopes(this.props.scopes)) {
+ // Render OAuth button if login is required, and the scopes needed are not available
+ inner_submit = <OAuth2Button scopes={this.props.scopes} rerender={() => this.setState({"logged_in": true})}/>;
+ } else {
+ inner_submit = <button form="form" type="submit">Submit</button>;
+ }
+ submit = <div css={submitStyles}>{ inner_submit }</div>;
+ }
+
+ return (
+ <div css={[unselectable, Navigation.containerStyles]}>
+ <div className={ "return_button" + (this.props.form_state ? "" : " closed") }>
+ <Link to="/" css={Navigation.returnStyles}>Return Home</Link>
+ </div>
+ <br css={Navigation.separatorStyles}/>
+ { submit }
+ </div>
+ );
+ }
+}
diff --git a/src/pages/FormPage/SuccessPage.tsx b/src/pages/FormPage/SuccessPage.tsx
new file mode 100644
index 0000000..e35bd4d
--- /dev/null
+++ b/src/pages/FormPage/SuccessPage.tsx
@@ -0,0 +1,45 @@
+/** @jsx jsx */
+import {jsx, css} from "@emotion/react";
+import {Link} from "react-router-dom";
+
+import {Form} from "../../api/forms";
+import HeaderBar from "../../components/HeaderBar";
+import {unselectable} from "../../commonStyles";
+
+import Navigation from "./Navigation";
+
+
+interface SuccessProps {
+ form: Form
+}
+
+const thanksStyle = css`
+ font-family: "Uni Sans", "Hind", "Arial", sans-serif;
+ margin-top: 15.5rem;
+`;
+
+const divStyle = css`
+ width: 80%;
+`;
+
+export default function Success(props: SuccessProps): JSX.Element {
+ let submitted_text;
+ if (props.form.submitted_text) {
+ submitted_text = props.form.submitted_text.split("\n").map((line, index) => <span key={index}>{line}<br/></span>);
+ submitted_text.push(<span key={submitted_text.length - 1}>{submitted_text.pop()?.props.children[0]}</span>);
+ } else {
+ submitted_text = "Thanks for your response!";
+ }
+
+ return (
+ <div>
+ <HeaderBar title={props.form.name} description={props.form.description}/>
+ <div css={[unselectable, Navigation.containerStyles, divStyle]}>
+ <h3 css={thanksStyle}>{submitted_text}</h3>
+ <div className={"return_button closed"}>
+ <Link to="/" css={Navigation.returnStyles}>Return Home</Link>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/pages/FormPage/submit.ts b/src/pages/FormPage/submit.ts
new file mode 100644
index 0000000..aee127f
--- /dev/null
+++ b/src/pages/FormPage/submit.ts
@@ -0,0 +1,195 @@
+import React, {SyntheticEvent} from "react";
+import * as Sentry from "@sentry/react";
+
+import RenderedQuestion from "../../components/Question";
+import {RefMapType} from "./FormPage";
+
+import ApiClient from "../../api/client";
+import {Question, QuestionType, UnittestFailure} from "../../api/question";
+import {checkScopes, OAuthScopes} from "../../api/auth";
+
+
+export enum FormState {
+ INITIAL = "initial",
+ SENDING = "sending",
+ SENT = "sent",
+ VALIDATION_ERROR = "validation_error",
+ UNKNOWN_ERROR = "error",
+}
+
+
+/**
+ * Handle validation and submission of a form.
+ *
+ * @param event The submission event.
+ * @param formID The form ID.
+ * @param questions A list of :RenderedQuestion: elements.
+ * @param refMap A map of question ID to object refs.
+ * @param setState A consumer which marks the current state of the form.
+ * @param OAuthRef A reference to the OAuth button to scroll to if the user is not logged in.
+ * @param scopes The OAuth scopes required to submit the form.
+ */
+export default async function handleSubmit(
+ event: SyntheticEvent,
+ formID: string,
+ questions: RenderedQuestion[],
+ refMap: RefMapType,
+ setState: (state: string) => void,
+ OAuthRef: React.RefObject<HTMLDivElement>,
+ scopes: OAuthScopes[]
+): Promise<void> {
+ try {
+ event.preventDefault();
+
+ if (scopes.length && !checkScopes(scopes)) {
+ // The form requires certain scopes, but the user is not logged in
+ if (!OAuthRef.current) {
+ Sentry.captureMessage("OAuthRef was not set, could not scroll to the button.");
+ } else {
+ OAuthRef.current.scrollIntoView({behavior: "smooth", block: "end"});
+ }
+
+ return;
+ }
+
+ if (!validate(questions, refMap)) {
+ return;
+ }
+
+ // FIXME: Save state while sending
+ // setState(FormState.SENDING);
+
+ await ApiClient.post(`forms/submit/${formID}`, {response: parseAnswers(questions, refMap)})
+ .then(() => setState(FormState.SENT))
+ .catch(error => {
+ if (!error.response) {
+ throw error;
+ }
+
+ switch (error.response.status) {
+ case 422:
+ // TODO: Re-enable this once we have better state management
+ // setState(FormState.VALIDATION_ERROR);
+ showUnitTestFailures(refMap, error.response.data);
+ break;
+
+ case 500:
+ default:
+ throw error;
+ }
+ });
+
+ } catch (e) {
+ // Send exception to sentry, and display an error page
+ Sentry.captureException(e);
+ console.error(e);
+ setState(FormState.UNKNOWN_ERROR);
+ }
+}
+
+
+/**
+ * Parse submission errors on unittests, and set up the environment for displaying the errors.
+ */
+function showUnitTestFailures(refMap: RefMapType, errors: UnittestFailure) {
+ for (const error of errors.test_results) {
+ const questionRef = refMap.get(error.question_id);
+
+ if (!questionRef?.current) {
+ throw new Error("Could not find question reference while verifying unittest failure.");
+ }
+
+ questionRef.current.setState({
+ valid: false,
+ unittestsFailed: true,
+ testFailure: error.return_code === 0,
+ error: error.result
+ });
+ }
+}
+
+/**
+ * Run client side validation.
+ */
+function validate(questions: RenderedQuestion[], refMap: RefMapType): boolean {
+ const invalidFieldIDs: number[] = [];
+ questions.forEach((prop, i) => {
+ const question: Question = prop.props.question;
+ if (!question.required) {
+ return;
+ }
+
+ // Add invalid fields to list
+ const questionRef = refMap.get(question.id);
+ if (questionRef && questionRef.current) {
+ questionRef.current.validateField();
+
+ if (!questionRef.current.realState.valid) {
+ invalidFieldIDs.push(i);
+ }
+
+ } else {
+ invalidFieldIDs.push(i);
+ }
+ });
+
+ if (invalidFieldIDs.length) {
+ const firstErrored = questions[invalidFieldIDs[0]];
+ if (firstErrored && firstErrored.props.scroll_ref) {
+ // If any element is already focused, unfocus it to avoid not scrolling behavior.
+ if (document.activeElement && document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+
+ firstErrored.props.scroll_ref?.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ if (firstErrored.props.focus_ref && firstErrored.props.focus_ref.current) {
+ firstErrored.props.focus_ref.current.focus({ preventScroll: true });
+ }
+ }
+
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Parse user answers into a valid submission.
+ */
+function parseAnswers(questions: RenderedQuestion[], refMap: RefMapType): { [key: string]: unknown } {
+ const answers: { [key: string]: unknown } = {};
+
+ questions.forEach(prop => {
+ const question: Question = prop.props.question;
+ const questionRef = refMap.get(question.id);
+
+ if (!questionRef?.current) {
+ throw new Error("Could not find a reference to the current question while submitting.");
+ }
+
+ // Parse input from each question
+ switch (question.type) {
+ case QuestionType.Section:
+ answers[question.id] = false;
+ break;
+ case QuestionType.Checkbox: {
+ const result: {[key: string]: boolean} = {};
+
+ const selected = questionRef.current.realState.value;
+ if (!(selected instanceof Map)) throw new Error("Could not parse checkbox answers.");
+ selected.forEach((value, key) => {
+ // Remove the index from the key and set its value
+ result[key.slice(6)] = value;
+ });
+
+ answers[question.id] = result;
+ break;
+ }
+
+ default:
+ answers[question.id] = questionRef.current.realState.value;
+ }
+ });
+
+ return answers;
+}