Skip to content

Commit

Permalink
refactor: AccountPreferences forms in favor of RHF (#30183)
Browse files Browse the repository at this point in the history
  • Loading branch information
dougfabris authored Aug 28, 2023
1 parent 2db32f0 commit fde7229
Show file tree
Hide file tree
Showing 11 changed files with 689 additions and 665 deletions.
27 changes: 27 additions & 0 deletions apps/meteor/client/lib/getDirtyFields.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends FieldValues>(
allFields: T,
dirtyFields: Partial<Record<keyof T, boolean | boolean[]>>,
): Partial<T> => {
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<T>);

return dirtyFieldsObjValue;
};
186 changes: 67 additions & 119 deletions apps/meteor/client/views/account/preferences/AccountPreferencesPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Record<string, () => void>>;
};

type FormatedData = { data: Omit<Partial<CurrentData>, '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<Partial<CurrentData>>({});
const commitRef = useRef({});

const dataDownloadEnabled = useSetting('UserData_EnableDownload');

const onChange = useCallback(
<K extends keyof CurrentData, I extends CurrentData[K], V extends CurrentData[K]>({
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 (
<Page>
<Page.Header title={t('Preferences')} />
<Page.ScrollableContentWithShadow>
<Box maxWidth='x600' w='full' alignSelf='center'>
<Accordion>
<PreferencesLocalizationSection commitRef={commitRef} onChange={onChange} defaultExpanded />
<PreferencesGlobalSection commitRef={commitRef} onChange={onChange} />
<PreferencesUserPresenceSection commitRef={commitRef} onChange={onChange} />
<PreferencesNotificationsSection commitRef={commitRef} onChange={onChange} />
<PreferencesMessagesSection commitRef={commitRef} onChange={onChange} />
<PreferencesHighlightsSection commitRef={commitRef} onChange={onChange} />
<PreferencesSoundSection commitRef={commitRef} onChange={onChange} />
{dataDownloadEnabled && <PreferencesMyDataSection onChange={onChange} />}
</Accordion>
</Box>
<FormProvider {...methods}>
<Box id={preferencesFormId} is='form' maxWidth='x600' w='full' alignSelf='center' onSubmit={handleSubmit(handleSaveData)}>
<Accordion>
<PreferencesLocalizationSection />
<PreferencesGlobalSection />
<PreferencesUserPresenceSection />
<PreferencesNotificationsSection />
<PreferencesMessagesSection />
<PreferencesHighlightsSection />
<PreferencesSoundSection />
{dataDownloadEnabled && <PreferencesMyDataSection />}
</Accordion>
</Box>
</FormProvider>
</Page.ScrollableContentWithShadow>
<Page.Footer isDirty={hasAnyChange}>
<Page.Footer isDirty={isDirty}>
<ButtonGroup>
<Button primary disabled={!hasAnyChange} onClick={handleSave}>
<Button onClick={() => reset(preferencesValues)}>{t('Cancel')}</Button>
<Button form={preferencesFormId} primary type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Accordion.Item title={t('Global')} {...props}>
<Accordion.Item title={t('Global')}>
<FieldGroup>
<Field>
<Field.Label>{t('Dont_ask_me_again_list')}</Field.Label>
<Field.Label htmlFor={dontAskAgainListId}>{t('Dont_ask_me_again_list')}</Field.Label>
<Field.Row>
<MultiSelect
placeholder={t('Nothing_found')}
value={(dontAskAgainList.length > 0 && dontAskAgainList) || undefined}
onChange={handleDontAskAgainList}
options={options}
<Controller
name='dontAskAgainList'
control={control}
render={({ field: { value, onChange } }) => (
<MultiSelect id={dontAskAgainListId} placeholder={t('Nothing_found')} value={value} onChange={onChange} options={options} />
)}
/>
</Field.Row>
</Field>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string[]>('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 (
<Accordion.Item title={t('Highlights')} {...props}>
<Accordion.Item title={t('Highlights')}>
<FieldGroup>
<Field>
<Field.Label>{t('Highlights_List')}</Field.Label>
<Field.Label htmlFor={highlightsId}>{t('Highlights_List')}</Field.Label>
<Field.Row>
<TextAreaInput rows={4} value={highlights} onChange={handleHighlights} />
<TextAreaInput id={highlightsId} {...register('highlights')} rows={4} />
</Field.Row>
<Field.Hint>{t('Highlights_How_To')}</Field.Hint>
</Field>
Expand Down
Loading

0 comments on commit fde7229

Please sign in to comment.