aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/views/common/OperationDialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/app/views/common/OperationDialog.tsx')
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx364
1 files changed, 364 insertions, 0 deletions
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;