aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx2
-rw-r--r--src/api/question.ts13
-rw-r--r--src/components/ErrorMessage.tsx18
-rw-r--r--src/components/InputTypes/Select.tsx24
-rw-r--r--src/components/InputTypes/index.tsx35
-rw-r--r--src/components/Question.tsx232
-rw-r--r--src/pages/FormPage.tsx347
-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
-rw-r--r--src/tests/pages/FormPage.test.tsx2
13 files changed, 720 insertions, 493 deletions
diff --git a/src/App.tsx b/src/App.tsx
index 752a6c6..5430e40 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -12,7 +12,7 @@ import { CSSTransition, TransitionGroup } from "react-transition-group";
import globalStyles from "./globalStyles";
const LandingPage = React.lazy(() => import("./pages/LandingPage"));
-const FormPage = React.lazy(() => import("./pages/FormPage"));
+const FormPage = React.lazy(() => import("./pages/FormPage/FormPage"));
const CallbackPage = React.lazy(() => import("./pages/CallbackPage"));
const routes = [
diff --git a/src/api/question.ts b/src/api/question.ts
index 9824b60..a9a4d4a 100644
--- a/src/api/question.ts
+++ b/src/api/question.ts
@@ -16,3 +16,16 @@ export interface Question {
data: { [key: string]: string | string[] },
required: boolean
}
+
+type UnittestError = {
+ question_id: string,
+ question_index: number,
+ return_code: number,
+ passed: boolean,
+ result: string,
+}
+
+export interface UnittestFailure {
+ error: string,
+ test_results: UnittestError[],
+}
diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx
index 650100d..6151603 100644
--- a/src/components/ErrorMessage.tsx
+++ b/src/components/ErrorMessage.tsx
@@ -1,10 +1,11 @@
/** @jsx jsx */
-import { jsx, css } from "@emotion/react";
+import {jsx, css} from "@emotion/react";
import colors from "../colors";
+import {selectable} from "../commonStyles";
interface ErrorMessageProps {
show: boolean,
- message: string
+ content: string | JSX.Element,
}
export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | null {
@@ -13,12 +14,23 @@ export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | nu
font-size: 1.15rem;
line-height: 1.1rem;
margin: 1rem 0 0;
+
visibility: ${props.show ? "visible" : "hidden"};
+ opacity: ${props.show ? 1 : 0};
+ transition: opacity 200ms, visibility 200ms;
+ `;
+
+ // These styles are not applied when content is an element;
+ const floatingStyles = css`
position: absolute;
z-index: -1;
`;
+ const isString = typeof props.content === "string";
+
return (
- <p css={styles}>{props.message}</p>
+ <div tabIndex={-1} css={[styles, selectable, isString ? floatingStyles : null]}>
+ {props.content}
+ </div>
);
}
diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx
index 16049a8..121e2ff 100644
--- a/src/components/InputTypes/Select.tsx
+++ b/src/components/InputTypes/Select.tsx
@@ -1,12 +1,14 @@
/** @jsx jsx */
-import { jsx, css } from "@emotion/react";
+import {jsx, css} from "@emotion/react";
import React from "react";
-import { hiddenInput, invalidStyles } from "../../commonStyles";
+
+import {hiddenInput, invalidStyles} from "../../commonStyles";
+import RenderedQuestion from "../Question";
interface SelectProps {
options: Array<string>,
- state_dict: Map<string, string | boolean | null>,
valid: boolean,
+ question: React.RefObject<RenderedQuestion>
onBlurHandler: () => void
}
@@ -159,8 +161,12 @@ class Select extends React.Component<SelectProps> {
return;
}
+ if (!this.props.question?.current) {
+ throw new Error("Missing ref for select question.");
+ }
+
// Update stored value
- this.props.state_dict.set("value", option_container.textContent);
+ this.props.question.current.setState({value: option_container.textContent});
// Close the menu
selected_option.current.focus();
@@ -187,10 +193,14 @@ class Select extends React.Component<SelectProps> {
}
focusOption(): void {
- if (!this.props.state_dict.get("value")) {
- this.props.state_dict.set("value", "temporary");
+ if (!this.props.question?.current) {
+ throw new Error("Missing ref for select question.");
+ }
+
+ if (!this.props.question.current.realState.value) {
+ this.props.question.current.setState({value: "temporary"});
this.props.onBlurHandler();
- this.props.state_dict.set("value", null);
+ this.props.question.current.setState({value: null});
}
}
diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx
index 3e13652..0973383 100644
--- a/src/components/InputTypes/index.tsx
+++ b/src/components/InputTypes/index.tsx
@@ -5,33 +5,26 @@ import Select from "./Select";
import ShortText from "./ShortText";
import TextArea from "./TextArea";
-import React, { ChangeEvent } from "react";
+import React, {ChangeEvent} from "react";
-import { QuestionType } from "../../api/question";
-import { QuestionProp } from "../Question";
+import {QuestionType} from "../../api/question";
+import RenderedQuestion from "../Question";
import Code from "./Code";
-const require_options: Array<QuestionType> = [
- QuestionType.Radio,
- QuestionType.Checkbox,
- QuestionType.Select,
- QuestionType.Range
-];
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => void, onBlurHandler: () => void, focus_ref: React.RefObject<any>): JSX.Element | JSX.Element[] {
+export default function create_input(
+ {props: renderedQuestionProps, realState}: RenderedQuestion,
+ handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => void, onBlurHandler: () => void,
+ focus_ref: React.RefObject<any> // eslint-disable-line @typescript-eslint/no-explicit-any
+): JSX.Element | JSX.Element[] {
let result: JSX.Element | JSX.Element[];
- // eslint-disable-next-line
- // @ts-ignore
- let options: string[] = question.data["options"];
- let valid = true;
- if (!public_state.get("valid")) {
- valid = false;
- }
+ const question = renderedQuestionProps.question;
+ const valid = realState.valid;
+
+ let options = question.data["options"];
// Catch input types that require options but don't have any
- if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) {
+ if (!(options instanceof Array)) {
// TODO: Implement some sort of warning here
options = [];
}
@@ -55,7 +48,7 @@ export default function create_input({ question, public_state }: QuestionProp, h
break;
case QuestionType.Select:
- result = <Select options={options} state_dict={public_state} valid={valid} onBlurHandler={onBlurHandler}/>;
+ result = <Select question={renderedQuestionProps.selfRef} valid={valid} options={options} onBlurHandler={onBlurHandler}/>;
break;
case QuestionType.ShortText:
diff --git a/src/components/Question.tsx b/src/components/Question.tsx
index 61e66e0..a5a71c5 100644
--- a/src/components/Question.tsx
+++ b/src/components/Question.tsx
@@ -15,15 +15,40 @@ const skip_normal_state: Array<QuestionType> = [
QuestionType.Range
];
+export interface QuestionState {
+ // Common keys
+ value: string | null | Map<string, boolean>
+
+ // Validation
+ valid: boolean
+ error: string
+
+ // Unittest-specific validation
+ unittestsFailed: boolean // This indicates a failure in testing when submitting (i.e not from common validation)
+ testFailure: boolean // Whether we had failed unittests, or other failures, such as code loading
+}
+
export type QuestionProp = {
question: Question,
- public_state: Map<string, string | boolean | null>,
scroll_ref: React.RefObject<HTMLDivElement>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- focus_ref: React.RefObject<any>
+ focus_ref: React.RefObject<any>,
+ selfRef: React.RefObject<RenderedQuestion>,
}
class RenderedQuestion extends React.Component<QuestionProp> {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore: TS2610
+ state: QuestionState;
+
+ /** The current state of the question components, which may or may not match the state rendered. */
+ public realState: QuestionState;
+
+ setState(state: Partial<QuestionState>): void {
+ this.realState = {...this.realState, ...state};
+ super.setState(state);
+ }
+
constructor(props: QuestionProp) {
super(props);
if (props.question.type === QuestionType.TextArea) {
@@ -35,28 +60,16 @@ class RenderedQuestion extends React.Component<QuestionProp> {
}
this.blurHandler = this.blurHandler.bind(this);
- const _state: {[key: string]: string | boolean | null} = {
- "valid": true,
- "error": "",
- };
-
- if (props.question.type === QuestionType.Code) {
- _state["unittestsFailed"] = false;
- }
-
- if (!skip_normal_state.includes(props.question.type)) {
- _state["value"] = "";
- }
+ this.state = {
+ value: skip_normal_state.includes(props.question.type) ? null : "",
+ valid: true,
+ error: "",
- this.state = _state;
- for (const [key, value] of Object.entries(_state)) {
- this.props.public_state.set(key, value);
- }
- }
+ unittestsFailed: false,
+ testFailure: false,
+ };
- setPublicState(target: string, value: string | boolean | null, callback?:() => void): void {
- this.props.public_state.set(target, value);
- this.setState({[target]: value}, callback);
+ this.realState = this.state;
}
// This is here to allow dynamic selection between the general handler, textarea, and code field handlers.
@@ -64,45 +77,46 @@ class RenderedQuestion extends React.Component<QuestionProp> {
blurHandler(): void {
if (this.props.question.required) {
- if (!this.props.public_state.get("value")) {
- this.setPublicState("error", "Field must be filled.");
- this.setPublicState("valid", false);
+ if (!this.realState.value) {
+ this.setState({
+ error: "Field must be filled.",
+ valid: false
+ });
} else {
- this.setPublicState("error", "");
- this.setPublicState("valid", true);
+ this.setState({
+ error: "",
+ valid: true,
+ unittestsFailed: false
+ });
}
}
}
normal_handler(event: ChangeEvent<HTMLInputElement>): void {
- let target: string;
- let value: string | boolean;
-
switch (event.target.type) {
case "checkbox":
- target = event.target.name;
- value = event.target.checked;
+ if (!(this.realState.value instanceof Map)) return;
+ this.realState.value.set(event.target.name, event.target.checked);
break;
- case "radio":
- // This handles radios and ranges, as they are both based on the same fundamental input type
- target = "value";
+ case "radio": {
+ // This handles radios and ranges, as they are both based on the same fundamental input type
+ let value;
if (event.target.parentElement) {
value = event.target.parentElement.innerText.trimEnd();
} else {
value = event.target.value;
}
+ this.setState({value: value});
break;
+ }
default:
- target = "value";
- value = event.target.value;
+ this.setState({value: event.target.value});
}
- this.setPublicState(target, value);
-
// Toggle checkbox class
- if (event.target.type == "checkbox" && event.target.parentElement !== null) {
+ if (event.target.type === "checkbox" && event.target.parentElement !== null) {
event.target.parentElement.classList.toggle("unselected");
event.target.parentElement.classList.toggle("selected");
}
@@ -110,49 +124,48 @@ class RenderedQuestion extends React.Component<QuestionProp> {
const options: string | string[] = this.props.question.data["options"];
switch (event.target.type) {
case "text":
- this.setPublicState("valid", true);
+ this.setState({valid: true});
break;
case "checkbox":
// We need to check this here, because checkbox doesn't have onBlur
if (this.props.question.required && typeof options !== "string") {
- const keys: string[] = [];
- options.forEach((val, index) => {
- keys.push(`${("000" + index).slice(-4)}. ${val}`);
+ if (!(this.realState.value instanceof Map)) return;
+ const valid = Array.from(this.realState.value.values()).includes(true);
+ this.setState({
+ error: valid ? "" : "Field must be filled.",
+ valid: valid
});
- if (keys.every(v => !this.props.public_state.get(v))) {
- this.setPublicState("error", "Field must be filled.");
- this.setPublicState("valid", false);
- } else {
- this.setPublicState("error", "");
- this.setPublicState("valid", true);
- }
}
+
break;
case "radio":
- this.setPublicState("valid", true);
- this.setPublicState("error", "");
+ this.setState({
+ valid: true,
+ error: ""
+ });
break;
}
}
text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void {
// We will validate again when focusing out.
- this.setPublicState("valid", true);
- this.setPublicState("error", "");
-
- this.setPublicState("value", event.target.value);
+ this.setState({
+ value: event.target.value,
+ valid: true,
+ error: ""
+ });
}
code_field_handler(newContent: string): void {
// If content stays same (what means that user have just zoomed in), then don't validate.
let validate = false;
- if (newContent != this.props.public_state.get("value")) {
+ if (newContent != this.realState.value) {
validate = true;
}
- this.setPublicState("value", newContent);
+ this.setState({value: newContent});
// CodeMirror don't provide onBlur event, so we have to run validation here.
if (validate) {
@@ -165,78 +178,74 @@ class RenderedQuestion extends React.Component<QuestionProp> {
return;
}
- let invalid = false;
- let unittest_failed = false;
+ let valid = true;
const options: string | string[] = this.props.question.data["options"];
+
switch (this.props.question.type) {
case QuestionType.TextArea:
case QuestionType.ShortText:
case QuestionType.Code:
- if (this.props.public_state.get("value") === "") {
- invalid = true;
- }
- if (this.props.public_state.get("unittestsFailed")) {
- unittest_failed = true;
+ if (this.realState.value === "") {
+ valid = false;
}
break;
case QuestionType.Select:
case QuestionType.Range:
case QuestionType.Radio:
- if (!this.props.public_state.get("value")) {
- invalid = true;
+ if (!this.realState.value) {
+ valid = false;
}
break;
case QuestionType.Checkbox:
if (typeof options !== "string") {
- const keys: string[] = [];
- options.forEach((val, index) => {
- keys.push(`${("000" + index).slice(-4)}. ${val}`);
- });
- if (keys.every(v => !this.props.public_state.get(v))) {
- invalid = true;
- }
+ if (!(this.realState.value instanceof Map)) return;
+ valid = Array.from(this.realState.value.values()).includes(true);
}
break;
}
- if (invalid) {
- this.setPublicState("error", "Field must be filled.");
- this.setPublicState("valid", false);
- } else if (unittest_failed) {
- this.setPublicState("error", "1 or more unittests failed.");
- this.setPublicState("valid", false);
- }
- else {
- this.setPublicState("error", "");
- this.setPublicState("valid", true);
- }
+ this.setState({
+ error: valid ? "" : "Field must be filled",
+ valid: valid
+ });
}
componentDidMount(): void {
// Initialize defaults for complex and nested fields
const options: string | string[] = this.props.question.data["options"];
- if (this.props.public_state.size === 0) {
- switch (this.props.question.type) {
- case QuestionType.Checkbox:
- if (typeof options === "string") {
- return;
- }
-
- options.forEach((option, index) => {
- this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false);
- });
- break;
+ switch (this.props.question.type) {
+ case QuestionType.Checkbox:
+ if (typeof options === "string") return;
+ this.setState({
+ value: new Map(options.map((option, index) =>
+ [`${("000" + index).slice(-4)}. ${option}`, false]
+ ))
+ });
+ break;
+ }
+ }
- case QuestionType.Range:
- case QuestionType.Radio:
- case QuestionType.Select:
- this.setPublicState("value", null);
- break;
- }
+ generateUnitTestErrorMessage(): JSX.Element {
+ let inner;
+
+ if (this.realState.testFailure) {
+ inner = <div>
+ {"Unittest Failure:\n"}
+ <ul css={css`font-size: 1rem;`}>
+ {this.realState.error.split(";").map(testName =>
+ <li css={css`letter-spacing: 0.5px;`} key={testName}>{testName}</li>
+ )}
+ </ul>
+ </div>;
+ } else {
+ inner = `Unittest Failure:\n\n${this.realState.error}`;
}
+
+ const element = <div css={css`white-space: pre-wrap; word-wrap: break-word;`}>{inner}</div>;
+ return <ErrorMessage show={!this.realState.valid} content={element}/>;
}
render(): JSX.Element {
@@ -295,22 +304,19 @@ class RenderedQuestion extends React.Component<QuestionProp> {
margin-left: 0.2rem;
}
`;
- let valid = true;
- if (!this.props.public_state.get("valid")) {
- valid = false;
- }
- const rawError = this.props.public_state.get("error");
- let error = "";
- if (typeof rawError === "string") {
- error = rawError;
+ let error;
+ if (this.props.question.type === QuestionType.Code && this.realState.unittestsFailed) {
+ error = this.generateUnitTestErrorMessage();
+ } else {
+ error = <ErrorMessage show={!this.realState.valid} content={this.realState.error}/>;
}
return <div ref={this.props.scroll_ref}>
<h2 css={[selectable, requiredStarStyles]}>
{name}<span css={css`display: none;`} className={question.required ? "required" : ""}>*</span>
</h2>
- { create_input(this.props, this.handler, this.blurHandler, this.props.focus_ref) }
- <ErrorMessage show={!valid} message={error} />
+ { create_input(this, this.handler, this.blurHandler, this.props.focus_ref) }
+ {error}
<hr css={css`color: gray; margin: 3rem 0;`}/>
</div>;
}
diff --git a/src/pages/FormPage.tsx b/src/pages/FormPage.tsx
deleted file mode 100644
index f21e4f0..0000000
--- a/src/pages/FormPage.tsx
+++ /dev/null
@@ -1,347 +0,0 @@
-/** @jsx jsx */
-import { jsx, css } from "@emotion/react";
-import { Link } from "react-router-dom";
-
-import React, { SyntheticEvent, useEffect, useState, createRef } from "react";
-import { useParams } from "react-router";
-import { PropagateLoader } from "react-spinners";
-import { AxiosError } from "axios";
-
-import HeaderBar from "../components/HeaderBar";
-import RenderedQuestion from "../components/Question";
-import Loading from "../components/Loading";
-import ScrollToTop from "../components/ScrollToTop";
-import OAuth2Button from "../components/OAuth2Button";
-
-import { Form, FormFeatures, getForm } from "../api/forms";
-import { OAuthScopes, checkScopes } from "../api/auth";
-import colors from "../colors";
-import { submitStyles, unselectable } from "../commonStyles";
-import { Question, QuestionType } from "../api/question";
-import ApiClient from "../api/client";
-
-interface PathParams {
- id: string
-}
-
-interface NavigationProps {
- form_state: boolean, // Whether the form is open or not
- scopes: OAuthScopes[]
-}
-
-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;
- }
- `;
-
- 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={this.separatorStyles}/>
- { submit }
- </div>
- );
- }
-}
-
-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<PathParams>();
-
- const [form, setForm] = useState<Form>();
- const [sending, setSending] = useState<boolean>();
- const [sent, setSent] = useState<boolean>();
-
- const bottomDivRef = createRef<HTMLDivElement>();
-
- useEffect(() => {
- getForm(id).then(form => {
- setForm(form);
- });
- }, []);
-
- if (form && sent) {
- const thanksStyle = css`font-family: "Uni Sans", "Hind", "Arial", sans-serif; margin-top: 15.5rem;`;
- const divStyle = css`width: 80%;`;
-
- let submitted_text;
- if (form.submitted_text) {
- submitted_text = 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={form.name} description={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>
- );
- }
-
- if (sending) {
- return (
- <div>
- <HeaderBar title={"Submitting..."}/>
- <div css={{display: "flex", justifyContent: "center", paddingTop: "40px"}}>
- <PropagateLoader color="white"/>
- </div>
- </div>
- );
- }
-
- if (!form) {
- return <Loading/>;
- }
-
- const refMap: Map<string, React.RefObject<RenderedQuestion>> = new Map();
- const questions = form.questions.map((question, index) => {
- const questionRef = createRef<RenderedQuestion>();
- refMap.set(question.id, questionRef);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- return <RenderedQuestion ref={questionRef} focus_ref={createRef<any>()} scroll_ref={createRef<HTMLDivElement>()} question={question} public_state={new Map()} key={index + Date.now()}/>;
- });
-
- const open: boolean = form.features.includes(FormFeatures.Open);
- 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); }
- }
-
- 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>;
- }
-
- async function handleSubmit(event: SyntheticEvent) {
- event.preventDefault();
- // Client-side required validation
- const invalidFieldIDs: number[] = [];
- questions.forEach((prop, i) => {
- const question: Question = prop.props.question;
- if (!question.required) {
- return;
- }
-
- const questionRef = refMap.get(question.id);
- if (questionRef && questionRef.current) {
- if(questionRef.current.props.question.type == QuestionType.Code){
- questionRef.current.props.public_state.set("unittestsFailed", false);
- }
- questionRef.current.validateField();
- }
- // In case when field is invalid, add this to invalid fields list.
- if (prop.props.public_state.get("valid") === false) {
- 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;
- }
-
- // Scroll to bottom when OAuth2 required
- if (scopes.length && !checkScopes(scopes)) {
- bottomDivRef.current.scrollIntoView({ behavior: "smooth", block: "end"});
-
- return;
- }
-
-
- const answers: { [key: string]: unknown } = {};
- questions.forEach(prop => {
- const question: Question = prop.props.question;
- const options: string | string[] = question.data["options"];
-
- // Parse input from each question
- switch (question.type) {
- case QuestionType.Section:
- answers[question.id] = false;
- break;
-
- case QuestionType.Checkbox: {
- if (typeof options !== "string") {
- const keys: Map<string, string> = new Map();
- options.forEach((val: string, index) => {
- keys.set(val, `${("000" + index).slice(-4)}. ${val}`);
- });
- const pairs: { [key: string]: boolean } = { };
- keys.forEach((val, key) => {
- pairs[key] = !!prop.props.public_state.get(val);
- });
- answers[question.id] = pairs;
- }
- break;
- }
-
- case QuestionType.Code:
- default:
- answers[question.id] = prop.props.public_state.get("value");
- }
- });
- await ApiClient.post(`forms/submit/${id}`, { response: answers })
- .then(() => {
- setSending(true);
- setSending(false);
- setSent(true);
- })
- .catch((err: AxiosError) => {
- switch (err.response.status) {
- case 422: {
- // Validation on a submitted code questions
- const questionId =
- err.response.data.test_results[0].question_id;
- questions.forEach((prop) => {
- const question: Question = prop.props.question;
- const questionRef = refMap.get(question.id);
- if (question.id === questionId) {
- prop.props.public_state.set("unittestsFailed", true);
- questionRef.current.validateField();
- }
- });
- break;
- }
- default:
- throw err;
- }
- });
- }
- return (
- <div>
- <HeaderBar title={form.name} description={form.description}/>
-
- <div>
- <form id="form" onSubmit={handleSubmit} css={[formStyles, unselectable]}>
- { closed_header }
- { questions }
- </form>
- <Navigation form_state={open} scopes={scopes}/>
- </div>
-
- <div css={css`margin-bottom: 10rem`} ref={bottomDivRef}/>
- <ScrollToTop/>
- </div>
- );
-}
-
-export default FormPage;
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;
+}
diff --git a/src/tests/pages/FormPage.test.tsx b/src/tests/pages/FormPage.test.tsx
index 3a906f3..bd297ec 100644
--- a/src/tests/pages/FormPage.test.tsx
+++ b/src/tests/pages/FormPage.test.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
-import FormPage from "../../pages/FormPage";
+import FormPage from "../../pages/FormPage/FormPage";
import * as forms from "../../api/forms";