From 05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 27 Oct 2020 19:21:35 +0800
Subject: Split front and back end.

---
 FrontEnd/src/app/views/common/OperationDialog.tsx | 364 ++++++++++++++++++++++
 1 file changed, 364 insertions(+)
 create mode 100644 FrontEnd/src/app/views/common/OperationDialog.tsx

(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
new file mode 100644
index 00000000..841392a6
--- /dev/null
+++ b/FrontEnd/src/app/views/common/OperationDialog.tsx
@@ -0,0 +1,364 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Form, Button, Modal } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import LoadingButton from "./LoadingButton";
+
+interface DefaultErrorPromptProps {
+  error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+  const { t } = useTranslation();
+
+  let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+  if (props.error != null) {
+    result = (
+      <>
+        {result}
+        <p className="text-danger">{props.error}</p>
+      </>
+    );
+  }
+
+  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";
+  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;
+  initValue?: boolean;
+}
+
+export interface OperationSelectInputInfoOption {
+  value: string;
+  label: string;
+  icon?: React.ReactElement;
+}
+
+export interface OperationSelectInputInfo {
+  type: "select";
+  label: string;
+  options: OperationSelectInputInfoOption[];
+  initValue?: string;
+}
+
+export type OperationInputInfo =
+  | OperationTextInputInfo
+  | OperationBoolInputInfo
+  | OperationSelectInputInfo;
+
+interface OperationResult {
+  type: "success" | "failure";
+  data: unknown;
+}
+
+interface OperationDialogProps {
+  open: boolean;
+  close: () => void;
+  title: React.ReactNode;
+  titleColor?: "default" | "dangerous" | "create" | string;
+  onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
+  inputScheme?: OperationInputInfo[];
+  inputPrompt?: string | (() => 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 { t } = useTranslation();
+
+  type Step = "input" | "process" | OperationResult;
+  const [step, setStep] = useState<Step>("input");
+  const [values, setValues] = useState<(boolean | string)[]>(
+    inputScheme.map((i) => {
+      if (i.type === "bool") {
+        return i.initValue ?? false;
+      } else if (i.type === "text" || i.type === "select") {
+        return i.initValue ?? "";
+      } else {
+        throw new UiLogicError("Unknown input scheme.");
+      }
+    })
+  );
+  const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
+
+  const close = (): void => {
+    if (step !== "process") {
+      props.close();
+      if (
+        typeof step === "object" &&
+        step.type === "success" &&
+        props.onSuccessAndClose
+      ) {
+        props.onSuccessAndClose();
+      }
+    } else {
+      console.log("Attempt to close modal when processing.");
+    }
+  };
+
+  const onConfirm = (): void => {
+    setStep("process");
+    props.onProcess(values).then(
+      (d: unknown) => {
+        setStep({
+          type: "success",
+          data: d,
+        });
+      },
+      (e: unknown) => {
+        setStep({
+          type: "failure",
+          data: e,
+        });
+      }
+    );
+  };
+
+  let body: React.ReactNode;
+  if (step === "input" || step === "process") {
+    const process = step === "process";
+
+    let inputPrompt =
+      typeof props.inputPrompt === "function"
+        ? props.inputPrompt()
+        : props.inputPrompt;
+    inputPrompt = <h6>{inputPrompt}</h6>;
+
+    const updateValue = (
+      index: number,
+      newValue: string | boolean
+    ): (string | boolean)[] => {
+      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;
+      }
+    };
+
+    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;
+    };
+
+    body = (
+      <>
+        <Modal.Body>
+          {inputPrompt}
+          {inputScheme.map((item, index) => {
+            const value = values[index];
+            const error: string | undefined = ((e) =>
+              typeof e === "string" ? t(e) : undefined)(inputError?.[index]);
+
+            if (item.type === "text") {
+              return (
+                <Form.Group key={index}>
+                  {item.label && <Form.Label>{t(item.label)}</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)
+                        )
+                      );
+                    }}
+                    isInvalid={error != null}
+                    disabled={process}
+                  />
+                  {error != null && (
+                    <Form.Control.Feedback type="invalid">
+                      {error}
+                    </Form.Control.Feedback>
+                  )}
+                  {item.helperText && (
+                    <Form.Text>{t(item.helperText)}</Form.Text>
+                  )}
+                </Form.Group>
+              );
+            } else if (item.type === "bool") {
+              return (
+                <Form.Group key={index}>
+                  <Form.Check<"input">
+                    type="checkbox"
+                    checked={value as boolean}
+                    onChange={(event) => {
+                      updateValue(index, event.currentTarget.checked);
+                    }}
+                    label={t(item.label)}
+                    disabled={process}
+                  />
+                </Form.Group>
+              );
+            } else if (item.type === "select") {
+              return (
+                <Form.Group key={index}>
+                  <Form.Label>{t(item.label)}</Form.Label>
+                  <Form.Control
+                    as="select"
+                    value={value as string}
+                    onChange={(event) => {
+                      updateValue(index, event.target.value);
+                    }}
+                    disabled={process}
+                  >
+                    {item.options.map((option, i) => {
+                      return (
+                        <option value={option.value} key={i}>
+                          {option.icon}
+                          {t(option.label)}
+                        </option>
+                      );
+                    })}
+                  </Form.Control>
+                </Form.Group>
+              );
+            }
+          })}
+        </Modal.Body>
+        <Modal.Footer>
+          <Button variant="outline-secondary" onClick={close}>
+            {t("operationDialog.cancel")}
+          </Button>
+          <LoadingButton
+            variant="primary"
+            loading={process}
+            disabled={testErrorInfo(inputError)}
+            onClick={() => {
+              if (validateAll()) {
+                onConfirm();
+              }
+            }}
+          >
+            {t("operationDialog.confirm")}
+          </LoadingButton>
+        </Modal.Footer>
+      </>
+    );
+  } else {
+    let content: React.ReactNode;
+    const result = step;
+    if (result.type === "success") {
+      content =
+        props.successPrompt?.(result.data) ?? t("operationDialog.success");
+      if (typeof content === "string")
+        content = <p className="text-success">{content}</p>;
+    } else {
+      content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+      if (typeof content === "string")
+        content = <DefaultErrorPrompt error={content} />;
+    }
+    body = (
+      <>
+        <Modal.Body>{content}</Modal.Body>
+        <Modal.Footer>
+          <Button variant="primary" onClick={close}>
+            {t("operationDialog.ok")}
+          </Button>
+        </Modal.Footer>
+      </>
+    );
+  }
+
+  const title = typeof props.title === "string" ? t(props.title) : props.title;
+
+  return (
+    <Modal show={props.open} onHide={close}>
+      <Modal.Header
+        className={
+          props.titleColor != null
+            ? "text-" +
+              (props.titleColor === "create"
+                ? "success"
+                : props.titleColor === "dangerous"
+                ? "danger"
+                : props.titleColor)
+            : undefined
+        }
+      >
+        {title}
+      </Modal.Header>
+      {body}
+    </Modal>
+  );
+};
+
+export default OperationDialog;
-- 
cgit v1.2.3

