diff --git a/README.md b/README.md index a7967b492..4eb7ca134 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Note: there is a test account you can use. Get this from another developer if yo - `HS_TASKS_SUGGESTIONS` - Comma-separated IDs of the HelpScout articles to suggest on the tasks page - `PRIVACY_POLICY_URL` - URL of the privacy policy - `TERMS_OF_USE_URL` - URL of the terms of use +- `DISABLE_SETUP_TOUR` - Set to `true` to disable starting users on the welcome tour. This should be removed from the codebase once tools are live. #### Auth provider diff --git a/next.config.js b/next.config.js index 86d0e4f0c..07e076370 100644 --- a/next.config.js +++ b/next.config.js @@ -99,6 +99,7 @@ const config = { PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL, TERMS_OF_USE_URL: process.env.TERMS_OF_USE_URL, DD_ENV: process.env.DD_ENV ?? 'development', + DISABLE_SETUP_TOUR: process.env.DISABLE_SETUP_TOUR, }, experimental: { modularizeImports: { diff --git a/pages/GetAccountLists.graphql b/pages/GetAccountLists.graphql index 813c5fa8a..834e1a228 100644 --- a/pages/GetAccountLists.graphql +++ b/pages/GetAccountLists.graphql @@ -1,4 +1,8 @@ query GetAccountLists { + user { + id + setup + } accountLists(first: 50) { nodes { id diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index 5e8c263a1..9cef845a7 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -2,7 +2,7 @@ import { NextPage } from 'next'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import React, { ReactElement, useMemo } from 'react'; -import { ApolloProvider as RawApolloProvider } from '@apollo/client'; +import { ApolloProvider } from '@apollo/client'; import createEmotionCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; import { Box, StyledEngineProvider } from '@mui/material'; @@ -25,6 +25,7 @@ import HelpscoutBeacon from 'src/components/Helpscout/HelpscoutBeacon'; import PrimaryLayout from 'src/components/Layouts/Primary'; import Loading from 'src/components/Loading'; import { RouterGuard } from 'src/components/RouterGuard/RouterGuard'; +import { SetupProvider } from 'src/components/Setup/SetupProvider'; import { AlertBanner } from 'src/components/Shared/alertBanner/AlertBanner'; import { SnackbarUtilsConfigurator } from 'src/components/Snackbar/Snackbar'; import TaskModalProvider from 'src/components/Task/Modal/TaskModalProvider'; @@ -60,11 +61,13 @@ const GraphQLProviders: React.FC<{ const client = useMemo(() => makeClient(apiToken), [apiToken]); return ( - - - {children} - - + + + + {children} + + + ); }; diff --git a/pages/accountLists.page.test.tsx b/pages/accountLists.page.test.tsx index 4840ac3dc..f035ff37d 100644 --- a/pages/accountLists.page.test.tsx +++ b/pages/accountLists.page.test.tsx @@ -4,6 +4,7 @@ import { render } from '@testing-library/react'; import { getSession } from 'next-auth/react'; import { I18nextProvider } from 'react-i18next'; import { session } from '__tests__/fixtures/session'; +import { UserSetupStageEnum } from 'src/graphql/types.generated'; import makeSsrClient from 'src/lib/apollo/ssrClient'; import i18n from 'src/lib/i18n'; import theme from 'src/theme'; @@ -22,16 +23,14 @@ interface GetServerSidePropsReturn { const accountListId = 'accountID1'; describe('Account Lists page', () => { - const context = { - req: {} as any, - }; + const context = {} as GetServerSidePropsContext; describe('NextAuth unauthorized', () => { it('should redirect to login', async () => { (getSession as jest.Mock).mockResolvedValue(null); const { props, redirect } = (await getServerSideProps( - context as GetServerSidePropsContext, + context, )) as GetServerSidePropsReturn; expect(props).toBeUndefined(); @@ -44,20 +43,63 @@ describe('Account Lists page', () => { describe('NextAuth authorized', () => { beforeEach(() => { + process.env.DISABLE_SETUP_TOUR = undefined; + (getSession as jest.Mock).mockResolvedValue(session); }); + it('redirects user to the setup tour is user.setup is not null', async () => { + (makeSsrClient as jest.Mock).mockReturnValue({ + query: jest.fn().mockResolvedValue({ + data: { + user: { id: 'user-1', setup: UserSetupStageEnum.NoAccountLists }, + accountLists: { nodes: [] }, + }, + }), + }); + + const result = await getServerSideProps(context); + expect(result).toEqual({ + redirect: { + destination: '/setup/start', + permanent: false, + }, + }); + }); + + it('does not redirect to the setup tour when DISABLE_SETUP_TOUR is true', async () => { + process.env.DISABLE_SETUP_TOUR = 'true'; + + (makeSsrClient as jest.Mock).mockReturnValue({ + query: jest.fn().mockResolvedValue({ + data: { + user: { id: 'user-1', setup: UserSetupStageEnum.NoAccountLists }, + accountLists: { nodes: [] }, + }, + }), + }); + + const result = await getServerSideProps(context); + expect(result).not.toEqual({ + redirect: { + destination: '/setup/start', + permanent: false, + }, + }); + }); + it('redirects user to their accountList page if only one accountList', async () => { (makeSsrClient as jest.Mock).mockReturnValue({ query: jest.fn().mockResolvedValue({ data: { + user: { id: 'user-1', setup: null }, accountLists: { nodes: [{ id: accountListId }] }, }, }), }); const { props, redirect } = (await getServerSideProps( - context as GetServerSidePropsContext, + context, )) as GetServerSidePropsReturn; expect(props).toBeUndefined(); @@ -75,13 +117,14 @@ describe('Account Lists page', () => { (makeSsrClient as jest.Mock).mockReturnValue({ query: jest.fn().mockResolvedValue({ data: { + user: { id: 'user-1', setup: null }, accountLists, }, }), }); const { props, redirect } = (await getServerSideProps( - context as GetServerSidePropsContext, + context, )) as GetServerSidePropsReturn; const { getByText } = render( diff --git a/pages/accountLists.page.tsx b/pages/accountLists.page.tsx index ad08dc5bb..b21812956 100644 --- a/pages/accountLists.page.tsx +++ b/pages/accountLists.page.tsx @@ -45,6 +45,16 @@ export const getServerSideProps = makeGetServerSideProps(async (session) => { query: GetAccountListsDocument, }); + if (data.user.setup && process.env.DISABLE_SETUP_TOUR !== 'true') { + // The user has not finished setting up, so start them on the tour + return { + redirect: { + destination: '/setup/start', + permanent: false, + }, + }; + } + if (data.accountLists.nodes.length === 1) { return { redirect: { diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx index bbd309d04..c12f4854c 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.test.tsx @@ -8,6 +8,8 @@ import { GetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUser 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 useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from 'src/theme'; import Integrations from './index.page'; @@ -48,6 +50,7 @@ const MocksProviders: React.FC = ({ children, setup }) => ( MailchimpAccount: MailchimpAccountQuery; PrayerlettersAccount: PrayerlettersAccountQuery; GetUserOptions: GetUserOptionsQuery; + SetupStage: SetupStageQuery; }> mocks={{ GetUsersOrganizationsAccounts: { @@ -62,19 +65,21 @@ const MocksProviders: React.FC = ({ children, setup }) => ( }, MailchimpAccount: { mailchimpAccount: [] }, PrayerlettersAccount: { prayerlettersAccount: [] }, - GetUserOptions: { + SetupStage: { + user: { + setup: null, + }, userOptions: [ { - id: '1', key: 'setup_position', - value: setup || 'finish', + value: setup || '', }, ], }, }} onCall={mutationSpy} > - {children} + {children} @@ -99,7 +104,7 @@ describe('Connect Services page', () => { describe('Setup Tour', () => { it('should not show setup banner and accordions should not be disabled', async () => { const { queryByText, queryByRole, findByText, getByText } = render( - + , ); diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index 7d5990b94..ada4b543c 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -5,7 +5,6 @@ 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 { useGetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.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'; @@ -13,6 +12,7 @@ import { MailchimpAccordion } from 'src/components/Settings/integrations/Mailchi import { OrganizationAccordion } from 'src/components/Settings/integrations/Organization/OrganizationAccordion'; import { PrayerlettersAccordion } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion'; import { SetupBanner } from 'src/components/Settings/preferences/SetupBanner'; +import { useSetupContext } from 'src/components/Setup/SetupProvider'; import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; @@ -29,21 +29,15 @@ const Integrations: React.FC = () => { const accountListId = useAccountListId() || ''; const { appName } = useGetAppSettings(); const { enqueueSnackbar } = useSnackbar(); + const { settingUp } = useSetupContext(); const [setup, setSetup] = useState(0); const setupAccordions = ['google', 'mailchimp', 'prayerletters.com']; - const { data: userOptions } = useGetUserOptionsQuery(); const [updateUserOptions] = useUpdateUserOptionsMutation(); - const isSettingUp = userOptions?.userOptions.some( - (option) => - option.key === 'setup_position' && - option.value === 'preferences.integrations', - ); - const handleSetupChange = async () => { - if (!isSettingUp) { + if (!settingUp) { return; } const nextNav = setup + 1; @@ -76,10 +70,10 @@ const Integrations: React.FC = () => { }, []); useEffect(() => { - if (isSettingUp) { + if (settingUp) { setExpandedPanel(setupAccordions[0]); } - }, [isSettingUp]); + }, [settingUp]); return ( { pageHeading={t('Connect Services')} selectedMenuId="integrations" > - {isSettingUp && ( + {settingUp && ( { diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx index 5d2ab2a00..9dc078d7c 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.test.tsx @@ -10,6 +10,8 @@ import { 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 theme from 'src/theme'; import Notifications from './notifications.page'; @@ -48,22 +50,25 @@ const MocksProviders: React.FC = ({ children, setup }) => ( GetUserOptions: GetUserOptionsQuery; NotificationsPreferences: NotificationsPreferencesQuery; NotificationTypes: NotificationTypesQuery; + SetupStage: SetupStageQuery; }> mocks={{ ...notificationSettingsMocks, - GetUserOptions: { + SetupStage: { + user: { + setup: null, + }, userOptions: [ { - id: '1', key: 'setup_position', - value: setup || 'finish', + value: setup || '', }, ], }, }} onCall={mutationSpy} > - {children} + {children} @@ -86,7 +91,7 @@ describe('Notifications page', () => { describe('Setup Tour', () => { it('should not show setup banner', async () => { const { queryByText, findByText } = render( - + , ); diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.tsx index dc1ba951b..3eec9cd72 100644 --- a/pages/accountLists/[accountListId]/settings/notifications.page.tsx +++ b/pages/accountLists/[accountListId]/settings/notifications.page.tsx @@ -5,9 +5,9 @@ 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 { useGetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.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'; @@ -19,18 +19,12 @@ const Notifications: React.FC = () => { const accountListId = useAccountListId() || ''; const { push } = useRouter(); const { enqueueSnackbar } = useSnackbar(); + const { settingUp } = useSetupContext(); - const { data: userOptions } = useGetUserOptionsQuery(); const [updateUserOptions] = useUpdateUserOptionsMutation(); - const isSettingUp = userOptions?.userOptions.some( - (option) => - option.key === 'setup_position' && - option.value === 'preferences.notifications', - ); - const handleSetupChange = async () => { - if (!isSettingUp) { + if (!settingUp) { return; } @@ -54,7 +48,7 @@ const Notifications: React.FC = () => { pageHeading={t('Notifications')} selectedMenuId="notifications" > - {isSettingUp && ( + {settingUp && ( = ({ GetPersonalPreferences: GetPersonalPreferencesQuery; GetProfileInfo: GetProfileInfoQuery; CanUserExportData: CanUserExportDataQuery; + SetupStage: SetupStageQuery; }> mocks={{ GetAccountPreferences: { @@ -139,18 +142,21 @@ const MocksProviders: React.FC = ({ exportedAt: null, }, }, - GetUserOptions: { + SetupStage: { + user: { + setup: null, + }, userOptions: [ { key: 'setup_position', - value: setup || 'finish', + value: setup || '', }, ], }, }} onCall={mutationSpy} > - {children} + {children} @@ -211,11 +217,7 @@ describe('Preferences page', () => { it('should not show setup banner and accordions should not be disabled', async () => { const { queryByText, queryByRole, findByText, getByText, getByRole } = render( - + , ); diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.tsx index 954f2c380..b6a6515e0 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.tsx @@ -6,7 +6,6 @@ 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 { useGetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; import { useGetUsersOrganizationsAccountsQuery } from 'src/components/Settings/integrations/Organization/Organizations.generated'; import { useCanUserExportDataQuery, @@ -28,6 +27,7 @@ import { MpdInfoAccordion } from 'src/components/Settings/preferences/accordions import { PrimaryOrgAccordion } from 'src/components/Settings/preferences/accordions/PrimaryOrgAccordion/PrimaryOrgAccordion'; import { TimeZoneAccordion } from 'src/components/Settings/preferences/accordions/TimeZoneAccordion/TimeZoneAccordion'; import { ProfileInfo } from 'src/components/Settings/preferences/info/ProfileInfo'; +import { useSetupContext } from 'src/components/Setup/SetupProvider'; import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; import { StickyBox } from 'src/components/Shared/Header/styledComponents'; import { useAccountListId } from 'src/hooks/useAccountListId'; @@ -46,6 +46,7 @@ const Preferences: React.FC = () => { const accountListId = useAccountListId() || ''; const { push, query } = useRouter(); const { enqueueSnackbar } = useSnackbar(); + const { settingUp } = useSetupContext(); const setupAccordions = ['locale', 'monthly goal', 'home country']; const [setup, setSetup] = useState(0); @@ -55,7 +56,6 @@ const Preferences: React.FC = () => { const countries = getCountries(); const timeZones = useGetTimezones(); - const { data: userOptions } = useGetUserOptionsQuery(); const [updateUserOptions] = useUpdateUserOptionsMutation(); const { data: personalPreferencesData, loading: personalPreferencesLoading } = @@ -80,20 +80,15 @@ const Preferences: React.FC = () => { const { data: userOrganizationAccountsData } = useGetUsersOrganizationsAccountsQuery(); - const savedSetupPosition = userOptions?.userOptions.find( - (option) => option.key === 'setup_position', - )?.value; - const isSettingUp = savedSetupPosition === 'preferences.personal'; - useEffect(() => { suggestArticles('HS_SETTINGS_PREFERENCES_SUGGESTIONS'); }, []); useEffect(() => { - if (isSettingUp) { + if (settingUp) { setExpandedPanel(setupAccordions[0]); } - }, [isSettingUp]); + }, [settingUp]); const handleAccordionChange = (panel: string) => { const panelLowercase = panel.toLowerCase(); @@ -112,11 +107,11 @@ const Preferences: React.FC = () => { }); }, }); - push(`/accountLists/${accountListId}/setup/start`); + push('/setup/start'); }; const handleSetupChange = async () => { - if (!isSettingUp) { + if (!settingUp) { return; } const nextNav = setup + 1; @@ -159,7 +154,7 @@ const Preferences: React.FC = () => { pageHeading={t('Preferences')} selectedMenuId={'preferences'} > - {isSettingUp && ( + {settingUp && ( { handleAccordionChange={handleAccordionChange} expandedPanel={expandedPanel} locale={personalPreferencesData?.user?.preferences?.locale || ''} - disabled={isSettingUp} + disabled={settingUp} /> { localeDisplay={ personalPreferencesData?.user?.preferences?.localeDisplay || '' } - disabled={isSettingUp && setup !== 0} + disabled={settingUp && setup !== 0} handleSetupChange={handleSetupChange} /> { defaultAccountList={ personalPreferencesData?.user?.defaultAccountList || '' } - disabled={isSettingUp} + disabled={settingUp} /> { personalPreferencesData?.user?.preferences?.timeZone || '' } timeZones={timeZones} - disabled={isSettingUp} + disabled={settingUp} /> { personalPreferencesData?.user?.preferences ?.hourToSendNotifications || null } - disabled={isSettingUp} + disabled={settingUp} /> )} @@ -247,7 +242,7 @@ const Preferences: React.FC = () => { expandedPanel={expandedPanel} name={accountPreferencesData?.accountList?.name || ''} accountListId={accountListId} - disabled={isSettingUp} + disabled={settingUp} /> { currency={ accountPreferencesData?.accountList?.settings?.currency || '' } - disabled={isSettingUp && setup !== 1} + disabled={settingUp && setup !== 1} handleSetupChange={handleSetupChange} /> { } accountListId={accountListId} countries={countries} - disabled={isSettingUp && setup !== 2} + disabled={settingUp && setup !== 2} handleSetupChange={handleSetupChange} /> { accountPreferencesData?.accountList?.settings?.currency || '' } accountListId={accountListId} - disabled={isSettingUp} + disabled={settingUp} /> {userOrganizationAccountsData?.userOrganizationAccounts && userOrganizationAccountsData?.userOrganizationAccounts?.length > @@ -295,7 +290,7 @@ const Preferences: React.FC = () => { '' } accountListId={accountListId} - disabled={isSettingUp} + disabled={settingUp} /> )} { accountPreferencesData?.accountList?.settings?.tester || false } accountListId={accountListId} - disabled={isSettingUp} + disabled={settingUp} /> { accountPreferencesData?.accountList?.settings?.currency || '' } accountListId={accountListId} - disabled={isSettingUp} + disabled={settingUp} /> {canUserExportData?.canUserExportData.allowed && ( { } accountListId={accountListId} data={personalPreferencesData} - disabled={isSettingUp} + disabled={settingUp} /> )} diff --git a/pages/setup/account.page.test.tsx b/pages/setup/account.page.test.tsx index 9e68cc0f2..a7baf8b47 100644 --- a/pages/setup/account.page.test.tsx +++ b/pages/setup/account.page.test.tsx @@ -6,19 +6,24 @@ import { getSession } from 'next-auth/react'; import { session } from '__tests__/fixtures/session'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { useNextSetupPage } from 'src/components/Setup/useNextSetupPage'; import makeSsrClient from 'src/lib/apollo/ssrClient'; import AccountPage, { getServerSideProps } from './account.page'; +jest.mock('src/components/Setup/useNextSetupPage'); jest.mock('src/lib/apollo/ssrClient'); +const next = jest.fn(); +(useNextSetupPage as jest.MockedFn).mockReturnValue({ + next, +}); + const push = jest.fn(); const router = { push, }; -const context = { - req: {}, -} as unknown as GetServerSidePropsContext; +const context = {} as unknown as GetServerSidePropsContext; const mutationSpy = jest.fn(); @@ -60,9 +65,7 @@ describe('Setup account page', () => { input: { attributes: { defaultAccountList: 'account-list-1' } }, }), ); - expect(push).toHaveBeenCalledWith( - '/accountLists/account-list-1/settings/preferences', - ); + expect(next).toHaveBeenCalled(); }); it('disables save button until the user selects an account', () => { diff --git a/pages/setup/account.page.tsx b/pages/setup/account.page.tsx index d91db8a58..157c4b701 100644 --- a/pages/setup/account.page.tsx +++ b/pages/setup/account.page.tsx @@ -1,5 +1,4 @@ import Head from 'next/head'; -import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { Autocomplete, TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; @@ -12,6 +11,7 @@ import { } from 'src/components/Settings/preferences/accordions/DefaultAccountAccordion/UpdateDefaultAccount.generated'; import { SetupPage } from 'src/components/Setup/SetupPage'; import { LargeButton } from 'src/components/Setup/styledComponents'; +import { useNextSetupPage } from 'src/components/Setup/useNextSetupPage'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import makeSsrClient from 'src/lib/apollo/ssrClient'; import { @@ -31,7 +31,7 @@ interface PageProps { const AccountPage: React.FC = ({ accountListOptions }) => { const { t } = useTranslation(); const { appName } = useGetAppSettings(); - const { push } = useRouter(); + const { next } = useNextSetupPage(); const [updateUserDefaultAccount, { loading: isSubmitting }] = useUpdateUserDefaultAccountMutation(); @@ -52,7 +52,7 @@ const AccountPage: React.FC = ({ accountListOptions }) => { }, }, }); - push(`/accountLists/${defaultAccountList.id}/settings/preferences`); + await next(); }; return ( diff --git a/pages/setup/connect.page.tsx b/pages/setup/connect.page.tsx index 770dcbb03..e9b48c4c4 100644 --- a/pages/setup/connect.page.tsx +++ b/pages/setup/connect.page.tsx @@ -5,8 +5,9 @@ import { Connect } from 'src/components/Setup/Connect'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { loadSession } from '../api/utils/pagePropsHelpers'; -// This is the second page of the setup tour. It lets users connect to organizations. It will be shown if the user -// doesn't have any organization accounts attached to their user or account lists. +// This is the second page of the setup tour. It lets users connect to +// organizations. It will be shown if the user doesn't have any organization +// accounts attached to their user or account lists. const ConnectPage = (): ReactElement => { const { t } = useTranslation(); const { appName } = useGetAppSettings(); diff --git a/pages/setup/start.page.test.tsx b/pages/setup/start.page.test.tsx index 57b1d9d7d..e1f628b6c 100644 --- a/pages/setup/start.page.test.tsx +++ b/pages/setup/start.page.test.tsx @@ -2,8 +2,16 @@ 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 { useNextSetupPage } from 'src/components/Setup/useNextSetupPage'; import StartPage from './start.page'; +jest.mock('src/components/Setup/useNextSetupPage'); + +const next = jest.fn(); +(useNextSetupPage as jest.MockedFn).mockReturnValue({ + next, +}); + const push = jest.fn(); const router = { push, @@ -34,6 +42,6 @@ describe('Setup start page', () => { input: { attributes: { locale: 'de' } }, }), ); - expect(push).toHaveBeenCalledWith('/setup/connect'); + expect(next).toHaveBeenCalled(); }); }); diff --git a/pages/setup/start.page.tsx b/pages/setup/start.page.tsx index 973f06904..a0566a6f1 100644 --- a/pages/setup/start.page.tsx +++ b/pages/setup/start.page.tsx @@ -1,11 +1,11 @@ import Head from 'next/head'; -import { useRouter } from 'next/router'; import React, { ReactElement, useState } from 'react'; import { Autocomplete, TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useUpdatePersonalPreferencesMutation } from 'src/components/Settings/preferences/accordions/UpdatePersonalPreferences.generated'; import { SetupPage } from 'src/components/Setup/SetupPage'; import { LargeButton } from 'src/components/Setup/styledComponents'; +import { useNextSetupPage } from 'src/components/Setup/useNextSetupPage'; import { PrivacyPolicyLink, TermsOfUseLink, @@ -18,7 +18,7 @@ import { loadSession } from '../api/utils/pagePropsHelpers'; const StartPage = (): ReactElement => { const { t } = useTranslation(); const { appName } = useGetAppSettings(); - const { push } = useRouter(); + const { next } = useNextSetupPage(); const [savePreferences] = useUpdatePersonalPreferencesMutation(); const [locale, setLocale] = useState( @@ -37,7 +37,7 @@ const StartPage = (): ReactElement => { }, }, }); - push('/setup/connect'); + await next(); }; return ( diff --git a/src/components/AccountLists/AccountLists.stories.tsx b/src/components/AccountLists/AccountLists.stories.tsx index 8fed32cfe..9e326fcb6 100644 --- a/src/components/AccountLists/AccountLists.stories.tsx +++ b/src/components/AccountLists/AccountLists.stories.tsx @@ -9,6 +9,10 @@ export const Default = (): ReactElement => { return ( { ).mockReturnValue({ + next, +}); + const push = jest.fn(); const router = { push, @@ -143,7 +151,7 @@ describe('Connect', () => { const { findByRole } = render(); userEvent.click(await findByRole('button', { name: 'No' })); - expect(push).toHaveBeenCalledWith('/setup/account'); + expect(next).toHaveBeenCalled(); }); }); }); diff --git a/src/components/Setup/Connect.tsx b/src/components/Setup/Connect.tsx index c316b8c5e..ad467d7c8 100644 --- a/src/components/Setup/Connect.tsx +++ b/src/components/Setup/Connect.tsx @@ -1,4 +1,3 @@ -import { useRouter } from 'next/router'; import React, { useCallback, useState } from 'react'; import DeleteIcon from '@mui/icons-material/Delete'; import { @@ -19,6 +18,7 @@ import { import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { SetupPage } from './SetupPage'; import { LargeButton } from './styledComponents'; +import { useNextSetupPage } from './useNextSetupPage'; const ButtonGroup = styled(Box)(({ theme }) => ({ width: '100%', @@ -41,7 +41,7 @@ export const Connect: React.FC = () => { const { t } = useTranslation(); const { appName } = useGetAppSettings(); const { enqueueSnackbar } = useSnackbar(); - const { push } = useRouter(); + const { next } = useNextSetupPage(); const { data, refetch } = useGetUsersOrganizationsAccountsQuery(); const organizationAccounts = data?.userOrganizationAccounts; @@ -79,10 +79,6 @@ export const Connect: React.FC = () => { await refetch(); }; - const handleContinue = () => { - push('/setup/account'); - }; - const CancelButton = useCallback( (props: ButtonProps) => { // Remove the cancel button when adding the first organization account @@ -156,11 +152,7 @@ export const Connect: React.FC = () => { > {t('Yes')} - + {t('No')} diff --git a/src/components/Setup/Setup.graphql b/src/components/Setup/Setup.graphql new file mode 100644 index 000000000..451e36964 --- /dev/null +++ b/src/components/Setup/Setup.graphql @@ -0,0 +1,12 @@ +query SetupStage { + user { + id + defaultAccountList + setup + } + userOptions { + id + key + value + } +} diff --git a/src/components/Setup/SetupProvider.test.tsx b/src/components/Setup/SetupProvider.test.tsx new file mode 100644 index 000000000..18fd0d69e --- /dev/null +++ b/src/components/Setup/SetupProvider.test.tsx @@ -0,0 +1,157 @@ +import { render, waitFor } from '@testing-library/react'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { UserSetupStageEnum } from 'src/graphql/types.generated'; +import { SetupStageQuery } from './Setup.generated'; +import { SetupProvider, useSetupContext } from './SetupProvider'; + +const push = jest.fn(); + +interface TestComponentProps { + setup: UserSetupStageEnum | null; + setupPosition?: string | null; + pathname?: string; +} + +const ContextTestingComponent = () => { + const { settingUp } = useSetupContext(); + + return ( +
+ {typeof settingUp === 'undefined' ? 'undefined' : settingUp.toString()} +
+ ); +}; + +const TestComponent: React.FC = ({ + setup, + setupPosition = null, + pathname = '/', +}) => ( + + + mocks={{ + SetupStage: { + user: { + setup, + }, + userOptions: [ + { + key: 'setup_position', + value: setupPosition, + }, + ], + }, + }} + > + + +
Page content
+
+ +
+); + +describe('SetupProvider', () => { + beforeEach(() => { + process.env.DISABLE_SETUP_TOUR = undefined; + }); + + it('renders child content', () => { + const { getByText } = render( + , + ); + + expect(getByText('Page content')).toBeInTheDocument(); + }); + + it('redirects if the user needs to connect to create an account list', async () => { + render(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/connect')); + }); + + it('redirects if the user needs to connect to an organization', async () => { + render(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/connect')); + }); + + it('redirects if the user needs to choose a default account', async () => { + render(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/account')); + }); + + it('does not redirect if the user is on the setup start page', async () => { + render( + , + ); + + await waitFor(() => expect(push).not.toHaveBeenCalled()); + }); + + it('does not redirect if the user does not need to set up their account', async () => { + render(); + + await waitFor(() => expect(push).not.toHaveBeenCalled()); + }); + + describe('settingUp context', () => { + it('is undefined while data is loading', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('setting-up')).toHaveTextContent('undefined'); + }); + + it('is true when setup is set', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('setting-up')).toHaveTextContent('true'), + ); + }); + + it('is true when setup_position is set', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('setting-up')).toHaveTextContent('true'), + ); + }); + + it('is false when setup_position is not set', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('setting-up')).toHaveTextContent('false'), + ); + }); + + it('is false when DISABLE_SETUP_TOUR is true', async () => { + process.env.DISABLE_SETUP_TOUR = 'true'; + + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('setting-up')).toHaveTextContent('false'), + ); + }); + }); +}); diff --git a/src/components/Setup/SetupProvider.tsx b/src/components/Setup/SetupProvider.tsx new file mode 100644 index 000000000..b84974af9 --- /dev/null +++ b/src/components/Setup/SetupProvider.tsx @@ -0,0 +1,83 @@ +import { useRouter } from 'next/router'; +import React, { + ReactNode, + createContext, + useContext, + useEffect, + useMemo, +} from 'react'; +import { UserSetupStageEnum } from 'src/graphql/types.generated'; +import { useSetupStageQuery } from './Setup.generated'; + +export interface SetupContext { + settingUp?: boolean; +} + +const SetupContext = createContext(null); + +export const useSetupContext = (): SetupContext => { + const setupContext = useContext(SetupContext); + if (!setupContext) { + throw new Error( + 'SetupProvider not found! Make sure that you are calling useSetupContext inside a component wrapped by .', + ); + } + + return setupContext; +}; + +interface Props { + children: ReactNode; +} + +// This context component ensures that users have gone through the setup process +// and provides the setup state to the rest of the application +export const SetupProvider: React.FC = ({ children }) => { + const { data } = useSetupStageQuery(); + const { push, pathname } = useRouter(); + + useEffect(() => { + if ( + !data || + pathname === '/setup/start' || + process.env.DISABLE_SETUP_TOUR === 'true' + ) { + return; + } + + // If the user hasn't completed crucial setup steps, take them to the tour + // to finish setting up their account. If they are on the preferences stage + // or beyond and manually typed in a URL, let them stay on the page they + // were on. + if ( + data.user.setup === UserSetupStageEnum.NoAccountLists || + data.user.setup === UserSetupStageEnum.NoOrganizationAccount + ) { + push('/setup/connect'); + } else if (data.user.setup === UserSetupStageEnum.NoDefaultAccountList) { + push('/setup/account'); + } + }, [data]); + + const settingUp = useMemo(() => { + if (!data) { + return undefined; + } + + if (process.env.DISABLE_SETUP_TOUR === 'true') { + return false; + } + + return ( + data.userOptions.some( + (option) => option.key === 'setup_position' && option.value !== '', + ) || data.user.setup !== null + ); + }, [data]); + + return ( + + {children} + + ); +}; diff --git a/src/components/Setup/useNextSetupPage.test.tsx b/src/components/Setup/useNextSetupPage.test.tsx new file mode 100644 index 000000000..a9d27c87b --- /dev/null +++ b/src/components/Setup/useNextSetupPage.test.tsx @@ -0,0 +1,110 @@ +import { ReactElement } from 'react'; +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { UserSetupStageEnum } from 'src/graphql/types.generated'; +import { SetupStageQuery } from './Setup.generated'; +import { useNextSetupPage } from './useNextSetupPage'; + +const push = jest.fn(); +const router = { + push, +}; + +interface HookWrapperProps { + setup: UserSetupStageEnum | null; + children: ReactElement; +} + +const mutationSpy = jest.fn(); + +const HookWrapper: React.FC = ({ setup, children }) => ( + + + mocks={{ + SetupStage: { + user: { + defaultAccountList: 'account-list-1', + setup, + }, + }, + }} + onCall={mutationSpy} + > + {children} + + +); + +type HookWrapper = React.FC<{ children: ReactElement }>; + +describe('useNextSetupPage', () => { + it('when the user has no organization accounts next should redirect to the connect page', async () => { + const Wrapper: HookWrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNextSetupPage(), { + wrapper: Wrapper, + }); + result.current.next(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/connect')); + }); + + it('when the user has no account lists next should redirect to the connect page', async () => { + const Wrapper: HookWrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNextSetupPage(), { + wrapper: Wrapper, + }); + result.current.next(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/connect')); + }); + + it('when the user has no default account list next should redirect to the account page', async () => { + const Wrapper: HookWrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNextSetupPage(), { + wrapper: Wrapper, + }); + result.current.next(); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/setup/account')); + }); + + it("when the user's account is set up next should set setup_position and redirect to the preferences page", async () => { + const Wrapper: HookWrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useNextSetupPage(), { + wrapper: Wrapper, + }); + result.current.next(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserOptions', { + key: 'setup_position', + value: 'preferences.personal', + }), + ); + await waitFor(() => + expect(push).toHaveBeenCalledWith( + '/accountLists/account-list-1/settings/preferences', + ), + ); + }); +}); diff --git a/src/components/Setup/useNextSetupPage.ts b/src/components/Setup/useNextSetupPage.ts new file mode 100644 index 000000000..9829197ab --- /dev/null +++ b/src/components/Setup/useNextSetupPage.ts @@ -0,0 +1,49 @@ +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 { useSetupStageLazyQuery } from './Setup.generated'; + +interface UseNextSetupPageResult { + // Advance to the next setup page + next: () => Promise; +} + +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 next = useCallback(async () => { + const { data } = await getSetupStage(); + switch (data?.user.setup) { + case UserSetupStageEnum.NoAccountLists: + case UserSetupStageEnum.NoOrganizationAccount: + push('/setup/connect'); + return; + + case UserSetupStageEnum.NoDefaultAccountList: + push('/setup/account'); + return; + + case null: + await saveSetupPosition('preferences.personal'); + push( + `/accountLists/${data.user.defaultAccountList}/settings/preferences`, + ); + return; + } + }, []); + + return { + next, + }; +};