diff options
author | 2022-07-01 23:27:40 +0400 | |
---|---|---|
committer | 2022-07-01 23:27:40 +0400 | |
commit | e0f9989b56501693289ffeae505b2b36eb4d6273 (patch) | |
tree | 575f538151a79060991071c8017f837e827fddee /src/components | |
parent | Cleanup Code Owners Comments (#475) (diff) | |
parent | Codify Question State Type (diff) |
Merge pull request #474 from python-discord/display-test-failures
Display Test Failures & Refactor Formpage
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/ErrorMessage.tsx | 18 | ||||
-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 | 232 |
4 files changed, 165 insertions, 144 deletions
diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index 650100d..6151603 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -1,10 +1,11 @@ /** @jsx jsx */ -import { jsx, css } from "@emotion/react"; +import {jsx, css} from "@emotion/react"; import colors from "../colors"; +import {selectable} from "../commonStyles"; interface ErrorMessageProps { show: boolean, - message: string + content: string | JSX.Element, } export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | null { @@ -13,12 +14,23 @@ 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 content is an element; + const floatingStyles = css` position: absolute; z-index: -1; `; + const isString = typeof props.content === "string"; + return ( - <p css={styles}>{props.message}</p> + <div tabIndex={-1} css={[styles, selectable, isString ? floatingStyles : null]}> + {props.content} + </div> ); } 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 61e66e0..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,28 +60,16 @@ class RenderedQuestion extends React.Component<QuestionProp> { } this.blurHandler = this.blurHandler.bind(this); - const _state: {[key: string]: string | boolean | null} = { - "valid": true, - "error": "", - }; - - if (props.question.type === QuestionType.Code) { - _state["unittestsFailed"] = false; - } - - if (!skip_normal_state.includes(props.question.type)) { - _state["value"] = ""; - } + this.state = { + value: skip_normal_state.includes(props.question.type) ? null : "", + valid: true, + error: "", - 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. @@ -64,45 +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); + 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,78 +178,74 @@ class RenderedQuestion extends React.Component<QuestionProp> { return; } - let invalid = false; - let unittest_failed = 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.props.public_state.get("unittestsFailed")) { - unittest_failed = 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 if (unittest_failed) { - this.setPublicState("error", "1 or more unittests failed."); - 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; + 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; + } + } - case QuestionType.Range: - case QuestionType.Radio: - case QuestionType.Select: - this.setPublicState("value", null); - break; - } + generateUnitTestErrorMessage(): JSX.Element { + let inner; + + if (this.realState.testFailure) { + inner = <div> + {"Unittest Failure:\n"} + <ul css={css`font-size: 1rem;`}> + {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${this.realState.error}`; } + + const element = <div css={css`white-space: pre-wrap; word-wrap: break-word;`}>{inner}</div>; + return <ErrorMessage show={!this.realState.valid} content={element}/>; } render(): JSX.Element { @@ -295,22 +304,19 @@ class RenderedQuestion extends React.Component<QuestionProp> { margin-left: 0.2rem; } `; - let valid = true; - 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.realState.unittestsFailed) { + error = this.generateUnitTestErrorMessage(); + } else { + 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) } - <ErrorMessage show={!valid} message={error} /> + { create_input(this, this.handler, this.blurHandler, this.props.focus_ref) } + {error} <hr css={css`color: gray; margin: 3rem 0;`}/> </div>; } |