diff options
author | crupest <crupest@outlook.com> | 2020-11-04 10:31:34 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2020-11-04 10:31:34 +0800 |
commit | 247f15813f914559846109c32f3a2c0e6d373fac (patch) | |
tree | edd36228f51bfdca2354cd5734028dd490a04350 /FrontEnd | |
parent | a581cf642fa0ff06c27e3d3d95af02aec3abd87d (diff) | |
download | timeline-247f15813f914559846109c32f3a2c0e6d373fac.tar.gz timeline-247f15813f914559846109c32f3a2c0e6d373fac.tar.bz2 timeline-247f15813f914559846109c32f3a2c0e6d373fac.zip |
refactor: Refactor operation dialog.
Diffstat (limited to 'FrontEnd')
-rw-r--r-- | FrontEnd/src/app/common.ts | 26 | ||||
-rw-r--r-- | FrontEnd/src/app/views/admin/UserAdmin.tsx | 22 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/OperationDialog.tsx | 222 | ||||
-rw-r--r-- | FrontEnd/src/app/views/home/TimelineCreateDialog.tsx | 39 | ||||
-rw-r--r-- | FrontEnd/src/app/views/settings/index.tsx | 55 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx | 18 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx | 14 | ||||
-rw-r--r-- | FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx | 2 |
8 files changed, 209 insertions, 189 deletions
diff --git a/FrontEnd/src/app/common.ts b/FrontEnd/src/app/common.ts index 0a2d345f..681568bb 100644 --- a/FrontEnd/src/app/common.ts +++ b/FrontEnd/src/app/common.ts @@ -1,5 +1,6 @@ import React from "react"; import { Observable, Subject } from "rxjs"; +import { TFunction } from "i18next"; // This error is thrown when ui goes wrong with bad logic. // Such as a variable should not be null, but it does. @@ -42,3 +43,28 @@ export function useValueEventEmiiter<T>(): [ return [getter, trigger]; }, []); } + +export type I18nText = + | string + | { type: "custom"; value: string } + | { type: "i18n"; value: string }; + +export function convertI18nText(text: I18nText, t: TFunction): string; +export function convertI18nText( + text: I18nText | null | undefined, + t: TFunction +): string | null; +export function convertI18nText( + text: I18nText | null | undefined, + t: TFunction +): string | null { + if (text == null) { + return null; + } else if (typeof text === "string") { + return t(text); + } else if (text.type === "i18n") { + return t(text.value); + } else { + return text.value; + } +} diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx index 18b77ca8..0f5f8796 100644 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -152,16 +152,18 @@ const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => { title="Create" titleColor="create" inputPrompt="You are creating a new user." - inputScheme={[ - { type: "text", label: "Username" }, - { type: "text", label: "Password" }, - { type: "bool", label: "Administrator" }, - ]} + inputScheme={ + [ + { type: "text", label: "Username" }, + { type: "text", label: "Password" }, + { type: "bool", label: "Administrator" }, + ] as const + } onProcess={([username, password, administrator]) => props.process({ - username: username as string, - password: password as string, - administrator: administrator as boolean, + username: username, + password: password, + administrator: administrator, }) } close={props.close} @@ -221,7 +223,7 @@ const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = ( )} inputScheme={[{ type: "text", label: "New Username" }]} onProcess={([newUsername]) => { - return props.process(newUsername as string); + return props.process(newUsername); }} /> ); @@ -245,7 +247,7 @@ const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = ( )} inputScheme={[{ type: "text", label: "New Password" }]} onProcess={([newPassword]) => { - return props.process(newPassword as string); + return props.process(newPassword); }} /> ); 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/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx index d9467719..786ebb5d 100644 --- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx +++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx @@ -20,27 +20,28 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { close={props.close} titleColor="success" title="home.createDialog.title" - inputScheme={[ - { - type: "text", - label: "home.createDialog.name", - helperText: "home.createDialog.nameFormat", - validator: (name) => { - if (name.length === 0) { - return "home.createDialog.noEmpty"; - } else if (name.length > 26) { - return "home.createDialog.tooLong"; - } else if (!validateTimelineName(name)) { - return "home.createDialog.badFormat"; - } else { - return null; - } + inputScheme={ + [ + { + type: "text", + label: "home.createDialog.name", + helperText: "home.createDialog.nameFormat", }, - }, - ]} + ] as const + } + inputValidator={([name]) => { + if (name.length === 0) { + return { 0: "home.createDialog.noEmpty" }; + } else if (name.length > 26) { + return { 0: "home.createDialog.tooLong" }; + } else if (!validateTimelineName(name)) { + return { 0: "home.createDialog.badFormat" }; + } else { + return null; + } + }} onProcess={([name]) => { - nameSaved = name as string; - return timelineService.createTimeline(nameSaved).toPromise(); + return timelineService.createTimeline(name).toPromise(); }} onSuccessAndClose={() => { history.push(`timelines/${nameSaved}`); diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx index 964e7442..4d4f18b5 100644 --- a/FrontEnd/src/app/views/settings/index.tsx +++ b/FrontEnd/src/app/views/settings/index.tsx @@ -4,9 +4,7 @@ import { useTranslation } from "react-i18next"; import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap"; import { useUser, userService } from "@/services/user"; -import OperationDialog, { - OperationInputErrorInfo, -} from "../common/OperationDialog"; +import OperationDialog from "../common/OperationDialog"; interface ChangePasswordDialogProps { open: boolean; @@ -15,60 +13,47 @@ interface ChangePasswordDialogProps { const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { const history = useHistory(); - const { t } = useTranslation(); const [redirect, setRedirect] = useState<boolean>(false); return ( <OperationDialog open={props.open} - title={t("settings.dialogChangePassword.title")} + title="settings.dialogChangePassword.title" titleColor="dangerous" - inputPrompt={t("settings.dialogChangePassword.prompt")} + inputPrompt="settings.dialogChangePassword.prompt" inputScheme={[ { type: "text", - label: t("settings.dialogChangePassword.inputOldPassword"), + label: "settings.dialogChangePassword.inputOldPassword", password: true, - validator: (v) => - v === "" - ? "settings.dialogChangePassword.errorEmptyOldPassword" - : null, }, { type: "text", - label: t("settings.dialogChangePassword.inputNewPassword"), + label: "settings.dialogChangePassword.inputNewPassword", password: true, - validator: (v, values) => { - const error: OperationInputErrorInfo = {}; - error[1] = - v === "" - ? "settings.dialogChangePassword.errorEmptyNewPassword" - : null; - if (v === values[2]) { - error[2] = null; - } else { - if (values[2] !== "") { - error[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; - } - } - return error; - }, }, { type: "text", - label: t("settings.dialogChangePassword.inputRetypeNewPassword"), + label: "settings.dialogChangePassword.inputRetypeNewPassword", password: true, - validator: (v, values) => - v !== values[1] - ? "settings.dialogChangePassword.errorRetypeNotMatch" - : null, }, ]} + inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { + const result: Record<number, string> = {}; + if (oldPassword === "") { + result[0] = "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + result[1] = "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + result[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; + } + return result; + }} onProcess={async ([oldPassword, newPassword]) => { - await userService - .changePassword(oldPassword as string, newPassword as string) - .toPromise(); + await userService.changePassword(oldPassword, newPassword).toPromise(); await userService.logout(); setRedirect(true); }} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx index 223525f9..ee49586e 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -6,9 +6,7 @@ import { TimelineChangePropertyRequest, } from "@/services/timeline"; -import OperationDialog, { - OperationSelectInputInfoOption, -} from "../common/OperationDialog"; +import OperationDialog from "../common/OperationDialog"; export interface TimelinePropertyInfo { title: string; @@ -45,12 +43,10 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> { type: "select", label: "timeline.dialogChangeProperty.visibility", - options: kTimelineVisibilities.map<OperationSelectInputInfoOption>( - (v) => ({ - label: labelMap[v], - value: v, - }) - ), + options: kTimelineVisibilities.map((v) => ({ + label: labelMap[v], + value: v, + })), initValue: props.oldInfo.visibility, }, { @@ -64,13 +60,13 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> onProcess={([newTitle, newVisibility, newDescription]) => { const req: TimelineChangePropertyRequest = {}; if (newTitle !== props.oldInfo.title) { - req.title = newTitle as string; + req.title = newTitle; } if (newVisibility !== props.oldInfo.visibility) { req.visibility = newVisibility as TimelineVisibility; } if (newDescription !== props.oldInfo.description) { - req.description = newDescription as string; + req.description = newDescription; } return props.onProcess(req); }} diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx index 894b8195..33609158 100644 --- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx @@ -33,15 +33,15 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { inputScheme={[ { type: "text", - validator: (value) => { - if (value !== name) { - return "timeline.deleteDialog.notMatch"; - } else { - return null; - } - }, }, ]} + inputValidator={([value]) => { + if (value !== name) { + return { 0: "timeline.deleteDialog.notMatch" }; + } else { + return null; + } + }} onProcess={() => { return timelineService.deleteTimeline(name).toPromise(); }} diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx index 251b18c5..0e95b05b 100644 --- a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx @@ -18,7 +18,7 @@ const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { { type: "text", label: "userPage.dialogChangeNickname.inputLabel" }, ]} onProcess={([newNickname]) => { - return props.onProcess(newNickname as string); + return props.onProcess(newNickname); }} close={props.close} /> |