0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console): implement environment variables input field (1/2) (#5471)

* refactor(console): extract the KeyValueInput ds component

extract the KeyValueInput ds component

* fix(console): remove unused styles

remove unused styles

* refactor(console): extract the FormField from KeyValueInput

extract the FormField from KeyValueInput

* fix(console): refactor some code based on code review comment

refactor some code based the code review comment
This commit is contained in:
simeng-li 2024-03-07 16:26:20 +08:00 committed by GitHub
parent a00badc891
commit fa3577e491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 181 additions and 87 deletions

View file

@ -0,0 +1,118 @@
import { type FieldError } from 'react-hook-form';
import CirclePlus from '@/assets/icons/circle-plus.svg';
import Minus from '@/assets/icons/minus.svg';
import Button from '@/ds-components/Button';
import IconButton from '@/ds-components/IconButton';
import TextInput, { type Props as TextInputProps } from '@/ds-components/TextInput';
import * as styles from './index.module.scss';
type FieldType = {
key: string;
value: string;
};
type ErrorType = {
[K in keyof FieldType]?: FieldError | string | undefined;
};
// TextInput props getter
type InputFieldPropsGetter = {
[K in keyof FieldType]: (index: number) => Omit<TextInputProps, 'ref'>;
};
type ErrorProps = {
error?: FieldError | string | undefined;
};
function Error({ error }: ErrorProps) {
if (!error) {
return null;
}
if (typeof error === 'string') {
return <div className={styles.error}>{error}</div>;
}
return <div className={styles.error}>{error.message}</div>;
}
type Props = {
className?: string;
fields: Array<FieldType & { id: string }>; // Id is required to uniquely identify each field
errors?: Array<ErrorType | undefined>;
getInputFieldProps: InputFieldPropsGetter;
onRemove: (index: number) => void;
onAppend: (field: FieldType) => void;
};
/**
* UI component for key-value input field.
*
* This component is used to add multiple key-value pairs.
* For most of the cases, it is designed to be used along with react-hook-form.
* All the input properties are registered with react-hook-form.
* @param {Props} props - The props for the component.
* @param {string} [props.className] - The class name for the container.
* @param {FieldType} props.fields - The array of key-value pairs. @see {@link https://react-hook-form.com/docs/usefieldarray}
* @param {ErrorType[]} [props.errors] - The array of errors for each field. Accepts both string and FieldError from RHF.
* @param {Function} props.onRemove - The function to remove a field. @see {@link https://react-hook-form.com/docs/usefieldarray}
* @param {Function} props.onAppend - The function to append a new field. @see {@link https://react-hook-form.com/docs/usefieldarray}
* @param {InputFieldPropsGetter} getInputFieldProps - The function bundle to get the input field props for each field. e.g. Use React Hook Form's register method to register the input field.
*/
function KeyValueInputField({
className,
fields,
errors,
getInputFieldProps,
onRemove,
onAppend,
}: Props) {
return (
<div className={className}>
{fields.map((field, index) => {
return (
// Use id as the element key if it exists (generated by react-hook-form useFieldArray method), otherwise use the key
<div key={field.id} className={styles.field}>
<div className={styles.input}>
<TextInput
className={styles.keyInput}
placeholder="Key"
error={Boolean(errors?.[index]?.key)}
{...getInputFieldProps.key(index)}
/>
<TextInput
className={styles.valueInput}
placeholder="Value"
error={Boolean(errors?.[index]?.value)}
{...getInputFieldProps.value(index)}
/>
{fields.length > 1 && (
<IconButton
onClick={() => {
onRemove(index);
}}
>
<Minus />
</IconButton>
)}
</div>
<Error error={errors?.[index]?.key} />
<Error error={errors?.[index]?.value} />
</div>
);
})}
<Button
size="small"
type="text"
title="general.add_another"
icon={<CirclePlus />}
onClick={() => {
onAppend({ key: '', value: '' });
}}
/>
</div>
);
}
export default KeyValueInputField;

View file

@ -18,7 +18,7 @@ import IconButton from '@/ds-components/IconButton';
import * as styles from './index.module.scss';
type Props = Omit<HTMLProps<HTMLInputElement>, 'size'> & {
export type Props = Omit<HTMLProps<HTMLInputElement>, 'size'> & {
error?: string | boolean | ReactElement;
icon?: ReactElement;
/**

View file

@ -1,16 +1,11 @@
import { useCallback, useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CirclePlus from '@/assets/icons/circle-plus.svg';
import Minus from '@/assets/icons/minus.svg';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import IconButton from '@/ds-components/IconButton';
import TextInput from '@/ds-components/TextInput';
import KeyValueInputField from '@/ds-components/KeyValueInputField';
import { type WebhookDetailsFormType } from '@/pages/WebhookDetails/types';
import * as styles from './index.module.scss';
const isValidHeaderKey = (key: string) => {
return /^[\u0021-\u0039\u003B-\u007E]+$/.test(key);
};
@ -21,6 +16,7 @@ const isValidHeaderValue = (value: string) => {
function CustomHeaderField() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
register,
@ -37,101 +33,81 @@ function CustomHeaderField() {
name: 'headers',
});
const keyValidator = (key: string, index: number) => {
const headers = getValues('headers');
if (!headers) {
const validateKey = useCallback(
(key: string, index: number) => {
const headers = getValues('headers');
if (!headers) {
return true;
}
if (headers.filter(({ key: _key }) => _key.length > 0 && _key === key).length > 1) {
return t('webhook_details.settings.key_duplicated_error');
}
const correspondValue = getValues(`headers.${index}.value`);
if (correspondValue) {
return Boolean(key) || t('webhook_details.settings.key_missing_error');
}
if (Boolean(key) && !isValidHeaderKey(key)) {
return t('webhook_details.settings.invalid_key_error');
}
return true;
}
},
[getValues, t]
);
if (headers.filter(({ key: _key }) => _key.length > 0 && _key === key).length > 1) {
return t('webhook_details.settings.key_duplicated_error');
}
const validateValue = useCallback(
(value: string, index: number) => {
if (Boolean(value) && !isValidHeaderValue(value)) {
return t('webhook_details.settings.invalid_value_error');
}
const correspondValue = getValues(`headers.${index}.value`);
if (correspondValue) {
return Boolean(key) || t('webhook_details.settings.key_missing_error');
}
return getValues(`headers.${index}.key`)
? Boolean(value) || t('webhook_details.settings.value_missing_error')
: true;
},
[getValues, t]
);
if (Boolean(key) && !isValidHeaderKey(key)) {
return t('webhook_details.settings.invalid_key_error');
}
return true;
};
const valueValidator = (value: string, index: number) => {
if (Boolean(value) && !isValidHeaderValue(value)) {
return t('webhook_details.settings.invalid_value_error');
}
return getValues(`headers.${index}.key`)
? Boolean(value) || t('webhook_details.settings.value_missing_error')
: true;
};
const revalidate = () => {
const revalidate = useCallback(() => {
for (const [index] of fields.entries()) {
void trigger(`headers.${index}.key`);
if (submitCount > 0) {
void trigger(`headers.${index}.value`);
}
}
};
}, [fields, submitCount, trigger]);
const getInputFieldProps = useMemo(
() => ({
key: (index: number) =>
register(`headers.${index}.key`, {
validate: (key) => validateKey(key, index),
onChange: revalidate,
}),
value: (index: number) =>
register(`headers.${index}.value`, {
validate: (value) => validateValue(value, index),
onChange: revalidate,
}),
}),
[validateKey, register, revalidate, validateValue]
);
return (
<FormField
title="webhook_details.settings.custom_headers"
tip={t('webhook_details.settings.custom_headers_tip')}
>
{fields.map((header, index) => {
return (
<div key={header.id} className={styles.field}>
<div className={styles.input}>
<TextInput
className={styles.keyInput}
placeholder="Key"
error={Boolean(headerErrors?.[index]?.key)}
{...register(`headers.${index}.key`, {
validate: (key) => keyValidator(key, index),
onChange: revalidate,
})}
/>
<TextInput
className={styles.valueInput}
placeholder="Value"
error={Boolean(headerErrors?.[index]?.value)}
{...register(`headers.${index}.value`, {
validate: (value) => valueValidator(value, index),
onChange: revalidate,
})}
/>
{fields.length > 1 && (
<IconButton
onClick={() => {
remove(index);
}}
>
<Minus />
</IconButton>
)}
</div>
{headerErrors?.[index]?.key?.message && (
<div className={styles.error}>{headerErrors[index]?.key?.message}</div>
)}
{headerErrors?.[index]?.value?.message && (
<div className={styles.error}>{headerErrors[index]?.value?.message}</div>
)}
</div>
);
})}
<Button
size="small"
type="text"
title="general.add_another"
icon={<CirclePlus />}
onClick={() => {
append({ key: '', value: '' });
}}
<KeyValueInputField
fields={fields}
// Force headerErrors to be an array, otherwise return undefined
errors={headerErrors?.map?.((error) => error)}
getInputFieldProps={getInputFieldProps}
onAppend={append}
onRemove={remove}
/>
</FormField>
);