diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.tsx | 2 | ||||
-rw-r--r-- | src/api/question.ts | 13 | ||||
-rw-r--r-- | src/components/ErrorMessage.tsx | 18 | ||||
-rw-r--r-- | src/components/InputTypes/Select.tsx | 24 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 35 | ||||
-rw-r--r-- | src/components/Question.tsx | 232 | ||||
-rw-r--r-- | src/pages/FormPage.tsx | 347 | ||||
-rw-r--r-- | src/pages/FormPage/ErrorPage.tsx | 45 | ||||
-rw-r--r-- | src/pages/FormPage/FormPage.tsx | 148 | ||||
-rw-r--r-- | src/pages/FormPage/Navigation.tsx | 107 | ||||
-rw-r--r-- | src/pages/FormPage/SuccessPage.tsx | 45 | ||||
-rw-r--r-- | src/pages/FormPage/submit.ts | 195 | ||||
-rw-r--r-- | src/tests/pages/FormPage.test.tsx | 2 |
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"; |