aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views/common/dialog/OperationDialog.tsx
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-07-11 18:45:25 +0800
committercrupest <crupest@outlook.com>2023-07-11 18:45:25 +0800
commit78f0934815a87573289c8e52af2666ea38c93251 (patch)
tree2a46597d7fd5ef64e07e557dfba4a62412e96e16 /FrontEnd/src/views/common/dialog/OperationDialog.tsx
parent82d747f694288db25ebd3f2aef146c8382017158 (diff)
downloadtimeline-78f0934815a87573289c8e52af2666ea38c93251.tar.gz
timeline-78f0934815a87573289c8e52af2666ea38c93251.tar.bz2
timeline-78f0934815a87573289c8e52af2666ea38c93251.zip
Fix dialog typo.
Diffstat (limited to 'FrontEnd/src/views/common/dialog/OperationDialog.tsx')
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.tsx531
1 files changed, 531 insertions, 0 deletions
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..71be030a
--- /dev/null
+++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
@@ -0,0 +1,531 @@
+import { useState } from "react";
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { TwitterPicker } from "react-color";
+import classNames from "classnames";
+import moment from "moment";
+
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
+
+import { PaletteColorType } from "@/palette";
+
+import Button from "../button/Button";
+import LoadingButton from "../button/LoadingButton";
+import Dialog from "./Dialog";
+
+import "./OperationDialog.css";
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="cru-color-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export interface OperationDialogTextInput {
+ type: "text";
+ label?: I18nText;
+ password?: boolean;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "value" | "onChange"
+ >;
+ helperText?: string;
+}
+
+export interface OperationDialogBoolInput {
+ type: "bool";
+ label: I18nText;
+ initValue?: boolean;
+ helperText?: string;
+}
+
+export interface OperationDialogSelectInputOption {
+ value: string;
+ label: I18nText;
+ icon?: React.ReactElement;
+}
+
+export interface OperationDialogSelectInput {
+ type: "select";
+ label: I18nText;
+ options: OperationDialogSelectInputOption[];
+ initValue?: string;
+}
+
+export interface OperationDialogColorInput {
+ type: "color";
+ label?: I18nText;
+ initValue?: string | null;
+ canBeNull?: boolean;
+}
+
+export interface OperationDialogDateTimeInput {
+ type: "datetime";
+ label?: I18nText;
+ initValue?: string;
+ helperText?: string;
+}
+
+export type OperationDialogInput =
+ | OperationDialogTextInput
+ | OperationDialogBoolInput
+ | OperationDialogSelectInput
+ | OperationDialogColorInput
+ | OperationDialogDateTimeInput;
+
+interface OperationInputTypeStringToValueTypeMap {
+ text: string;
+ bool: boolean;
+ select: string;
+ color: string | null;
+ datetime: string;
+}
+
+type MapOperationInputTypeStringToValueType<Type> =
+ Type extends keyof OperationInputTypeStringToValueTypeMap
+ ? OperationInputTypeStringToValueTypeMap[Type]
+ : never;
+
+type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
+ ? MapOperationInputTypeStringToValueType<T["type"]>
+ : T;
+
+const initValueMapperMap: {
+ [T in OperationDialogInput as T["type"]]: (
+ item: T
+ ) => MapOperationInputInfoValueType<T>;
+} = {
+ bool: (item) => item.initValue ?? false,
+ color: (item) => item.initValue ?? null,
+ datetime: (item) => {
+ if (item.initValue != null) {
+ return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss");
+ } else {
+ return "";
+ }
+ },
+ select: (item) => item.initValue ?? item.options[0].value,
+ text: (item) => item.initValue ?? "",
+};
+
+type MapOperationInputInfoValueTypeList<
+ Tuple extends readonly OperationDialogInput[]
+> = {
+ [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
+} & { length: Tuple["length"] };
+
+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<
+ TData,
+ OperationInputInfoList extends readonly OperationDialogInput[]
+> {
+ open: boolean;
+ onClose: () => void;
+ title: I18nText | (() => React.ReactNode);
+ themeColor?: PaletteColorType;
+ onProcess: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => Promise<TData>;
+ inputScheme?: OperationInputInfoList;
+ inputValidator?: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => OperationInputError;
+ inputPrompt?: I18nText | (() => React.ReactNode);
+ processPrompt?: () => React.ReactNode;
+ successPrompt?: (data: TData) => React.ReactNode;
+ failurePrompt?: (error: unknown) => React.ReactNode;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+const OperationDialog = <
+ TData,
+ OperationInputInfoList extends readonly OperationDialogInput[]
+>(
+ props: OperationDialogProps<TData, OperationInputInfoList>
+): React.ReactElement => {
+ const inputScheme = (props.inputScheme ??
+ []) as readonly OperationDialogInput[];
+
+ const { t } = useTranslation();
+
+ type Step =
+ | { type: "input" }
+ | { type: "process" }
+ | {
+ type: "success";
+ data: TData;
+ }
+ | {
+ type: "failure";
+ data: unknown;
+ };
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ type ValueType = boolean | string | null | undefined;
+
+ const [values, setValues] = useState<ValueType[]>(
+ inputScheme.map((item) => {
+ if (item.type in initValueMapperMap) {
+ return (
+ initValueMapperMap[item.type] as (
+ i: OperationDialogInput
+ ) => ValueType
+ )(item);
+ } else {
+ throw new UiLogicError("Unknown input scheme.");
+ }
+ })
+ );
+ const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
+ inputScheme.map(() => false)
+ );
+ const [inputError, setInputError] = useState<OperationInputError>();
+
+ const close = (): void => {
+ if (step.type !== "process") {
+ props.onClose();
+ if (step.type === "success" && props.onSuccessAndClose) {
+ props.onSuccessAndClose(step.data);
+ }
+ } else {
+ console.log("Attempt to close modal when processing.");
+ }
+ };
+
+ const onConfirm = (): void => {
+ setStep({ type: "process" });
+ props
+ .onProcess(
+ values.map((v, index) => {
+ if (inputScheme[index].type === "datetime" && v !== "")
+ return new Date(v as string).toISOString();
+ else return v;
+ }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ )
+ .then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step.type === "input" || step.type === "process") {
+ const process = step.type === "process";
+
+ let inputPrompt =
+ typeof props.inputPrompt === "function"
+ ? props.inputPrompt()
+ : convertI18nText(props.inputPrompt, t);
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ const validate = (values: ValueType[]): 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: ValueType): void => {
+ const oldValues = values;
+ const newValues = oldValues.slice();
+ newValues[index] = newValue;
+ setValues(newValues);
+ if (dirtyList[index] === false) {
+ const newDirtyList = dirtyList.slice();
+ newDirtyList[index] = true;
+ setDirtyList(newDirtyList);
+ }
+ validate(newValues);
+ };
+
+ const canProcess = isNoError(inputError);
+
+ body = (
+ <>
+ <div>
+ {inputPrompt}
+ {inputScheme.map((item, index) => {
+ const value = values[index];
+ const error: string | null =
+ dirtyList[index] && inputError != null
+ ? convertI18nText(inputError[index], t)
+ : null;
+
+ if (item.type === "text") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.label && (
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ )}
+ <input
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ disabled={process}
+ />
+ {error != null && (
+ <div className="cru-operation-dialog-error-text">
+ {error}
+ </div>
+ )}
+ {item.helperText && (
+ <div className="cru-operation-dialog-helper-text">
+ {t(item.helperText)}
+ </div>
+ )}
+ </div>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ <input
+ type="checkbox"
+ checked={value as boolean}
+ onChange={(event) => {
+ updateValue(index, event.currentTarget.checked);
+ }}
+ disabled={process}
+ />
+ <label className="cru-operation-dialog-inline-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ {error != null && (
+ <div className="cru-operation-dialog-error-text">
+ {error}
+ </div>
+ )}
+ {item.helperText && (
+ <div className="cru-operation-dialog-helper-text">
+ {t(item.helperText)}
+ </div>
+ )}
+ </div>
+ );
+ } else if (item.type === "select") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ <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}
+ {convertI18nText(option.label, t)}
+ </option>
+ );
+ })}
+ </select>
+ </div>
+ );
+ } else if (item.type === "color") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.canBeNull ? (
+ <input
+ type="checkbox"
+ checked={value !== null}
+ onChange={(event) => {
+ if (event.currentTarget.checked) {
+ updateValue(index, "#007bff");
+ } else {
+ updateValue(index, null);
+ }
+ }}
+ disabled={process}
+ />
+ ) : null}
+ <label className="cru-operation-dialog-inline-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ {value !== null && (
+ <TwitterPicker
+ color={value as string}
+ triangle="hide"
+ onChange={(result) => updateValue(index, result.hex)}
+ />
+ )}
+ </div>
+ );
+ } else if (item.type === "datetime") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.label && (
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ )}
+ <input
+ type="datetime-local"
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ disabled={process}
+ />
+ {error != null && <div>{error}</div>}
+ </div>
+ );
+ }
+ })}
+ </div>
+ <hr />
+ <div className="cru-dialog-bottom-area">
+ <Button
+ text="operationDialog.cancel"
+ color="secondary"
+ outline
+ onClick={close}
+ disabled={process}
+ />
+ <LoadingButton
+ color={props.themeColor}
+ loading={process}
+ disabled={!canProcess}
+ onClick={() => {
+ setDirtyList(inputScheme.map(() => true));
+ if (validate(values)) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </LoadingButton>
+ </div>
+ </>
+ );
+ } 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="cru-color-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <div>{content}</div>
+ <hr />
+ <div className="cru-dialog-bottom-area">
+ <Button text="operationDialog.ok" color="primary" onClick={close} />
+ </div>
+ </>
+ );
+ }
+
+ const title =
+ typeof props.title === "function"
+ ? props.title()
+ : convertI18nText(props.title, t);
+
+ return (
+ <Dialog open={props.open} onClose={close}>
+ <h3
+ className={
+ props.themeColor != null
+ ? "cru-color-" + props.themeColor
+ : "cru-color-primary"
+ }
+ >
+ {title}
+ </h3>
+ <hr />
+ {body}
+ </Dialog>
+ );
+};
+
+export default OperationDialog;