aboutsummaryrefslogtreecommitdiffstats
path: root/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages')
-rw-r--r--src/pages/FormPage.tsx347
-rw-r--r--src/pages/FormPage/ErrorPage.tsx47
-rw-r--r--src/pages/FormPage/FormPage.tsx148
-rw-r--r--src/pages/FormPage/Navigation.tsx107
-rw-r--r--src/pages/FormPage/SuccessPage.tsx45
-rw-r--r--src/pages/FormPage/submit.ts190
6 files changed, 537 insertions, 347 deletions
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;
+}