From f5d10683a1edeba4dabe148ff7aa682c044f7496 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sun, 26 Jul 2020 15:02:55 +0800
Subject: Merge front end repo

---
 .../ClientApp/src/app/common/OperationDialog.tsx   | 381 +++++++++++++++++++++
 1 file changed, 381 insertions(+)
 create mode 100644 Timeline/ClientApp/src/app/common/OperationDialog.tsx

(limited to 'Timeline/ClientApp/src/app/common/OperationDialog.tsx')

diff --git a/Timeline/ClientApp/src/app/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
new file mode 100644
index 00000000..501a353e
--- /dev/null
+++ b/Timeline/ClientApp/src/app/common/OperationDialog.tsx
@@ -0,0 +1,381 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Spinner,
+  Container,
+  ModalBody,
+  Label,
+  Input,
+  FormGroup,
+  FormFeedback,
+  ModalFooter,
+  Button,
+  Modal,
+  ModalHeader,
+  FormText,
+} from 'reactstrap';
+
+import { UiLogicError } from '../common';
+
+const DefaultProcessPrompt: React.FC = (_) => {
+  return (
+    <Container className="justify-content-center align-items-center">
+      <Spinner />
+    </Container>
+  );
+};
+
+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'
+  >;
+  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') {
+    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 = (
+      <>
+        <ModalBody>
+          {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 (
+                <FormGroup key={index}>
+                  {item.label && <Label>{t(item.label)}</Label>}
+                  <Input
+                    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)
+                        )
+                      );
+                    }}
+                    invalid={error != null}
+                    {...item.textFieldProps}
+                  />
+                  {error != null && <FormFeedback>{error}</FormFeedback>}
+                  {item.helperText && <FormText>{t(item.helperText)}</FormText>}
+                </FormGroup>
+              );
+            } else if (item.type === 'bool') {
+              return (
+                <FormGroup check key={index}>
+                  <Input
+                    type="checkbox"
+                    value={value as string}
+                    onChange={(e) => {
+                      updateValue(
+                        index,
+                        (e.target as HTMLInputElement).checked
+                      );
+                    }}
+                  />
+                  <Label check>{t(item.label)}</Label>
+                </FormGroup>
+              );
+            } else if (item.type === 'select') {
+              return (
+                <FormGroup key={index}>
+                  <Label>{t(item.label)}</Label>
+                  <Input
+                    type="select"
+                    value={value as string}
+                    onChange={(event) => {
+                      updateValue(index, event.target.value);
+                    }}
+                  >
+                    {item.options.map((option, i) => {
+                      return (
+                        <option value={option.value} key={i}>
+                          {option.icon}
+                          {t(option.label)}
+                        </option>
+                      );
+                    })}
+                  </Input>
+                </FormGroup>
+              );
+            }
+          })}
+        </ModalBody>
+        <ModalFooter>
+          <Button color="secondary" onClick={close}>
+            {t('operationDialog.cancel')}
+          </Button>
+          <Button
+            color="primary"
+            disabled={testErrorInfo(inputError)}
+            onClick={() => {
+              if (validateAll()) {
+                onConfirm();
+              }
+            }}
+          >
+            {t('operationDialog.confirm')}
+          </Button>
+        </ModalFooter>
+      </>
+    );
+  } else if (step === 'process') {
+    body = (
+      <ModalBody>
+        {props.processPrompt?.() ?? <DefaultProcessPrompt />}
+      </ModalBody>
+    );
+  } 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 = (
+      <>
+        <ModalBody>{content}</ModalBody>
+        <ModalFooter>
+          <Button color="primary" onClick={close}>
+            {t('operationDialog.ok')}
+          </Button>
+        </ModalFooter>
+      </>
+    );
+  }
+
+  const title = typeof props.title === 'string' ? t(props.title) : props.title;
+
+  return (
+    <Modal isOpen={props.open} toggle={close}>
+      <ModalHeader
+        className={
+          props.titleColor != null
+            ? 'text-' +
+              (props.titleColor === 'create'
+                ? 'success'
+                : props.titleColor === 'dangerous'
+                ? 'danger'
+                : props.titleColor)
+            : undefined
+        }
+      >
+        {title}
+      </ModalHeader>
+      {body}
+    </Modal>
+  );
+};
+
+export default OperationDialog;
-- 
cgit v1.2.3

