diff --git a/apps/meteor/client/lib/getDirtyFields.ts b/apps/meteor/client/lib/getDirtyFields.ts new file mode 100644 index 000000000000..ede3197a23d5 --- /dev/null +++ b/apps/meteor/client/lib/getDirtyFields.ts @@ -0,0 +1,27 @@ +import type { FieldValues } from 'react-hook-form'; + +/** + * Helper function to get dirty fields from react-hook-form + * @param allFields all fields object + * @param dirtyFields dirty fields object + * @returns all dirty fields object + */ +export const getDirtyFields = ( + allFields: T, + dirtyFields: Partial>, +): Partial => { + const dirtyFieldsObjValue = Object.keys(dirtyFields).reduce((acc, currentField) => { + const isDirty = Array.isArray(dirtyFields[currentField]) + ? (dirtyFields[currentField] as boolean[]).some((value) => value === true) + : dirtyFields[currentField] === true; + if (isDirty) { + return { + ...acc, + [currentField]: allFields[currentField], + }; + } + return acc; + }, {} as Partial); + + return dirtyFieldsObjValue; +}; diff --git a/apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx b/apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx index ec747a913075..e3b18cabe759 100644 --- a/apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx +++ b/apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx @@ -1,9 +1,13 @@ import { ButtonGroup, Button, Box, Accordion } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { MutableRefObject, ReactElement } from 'react'; -import React, { useState, useCallback, useRef } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import Page from '../../../components/Page'; +import { getDirtyFields } from '../../../lib/getDirtyFields'; import PreferencesGlobalSection from './PreferencesGlobalSection'; import PreferencesHighlightsSection from './PreferencesHighlightsSection'; import PreferencesLocalizationSection from './PreferencesLocalizationSection'; @@ -12,142 +16,86 @@ import PreferencesMyDataSection from './PreferencesMyDataSection'; import PreferencesNotificationsSection from './PreferencesNotificationsSection'; import PreferencesSoundSection from './PreferencesSoundSection'; import PreferencesUserPresenceSection from './PreferencesUserPresenceSection'; - -type CurrentData = { - enableNewMessageTemplate: boolean; - language: string; - newRoomNotification: string; - newMessageNotification: string; - clockMode: number; - useEmojis: boolean; - convertAsciiEmoji: boolean; - saveMobileBandwidth: boolean; - collapseMediaByDefault: boolean; - autoImageLoad: boolean; - emailNotificationMode: string; - unreadAlert: boolean; - notificationsSoundVolume: number; - desktopNotifications: string; - pushNotifications: string; - enableAutoAway: boolean; - highlights: string; - hideUsernames: boolean; - hideRoles: boolean; - displayAvatars: boolean; - hideFlexTab: boolean; - sendOnEnter: string; - idleTimeLimit: number; - sidebarShowFavorites: boolean; - sidebarShowUnread: boolean; - sidebarSortby: string; - sidebarViewMode: string; - sidebarDisplayAvatar: boolean; - sidebarGroupByType: boolean; - muteFocusedConversations: boolean; - receiveLoginDetectionEmail: boolean; - dontAskAgainList: [action: string, label: string][]; - notifyCalendarEvents: boolean; - enableMobileRinging: boolean; -}; - -export type FormSectionProps = { - onChange: any; - commitRef: MutableRefObject void>>; -}; - -type FormatedData = { data: Omit, 'dontAskAgainList' | 'highlights'> }; +import type { AccountPreferencesData } from './useAccountPreferencesValues'; +import { useAccountPreferencesValues } from './useAccountPreferencesValues'; const AccountPreferencesPage = (): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - - const [hasAnyChange, setHasAnyChange] = useState(false); - - const saveData = useRef>({}); - const commitRef = useRef({}); - const dataDownloadEnabled = useSetting('UserData_EnableDownload'); - - const onChange = useCallback( - ({ - initialValue, - value, - key, - }: { - initialValue: I; - value: V; - key: K; - }) => { - const { current } = saveData; - if (current) { - if (JSON.stringify(initialValue) !== JSON.stringify(value)) { - current[key] = value; - } else { - delete current[key]; - } - } - - const anyChange = !!Object.values(current).length; - if (anyChange !== hasAnyChange) { - setHasAnyChange(anyChange); - } + const preferencesValues = useAccountPreferencesValues(); + + const methods = useForm({ defaultValues: preferencesValues }); + const { + handleSubmit, + reset, + watch, + formState: { isDirty, dirtyFields }, + } = methods; + + const currentData = watch(); + + const setPreferencesEndpoint = useEndpoint('POST', '/v1/users.setPreferences'); + const setPreferencesAction = useMutation({ + mutationFn: setPreferencesEndpoint, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Preferences_saved') }); }, - [hasAnyChange], - ); - - const saveFn = useEndpoint('POST', '/v1/users.setPreferences'); - - const handleSave = useCallback(async () => { - try { - const { current: data } = saveData; - if (data?.highlights || data?.highlights === '') { - Object.assign(data, { - highlights: data.highlights + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => reset(currentData), + }); + + const handleSaveData = async (formData: AccountPreferencesData) => { + const { highlights, dontAskAgainList, ...data } = getDirtyFields(formData, dirtyFields); + if (highlights || highlights === '') { + Object.assign(data, { + highlights: + typeof highlights === 'string' && + highlights .split(/,|\n/) .map((val) => val.trim()) .filter(Boolean), - }); - } + }); + } - if (data?.dontAskAgainList) { - const list = - Array.isArray(data.dontAskAgainList) && data.dontAskAgainList.length > 0 - ? data.dontAskAgainList.map(([action, label]) => ({ action, label })) - : []; - Object.assign(data, { dontAskAgainList: list }); - } + if (dontAskAgainList) { + const list = + Array.isArray(dontAskAgainList) && dontAskAgainList.length > 0 + ? dontAskAgainList.map(([action, label]) => ({ action, label })) + : []; + Object.assign(data, { dontAskAgainList: list }); + } - await saveFn({ data } as FormatedData); - saveData.current = {}; - setHasAnyChange(false); - Object.values(commitRef.current).forEach((fn) => (fn as () => void)()); + setPreferencesAction.mutateAsync({ data }); + }; - dispatchToastMessage({ type: 'success', message: t('Preferences_saved') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, [dispatchToastMessage, saveFn, t]); + const preferencesFormId = useUniqueId(); return ( - - - - - - - - - - {dataDownloadEnabled && } - - + + + + + + + + + + + {dataDownloadEnabled && } + + + - + - + diff --git a/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx index 7c2410deb9fb..4e616072d185 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx @@ -1,50 +1,31 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Accordion, Field, FieldGroup, MultiSelect } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; -import { useForm } from '../../../hooks/useForm'; -import type { FormSectionProps } from './AccountPreferencesPage'; - -const PreferencesGlobalSection = ({ onChange, commitRef, ...props }: FormSectionProps): ReactElement => { +const PreferencesGlobalSection = () => { const t = useTranslation(); - const userDontAskAgainList = useUserPreference<{ action: string; label: string }[]>('dontAskAgainList'); - - const options = useMemo( - () => (userDontAskAgainList || []).map(({ action, label }) => [action, label]) as SelectOption[], - [userDontAskAgainList], - ); - - const selectedOptions = options.map(([action]) => action); - - const { values, handlers, commit } = useForm( - { - dontAskAgainList: selectedOptions, - }, - onChange, - ); - - const { dontAskAgainList } = values as { - dontAskAgainList: string[]; - }; - - const { handleDontAskAgainList } = handlers; + const userDontAskAgainList = useUserPreference<{ action: string; label: string }[]>('dontAskAgainList') || []; + const options: SelectOption[] = userDontAskAgainList.map(({ action, label }) => [action, label]); - commitRef.current.global = commit; + const { control } = useFormContext(); + const dontAskAgainListId = useUniqueId(); return ( - + - {t('Dont_ask_me_again_list')} + {t('Dont_ask_me_again_list')} - 0 && dontAskAgainList) || undefined} - onChange={handleDontAskAgainList} - options={options} + ( + + )} /> diff --git a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx index 9179446df4d7..8c05a92bad47 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx @@ -1,31 +1,22 @@ import { Accordion, Field, FieldGroup, TextAreaInput } from '@rocket.chat/fuselage'; -import { useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { useFormContext } from 'react-hook-form'; -import { useForm } from '../../../hooks/useForm'; -import type { FormSectionProps } from './AccountPreferencesPage'; - -const PreferencesHighlightsSection = ({ onChange, commitRef, ...props }: FormSectionProps): ReactElement => { +const PreferencesHighlightsSection = () => { const t = useTranslation(); + const { register } = useFormContext(); - const userHighlights = useUserPreference('highlights')?.join(',\n') ?? ''; - - const { values, handlers, commit } = useForm({ highlights: userHighlights }, onChange); - - const { highlights } = values as { highlights: string }; - - const { handleHighlights } = handlers; - - commitRef.current.highlights = commit; + const highlightsId = useUniqueId(); return ( - + - {t('Highlights_List')} + {t('Highlights_List')} - + {t('Highlights_How_To')} diff --git a/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx index c48ffdc0e95a..2faaa9da89c2 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx @@ -1,45 +1,37 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Accordion, Field, Select, FieldGroup } from '@rocket.chat/fuselage'; -import { useUserPreference, useLanguages, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import React, { useEffect, useMemo } from 'react'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useLanguages, useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; +import { useFormContext, Controller } from 'react-hook-form'; -import { useForm } from '../../../hooks/useForm'; -import type { FormSectionProps } from './AccountPreferencesPage'; - -const PreferencesLocalizationSection = ({ - onChange, - commitRef, - ...props -}: { defaultExpanded?: boolean } & FormSectionProps): ReactElement => { +const PreferencesLocalizationSection = () => { const t = useTranslation(); - const userLanguage = useUserPreference('language') || ''; const languages = useLanguages(); + const { control } = useFormContext(); + const languageOptions = useMemo(() => { const mapOptions: SelectOption[] = languages.map(({ key, name }) => [key, name]); mapOptions.sort(([a], [b]) => a.localeCompare(b)); return mapOptions; }, [languages]); - const { values, handlers, commit } = useForm({ language: userLanguage }, onChange); - - const { language } = values as { language: string }; - const { handleLanguage } = handlers; - - useEffect(() => { - if (commitRef) { - commitRef.current.localization = commit; - } - }, [commit, commitRef]); + const languageId = useUniqueId(); return ( - + - {t('Language')} + {t('Language')} - + )} + /> diff --git a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx index 76aef16dfcd5..e5157d517967 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMessagesSection.tsx @@ -1,86 +1,14 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Accordion, Field, Select, FieldGroup, ToggleSwitch, Box } from '@rocket.chat/fuselage'; -import { useUserPreference, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; -import { useForm } from '../../../hooks/useForm'; -import type { FormSectionProps } from './AccountPreferencesPage'; - -type Values = { - unreadAlert: boolean; - showThreadsInMainChannel: boolean; - alsoSendThreadToChannel: 'default' | 'always' | 'never'; - useEmojis: boolean; - convertAsciiEmoji: boolean; - autoImageLoad: boolean; - saveMobileBandwidth: boolean; - collapseMediaByDefault: boolean; - hideUsernames: boolean; - hideRoles: boolean; - hideFlexTab: boolean; - displayAvatars: boolean; - clockMode: 0 | 1 | 2; - sendOnEnter: 'normal' | 'alternative' | 'desktop'; -}; - -const PreferencesMessagesSection = ({ onChange, commitRef, ...props }: FormSectionProps): ReactElement => { +const PreferencesMessagesSection = () => { const t = useTranslation(); - - const showRoles = useSetting('UI_DisplayRoles'); - - const settings = { - unreadAlert: useUserPreference('unreadAlert'), - showThreadsInMainChannel: useUserPreference('showThreadsInMainChannel'), - alsoSendThreadToChannel: useUserPreference('alsoSendThreadToChannel'), - useEmojis: useUserPreference('useEmojis'), - convertAsciiEmoji: useUserPreference('convertAsciiEmoji'), - autoImageLoad: useUserPreference('autoImageLoad'), - saveMobileBandwidth: useUserPreference('saveMobileBandwidth'), - collapseMediaByDefault: useUserPreference('collapseMediaByDefault'), - hideUsernames: useUserPreference('hideUsernames'), - hideRoles: useUserPreference('hideRoles'), - hideFlexTab: useUserPreference('hideFlexTab'), - clockMode: useUserPreference('clockMode') ?? 0, - sendOnEnter: useUserPreference('sendOnEnter'), - displayAvatars: useUserPreference('displayAvatars'), - }; - - const { values, handlers, commit } = useForm(settings, onChange); - - const { - unreadAlert, - showThreadsInMainChannel, - alsoSendThreadToChannel, - useEmojis, - convertAsciiEmoji, - autoImageLoad, - saveMobileBandwidth, - collapseMediaByDefault, - hideUsernames, - hideRoles, - hideFlexTab, - displayAvatars, - clockMode, - sendOnEnter, - } = values as Values; - - const { - handleUnreadAlert, - handleShowThreadsInMainChannel, - handleAlsoSendThreadToChannel, - handleUseEmojis, - handleConvertAsciiEmoji, - handleAutoImageLoad, - handleSaveMobileBandwidth, - handleCollapseMediaByDefault, - handleHideUsernames, - handleHideRoles, - handleHideFlexTab, - handleDisplayAvatars, - handleClockMode, - handleSendOnEnter, - } = handlers; + const displayRolesEnabled = useSetting('UI_DisplayRoles'); + const { control } = useFormContext(); const alsoSendThreadMessageToChannelOptions = useMemo( (): SelectOption[] => [ @@ -109,175 +37,235 @@ const PreferencesMessagesSection = ({ onChange, commitRef, ...props }: FormSecti [t], ); - commitRef.current.messages = commit; + const unreadAlertId = useUniqueId(); + const showThreadsInMainChannelId = useUniqueId(); + const alsoSendThreadToChannelId = useUniqueId(); + const clockModeId = useUniqueId(); + const useEmojisId = useUniqueId(); + const convertAsciiEmojiId = useUniqueId(); + const autoImageLoadId = useUniqueId(); + const saveMobileBandwidthId = useUniqueId(); + const collapseMediaByDefaultId = useUniqueId(); + const hideUsernamesId = useUniqueId(); + const hideRolesId = useUniqueId(); + const hideFlexTabId = useUniqueId(); + const displayAvatarsId = useUniqueId(); + const sendOnEnterId = useUniqueId(); return ( - + - {useMemo( - () => ( - - {t('Unread_Tray_Icon_Alert')} - - - - - ), - [handleUnreadAlert, t, unreadAlert], - )} - {useMemo( - () => ( - - - {t('Always_show_thread_replies_in_main_channel')} - - - - - {t('Accounts_Default_User_Preferences_showThreadsInMainChannel_Description')} - - ), - [handleShowThreadsInMainChannel, showThreadsInMainChannel, t], - )} - {useMemo( - () => ( - - {t('Also_send_thread_message_to_channel_behavior')} - + + + {t('Unread_Tray_Icon_Alert')} + + ( + + )} + /> + + + + + + {t('Always_show_thread_replies_in_main_channel')} + + ( + + )} + /> + + + + {t('Accounts_Default_User_Preferences_showThreadsInMainChannel_Description')} + + + + {t('Also_send_thread_message_to_channel_behavior')} + + ( - - - ), - [clockMode, handleClockMode, t, timeFormatOptions], - )} - {useMemo( - () => ( - - {t('Use_Emojis')} - - - - - ), - [handleUseEmojis, t, useEmojis], - )} - {useMemo( - () => ( - - {t('Convert_Ascii_Emojis')} - - - - - ), - [convertAsciiEmoji, handleConvertAsciiEmoji, t], - )} - {useMemo( - () => ( - - {t('Auto_Load_Images')} - - - - - ), - [autoImageLoad, handleAutoImageLoad, t], - )} - {useMemo( - () => ( - - {t('Save_Mobile_Bandwidth')} - - - - - ), - [handleSaveMobileBandwidth, saveMobileBandwidth, t], - )} - {useMemo( - () => ( - - {t('Collapse_Embedded_Media_By_Default')} - - - - - ), - [collapseMediaByDefault, handleCollapseMediaByDefault, t], - )} - {useMemo( - () => ( - - {t('Hide_usernames')} + )} + /> + + + {t('Accounts_Default_User_Preferences_alsoSendThreadToChannel_Description')} + + + + {t('Message_TimeFormat')} + + ( + + ( + + )} + /> - {t('Enter_Behaviour_Description')} - - ), - [handleSendOnEnter, sendOnEnter, sendOnEnterOptions, t], + + )} + + + {t('Hide_flextab')} + + ( + + )} + /> + + + + + + {t('Display_avatars')} + + ( + + )} + /> + + + + + {t('Enter_Behaviour')} + + ( + + ( + + ( + ( + - - - ), - [onChangeNewRoomNotification, newRoomNotification, soundsList, t], - )} - {useMemo( - () => ( - - {t('New_Message_Notification')} - - { + onChange(value); + customSound.play(value, { volume: notificationsSoundVolume / 100 }); + }} + options={soundsList} + /> + )} + /> + + + + {t('New_Message_Notification')} + + ( +