diff options
| author | 2021-02-21 07:35:29 +0200 | |
|---|---|---|
| committer | 2021-02-21 07:35:29 +0200 | |
| commit | b1f05fa57c862ce8219e5ca464e794353261f842 (patch) | |
| tree | ff67e7265ad52099181ceb0bf2a0af36f0525fdd | |
| parent | Move hCaptcha types library to dev-dependencies (diff) | |
Migrate from public state map to Redux
Diffstat (limited to '')
| -rw-r--r-- | src/App.tsx | 4 | ||||
| -rw-r--r-- | src/components/InputTypes/Checkbox.tsx | 7 | ||||
| -rw-r--r-- | src/components/InputTypes/Select.tsx | 40 | ||||
| -rw-r--r-- | src/components/InputTypes/index.tsx | 13 | ||||
| -rw-r--r-- | src/components/Question.tsx | 153 | ||||
| -rw-r--r-- | src/pages/FormPage.tsx | 38 | ||||
| -rw-r--r-- | src/store/form/actions.ts | 79 | ||||
| -rw-r--r-- | src/store/form/reducers.ts | 29 | ||||
| -rw-r--r-- | src/store/form/store.ts | 4 | ||||
| -rw-r--r-- | src/store/form/types.ts | 5 |
10 files changed, 285 insertions, 87 deletions
diff --git a/src/App.tsx b/src/App.tsx index 523e583..14e5329 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import React, { Suspense } from "react"; import { jsx, css, Global } from "@emotion/react"; +import { Provider } from "react-redux"; import { BrowserRouter as Router, Route, @@ -14,6 +15,7 @@ import { PropagateLoader } from "react-spinners"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import globalStyles from "./globalStyles"; +import { store } from "./store/form/store"; const LandingPage = React.lazy(() => import("./pages/LandingPage")); const FormPage = React.lazy(() => import("./pages/FormPage")); @@ -51,7 +53,7 @@ function App(): JSX.Element { {routes.map(({path, Component}) => ( <Route exact key={path} path={path}> <Suspense fallback={<PageLoading/>}> - <Component/> + {path == "/form/:id" ? <Provider store={store}><Component/></Provider> : <Component/>} </Suspense> </Route> ))} diff --git a/src/components/InputTypes/Checkbox.tsx b/src/components/InputTypes/Checkbox.tsx index 3093caf..b3130c6 100644 --- a/src/components/InputTypes/Checkbox.tsx +++ b/src/components/InputTypes/Checkbox.tsx @@ -3,6 +3,8 @@ import { jsx, css } from "@emotion/react"; import React, { ChangeEvent } from "react"; import colors from "../../colors"; import { multiSelectInput, hiddenInput } from "../../commonStyles"; +import {useSelector} from "react-redux"; +import {FormState} from "../../store/form/types"; interface CheckboxProps { index: number, @@ -53,10 +55,13 @@ const activeStyles = css` `; export default function Checkbox(props: CheckboxProps): JSX.Element { + const values = useSelector<FormState, FormState["values"]>( + state => state.values + ); return ( <label css={[generalStyles, activeStyles]}> <label className="unselected" css={multiSelectInput}> - <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler}/> + <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler} checked={!!values.get(`${("000" + props.index).slice(-4)}. ${props.option}`)}/> <span className="checkmark"/> </label> {props.option}<br/> diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx index 2d0187a..c0f5425 100644 --- a/src/components/InputTypes/Select.tsx +++ b/src/components/InputTypes/Select.tsx @@ -1,13 +1,26 @@ /** @jsx jsx */ import { jsx, css } from "@emotion/react"; import React from "react"; +import { connect } from "react-redux"; + import { hiddenInput, invalidStyles } from "../../commonStyles"; +import { Question } from "../../api/question"; +import { setValue, SetValueAction } from "../../store/form/actions"; +import { FormState } from "../../store/form/types"; interface SelectProps { options: Array<string>, - state_dict: Map<string, string | boolean | null>, valid: boolean, - onBlurHandler: () => void + onBlurHandler: () => void, + question: Question +} + +interface SelectStateProps { + values: Map<string, string | Map<string, boolean> | null> +} + +interface SelectDispatchProps { + setValue: (question: Question, value: string | Map<string, boolean> | null) => SetValueAction } const containerStyles = css` @@ -143,7 +156,7 @@ const optionStyles = css` } `; -class Select extends React.Component<SelectProps> { +class Select extends React.Component<SelectProps & SelectStateProps & SelectDispatchProps> { handler(selected_option: React.RefObject<HTMLDivElement>, event: React.ChangeEvent<HTMLInputElement>): void { const option_container = event.target.parentElement; if (!option_container || !option_container.parentElement || !selected_option.current) { @@ -151,7 +164,7 @@ class Select extends React.Component<SelectProps> { } // Update stored value - this.props.state_dict.set("value", option_container.textContent); + this.props.setValue(this.props.question, option_container.textContent); // Close the menu selected_option.current.focus(); @@ -178,10 +191,10 @@ 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.values.get(this.props.question.id)) { + this.props.setValue(this.props.question, "temporary"); this.props.onBlurHandler(); - this.props.state_dict.set("value", null); + this.props.setValue(this.props.question, null); } } @@ -211,4 +224,15 @@ class Select extends React.Component<SelectProps> { } } -export default Select; +const mapStateToProps = (state: FormState, ownProps: SelectProps): SelectProps & SelectStateProps => { + return { + ...ownProps, + values: state.values + }; +}; + +const mapDispatchToProps = { + setValue +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Select); diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx index bc65248..e816c9c 100644 --- a/src/components/InputTypes/index.tsx +++ b/src/components/InputTypes/index.tsx @@ -6,10 +6,10 @@ 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 {QuestionDispatchProp, QuestionProp, QuestionStateProp} from "../Question"; const require_options: Array<QuestionType> = [ QuestionType.Radio, @@ -19,14 +19,15 @@ const require_options: Array<QuestionType> = [ ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void, onBlurHandler: () => void, focus_ref: React.RefObject<any>): JSX.Element | JSX.Element[] { +export default function create_input(props: QuestionProp & QuestionStateProp & QuestionDispatchProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void, onBlurHandler: () => void, focus_ref: React.RefObject<any>): JSX.Element | JSX.Element[] { let result: JSX.Element | JSX.Element[]; + const question = props.question; // eslint-disable-next-line // @ts-ignore let options: string[] = question.data["options"]; let valid = true; - if (!public_state.get("valid")) { + if (!props.valid.get(question.id)) { valid = false; } @@ -51,7 +52,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 options={options} question={question} valid={valid} onBlurHandler={onBlurHandler}/>; break; case QuestionType.ShortText: diff --git a/src/components/Question.tsx b/src/components/Question.tsx index 0af745e..74c4a71 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -1,11 +1,14 @@ /** @jsx jsx */ import { jsx, css } from "@emotion/react"; import React, { ChangeEvent } from "react"; +import { connect } from "react-redux"; import { Question, QuestionType } from "../api/question"; import { selectable } from "../commonStyles"; import create_input from "./InputTypes"; import ErrorMessage from "./ErrorMessage"; +import { FormState } from "../store/form/types"; +import { setError, SetErrorAction, setValid, SetValidAction, setValue, SetValueAction } from "../store/form/actions"; const skip_normal_state: Array<QuestionType> = [ QuestionType.Radio, @@ -17,14 +20,25 @@ const skip_normal_state: Array<QuestionType> = [ 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> } -class RenderedQuestion extends React.Component<QuestionProp> { - constructor(props: QuestionProp) { +export type QuestionStateProp = { + values: Map<string, string | Map<string, boolean> | null>, + errors: Map<string, string>, + valid: Map<string, boolean>, +}; + +export type QuestionDispatchProp = { + setValue: (question: Question, value: string | Map<string, boolean> | null) => SetValueAction, + setValid: (question: Question, valid: boolean) => SetValidAction, + setError: (question: Question, error: string) => SetErrorAction +}; + +export class RenderedQuestion extends React.Component<QuestionProp & QuestionStateProp & QuestionDispatchProp> { + constructor(props: QuestionProp & QuestionStateProp & QuestionDispatchProp) { super(props); if (props.question.type === QuestionType.TextArea) { this.handler = this.text_area_handler.bind(this); @@ -33,47 +47,39 @@ class RenderedQuestion extends React.Component<QuestionProp> { } this.blurHandler = this.blurHandler.bind(this); - this.setPublicState("valid", true); - this.setPublicState("error", ""); + props.setValid(props.question, true); + props.setError(props.question, ""); if (!skip_normal_state.includes(props.question.type)) { - this.setPublicState("value", ""); + props.setValue(props.question, ""); } } - setPublicState(target: string, value: string | boolean | null, callback?:() => void): void { - this.setState({[target]: value}, callback); - this.props.public_state.set(target, value); - } - // This is here to allow dynamic selection between the general handler, and the textarea handler. handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {} // eslint-disable-line 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.props.values.get(this.props.question.id)) { + this.props.setError(this.props.question, "Field must be filled."); + this.props.setValid(this.props.question, false); } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); + this.props.setError(this.props.question, ""); + this.props.setValid(this.props.question, true); } } } normal_handler(event: ChangeEvent<HTMLInputElement>): void { - let target: string; - let value: string | boolean; + let value: string | [string, boolean]; switch (event.target.type) { case "checkbox": - target = event.target.name; - value = event.target.checked; + value = [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"; + // This handles radios and ranges, as they are both based on the same fundamental input type if (event.target.parentElement) { value = event.target.parentElement.innerText.trimEnd(); } else { @@ -82,11 +88,19 @@ class RenderedQuestion extends React.Component<QuestionProp> { break; default: - target = "value"; value = event.target.value; } - this.setPublicState(target, value); + if (value instanceof Array) { + let values = this.props.values.get(this.props.question.id); + if (!(values instanceof Map)) { + values = new Map<string, boolean>(); + } + values.set(value[0], value[1]); + this.props.setValue(this.props.question, values); + } else { + this.props.setValue(this.props.question, value); + } // Toggle checkbox class if (event.target.type == "checkbox" && event.target.parentElement !== null) { @@ -97,7 +111,7 @@ 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.props.setValid(this.props.question, true); break; case "checkbox": @@ -107,29 +121,30 @@ class RenderedQuestion extends React.Component<QuestionProp> { options.forEach((val, index) => { keys.push(`${("000" + index).slice(-4)}. ${val}`); }); - if (keys.every(v => !this.props.public_state.get(v))) { - this.setPublicState("error", "Field must be filled."); - this.setPublicState("valid", false); + const values = this.props.values.get(this.props.question.id); + if (values instanceof Map && keys.every(v => !values.get(v))) { + this.props.setError(this.props.question, "Field must be filled."); + this.props.setValid(this.props.question, false); } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); + this.props.setError(this.props.question, ""); + this.props.setValid(this.props.question, true); } } break; case "radio": - this.setPublicState("valid", true); - this.setPublicState("error", ""); + this.props.setError(this.props.question, ""); + this.props.setValid(this.props.question, true); break; } } text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void { // We will validate again when focusing out. - this.setPublicState("valid", true); - this.setPublicState("error", ""); + this.props.setError(this.props.question, ""); + this.props.setValid(this.props.question, true); - this.setPublicState("value", event.target.value); + this.props.setValue(this.props.question, event.target.value); } validateField(): void { @@ -142,7 +157,7 @@ class RenderedQuestion extends React.Component<QuestionProp> { switch (this.props.question.type) { case QuestionType.TextArea: case QuestionType.ShortText: - if (this.props.public_state.get("value") === "") { + if (this.props.values.get(this.props.question.id) === "") { invalid = true; } break; @@ -150,7 +165,7 @@ class RenderedQuestion extends React.Component<QuestionProp> { case QuestionType.Select: case QuestionType.Range: case QuestionType.Radio: - if (!this.props.public_state.get("value")) { + if (!this.props.values.get(this.props.question.id)) { invalid = true; } break; @@ -161,7 +176,8 @@ class RenderedQuestion extends React.Component<QuestionProp> { options.forEach((val, index) => { keys.push(`${("000" + index).slice(-4)}. ${val}`); }); - if (keys.every(v => !this.props.public_state.get(v))) { + const values = this.props.values.get(this.props.question.id); + if (values instanceof Map && keys.every(v => !values.get(v))) { invalid = true; } } @@ -169,36 +185,36 @@ class RenderedQuestion extends React.Component<QuestionProp> { } if (invalid) { - this.setPublicState("error", "Field must be filled."); - this.setPublicState("valid", false); + this.props.setError(this.props.question, "Field must be filled."); + this.props.setValid(this.props.question, false); } else { - this.setPublicState("error", ""); - this.setPublicState("valid", true); + this.props.setError(this.props.question, ""); + this.props.setValid(this.props.question, true); } } componentDidMount(): void { // Initialize defaults for complex and nested fields const options: string | string[] = this.props.question.data["options"]; + const values = this.props.values.get(this.props.question.id); - if (this.props.public_state.size === 0) { - switch (this.props.question.type) { - case QuestionType.Checkbox: - if (typeof options === "string") { - return; - } + switch (this.props.question.type) { + case QuestionType.Checkbox: + if (typeof options === "string" || !(values instanceof Map)) { + return; + } - options.forEach((option, index) => { - this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false); - }); - break; + options.forEach((option, index) => { + values.set(`${("000" + index).slice(-4)}. ${option}`, false); + }); + this.props.setValue(this.props.question, values); + break; - case QuestionType.Range: - case QuestionType.Radio: - case QuestionType.Select: - this.setPublicState("value", null); - break; - } + case QuestionType.Range: + case QuestionType.Radio: + case QuestionType.Select: + this.props.setValue(this.props.question, null); + break; } } @@ -250,10 +266,10 @@ class RenderedQuestion extends React.Component<QuestionProp> { } `; let valid = true; - if (!this.props.public_state.get("valid")) { + if (!this.props.valid.get(this.props.question.id)) { valid = false; } - const rawError = this.props.public_state.get("error"); + const rawError = this.props.errors.get(this.props.question.id); let error = ""; if (typeof rawError === "string") { error = rawError; @@ -271,4 +287,19 @@ class RenderedQuestion extends React.Component<QuestionProp> { } } -export default RenderedQuestion; +const mapStateToProps = (state: FormState, ownProps: QuestionProp): QuestionProp & QuestionStateProp => { + return { + ...ownProps, + values: state.values, + errors: state.errors, + valid: state.valid + }; +}; + +const mapDispatchToProps = { + setValue, + setValid, + setError +}; + +export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(RenderedQuestion); diff --git a/src/pages/FormPage.tsx b/src/pages/FormPage.tsx index 1e331b9..e9306e3 100644 --- a/src/pages/FormPage.tsx +++ b/src/pages/FormPage.tsx @@ -5,9 +5,10 @@ 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 { useDispatch, useSelector } from "react-redux"; import HeaderBar from "../components/HeaderBar"; -import RenderedQuestion from "../components/Question"; +import ConnectedRenderedQuestion, { RenderedQuestion } from "../components/Question"; import Loading from "../components/Loading"; import ScrollToTop from "../components/ScrollToTop"; @@ -16,6 +17,8 @@ import colors from "../colors"; import { unselectable } from "../commonStyles"; import { Question, QuestionType } from "../api/question"; import ApiClient from "../api/client"; +import { FormState } from "../store/form/types"; +import { clean } from "../store/form/actions"; interface PathParams { id: string @@ -157,11 +160,21 @@ const closedHeaderStyles = css` function FormPage(): JSX.Element { const { id } = useParams<PathParams>(); + const valid = useSelector<FormState, FormState["valid"]>( + state => state.valid + ); + const values = useSelector<FormState, FormState["values"]>( + state => state.values + ); + + const dispatch = useDispatch(); + const [form, setForm] = useState<Form>(); const [sending, setSending] = useState<boolean>(); const [sent, setSent] = useState<boolean>(); useEffect(() => { + dispatch(clean()); getForm(id).then(form => { setForm(form); }); @@ -203,7 +216,7 @@ function FormPage(): JSX.Element { const questionRef = createRef<RenderedQuestion>(); refMap.set(question.id, questionRef); // eslint-disable-next-line @typescript-eslint/no-explicit-any - return <RenderedQuestion ref={questionRef} focus_ref={createRef<any>()} scroll_ref={createRef<HTMLDivElement>()} question={question} public_state={new Map()} key={index + Date.now()}/>; + return <ConnectedRenderedQuestion ref={questionRef} focus_ref={createRef<any>()} scroll_ref={createRef<HTMLDivElement>()} question={question} key={index + Date.now()}/>; }); async function handleSubmit(event: SyntheticEvent) { @@ -220,8 +233,8 @@ function FormPage(): JSX.Element { 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) { + // In case when field is invalid, add this to invalid fields list + if (valid.get(question.id) === false) { invalidFieldIDs.push(i); } }); @@ -257,22 +270,27 @@ function FormPage(): JSX.Element { case QuestionType.Checkbox: { if (typeof options !== "string") { + console.log(values); + const checkbox_values = values.get(question.id); + console.log(checkbox_values); 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; + if (checkbox_values instanceof Map) { + const pairs: { [key: string]: boolean } = { }; + keys.forEach((val, key) => { + pairs[key] = !!checkbox_values.get(val); + }); + answers[question.id] = pairs; + } } break; } case QuestionType.Code: default: - answers[question.id] = prop.props.public_state.get("value"); + answers[question.id] = values.get(question.id); } }); diff --git a/src/store/form/actions.ts b/src/store/form/actions.ts new file mode 100644 index 0000000..8b48a37 --- /dev/null +++ b/src/store/form/actions.ts @@ -0,0 +1,79 @@ +import { Question } from "../../api/question"; + +// All Redux actions that can be triggered +export enum FormAction { + SET_VALUE = "SET_VALUE", + SET_ERROR = "SET_ERROR", + SET_VALID = "SET_VALID", + CLEAN = "CLEAN" +} + +// This is base for all actions +export interface DefaultFormAction { + type: FormAction +} + +// Return values for actions +export interface SetValueAction extends DefaultFormAction { + type: FormAction.SET_VALUE, + payload: { + question: Question, + value: string | Map<string, boolean> | null + } +} + +export interface SetErrorAction extends DefaultFormAction { + type: FormAction.SET_ERROR, + payload: { + question: Question, + error: string + } +} + +export interface SetValidAction extends DefaultFormAction { + type: FormAction.SET_VALID, + payload: { + question: Question, + valid: boolean + } +} + +export interface CleanAction extends DefaultFormAction { + type: FormAction.CLEAN +} + +export type Action = SetValueAction | SetErrorAction | SetValidAction | CleanAction; + +export function setValue(question: Question, value: string | Map<string, boolean> | null): SetValueAction { + return { + type: FormAction.SET_VALUE, + payload: { + question: question, + value: value + } + }; +} + +export function setError(question: Question, error: string): SetErrorAction { + return { + type: FormAction.SET_ERROR, + payload: { + question: question, + error: error + } + }; +} + +export function setValid(question: Question, valid: boolean): SetValidAction { + return { + type: FormAction.SET_VALID, + payload: { + question: question, + valid: valid + } + }; +} + +export function clean(): CleanAction { + return { type: FormAction.CLEAN }; +} diff --git a/src/store/form/reducers.ts b/src/store/form/reducers.ts new file mode 100644 index 0000000..fcbb33a --- /dev/null +++ b/src/store/form/reducers.ts @@ -0,0 +1,29 @@ +import {Action, FormAction} from "./actions"; +import {FormState} from "./types"; + +export const initialState: FormState = { + values: new Map(), + errors: new Map(), + valid: new Map() +}; + +export function formReducer(state = initialState, action: Action): FormState { + const new_state = state; + switch (action.type) { + case FormAction.SET_VALUE: + new_state.values.set(action.payload.question.id, action.payload.value); + break; + + case FormAction.SET_ERROR: + new_state.errors.set(action.payload.question.id, action.payload.error); + break; + + case FormAction.SET_VALID: + new_state.valid.set(action.payload.question.id, action.payload.valid); + break; + + case FormAction.CLEAN: + return initialState; + } + return new_state; +} diff --git a/src/store/form/store.ts b/src/store/form/store.ts new file mode 100644 index 0000000..4311a2f --- /dev/null +++ b/src/store/form/store.ts @@ -0,0 +1,4 @@ +import { createStore } from "redux"; +import { formReducer } from "./reducers"; + +export const store = createStore(formReducer); diff --git a/src/store/form/types.ts b/src/store/form/types.ts new file mode 100644 index 0000000..1d3e594 --- /dev/null +++ b/src/store/form/types.ts @@ -0,0 +1,5 @@ +export interface FormState { + values: Map<string, string | Map<string, boolean> | null>, + errors: Map<string, string>, + valid: Map<string, boolean> +} |