aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-11-04 10:31:34 +0800
committercrupest <crupest@outlook.com>2020-11-04 10:31:34 +0800
commit247f15813f914559846109c32f3a2c0e6d373fac (patch)
treeedd36228f51bfdca2354cd5734028dd490a04350 /FrontEnd/src
parenta581cf642fa0ff06c27e3d3d95af02aec3abd87d (diff)
downloadtimeline-247f15813f914559846109c32f3a2c0e6d373fac.tar.gz
timeline-247f15813f914559846109c32f3a2c0e6d373fac.tar.bz2
timeline-247f15813f914559846109c32f3a2c0e6d373fac.zip
refactor: Refactor operation dialog.
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/common.ts26
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx22
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx222
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx39
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx55
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx18
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx14
-rw-r--r--FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx2
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}
/>