From b082a3ba08c9782c0834af77e6c3f0438534ad72 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Wed, 4 Nov 2020 10:31:34 +0800
Subject: refactor: Refactor operation dialog.

---
 FrontEnd/src/app/views/common/OperationDialog.tsx | 222 +++++++++++-----------
 1 file changed, 116 insertions(+), 106 deletions(-)

(limited to 'FrontEnd/src/app/views/common/OperationDialog.tsx')

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}>
-- 
cgit v1.2.3

