0
Fork 0
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:
Xiao Yijun 2022-04-01 13:45:57 +08:00 committed by GitHub
parent 2356c2ae2e
commit b416ee877e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 252 additions and 150 deletions

View file

@ -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);
}
}

View 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;

View 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;
};
};

View 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;
};

View file

@ -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;

View file

@ -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}>

View file

@ -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*$/;

View file

@ -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',

View file

@ -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',