From 78f0934815a87573289c8e52af2666ea38c93251 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 11 Jul 2023 18:45:25 +0800 Subject: Fix dialog typo. --- FrontEnd/src/views/common/dialog/ConfirmDialog.tsx | 43 ++ FrontEnd/src/views/common/dialog/Dialog.css | 55 +++ FrontEnd/src/views/common/dialog/Dialog.tsx | 46 ++ .../src/views/common/dialog/FullPageDialog.css | 44 ++ .../src/views/common/dialog/FullPageDialog.tsx | 53 ++ .../src/views/common/dialog/OperationDialog.css | 25 + .../src/views/common/dialog/OperationDialog.tsx | 531 +++++++++++++++++++++ 7 files changed, 797 insertions(+) create mode 100644 FrontEnd/src/views/common/dialog/ConfirmDialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/Dialog.css create mode 100644 FrontEnd/src/views/common/dialog/Dialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.css create mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.css create mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.tsx (limited to 'FrontEnd/src/views/common/dialog') diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..8c2cea5a --- /dev/null +++ b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx @@ -0,0 +1,43 @@ +import { convertI18nText, I18nText } from "@/common"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "../button/Button"; +import Dialog from "./Dialog"; + +const ConfirmDialog: React.FC<{ + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ open, onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + +

{convertI18nText(title, t)}

+
+

{convertI18nText(body, t)}

+
+
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css new file mode 100644 index 00000000..21ea52fc --- /dev/null +++ b/FrontEnd/src/views/common/dialog/Dialog.css @@ -0,0 +1,55 @@ +.cru-dialog-overlay { + position: fixed; + z-index: 1040; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.92); + + display: flex; + padding: 2em; + + overflow: auto; +} + +.cru-dialog-container { + max-width: 100%; + min-width: 30vw; + + margin: auto; + + border: var(--cru-primary-color) 1px solid; + border-radius: 5px; + padding: 1.5em; + background-color: white; +} + +.cru-dialog-bottom-area { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-bottom-area > * { + margin: 0 0.5em; +} + +.cru-dialog-enter .cru-dialog-container { + transform: scale(0, 0); + opacity: 0; + transform-origin: center; +} + +.cru-dialog-enter-active .cru-dialog-container { + transform: scale(1, 1); + opacity: 1; + transition: transform 0.3s, opacity 0.3s; + transform-origin: center; +} + +.cru-dialog-exit-active .cru-dialog-container { + transition: transform 0.3s, opacity 0.3s; + transform: scale(0, 0); + opacity: 0; + transform-origin: center; +} diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx new file mode 100644 index 00000000..c755950d --- /dev/null +++ b/FrontEnd/src/views/common/dialog/Dialog.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import ReactDOM from "react-dom"; +import { CSSTransition } from "react-transition-group"; + +import "./Dialog.css"; + +export interface DialogProps { + onClose: () => void; + open: boolean; + children?: React.ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog(props: DialogProps): React.ReactElement | null { + const { open, onClose, children, disableCloseOnClickOnOverlay } = props; + + return ReactDOM.createPortal( + +
{ + onClose(); + } + } + > +
e.stopPropagation()} + > + {children} +
+
+
, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ); +} diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css new file mode 100644 index 00000000..2f1fc636 --- /dev/null +++ b/FrontEnd/src/views/common/dialog/FullPageDialog.css @@ -0,0 +1,44 @@ +.cru-full-page { + position: fixed; + z-index: 1030; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: white; + padding-top: 56px; +} + +.cru-full-page-top-bar { + height: 56px; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + background-color: var(--cru-primary-color); + display: flex; + align-items: center; +} + +.cru-full-page-content-container { + overflow: scroll; +} + +.cru-full-page-back-button { + color: var(--cru-primary-t-color); +} + +.cru-full-page-enter { + transform: translate(100%, 0); +} + +.cru-full-page-enter-active { + transform: none; + transition: transform 0.3s; +} + +.cru-full-page-exit-active { + transition: transform 0.3s; + transform: translate(100%, 0); +} diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..6368fc0a --- /dev/null +++ b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import classnames from "classnames"; +import { CSSTransition } from "react-transition-group"; + +import "./FullPageDialog.css"; +import IconButton from "../button/IconButton"; + +export interface FullPageDialogProps { + show: boolean; + onBack: () => void; + contentContainerClassName?: string; + children: React.ReactNode; +} + +const FullPageDialog: React.FC = ({ + show, + onBack, + children, + contentContainerClassName, +}) => { + return createPortal( + +
+
+ +
+
+ {children} +
+
+
, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ); +}; + +export default FullPageDialog; diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css new file mode 100644 index 00000000..2f7617d0 --- /dev/null +++ b/FrontEnd/src/views/common/dialog/OperationDialog.css @@ -0,0 +1,25 @@ +.cru-operation-dialog-group { + display: block; + margin: 0.4em 0; +} + +.cru-operation-dialog-label { + display: block; + color: var(--cru-primary-color); +} + +.cru-operation-dialog-inline-label { + margin-inline-start: 0.5em; +} + +.cru-operation-dialog-error-text { + display: block; + font-size: 0.8em; + color: var(--cru-danger-color); +} + +.cru-operation-dialog-helper-text { + display: block; + font-size: 0.8em; + color: var(--cru-primary-color); +} 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 = (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" + >; + 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 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; + onClose: () => void; + title: I18nText | (() => React.ReactNode); + themeColor?: PaletteColorType; + 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.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 + ) + .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 && ( + + )} + { + const v = e.target.value; + updateValue(index, v); + }} + disabled={process} + /> + {error != null && ( +
+ {error} +
+ )} + {item.helperText && ( +
+ {t(item.helperText)} +
+ )} +
+ ); + } else if (item.type === "bool") { + return ( +
+ { + updateValue(index, event.currentTarget.checked); + }} + disabled={process} + /> + + {error != null && ( +
+ {error} +
+ )} + {item.helperText && ( +
+ {t(item.helperText)} +
+ )} +
+ ); + } else if (item.type === "select") { + return ( +
+ + +
+ ); + } else if (item.type === "color") { + return ( +
+ {item.canBeNull ? ( + { + if (event.currentTarget.checked) { + updateValue(index, "#007bff"); + } else { + updateValue(index, null); + } + }} + disabled={process} + /> + ) : null} + + {value !== null && ( + updateValue(index, result.hex)} + /> + )} +
+ ); + } else if (item.type === "datetime") { + return ( +
+ {item.label && ( + + )} + { + const v = e.target.value; + updateValue(index, v); + }} + disabled={process} + /> + {error != null &&
{error}
} +
+ ); + } + })} +
+
+
+
+ + ); + } 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; -- cgit v1.2.3