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:
parent
a00badc891
commit
fa3577e491
4 changed files with 181 additions and 87 deletions
118
packages/console/src/ds-components/KeyValueInputField/index.tsx
Normal file
118
packages/console/src/ds-components/KeyValueInputField/index.tsx
Normal 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;
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue