diff options
Diffstat (limited to 'FrontEnd/src/app/views/common')
-rw-r--r-- | FrontEnd/src/app/views/common/AppBar.tsx | 26 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/OperationDialog.tsx | 222 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/SearchInput.tsx | 12 |
3 files changed, 136 insertions, 124 deletions
diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx index ee4ead8f..699c596e 100644 --- a/FrontEnd/src/app/views/common/AppBar.tsx +++ b/FrontEnd/src/app/views/common/AppBar.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { LinkContainer } from "react-router-bootstrap"; import { Navbar, Nav } from "react-bootstrap"; +import { NavLink } from "react-router-dom"; import { useUser, useAvatar } from "@/services/user"; @@ -28,18 +29,17 @@ const AppBar: React.FC = (_) => { <Navbar.Toggle /> <Navbar.Collapse> <Nav className="mr-auto"> - <LinkContainer to="/settings"> - <Nav.Link>{t("nav.settings")}</Nav.Link> - </LinkContainer> - - <LinkContainer to="/about"> - <Nav.Link>{t("nav.about")}</Nav.Link> - </LinkContainer> + <NavLink to="/settings" className="nav-link" activeClassName="active"> + {t("nav.settings")} + </NavLink> + <NavLink to="/about" className="nav-link" activeClassName="active"> + {t("nav.about")} + </NavLink> {isAdministrator && ( - <LinkContainer to="/admin"> - <Nav.Link>Administration</Nav.Link> - </LinkContainer> + <NavLink to="/admin" className="nav-link" activeClassName="active"> + Administration + </NavLink> )} </Nav> <Nav className="ml-auto mr-2"> @@ -51,9 +51,9 @@ const AppBar: React.FC = (_) => { /> </LinkContainer> ) : ( - <LinkContainer to="/login"> - <Nav.Link>{t("nav.login")}</Nav.Link> - </LinkContainer> + <NavLink to="/login" className="nav-link" activeClassName="active"> + {t("nav.login")} + </NavLink> )} </Nav> </Navbar.Collapse> diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx index 841392a6..e32e9277 100644 --- a/FrontEnd/src/app/views/common/OperationDialog.tsx +++ b/FrontEnd/src/app/views/common/OperationDialog.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Form, Button, Modal } from "react-bootstrap"; -import { UiLogicError } from "@/common"; +import { convertI18nText, I18nText, UiLogicError } from "@/common"; import LoadingButton from "./LoadingButton"; @@ -27,45 +27,33 @@ const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { return result; }; -export type OperationInputOptionalError = undefined | null | string; - -export interface OperationInputErrorInfo { - [index: number]: OperationInputOptionalError; -} - -export type OperationInputValidator<TValue> = ( - value: TValue, - values: (string | boolean)[] -) => OperationInputOptionalError | OperationInputErrorInfo; - export interface OperationTextInputInfo { type: "text"; + label?: I18nText; password?: boolean; - label?: string; initValue?: string; textFieldProps?: Omit< React.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "aria-relevant" >; helperText?: string; - validator?: OperationInputValidator<string>; } export interface OperationBoolInputInfo { type: "bool"; - label: string; + label: I18nText; initValue?: boolean; } export interface OperationSelectInputInfoOption { value: string; - label: string; + label: I18nText; icon?: React.ReactElement; } export interface OperationSelectInputInfo { type: "select"; - label: string; + label: I18nText; options: OperationSelectInputInfoOption[]; initValue?: string; } @@ -75,27 +63,67 @@ export type OperationInputInfo = | OperationBoolInputInfo | OperationSelectInputInfo; +type MapOperationInputInfoValueType<T> = T extends OperationTextInputInfo + ? string + : T extends OperationBoolInputInfo + ? boolean + : T extends OperationSelectInputInfo + ? string + : never; + +type MapOperationInputInfoValueTypeList< + Tuple extends readonly OperationInputInfo[] +> = { + [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>; +} & { length: Tuple["length"] }; + interface OperationResult { type: "success" | "failure"; data: unknown; } -interface OperationDialogProps { +export type OperationInputError = + | { + [index: number]: I18nText | null | undefined; + } + | null + | undefined; + +const isNoError = (error: OperationInputError): boolean => { + if (error == null) return true; + for (const key in error) { + if (error[key] != null) return false; + } + return true; +}; + +export interface OperationDialogProps< + OperationInputInfoList extends readonly OperationInputInfo[] +> { open: boolean; close: () => void; - title: React.ReactNode; + title: I18nText | (() => React.ReactNode); titleColor?: "default" | "dangerous" | "create" | string; - onProcess: (inputs: (string | boolean)[]) => Promise<unknown>; - inputScheme?: OperationInputInfo[]; - inputPrompt?: string | (() => React.ReactNode); + onProcess: ( + inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> + ) => Promise<unknown>; + inputScheme?: OperationInputInfoList; + inputValidator?: ( + inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> + ) => OperationInputError; + inputPrompt?: I18nText | (() => React.ReactNode); processPrompt?: () => React.ReactNode; successPrompt?: (data: unknown) => React.ReactNode; failurePrompt?: (error: unknown) => React.ReactNode; onSuccessAndClose?: () => void; } -const OperationDialog: React.FC<OperationDialogProps> = (props) => { - const inputScheme = props.inputScheme ?? []; +const OperationDialog = < + OperationInputInfoList extends readonly OperationInputInfo[] +>( + props: OperationDialogProps<OperationInputInfoList> +): React.ReactElement => { + const inputScheme = props.inputScheme as readonly OperationInputInfo[]; const { t } = useTranslation(); @@ -112,7 +140,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { } }) ); - const [inputError, setInputError] = useState<OperationInputErrorInfo>({}); + const [dirtyList, setDirtyList] = useState<boolean[]>(() => + inputScheme.map(() => false) + ); + const [inputError, setInputError] = useState<OperationInputError>(); const close = (): void => { if (step !== "process") { @@ -131,20 +162,26 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { const onConfirm = (): void => { setStep("process"); - props.onProcess(values).then( - (d: unknown) => { - setStep({ - type: "success", - data: d, - }); - }, - (e: unknown) => { - setStep({ - type: "failure", - data: e, - }); - } - ); + props + .onProcess( + (values as unknown) as MapOperationInputInfoValueTypeList< + OperationInputInfoList + > + ) + .then( + (d: unknown) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + } + ); }; let body: React.ReactNode; @@ -154,65 +191,37 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { let inputPrompt = typeof props.inputPrompt === "function" ? props.inputPrompt() - : props.inputPrompt; + : convertI18nText(props.inputPrompt, t); inputPrompt = <h6>{inputPrompt}</h6>; - const updateValue = ( - index: number, - newValue: string | boolean - ): (string | boolean)[] => { + const validate = (values: (string | boolean)[]): boolean => { + const { inputValidator } = props; + if (inputValidator != null) { + const result = inputValidator( + (values as unknown) as MapOperationInputInfoValueTypeList< + OperationInputInfoList + > + ); + setInputError(result); + return isNoError(result); + } + return true; + }; + + const updateValue = (index: number, newValue: string | boolean): void => { const oldValues = values; const newValues = oldValues.slice(); newValues[index] = newValue; setValues(newValues); - return newValues; - }; - - const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => { - for (let i = 0; i < inputScheme.length; i++) { - if (inputScheme[i].type === "text" && errorInfo[i] != null) { - return true; - } - } - return false; - }; - - const calculateError = ( - oldError: OperationInputErrorInfo, - index: number, - newError: OperationInputOptionalError | OperationInputErrorInfo - ): OperationInputErrorInfo => { - if (newError === undefined) { - return oldError; - } else if (newError === null || typeof newError === "string") { - return { ...oldError, [index]: newError }; - } else { - const newInputError: OperationInputErrorInfo = { ...oldError }; - for (const [index, error] of Object.entries(newError)) { - if (error !== undefined) { - newInputError[+index] = error as OperationInputOptionalError; - } - } - return newInputError; + if (dirtyList[index] === false) { + const newDirtyList = dirtyList.slice(); + newDirtyList[index] = true; + setDirtyList(newDirtyList); } + validate(newValues); }; - const validateAll = (): boolean => { - let newInputError = inputError; - for (let i = 0; i < inputScheme.length; i++) { - const item = inputScheme[i]; - if (item.type === "text") { - newInputError = calculateError( - newInputError, - i, - item.validator?.(values[i] as string, values) - ); - } - } - const result = !testErrorInfo(newInputError); - setInputError(newInputError); - return result; - }; + const canProcess = isNoError(inputError); body = ( <> @@ -220,26 +229,23 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { {inputPrompt} {inputScheme.map((item, index) => { const value = values[index]; - const error: string | undefined = ((e) => - typeof e === "string" ? t(e) : undefined)(inputError?.[index]); + const error: string | null = + dirtyList[index] && inputError != null + ? convertI18nText(inputError[index], t) + : null; if (item.type === "text") { return ( <Form.Group key={index}> - {item.label && <Form.Label>{t(item.label)}</Form.Label>} + {item.label && ( + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + )} <Form.Control type={item.password === true ? "password" : "text"} value={value as string} onChange={(e) => { const v = e.target.value; - const newValues = updateValue(index, v); - setInputError( - calculateError( - inputError, - index, - item.validator?.(v, newValues) - ) - ); + updateValue(index, v); }} isInvalid={error != null} disabled={process} @@ -263,7 +269,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { onChange={(event) => { updateValue(index, event.currentTarget.checked); }} - label={t(item.label)} + label={convertI18nText(item.label, t)} disabled={process} /> </Form.Group> @@ -271,7 +277,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { } else if (item.type === "select") { return ( <Form.Group key={index}> - <Form.Label>{t(item.label)}</Form.Label> + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> <Form.Control as="select" value={value as string} @@ -284,7 +290,7 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { return ( <option value={option.value} key={i}> {option.icon} - {t(option.label)} + {convertI18nText(option.label, t)} </option> ); })} @@ -301,9 +307,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { <LoadingButton variant="primary" loading={process} - disabled={testErrorInfo(inputError)} + disabled={!canProcess} onClick={() => { - if (validateAll()) { + setDirtyList(inputScheme.map(() => true)); + if (validate(values)) { onConfirm(); } }} @@ -338,7 +345,10 @@ const OperationDialog: React.FC<OperationDialogProps> = (props) => { ); } - const title = typeof props.title === "string" ? t(props.title) : props.title; + const title = + typeof props.title === "function" + ? props.title() + : convertI18nText(props.title, t); return ( <Modal show={props.open} onHide={close}> diff --git a/FrontEnd/src/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx index 9833d515..1373bd68 100644 --- a/FrontEnd/src/app/views/common/SearchInput.tsx +++ b/FrontEnd/src/app/views/common/SearchInput.tsx @@ -36,7 +36,7 @@ const SearchInput: React.FC<SearchInputProps> = (props) => { ); return ( - <Form inline className={clsx("my-2", props.className)}> + <Form inline className={clsx(" flex-sm-nowrap", props.className)}> <Form.Control className="mr-sm-2 flex-grow-1" value={props.value} @@ -44,10 +44,12 @@ const SearchInput: React.FC<SearchInputProps> = (props) => { onKeyPress={onInputKeyPress} placeholder={props.placeholder} /> - <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3"> - {props.additionalButton} - </div> - <div className="mt-2 mt-sm-0 ml-auto ml-sm-0"> + {props.additionalButton ? ( + <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ml-sm-2"> + {props.additionalButton} + </div> + ) : null} + <div className="mt-2 mt-sm-0 flex-shrink-0 ml-auto ml-sm-0"> {props.loading ? ( <Spinner variant="primary" animation="border" /> ) : ( |