import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Form, Button, Modal } from "react-bootstrap"; import { TwitterPicker } from "react-color"; import moment from "moment"; import { convertI18nText, I18nText, UiLogicError } from "@/common"; import LoadingButton from "./LoadingButton"; interface DefaultErrorPromptProps { error?: string; } const DefaultErrorPrompt: React.FC = (props) => { const { t } = useTranslation(); let result =

{t("operationDialog.error")}

; if (props.error != null) { result = ( <> {result}

{props.error}

); } return result; }; export interface OperationDialogTextInput { type: "text"; label?: I18nText; password?: boolean; initValue?: string; textFieldProps?: Omit< React.InputHTMLAttributes, "type" | "value" | "onChange" | "aria-relevant" >; helperText?: string; } export interface OperationDialogBoolInput { type: "bool"; label: I18nText; initValue?: boolean; } 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; } export type OperationDialogInput = | OperationDialogTextInput | OperationDialogBoolInput | OperationDialogSelectInput | OperationDialogColorInput | OperationDialogDateTimeInput; interface OperationInputTypeStringToValueTypeMap { text: string; bool: boolean; select: string; color: string | null; datetime: string; } type MapOperationInputTypeStringToValueType = Type extends keyof OperationInputTypeStringToValueTypeMap ? OperationInputTypeStringToValueTypeMap[Type] : never; type MapOperationInputInfoValueType = T extends OperationDialogInput ? MapOperationInputTypeStringToValueType : T; const initValueMapperMap: { [T in OperationDialogInput as T["type"]]: ( item: T ) => MapOperationInputInfoValueType; } = { 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; } & { 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; close: () => void; title: I18nText | (() => React.ReactNode); themeColor?: "danger" | "success" | string; onProcess: ( inputs: MapOperationInputInfoValueTypeList ) => Promise; inputScheme?: OperationInputInfoList; inputValidator?: ( inputs: MapOperationInputInfoValueTypeList ) => 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 ): 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({ type: "input" }); type ValueType = boolean | string | null | undefined; const [values, setValues] = useState( 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(() => inputScheme.map(() => false) ); const [inputError, setInputError] = useState(); const close = (): void => { if (step.type !== "process") { props.close(); 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 ) .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 =
{inputPrompt}
; const validate = (values: ValueType[]): boolean => { const { inputValidator } = props; if (inputValidator != null) { const result = inputValidator( values as unknown as MapOperationInputInfoValueTypeList ); 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 = ( <> {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 ( {item.label && ( {convertI18nText(item.label, t)} )} { const v = e.target.value; updateValue(index, v); }} isInvalid={error != null} disabled={process} /> {error != null && ( {error} )} {item.helperText && ( {t(item.helperText)} )} ); } else if (item.type === "bool") { return ( type="checkbox" checked={value as boolean} onChange={(event) => { updateValue(index, event.currentTarget.checked); }} label={convertI18nText(item.label, t)} disabled={process} /> ); } else if (item.type === "select") { return ( {convertI18nText(item.label, t)} { updateValue(index, event.target.value); }} disabled={process} > {item.options.map((option, i) => { return ( ); })} ); } else if (item.type === "color") { return ( {item.canBeNull ? ( type="checkbox" checked={value !== null} onChange={(event) => { if (event.currentTarget.checked) { updateValue(index, "#007bff"); } else { updateValue(index, null); } }} label={convertI18nText(item.label, t)} disabled={process} /> ) : ( {convertI18nText(item.label, t)} )} {value !== null && ( updateValue(index, result.hex)} /> )} ); } else if (item.type === "datetime") { return ( {item.label && ( {convertI18nText(item.label, t)} )} { const v = e.target.value; updateValue(index, v); }} isInvalid={error != null} disabled={process} /> {error != null && ( {error} )} ); } })} { setDirtyList(inputScheme.map(() => true)); if (validate(values)) { onConfirm(); } }} > {t("operationDialog.confirm")} ); } 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 =

{content}

; } else { content = props.failurePrompt?.(result.data) ?? ; if (typeof content === "string") content = ; } body = ( <> {content} ); } const title = typeof props.title === "function" ? props.title() : convertI18nText(props.title, t); return ( {title} {body} ); }; export default OperationDialog;