aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar Hassan Abouelela <[email protected]>2022-07-01 17:20:14 +0400
committerGravatar Hassan Abouelela <[email protected]>2022-07-01 17:20:14 +0400
commit09f25bedcbca12b0cc5b931c0e261819531dc692 (patch)
tree575f538151a79060991071c8017f837e827fddee /src
parentRemove Unused Prop From ErrorPage (diff)
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/components/InputTypes/Select.tsx24
-rw-r--r--src/components/InputTypes/index.tsx35
-rw-r--r--src/components/Question.tsx217
-rw-r--r--src/pages/FormPage/FormPage.tsx2
-rw-r--r--src/pages/FormPage/submit.ts57
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;
}
});