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',