Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
15a1c9b
fix: change validation mode `onBlur` to `onSubmit`
juliajforesti Mar 12, 2026
5a89087
test: adjust `external-messages` tests
juliajforesti Mar 13, 2026
0ce530d
test: adjust omni `contact-center-contacts` tests
juliajforesti Mar 13, 2026
59bb89c
test: adjust omni `tags` and `unit` tests
juliajforesti Mar 13, 2026
c69ec14
fix: change validation mode `onChange` to `onSubmit`
juliajforesti Mar 13, 2026
bab1c5e
fix: `PriorityEditForm` validation
juliajforesti Mar 13, 2026
1fc2e81
test: adjust `omnichannel-monitor-department` tests
juliajforesti Mar 16, 2026
f520052
test: adjust `omnichannel-priorities` tests
juliajforesti Mar 16, 2026
ea1bc93
fix: mark required fields in SLA edit form
juliajforesti Mar 16, 2026
82a386e
test: adjust `omnichannel-departments` tests
juliajforesti Mar 16, 2026
911f589
test: adjust `omnichannel-sla-policies` tests
juliajforesti Mar 17, 2026
e2229b9
review
juliajforesti Mar 18, 2026
d78b8d4
fix: remove redundant `mode: 'onSubmit'` from useForm initialization
juliajforesti Mar 19, 2026
24c254b
chore: create `useFormSubmitWithDirtyCheck`
juliajforesti Mar 19, 2026
d1058f4
chore: apply `useFormSubmitWithDirtyCheck` for better a11y
juliajforesti Mar 19, 2026
3613c18
review
juliajforesti Mar 19, 2026
8a8d79a
chore: translation
juliajforesti Mar 19, 2026
db7b7b7
test: adjust `omnichannel-departaments`
juliajforesti Mar 19, 2026
f4c9460
chore: adjust priority and sla forms
juliajforesti Mar 20, 2026
4907bf1
chore: add docs
juliajforesti Mar 20, 2026
32bc172
Merge branch 'develop' into fix/form-validation
juliajforesti Mar 25, 2026
87ab430
Merge branch 'develop' into fix/form-validation
juliajforesti Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ const CreateDiscussion = ({
watch,
setValue,
} = useForm({
mode: 'onBlur',
defaultValues: {
name: nameSuggestion || '',
parentRoom: '',
Expand Down
39 changes: 39 additions & 0 deletions apps/meteor/client/hooks/useFormSubmitWithDirtyCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';

type UseFormSubmitOptions = {
isDirty: boolean;
noChangesMessage?: string;
};

/**
* A reusable hook for form submission that implements accessible form validation patterns.
*
* This hook wraps your form submission handler and:
* - Allows submission attempts in both create and edit modes (keeping buttons enabled for a11y)
* - Provides user-friendly feedback when trying to save an unchanged edit form
*/

export const useFormSubmitWithDirtyCheck = <TData>(
onSubmit: (data: TData) => Promise<void> | void,
{ isDirty, noChangesMessage = 'No_changes_to_save' }: UseFormSubmitOptions,
) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

return useCallback(
async (data: TData): Promise<void> => {
if (!!data && !isDirty) {
dispatchToastMessage({
type: 'info',
message: t(noChangesMessage),
});
return;
}

await onSubmit(data);
},
[isDirty, onSubmit, dispatchToastMessage, t, noChangesMessage],
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
setValue,
watch,
} = useForm({
mode: 'onBlur',
defaultValues: {
members: [],
name: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => {
control,
handleSubmit,
formState: { isSubmitting, isValidating, errors },
} = useForm({ mode: 'onBlur', defaultValues: { users: [] } });
} = useForm({ defaultValues: { users: [] } });

const goToRoom = useGoToRoom();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const AccountProfilePage = (): ReactElement => {

const methods = useForm({
defaultValues: getProfileInitialValues(user),
mode: 'onBlur',
reValidateMode: 'onBlur',
});

const {
Expand Down
1 change: 0 additions & 1 deletion apps/meteor/client/views/admin/users/AdminUserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD
isNewUserPage,
isVerificationNeeded: !!isVerificationNeeded,
}),
mode: 'onBlur',
});

const showVoipExtension = useShowVoipExtension();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ const MessageForm = (props: MessageFormProps) => {
setValue,
} = useForm<MessageFormData>({
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
templateParameters: defaultValues?.templateParameters ?? {},
templateId: defaultValues?.templateId ?? '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ const RecipientForm = (props: RecipientFormProps) => {

const { trigger, control, handleSubmit, formState, clearErrors, setValue } = useForm<RecipientFormData>({
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
contactId: defaultValues?.contactId ?? '',
providerId: defaultValues?.providerId ?? '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import AdvancedContactModal from './AdvancedContactModal';
import { useCreateContact } from './hooks/useCreateContact';
import { useEditContact } from './hooks/useEditContact';
import { hasAtLeastOnePermission } from '../../../../app/authorization/client';
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
import { ContactManagerInput } from '../additionalForms';
Expand Down Expand Up @@ -89,12 +90,11 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps
const initialValue = getInitialValues(contactData);

const {
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, isDirty },
control,
watch,
handleSubmit,
} = useForm<ContactFormData>({
mode: 'onBlur',
reValidateMode: 'onBlur',
defaultValues: initialValue,
});
Expand Down Expand Up @@ -165,26 +165,31 @@ const EditContactInfo = ({ contactData, onClose, onCancel }: ContactNewEditProps

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

const handleSave = async (data: ContactFormData): Promise<void> => {
const { name, phones, emails, customFields, contactManager } = data;

const payload = {
name,
phones: phones.map(({ phoneNumber }) => phoneNumber),
emails: emails.map(({ address }) => address),
customFields,
contactManager,
};

if (contactData) {
await editContact.mutateAsync({ contactId: contactData?._id, ...payload });
const handleSave = useFormSubmitWithDirtyCheck(
async (data: ContactFormData): Promise<void> => {
const { name, phones, emails, customFields, contactManager } = data;

const payload = {
name,
phones: phones.map(({ phoneNumber }) => phoneNumber),
emails: emails.map(({ address }) => address),
customFields,
contactManager,
};

if (contactData) {
await editContact.mutateAsync({ contactId: contactData?._id, ...payload });
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
return;
}

await createContact.mutateAsync(payload);
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
return;
}

await createContact.mutateAsync(payload);
await queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.contacts() });
};
},
{
isDirty,
},
);

const formId = useId();
const nameField = useId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
ToggleSwitch,
Box,
} from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarTitle,
ContextualbarHeader,
Expand All @@ -28,6 +27,7 @@ import { FormProvider, useForm, Controller } from 'react-hook-form';

import { CustomFieldsAdditionalForm } from '../additionalForms';
import { useRemoveCustomField } from './useRemoveCustomField';
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';

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

const handleDelete = useRemoveCustomField();

const methods = useForm<EditCustomFieldsFormData>({ mode: 'onBlur', values: getInitialValues(customFieldData) });
const methods = useForm<EditCustomFieldsFormData>({
values: getInitialValues(customFieldData),
});
const {
control,
handleSubmit,
Expand All @@ -75,25 +77,28 @@ const EditCustomFields = ({ customFieldData, onClose }: { customFieldData?: Seri

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

const handleSave = useEffectEvent(async ({ visibility, ...data }: EditCustomFieldsFormData) => {
try {
await saveCustomField({
customFieldId: customFieldData?._id as unknown as string,
customFieldData: {
visibility: visibility ? 'visible' : 'hidden',
...data,
},
});
const handleSave = useFormSubmitWithDirtyCheck(
async ({ visibility, ...data }: EditCustomFieldsFormData) => {
try {
await saveCustomField({
customFieldId: customFieldData?._id as unknown as string,
customFieldData: {
visibility: visibility ? 'visible' : 'hidden',
...data,
},
});

dispatchToastMessage({ type: 'success', message: t('Saved') });
queryClient.invalidateQueries({
queryKey: omnichannelQueryKeys.livechat.customFields(),
});
onClose();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
dispatchToastMessage({ type: 'success', message: t('Saved') });
queryClient.invalidateQueries({
queryKey: omnichannelQueryKeys.livechat.customFields(),
});
onClose();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
{ isDirty },
);

const scopeOptions: SelectOption[] = useMemo(
() => [
Expand Down Expand Up @@ -221,7 +226,7 @@ const EditCustomFields = ({ customFieldData, onClose }: { customFieldData?: Seri
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button form={formId} primary type='submit' disabled={!isDirty}>
<Button form={formId} primary type='submit'>
{t('Save')}
</Button>
</ButtonGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
FieldHint,
Option,
} from '@rocket.chat/fuselage';
import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { validateEmail } from '@rocket.chat/tools';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint, useRouter, usePermission } from '@rocket.chat/ui-contexts';
Expand All @@ -32,6 +32,7 @@ import type { EditDepartmentFormData } from './definitions';
import { formatAgentListPayload } from './utils/formatAgentListPayload';
import { formatEditDepartmentPayload } from './utils/formatEditDepartmentPayload';
import { getFormInitialValues } from './utils/getFormInititalValues';
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { useRoomsList } from '../../../hooks/useRoomsList';
import { EeTextInput, EeTextAreaInput, EeNumberInput, DepartmentBusinessHours } from '../additionalForms';
Expand Down Expand Up @@ -68,8 +69,8 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
register,
control,
handleSubmit,
formState: { errors, isValid, isDirty, isSubmitting },
} = useForm<EditDepartmentFormData>({ mode: 'onChange', defaultValues: initialValues });
formState: { errors, isDirty, isSubmitting },
} = useForm<EditDepartmentFormData>({ defaultValues: initialValues });

const [fallbackFilter, setFallbackFilter] = useState<string>('');
const [isUnitRequired, setUnitRequired] = useState(false);
Expand All @@ -82,42 +83,43 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
const updateDepartmentInfo = useEndpoint('PUT', '/v1/livechat/department/:_id', { _id: id || '' });
const saveDepartmentAgentsInfoOnEdit = useEndpoint('POST', `/v1/livechat/department/:_id/agents`, { _id: id || '' });

const handleSave = useEffectEvent(async (data: EditDepartmentFormData) => {
try {
const { agentList } = data;
const payload = formatEditDepartmentPayload(data);
const departmentUnit = data.unit ? { _id: data.unit } : undefined;
const handleSave = useFormSubmitWithDirtyCheck(
async (data: EditDepartmentFormData) => {
try {
const { agentList } = data;
const payload = formatEditDepartmentPayload(data);
const departmentUnit = data.unit ? { _id: data.unit } : undefined;

if (id) {
await updateDepartmentInfo({
department: payload,
agents: [],
departmentUnit,
});
if (id) {
await updateDepartmentInfo({
department: payload,
agents: [],
departmentUnit,
});

const { agentList: initialAgentList } = initialValues;
const agentListPayload = formatAgentListPayload(initialAgentList, agentList);
const { agentList: initialAgentList } = initialValues;
const agentListPayload = formatAgentListPayload(initialAgentList, agentList);

if (agentListPayload.upsert.length > 0 || agentListPayload.remove.length > 0) {
await saveDepartmentAgentsInfoOnEdit(agentListPayload);
if (agentListPayload.upsert.length > 0 || agentListPayload.remove.length > 0) {
await saveDepartmentAgentsInfoOnEdit(agentListPayload);
}
} else {
await createDepartment({
department: payload,
agents: agentList.map(({ agentId, count, order }) => ({ agentId, count, order })),
departmentUnit,
});
}
} else {
await createDepartment({
department: payload,
agents: agentList.map(({ agentId, count, order }) => ({ agentId, count, order })),
departmentUnit,
});
}

queryClient.invalidateQueries({ queryKey: ['/v1/livechat/department/:_id', id] });
dispatchToastMessage({ type: 'success', message: t('Saved') });
router.navigate('/omnichannel/departments');
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});

const isFormValid = isValid && isDirty;
queryClient.invalidateQueries({ queryKey: ['/v1/livechat/department/:_id', id] });
dispatchToastMessage({ type: 'success', message: t('Saved') });
router.navigate('/omnichannel/departments');
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
{ isDirty },
);

const formId = useId();
const enabledField = useId();
Expand All @@ -140,7 +142,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
<Page>
<PageHeader title={title} onClickBack={() => router.navigate('/omnichannel/departments')}>
<ButtonGroup>
<Button type='submit' form={formId} primary disabled={!isFormValid} loading={isSubmitting}>
<Button type='submit' form={formId} primary loading={isSubmitting}>
{t('Save')}
</Button>
</ButtonGroup>
Expand Down
Loading
Loading