From e14ec50816ef34ee1df61cb8e824cb2a55ff6db9 Mon Sep 17 00:00:00 2001 From: Hugo Costa Date: Fri, 23 Jun 2023 09:45:07 -0300 Subject: [PATCH] feat: custom fields component to registration form (#29202) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> --- .changeset/custom-fields.md | 10 ++ apps/meteor/app/api/server/v1/users.ts | 13 ++ .../AccountsCustomFieldsAssembler.tsx | 71 -------- .../components/AccountsCustomFields/index.ts | 1 - .../client/components/CustomFieldsForm.js | 152 ------------------ .../account/profile/AccountProfileForm.tsx | 45 +++++- .../account/profile/AccountProfilePage.tsx | 2 +- .../client/views/admin/users/AddUser.js | 2 +- .../client/views/admin/users/EditUser.js | 2 +- .../client/views/admin/users/UserForm.js | 36 +++-- .../customFields/CustomFieldsForm.stories.tsx | 10 +- .../customFields/EditCustomFieldsPage.js | 4 +- ...omFieldsForm.js => NewCustomFieldsForm.js} | 4 +- .../customFields/NewCustomFieldsPage.js | 4 +- .../chats/contextualBar/RoomEdit/RoomEdit.tsx | 2 +- .../contacts/contextualBar/ContactNewEdit.tsx | 2 +- .../utils/formatCustomFieldsMetadata.tsx | 4 +- .../root/MainLayout/RegisterUsername.tsx | 72 +++++---- .../core-typings/src/CustomFieldMetadata.ts | 12 ++ packages/core-typings/src/index.ts | 1 + .../src/v1/users/UserRegisterParamsPOST.ts | 5 + packages/ui-client/package.json | 1 + .../src/components/CustomFieldsForm.tsx | 52 +++--- packages/ui-client/src/components/index.ts | 1 + .../src}/hooks/useAccountsCustomFields.ts | 14 +- packages/ui-contexts/src/index.ts | 1 + .../web-ui-registration/src/RegisterForm.tsx | 16 +- yarn.lock | 1 + 28 files changed, 209 insertions(+), 331 deletions(-) create mode 100644 .changeset/custom-fields.md delete mode 100644 apps/meteor/client/components/AccountsCustomFields/AccountsCustomFieldsAssembler.tsx delete mode 100644 apps/meteor/client/components/AccountsCustomFields/index.ts delete mode 100644 apps/meteor/client/components/CustomFieldsForm.js rename apps/meteor/client/views/omnichannel/customFields/{CustomFieldsForm.js => NewCustomFieldsForm.js} (94%) create mode 100644 packages/core-typings/src/CustomFieldMetadata.ts rename apps/meteor/client/components/CustomFieldsFormV2.tsx => packages/ui-client/src/components/CustomFieldsForm.tsx (51%) rename {apps/meteor/client => packages/ui-contexts/src}/hooks/useAccountsCustomFields.ts (64%) diff --git a/.changeset/custom-fields.md b/.changeset/custom-fields.md new file mode 100644 index 000000000000..c4598efb5f71 --- /dev/null +++ b/.changeset/custom-fields.md @@ -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 diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 8c0a8aea8e12..56c15c6e35c8 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -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 }); }, }, diff --git a/apps/meteor/client/components/AccountsCustomFields/AccountsCustomFieldsAssembler.tsx b/apps/meteor/client/components/AccountsCustomFields/AccountsCustomFieldsAssembler.tsx deleted file mode 100644 index 7598fefb47bf..000000000000 --- a/apps/meteor/client/components/AccountsCustomFields/AccountsCustomFieldsAssembler.tsx +++ /dev/null @@ -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 ( - - - {t.has(customField.name) ? t(customField.name) : customField.name} - {customField.required && '*'} - - - {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 - */ - setState(val)} /> - - {selectError} - - ), - [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 ; - } - - if (value.type === 'text') { - return ; - } - - 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 ; -} diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index aa0b23849ae1..23381a634124 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -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(); 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 => { 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, ], )} - + + {customFieldsMetadata && } ); }; diff --git a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx index 4d424167643a..b2f629dd38fa 100644 --- a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx @@ -248,7 +248,7 @@ const AccountProfilePage = (): ReactElement => { - - diff --git a/apps/meteor/client/views/admin/users/EditUser.js b/apps/meteor/client/views/admin/users/EditUser.js index 984a6e1552bf..ae216dd4114a 100644 --- a/apps/meteor/client/views/admin/users/EditUser.js +++ b/apps/meteor/client/views/admin/users/EditUser.js @@ -134,7 +134,7 @@ function EditUser({ data, roles, onReload, ...props }) { - diff --git a/apps/meteor/client/views/admin/users/UserForm.js b/apps/meteor/client/views/admin/users/UserForm.js index d530a0b49816..b0ce0cfdfbf6 100644 --- a/apps/meteor/client/views/admin/users/UserForm.js +++ b/apps/meteor/client/views/admin/users/UserForm.js @@ -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 ( e.preventDefault(), [])} autoComplete='off'> @@ -274,13 +284,17 @@ export default function UserForm({ formValues, formHandlers, availableRoles, app ), [handleSendWelcomeEmail, t, sendWelcomeEmail, isSmtpEnabled], )} - {hasCustomFields && ( - <> - - {t('Custom_Fields')} - + {useMemo( + () => + customFieldsMetadata && ( + <> + + {t('Custom_Fields')} + + + ), + [customFieldsMetadata, control, t], )} - {append} diff --git a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.stories.tsx b/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.stories.tsx index f8c6320e6615..55bd3ee4e255 100644 --- a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.stories.tsx +++ b/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.stories.tsx @@ -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) => ( @@ -15,9 +15,9 @@ export default { ), ], -} as ComponentMeta; +} as ComponentMeta; -export const Default: ComponentStory = (args) => ; +export const Default: ComponentStory = (args) => ; Default.storyName = 'CustomFieldsForm'; Default.args = { values: { diff --git a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js index 9b5854b29913..45bbe78a9724 100644 --- a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js @@ -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 }) => { - + {AdditionalForm && } diff --git a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.js b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsForm.js similarity index 94% rename from apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.js rename to apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsForm.js index 5ab6f4a19dfd..5ef97a6e8e3d 100644 --- a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsForm.js +++ b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsForm.js @@ -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; diff --git a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js index ee00c9242778..c4185758dbc4 100644 --- a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js @@ -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 }) => { - + {AdditionalForm && } diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx index 71e7ec57ca42..435e29743469 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx @@ -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'; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx index c98d9ccb2257..53c30d49a0d4 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx @@ -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'; diff --git a/apps/meteor/client/views/omnichannel/directory/utils/formatCustomFieldsMetadata.tsx b/apps/meteor/client/views/omnichannel/directory/utils/formatCustomFieldsMetadata.tsx index a4f84f45f91e..b5648d67199f 100644 --- a/apps/meteor/client/views/omnichannel/directory/utils/formatCustomFieldsMetadata.tsx +++ b/apps/meteor/client/views/omnichannel/directory/utils/formatCustomFieldsMetadata.tsx @@ -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[], diff --git a/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx b/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx index 92dab6f3db08..e7dae770c444 100644 --- a/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx +++ b/apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx @@ -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(); const { register, handleSubmit, setValue, getValues, setError, + control, formState: { errors }, - } = methods; + } = useForm({ + mode: 'onBlur', + }); useEffect(() => { if (data?.result && getValues('username') === '') { @@ -89,37 +93,35 @@ const RegisterUsername = () => { background={customBackground} logo={!hideLogo && customLogo ? : <>} > - -
registerUsernameMutation.mutate(data))}> - - {t('Username_title')} - {t('Username_description')} - - - {!isLoading && ( - - - {t('Username')} - - - - {errors.username && {errors.username.message}} - - - )} - {isLoading && t('Loading_suggestion')} - - - - - - - - -
-
+
registerUsernameMutation.mutate(data))}> + + {t('Username_title')} + {t('Username_description')} + + + {!isLoading && ( + + + {t('Username')} + + + + {errors.username && {errors.username.message}} + + + )} + {isLoading && t('Loading_suggestion')} + + + + + + + + +
); }; diff --git a/packages/core-typings/src/CustomFieldMetadata.ts b/packages/core-typings/src/CustomFieldMetadata.ts new file mode 100644 index 000000000000..bd351c9e37d1 --- /dev/null +++ b/packages/core-typings/src/CustomFieldMetadata.ts @@ -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[]; +}; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index d2c35b3a4a8f..9ba91eb373b4 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -133,3 +133,4 @@ export * from './migrations/IControl'; export * from './ICustomOAuthConfig'; export * from './IModerationReport'; +export * from './CustomFieldMetadata'; diff --git a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts index 236f7136966a..a0475042c941 100644 --- a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts @@ -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, diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index e1c6be800205..8fbb4d6acd21 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -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" }, diff --git a/apps/meteor/client/components/CustomFieldsFormV2.tsx b/packages/ui-client/src/components/CustomFieldsForm.tsx similarity index 51% rename from apps/meteor/client/components/CustomFieldsFormV2.tsx rename to packages/ui-client/src/components/CustomFieldsForm.tsx index 8125c14fa457..dee63d50106e 100644 --- a/apps/meteor/client/components/CustomFieldsFormV2.tsx +++ b/packages/ui-client/src/components/CustomFieldsForm.tsx @@ -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 = { metadata: CustomFieldMetadata[]; @@ -43,36 +33,54 @@ const CustomField = ({ ...props }: CustomFieldProps) => { 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 ( name={name} control={control} defaultValue={defaultValue ?? ''} - rules={{ required: required && t('The_field_is_required', label || name) }} - render={({ field, formState: { errors } }) => ( - + rules={{ required, minLength: props.minLength, maxLength: props.maxLength }} + render={({ field }) => ( + {label || t(name as TranslationKey)} {required && '*'} - + - {get(errors, name)?.message} + {error} )} /> ); }; -CustomField.displayName = 'CustomField'; - +// eslint-disable-next-line react/no-multi-comp export const CustomFieldsForm = ({ formName, formControl, metadata }: CustomFieldFormProps) => ( <> - {metadata.map(({ name: fieldName, ...props }) => ( - - ))} + {metadata.map(({ name: fieldName, ...props }) => { + props.label = props.label ?? fieldName; + return ; + })} ); diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index 9aa37f5479eb..5a0f1463be80 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -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'; diff --git a/apps/meteor/client/hooks/useAccountsCustomFields.ts b/packages/ui-contexts/src/hooks/useAccountsCustomFields.ts similarity index 64% rename from apps/meteor/client/hooks/useAccountsCustomFields.ts rename to packages/ui-contexts/src/hooks/useAccountsCustomFields.ts index 66786fdceb6e..8daa51b240ff 100644 --- a/apps/meteor/client/hooks/useAccountsCustomFields.ts +++ b/packages/ui-contexts/src/hooks/useAccountsCustomFields.ts @@ -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(() => { diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 771a34eed36d..7780e6fa7fa1 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -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'; diff --git a/packages/web-ui-registration/src/RegisterForm.tsx b/packages/web-ui-registration/src/RegisterForm.tsx index 8870832991e4..5400dccce79f 100644 --- a/packages/web-ui-registration/src/RegisterForm.tsx +++ b/packages/web-ui-registration/src/RegisterForm.tsx @@ -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(undefined); const dispatchToastMessage = useToastMessageDispatch(); @@ -44,6 +48,7 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo watch, getValues, clearErrors, + control, formState: { errors }, } = useForm(); @@ -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 && {t('registration.component.form.requiredField')}} )} + {customFields.length > 0 && } + {serverError && {serverError}} diff --git a/yarn.lock b/yarn.lock index 6d50fb8fa28a..a0de900e478f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: