mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -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';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = Omit<HTMLProps<HTMLInputElement>, 'size'> & {
|
export type Props = Omit<HTMLProps<HTMLInputElement>, 'size'> & {
|
||||||
error?: string | boolean | ReactElement;
|
error?: string | boolean | ReactElement;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 FormField from '@/ds-components/FormField';
|
||||||
import IconButton from '@/ds-components/IconButton';
|
import KeyValueInputField from '@/ds-components/KeyValueInputField';
|
||||||
import TextInput from '@/ds-components/TextInput';
|
|
||||||
import { type WebhookDetailsFormType } from '@/pages/WebhookDetails/types';
|
import { type WebhookDetailsFormType } from '@/pages/WebhookDetails/types';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
|
||||||
|
|
||||||
const isValidHeaderKey = (key: string) => {
|
const isValidHeaderKey = (key: string) => {
|
||||||
return /^[\u0021-\u0039\u003B-\u007E]+$/.test(key);
|
return /^[\u0021-\u0039\u003B-\u007E]+$/.test(key);
|
||||||
};
|
};
|
||||||
|
@ -21,6 +16,7 @@ const isValidHeaderValue = (value: string) => {
|
||||||
|
|
||||||
function CustomHeaderField() {
|
function CustomHeaderField() {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
register,
|
register,
|
||||||
|
@ -37,101 +33,81 @@ function CustomHeaderField() {
|
||||||
name: 'headers',
|
name: 'headers',
|
||||||
});
|
});
|
||||||
|
|
||||||
const keyValidator = (key: string, index: number) => {
|
const validateKey = useCallback(
|
||||||
const headers = getValues('headers');
|
(key: string, index: number) => {
|
||||||
if (!headers) {
|
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;
|
return true;
|
||||||
}
|
},
|
||||||
|
[getValues, t]
|
||||||
|
);
|
||||||
|
|
||||||
if (headers.filter(({ key: _key }) => _key.length > 0 && _key === key).length > 1) {
|
const validateValue = useCallback(
|
||||||
return t('webhook_details.settings.key_duplicated_error');
|
(value: string, index: number) => {
|
||||||
}
|
if (Boolean(value) && !isValidHeaderValue(value)) {
|
||||||
|
return t('webhook_details.settings.invalid_value_error');
|
||||||
|
}
|
||||||
|
|
||||||
const correspondValue = getValues(`headers.${index}.value`);
|
return getValues(`headers.${index}.key`)
|
||||||
if (correspondValue) {
|
? Boolean(value) || t('webhook_details.settings.value_missing_error')
|
||||||
return Boolean(key) || t('webhook_details.settings.key_missing_error');
|
: true;
|
||||||
}
|
},
|
||||||
|
[getValues, t]
|
||||||
|
);
|
||||||
|
|
||||||
if (Boolean(key) && !isValidHeaderKey(key)) {
|
const revalidate = useCallback(() => {
|
||||||
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 = () => {
|
|
||||||
for (const [index] of fields.entries()) {
|
for (const [index] of fields.entries()) {
|
||||||
void trigger(`headers.${index}.key`);
|
void trigger(`headers.${index}.key`);
|
||||||
if (submitCount > 0) {
|
if (submitCount > 0) {
|
||||||
void trigger(`headers.${index}.value`);
|
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 (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
title="webhook_details.settings.custom_headers"
|
title="webhook_details.settings.custom_headers"
|
||||||
tip={t('webhook_details.settings.custom_headers_tip')}
|
tip={t('webhook_details.settings.custom_headers_tip')}
|
||||||
>
|
>
|
||||||
{fields.map((header, index) => {
|
<KeyValueInputField
|
||||||
return (
|
fields={fields}
|
||||||
<div key={header.id} className={styles.field}>
|
// Force headerErrors to be an array, otherwise return undefined
|
||||||
<div className={styles.input}>
|
errors={headerErrors?.map?.((error) => error)}
|
||||||
<TextInput
|
getInputFieldProps={getInputFieldProps}
|
||||||
className={styles.keyInput}
|
onAppend={append}
|
||||||
placeholder="Key"
|
onRemove={remove}
|
||||||
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: '' });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue