mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): multi-text-input (#472)
This commit is contained in:
parent
2356c2ae2e
commit
b416ee877e
9 changed files with 252 additions and 150 deletions
|
@ -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);
|
||||
}
|
||||
}
|
83
packages/console/src/components/MultiTextInput/index.tsx
Normal file
83
packages/console/src/components/MultiTextInput/index.tsx
Normal file
|
@ -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;
|
12
packages/console/src/components/MultiTextInput/types.ts
Normal file
12
packages/console/src/components/MultiTextInput/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export type MultiTextInputError = {
|
||||
required?: string;
|
||||
inputs?: Record<number, string | undefined>;
|
||||
};
|
||||
|
||||
export type MultiTextInputRule = {
|
||||
required?: string;
|
||||
pattern?: {
|
||||
regex: RegExp;
|
||||
message: string;
|
||||
};
|
||||
};
|
59
packages/console/src/components/MultiTextInput/utils.ts
Normal file
59
packages/console/src/components/MultiTextInput/utils.ts
Normal file
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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}>
|
||||
|
|
|
@ -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*$/;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue