aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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;
}
});