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..9e68cc0f2 --- /dev/null +++ b/pages/setup/account.page.test.tsx @@ -0,0 +1,168 @@ +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; + +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 { 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', + ); + }); + + 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', () => { + 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('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: { + 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..dbce25ba5 --- /dev/null +++ b/pages/setup/account.page.tsx @@ -0,0 +1,154 @@ +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; + try { + await ssrClient.mutate< + UpdateUserDefaultAccountMutation, + UpdateUserDefaultAccountMutationVariables + >({ + mutation: UpdateUserDefaultAccountDocument, + variables: { + input: { + attributes: { + defaultAccountList: defaultAccountListId, + }, + }, + }, + }); + 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 { + props: { + session, + accountListOptions, + }, + }; +}; + +export default AccountPage;