diff options
Diffstat (limited to 'src/pages')
| -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 | 
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; +} | 
