feat: custom fields component to registration form (#29202)

Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
pull/29529/head
Hugo Costa 3 years ago committed by GitHub
parent bc115050ae
commit e14ec50816
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      .changeset/custom-fields.md
  2. 13
      apps/meteor/app/api/server/v1/users.ts
  3. 71
      apps/meteor/client/components/AccountsCustomFields/AccountsCustomFieldsAssembler.tsx
  4. 1
      apps/meteor/client/components/AccountsCustomFields/index.ts
  5. 152
      apps/meteor/client/components/CustomFieldsForm.js
  6. 45
      apps/meteor/client/views/account/profile/AccountProfileForm.tsx
  7. 2
      apps/meteor/client/views/account/profile/AccountProfilePage.tsx
  8. 2
      apps/meteor/client/views/admin/users/AddUser.js
  9. 2
      apps/meteor/client/views/admin/users/EditUser.js
  10. 36
      apps/meteor/client/views/admin/users/UserForm.js
  11. 10
      apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.stories.tsx
  12. 4
      apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js
  13. 4
      apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsForm.js
  14. 4
      apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js
  15. 2
      apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx
  16. 2
      apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx
  17. 4
      apps/meteor/client/views/omnichannel/directory/utils/formatCustomFieldsMetadata.tsx
  18. 72
      apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx
  19. 12
      packages/core-typings/src/CustomFieldMetadata.ts
  20. 1
      packages/core-typings/src/index.ts
  21. 5
      packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts
  22. 1
      packages/ui-client/package.json
  23. 52
      packages/ui-client/src/components/CustomFieldsForm.tsx
  24. 1
      packages/ui-client/src/components/index.ts
  25. 14
      packages/ui-contexts/src/hooks/useAccountsCustomFields.ts
  26. 1
      packages/ui-contexts/src/index.ts
  27. 16
      packages/web-ui-registration/src/RegisterForm.tsx
  28. 1
      yarn.lock

@ -0,0 +1,10 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/rest-typings": patch
"@rocket.chat/ui-client": patch
"@rocket.chat/ui-contexts": patch
"@rocket.chat/web-ui-registration": patch
---
Added and Improved Custom Fields form to Registration Flow

@ -564,6 +564,15 @@ API.v1.addRoute(
}
const { secret: secretURL, ...params } = this.bodyParams;
if (this.bodyParams.customFields) {
try {
await validateCustomFields(this.bodyParams.customFields);
} catch (e) {
return API.v1.failure(e);
}
}
// Register the user
const userId = await Meteor.callAsync('registerUser', {
...params,
@ -579,6 +588,10 @@ API.v1.addRoute(
return API.v1.failure('User not found');
}
if (this.bodyParams.customFields) {
await saveCustomFields(userId, this.bodyParams.customFields);
}
return API.v1.success({ user });
},
},

@ -1,71 +0,0 @@
import { TextInput, Field, Select } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { FieldError } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useAccountsCustomFields } from '../../hooks/useAccountsCustomFields';
const AccountsCustomFieldsAssembler = () => {
const t = useTranslation();
const customFields = useAccountsCustomFields();
const { register, getFieldState, setValue } = useFormContext();
return (
<>
{customFields?.map((customField, index) => {
const getErrorMessage = (error: FieldError | undefined) => {
switch (error?.type) {
case 'required':
return t('The_field_is_required', customField.name);
case 'minLength':
return t('Min_length_is', customField.minLength);
case 'maxLength':
return t('Max_length_is', customField.maxLength);
}
};
const { onChange, ...handlers } = register(customField.name, {
required: customField.required,
minLength: customField.minLength,
maxLength: customField.maxLength,
});
const error = getErrorMessage(getFieldState(customField.name).error);
return (
<Field key={index}>
<Field.Label>
{t.has(customField.name) ? t(customField.name) : customField.name}
{customField.required && '*'}
</Field.Label>
<Field.Row>
{customField.type === 'select' && (
/*
the Select component is a controlled component,
the onchange handler are not compatible among them,
so we need to setValue on the onChange handler
Select also doesn't follow the ideal implementation, but is what we have for now
*/
<Select
onChange={(value) => {
setValue(customField.name, value);
}}
{...handlers}
options={customField.options.map((option) => [option, option, customField.defaultValue === option])}
error={error}
value={customField.defaultValue}
/>
)}
{customField.type === 'text' && <TextInput onChange={onChange} {...handlers} error={error} />}
</Field.Row>
<Field.Error>{error}</Field.Error>
</Field>
);
})}
</>
);
};
export default AccountsCustomFieldsAssembler;

