Skip to content

Commit 147fa09

Browse files
fix(ux): standardize form validation (#39590)
1 parent c495bdd commit 147fa09

File tree

36 files changed

+706
-358
lines changed

36 files changed

+706
-358
lines changed

apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ const CreateDiscussion = ({
6060
watch,
6161
setValue,
6262
} = useForm({
63-
mode: 'onBlur',
6463
defaultValues: {
6564
name: nameSuggestion || '',
6665
parentRoom: '',
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
2+
import { useCallback } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
5+
type UseFormSubmitOptions = {
6+
isDirty: boolean;
7+
noChangesMessage?: string;
8+
};
9+
10+
/**
11+
* A reusable hook for form submission that implements accessible form validation patterns.
12+
*
13+
* This hook wraps your form submission handler and:
14+
* - Allows submission attempts in both create and edit modes (keeping buttons enabled for a11y)
15+
* - Provides user-friendly feedback when trying to save an unchanged edit form
16+
*/
17+
18+
export const useFormSubmitWithDirtyCheck = <TData>(
19+
onSubmit: (data: TData) => Promise<void> | void,
20+
{ isDirty, noChangesMessage = 'No_changes_to_save' }: UseFormSubmitOptions,
21+
) => {
22+
const { t } = useTranslation();
23+
const dispatchToastMessage = useToastMessageDispatch();
24+
25+
return useCallback(
26+
async (data: TData): Promise<void> => {
27+
if (!!data && !isDirty) {
28+
dispatchToastMessage({
29+
type: 'info',
30+
message: t(noChangesMessage),
31+
});
32+
return;
33+
}
34+
35+
await onSubmit(data);
36+
},
37+
[isDirty, onSubmit, dispatchToastMessage, t, noChangesMessage],
38+
);
39+
};

apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
111111
setValue,
112112
watch,
113113
} = useForm({
114-
mode: 'onBlur',
115114
defaultValues: {
116115
members: [],
117116
name: '',

apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateDirectMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => {
3838
control,
3939
handleSubmit,
4040
formState: { isSubmitting, isValidating, errors },
41-
} = useForm({ mode: 'onBlur', defaultValues: { users: [] } });
41+
} = useForm({ defaultValues: { users: [] } });
4242

4343
const goToRoom = useGoToRoom();
4444

apps/meteor/client/views/account/profile/AccountProfilePage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const AccountProfilePage = (): ReactElement => {
3939

4040
const methods = useForm({
4141
defaultValues: getProfileInitialValues(user),
42-
mode: 'onBlur',
42+
reValidateMode: 'onBlur',
4343
});
4444

4545
const {

apps/meteor/client/views/admin/users/AdminUserForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD
120120
isNewUserPage,
121121
isVerificationNeeded: !!isVerificationNeeded,
122122
}),
123-
mode: 'onBlur',
124123
});
125124

126125
const showVoipExtension = useShowVoipExtension();

apps/meteor/client/views/omnichannel/components/outboundMessage/components/OutboundMessageWizard/forms/MessageForm/MessageForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ const MessageForm = (props: MessageFormProps) => {
5050
setValue,
5151
} = useForm<MessageFormData>({
5252
mode: 'onChange',
53-
reValidateMode: 'onChange',
5453
defaultValues: {
5554
templateParameters: defaultValues?.templateParameters ?? {},
5655
templateId: defaultValues?.templateId ?? '',

apps/meteor/client/views/omnichannel/components/outboundMessage/components/OutboundMessageWizard/forms/RecipientForm/RecipientForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ const RecipientForm = (props: RecipientFormProps) => {
4646

4747
const { trigger, control, handleSubmit, formState, clearErrors, setValue } = useForm<RecipientFormData>({
4848
mode: 'onChange',
49-
reValidateMode: 'onChange',
5049
defaultValues: {
5150
contactId: defaultValues?.contactId ?? '',
5251
providerId: defaultValues?.providerId ?? '',

apps/meteor/client/views/omnichannel/contactInfo/EditContactInfo.tsx

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import AdvancedContactModal from './AdvancedContactModal';
2323
import { useCreateContact } from './hooks/useCreateContact';
2424
import { useEditContact } from './hooks/useEditContact';
2525
import { hasAtLeastOnePermission } from '../../../../app/authorization/client';
26+
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
2627
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
2728
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
2829
import { ContactManagerInput } from '../additionalForms';
@@ -89,12 +90,11 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps
8990
const initialValue = getInitialValues(contactData);
9091

9192
const {
92-
formState: { errors, isSubmitting },
93+
formState: { errors, isSubmitting, isDirty },
9394
control,
9495
watch,
9596
handleSubmit,
9697
} = useForm<ContactFormData>({
97-
mode: 'onBlur',
9898
reValidateMode: 'onBlur',
9999
defaultValues: initialValue,
100100
});
@@ -165,26 +165,31 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps
165165

166166
const validateName = (v: string): string | boolean => (!v.trim() ? t('Required_field', { field: t('Name') }) : true);
167167

168-
const handleSave = async (data: ContactFormData): Promise<void> => {
169-
const { name, phones, emails, customFields, contactManager } = data;
170-
171-
const payload = {
172-
name,
173-
phones: phones.map(({ phoneNumber }) => phoneNumber),
174-
emails: emails.map(({ address }) => address),
175-
customFields,
176-
contactManager,
177-
};
178-
179-
if (contactData) {
180-
await editContact.mutateAsync({ contactId: contactData?._id, ...payload });
168+
const handleSave = useFormSubmitWithDirtyCheck(
169+
async (data: ContactFormData): Promise<void> => {
170+
const { name, phones, emails, customFields, contactManager } = data;
171+
172+
const payload = {
173+
name,
174+
phones: phones.map(({ phoneNumber }) => phoneNumber),
175+
emails: emails.map(({ address }) => address),
176+
customFields,
177+
contactManager,
178+
};
179+
180+
if (contactData) {
181+
await editContact.mutateAsync({ contactId: contactData?._id, ...payload });
182+
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
183+
return;
184+
}
185+
186+
await createContact.mutateAsync(payload);
181187
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
182-
return;
183-
}
184-
185-
await createContact.mutateAsync(payload);
186-
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
187-
};
188+
},
189+
{
190+
isDirty,
191+
},
192+
);
188193

189194
const formId = useId();
190195
const nameField = useId();

apps/meteor/client/views/omnichannel/customFields/EditCustomFields.tsx

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
ToggleSwitch,
1414
Box,
1515
} from '@rocket.chat/fuselage';
16-
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
1716
import {
1817
ContextualbarTitle,
1918
ContextualbarHeader,
@@ -28,6 +27,7 @@ import { FormProvider, useForm, Controller } from 'react-hook-form';
2827

2928
import { CustomFieldsAdditionalForm } from '../additionalForms';
3029
import { useRemoveCustomField } from './useRemoveCustomField';
30+
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
3131
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
3232

3333
export type EditCustomFieldsFormData = {
@@ -66,7 +66,9 @@ const EditCustomFields = ({ customFieldData, onClose }: { customFieldData?: Seri
6666

6767
const handleDelete = useRemoveCustomField();
6868

69-
const methods = useForm<EditCustomFieldsFormData>({ mode: 'onBlur', values: getInitialValues(customFieldData) });
69+
const methods = useForm<EditCustomFieldsFormData>({
70+
values: getInitialValues(customFieldData),
71+
});
7072
const {
7173
control,
7274
handleSubmit,
@@ -75,25 +77,28 @@ const EditCustomFields = ({ customFieldData, onClose }: { customFieldData?: Seri
7577

7678
const saveCustomField = useEndpoint('POST', '/v1/livechat/custom-fields.save');
7779

78-
const handleSave = useEffectEvent(async ({ visibility, ...data }: EditCustomFieldsFormData) => {
79-
try {
80-
await saveCustomField({
81-
customFieldId: customFieldData?._id as unknown as string,
82-
customFieldData: {
83-
visibility: visibility ? 'visible' : 'hidden',
84-
...data,
85-
},
86-
});
80+
const handleSave = useFormSubmitWithDirtyCheck(
81+
async ({ visibility, ...data }: EditCustomFieldsFormData) => {
82+
try {
83+
await saveCustomField({
84+
customFieldId: customFieldData?._id as unknown as string,
85+
customFieldData: {
86+
visibility: visibility ? 'visible' : 'hidden',
87+
...data,
88+
},
89+
});
8790

88-
dispatchToastMessage({ type: 'success', message: t('Saved') });
89-
queryClient.invalidateQueries({
90-
queryKey: omnichannelQueryKeys.livechat.customFields(),
91-
});
92-
onClose();
93-
} catch (error) {
94-
dispatchToastMessage({ type: 'error', message: error });
95-
}
96-
});
91+
dispatchToastMessage({ type: 'success', message: t('Saved') });
92+
queryClient.invalidateQueries({
93+
queryKey: omnichannelQueryKeys.livechat.customFields(),
94+
});
95+
onClose();
96+
} catch (error) {
97+
dispatchToastMessage({ type: 'error', message: error });
98+
}
99+
},
100+
{ isDirty },
101+
);
97102

98103
const scopeOptions: SelectOption[] = useMemo(
99104
() => [
@@ -221,7 +226,7 @@ const EditCustomFields = ({ customFieldData, onClose }: { customFieldData?: Seri
221226
<ContextualbarFooter>
222227
<ButtonGroup stretch>
223228
<Button onClick={onClose}>{t('Cancel')}</Button>
224-
<Button form={formId} primary type='submit' disabled={!isDirty}>
229+
<Button form={formId} primary type='submit'>
225230
{t('Save')}
226231
</Button>
227232
</ButtonGroup>

0 commit comments

Comments
 (0)