diff options
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"; |