@ -1 +0,0 @@
export { default } from './AccountsCustomFieldsAssembler';

@ -1,152 +0,0 @@
import { TextInput, Select, Field } from '@rocket.chat/fuselage';
import { capitalize } from '@rocket.chat/string-helpers';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo, useEffect, useState } from 'react';
import { useComponentDidUpdate } from '../hooks/useComponentDidUpdate';
import { useForm } from '../hooks/useForm';
const CustomTextInput = ({ label, name, required, minLength, maxLength, setState, state, className, setCustomFieldsError = () => [] }) => {
const t = useTranslation();
const [inputError, setInputError] = useState('');
const verify = useMemo(() => {
const errors = [];
if (!state && required) {
errors.push(t('The_field_is_required', label || name));
}
if (state.length < minLength && state.length > 0) {
errors.push(t('Min_length_is', minLength));
}
return errors.join(', ');
}, [state, required, minLength, t, label, name]);
useEffect(() => {
setCustomFieldsError((oldErrors) => (verify ? [...oldErrors, { name }] : oldErrors.filter((item) => item.name !== name)));
}, [name, setCustomFieldsError, verify]);
useComponentDidUpdate(() => {
setInputError(verify);
}, [verify]);
return useMemo(
() => (
<Field className={className}>
<Field.Label>
{label || t(name)}
{required && '*'}
</Field.Label>
<Field.Row>
<TextInput
name={name}
error={inputError}
maxLength={maxLength}
flexGrow={1}
value={state}
onChange={(e) => setState(e.currentTarget.value)}
/>
</Field.Row>
<Field.Error>{inputError}</Field.Error>
</Field>
),
[className, label, t, name, required, inputError, maxLength, state, setState],
);
};
const CustomSelect = ({ label, name, required, options = {}, setState, state, className, setCustomFieldsError = () => [] }) => {
const t = useTranslation();
const [selectError, setSelectError] = useState('');
const mappedOptions = useMemo(() => Object.values(options).map((value) => [value, value]), [options]);
const verify = useMemo(
() => (!state.length && required ? t('The_field_is_required', label || name) : ''),
[name, label, required, state.length, t],
);
useEffect(() => {
setCustomFieldsError((oldErrors) => (verify ? [...oldErrors, { name }] : oldErrors.filter((item) => item.name !== name)));
}, [name, setCustomFieldsError, verify]);
useComponentDidUpdate(() => {
setSelectError(verify);
}, [verify]);
return useMemo(
() => (
<Field className={className}>
<Field.Label>
{label || t(name)}
{required && '*'}
</Field.Label>
<Field.Row>
<Select name={name} error={selectError} flexGrow={1} value={state} options={mappedOptions} onChange={(val) => setState(val)} />
</Field.Row>
<Field.Error>{selectError}</Field.Error>
</Field>
),
[className, label, t, name, required, selectError, state, mappedOptions, setState],
);
};
const CustomFieldsAssembler = ({ formValues, formHandlers, customFields, ...props }) =>
Object.entries(customFields).map(([key, value]) => {
const extraProps = {
name: key,
setState: formHandlers[`handle${capitalize(key)}`],
state: formValues[key],
...value,
};
if (value.type === 'select') {
return <CustomSelect {...extraProps} {...props} key={key} />;
}
if (value.type === 'text') {
return <CustomTextInput {...extraProps} {...props} key={key} />;
}
return null;
});
export default function CustomFieldsForm({ jsonCustomFields, customFieldsData, setCustomFieldsData, onLoadFields = () => {}, ...props }) {
const accountsCustomFieldsJson = useSetting('Accounts_CustomFields');
const [customFields] = useState(() => {
try {
return jsonCustomFields || JSON.parse(accountsCustomFieldsJson || '{}');
} catch {
return {};
}
});
const hasCustomFields = useMemo(() => Object.values(customFields).length > 0, [customFields]);
const defaultFields = useMemo(
() =>
Object.entries(customFields).reduce((data, [key, value]) => {
data[key] = value.defaultValue ?? '';
return data;
}, {}),
[customFields],
);
const { values, handlers } = useForm({ ...defaultFields, ...customFieldsData });
useEffect(() => {
onLoadFields?.(hasCustomFields);
}, [onLoadFields, hasCustomFields]);
useEffect(() => {
if (hasCustomFields) {
setCustomFieldsData(values);
}
}, [hasCustomFields, setCustomFieldsData, values]);
if (!hasCustomFields) {
return null;
}
return <CustomFieldsAssembler formValues={values} formHandlers={handlers} customFields={customFields} {...props} />;
}

