From 68ca8b0976efe90c0c40bcae69f0825671b98bad Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 16:23:25 +0800 Subject: Merge front end to this repo. But I need to wait for aspnet core support for custom port and package manager for dev server. --- Timeline/ClientApp/src/common/OperationDialog.tsx | 380 ++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 Timeline/ClientApp/src/common/OperationDialog.tsx (limited to 'Timeline/ClientApp/src/common/OperationDialog.tsx') diff --git a/Timeline/ClientApp/src/common/OperationDialog.tsx b/Timeline/ClientApp/src/common/OperationDialog.tsx new file mode 100644 index 00000000..e7b6612c --- /dev/null +++ b/Timeline/ClientApp/src/common/OperationDialog.tsx @@ -0,0 +1,380 @@ +import React, { useState, InputHTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Spinner, + Container, + ModalBody, + Label, + Input, + FormGroup, + FormFeedback, + ModalFooter, + Button, + Modal, + ModalHeader, + FormText +} from 'reactstrap'; + +const DefaultProcessPrompt: React.FC = _ => { + return ( + + + + ); +}; + +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 type OperationInputOptionalError = undefined | null | string; + +export interface OperationInputErrorInfo { + [index: number]: OperationInputOptionalError; +} + +export type OperationInputValidator = ( + value: TValue, + values: (string | boolean)[] +) => OperationInputOptionalError | OperationInputErrorInfo; + +export interface OperationTextInputInfo { + type: 'text'; + password?: boolean; + label?: string; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes, + 'type' | 'value' | 'onChange' + >; + helperText?: string; + validator?: OperationInputValidator; +} + +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; + 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 = props => { + const inputScheme = props.inputScheme ?? []; + + const { t } = useTranslation(); + + type Step = 'input' | 'process' | OperationResult; + const [step, setStep] = useState('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 Error('Unknown input scheme.'); + } + }) + ); + const [inputError, setInputError] = useState({}); + + 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 => { + setStep({ + type: 'success', + data: d + }); + }, + e => { + setStep({ + type: 'failure', + data: e + }); + } + ); + }; + + let body: React.ReactNode; + if (step === 'input') { + let inputPrompt = + typeof props.inputPrompt === 'function' + ? props.inputPrompt() + : props.inputPrompt; + inputPrompt =
{inputPrompt}
; + + 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (newInputError as any)[index] = error; + } + } + 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 = ( + <> + + {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 ( + + {item.label && } + { + const v = e.target.value; + const newValues = updateValue(index, v); + setInputError( + calculateError( + inputError, + index, + item.validator?.(v, newValues) + ) + ); + }} + invalid={error != null} + {...item.textFieldProps} + /> + {error != null && {error}} + {item.helperText && {t(item.helperText)}} + + ); + } else if (item.type === 'bool') { + return ( + + { + updateValue( + index, + (e.target as HTMLInputElement).checked + ); + }} + /> + + + ); + } else if (item.type === 'select') { + return ( + + + { + updateValue(index, event.target.value); + }} + > + {item.options.map((option, i) => { + return ( + + ); + })} + + + ); + } + })} + + + + + + + ); + } else if (step === 'process') { + body = ( + + {props.processPrompt?.() ?? } + + ); + } 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 === 'string' ? t(props.title) : props.title; + + return ( + + + {title} + + {body} + + ); +}; + +export default OperationDialog; -- cgit v1.2.3