From efecde1e31d3a98daac60957d18f9f5a6a842fa8 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 29 Aug 2024 16:02:13 -0500 Subject: [PATCH 1/3] Add default account setup page --- pages/setup/Account.graphql | 8 ++ pages/setup/account.page.test.tsx | 134 +++++++++++++++++++++++++++ pages/setup/account.page.tsx | 147 ++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 pages/setup/Account.graphql create mode 100644 pages/setup/account.page.test.tsx create mode 100644 pages/setup/account.page.tsx diff --git a/pages/setup/Account.graphql b/pages/setup/Account.graphql new file mode 100644 index 000000000..32a601fd3 --- /dev/null +++ b/pages/setup/Account.graphql @@ -0,0 +1,8 @@ +query AccountListOptions { + accountLists(first: 50) { + nodes { + id + name + } + } +} diff --git a/pages/setup/account.page.test.tsx b/pages/setup/account.page.test.tsx new file mode 100644 index 000000000..10213ae1d --- /dev/null +++ b/pages/setup/account.page.test.tsx @@ -0,0 +1,134 @@ +import { GetServerSidePropsContext } from 'next'; +import { ApolloClient, NormalizedCacheObject } from '@apollo/client'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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 makeSsrClient from 'src/lib/apollo/ssrClient'; +import AccountPage, { getServerSideProps } from './account.page'; + +jest.mock('src/lib/apollo/ssrClient'); + +const push = jest.fn(); +const router = { + push, +}; + +const context = { + req: {}, +} as unknown as GetServerSidePropsContext; + +describe('Setup account page', () => { + it('renders account options, saves default account, and advances to the next page', async () => { + const accountListOptions = { + accountLists: { + nodes: [1, 2, 3].map((id) => ({ + id: `account-list-${id}`, + name: `Account List ${id}`, + })), + }, + }; + + const mutationSpy = jest.fn(); + const { getByRole } = render( + + + + + , + ); + + expect( + getByRole('heading', { name: 'Set default account' }), + ).toBeInTheDocument(); + + userEvent.click(getByRole('combobox', { name: 'Account' })); + userEvent.click(getByRole('option', { name: 'Account List 1' })); + const continueButton = getByRole('button', { name: 'Continue Tour' }); + userEvent.click(continueButton); + expect(continueButton).toBeDisabled(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateUserDefaultAccount', { + input: { attributes: { defaultAccountList: 'account-list-1' } }, + }), + ); + expect(push).toHaveBeenCalledWith( + '/accountLists/account-list-1/settings/preferences', + ); + }); +}); + +describe('getServerSideProps', () => { + const query = jest.fn(); + const mutate = jest.fn(); + + beforeEach(() => { + (makeSsrClient as jest.MockedFn).mockReturnValue({ + query, + mutate, + } as unknown as ApolloClient); + }); + + it('redirects to the login page if the session is missing', async () => { + (getSession as jest.MockedFn).mockResolvedValueOnce( + null, + ); + + await expect(getServerSideProps(context)).resolves.toEqual({ + redirect: { + destination: '/login', + permanent: false, + }, + }); + }); + + it('sets the single account list as the default', async () => { + query.mockResolvedValue({ + data: { + accountLists: { + nodes: [{ id: 'account-list-1' }], + }, + }, + }); + + await expect(getServerSideProps(context)).resolves.toEqual({ + redirect: { + destination: '/accountLists/account-list-1/settings/preferences', + permanent: false, + }, + }); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + attributes: { + defaultAccountList: 'account-list-1', + }, + }, + }, + }), + ); + }); + + it('does not set an account list as the default when there are multiple', async () => { + const accountListOptions = { + accountLists: { + nodes: [{ id: 'account-list-1' }, { id: 'account-list-2' }], + }, + }; + query.mockResolvedValue({ + data: accountListOptions, + }); + + await expect(getServerSideProps(context)).resolves.toEqual({ + props: { + accountListOptions, + session, + }, + }); + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/pages/setup/account.page.tsx b/pages/setup/account.page.tsx new file mode 100644 index 000000000..958776055 --- /dev/null +++ b/pages/setup/account.page.tsx @@ -0,0 +1,147 @@ +import { GetServerSideProps } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import React, { useState } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { getSession } from 'next-auth/react'; +import { useTranslation } from 'react-i18next'; +import { + UpdateUserDefaultAccountDocument, + UpdateUserDefaultAccountMutation, + UpdateUserDefaultAccountMutationVariables, + useUpdateUserDefaultAccountMutation, +} from 'src/components/Settings/preferences/accordions/DefaultAccountAccordion/UpdateDefaultAccount.generated'; +import { SetupPage } from 'src/components/Setup/SetupPage'; +import { LargeButton } from 'src/components/Setup/styledComponents'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import makeSsrClient from 'src/lib/apollo/ssrClient'; +import { + AccountListOptionsDocument, + AccountListOptionsQuery, +} from './Account.generated'; + +type AccountList = AccountListOptionsQuery['accountLists']['nodes'][number]; + +interface PageProps { + accountListOptions: AccountListOptionsQuery; +} + +// This is the third page page of the setup tour. It lets users choose their +// default account list. It will be shown if the user has more than one account +// list and don't have a default chosen yet. +const AccountPage: React.FC = ({ accountListOptions }) => { + const { t } = useTranslation(); + const { appName } = useGetAppSettings(); + const { push } = useRouter(); + const [updateUserDefaultAccount, { loading: isSubmitting }] = + useUpdateUserDefaultAccountMutation(); + + const [defaultAccountList, setDefaultAccountList] = + useState(null); + + const handleSave = async () => { + if (!defaultAccountList) { + return; + } + + await updateUserDefaultAccount({ + variables: { + input: { + attributes: { + defaultAccountList: defaultAccountList.id, + }, + }, + }, + }); + push(`/accountLists/${defaultAccountList.id}/settings/preferences`); + }; + + return ( + <> + + + {appName} | {t('Setup - Default Account')} + + + + {t( + 'Which account would you like to see by default when you open {{appName}}?', + { appName }, + )} + setDefaultAccountList(value)} + options={accountListOptions.accountLists.nodes} + getOptionLabel={(accountList) => accountList.name ?? ''} + fullWidth + renderInput={(params) => ( + + )} + /> + + {t('Continue Tour')} + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async ( + context, +) => { + const session = await getSession(context); + const apiToken = session?.user?.apiToken; + if (!apiToken) { + return { + redirect: { + destination: '/login', + permanent: false, + }, + }; + } + + const ssrClient = makeSsrClient(apiToken); + const { data: accountListOptions } = + await ssrClient.query({ + query: AccountListOptionsDocument, + }); + + if (accountListOptions.accountLists.nodes.length === 1) { + // The user has exactly one account list, so set it as the default and go to preferences + const defaultAccountListId = accountListOptions.accountLists.nodes[0].id; + await ssrClient.mutate< + UpdateUserDefaultAccountMutation, + UpdateUserDefaultAccountMutationVariables + >({ + mutation: UpdateUserDefaultAccountDocument, + variables: { + input: { + attributes: { + defaultAccountList: defaultAccountListId, + }, + }, + }, + }); + return { + redirect: { + destination: `/accountLists/${defaultAccountListId}/settings/preferences`, + permanent: false, + }, + }; + } + + return { + props: { + session, + accountListOptions, + }, + }; +}; + +export default AccountPage; From 21ec986801eaae9b229d1e1bb6ff8300811f1b16 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 30 Aug 2024 09:13:57 -0500 Subject: [PATCH 2/3] Handle server-side mutation errors --- pages/setup/account.page.test.tsx | 19 ++++++++++++++ pages/setup/account.page.tsx | 41 ++++++++++++++++++------------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/pages/setup/account.page.test.tsx b/pages/setup/account.page.test.tsx index 10213ae1d..e4c99bc07 100644 --- a/pages/setup/account.page.test.tsx +++ b/pages/setup/account.page.test.tsx @@ -113,6 +113,25 @@ describe('getServerSideProps', () => { ); }); + it('swallows server-side mutation errors', async () => { + const accountListOptions = { + accountLists: { + nodes: [{ id: 'account-list-1' }], + }, + }; + query.mockResolvedValue({ + data: accountListOptions, + }); + mutate.mockRejectedValue(new Error('Failed')); + + await expect(getServerSideProps(context)).resolves.toEqual({ + props: { + accountListOptions, + session, + }, + }); + }); + it('does not set an account list as the default when there are multiple', async () => { const accountListOptions = { accountLists: { diff --git a/pages/setup/account.page.tsx b/pages/setup/account.page.tsx index 958776055..dbce25ba5 100644 --- a/pages/setup/account.page.tsx +++ b/pages/setup/account.page.tsx @@ -115,25 +115,32 @@ export const getServerSideProps: GetServerSideProps = async ( if (accountListOptions.accountLists.nodes.length === 1) { // The user has exactly one account list, so set it as the default and go to preferences const defaultAccountListId = accountListOptions.accountLists.nodes[0].id; - await ssrClient.mutate< - UpdateUserDefaultAccountMutation, - UpdateUserDefaultAccountMutationVariables - >({ - mutation: UpdateUserDefaultAccountDocument, - variables: { - input: { - attributes: { - defaultAccountList: defaultAccountListId, + try { + await ssrClient.mutate< + UpdateUserDefaultAccountMutation, + UpdateUserDefaultAccountMutationVariables + >({ + mutation: UpdateUserDefaultAccountDocument, + variables: { + input: { + attributes: { + defaultAccountList: defaultAccountListId, + }, }, }, - }, - }); - return { - redirect: { - destination: `/accountLists/${defaultAccountListId}/settings/preferences`, - permanent: false, - }, - }; + }); + return { + redirect: { + destination: `/accountLists/${defaultAccountListId}/settings/preferences`, + permanent: false, + }, + }; + } catch { + // If setting the account list failed, silently swallow the error and let + // the user view the page. If the error is persistent, the mutation will + // fail there when they try to choose a default account list, and they + // will at least get an error message. + } } return { From 983acac3827d937b95d837d174c142ea62074049 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 30 Aug 2024 09:17:51 -0500 Subject: [PATCH 3/3] Add test that save button is disabled --- pages/setup/account.page.test.tsx | 49 ++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/pages/setup/account.page.test.tsx b/pages/setup/account.page.test.tsx index e4c99bc07..9e68cc0f2 100644 --- a/pages/setup/account.page.test.tsx +++ b/pages/setup/account.page.test.tsx @@ -20,25 +20,30 @@ const context = { req: {}, } as unknown as GetServerSidePropsContext; +const mutationSpy = jest.fn(); + +const TestComponent: React.FC = () => { + const accountListOptions = { + accountLists: { + nodes: [1, 2, 3].map((id) => ({ + id: `account-list-${id}`, + name: `Account List ${id}`, + })), + }, + }; + + return ( + + + + + + ); +}; + describe('Setup account page', () => { it('renders account options, saves default account, and advances to the next page', async () => { - const accountListOptions = { - accountLists: { - nodes: [1, 2, 3].map((id) => ({ - id: `account-list-${id}`, - name: `Account List ${id}`, - })), - }, - }; - - const mutationSpy = jest.fn(); - const { getByRole } = render( - - - - - , - ); + const { getByRole } = render(); expect( getByRole('heading', { name: 'Set default account' }), @@ -59,6 +64,16 @@ describe('Setup account page', () => { '/accountLists/account-list-1/settings/preferences', ); }); + + it('disables save button until the user selects an account', () => { + const { getByRole } = render(); + + expect(getByRole('button', { name: 'Continue Tour' })).toBeDisabled(); + + userEvent.click(getByRole('combobox', { name: 'Account' })); + userEvent.click(getByRole('option', { name: 'Account List 1' })); + expect(getByRole('button', { name: 'Continue Tour' })).not.toBeDisabled(); + }); }); describe('getServerSideProps', () => {