From 68545056d8668d3222bb81780ad484c079d2a069 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 00:10:01 +0400 Subject: Allow More Customization In Error Message Add transitions to the error message component, and allow more flexibility by allowing the caller to specify the inner component of the message. Signed-off-by: Hassan Abouelela --- src/components/ErrorMessage.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index 650100d..cbadcdc 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -1,10 +1,12 @@ /** @jsx jsx */ import { jsx, css } from "@emotion/react"; import colors from "../colors"; +import {selectable} from "../commonStyles"; interface ErrorMessageProps { show: boolean, - message: string + message: string, + innerElement?: JSX.Element, } export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | null { @@ -13,12 +15,21 @@ export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | nu font-size: 1.15rem; line-height: 1.1rem; margin: 1rem 0 0; + visibility: ${props.show ? "visible" : "hidden"}; + opacity: ${props.show ? 1 : 0}; + transition: opacity 200ms, visibility 200ms; + `; + + // These styles are not applied when inner element is explicitly set + const floatingStyles = css` position: absolute; z-index: -1; `; return ( -

{props.message}

+
+ {props.innerElement ? props.innerElement : props.message} +
); } -- cgit v1.2.3 From 2d4f959778a80a159a2a09424c2e736c8e21620e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 00:11:13 +0400 Subject: Add Unittest Failure Interface Signed-off-by: Hassan Abouelela --- src/api/question.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'src') diff --git a/src/api/question.ts b/src/api/question.ts index 9824b60..3561055 100644 --- a/src/api/question.ts +++ b/src/api/question.ts @@ -16,3 +16,16 @@ export interface Question { data: { [key: string]: string | string[] }, required: boolean } + +type unittestError = { + question_id: string, + question_index: number, + return_code: number, + passed: boolean, + result: string, +} + +export interface UnittestFailure { + error: string, + test_results: unittestError[], +} -- cgit v1.2.3 From 1501568f789ec1fb506ccb94126293b16872f474 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 00:15:02 +0400 Subject: Display Test Names For Unittest Failures Signed-off-by: Hassan Abouelela --- src/components/Question.tsx | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/components/Question.tsx b/src/components/Question.tsx index 61e66e0..2ce84b2 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -239,6 +239,29 @@ class RenderedQuestion extends React.Component { } } + generateUnitTestErrorMessage(valid: boolean): JSX.Element { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const failures: string = this.props.public_state.get("error"); + let inner; + + if (this.props.public_state.get("testFailure")) { + inner =
+ {"Unittest Failure:\n"} +
    + {failures.split(";").map(testName => +
  • {testName}
  • + )} +
+
; + } else { + inner = `Unittest Failure:\n\n${failures}`; + } + + const element =
{inner}
; + return ; + } + render(): JSX.Element { const question = this.props.question; @@ -299,10 +322,15 @@ class RenderedQuestion extends React.Component { if (!this.props.public_state.get("valid")) { valid = false; } - const rawError = this.props.public_state.get("error"); - let error = ""; - if (typeof rawError === "string") { - error = rawError; + + let error; + if (this.props.question.type === QuestionType.Code && this.props.public_state.get("unittestsFailed")) { + error = this.generateUnitTestErrorMessage(valid); + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const message: string = this.props.public_state.get("error"); + error = ; } return
@@ -310,7 +338,7 @@ class RenderedQuestion extends React.Component { {name}* { create_input(this.props, this.handler, this.blurHandler, this.props.focus_ref) } - + {error}
; } -- cgit v1.2.3 From a378b1c9ee096388002f3b0fdc26636e9c1cd57b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 00:19:13 +0400 Subject: 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 --- src/App.tsx | 2 +- src/components/Question.tsx | 19 +- src/pages/FormPage.tsx | 347 ------------------------------------- src/pages/FormPage/ErrorPage.tsx | 47 +++++ src/pages/FormPage/FormPage.tsx | 148 ++++++++++++++++ src/pages/FormPage/Navigation.tsx | 107 ++++++++++++ src/pages/FormPage/SuccessPage.tsx | 45 +++++ src/pages/FormPage/submit.ts | 190 ++++++++++++++++++++ src/tests/pages/FormPage.test.tsx | 2 +- 9 files changed, 545 insertions(+), 362 deletions(-) delete mode 100644 src/pages/FormPage.tsx create mode 100644 src/pages/FormPage/ErrorPage.tsx create mode 100644 src/pages/FormPage/FormPage.tsx create mode 100644 src/pages/FormPage/Navigation.tsx create mode 100644 src/pages/FormPage/SuccessPage.tsx create mode 100644 src/pages/FormPage/submit.ts (limited to 'src') 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 { "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 { } 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 { } 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 { 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 { 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 { - 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 = this.setState({"logged_in": true})}/>; - } else { - inner_submit = ; - } - submit =
{ inner_submit }
; - } - - return ( -
-
- Return Home -
-
- { submit } -
- ); - } -} - -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(); - - const [form, setForm] = useState
(); - const [sending, setSending] = useState(); - const [sent, setSent] = useState(); - - const bottomDivRef = createRef(); - - 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) => {line}
); - submitted_text.push({submitted_text.pop()?.props.children[0]}); - } else { - submitted_text = "Thanks for your response!"; - } - - return ( -
- -
-

{submitted_text}

-
- Return Home -
-
-
- ); - } - - if (sending) { - return ( -
- -
- -
-
- ); - } - - if (!form) { - return ; - } - - const refMap: Map> = new Map(); - const questions = form.questions.map((question, index) => { - const questionRef = createRef(); - refMap.set(question.id, questionRef); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ()} scroll_ref={createRef()} 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 =
This form is now closed. You will not be able to submit your response.
; - } - - 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 = 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 ( -
- - -
- - { closed_header } - { questions } - - -
- -
- -
- ); -} - -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 ( +
+ +
+

{props.message}

+
+ Return Home +
+
+
+ +
+
+
+ ); +} 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>; + + +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
(); + const [state, setState] = useState(FormState.INITIAL); + + const OAuthRef = createRef(); + + 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 ; + } + + 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 = +
This form is now closed. You will not be able to submit your response.
; + } + + // 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(); + refMap.set(question.id, questionRef); + + return ()} + focus_ref={createRef()} // eslint-disable-line @typescript-eslint/no-explicit-any + key={index + Date.now()} + ref={questionRef} + />; + }); + + switch (state) { + case FormState.SENT: + return ; + case FormState.SENDING: + return ( +
+ +
+ +
+
+ ); + + case FormState.UNKNOWN_ERROR: + return ; + } + + const handler = (event: SyntheticEvent) => handleSubmit(event, form.id, questions, refMap, setState, OAuthRef, scopes); + + return ( +
+ + +
+ + {closed_header} + {questions} + + +
+ +
+ +
+ ); +} + +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 { + 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 = this.setState({"logged_in": true})}/>; + } else { + inner_submit = ; + } + submit =
{ inner_submit }
; + } + + return ( +
+
+ Return Home +
+
+ { submit } +
+ ); + } +} 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) => {line}
); + submitted_text.push({submitted_text.pop()?.props.children[0]}); + } else { + submitted_text = "Thanks for your response!"; + } + + return ( +
+ +
+

{submitted_text}

+
+ Return Home +
+
+
+ ); +} 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, + scopes: OAuthScopes[] +): Promise { + 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 = 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"; -- cgit v1.2.3 From acb7df25f258701722ee56b087e272b7118fe8ac Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 01:17:19 +0400 Subject: Style Changes Signed-off-by: Hassan Abouelela --- src/api/question.ts | 4 ++-- src/components/ErrorMessage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/api/question.ts b/src/api/question.ts index 3561055..a9a4d4a 100644 --- a/src/api/question.ts +++ b/src/api/question.ts @@ -17,7 +17,7 @@ export interface Question { required: boolean } -type unittestError = { +type UnittestError = { question_id: string, question_index: number, return_code: number, @@ -27,5 +27,5 @@ type unittestError = { export interface UnittestFailure { error: string, - test_results: unittestError[], + test_results: UnittestError[], } diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index cbadcdc..e6078ff 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -1,5 +1,5 @@ /** @jsx jsx */ -import { jsx, css } from "@emotion/react"; +import {jsx, css} from "@emotion/react"; import colors from "../colors"; import {selectable} from "../commonStyles"; -- cgit v1.2.3 From 9696f5269ddbc1ba090830f84cb17ac6624c777f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 13:41:48 +0400 Subject: Improve ErrorMessage Interface Unify the string and element types on the interface to clarify they are mutually exclusive. Signed-off-by: Hassan Abouelela --- src/components/ErrorMessage.tsx | 11 ++++++----- src/components/Question.tsx | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index e6078ff..6151603 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -5,8 +5,7 @@ import {selectable} from "../commonStyles"; interface ErrorMessageProps { show: boolean, - message: string, - innerElement?: JSX.Element, + content: string | JSX.Element, } export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | null { @@ -21,15 +20,17 @@ export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | nu transition: opacity 200ms, visibility 200ms; `; - // These styles are not applied when inner element is explicitly set + // These styles are not applied when content is an element; const floatingStyles = css` position: absolute; z-index: -1; `; + const isString = typeof props.content === "string"; + return ( -
- {props.innerElement ? props.innerElement : props.message} +
+ {props.content}
); } diff --git a/src/components/Question.tsx b/src/components/Question.tsx index b42ea09..d4883ec 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -252,7 +252,7 @@ class RenderedQuestion extends React.Component { } const element =
{inner}
; - return ; + return ; } render(): JSX.Element { @@ -323,7 +323,7 @@ class RenderedQuestion extends React.Component { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const message: string = this.props.public_state.get("error"); - error = ; + error = ; } return
-- cgit v1.2.3 From b83dff712dfbdb521a0739aac34dd05bf3350a1e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 13:44:38 +0400 Subject: Remove Unused Prop From ErrorPage Signed-off-by: Hassan Abouelela --- src/pages/FormPage/ErrorPage.tsx | 2 -- src/pages/FormPage/FormPage.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/pages/FormPage/ErrorPage.tsx b/src/pages/FormPage/ErrorPage.tsx index 3766181..9a7fad9 100644 --- a/src/pages/FormPage/ErrorPage.tsx +++ b/src/pages/FormPage/ErrorPage.tsx @@ -4,7 +4,6 @@ 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"; @@ -14,7 +13,6 @@ import Navigation from "./Navigation"; interface ErrorProps { form: Form - questions: RenderedQuestion[] message: string } diff --git a/src/pages/FormPage/FormPage.tsx b/src/pages/FormPage/FormPage.tsx index 1852c75..a67566d 100644 --- a/src/pages/FormPage/FormPage.tsx +++ b/src/pages/FormPage/FormPage.tsx @@ -120,7 +120,7 @@ function FormPage(): JSX.Element { case FormState.UNKNOWN_ERROR: return ; } -- cgit v1.2.3 From 09f25bedcbca12b0cc5b931c0e261819531dc692 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 1 Jul 2022 17:20:14 +0400 Subject: Codify Question State Type Uses an interface to define the RenderedQuestion component state, to reduce the required type and existence checks in the rest of the codebase. Signed-off-by: Hassan Abouelela --- src/components/InputTypes/Select.tsx | 24 ++-- src/components/InputTypes/index.tsx | 35 +++--- src/components/Question.tsx | 217 ++++++++++++++++------------------- src/pages/FormPage/FormPage.tsx | 2 +- src/pages/FormPage/submit.ts | 57 ++++----- 5 files changed, 164 insertions(+), 171 deletions(-) (limited to 'src') diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx index 16049a8..121e2ff 100644 --- a/src/components/InputTypes/Select.tsx +++ b/src/components/InputTypes/Select.tsx @@ -1,12 +1,14 @@ /** @jsx jsx */ -import { jsx, css } from "@emotion/react"; +import {jsx, css} from "@emotion/react"; import React from "react"; -import { hiddenInput, invalidStyles } from "../../commonStyles"; + +import {hiddenInput, invalidStyles} from "../../commonStyles"; +import RenderedQuestion from "../Question"; interface SelectProps { options: Array, - state_dict: Map, valid: boolean, + question: React.RefObject onBlurHandler: () => void } @@ -159,8 +161,12 @@ class Select extends React.Component { return; } + if (!this.props.question?.current) { + throw new Error("Missing ref for select question."); + } + // Update stored value - this.props.state_dict.set("value", option_container.textContent); + this.props.question.current.setState({value: option_container.textContent}); // Close the menu selected_option.current.focus(); @@ -187,10 +193,14 @@ class Select extends React.Component { } focusOption(): void { - if (!this.props.state_dict.get("value")) { - this.props.state_dict.set("value", "temporary"); + if (!this.props.question?.current) { + throw new Error("Missing ref for select question."); + } + + if (!this.props.question.current.realState.value) { + this.props.question.current.setState({value: "temporary"}); this.props.onBlurHandler(); - this.props.state_dict.set("value", null); + this.props.question.current.setState({value: null}); } } diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx index 3e13652..0973383 100644 --- a/src/components/InputTypes/index.tsx +++ b/src/components/InputTypes/index.tsx @@ -5,33 +5,26 @@ import Select from "./Select"; import ShortText from "./ShortText"; import TextArea from "./TextArea"; -import React, { ChangeEvent } from "react"; +import React, {ChangeEvent} from "react"; -import { QuestionType } from "../../api/question"; -import { QuestionProp } from "../Question"; +import {QuestionType} from "../../api/question"; +import RenderedQuestion from "../Question"; import Code from "./Code"; -const require_options: Array = [ - QuestionType.Radio, - QuestionType.Checkbox, - QuestionType.Select, - QuestionType.Range -]; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent | string) => void, onBlurHandler: () => void, focus_ref: React.RefObject): JSX.Element | JSX.Element[] { +export default function create_input( + {props: renderedQuestionProps, realState}: RenderedQuestion, + handler: (event: ChangeEvent | string) => void, onBlurHandler: () => void, + focus_ref: React.RefObject // eslint-disable-line @typescript-eslint/no-explicit-any +): JSX.Element | JSX.Element[] { let result: JSX.Element | JSX.Element[]; - // eslint-disable-next-line - // @ts-ignore - let options: string[] = question.data["options"]; - let valid = true; - if (!public_state.get("valid")) { - valid = false; - } + const question = renderedQuestionProps.question; + const valid = realState.valid; + + let options = question.data["options"]; // Catch input types that require options but don't have any - if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) { + if (!(options instanceof Array)) { // TODO: Implement some sort of warning here options = []; } @@ -55,7 +48,7 @@ export default function create_input({ question, public_state }: QuestionProp, h break; case QuestionType.Select: - result = ; break; case QuestionType.ShortText: diff --git a/src/components/Question.tsx b/src/components/Question.tsx index d4883ec..a5a71c5 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -15,15 +15,40 @@ const skip_normal_state: Array = [ QuestionType.Range ]; +export interface QuestionState { + // Common keys + value: string | null | Map + + // Validation + valid: boolean + error: string + + // Unittest-specific validation + unittestsFailed: boolean // This indicates a failure in testing when submitting (i.e not from common validation) + testFailure: boolean // Whether we had failed unittests, or other failures, such as code loading +} + export type QuestionProp = { question: Question, - public_state: Map, scroll_ref: React.RefObject, // eslint-disable-next-line @typescript-eslint/no-explicit-any - focus_ref: React.RefObject + focus_ref: React.RefObject, + selfRef: React.RefObject, } class RenderedQuestion extends React.Component { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: TS2610 + state: QuestionState; + + /** The current state of the question components, which may or may not match the state rendered. */ + public realState: QuestionState; + + setState(state: Partial): void { + this.realState = {...this.realState, ...state}; + super.setState(state); + } + constructor(props: QuestionProp) { super(props); if (props.question.type === QuestionType.TextArea) { @@ -35,24 +60,16 @@ class RenderedQuestion extends React.Component { } this.blurHandler = this.blurHandler.bind(this); - const _state: {[key: string]: string | boolean | null} = { - "valid": true, - "error": "", - }; + this.state = { + value: skip_normal_state.includes(props.question.type) ? null : "", + valid: true, + error: "", - if (!skip_normal_state.includes(props.question.type)) { - _state["value"] = ""; - } - - this.state = _state; - for (const [key, value] of Object.entries(_state)) { - this.props.public_state.set(key, value); - } - } + unittestsFailed: false, + testFailure: false, + }; - setPublicState(target: string, value: string | boolean | null, callback?:() => void): void { - this.props.public_state.set(target, value); - this.setState({[target]: value}, callback); + this.realState = this.state; } // This is here to allow dynamic selection between the general handler, textarea, and code field handlers. @@ -60,49 +77,46 @@ class RenderedQuestion extends React.Component { blurHandler(): void { if (this.props.question.required) { - if (!this.props.public_state.get("value")) { - this.setPublicState("error", "Field must be filled."); - this.setPublicState("valid", false); + if (!this.realState.value) { + this.setState({ + error: "Field must be filled.", + valid: false + }); } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); - - if (this.props.question.type === QuestionType.Code) { - this.props.public_state.set("unittestsFailed", false); - } + this.setState({ + error: "", + valid: true, + unittestsFailed: false + }); } } } normal_handler(event: ChangeEvent): void { - let target: string; - let value: string | boolean; - switch (event.target.type) { case "checkbox": - target = event.target.name; - value = event.target.checked; + if (!(this.realState.value instanceof Map)) return; + this.realState.value.set(event.target.name, event.target.checked); break; - case "radio": - // This handles radios and ranges, as they are both based on the same fundamental input type - target = "value"; + case "radio": { + // This handles radios and ranges, as they are both based on the same fundamental input type + let value; if (event.target.parentElement) { value = event.target.parentElement.innerText.trimEnd(); } else { value = event.target.value; } + this.setState({value: value}); break; + } default: - target = "value"; - value = event.target.value; + this.setState({value: event.target.value}); } - this.setPublicState(target, value); - // Toggle checkbox class - if (event.target.type == "checkbox" && event.target.parentElement !== null) { + if (event.target.type === "checkbox" && event.target.parentElement !== null) { event.target.parentElement.classList.toggle("unselected"); event.target.parentElement.classList.toggle("selected"); } @@ -110,49 +124,48 @@ class RenderedQuestion extends React.Component { const options: string | string[] = this.props.question.data["options"]; switch (event.target.type) { case "text": - this.setPublicState("valid", true); + this.setState({valid: true}); break; case "checkbox": // We need to check this here, because checkbox doesn't have onBlur if (this.props.question.required && typeof options !== "string") { - const keys: string[] = []; - options.forEach((val, index) => { - keys.push(`${("000" + index).slice(-4)}. ${val}`); + if (!(this.realState.value instanceof Map)) return; + const valid = Array.from(this.realState.value.values()).includes(true); + this.setState({ + error: valid ? "" : "Field must be filled.", + valid: valid }); - if (keys.every(v => !this.props.public_state.get(v))) { - this.setPublicState("error", "Field must be filled."); - this.setPublicState("valid", false); - } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); - } } + break; case "radio": - this.setPublicState("valid", true); - this.setPublicState("error", ""); + this.setState({ + valid: true, + error: "" + }); break; } } text_area_handler(event: ChangeEvent): void { // We will validate again when focusing out. - this.setPublicState("valid", true); - this.setPublicState("error", ""); - - this.setPublicState("value", event.target.value); + this.setState({ + value: event.target.value, + valid: true, + error: "" + }); } code_field_handler(newContent: string): void { // If content stays same (what means that user have just zoomed in), then don't validate. let validate = false; - if (newContent != this.props.public_state.get("value")) { + if (newContent != this.realState.value) { validate = true; } - this.setPublicState("value", newContent); + this.setState({value: newContent}); // CodeMirror don't provide onBlur event, so we have to run validation here. if (validate) { @@ -165,94 +178,74 @@ class RenderedQuestion extends React.Component { return; } - let invalid = false; + let valid = true; const options: string | string[] = this.props.question.data["options"]; switch (this.props.question.type) { case QuestionType.TextArea: case QuestionType.ShortText: case QuestionType.Code: - if (this.props.public_state.get("value") === "") { - invalid = true; + if (this.realState.value === "") { + valid = false; } break; case QuestionType.Select: case QuestionType.Range: case QuestionType.Radio: - if (!this.props.public_state.get("value")) { - invalid = true; + if (!this.realState.value) { + valid = false; } break; case QuestionType.Checkbox: if (typeof options !== "string") { - const keys: string[] = []; - options.forEach((val, index) => { - keys.push(`${("000" + index).slice(-4)}. ${val}`); - }); - if (keys.every(v => !this.props.public_state.get(v))) { - invalid = true; - } + if (!(this.realState.value instanceof Map)) return; + valid = Array.from(this.realState.value.values()).includes(true); } break; } - if (invalid) { - this.setPublicState("error", "Field must be filled."); - this.setPublicState("valid", false); - } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); - } + this.setState({ + error: valid ? "" : "Field must be filled", + valid: valid + }); } componentDidMount(): void { // Initialize defaults for complex and nested fields const options: string | string[] = this.props.question.data["options"]; - if (this.props.public_state.size === 0) { - switch (this.props.question.type) { - case QuestionType.Checkbox: - if (typeof options === "string") { - return; - } - - options.forEach((option, index) => { - this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false); - }); - break; - - case QuestionType.Range: - case QuestionType.Radio: - case QuestionType.Select: - this.setPublicState("value", null); - break; - } + switch (this.props.question.type) { + case QuestionType.Checkbox: + if (typeof options === "string") return; + this.setState({ + value: new Map(options.map((option, index) => + [`${("000" + index).slice(-4)}. ${option}`, false] + )) + }); + break; } } - generateUnitTestErrorMessage(valid: boolean): JSX.Element { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const failures: string = this.props.public_state.get("error"); + generateUnitTestErrorMessage(): JSX.Element { let inner; - if (this.props.public_state.get("testFailure")) { + if (this.realState.testFailure) { inner =
{"Unittest Failure:\n"}
    - {failures.split(";").map(testName => + {this.realState.error.split(";").map(testName =>
  • {testName}
  • )}
; } else { - inner = `Unittest Failure:\n\n${failures}`; + inner = `Unittest Failure:\n\n${this.realState.error}`; } const element =
{inner}
; - return ; + return ; } render(): JSX.Element { @@ -311,26 +304,18 @@ class RenderedQuestion extends React.Component { margin-left: 0.2rem; } `; - let valid = true; - if (!this.props.public_state.get("valid")) { - valid = false; - } - let error; - if (this.props.question.type === QuestionType.Code && this.props.public_state.get("unittestsFailed")) { - error = this.generateUnitTestErrorMessage(valid); + if (this.props.question.type === QuestionType.Code && this.realState.unittestsFailed) { + error = this.generateUnitTestErrorMessage(); } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const message: string = this.props.public_state.get("error"); - error = ; + error = ; } return

{name}*

- { create_input(this.props, this.handler, this.blurHandler, this.props.focus_ref) } + { create_input(this, this.handler, this.blurHandler, this.props.focus_ref) } {error}
; diff --git a/src/pages/FormPage/FormPage.tsx b/src/pages/FormPage/FormPage.tsx index a67566d..9f74410 100644 --- a/src/pages/FormPage/FormPage.tsx +++ b/src/pages/FormPage/FormPage.tsx @@ -97,10 +97,10 @@ function FormPage(): JSX.Element { return ()} focus_ref={createRef()} // eslint-disable-line @typescript-eslint/no-explicit-any key={index + Date.now()} + selfRef={questionRef} ref={questionRef} />; }); diff --git a/src/pages/FormPage/submit.ts b/src/pages/FormPage/submit.ts index 060c050..aee127f 100644 --- a/src/pages/FormPage/submit.ts +++ b/src/pages/FormPage/submit.ts @@ -59,7 +59,7 @@ export default async function handleSubmit( // FIXME: Save state while sending // setState(FormState.SENDING); - await ApiClient.post(`forms/submit/${formID}`, {response: parseAnswers(questions)}) + await ApiClient.post(`forms/submit/${formID}`, {response: parseAnswers(questions, refMap)}) .then(() => setState(FormState.SENT)) .catch(error => { if (!error.response) { @@ -99,10 +99,12 @@ function showUnitTestFailures(refMap: RefMapType, errors: UnittestFailure) { 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); + questionRef.current.setState({ + valid: false, + unittestsFailed: true, + testFailure: error.return_code === 0, + error: error.result + }); } } @@ -117,13 +119,16 @@ function validate(questions: RenderedQuestion[], refMap: RefMapType): boolean { return; } + // Add invalid fields to list 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) { + if (!questionRef.current.realState.valid) { + invalidFieldIDs.push(i); + } + + } else { invalidFieldIDs.push(i); } }); @@ -136,9 +141,7 @@ function validate(questions: RenderedQuestion[], refMap: RefMapType): boolean { document.activeElement.blur(); } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - firstErrored.props.scroll_ref.current.scrollIntoView({ behavior: "smooth", block: "center" }); + 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 }); } @@ -153,36 +156,38 @@ function validate(questions: RenderedQuestion[], refMap: RefMapType): boolean { /** * Parse user answers into a valid submission. */ -function parseAnswers(questions: RenderedQuestion[]): { [key: string]: unknown } { +function parseAnswers(questions: RenderedQuestion[], refMap: RefMapType): { [key: string]: unknown } { const answers: { [key: string]: unknown } = {}; questions.forEach(prop => { const question: Question = prop.props.question; - const options: string | string[] = question.data["options"]; + const questionRef = refMap.get(question.id); + + if (!questionRef?.current) { + throw new Error("Could not find a reference to the current question while submitting."); + } // Parse input from each question switch (question.type) { case QuestionType.Section: answers[question.id] = false; break; - case QuestionType.Checkbox: { - if (typeof options !== "string") { - const keys: Map = 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; - } + const result: {[key: string]: boolean} = {}; + + const selected = questionRef.current.realState.value; + if (!(selected instanceof Map)) throw new Error("Could not parse checkbox answers."); + selected.forEach((value, key) => { + // Remove the index from the key and set its value + result[key.slice(6)] = value; + }); + + answers[question.id] = result; break; } default: - answers[question.id] = prop.props.public_state.get("value"); + answers[question.id] = questionRef.current.realState.value; } }); -- cgit v1.2.3