From b8b03bebb4093e186bcb0a97cf7bfb8d837b16f3 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 10:30:37 -0500 Subject: [PATCH 1/9] Add useSavedPreference hook --- .../contacts/flows/setup.page.tsx | 3 +- .../UpdateUserOptions.graphql | 2 +- .../DeleteFilterModal/DeleteFilterModal.tsx | 23 ++------ src/hooks/UserPreference.graphql | 6 ++ src/hooks/useSavedPreference.ts | 56 +++++++++++++++++++ src/lib/apollo/cache.ts | 11 ++++ 6 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 src/hooks/UserPreference.graphql create mode 100644 src/hooks/useSavedPreference.ts diff --git a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx index 3fa5baef8..b72313c92 100644 --- a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx +++ b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx @@ -79,7 +79,7 @@ const ContactFlowSetupPage: React.FC = () => { key: 'flows', value: stringified, }, - update: (cache, { data: updatedUserOption }) => { + update: (cache) => { const query = { query: GetUserOptionsDocument, }; @@ -95,7 +95,6 @@ const ContactFlowSetupPage: React.FC = () => { ...filteredOld, { __typename: 'Option', - id: updatedUserOption?.createOrUpdateUserOption?.option.id, key: 'flows', value: stringified, }, diff --git a/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql b/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql index f35d58f81..9f1b85680 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql +++ b/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql @@ -1,7 +1,7 @@ mutation UpdateUserOptions($key: String!, $value: String!) { createOrUpdateUserOption(input: { key: $key, value: $value }) { option { - id + key value } } diff --git a/src/components/Shared/Filters/DeleteFilterModal/DeleteFilterModal.tsx b/src/components/Shared/Filters/DeleteFilterModal/DeleteFilterModal.tsx index 61c20d19f..571979ab3 100644 --- a/src/components/Shared/Filters/DeleteFilterModal/DeleteFilterModal.tsx +++ b/src/components/Shared/Filters/DeleteFilterModal/DeleteFilterModal.tsx @@ -7,10 +7,6 @@ import { } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; -import { - GetUserOptionsDocument, - GetUserOptionsQuery, -} from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { CancelButton, SubmitButton, @@ -42,21 +38,10 @@ export const DeleteFilterModal: React.FC = ({ }, }, update: (cache) => { - const query = { - query: GetUserOptionsDocument, - }; - const dataFromCache = cache.readQuery(query); - if (dataFromCache) { - const filteredOutDeleted = dataFromCache.userOptions.filter( - (option) => option.id !== filter.id, - ); - - cache.writeQuery({ - ...query, - data: { - userOptions: filteredOutDeleted, - }, - }); + const cacheId = cache.identify(filter); + if (cacheId) { + cache.evict({ id: cacheId }); + cache.gc(); } enqueueSnackbar(t('Saved Filter Deleted!'), { diff --git a/src/hooks/UserPreference.graphql b/src/hooks/UserPreference.graphql new file mode 100644 index 000000000..fe754d11b --- /dev/null +++ b/src/hooks/UserPreference.graphql @@ -0,0 +1,6 @@ +query UserOption($key: String!) { + userOption(key: $key) { + key + value + } +} diff --git a/src/hooks/useSavedPreference.ts b/src/hooks/useSavedPreference.ts new file mode 100644 index 000000000..b1c0063d0 --- /dev/null +++ b/src/hooks/useSavedPreference.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; +import { useUserOptionQuery } from './UserPreference.generated'; + +/** + * This hook makes saving state as a user preference as easy as using `useState`. + * + * @param key The unique name of the user preference key. + * @param defaultValue The initial value while waiting for the user preferences to load. + */ +export const useSavedPreference = ( + key: string, + defaultValue: string = '', +): [string, (value: string) => void] => { + const { data } = useUserOptionQuery({ + variables: { + key, + }, + }); + const [updateUserOptions] = useUpdateUserOptionsMutation(); + + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + if (data?.userOption) { + setValue(data?.userOption.value ?? defaultValue); + } + }, [data]); + + const changeValue = useCallback( + (newValue: string) => { + if (newValue === value) { + return; + } + + updateUserOptions({ + variables: { + key, + value: newValue, + }, + optimisticResponse: { + createOrUpdateUserOption: { + option: { + __typename: 'Option' as const, + key, + value: newValue, + }, + }, + }, + }); + }, + [value, key], + ); + + return [value, changeValue]; +}; diff --git a/src/lib/apollo/cache.ts b/src/lib/apollo/cache.ts index b484c2763..3f86fa0a8 100644 --- a/src/lib/apollo/cache.ts +++ b/src/lib/apollo/cache.ts @@ -42,6 +42,8 @@ export const createCache = () => }, merge: true, }, + // For Options, use the key as the unique id to make it easier to find them in the cache + Option: { keyFields: ['key'] }, User: { merge: true }, Contact: { fields: { @@ -80,6 +82,15 @@ export const createCache = () => people: paginationFieldPolicy, tasks: paginationFieldPolicy, userNotifications: paginationFieldPolicy, + // When loading a user option, look it up from the cache by its key + userOption: { + read: (_, { args, toReference }) => + args && + toReference({ + __typename: 'Option', + key: args.key, + }), + }, // Ignore the input.pageNumber arg so that queries with different page numbers will // be merged together searchOrganizationsAccountLists: { From 3dff58561ba06e4ac1f8a95da10c4d7b6cb01117 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 11:16:49 -0500 Subject: [PATCH 2/9] Add tests --- src/hooks/useSavedPreference.test.tsx | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/hooks/useSavedPreference.test.tsx diff --git a/src/hooks/useSavedPreference.test.tsx b/src/hooks/useSavedPreference.test.tsx new file mode 100644 index 000000000..6ee5aee5e --- /dev/null +++ b/src/hooks/useSavedPreference.test.tsx @@ -0,0 +1,133 @@ +import { ReactElement } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { UpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; +import { createCache } from 'src/lib/apollo/cache'; +import { + UserOptionDocument, + UserOptionQuery, +} from './UserPreference.generated'; +import { useSavedPreference } from './useSavedPreference'; + +const key = 'test_option'; + +const mutationSpy = jest.fn(); + +interface WrapperProps { + cached?: boolean; +} + +/** + * `renderHook` doesn't let you pass props to the wrapper. To workaround that, this function accepts + * props to customize its behavior and creates a Wrapper component with access to those props. + * The component returned from this function can be passed as the `wrapper` option to `renderHook`. + **/ +const makeWrapper = (props: WrapperProps = {}) => { + const { cached = true } = props; + + const cache = createCache(); + if (cached) { + // Prime the cache with an option + cache.writeQuery({ + query: UserOptionDocument, + data: { + userOption: { + __typename: 'Option' as const, + key, + value: 'cached', + }, + }, + }); + } + + const Wrapper = ({ children }: { children: ReactElement }) => ( + + mocks={{ + UserOption: { + userOption: { + key, + value: 'initial', + }, + }, + UpdateUserOptions: { + createOrUpdateUserOption: { + option: { + key, + value: 'server', + }, + }, + }, + }} + defaultOptions={{ + watchQuery: { + // Execute the query also even though the result is cached + fetchPolicy: cached ? 'cache-and-network' : undefined, + }, + }} + cache={cache} + onCall={mutationSpy} + > + {children} + + ); + return Wrapper; +}; + +describe('useSavedPreference', () => { + it('returns an empty string initially if there is no default value and the cache is empty', () => { + const { result } = renderHook(() => useSavedPreference(key), { + wrapper: makeWrapper({ cached: false }), + }); + + expect(result.current[0]).toBe(''); + }); + + it('returns the default value initially if the cache is empty', () => { + const { result } = renderHook(() => useSavedPreference(key, 'default'), { + wrapper: makeWrapper({ cached: false }), + }); + + expect(result.current[0]).toBe('default'); + }); + + it('returns the cached value until the option refreshes', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useSavedPreference(key), + { + wrapper: makeWrapper(), + }, + ); + + expect(result.current[0]).toBe('cached'); + + await waitForNextUpdate(); + + expect(mutationSpy).toHaveGraphqlOperation('UserOption', { key }); + expect(result.current[0]).toBe('initial'); + }); + + it('setting the value updates the value optimistically then updates to the response value', async () => { + const { result, waitForNextUpdate, rerender } = renderHook( + () => useSavedPreference(key), + { + wrapper: makeWrapper({ cached: false }), + }, + ); + + const newValue = 'changed'; + result.current[1](newValue); + rerender(); + expect(result.current[0]).toBe(newValue); + + await waitForNextUpdate(); + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + key, + value: newValue, + }); + + expect(result.current[0]).toBe('server'); + }); +}); From 0a52ce96f746072320baa8b38502237e2e495d0f Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 12:35:10 -0500 Subject: [PATCH 3/9] Add JSON serialization support --- src/hooks/useSavedPreference.test.tsx | 58 ++++++++++++++++++--------- src/hooks/useSavedPreference.ts | 45 +++++++++++++++------ 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/hooks/useSavedPreference.test.tsx b/src/hooks/useSavedPreference.test.tsx index 6ee5aee5e..dcefb8776 100644 --- a/src/hooks/useSavedPreference.test.tsx +++ b/src/hooks/useSavedPreference.test.tsx @@ -10,11 +10,14 @@ import { import { useSavedPreference } from './useSavedPreference'; const key = 'test_option'; +const defaultValue = 'default'; +const newValue = 'changed'; const mutationSpy = jest.fn(); interface WrapperProps { cached?: boolean; + json?: boolean; } /** @@ -23,7 +26,7 @@ interface WrapperProps { * The component returned from this function can be passed as the `wrapper` option to `renderHook`. **/ const makeWrapper = (props: WrapperProps = {}) => { - const { cached = true } = props; + const { cached = true, json = false } = props; const cache = createCache(); if (cached) { @@ -34,7 +37,7 @@ const makeWrapper = (props: WrapperProps = {}) => { userOption: { __typename: 'Option' as const, key, - value: 'cached', + value: json ? '["cached"]' : 'cached', }, }, }); @@ -49,14 +52,14 @@ const makeWrapper = (props: WrapperProps = {}) => { UserOption: { userOption: { key, - value: 'initial', + value: json ? '["initial"]' : 'initial', }, }, UpdateUserOptions: { createOrUpdateUserOption: { option: { key, - value: 'server', + value: json ? '["server"]' : 'server', }, }, }, @@ -77,25 +80,20 @@ const makeWrapper = (props: WrapperProps = {}) => { }; describe('useSavedPreference', () => { - it('returns an empty string initially if there is no default value and the cache is empty', () => { - const { result } = renderHook(() => useSavedPreference(key), { - wrapper: makeWrapper({ cached: false }), - }); - - expect(result.current[0]).toBe(''); - }); - it('returns the default value initially if the cache is empty', () => { - const { result } = renderHook(() => useSavedPreference(key, 'default'), { - wrapper: makeWrapper({ cached: false }), - }); + const { result } = renderHook( + () => useSavedPreference({ key, defaultValue }), + { + wrapper: makeWrapper({ cached: false }), + }, + ); - expect(result.current[0]).toBe('default'); + expect(result.current[0]).toBe(defaultValue); }); it('returns the cached value until the option refreshes', async () => { const { result, waitForNextUpdate } = renderHook( - () => useSavedPreference(key), + () => useSavedPreference({ key, defaultValue }), { wrapper: makeWrapper(), }, @@ -111,13 +109,12 @@ describe('useSavedPreference', () => { it('setting the value updates the value optimistically then updates to the response value', async () => { const { result, waitForNextUpdate, rerender } = renderHook( - () => useSavedPreference(key), + () => useSavedPreference({ key, defaultValue }), { wrapper: makeWrapper({ cached: false }), }, ); - const newValue = 'changed'; result.current[1](newValue); rerender(); expect(result.current[0]).toBe(newValue); @@ -130,4 +127,27 @@ describe('useSavedPreference', () => { expect(result.current[0]).toBe('server'); }); + + it('serializes and deserializes the value as JSON', async () => { + const { result, waitForNextUpdate, rerender } = renderHook( + () => useSavedPreference({ key, defaultValue: [defaultValue] }), + { + wrapper: makeWrapper({ cached: false, json: true }), + }, + ); + + expect(result.current[0]).toEqual([defaultValue]); + + await waitForNextUpdate(); + + result.current[1]([newValue]); + rerender(); + expect(result.current[0]).toEqual([newValue]); + + await waitForNextUpdate(); + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + key, + value: '["changed"]', + }); + }); }); diff --git a/src/hooks/useSavedPreference.ts b/src/hooks/useSavedPreference.ts index b1c0063d0..e1fdb1688 100644 --- a/src/hooks/useSavedPreference.ts +++ b/src/hooks/useSavedPreference.ts @@ -2,16 +2,23 @@ import { useCallback, useEffect, useState } from 'react'; import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { useUserOptionQuery } from './UserPreference.generated'; +interface UseSavedPreferenceOptions { + /** The unique name of the user preference key. */ + key: string; + + /** The default value when the preference is missing or hasn't loaded yet. */ + defaultValue: T; +} + /** - * This hook makes saving state as a user preference as easy as using `useState`. - * - * @param key The unique name of the user preference key. - * @param defaultValue The initial value while waiting for the user preferences to load. + * This hook makes saving state as a user preference as easy as using `useState`. If `defaultValue` + * is not a string, the value will be transparently serialized and deserialized as JSON because the + * server only supports string option values. */ -export const useSavedPreference = ( - key: string, - defaultValue: string = '', -): [string, (value: string) => void] => { +export const useSavedPreference = ({ + key, + defaultValue, +}: UseSavedPreferenceOptions): [T, (value: T) => void] => { const { data } = useUserOptionQuery({ variables: { key, @@ -22,28 +29,40 @@ export const useSavedPreference = ( const [value, setValue] = useState(defaultValue); useEffect(() => { - if (data?.userOption) { - setValue(data?.userOption.value ?? defaultValue); + if (!data?.userOption) { + return; + } + + if (typeof defaultValue === 'string') { + setValue((data.userOption.value ?? defaultValue) as T); + } else { + setValue( + typeof data.userOption.value === 'string' + ? JSON.parse(data.userOption.value) + : defaultValue, + ); } }, [data]); const changeValue = useCallback( - (newValue: string) => { + (newValue: T) => { if (newValue === value) { return; } + const serializedValue = + typeof newValue === 'string' ? newValue : JSON.stringify(newValue); updateUserOptions({ variables: { key, - value: newValue, + value: serializedValue, }, optimisticResponse: { createOrUpdateUserOption: { option: { __typename: 'Option' as const, key, - value: newValue, + value: serializedValue, }, }, }, From 662098604ffc74d8ea29476f794f9bc35bf2f7a6 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 13:55:19 -0500 Subject: [PATCH 4/9] Use userOption query in SetupProvider --- src/components/Setup/Setup.graphql | 2 +- src/components/Setup/SetupProvider.test.tsx | 10 ++++------ src/components/Setup/SetupProvider.tsx | 4 +--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/Setup/Setup.graphql b/src/components/Setup/Setup.graphql index 451e36964..50fefaea5 100644 --- a/src/components/Setup/Setup.graphql +++ b/src/components/Setup/Setup.graphql @@ -4,7 +4,7 @@ query SetupStage { defaultAccountList setup } - userOptions { + userOption(key: "setup_position") { id key value diff --git a/src/components/Setup/SetupProvider.test.tsx b/src/components/Setup/SetupProvider.test.tsx index 0eaddc627..60ca5820b 100644 --- a/src/components/Setup/SetupProvider.test.tsx +++ b/src/components/Setup/SetupProvider.test.tsx @@ -37,12 +37,10 @@ const TestComponent: React.FC = ({ user: { setup, }, - userOptions: [ - { - key: 'setup_position', - value: setupPosition, - }, - ], + userOption: { + key: 'setup_position', + value: setupPosition, + }, }, }} > diff --git a/src/components/Setup/SetupProvider.tsx b/src/components/Setup/SetupProvider.tsx index ec99c891e..cbdb50942 100644 --- a/src/components/Setup/SetupProvider.tsx +++ b/src/components/Setup/SetupProvider.tsx @@ -72,9 +72,7 @@ export const SetupProvider: React.FC = ({ children }) => { return undefined; } - const setupPosition = data.userOptions.find( - (option) => option.key === 'setup_position', - )?.value; + const setupPosition = data.userOption?.value; // The user is on the setup tour if the setup position matches their current page return ( From b1493b338aaa88f2e7ebdd8ef7c0980d6e529725 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 13:17:58 -0500 Subject: [PATCH 5/9] Use hook on tour pages --- .../settings/integrations/index.page.test.tsx | 76 ++++++++----------- .../settings/integrations/index.page.tsx | 21 ++--- .../settings/notifications.page.test.tsx | 38 ++++------ .../settings/notifications.page.tsx | 21 ++--- .../settings/preferences.page.test.tsx | 52 +++---------- .../settings/preferences.page.tsx | 33 ++------ .../setup/finish.page.test.tsx | 14 ++++ .../[accountListId]/setup/finish.page.tsx | 19 ++--- src/components/Setup/useNextSetupPage.ts | 17 ++--- 9 files changed, 104 insertions(+), 187 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx index 613fbd7f5..5074634c2 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx @@ -4,12 +4,10 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { GetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { MailchimpAccountQuery } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccount.generated'; import { GetUsersOrganizationsAccountsQuery } from 'src/components/Settings/integrations/Organization/Organizations.generated'; import { PrayerlettersAccountQuery } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.generated'; -import { SetupStageQuery } from 'src/components/Setup/Setup.generated'; -import { SetupProvider } from 'src/components/Setup/SetupProvider'; +import { TestSetupProvider } from 'src/components/Setup/SetupProvider'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from 'src/theme'; import Integrations from './index.page'; @@ -40,48 +38,36 @@ jest.mock('notistack', () => ({ })); interface MocksProvidersProps { children: JSX.Element; - setup?: string; + setup?: boolean; } -const MocksProviders: React.FC = ({ children, setup }) => ( +const MocksProviders: React.FC = ({ + children, + setup = false, +}) => ( - - mocks={{ - GetUsersOrganizationsAccounts: { - userOrganizationAccounts: [ - { - organization: {}, - }, - { - organization: {}, - }, - ], - }, - MailchimpAccount: { mailchimpAccount: [] }, - PrayerlettersAccount: { prayerlettersAccount: [] }, - SetupStage: { - user: { - setup: null, + + + mocks={{ + GetUsersOrganizationsAccounts: { + userOrganizationAccounts: [ + { organization: {} }, + { organization: {} }, + ], }, - userOptions: [ - { - key: 'setup_position', - value: setup || '', - }, - ], - }, - }} - onCall={mutationSpy} - > - {children} - + MailchimpAccount: { mailchimpAccount: [] }, + PrayerlettersAccount: { prayerlettersAccount: [] }, + }} + onCall={mutationSpy} + > + {children} + + ); @@ -132,7 +118,7 @@ describe('Connect Services page', () => { it('should show setup banner and open google', async () => { const { findByText, getByRole, getByText } = render( - + , ); @@ -141,10 +127,10 @@ describe('Connect Services page', () => { ).toBeInTheDocument(); //Accordions should be disabled - await waitFor(() => { - const label = getByText('Organization'); - expect(() => userEvent.click(label)).toThrow(); - }); + expect(getByRole('button', { name: 'Organization' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); const nextButton = getByRole('button', { name: 'Next Step' }); diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index 5ad22ca8e..37a3df80c 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -1,10 +1,8 @@ import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import { Button } from '@mui/material'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { ChalklineAccordion } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordion'; import { GoogleAccordion } from 'src/components/Settings/integrations/Google/GoogleAccordion'; import { TheKeyAccordion } from 'src/components/Settings/integrations/Key/TheKeyAccordion'; @@ -17,6 +15,7 @@ import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/Accordion import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; import { SettingsWrapper } from '../Wrapper'; const Integrations: React.FC = () => { @@ -27,13 +26,15 @@ const Integrations: React.FC = () => { ); const accountListId = useAccountListId() || ''; const { appName } = useGetAppSettings(); - const { enqueueSnackbar } = useSnackbar(); const { onSetupTour } = useSetupContext(); const [setup, setSetup] = useState(0); const setupAccordions = ['google', 'mailchimp', 'prayerletters.com']; - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [_, setSetupPosition] = useSavedPreference({ + key: 'setup_position', + defaultValue: '', + }); const handleSetupChange = async () => { if (!onSetupTour) { @@ -42,17 +43,7 @@ const Integrations: React.FC = () => { const nextNav = setup + 1; if (setupAccordions.length === nextNav) { - await updateUserOptions({ - variables: { - key: 'setup_position', - value: 'finish', - }, - onError: () => { - enqueueSnackbar(t('Saving setup phase failed.'), { - variant: 'error', - }); - }, - }); + setSetupPosition('finish'); push(`/accountLists/${accountListId}/setup/finish`); } else { setSetup(nextNav); diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx index a8e8bf8a0..1aa04ab9f 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx @@ -4,14 +4,12 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { GetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { NotificationTypesQuery, NotificationsPreferencesQuery, } from 'src/components/Settings/notifications/Notifications.generated'; import { notificationSettingsMocks } from 'src/components/Settings/notifications/notificationSettingsMocks'; -import { SetupStageQuery } from 'src/components/Setup/Setup.generated'; -import { SetupProvider } from 'src/components/Setup/SetupProvider'; +import { TestSetupProvider } from 'src/components/Setup/SetupProvider'; import theme from 'src/theme'; import Notifications from './notifications.page'; @@ -41,35 +39,25 @@ jest.mock('notistack', () => ({ interface MocksProvidersProps { children: JSX.Element; - setup?: string; + setup?: boolean; } -const MocksProviders: React.FC = ({ children, setup }) => ( +const MocksProviders: React.FC = ({ + children, + setup = false, +}) => ( mocks={{ ...notificationSettingsMocks, - SetupStage: { - user: { - setup: null, - }, - userOptions: [ - { - key: 'setup_position', - value: setup || '', - }, - ], - }, }} onCall={mutationSpy} > - {children} + {children} @@ -107,7 +95,7 @@ describe('Notifications page', () => { it('should show setup banner move to the next part', async () => { const { findByText, getByRole } = render( - + , ); @@ -133,8 +121,8 @@ describe('Notifications page', () => { }); it('moves to the next section with Save Button', async () => { - const { getAllByRole, findByText } = render( - + const { findAllByRole, findByText } = render( + , ); @@ -143,7 +131,11 @@ describe('Notifications page', () => { await findByText('Setup your notifications here'), ).toBeInTheDocument(); - const saveButton = getAllByRole('button', { name: 'Save Changes' })[0]; + const saveButton = ( + await findAllByRole('button', { + name: 'Save Changes', + }) + )[0]; // Move to Integrations userEvent.click(saveButton); diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.tsx index 385fc0420..9f1f3b37e 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.tsx @@ -1,16 +1,15 @@ import { useRouter } from 'next/router'; import React from 'react'; import { Box, Button } from '@mui/material'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { NotificationsTable } from 'src/components/Settings/notifications/NotificationsTable'; import { SetupBanner } from 'src/components/Settings/preferences/SetupBanner'; import { useSetupContext } from 'src/components/Setup/SetupProvider'; import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; import { SettingsWrapper } from './Wrapper'; const Notifications: React.FC = () => { @@ -18,27 +17,19 @@ const Notifications: React.FC = () => { const { appName } = useGetAppSettings(); const accountListId = useAccountListId() || ''; const { push } = useRouter(); - const { enqueueSnackbar } = useSnackbar(); const { onSetupTour } = useSetupContext(); - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [_, setSetupPosition] = useSavedPreference({ + key: 'setup_position', + defaultValue: '', + }); const handleSetupChange = async () => { if (!onSetupTour) { return; } - await updateUserOptions({ - variables: { - key: 'setup_position', - value: 'preferences.integrations', - }, - onError: () => { - enqueueSnackbar(t('Saving setup phase failed.'), { - variant: 'error', - }); - }, - }); + setSetupPosition('preferences.integrations'); push(`/accountLists/${accountListId}/settings/integrations`); }; diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx index a772d3e84..8497d6b17 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx @@ -6,7 +6,6 @@ import userEvent from '@testing-library/user-event'; import { session } from '__tests__/fixtures/session'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { GetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { MailchimpAccountQuery } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccount.generated'; import { GetUsersOrganizationsAccountsQuery } from 'src/components/Settings/integrations/Organization/Organizations.generated'; import { PrayerlettersAccountQuery } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.generated'; @@ -16,8 +15,7 @@ import { } from 'src/components/Settings/preferences/GetAccountPreferences.generated'; import { GetPersonalPreferencesQuery } from 'src/components/Settings/preferences/GetPersonalPreferences.generated'; import { GetProfileInfoQuery } from 'src/components/Settings/preferences/GetProfileInfo.generated'; -import { SetupStageQuery } from 'src/components/Setup/Setup.generated'; -import { SetupProvider } from 'src/components/Setup/SetupProvider'; +import { TestSetupProvider } from 'src/components/Setup/SetupProvider'; import theme from 'src/theme'; import Preferences from './preferences.page'; @@ -49,7 +47,7 @@ interface MocksProvidersProps { children: JSX.Element; canUserExportData: boolean; singleOrg?: boolean; - setup?: string; + setup?: boolean; router?: Partial | undefined; } @@ -57,7 +55,7 @@ const MocksProviders: React.FC = ({ children, canUserExportData, singleOrg, - setup, + setup = false, router = defaultRouter, }) => ( @@ -66,12 +64,10 @@ const MocksProviders: React.FC = ({ GetUsersOrganizationsAccounts: GetUsersOrganizationsAccountsQuery; MailchimpAccount: MailchimpAccountQuery; PrayerlettersAccount: PrayerlettersAccountQuery; - GetUserOptions: GetUserOptionsQuery; GetAccountPreferences: GetAccountPreferencesQuery; GetPersonalPreferences: GetPersonalPreferencesQuery; GetProfileInfo: GetProfileInfoQuery; CanUserExportData: CanUserExportDataQuery; - SetupStage: SetupStageQuery; }> mocks={{ GetAccountPreferences: { @@ -127,19 +123,8 @@ const MocksProviders: React.FC = ({ }, GetUsersOrganizationsAccounts: { userOrganizationAccounts: singleOrg - ? [ - { - organization: {}, - }, - ] - : [ - { - organization: {}, - }, - { - organization: {}, - }, - ], + ? [{ organization: {} }] + : [{ organization: {} }, { organization: {} }], }, CanUserExportData: { canUserExportData: { @@ -147,21 +132,10 @@ const MocksProviders: React.FC = ({ exportedAt: null, }, }, - SetupStage: { - user: { - setup: null, - }, - userOptions: [ - { - key: 'setup_position', - value: setup || '', - }, - ], - }, }} onCall={mutationSpy} > - {children} + {children} @@ -277,20 +251,18 @@ describe('Preferences page', () => { }); it('should show setup banner and open locale', async () => { - const { findByText, getByRole, queryByText, getByText } = render( - + const { findByRole, findByText, getByRole, queryByText } = render( + , ); //Accordions should be disabled + expect(await findByRole('button', { name: 'Language' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); await waitFor(() => { - const label = getByText('Language'); - expect(() => userEvent.click(label)).toThrow(); expect( queryByText('The language determines your default language for .'), ).not.toBeInTheDocument(); diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.tsx index 4e9a6c663..c2ef641b8 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.tsx @@ -2,10 +2,8 @@ import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import { Box, Button, Skeleton } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { useGetUsersOrganizationsAccountsQuery } from 'src/components/Settings/integrations/Organization/Organizations.generated'; import { useCanUserExportDataQuery, @@ -33,6 +31,7 @@ import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useGetTimezones } from 'src/hooks/useGetTimezones'; import { useRequiredSession } from 'src/hooks/useRequiredSession'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; import { getCountries } from 'src/lib/data/countries'; import { SettingsWrapper } from './Wrapper'; @@ -45,7 +44,6 @@ const Preferences: React.FC = () => { const { t } = useTranslation(); const accountListId = useAccountListId() || ''; const { push, query } = useRouter(); - const { enqueueSnackbar } = useSnackbar(); const { onSetupTour } = useSetupContext(); const session = useRequiredSession(); @@ -57,7 +55,10 @@ const Preferences: React.FC = () => { const countries = getCountries(); const timeZones = useGetTimezones(); - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [_, setSetupPosition] = useSavedPreference({ + key: 'setup_position', + defaultValue: '', + }); useEffect(() => { const redirectToDownloadExportedData = (exportDataExportId: string) => { @@ -109,17 +110,7 @@ const Preferences: React.FC = () => { }; const resetWelcomeTour = async () => { - await updateUserOptions({ - variables: { - key: 'setup_position', - value: 'start', - }, - onError: () => { - enqueueSnackbar(t('Resetting the welcome tour failed.'), { - variant: 'error', - }); - }, - }); + setSetupPosition('start'); push('/setup/start'); }; @@ -130,17 +121,7 @@ const Preferences: React.FC = () => { const nextNav = setup + 1; if (setupAccordions.length === nextNav) { - await updateUserOptions({ - variables: { - key: 'setup_position', - value: 'preferences.notifications', - }, - onError: () => { - enqueueSnackbar(t('Saving setup phase failed.'), { - variant: 'error', - }); - }, - }); + setSetupPosition('preferences.notifications'); push(`/accountLists/${accountListId}/settings/notifications`); } else { setSetup(nextNav); diff --git a/pages/accountLists/[accountListId]/setup/finish.page.test.tsx b/pages/accountLists/[accountListId]/setup/finish.page.test.tsx index ca36862b3..d921f35b1 100644 --- a/pages/accountLists/[accountListId]/setup/finish.page.test.tsx +++ b/pages/accountLists/[accountListId]/setup/finish.page.test.tsx @@ -40,6 +40,13 @@ describe('Finish account page', () => { it('yes button redirects to tools', async () => { const { getByRole } = render(); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + key: 'setup_position', + value: 'finish', + }), + ); + userEvent.click(getByRole('button', { name: /Yes/ })); await waitFor(() => @@ -56,6 +63,13 @@ describe('Finish account page', () => { it('no button redirects to the dashboard', async () => { const { getByRole } = render(); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + key: 'setup_position', + value: 'finish', + }), + ); + userEvent.click(getByRole('button', { name: /Nope/ })); await waitFor(() => diff --git a/pages/accountLists/[accountListId]/setup/finish.page.tsx b/pages/accountLists/[accountListId]/setup/finish.page.tsx index 81e6ed384..c86a5bd79 100644 --- a/pages/accountLists/[accountListId]/setup/finish.page.tsx +++ b/pages/accountLists/[accountListId]/setup/finish.page.tsx @@ -4,11 +4,11 @@ import React, { useEffect } from 'react'; import { Button } from '@mui/material'; import { Trans, useTranslation } from 'react-i18next'; import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { SetupPage } from 'src/components/Setup/SetupPage'; import { LargeButton } from 'src/components/Setup/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; // This is the last page of the tour, and it lets users choose to go to the // tools page. It is always shown. @@ -17,27 +17,22 @@ const FinishPage: React.FC = () => { const { appName } = useGetAppSettings(); const accountListId = useAccountListId(); const { push } = useRouter(); - const [updateUserOptions] = useUpdateUserOptionsMutation(); - - const setSetupPosition = (setupPosition: string) => - updateUserOptions({ - variables: { - key: 'setup_position', - value: setupPosition, - }, - }); + const [_, setSetupPosition] = useSavedPreference({ + key: 'setup_position', + defaultValue: '', + }); useEffect(() => { setSetupPosition('finish'); }, []); const handleNext = async () => { - await setSetupPosition(''); + setSetupPosition(''); push(`/accountLists/${accountListId}/tools?setup=1`); }; const handleFinish = async () => { - await setSetupPosition(''); + setSetupPosition(''); push(`/accountLists/${accountListId}`); }; diff --git a/src/components/Setup/useNextSetupPage.ts b/src/components/Setup/useNextSetupPage.ts index 9829197ab..0c904edb5 100644 --- a/src/components/Setup/useNextSetupPage.ts +++ b/src/components/Setup/useNextSetupPage.ts @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import { useCallback } from 'react'; import { UserSetupStageEnum } from 'src/graphql/types.generated'; -import { useUpdateUserOptionsMutation } from '../Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; import { useSetupStageLazyQuery } from './Setup.generated'; interface UseNextSetupPageResult { @@ -12,15 +12,10 @@ interface UseNextSetupPageResult { export const useNextSetupPage = (): UseNextSetupPageResult => { const { push } = useRouter(); const [getSetupStage] = useSetupStageLazyQuery(); - const [updateUserOptions] = useUpdateUserOptionsMutation(); - - const saveSetupPosition = (setupPosition: string) => - updateUserOptions({ - variables: { - key: 'setup_position', - value: setupPosition, - }, - }); + const [_, setSetupPosition] = useSavedPreference({ + key: 'setup_position', + defaultValue: '', + }); const next = useCallback(async () => { const { data } = await getSetupStage(); @@ -35,7 +30,7 @@ export const useNextSetupPage = (): UseNextSetupPageResult => { return; case null: - await saveSetupPosition('preferences.personal'); + setSetupPosition('preferences.personal'); push( `/accountLists/${data.user.defaultAccountList}/settings/preferences`, ); From 6d84662258adbb47561c181fc224a10371c17c04 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 15:04:23 -0500 Subject: [PATCH 6/9] Add loading field to useSavedPreference response --- src/hooks/useSavedPreference.test.tsx | 2 ++ src/hooks/useSavedPreference.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSavedPreference.test.tsx b/src/hooks/useSavedPreference.test.tsx index dcefb8776..f4bf4dad7 100644 --- a/src/hooks/useSavedPreference.test.tsx +++ b/src/hooks/useSavedPreference.test.tsx @@ -89,6 +89,7 @@ describe('useSavedPreference', () => { ); expect(result.current[0]).toBe(defaultValue); + expect(result.current[2]).toEqual({ loading: true }); }); it('returns the cached value until the option refreshes', async () => { @@ -100,6 +101,7 @@ describe('useSavedPreference', () => { ); expect(result.current[0]).toBe('cached'); + expect(result.current[2]).toEqual({ loading: false }); await waitForNextUpdate(); diff --git a/src/hooks/useSavedPreference.ts b/src/hooks/useSavedPreference.ts index e1fdb1688..3e5152596 100644 --- a/src/hooks/useSavedPreference.ts +++ b/src/hooks/useSavedPreference.ts @@ -18,8 +18,12 @@ interface UseSavedPreferenceOptions { export const useSavedPreference = ({ key, defaultValue, -}: UseSavedPreferenceOptions): [T, (value: T) => void] => { - const { data } = useUserOptionQuery({ +}: UseSavedPreferenceOptions): [ + T, + (value: T) => void, + { loading: boolean }, +] => { + const { data, loading, error } = useUserOptionQuery({ variables: { key, }, @@ -71,5 +75,5 @@ export const useSavedPreference = ({ [value, key], ); - return [value, changeValue]; + return [value, changeValue, { loading: loading && !data && !error }]; }; From 71ea012d7828aba185934dfa3ab53bba1a7dac49 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 15:04:53 -0500 Subject: [PATCH 7/9] Use hook in flows --- .../contacts/flows/setup.page.tsx | 101 ++++++------------ .../settings/integrations/index.page.test.tsx | 2 +- .../settings/notifications.page.test.tsx | 4 +- .../settings/preferences.page.test.tsx | 2 +- .../setup/finish.page.test.tsx | 10 +- ...actsFlow.test.tsx => ContactFlow.test.tsx} | 24 ++--- .../Contacts/ContactFlow/ContactFlow.tsx | 2 +- .../DropZone/ContactFlowSetupDropZone.tsx | 2 +- .../Header/ContactFlowSetupHeader.tsx | 4 +- .../ResetToDefault/ResetToDefaultModal.tsx | 6 +- .../UpdateUserOptions.graphql | 8 -- .../ContactFlow/useFlowOptions.test.tsx | 52 ++++----- .../Contacts/ContactFlow/useFlowOptions.ts | 47 ++++---- .../ContactsContext/ContactsContext.tsx | 6 +- .../Setup/useNextSetupPage.test.tsx | 2 +- .../Appeal/AppealsContext/AppealsContext.tsx | 6 +- src/hooks/SavedPreference.graphql | 15 +++ src/hooks/UserPreference.graphql | 6 -- src/hooks/useSavedPreference.test.tsx | 12 +-- src/hooks/useSavedPreference.ts | 10 +- 20 files changed, 142 insertions(+), 179 deletions(-) rename src/components/Contacts/ContactFlow/{ContactsFlow.test.tsx => ContactFlow.test.tsx} (86%) delete mode 100644 src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql create mode 100644 src/hooks/SavedPreference.graphql delete mode 100644 src/hooks/UserPreference.graphql diff --git a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx index b72313c92..3f42f53c2 100644 --- a/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx +++ b/pages/accountLists/[accountListId]/contacts/flows/setup.page.tsx @@ -9,19 +9,11 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; import { useTranslation } from 'react-i18next'; import { v4 as uuidv4 } from 'uuid'; import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { - ContactFlowOption, - colorMap, -} from 'src/components/Contacts/ContactFlow/ContactFlow'; +import { colorMap } from 'src/components/Contacts/ContactFlow/ContactFlow'; import { ContactFlowSetupColumn } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/Column/ContactFlowSetupColumn'; import { UnusedStatusesColumn } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/Column/UnusedStatusesColumn'; import { ContactFlowSetupDragLayer } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/DragLayer/ContactFlowSetupDragLayer'; import { ContactFlowSetupHeader } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ContactFlowSetupHeader'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; -import { - GetUserOptionsDocument, - GetUserOptionsQuery, -} from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { getDefaultFlowOptions } from 'src/components/Contacts/ContactFlow/contactFlowDefaultOptions'; import { FlowOption, @@ -51,7 +43,7 @@ const ContactFlowSetupPage: React.FC = () => { const resetColumnsMessage = t( 'Since all columns have been removed, resetting columns to their default values', ); - const { options: userOptions, loading } = useFlowOptions(); + const [userOptions, updateOptions, { loading }] = useFlowOptions(); useEffect(() => { if (!userOptions.length) { @@ -61,7 +53,6 @@ const ContactFlowSetupPage: React.FC = () => { } }, [userOptions]); - const [updateUserOptions] = useUpdateUserOptionsMutation(); const { appName } = useGetAppSettings(); const allUsedStatuses = flowOptions @@ -71,48 +62,8 @@ const ContactFlowSetupPage: React.FC = () => { (status) => !allUsedStatuses.includes(status), ); - const updateOptions = useCallback( - async (options: ContactFlowOption[]): Promise => { - const stringified = JSON.stringify(options); - await updateUserOptions({ - variables: { - key: 'flows', - value: stringified, - }, - update: (cache) => { - const query = { - query: GetUserOptionsDocument, - }; - const dataFromCache = cache.readQuery(query); - - if (dataFromCache) { - const filteredOld = dataFromCache.userOptions.filter( - (option) => option.key !== 'flows', - ); - - const data = { - userOptions: [ - ...filteredOld, - { - __typename: 'Option', - key: 'flows', - value: stringified, - }, - ], - }; - cache.writeQuery({ ...query, data }); - } - enqueueSnackbar(t('User options updated!'), { - variant: 'success', - }); - }, - }); - }, - [], - ); - - const addColumn = (): Promise => { - return updateOptions([ + const addColumn = (): void => { + updateOptions([ ...flowOptions, { name: 'Untitled', @@ -137,27 +88,35 @@ const ContactFlowSetupPage: React.FC = () => { const changeColor = (index: number, color: string): void => { const temp = [...flowOptions]; - temp[index].color = color; + temp[index] = { ...temp[index], color }; updateOptions(temp); }; - const moveStatus = ( - originIndex: number, - destinationIndex: number, - draggedStatus: StatusEnum, - ): void => { - const temp = [...flowOptions]; - if (originIndex > -1) { - temp[originIndex].statuses = temp[originIndex].statuses.filter( - (status) => status !== draggedStatus, - ); - } - if (destinationIndex > -1) { - temp[destinationIndex].statuses.push(draggedStatus); - } - updateOptions(temp); - setFlowOptions(temp); - }; + const moveStatus = useCallback( + ( + originIndex: number, + destinationIndex: number, + draggedStatus: StatusEnum, + ): void => { + const temp = [...flowOptions]; + if (originIndex > -1) { + temp[originIndex] = { + ...temp[originIndex], + statuses: temp[originIndex].statuses.filter( + (status) => status !== draggedStatus, + ), + }; + } + if (destinationIndex > -1) { + temp[destinationIndex] = { + ...temp[destinationIndex], + statuses: [...temp[destinationIndex].statuses, draggedStatus], + }; + } + updateOptions(temp); + }, + [flowOptions], + ); const changeTitle = ( event: React.ChangeEvent, diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx index 5074634c2..c9a38df3b 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx @@ -154,7 +154,7 @@ describe('Connect Services page', () => { // Move to finish userEvent.click(nextButton); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'finish', }); diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx index 1aa04ab9f..130f2d263 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx @@ -110,7 +110,7 @@ describe('Notifications page', () => { // Move to Integrations userEvent.click(skipButton); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'preferences.integrations', }); @@ -140,7 +140,7 @@ describe('Notifications page', () => { // Move to Integrations userEvent.click(saveButton); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'preferences.integrations', }); diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx index 8497d6b17..82ce3c91e 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx @@ -299,7 +299,7 @@ describe('Preferences page', () => { // Move to Notifications userEvent.click(skipButton); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'preferences.notifications', }); diff --git a/pages/accountLists/[accountListId]/setup/finish.page.test.tsx b/pages/accountLists/[accountListId]/setup/finish.page.test.tsx index d921f35b1..222496f7a 100644 --- a/pages/accountLists/[accountListId]/setup/finish.page.test.tsx +++ b/pages/accountLists/[accountListId]/setup/finish.page.test.tsx @@ -30,7 +30,7 @@ describe('Finish account page', () => { render(); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'finish', }), @@ -41,7 +41,7 @@ describe('Finish account page', () => { const { getByRole } = render(); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'finish', }), @@ -50,7 +50,7 @@ describe('Finish account page', () => { userEvent.click(getByRole('button', { name: /Yes/ })); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: '', }), @@ -64,7 +64,7 @@ describe('Finish account page', () => { const { getByRole } = render(); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'finish', }), @@ -73,7 +73,7 @@ describe('Finish account page', () => { userEvent.click(getByRole('button', { name: /Nope/ })); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: '', }), diff --git a/src/components/Contacts/ContactFlow/ContactsFlow.test.tsx b/src/components/Contacts/ContactFlow/ContactFlow.test.tsx similarity index 86% rename from src/components/Contacts/ContactFlow/ContactsFlow.test.tsx rename to src/components/Contacts/ContactFlow/ContactFlow.test.tsx index 30456d678..2ed78eb9f 100644 --- a/src/components/Contacts/ContactFlow/ContactsFlow.test.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlow.test.tsx @@ -19,20 +19,12 @@ const router = { }; const mocks = { - GetUserOptions: { - userOptions: [ - { - id: 'test-id', - key: 'contacts_view', - value: 'flows', - }, - { - id: '551d0a2c-4c90-444c-97dc-11f4ca858e3c', - key: 'flows', - value: - '[{"name":"UntitledOne","id":"6ced166a-d570-4086-af56-e3eeed8a1f98","statuses":["Appointment Scheduled","Not Interested"],"color":"color-text"},{"name":"UntitledTwo","id":"8a6bc2ed-820e-437b-81b8-36fbbe91f5e3","statuses":["Partner - Pray","Never Ask","Partner - Financial"],"color":"color-info"}]', - }, - ], + UserOption: { + userOption: { + key: 'flows', + value: + '[{"name":"UntitledOne","id":"6ced166a-d570-4086-af56-e3eeed8a1f98","statuses":["Appointment Scheduled","Not Interested"],"color":"color-text"},{"name":"UntitledTwo","id":"8a6bc2ed-820e-437b-81b8-36fbbe91f5e3","statuses":["Partner - Pray","Never Ask","Partner - Financial"],"color":"color-info"}]', + }, }, }; @@ -101,8 +93,8 @@ describe('ContactFlow', () => { mocks={{ - GetUserOptions: { - userOptions: [], + UserOption: { + userOption: null, }, }} > diff --git a/src/components/Contacts/ContactFlow/ContactFlow.tsx b/src/components/Contacts/ContactFlow/ContactFlow.tsx index e94ee25de..0e5033774 100644 --- a/src/components/Contacts/ContactFlow/ContactFlow.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlow.tsx @@ -52,7 +52,7 @@ export const ContactFlow: React.FC = ({ onContactSelected, searchTerm, }: Props) => { - const { options: userFlowOptions, loading: loadingUserOptions } = + const [userFlowOptions, _, { loading: loadingUserOptions }] = useFlowOptions(); const { t } = useTranslation(); diff --git a/src/components/Contacts/ContactFlow/ContactFlowSetup/DropZone/ContactFlowSetupDropZone.tsx b/src/components/Contacts/ContactFlow/ContactFlowSetup/DropZone/ContactFlowSetupDropZone.tsx index 97bad62ff..b23c2dc51 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowSetup/DropZone/ContactFlowSetupDropZone.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowSetup/DropZone/ContactFlowSetupDropZone.tsx @@ -36,7 +36,7 @@ export const ContactFlowSetupDropZone: React.FC = ({ canDrop: !!monitor.canDrop(), }), }), - [flowOptions], + [columnIndex, moveStatus, flowOptions], ); return ( diff --git a/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ContactFlowSetupHeader.tsx b/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ContactFlowSetupHeader.tsx index 911648420..c8bf7d5c1 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ContactFlowSetupHeader.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ContactFlowSetupHeader.tsx @@ -56,7 +56,7 @@ const AddColumnLoadingButton = styled(LoadingButton)(({ theme }) => ({ interface Props { addColumn: () => void; - updateOptions: (options: ContactFlowOption[]) => Promise; + updateOptions: (options: ContactFlowOption[]) => void; resetColumnsMessage: string; } @@ -72,7 +72,7 @@ export const ContactFlowSetupHeader: React.FC = ({ const handleAddColumnButtonClick = async () => { setAddingColumn(true); - await addColumn(); + addColumn(); setAddingColumn(false); }; diff --git a/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ResetToDefault/ResetToDefaultModal.tsx b/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ResetToDefault/ResetToDefaultModal.tsx index 3c6f0b523..dd2e60abc 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ResetToDefault/ResetToDefaultModal.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowSetup/Header/ResetToDefault/ResetToDefaultModal.tsx @@ -26,7 +26,7 @@ import { useContactPartnershipStatuses } from 'src/hooks/useContactPartnershipSt import { ContactFlowOption } from '../../../ContactFlow'; interface ResetToDefaultModalProps { - updateOptions: (options: ContactFlowOption[]) => Promise; + updateOptions: (options: ContactFlowOption[]) => void; handleClose: () => void; resetColumnsMessage: string; } @@ -45,14 +45,14 @@ export const ResetToDefaultModal: React.FC = ({ const { enqueueSnackbar } = useSnackbar(); const [updating, setUpdating] = useState(false); - const handleOnSubmit = async (values: { resetToDefaultType: string }) => { + const handleOnSubmit = (values: { resetToDefaultType: string }) => { const defaultValues = getDefaultFlowOptions( t, contactStatuses, values.resetToDefaultType as DefaultTypeEnum, ); - await updateOptions(defaultValues); + updateOptions(defaultValues); enqueueSnackbar(resetColumnsMessage, { variant: 'success', }); diff --git a/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql b/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql deleted file mode 100644 index 9f1b85680..000000000 --- a/src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation UpdateUserOptions($key: String!, $value: String!) { - createOrUpdateUserOption(input: { key: $key, value: $value }) { - option { - key - value - } - } -} diff --git a/src/components/Contacts/ContactFlow/useFlowOptions.test.tsx b/src/components/Contacts/ContactFlow/useFlowOptions.test.tsx index d9f4b4720..5842c7bcb 100644 --- a/src/components/Contacts/ContactFlow/useFlowOptions.test.tsx +++ b/src/components/Contacts/ContactFlow/useFlowOptions.test.tsx @@ -8,20 +8,18 @@ import { useFlowOptions } from './useFlowOptions'; const Wrapper = ({ children }: { children: ReactElement }) => ( mocks={{ - GetUserOptions: { - userOptions: [ - { - key: 'flows', - value: JSON.stringify([ - { - id: 'flow-1', - name: 'Column', - statuses: ['NEVER_ASK', 'Partner - Financial', 'foo'], - color: 'color-success', - }, - ]), - }, - ], + UserOption: { + userOption: { + key: 'flows', + value: JSON.stringify([ + { + id: 'flow-1', + name: 'Column', + statuses: ['NEVER_ASK', 'Partner - Financial', 'foo'], + color: 'color-success', + }, + ]), + }, }, }} > @@ -35,8 +33,11 @@ describe('useFlowOptions', () => { wrapper: Wrapper, }); - expect(result.current.options).toEqual([]); - expect(result.current.loading).toBe(true); + expect(result.current).toEqual([ + [], + expect.any(Function), + { loading: true }, + ]); }); it('converts old and new statuses to StatusEnum values and filters out invalid values', async () => { @@ -46,14 +47,17 @@ describe('useFlowOptions', () => { await waitForNextUpdate(); - expect(result.current.options).toEqual([ - { - id: 'flow-1', - name: 'Column', - statuses: [StatusEnum.NeverAsk, StatusEnum.PartnerFinancial], - color: 'color-success', - }, + expect(result.current).toEqual([ + [ + { + id: 'flow-1', + name: 'Column', + statuses: [StatusEnum.NeverAsk, StatusEnum.PartnerFinancial], + color: 'color-success', + }, + ], + expect.any(Function), + { loading: false }, ]); - expect(result.current.loading).toBe(false); }); }); diff --git a/src/components/Contacts/ContactFlow/useFlowOptions.ts b/src/components/Contacts/ContactFlow/useFlowOptions.ts index c88c5dcc2..99d1e41c7 100644 --- a/src/components/Contacts/ContactFlow/useFlowOptions.ts +++ b/src/components/Contacts/ContactFlow/useFlowOptions.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { StatusEnum } from 'src/graphql/types.generated'; -import { useGetUserOptionsQuery } from './GetUserOptions.generated'; +import { useSavedPreference } from 'src/hooks/useSavedPreference'; // Convert a status string from flow options into a StatusEnum const convertFlowOptionStatus = (status: string): StatusEnum | null => { @@ -85,26 +85,31 @@ export interface FlowOption { id: string; } -interface UseFlowOptionReturn { - options: FlowOption[]; - loading: boolean; -} +type UseFlowOptionReturn = [ + FlowOption[], + (options: FlowOption[]) => void, + { loading: boolean }, +]; export const useFlowOptions = (): UseFlowOptionReturn => { - const { data, loading } = useGetUserOptionsQuery(); - - const options = useMemo(() => { - const rawOptions: RawFlowOption[] = JSON.parse( - data?.userOptions.find((option) => option.key === 'flows')?.value || '[]', - ); - return rawOptions.map((option) => ({ - ...option, - statuses: option.statuses - .map((status) => convertFlowOptionStatus(status)) - // Ignore null values that didn't match a valid status - .filter(isTruthy), - })); - }, [data]); - - return { options, loading }; + const [options, setOptions, { loading }] = useSavedPreference< + RawFlowOption[] + >({ + key: 'flows', + defaultValue: [], + }); + + const convertedOptions = useMemo( + () => + options.map((option) => ({ + ...option, + statuses: option.statuses + .map((status) => convertFlowOptionStatus(status)) + // Ignore null values that didn't match a valid status + .filter(isTruthy), + })), + [options], + ); + + return [convertedOptions, setOptions, { loading }]; }; diff --git a/src/components/Contacts/ContactsContext/ContactsContext.tsx b/src/components/Contacts/ContactsContext/ContactsContext.tsx index 5dd53dddd..b76f737d1 100644 --- a/src/components/Contacts/ContactsContext/ContactsContext.tsx +++ b/src/components/Contacts/ContactsContext/ContactsContext.tsx @@ -18,6 +18,7 @@ import { TaskFilterSetInput, } from 'src/graphql/types.generated'; import { useGetIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; +import { useUpdateUserOptionMutation } from 'src/hooks/SavedPreference.generated'; import { useDebouncedCallback } from 'src/hooks/useDebounce'; import { useLocale } from 'src/hooks/useLocale'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; @@ -28,7 +29,6 @@ import { ListHeaderCheckBoxState, TableViewModeEnum, } from '../../Shared/Header/ListHeader'; -import { useUpdateUserOptionsMutation } from '../ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { useGetUserOptionsQuery } from '../ContactFlow/GetUserOptions.generated'; import { Coordinates } from '../ContactsMap/coordinates'; @@ -265,10 +265,10 @@ export const ContactsProvider: React.FC = ({ //#region JSX - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [updateUserOption] = useUpdateUserOptionMutation(); const updateOptions = async (view: string): Promise => { - await updateUserOptions({ + await updateUserOption({ variables: { key: 'contacts_view', value: view, diff --git a/src/components/Setup/useNextSetupPage.test.tsx b/src/components/Setup/useNextSetupPage.test.tsx index a9d27c87b..ba67dcfb4 100644 --- a/src/components/Setup/useNextSetupPage.test.tsx +++ b/src/components/Setup/useNextSetupPage.test.tsx @@ -96,7 +96,7 @@ describe('useNextSetupPage', () => { result.current.next(); await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key: 'setup_position', value: 'preferences.personal', }), diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx index 663dd3d9e..c5e413e10 100644 --- a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { debounce, omit } from 'lodash'; import { useContactFiltersQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; import { PageEnum } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { useGetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { ContactsContextSavedFilters as AppealsContextSavedFilters, @@ -12,6 +11,7 @@ import { } from 'src/components/Contacts/ContactsContext/ContactsContext'; import { UserOptionFragment } from 'src/components/Shared/Filters/FilterPanel.generated'; import { useGetIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; +import { useUpdateUserOptionMutation } from 'src/hooks/SavedPreference.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useMassSelection } from 'src/hooks/useMassSelection'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; @@ -419,10 +419,10 @@ export const AppealsProvider: React.FC = ({ //#region JSX - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [updateUserOption] = useUpdateUserOptionMutation(); const updateOptions = async (view: string): Promise => { - await updateUserOptions({ + await updateUserOption({ variables: { key: 'contacts_view', value: view, diff --git a/src/hooks/SavedPreference.graphql b/src/hooks/SavedPreference.graphql new file mode 100644 index 000000000..6d9d67455 --- /dev/null +++ b/src/hooks/SavedPreference.graphql @@ -0,0 +1,15 @@ +query UserOption($key: String!) { + userOption(key: $key) { + key + value + } +} + +mutation UpdateUserOption($key: String!, $value: String!) { + createOrUpdateUserOption(input: { key: $key, value: $value }) { + option { + key + value + } + } +} diff --git a/src/hooks/UserPreference.graphql b/src/hooks/UserPreference.graphql deleted file mode 100644 index fe754d11b..000000000 --- a/src/hooks/UserPreference.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query UserOption($key: String!) { - userOption(key: $key) { - key - value - } -} diff --git a/src/hooks/useSavedPreference.test.tsx b/src/hooks/useSavedPreference.test.tsx index f4bf4dad7..ecca5f616 100644 --- a/src/hooks/useSavedPreference.test.tsx +++ b/src/hooks/useSavedPreference.test.tsx @@ -1,12 +1,12 @@ import { ReactElement } from 'react'; import { renderHook } from '@testing-library/react-hooks'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { UpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; import { createCache } from 'src/lib/apollo/cache'; import { + UpdateUserOptionMutation, UserOptionDocument, UserOptionQuery, -} from './UserPreference.generated'; +} from './SavedPreference.generated'; import { useSavedPreference } from './useSavedPreference'; const key = 'test_option'; @@ -46,7 +46,7 @@ const makeWrapper = (props: WrapperProps = {}) => { const Wrapper = ({ children }: { children: ReactElement }) => ( mocks={{ UserOption: { @@ -55,7 +55,7 @@ const makeWrapper = (props: WrapperProps = {}) => { value: json ? '["initial"]' : 'initial', }, }, - UpdateUserOptions: { + UpdateUserOption: { createOrUpdateUserOption: { option: { key, @@ -122,7 +122,7 @@ describe('useSavedPreference', () => { expect(result.current[0]).toBe(newValue); await waitForNextUpdate(); - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key, value: newValue, }); @@ -147,7 +147,7 @@ describe('useSavedPreference', () => { expect(result.current[0]).toEqual([newValue]); await waitForNextUpdate(); - expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOption', { key, value: '["changed"]', }); diff --git a/src/hooks/useSavedPreference.ts b/src/hooks/useSavedPreference.ts index 3e5152596..095d26bda 100644 --- a/src/hooks/useSavedPreference.ts +++ b/src/hooks/useSavedPreference.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; -import { useUserOptionQuery } from './UserPreference.generated'; +import { + useUpdateUserOptionMutation, + useUserOptionQuery, +} from './SavedPreference.generated'; interface UseSavedPreferenceOptions { /** The unique name of the user preference key. */ @@ -28,7 +30,7 @@ export const useSavedPreference = ({ key, }, }); - const [updateUserOptions] = useUpdateUserOptionsMutation(); + const [updateUserOption] = useUpdateUserOptionMutation(); const [value, setValue] = useState(defaultValue); @@ -56,7 +58,7 @@ export const useSavedPreference = ({ const serializedValue = typeof newValue === 'string' ? newValue : JSON.stringify(newValue); - updateUserOptions({ + updateUserOption({ variables: { key, value: serializedValue, From f7626e02e09c6ca3fbb50608f518aac0b35d0871 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 18 Oct 2024 15:37:33 -0500 Subject: [PATCH 8/9] Rename hook to useUserPreference --- .../settings/integrations/index.page.tsx | 4 ++-- .../settings/notifications.page.tsx | 4 ++-- .../[accountListId]/settings/preferences.page.tsx | 4 ++-- .../[accountListId]/setup/finish.page.tsx | 4 ++-- .../Contacts/ContactFlow/useFlowOptions.ts | 14 +++++++------- .../Contacts/ContactsContext/ContactsContext.tsx | 2 +- src/components/Setup/useNextSetupPage.ts | 4 ++-- .../Tool/Appeal/AppealsContext/AppealsContext.tsx | 2 +- ...edPreference.graphql => UserPreference.graphql} | 0 ...ference.test.tsx => useUserPreference.test.tsx} | 14 +++++++------- ...{useSavedPreference.ts => useUserPreference.ts} | 8 ++++---- 11 files changed, 30 insertions(+), 30 deletions(-) rename src/hooks/{SavedPreference.graphql => UserPreference.graphql} (100%) rename src/hooks/{useSavedPreference.test.tsx => useUserPreference.test.tsx} (91%) rename src/hooks/{useSavedPreference.ts => useUserPreference.ts} (92%) diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index 37a3df80c..e60ed30c8 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -15,7 +15,7 @@ import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/Accordion import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; import { SettingsWrapper } from '../Wrapper'; const Integrations: React.FC = () => { @@ -31,7 +31,7 @@ const Integrations: React.FC = () => { const setupAccordions = ['google', 'mailchimp', 'prayerletters.com']; - const [_, setSetupPosition] = useSavedPreference({ + const [_, setSetupPosition] = useUserPreference({ key: 'setup_position', defaultValue: '', }); diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.tsx index 9f1f3b37e..3a94e0139 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.tsx @@ -9,7 +9,7 @@ import { useSetupContext } from 'src/components/Setup/SetupProvider'; import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; import { SettingsWrapper } from './Wrapper'; const Notifications: React.FC = () => { @@ -19,7 +19,7 @@ const Notifications: React.FC = () => { const { push } = useRouter(); const { onSetupTour } = useSetupContext(); - const [_, setSetupPosition] = useSavedPreference({ + const [_, setSetupPosition] = useUserPreference({ key: 'setup_position', defaultValue: '', }); diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.tsx index c2ef641b8..9733ef0fa 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.tsx @@ -31,7 +31,7 @@ import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useGetTimezones } from 'src/hooks/useGetTimezones'; import { useRequiredSession } from 'src/hooks/useRequiredSession'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; import { getCountries } from 'src/lib/data/countries'; import { SettingsWrapper } from './Wrapper'; @@ -55,7 +55,7 @@ const Preferences: React.FC = () => { const countries = getCountries(); const timeZones = useGetTimezones(); - const [_, setSetupPosition] = useSavedPreference({ + const [_, setSetupPosition] = useUserPreference({ key: 'setup_position', defaultValue: '', }); diff --git a/pages/accountLists/[accountListId]/setup/finish.page.tsx b/pages/accountLists/[accountListId]/setup/finish.page.tsx index c86a5bd79..d712eec1c 100644 --- a/pages/accountLists/[accountListId]/setup/finish.page.tsx +++ b/pages/accountLists/[accountListId]/setup/finish.page.tsx @@ -8,7 +8,7 @@ import { SetupPage } from 'src/components/Setup/SetupPage'; import { LargeButton } from 'src/components/Setup/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; // This is the last page of the tour, and it lets users choose to go to the // tools page. It is always shown. @@ -17,7 +17,7 @@ const FinishPage: React.FC = () => { const { appName } = useGetAppSettings(); const accountListId = useAccountListId(); const { push } = useRouter(); - const [_, setSetupPosition] = useSavedPreference({ + const [_, setSetupPosition] = useUserPreference({ key: 'setup_position', defaultValue: '', }); diff --git a/src/components/Contacts/ContactFlow/useFlowOptions.ts b/src/components/Contacts/ContactFlow/useFlowOptions.ts index 99d1e41c7..16d836931 100644 --- a/src/components/Contacts/ContactFlow/useFlowOptions.ts +++ b/src/components/Contacts/ContactFlow/useFlowOptions.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { StatusEnum } from 'src/graphql/types.generated'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; // Convert a status string from flow options into a StatusEnum const convertFlowOptionStatus = (status: string): StatusEnum | null => { @@ -92,12 +92,12 @@ type UseFlowOptionReturn = [ ]; export const useFlowOptions = (): UseFlowOptionReturn => { - const [options, setOptions, { loading }] = useSavedPreference< - RawFlowOption[] - >({ - key: 'flows', - defaultValue: [], - }); + const [options, setOptions, { loading }] = useUserPreference( + { + key: 'flows', + defaultValue: [], + }, + ); const convertedOptions = useMemo( () => diff --git a/src/components/Contacts/ContactsContext/ContactsContext.tsx b/src/components/Contacts/ContactsContext/ContactsContext.tsx index b76f737d1..84cfb4252 100644 --- a/src/components/Contacts/ContactsContext/ContactsContext.tsx +++ b/src/components/Contacts/ContactsContext/ContactsContext.tsx @@ -18,7 +18,7 @@ import { TaskFilterSetInput, } from 'src/graphql/types.generated'; import { useGetIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; -import { useUpdateUserOptionMutation } from 'src/hooks/SavedPreference.generated'; +import { useUpdateUserOptionMutation } from 'src/hooks/UserPreference.generated'; import { useDebouncedCallback } from 'src/hooks/useDebounce'; import { useLocale } from 'src/hooks/useLocale'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; diff --git a/src/components/Setup/useNextSetupPage.ts b/src/components/Setup/useNextSetupPage.ts index 0c904edb5..b26af04a9 100644 --- a/src/components/Setup/useNextSetupPage.ts +++ b/src/components/Setup/useNextSetupPage.ts @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import { useCallback } from 'react'; import { UserSetupStageEnum } from 'src/graphql/types.generated'; -import { useSavedPreference } from 'src/hooks/useSavedPreference'; +import { useUserPreference } from 'src/hooks/useUserPreference'; import { useSetupStageLazyQuery } from './Setup.generated'; interface UseNextSetupPageResult { @@ -12,7 +12,7 @@ interface UseNextSetupPageResult { export const useNextSetupPage = (): UseNextSetupPageResult => { const { push } = useRouter(); const [getSetupStage] = useSetupStageLazyQuery(); - const [_, setSetupPosition] = useSavedPreference({ + const [_, setSetupPosition] = useUserPreference({ key: 'setup_position', defaultValue: '', }); diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx index c5e413e10..89a7c06af 100644 --- a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx @@ -11,7 +11,7 @@ import { } from 'src/components/Contacts/ContactsContext/ContactsContext'; import { UserOptionFragment } from 'src/components/Shared/Filters/FilterPanel.generated'; import { useGetIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; -import { useUpdateUserOptionMutation } from 'src/hooks/SavedPreference.generated'; +import { useUpdateUserOptionMutation } from 'src/hooks/UserPreference.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useMassSelection } from 'src/hooks/useMassSelection'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; diff --git a/src/hooks/SavedPreference.graphql b/src/hooks/UserPreference.graphql similarity index 100% rename from src/hooks/SavedPreference.graphql rename to src/hooks/UserPreference.graphql diff --git a/src/hooks/useSavedPreference.test.tsx b/src/hooks/useUserPreference.test.tsx similarity index 91% rename from src/hooks/useSavedPreference.test.tsx rename to src/hooks/useUserPreference.test.tsx index ecca5f616..69bfaa4c6 100644 --- a/src/hooks/useSavedPreference.test.tsx +++ b/src/hooks/useUserPreference.test.tsx @@ -6,8 +6,8 @@ import { UpdateUserOptionMutation, UserOptionDocument, UserOptionQuery, -} from './SavedPreference.generated'; -import { useSavedPreference } from './useSavedPreference'; +} from './UserPreference.generated'; +import { useUserPreference } from './useUserPreference'; const key = 'test_option'; const defaultValue = 'default'; @@ -79,10 +79,10 @@ const makeWrapper = (props: WrapperProps = {}) => { return Wrapper; }; -describe('useSavedPreference', () => { +describe('useUserPreference', () => { it('returns the default value initially if the cache is empty', () => { const { result } = renderHook( - () => useSavedPreference({ key, defaultValue }), + () => useUserPreference({ key, defaultValue }), { wrapper: makeWrapper({ cached: false }), }, @@ -94,7 +94,7 @@ describe('useSavedPreference', () => { it('returns the cached value until the option refreshes', async () => { const { result, waitForNextUpdate } = renderHook( - () => useSavedPreference({ key, defaultValue }), + () => useUserPreference({ key, defaultValue }), { wrapper: makeWrapper(), }, @@ -111,7 +111,7 @@ describe('useSavedPreference', () => { it('setting the value updates the value optimistically then updates to the response value', async () => { const { result, waitForNextUpdate, rerender } = renderHook( - () => useSavedPreference({ key, defaultValue }), + () => useUserPreference({ key, defaultValue }), { wrapper: makeWrapper({ cached: false }), }, @@ -132,7 +132,7 @@ describe('useSavedPreference', () => { it('serializes and deserializes the value as JSON', async () => { const { result, waitForNextUpdate, rerender } = renderHook( - () => useSavedPreference({ key, defaultValue: [defaultValue] }), + () => useUserPreference({ key, defaultValue: [defaultValue] }), { wrapper: makeWrapper({ cached: false, json: true }), }, diff --git a/src/hooks/useSavedPreference.ts b/src/hooks/useUserPreference.ts similarity index 92% rename from src/hooks/useSavedPreference.ts rename to src/hooks/useUserPreference.ts index 095d26bda..204794ed9 100644 --- a/src/hooks/useSavedPreference.ts +++ b/src/hooks/useUserPreference.ts @@ -2,9 +2,9 @@ import { useCallback, useEffect, useState } from 'react'; import { useUpdateUserOptionMutation, useUserOptionQuery, -} from './SavedPreference.generated'; +} from './UserPreference.generated'; -interface UseSavedPreferenceOptions { +interface UseUserPreferenceOptions { /** The unique name of the user preference key. */ key: string; @@ -17,10 +17,10 @@ interface UseSavedPreferenceOptions { * is not a string, the value will be transparently serialized and deserialized as JSON because the * server only supports string option values. */ -export const useSavedPreference = ({ +export const useUserPreference = ({ key, defaultValue, -}: UseSavedPreferenceOptions): [ +}: UseUserPreferenceOptions): [ T, (value: T) => void, { loading: boolean }, From d830fb238f66f70d7da2916686b4971e9c898755 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 21 Oct 2024 11:20:38 -0500 Subject: [PATCH 9/9] Increase flaky test timeout --- .../[accountListId]/settings/preferences.page.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx index 82ce3c91e..33c1b08f2 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx @@ -307,6 +307,6 @@ describe('Preferences page', () => { '/accountLists/account-list-1/settings/notifications', ); }); - }); + }, 15000); }); });