diff options
-rw-r--r-- | src/components/InputTypes/Select.tsx | 24 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 35 | ||||
-rw-r--r-- | src/components/Question.tsx | 217 | ||||
-rw-r--r-- | src/pages/FormPage/FormPage.tsx | 2 | ||||
-rw-r--r-- | src/pages/FormPage/submit.ts | 57 |
5 files changed, 164 insertions, 171 deletions
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<string>, - state_dict: Map<string, string | boolean | null>, valid: boolean, + question: React.RefObject<RenderedQuestion> onBlurHandler: () => void } @@ -159,8 +161,12 @@ class Select extends React.Component<SelectProps> { 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<SelectProps> { } 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> = [ - 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<HTMLInputElement | HTMLTextAreaElement> | string) => void, onBlurHandler: () => void, focus_ref: React.RefObject<any>): JSX.Element | JSX.Element[] { +export default function create_input( + {props: renderedQuestionProps, realState}: RenderedQuestion, + handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => void, onBlurHandler: () => void, + focus_ref: React.RefObject<any> // 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 = <Select options={options} state_dict={public_state} valid={valid} onBlurHandler={onBlurHandler}/>; + result = <Select question={renderedQuestionProps.selfRef} valid={valid} options={options} onBlurHandler={onBlurHandler}/>; 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> = [ QuestionType.Range ]; +export interface QuestionState { + // Common keys + value: string | null | Map<string, boolean> + + // 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<string, string | boolean | null>, scroll_ref: React.RefObject<HTMLDivElement>, // eslint-disable-next-line @typescript-eslint/no-explicit-any - focus_ref: React.RefObject<any> + focus_ref: React.RefObject<any>, + selfRef: React.RefObject<RenderedQuestion>, } class RenderedQuestion extends React.Component<QuestionProp> { + // 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<QuestionState>): 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<QuestionProp> { } 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<QuestionProp> { 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<HTMLInputElement>): 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<QuestionProp> { 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<HTMLTextAreaElement>): 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<QuestionProp> { 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 = <div> {"Unittest Failure:\n"} <ul css={css`font-size: 1rem;`}> - {failures.split(";").map(testName => + {this.realState.error.split(";").map(testName => <li css={css`letter-spacing: 0.5px;`} key={testName}>{testName}</li> )} </ul> </div>; } else { - inner = `Unittest Failure:\n\n${failures}`; + inner = `Unittest Failure:\n\n${this.realState.error}`; } const element = <div css={css`white-space: pre-wrap; word-wrap: break-word;`}>{inner}</div>; - return <ErrorMessage show={!valid} content={element}/>; + return <ErrorMessage show={!this.realState.valid} content={element}/>; } render(): JSX.Element { @@ -311,26 +304,18 @@ class RenderedQuestion extends React.Component<QuestionProp> { 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 = <ErrorMessage show={!valid} content={message}/>; + error = <ErrorMessage show={!this.realState.valid} content={this.realState.error}/>; } return <div ref={this.props.scroll_ref}> <h2 css={[selectable, requiredStarStyles]}> {name}<span css={css`display: none;`} className={question.required ? "required" : ""}>*</span> </h2> - { create_input(this.props, this.handler, this.blurHandler, this.props.focus_ref) } + { create_input(this, this.handler, this.blurHandler, this.props.focus_ref) } {error} <hr css={css`color: gray; margin: 3rem 0;`}/> </div>; 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 <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()} + 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<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; - } + 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; } }); |