Skip to content

Commit

Permalink
refactor(console): extract the KeyValueInput ds component
Browse files Browse the repository at this point in the history
extract the KeyValueInput ds component
  • Loading branch information
simeng-li committed Mar 6, 2024
1 parent 2cab7b6 commit 621de3e
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 89 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@use '@/scss/underscore' as _;

.field {
margin-bottom: _.unit(3);

.input {
display: flex;
gap: _.unit(2);
align-items: center;

.keyInput {
flex: 1;
}

.valueInput {
flex: 2;
}
}

.error {
font: var(--font-body-2);
color: var(--color-error);
margin-top: _.unit(1);
}
}
127 changes: 127 additions & 0 deletions packages/console/src/ds-components/KeyValueInputField/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { type ReactElement, useCallback } from 'react';
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 type DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
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 Props = {
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
tip?: 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 {string} title - The title of the field.
* @param {string} [tip] - The tip for the field.
* @param {FieldType} fields - The array of key-value pairs. @see {@link https://react-hook-form.com/docs/usefieldarray}
* @param {ErrorType[]} [errors] - The array of errors for each field. Accepts both string and FieldError from RHF.
* @param {Function} onRemove - The function to remove a field. @see {@link https://react-hook-form.com/docs/usefieldarray}
* @param {Function} 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({
title,
tip,
fields,
errors,
getInputFieldProps,
onRemove,
onAppend,
}: Props) {
const renderErrors = useCallback(
(index: number, key: keyof FieldType) => {
const error = errors?.[index]?.[key];

if (!error) {
return null;
}

if (typeof error === 'string') {
return <div className={styles.error}>{error}</div>;
}

return <div className={styles.error}>{error.message}</div>;
},
[errors]
);

return (
<FormField title={title} tip={tip}>
{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>
{renderErrors(index, 'key')}
{renderErrors(index, 'value')}
</div>
);
})}
<Button
size="small"
type="text"
title="general.add_another"
icon={<CirclePlus />}
onClick={() => {
onAppend({ key: '', value: '' });
}}
/>
</FormField>
);
}

export default KeyValueInputField;
2 changes: 1 addition & 1 deletion packages/console/src/ds-components/TextInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
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);
};
Expand All @@ -21,6 +15,7 @@ const isValidHeaderValue = (value: string) => {

function CustomHeaderField() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const {
control,
register,
Expand All @@ -37,103 +32,80 @@ function CustomHeaderField() {
name: 'headers',
});

const keyValidator = (key: string, index: number) => {
const headers = getValues('headers');
if (!headers) {
return true;
}
const keyValidator = 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');
}
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');
}
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');
}
if (Boolean(key) && !isValidHeaderKey(key)) {
return t('webhook_details.settings.invalid_key_error');
}

return true;
};
return true;
},
[getValues, t]
);

const valueValidator = (value: string, index: number) => {
if (Boolean(value) && !isValidHeaderValue(value)) {
return t('webhook_details.settings.invalid_value_error');
}
const valueValidator = useCallback(
(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;
};
return getValues(`headers.${index}.key`)
? Boolean(value) || t('webhook_details.settings.value_missing_error')
: true;
},
[getValues, t]
);

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) => keyValidator(key, index),
onChange: revalidate,
}),
value: (index: number) =>
register(`headers.${index}.value`, {
validate: (value) => valueValidator(value, index),
onChange: revalidate,
}),
}),
[keyValidator, register, revalidate, valueValidator]
);

return (
<FormField
<KeyValueInputField
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: '' });
}}
/>
</FormField>
fields={fields}
// Force headerErrors to be an array, otherwise return undefined
errors={headerErrors?.map?.((error) => error)}
getInputFieldProps={getInputFieldProps}
onAppend={append}
onRemove={remove}
/>
);
}

Expand Down

0 comments on commit 621de3e

Please sign in to comment.