diff options
author | 2022-07-01 00:19:13 +0400 | |
---|---|---|
committer | 2022-07-01 01:15:35 +0400 | |
commit | a378b1c9ee096388002f3b0fdc26636e9c1cd57b (patch) | |
tree | d11d64dfc393cefecd2d2523f556b78dfba0701a /src | |
parent | Display Test Names For Unittest Failures (diff) |
Restructure FormPage
The main FormPage component had gotten very out of hand, with many
moving parts that were hard to parse, understand, or modify. This
refactors breaks things up into separate files with better defined
goals.
Most changes are just straight copies without much change, however some
structural changes have been introduced as a foundation for improving
the app.
Signed-off-by: Hassan Abouelela <[email protected]>
Diffstat (limited to 'src')
-rw-r--r-- | src/App.tsx | 2 | ||||
-rw-r--r-- | src/components/Question.tsx | 19 | ||||
-rw-r--r-- | src/pages/FormPage.tsx | 347 | ||||
-rw-r--r-- | src/pages/FormPage/ErrorPage.tsx | 47 | ||||
-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 | 190 | ||||
-rw-r--r-- | src/tests/pages/FormPage.test.tsx | 2 |
9 files changed, 545 insertions, 362 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/components/Question.tsx b/src/components/Question.tsx index 2ce84b2..b42ea09 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -40,10 +40,6 @@ class RenderedQuestion extends React.Component<QuestionProp> { "error": "", }; - if (props.question.type === QuestionType.Code) { - _state["unittestsFailed"] = false; - } - if (!skip_normal_state.includes(props.question.type)) { _state["value"] = ""; } @@ -70,6 +66,10 @@ class RenderedQuestion extends React.Component<QuestionProp> { } else { this.setPublicState("error", ""); this.setPublicState("valid", true); + + if (this.props.question.type === QuestionType.Code) { + this.props.public_state.set("unittestsFailed", false); + } } } } @@ -166,8 +166,8 @@ class RenderedQuestion extends React.Component<QuestionProp> { } let invalid = false; - let unittest_failed = false; const options: string | string[] = this.props.question.data["options"]; + switch (this.props.question.type) { case QuestionType.TextArea: case QuestionType.ShortText: @@ -175,9 +175,6 @@ class RenderedQuestion extends React.Component<QuestionProp> { if (this.props.public_state.get("value") === "") { invalid = true; } - if (this.props.public_state.get("unittestsFailed")) { - unittest_failed = true; - } break; case QuestionType.Select: @@ -204,11 +201,7 @@ class RenderedQuestion extends React.Component<QuestionProp> { 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 { + } else { this.setPublicState("error", ""); this.setPublicState("valid", true); } 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..3766181 --- /dev/null +++ b/src/pages/FormPage/ErrorPage.tsx @@ -0,0 +1,47 @@ +/** @jsx jsx */ +import {jsx, css} from "@emotion/react"; +import {Link} from "react-router-dom"; +import React from "react"; + +import HeaderBar from "../../components/HeaderBar"; +import RenderedQuestion from "../../components/Question"; + +import {Form} from "../../api/forms"; +import {selectable, submitStyles, unselectable} from "../../commonStyles"; + +import Navigation from "./Navigation"; + + +interface ErrorProps { + form: Form + questions: RenderedQuestion[] + 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..1852c75 --- /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} + public_state={new Map()} + scroll_ref={createRef<HTMLDivElement>()} + focus_ref={createRef<any>()} // eslint-disable-line @typescript-eslint/no-explicit-any + key={index + Date.now()} + 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} questions={questions} + 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..060c050 --- /dev/null +++ b/src/pages/FormPage/submit.ts @@ -0,0 +1,190 @@ +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)}) + .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.setPublicState("valid", false); + questionRef.current.setPublicState("unittestsFailed", true); + questionRef.current.setPublicState("testFailure", error.return_code === 0); + questionRef.current.setPublicState("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; + } + + const questionRef = refMap.get(question.id); + if (questionRef && questionRef.current) { + 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(); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 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[]): { [key: string]: unknown } { + 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; + } + + default: + answers[question.id] = prop.props.public_state.get("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"; |