From b416ee877e3aa2ff1ffdad6740d7e43c0b92ba2f Mon Sep 17 00:00:00 2001
From: Xiao Yijun <xiaoyijun@silverhand.io>
Date: Fri, 1 Apr 2022 13:45:57 +0800
Subject: [PATCH] feat(console): multi-text-input (#472)

---
 .../index.module.scss                         |  11 +-
 .../src/components/MultiTextInput/index.tsx   |  83 +++++++++
 .../src/components/MultiTextInput/types.ts    |  12 ++
 .../src/components/MultiTextInput/utils.ts    |  59 +++++++
 .../src/components/MultilineInput/index.tsx   |  71 --------
 .../src/pages/ApplicationDetails/index.tsx    | 157 +++++++++---------
 packages/console/src/utilities/regex.ts       |   1 +
 packages/phrases/src/locales/en.ts            |   4 +-
 packages/phrases/src/locales/zh-cn.ts         |   4 +-
 9 files changed, 252 insertions(+), 150 deletions(-)
 rename packages/console/src/components/{MultilineInput => MultiTextInput}/index.module.scss (54%)
 create mode 100644 packages/console/src/components/MultiTextInput/index.tsx
 create mode 100644 packages/console/src/components/MultiTextInput/types.ts
 create mode 100644 packages/console/src/components/MultiTextInput/utils.ts
 delete mode 100644 packages/console/src/components/MultilineInput/index.tsx

diff --git a/packages/console/src/components/MultilineInput/index.module.scss b/packages/console/src/components/MultiTextInput/index.module.scss
similarity index 54%
rename from packages/console/src/components/MultilineInput/index.module.scss
rename to packages/console/src/components/MultiTextInput/index.module.scss
index 64585df2b..c289c58c0 100644
--- a/packages/console/src/components/MultilineInput/index.module.scss
+++ b/packages/console/src/components/MultiTextInput/index.module.scss
@@ -9,9 +9,16 @@
     display: flex;
     align-items: center;
 
-    .textField {
+    > :first-child {
       @include _.form-text-field;
-      margin-right: _.unit(3);
+      margin-right: _.unit(2);
+      flex-shrink: 0;
     }
   }
+
+  .error {
+    font: var(--font-body-medium);
+    color: var(--color-error);
+    margin-top: _.unit(2);
+  }
 }
diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx
new file mode 100644
index 000000000..87c4dd6d7
--- /dev/null
+++ b/packages/console/src/components/MultiTextInput/index.tsx
@@ -0,0 +1,83 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import * as textButtonStyles from '@/components/TextButton/index.module.scss';
+import Minus from '@/icons/Minus';
+
+import IconButton from '../IconButton';
+import TextInput from '../TextInput';
+import * as styles from './index.module.scss';
+import { MultiTextInputError } from './types';
+
+type Props = {
+  value: string[];
+  onChange: (value: string[]) => void;
+  error?: MultiTextInputError;
+};
+
+const MultiTextInput = ({ value, onChange, error }: Props) => {
+  const { t } = useTranslation(undefined, {
+    keyPrefix: 'admin_console',
+  });
+
+  const fields = useMemo(() => {
+    if (value.length === 0) {
+      return [''];
+    }
+
+    return value;
+  }, [value]);
+
+  const handleAdd = () => {
+    onChange([...fields, '']);
+  };
+
+  const handleRemove = (index: number) => {
+    onChange(fields.filter((_, i) => i !== index));
+  };
+
+  const handleInputChange = (inputValue: string, index: number) => {
+    onChange(fields.map((fieldValue, i) => (i === index ? inputValue : fieldValue)));
+  };
+
+  return (
+    <div className={styles.multilineInput}>
+      {fields.map((fieldValue, fieldIndex) => (
+        // eslint-disable-next-line react/no-array-index-key
+        <div key={fieldIndex}>
+          <div className={styles.deletableInput}>
+            <TextInput
+              hasError={Boolean(
+                error?.inputs?.[fieldIndex] || (fieldIndex === 0 && error?.required)
+              )}
+              value={fieldValue}
+              onChange={({ currentTarget: { value } }) => {
+                handleInputChange(value, fieldIndex);
+              }}
+            />
+            {fields.length > 1 && (
+              <IconButton
+                onClick={() => {
+                  handleRemove(fieldIndex);
+                }}
+              >
+                <Minus />
+              </IconButton>
+            )}
+          </div>
+          {fieldIndex === 0 && error?.required && (
+            <div className={styles.error}>{error.required}</div>
+          )}
+          {error?.inputs?.[fieldIndex] && (
+            <div className={styles.error}>{error.inputs[fieldIndex]}</div>
+          )}
+        </div>
+      ))}
+      <div className={textButtonStyles.button} onClick={handleAdd}>
+        {t('form.add_another')}
+      </div>
+    </div>
+  );
+};
+
+export default MultiTextInput;
diff --git a/packages/console/src/components/MultiTextInput/types.ts b/packages/console/src/components/MultiTextInput/types.ts
new file mode 100644
index 000000000..1390ed93e
--- /dev/null
+++ b/packages/console/src/components/MultiTextInput/types.ts
@@ -0,0 +1,12 @@
+export type MultiTextInputError = {
+  required?: string;
+  inputs?: Record<number, string | undefined>;
+};
+
+export type MultiTextInputRule = {
+  required?: string;
+  pattern?: {
+    regex: RegExp;
+    message: string;
+  };
+};
diff --git a/packages/console/src/components/MultiTextInput/utils.ts b/packages/console/src/components/MultiTextInput/utils.ts
new file mode 100644
index 000000000..cffcc6630
--- /dev/null
+++ b/packages/console/src/components/MultiTextInput/utils.ts
@@ -0,0 +1,59 @@
+import { conditional } from '@silverhand/essentials';
+
+import { MultiTextInputError, MultiTextInputRule } from './types';
+
+export const validate = (
+  value: string[],
+  rule?: MultiTextInputRule
+): MultiTextInputError | undefined => {
+  if (!rule) {
+    return;
+  }
+
+  const requiredError = conditional(value.filter(Boolean).length === 0 && rule.required);
+
+  if (requiredError) {
+    return {
+      required: requiredError,
+    };
+  }
+
+  if (rule.pattern) {
+    const { regex, message } = rule.pattern;
+
+    const inputErrors = Object.fromEntries(
+      value.map((element, index) => {
+        return [index, regex.test(element) ? undefined : message];
+      })
+    );
+
+    if (Object.values(inputErrors).some(Boolean)) {
+      return {
+        inputs: inputErrors,
+      };
+    }
+  }
+};
+
+/**
+ * Utils for React Hook Form
+ */
+export const createValidatorForRhf =
+  (rule: MultiTextInputRule) =>
+  (value: string[]): boolean | string => {
+    const error = validate(value, rule);
+
+    if (error) {
+      return JSON.stringify(error);
+    }
+
+    return true;
+  };
+
+export const convertRhfErrorMessage = (errorMessage?: string): MultiTextInputError | undefined => {
+  if (!errorMessage) {
+    return;
+  }
+
+  return JSON.parse(errorMessage) as MultiTextInputError;
+};
diff --git a/packages/console/src/components/MultilineInput/index.tsx b/packages/console/src/components/MultilineInput/index.tsx
deleted file mode 100644
index 95c03a884..000000000
--- a/packages/console/src/components/MultilineInput/index.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React, { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import * as textButtonStyles from '@/components/TextButton/index.module.scss';
-import Minus from '@/icons/Minus';
-
-import IconButton from '../IconButton';
-import TextInput from '../TextInput';
-import * as styles from './index.module.scss';
-
-type Props = {
-  value: string[];
-  onChange: (value: string[]) => void;
-};
-
-const MultilineInput = ({ value, onChange }: Props) => {
-  const { t } = useTranslation(undefined, {
-    keyPrefix: 'general',
-  });
-
-  const fields = useMemo(() => {
-    if (value.length === 0) {
-      return [''];
-    }
-
-    return value;
-  }, [value]);
-
-  const handleAdd = () => {
-    onChange([...fields, '']);
-  };
-
-  const handleRemove = (index: number) => {
-    onChange(fields.filter((_, i) => i !== index));
-  };
-
-  const handleInputChange = (event: React.FormEvent<HTMLInputElement>, index: number) => {
-    onChange(fields.map((value, i) => (i === index ? event.currentTarget.value : value)));
-  };
-
-  return (
-    <div className={styles.multilineInput}>
-      {fields.map((fieldValue, fieldIndex) => (
-        // eslint-disable-next-line react/no-array-index-key
-        <div key={fieldIndex} className={styles.deletableInput}>
-          <TextInput
-            className={styles.textField}
-            value={fieldValue}
-            onChange={(event) => {
-              handleInputChange(event, fieldIndex);
-            }}
-          />
-          {fields.length > 1 && (
-            <IconButton
-              onClick={() => {
-                handleRemove(fieldIndex);
-              }}
-            >
-              <Minus />
-            </IconButton>
-          )}
-        </div>
-      ))}
-      <div className={textButtonStyles.button} onClick={handleAdd}>
-        {t('add_another')}
-      </div>
-    </div>
-  );
-};
-
-export default MultilineInput;
diff --git a/packages/console/src/pages/ApplicationDetails/index.tsx b/packages/console/src/pages/ApplicationDetails/index.tsx
index 8e8708dcb..bdb2a4795 100644
--- a/packages/console/src/pages/ApplicationDetails/index.tsx
+++ b/packages/console/src/pages/ApplicationDetails/index.tsx
@@ -1,6 +1,6 @@
 import { Application } from '@logto/schemas';
-import React, { useEffect, useMemo, useState } from 'react';
-import { useController, useForm } from 'react-hook-form';
+import React, { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
 import { toast } from 'react-hot-toast';
 import { useTranslation } from 'react-i18next';
 import Modal from 'react-modal';
@@ -15,7 +15,8 @@ import CopyToClipboard from '@/components/CopyToClipboard';
 import Drawer from '@/components/Drawer';
 import FormField from '@/components/FormField';
 import ImagePlaceholder from '@/components/ImagePlaceholder';
-import MultilineInput from '@/components/MultilineInput';
+import MultiTextInput from '@/components/MultiTextInput';
+import { convertRhfErrorMessage, createValidatorForRhf } from '@/components/MultiTextInput/utils';
 import TabNav, { TabNavLink } from '@/components/TabNav';
 import TextInput from '@/components/TextInput';
 import useApi, { RequestError } from '@/hooks/use-api';
@@ -23,6 +24,7 @@ import Delete from '@/icons/Delete';
 import More from '@/icons/More';
 import * as modalStyles from '@/scss/modal.module.scss';
 import { applicationTypeI18nKey } from '@/types/applications';
+import { noSpaceRegex } from '@/utilities/regex';
 
 import DeleteForm from './components/DeleteForm';
 import * as styles from './index.module.scss';
@@ -69,29 +71,23 @@ const ApplicationDetails = () => {
     reset(data);
   }, [data, reset]);
 
-  const {
-    field: { value: redirectUris, onChange: onRedirectUriChange },
-  } = useController({
-    control,
-    name: 'oidcClientMetadata.redirectUris',
-    defaultValue: [],
-  });
-
-  const {
-    field: { value: postSignOutRedirectUris, onChange: onPostSignOutRedirectUriChange },
-  } = useController({
-    control,
-    name: 'oidcClientMetadata.postLogoutRedirectUris',
-    defaultValue: [],
-  });
-
   const onSubmit = handleSubmit(async (formData) => {
     if (!data || isSubmitting) {
       return;
     }
 
     const updatedApplication = await api
-      .patch(`/api/applications/${data.id}`, { json: formData })
+      .patch(`/api/applications/${data.id}`, {
+        json: {
+          ...formData,
+          oidcClientMetadata: {
+            ...formData.oidcClientMetadata,
+            redirectUris: formData.oidcClientMetadata.redirectUris.filter(Boolean),
+            postLogoutRedirectUris:
+              formData.oidcClientMetadata.postLogoutRedirectUris.filter(Boolean),
+          },
+        },
+      })
       .json<Application>();
     void mutate(updatedApplication);
     toast.success(t('application_details.save_success'));
@@ -99,64 +95,75 @@ const ApplicationDetails = () => {
 
   const isAdvancedSettings = location.pathname.includes('advanced-settings');
 
-  const SettingsPage = useMemo(() => {
-    return (
-      oidcConfig && (
-        <>
-          <FormField isRequired title="admin_console.application_details.application_name">
-            <TextInput {...register('name', { required: true })} />
-          </FormField>
-          <FormField title="admin_console.application_details.description">
-            <TextInput {...register('description')} />
-          </FormField>
-          <FormField title="admin_console.application_details.authorization_endpoint">
-            <CopyToClipboard
-              className={styles.textField}
-              value={oidcConfig.authorization_endpoint}
+  const SettingsPage = oidcConfig && (
+    <>
+      <FormField isRequired title="admin_console.application_details.application_name">
+        <TextInput {...register('name', { required: true })} />
+      </FormField>
+      <FormField title="admin_console.application_details.description">
+        <TextInput {...register('description')} />
+      </FormField>
+      <FormField title="admin_console.application_details.authorization_endpoint">
+        <CopyToClipboard className={styles.textField} value={oidcConfig.authorization_endpoint} />
+      </FormField>
+      <FormField isRequired title="admin_console.application_details.redirect_uri">
+        <Controller
+          name="oidcClientMetadata.redirectUris"
+          control={control}
+          defaultValue={[]}
+          rules={{
+            validate: createValidatorForRhf({
+              required: t('application_details.redirect_uri_required'),
+              pattern: {
+                regex: noSpaceRegex,
+                message: t('application_details.no_space_in_uri'),
+              },
+            }),
+          }}
+          render={({ field: { onChange, value }, fieldState: { error } }) => (
+            <MultiTextInput
+              value={value}
+              error={convertRhfErrorMessage(error?.message)}
+              onChange={onChange}
             />
-          </FormField>
-          <FormField title="admin_console.application_details.redirect_uri">
-            <MultilineInput
-              value={redirectUris}
-              onChange={(value) => {
-                onRedirectUriChange(value);
-              }}
+          )}
+        />
+      </FormField>
+      <FormField title="admin_console.application_details.post_sign_out_redirect_uri">
+        <Controller
+          name="oidcClientMetadata.postLogoutRedirectUris"
+          control={control}
+          defaultValue={[]}
+          rules={{
+            validate: createValidatorForRhf({
+              pattern: {
+                regex: noSpaceRegex,
+                message: t('application_details.no_space_in_uri'),
+              },
+            }),
+          }}
+          render={({ field: { onChange, value }, fieldState: { error } }) => (
+            <MultiTextInput
+              value={value}
+              error={convertRhfErrorMessage(error?.message)}
+              onChange={onChange}
             />
-          </FormField>
-          <FormField title="admin_console.application_details.post_sign_out_redirect_uri">
-            <MultilineInput
-              value={postSignOutRedirectUris}
-              onChange={(value) => {
-                onPostSignOutRedirectUriChange(value);
-              }}
-            />
-          </FormField>
-        </>
-      )
-    );
-  }, [
-    oidcConfig,
-    onPostSignOutRedirectUriChange,
-    onRedirectUriChange,
-    postSignOutRedirectUris,
-    redirectUris,
-    register,
-  ]);
+          )}
+        />
+      </FormField>
+    </>
+  );
 
-  const AdvancedSettingsPage = useMemo(() => {
-    return (
-      oidcConfig && (
-        <>
-          <FormField title="admin_console.application_details.token_endpoint">
-            <CopyToClipboard className={styles.textField} value={oidcConfig.token_endpoint} />
-          </FormField>
-          <FormField title="admin_console.application_details.user_info_endpoint">
-            <CopyToClipboard className={styles.textField} value={oidcConfig.userinfo_endpoint} />
-          </FormField>
-        </>
-      )
-    );
-  }, [oidcConfig]);
+  const AdvancedSettingsPage = oidcConfig && (
+    <>
+      <FormField title="admin_console.application_details.token_endpoint">
+        <CopyToClipboard className={styles.textField} value={oidcConfig.token_endpoint} />
+      </FormField>
+      <FormField title="admin_console.application_details.user_info_endpoint">
+        <CopyToClipboard className={styles.textField} value={oidcConfig.userinfo_endpoint} />
+      </FormField>
+    </>
+  );
 
   return (
     <div className={styles.container}>
diff --git a/packages/console/src/utilities/regex.ts b/packages/console/src/utilities/regex.ts
index 01e9acb72..5922e47fc 100644
--- a/packages/console/src/utilities/regex.ts
+++ b/packages/console/src/utilities/regex.ts
@@ -1,3 +1,4 @@
 // TODO - LOG-1876: Share Regex Between Logto Core and Front-End
 export const emailRegEx = /^\S+@\S+\.\S+$/;
 export const phoneRegEx = /^[1-9]\d{10}$/;
+export const noSpaceRegex = /^\S*$/;
diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts
index b5b0173dd..0383fb68f 100644
--- a/packages/phrases/src/locales/en.ts
+++ b/packages/phrases/src/locales/en.ts
@@ -2,7 +2,6 @@
 const translation = {
   general: {
     placeholder: 'Placeholder',
-    add_another: '+ Add Another',
     skip: 'Skip',
     next: 'Next',
     retry: 'Try again',
@@ -41,6 +40,7 @@ const translation = {
     },
     form: {
       required: 'Required',
+      add_another: '+ Add Another',
     },
     errors: {
       something_went_wrong: 'Oops! Something went wrong',
@@ -133,6 +133,8 @@ const translation = {
       delete: 'Delete',
       application_deleted: 'The application {{name}} deleted.',
       save_success: 'Saved!',
+      redirect_uri_required: 'You have to enter at least one redirect URI.',
+      no_space_in_uri: 'Space is not allowed in URI',
     },
     api_resources: {
       title: 'API Resources',
diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts
index 72e258204..b041893d5 100644
--- a/packages/phrases/src/locales/zh-cn.ts
+++ b/packages/phrases/src/locales/zh-cn.ts
@@ -4,7 +4,6 @@ import en from './en';
 const translation = {
   general: {
     placeholder: '占位符',
-    add_another: '+ Add Another',
     skip: '跳过',
     next: '下一步',
     retry: '重试',
@@ -43,6 +42,7 @@ const translation = {
     },
     form: {
       required: '必填',
+      add_another: '+ Add Another',
     },
     errors: {
       something_went_wrong: '哎哟喂,遇到了一个错误',
@@ -133,6 +133,8 @@ const translation = {
       delete: 'Delete',
       application_deleted: 'The application {{name}} deleted.',
       save_success: 'Saved!',
+      redirect_uri_required: 'You have to enter at least one redirect URI.',
+      no_space_in_uri: 'Space is not allowed in URI',
     },
     api_resources: {
       title: 'API Resources',