@ -2,15 +2,15 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Field, FieldGroup, TextInput, TextAreaInput, Box, Icon, PasswordInput, Button } from '@rocket.chat/fuselage';
import { useDebouncedCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import { PasswordVerifier } from '@rocket.chat/ui-client';
import { CustomFieldsForm, PasswordVerifier } from '@rocket.chat/ui-client';
import { useAccountsCustomFields, useVerifyPassword, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useVerifyPassword, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import type { Dispatch, ReactElement, SetStateAction } from 'react';
import React, { useCallback, useMemo, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { validateEmail } from '../../../../lib/emailValidator';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import CustomFieldsForm from '../../../components/CustomFieldsForm';
import UserStatusMenu from '../../../components/UserStatusMenu';
import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor';
import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants';
@ -33,6 +33,8 @@ const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChang
const getAvatarSuggestions = useEndpoint('GET', '/v1/users.getAvatarSuggestion');
const sendConfirmationEmail = useEndpoint('POST', '/v1/users.sendConfirmationEmail');
const customFieldsMetadata = useAccountsCustomFields();
const [usernameError, setUsernameError] = useState<string | undefined>();
const [avatarSuggestions, setAvatarSuggestions] = useSafely(
useState<{
@ -71,10 +73,19 @@ const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChang
handleStatusText,
handleStatusType,
handleBio,
handleCustomFields,
handleNickname,
handleCustomFields,
} = handlers;
const {
control,
watch,
formState: { errors: customFieldsErrors },
} = useForm({
defaultValues: { customFields: { ...customFields } },
mode: 'onBlur',
});
const previousEmail = user ? getUserEmailAddress(user) : '';
const handleSendConfirmationEmail = useCallback(async () => {
@ -123,6 +134,11 @@ const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChang
[namesRegex, t, user?.username, checkUsernameAvailability, setUsernameError],
);
useEffect(() => {
const subscription = watch((value) => handleCustomFields({ ...value.customFields }));
return () => subscription.unsubscribe();
}, [watch, handleCustomFields]);
useEffect(() => {
const getSuggestions = async (): Promise<void> => {
const { suggestions } = await getAvatarSuggestions();
@ -166,9 +182,25 @@ const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChang
return undefined;
}, [bio, t]);
const customFieldsError = useMemo(() => {
if (customFieldsErrors) {
return customFieldsErrors;
}
return undefined;
}, [customFieldsErrors]);
const verified = user?.emails?.[0]?.verified ?? false;
const canSave = !(!!passwordError || !!emailError || !!usernameError || !!nameError || !!statusTextError || !!bioError);
const canSave = !(
!!passwordError ||
!!emailError ||
!!usernameError ||
!!nameError ||
!!statusTextError ||
!!bioError ||
!customFieldsError
);
useEffect(() => {
onSaveStateChange(canSave);
@ -358,7 +390,8 @@ const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChang
passwordVerifications,
],
)}
<CustomFieldsForm jsonCustomFields={undefined} customFieldsData={customFields} setCustomFieldsData={handleCustomFields} />
{customFieldsMetadata && <CustomFieldsForm formName='customFields' formControl={control} metadata={customFieldsMetadata} />}
</FieldGroup>
);
};

@ -248,7 +248,7 @@ const AccountProfilePage = (): ReactElement => {
<Page>
<Page.Header title={t('Profile')}>
<ButtonGroup>
<Button danger disabled={!hasUnsavedChanges} onClick={reset}>
<Button disabled={!hasUnsavedChanges} onClick={reset}>
{t('Reset')}
</Button>
<Button data-qa='AccountProfilePageSaveButton' primary disabled={!hasUnsavedChanges || !canSave || loggingOut} onClick={onSave}>

@ -125,7 +125,7 @@ const AddUser = ({ onReload, ...props }) => {
<Button flexGrow={1} disabled={!hasUnsavedChanges} onClick={reset} mie='x4'>
{t('Cancel')}
</Button>
<Button flexGrow={1} disabled={!hasUnsavedChanges} onClick={handleSave}>
<Button primary flexGrow={1} disabled={!hasUnsavedChanges} onClick={handleSave}>
{t('Save')}
</Button>
</Box>

@ -134,7 +134,7 @@ function EditUser({ data, roles, onReload, ...props }) {
<Button flexGrow={1} type='reset' disabled={!canSaveOrReset} onClick={reset}>
{t('Reset')}
</Button>
<Button mie='none' flexGrow={1} disabled={!canSaveOrReset} onClick={handleSave}>
<Button primary mie='none' flexGrow={1} disabled={!canSaveOrReset} onClick={handleSave}>
{t('Save')}
</Button>
</Margins>

@ -10,16 +10,16 @@ import {
Divider,
FieldGroup,
} from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback, useMemo, useState } from 'react';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import { useTranslation, useAccountsCustomFields } from '@rocket.chat/ui-contexts';
import React, { useCallback, useMemo, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { validateEmail } from '../../../../lib/emailValidator';
import { ContextualbarScrollableContent } from '../../../components/Contextualbar';
import CustomFieldsForm from '../../../components/CustomFieldsForm';
export default function UserForm({ formValues, formHandlers, availableRoles, append, prepend, errors, isSmtpEnabled, ...props }) {
const t = useTranslation();
const [hasCustomFields, setHasCustomFields] = useState(false);
const {
name,
@ -55,7 +55,17 @@ export default function UserForm({ formValues, formHandlers, availableRoles, app
handleSendWelcomeEmail,
} = formHandlers;
const onLoadCustomFields = useCallback((hasCustomFields) => setHasCustomFields(hasCustomFields), []);
const customFieldsMetadata = useAccountsCustomFields();
const { control, watch } = useForm({
defaultValues: { customFields: { ...customFields } },
mode: 'onBlur',
});
useEffect(() => {
const subscription = watch((value) => handleCustomFields({ ...value.customFields }));
return () => subscription.unsubscribe();
}, [watch, handleCustomFields]);
return (
<ContextualbarScrollableContent {...props} is='form' onSubmit={useCallback((e) => e.preventDefault(), [])} autoComplete='off'>
@ -274,13 +284,17 @@ export default function UserForm({ formValues, formHandlers, availableRoles, app
),
[handleSendWelcomeEmail, t, sendWelcomeEmail, isSmtpEnabled],
)}
{hasCustomFields && (
<>
<Divider />
<Box fontScale='h4'>{t('Custom_Fields')}</Box>
</>
{useMemo(
() =>
customFieldsMetadata && (
<>
<Divider />
<Box fontScale='h4'>{t('Custom_Fields')}</Box>
<CustomFieldsForm formName='customFields' formControl={control} metadata={customFieldsMetadata} />
</>
),
[customFieldsMetadata, control, t],
)}
<CustomFieldsForm onLoadFields={onLoadCustomFields} customFieldsData={customFields} setCustomFieldsData={handleCustomFields} />
{append}
</FieldGroup>
</ContextualbarScrollableContent>

@ -3,11 +3,11 @@ import { action } from '@storybook/addon-actions';
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import CustomFieldsForm from './CustomFieldsForm';
import NewCustomFieldsForm from './NewCustomFieldsForm';
export default {
title: 'Omnichannel/CustomFieldsForm',
component: CustomFieldsForm,
title: 'Omnichannel/NewCustomFieldsForm',
component: NewCustomFieldsForm,
decorators: [
(fn) => (
<Box maxWidth='x600' alignSelf='center' w='full' m='x24'>
@ -15,9 +15,9 @@ export default {
</Box>
),
],
} as ComponentMeta<typeof CustomFieldsForm>;
} as ComponentMeta<typeof NewCustomFieldsForm>;
export const Default: ComponentStory<typeof CustomFieldsForm> = (args) => <CustomFieldsForm {...args} />;
export const Default: ComponentStory<typeof NewCustomFieldsForm> = (args) => <NewCustomFieldsForm {...args} />;
Default.storyName = 'CustomFieldsForm';
Default.args = {
values: {

@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react';
import Page from '../../../components/Page';
import { useForm } from '../../../hooks/useForm';
import { useFormsSubscription } from '../additionalForms';
import CustomFieldsForm from './CustomFieldsForm';
import NewCustomFieldsForm from './NewCustomFieldsForm';
const getInitialValues = (cf) => ({
id: cf._id,
@ -79,7 +79,7 @@ const EditCustomFieldsPage = ({ customField, id, reload }) => {
<Page.ScrollableContentWithShadow>
<Box maxWidth='x600' w='full' alignSelf='center'>
<FieldGroup>
<CustomFieldsForm values={values} handlers={handlers} />
<NewCustomFieldsForm values={values} handlers={handlers} />
{AdditionalForm && <AdditionalForm onChange={handleAdditionalForm} state={values} data={customField} />}
</FieldGroup>
</Box>

@ -2,7 +2,7 @@ import { Box, Field, TextInput, ToggleSwitch, Select } from '@rocket.chat/fusela
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
const CustomFieldsForm = ({ values = {}, handlers = {}, className }) => {
const NewCustomFieldsForm = ({ values = {}, handlers = {}, className }) => {
const t = useTranslation();
const { id, field, label, scope, visibility, searchable, regexp } = values;
@ -63,4 +63,4 @@ const CustomFieldsForm = ({ values = {}, handlers = {}, className }) => {
);
};
export default CustomFieldsForm;
export default NewCustomFieldsForm;

@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react';
import Page from '../../../components/Page';
import { useForm } from '../../../hooks/useForm';
import { useFormsSubscription } from '../additionalForms';
import CustomFieldsForm from './CustomFieldsForm';
import NewCustomFieldsForm from './NewCustomFieldsForm';
const initialValues = {
field: '',
@ -78,7 +78,7 @@ const NewCustomFieldsPage = ({ reload }) => {
<Page.ScrollableContentWithShadow>
<Box maxWidth='x600' w='full' alignSelf='center'>
<FieldGroup>
<CustomFieldsForm values={values} handlers={handlers} />
<NewCustomFieldsForm values={values} handlers={handlers} />
{AdditionalForm && <AdditionalForm onChange={handleAdditionalForm} state={values} />}
</FieldGroup>
</Box>

@ -1,5 +1,6 @@
import type { ILivechatVisitor, IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
@ -8,7 +9,6 @@ import { useController, useForm } from 'react-hook-form';
import { hasAtLeastOnePermission } from '../../../../../../../app/authorization/client';
import { useOmnichannelPriorities } from '../../../../../../../ee/client/omnichannel/hooks/useOmnichannelPriorities';
import { ContextualbarFooter, ContextualbarScrollableContent } from '../../../../../../components/Contextualbar';
import { CustomFieldsForm } from '../../../../../../components/CustomFieldsFormV2';
import Tags from '../../../../../../components/Omnichannel/Tags';
import { useFormsSubscription } from '../../../../additionalForms';
import { FormSkeleton } from '../../../components/FormSkeleton';

@ -1,5 +1,6 @@
import type { ILivechatVisitor, Serialized } from '@rocket.chat/core-typings';
import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { ReactElement } from 'react';
@ -9,7 +10,6 @@ import { useForm } from 'react-hook-form';
import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client';
import { validateEmail } from '../../../../../../lib/emailValidator';
import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../../../components/Contextualbar';
import { CustomFieldsForm } from '../../../../../components/CustomFieldsFormV2';
import { createToken } from '../../../../../lib/utils/createToken';
import { useFormsSubscription } from '../../../additionalForms';
import { FormSkeleton } from '../../components/FormSkeleton';

@ -1,6 +1,4 @@
import type { ILivechatCustomField, Serialized } from '@rocket.chat/core-typings';
import type { CustomFieldMetadata } from '../../../../components/CustomFieldsFormV2';
import type { ILivechatCustomField, Serialized, CustomFieldMetadata } from '@rocket.chat/core-typings';
export const formatCustomFieldsMetadata = (
customFields: Serialized<ILivechatCustomField>[],

@ -2,6 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings';
import { TextInput, ButtonGroup, Button, FieldGroup, Field, Box } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { VerticalWizardLayout, Form } from '@rocket.chat/layout';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import {
useSetting,
useTranslation,
@ -11,12 +12,12 @@ import {
useToastMessageDispatch,
useAssetWithDarkModePath,
useMethod,
useAccountsCustomFields,
} from '@rocket.chat/ui-contexts';
import { useQuery, useMutation } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import AccountsCustomFields from '../../../components/AccountsCustomFields';
import { queryClient } from '../../../lib/queryClient';
type RegisterUsernamePayload = {
@ -32,6 +33,7 @@ const RegisterUsername = () => {
const customLogo = useAssetWithDarkModePath('logo');
const customBackground = useAssetWithDarkModePath('background');
const dispatchToastMessage = useToastMessageDispatch();
const customFields = useAccountsCustomFields();
if (!uid) {
throw new Error('Invalid user');
@ -42,15 +44,17 @@ const RegisterUsername = () => {
const usernameSuggestion = useEndpoint('GET', '/v1/users.getUsernameSuggestion');
const { data, isLoading } = useQuery(['suggestion'], async () => usernameSuggestion());
const methods = useForm<RegisterUsernamePayload>();
const {
register,
handleSubmit,
setValue,
getValues,
setError,
control,
formState: { errors },
} = methods;
} = useForm<RegisterUsernamePayload>({
mode: 'onBlur',
});
useEffect(() => {
if (data?.result && getValues('username') === '') {
@ -89,37 +93,35 @@ const RegisterUsername = () => {
background={customBackground}
logo={!hideLogo && customLogo ? <Box is='img' maxHeight='x40' mi='neg-x8' src={customLogo} alt='Logo' /> : <></>}
>
<FormProvider {...methods}>
<Form aria-labelledby={formLabelId} onSubmit={handleSubmit((data) => registerUsernameMutation.mutate(data))}>
<Form.Header>
<Form.Title id={formLabelId}>{t('Username_title')}</Form.Title>
<Form.Subtitle>{t('Username_description')}</Form.Subtitle>
</Form.Header>
<Form.Container>
{!isLoading && (
<FieldGroup>
<Field>
<Field.Label id='username-label'>{t('Username')}</Field.Label>
<Field.Row>
<TextInput aria-labelledby='username-label' {...register('username', { required: t('Username_cant_be_empty') })} />
</Field.Row>
{errors.username && <Field.Error>{errors.username.message}</Field.Error>}
</Field>
</FieldGroup>
)}
{isLoading && t('Loading_suggestion')}
<AccountsCustomFields />
</Form.Container>
<Form.Footer>
<ButtonGroup stretch vertical flexGrow={1}>
<Button disabled={isLoading} type='submit' primary>
{t('Use_this_username')}
</Button>
<Button onClick={logout}>{t('Logout')}</Button>
</ButtonGroup>
</Form.Footer>
</Form>
</FormProvider>
<Form aria-labelledby={formLabelId} onSubmit={handleSubmit((data) => registerUsernameMutation.mutate(data))}>
<Form.Header>
<Form.Title id={formLabelId}>{t('Username_title')}</Form.Title>
<Form.Subtitle>{t('Username_description')}</Form.Subtitle>
</Form.Header>
<Form.Container>
{!isLoading && (
<FieldGroup>
<Field>
<Field.Label id='username-label'>{t('Username')}</Field.Label>
<Field.Row>
<TextInput aria-labelledby='username-label' {...register('username', { required: t('Username_cant_be_empty') })} />
</Field.Row>
{errors.username && <Field.Error>{errors.username.message}</Field.Error>}
</Field>
</FieldGroup>
)}
{isLoading && t('Loading_suggestion')}
<CustomFieldsForm formName='customFields' formControl={control} metadata={customFields} />
</Form.Container>
<Form.Footer>
<ButtonGroup stretch vertical flexGrow={1}>
<Button disabled={isLoading} type='submit' primary>
{t('Use_this_username')}
</Button>
<Button onClick={logout}>{t('Logout')}</Button>
</ButtonGroup>
</Form.Footer>
</Form>
</VerticalWizardLayout>
);
};

@ -0,0 +1,12 @@
import type { SelectOption } from '@rocket.chat/fuselage';
export type CustomFieldMetadata = {
name: string;
label?: string;
type: 'select' | 'text';
required?: boolean;
defaultValue?: any;
minLength?: number;
maxLength?: number;
options?: SelectOption[];
};

@ -133,3 +133,4 @@ export * from './migrations/IControl';
export * from './ICustomOAuthConfig';
export * from './IModerationReport';
export * from './CustomFieldMetadata';

@ -11,6 +11,7 @@ export type UserRegisterParamsPOST = {
pass: string;
secret?: string;
reason?: string;
customFields?: object;
};
const UserRegisterParamsPostSchema = {
@ -38,6 +39,10 @@ const UserRegisterParamsPostSchema = {
type: 'string',
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
},
required: ['username', 'email', 'pass'],
additionalProperties: false,

@ -31,6 +31,7 @@
"eslint-plugin-testing-library": "~5.11.0",
"jest": "~29.5.0",
"react": "~17.0.2",
"react-hook-form": "^7.30.0",
"ts-jest": "~29.0.5",
"typescript": "~5.1.3"
},

@ -1,20 +1,10 @@
/* eslint-disable react/no-multi-comp */
import type { CustomFieldMetadata } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Field, Select, TextInput } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { Control, FieldValues } from 'react-hook-form';
import { Controller, get } from 'react-hook-form';
export type CustomFieldMetadata = {
name: string;
label: string;
type: 'select' | 'text';
required?: boolean;
defaultValue?: any;
options?: SelectOption[];
};
import { Controller } from 'react-hook-form';
type CustomFieldFormProps<T extends FieldValues> = {
metadata: CustomFieldMetadata[];
@ -43,36 +33,54 @@ const CustomField = <T extends FieldValues>({
...props
}: CustomFieldProps<T>) => {
const t = useTranslation();
const { getFieldState } = control;
const Component = FIELD_TYPES[type] ?? null;
const selectOptions =
options.length > 0 && options[0] instanceof Array ? options : options.map((option) => [option, option, defaultValue === option]);
const getErrorMessage = (error: any) => {
switch (error?.type) {
case 'required':
return t('The_field_is_required', label || name);
case 'minLength':
return t('Min_length_is', props?.minLength);
case 'maxLength':
return t('Max_length_is', props?.maxLength);
}
};
const error = getErrorMessage(getFieldState(name as any).error);
return (
<Controller<T, any>
name={name}
control={control}
defaultValue={defaultValue ?? ''}
rules={{ required: required && t('The_field_is_required', label || name) }}
render={({ field, formState: { errors } }) => (
<Field>
rules={{ required, minLength: props.minLength, maxLength: props.maxLength }}
render={({ field }) => (
<Field rcx-field-group__item>
<Field.Label>
{label || t(name as TranslationKey)}
{required && '*'}
</Field.Label>
<Field.Row>
<Component {...props} {...field} options={options} error={get(errors, name) as string} flexGrow={1} />
<Component {...props} {...field} error={error} options={selectOptions as SelectOption[]} flexGrow={1} />
</Field.Row>
<Field.Error>{get(errors, name)?.message}</Field.Error>
<Field.Error>{error}</Field.Error>
</Field>
)}
/>
);
};
CustomField.displayName = 'CustomField';
// eslint-disable-next-line react/no-multi-comp
export const CustomFieldsForm = <T extends FieldValues>({ formName, formControl, metadata }: CustomFieldFormProps<T>) => (
<>
{metadata.map(({ name: fieldName, ...props }) => (
<CustomField key={fieldName} name={`${formName}.${fieldName}`} control={formControl} {...props} />
))}
{metadata.map(({ name: fieldName, ...props }) => {
props.label = props.label ?? fieldName;
return <CustomField key={fieldName} name={`${formName}.${fieldName}`} control={formControl} {...props} />;
})}
</>
);

@ -1,6 +1,7 @@
export * from './EmojiPicker';
export * from './ExternalLink';
export * from './DotLeader';
export * from './CustomFieldsForm';
export * from './PasswordVerifier';
export { default as TextSeparator } from './TextSeparator';
export * from './TooltipComponent';

@ -1,17 +1,9 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { CustomFieldMetadata } from '@rocket.chat/core-typings';
type AccountsCustomField = {
type: 'text' | 'select';
required: boolean;
defaultValue: string;
minLength: number;
maxLength: number;
options: string[];
name: string;
};
import { useSetting } from './useSetting';
export const useAccountsCustomFields = (): AccountsCustomField[] => {
export const useAccountsCustomFields = (): CustomFieldMetadata[] => {
const accountsCustomFieldsJSON = useSetting('Accounts_CustomFields');
return useMemo(() => {

@ -87,6 +87,7 @@ export { useAvailableDevices } from './hooks/useAvailableDevices';
export { useIsDeviceManagementEnabled } from './hooks/useIsDeviceManagementEnabled';
export { useSetOutputMediaDevice } from './hooks/useSetOutputMediaDevice';
export { useSetInputMediaDevice } from './hooks/useSetInputMediaDevice';
export { useAccountsCustomFields } from './hooks/useAccountsCustomFields';
export { ServerMethods, ServerMethodName, ServerMethodParameters, ServerMethodReturn, ServerMethodFunction } from './ServerContext/methods';
export { StreamerEvents, StreamNames, StreamKeys, StreamerConfigs, StreamerConfig, StreamerCallbackArgs } from './ServerContext/streams';

@ -1,9 +1,10 @@
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { FieldGroup, TextInput, Field, PasswordInput, ButtonGroup, Button, TextAreaInput } from '@rocket.chat/fuselage';
import { FieldGroup, TextInput, Field, PasswordInput, ButtonGroup, Button, TextAreaInput, Callout } from '@rocket.chat/fuselage';
import { Form, ActionLink } from '@rocket.chat/layout';
import { useSetting, useVerifyPassword, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { PasswordVerifier } from '@rocket.chat/ui-client';
import { useAccountsCustomFields, useVerifyPassword, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { PasswordVerifier, CustomFieldsForm } from '@rocket.chat/ui-client';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
@ -34,6 +35,9 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo
const formLabelId = useUniqueId();
const registerUser = useRegisterMethod();
const customFields = useAccountsCustomFields();
const [serverError, setServerError] = useState<string | undefined>(undefined);
const dispatchToastMessage = useToastMessageDispatch();
@ -44,6 +48,7 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo
watch,
getValues,
clearErrors,
control,
formState: { errors },
} = useForm<LoginRegisterPayload>();
@ -75,6 +80,9 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo
dispatchToastMessage({ type: 'info', message: t('registration.page.registration.waitActivationWarning') });
setLoginRoute('login');
}
if (error.error === 'error-user-registration-custom-field') {
setServerError(error.message);
}
},
},
);
@ -196,6 +204,8 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo
{errors.reason && <Field.Error>{t('registration.component.form.requiredField')}</Field.Error>}
</Field>
)}
{customFields.length > 0 && <CustomFieldsForm formName='customFields' formControl={control} metadata={customFields} />}
{serverError && <Callout type='danger'>{serverError}</Callout>}
</FieldGroup>
</Form.Container>
<Form.Footer>

@ -10932,6 +10932,7 @@ __metadata:
eslint-plugin-testing-library: ~5.11.0
jest: ~29.5.0
react: ~17.0.2
react-hook-form: ^7.30.0
ts-jest: ~29.0.5
typescript: ~5.1.3
peerDependencies:

Loading…
Cancel
Save