From f4a9528b93ffe8ec0ad86443bbe696eef59b0b90 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 19 Aug 2024 16:19:13 -0500 Subject: [PATCH 1/8] Refactor connect organization functionality into another component --- .../Organization/ConnectOrganization.test.tsx | 348 +++++++++++++++++ .../Organization/ConnectOrganization.tsx | 315 +++++++++++++++ .../OrganizationAddAccountModal.test.tsx | 357 +---------------- .../Modals/OrganizationAddAccountModal.tsx | 363 +----------------- .../Modals/OrganizationEditAccountModal.tsx | 2 +- .../Organization/OrganizationAccordion.tsx | 1 - .../integrations/Organization/schema.ts | 56 +++ 7 files changed, 741 insertions(+), 701 deletions(-) create mode 100644 src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx create mode 100644 src/components/Settings/integrations/Organization/ConnectOrganization.tsx create mode 100644 src/components/Settings/integrations/Organization/schema.ts diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx new file mode 100644 index 000000000..1e7a6efd8 --- /dev/null +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx @@ -0,0 +1,348 @@ +import { PropsWithChildren } from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import * as Types from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { ConnectOrganization } from './ConnectOrganization'; +import { GetOrganizationsQuery } from './Organizations.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = ({ children }: PropsWithChildren) => ( + + + {children} + + +); + +const GetOrganizationsMock: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' | 'disableNewUsers' +>[] = [ + { + id: 'organizationId', + name: 'organizationName', + apiClass: 'OfflineOrg', + oauth: false, + giftAidPercentage: 0, + disableNewUsers: false, + }, + { + id: 'ministryId', + name: 'ministryName', + apiClass: 'Siebel', + oauth: false, + giftAidPercentage: 80, + disableNewUsers: false, + }, + { + id: 'loginId', + name: 'loginName', + apiClass: 'DataServer', + oauth: false, + giftAidPercentage: 70, + disableNewUsers: false, + }, + { + id: 'oAuthId', + name: 'oAuthName', + apiClass: 'DataServer', + oauth: true, + giftAidPercentage: 60, + disableNewUsers: false, + }, + { + id: 'disableNewUserOrgId', + name: 'Not Allowed Org Name', + apiClass: 'DataServer', + oauth: false, + giftAidPercentage: 60, + disableNewUsers: true, + }, + { + id: 'disableNewUserAsNull', + name: 'Org With DisableNewUsers As NULL', + apiClass: 'OfflineOrg', + oauth: false, + giftAidPercentage: 60, + disableNewUsers: null, + }, +]; + +const standardMocks = { + GetOrganizations: { + organizations: GetOrganizationsMock, + }, +}; + +const onDone = jest.fn(); + +describe('Connect Organization', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + let mocks = { ...standardMocks }; + + beforeEach(() => { + onDone.mockClear(); + mocks = { ...standardMocks }; + }); + it('should render and not show disabled Orgs', async () => { + const { getByText, getByRole, queryByRole } = render( + + + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect( + queryByRole('option', { name: 'Not Allowed Org Name' }), + ).not.toBeInTheDocument(), + ); + + userEvent.click(getByText(/cancel/i)); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('should select offline Organization and add it', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, findByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + userEvent.click(await findByRole('option', { name: 'organizationName' })); + + await waitFor(() => { + expect(getByText('Add Account')).not.toBeDisabled(); + userEvent.click(getByText('Add Account')); + }); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} added your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateOrganizationAccount', + ); + + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + attributes: { + organizationId: mocks.GetOrganizations.organizations[0].id, + }, + }); + }); + }); + + it('allows offline Organization to be added if disableNewUsers is null', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, findByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + userEvent.click( + await findByRole('option', { name: 'Org With DisableNewUsers As NULL' }), + ); + + await waitFor(() => { + expect(getByText('Add Account')).not.toBeDisabled(); + userEvent.click(getByText('Add Account')); + }); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} added your organization account', + { variant: 'success' }, + ); + }); + }); + + it('should select Ministry Organization and be unable to add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'ministryName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'ministryName' })); + + await waitFor(() => { + expect( + getByText('You must log into {{appName}} with your ministry email'), + ).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + }); + + it('should select Login Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, getByTestId } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'loginName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'loginName' })); + + await waitFor(() => { + expect(getByText('Username')).toBeInTheDocument(); + expect(getByText('Password')).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + + userEvent.type( + getByRole('textbox', { + name: /username/i, + }), + 'MyUsername', + ); + await waitFor(() => expect(getByText('Add Account')).toBeDisabled()); + userEvent.type(getByTestId('passwordInput'), 'MyPassword'); + + await waitFor(() => expect(getByText('Add Account')).not.toBeDisabled()); + userEvent.click(getByText('Add Account')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} added your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + attributes: { + organizationId: mocks.GetOrganizations.organizations[2].id, + username: 'MyUsername', + password: 'MyPassword', + }, + }); + }); + }); + + it('should select OAuth Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'oAuthName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'oAuthName' })); + + await waitFor(() => { + expect( + getByText( + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + ), + ).toBeInTheDocument(); + expect(getByText('Connect')).toBeInTheDocument(); + expect(getByText('Connect')).not.toBeDisabled(); + }); + + userEvent.click(getByText('Connect')); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to complete authentication to connect.', + { variant: 'success' }, + ); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx new file mode 100644 index 000000000..4d4bad1c2 --- /dev/null +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx @@ -0,0 +1,315 @@ +import React, { ReactElement, useState } from 'react'; +import { useApolloClient } from '@apollo/client'; +import { + Autocomplete, + Box, + Button, + DialogActions, + Link, + TextField, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Formik } from 'formik'; +import { signOut } from 'next-auth/react'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { clearDataDogUser } from 'src/lib/dataDog'; +import { articles, showArticle } from 'src/lib/helpScout'; +import theme from 'src/theme'; +import { useOauthUrl } from '../useOauthUrl'; +import { + OrganizationTypesEnum, + getOrganizationType, +} from './OrganizationAccordion'; +import { + useCreateOrganizationAccountMutation, + useGetOrganizationsQuery, +} from './Organizations.generated'; +import { OrganizationFormikSchema, OrganizationSchema } from './schema'; + +interface ConnectOrganizationProps { + onDone: () => void; + accountListId: string | undefined; +} + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +const WarningBox = styled(Box)(() => ({ + padding: '15px', + background: theme.palette.mpdxYellow.main, + maxWidth: 'calc(100% - 20px)', + margin: '10px auto 0', +})); + +const StyledTypography = styled(Typography)(() => ({ + marginTop: '10px', + color: theme.palette.mpdxYellow.dark, +})); + +export const ConnectOrganization: React.FC = ({ + onDone, + accountListId, +}) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + const client = useApolloClient(); + const [organizationType, setOrganizationType] = + useState(); + const [createOrganizationAccount] = useCreateOrganizationAccountMutation(); + const { data: organizations, loading } = useGetOrganizationsQuery(); + const { getOrganizationOauthUrl: getOauthUrl } = useOauthUrl(); + + const onSubmit = async (attributes: Partial) => { + if (!attributes?.selectedOrganization) { + return; + } + const { apiClass, oauth, id } = attributes.selectedOrganization; + const type = getOrganizationType(apiClass, oauth); + + if (type === OrganizationTypesEnum.OAUTH) { + enqueueSnackbar( + t('Redirecting you to complete authentication to connect.'), + { variant: 'success' }, + ); + window.location.href = getOauthUrl(id); + return; + } + + if (!accountListId) { + return; + } + + const createAccountAttributes: { + organizationId: string; + password?: string; + username?: string; + } = { + organizationId: id, + }; + if (attributes.password) { + createAccountAttributes.password = attributes.password; + } + if (attributes.username) { + createAccountAttributes.username = attributes.username; + } + + await createOrganizationAccount({ + variables: { + input: { + attributes: createAccountAttributes, + }, + }, + refetchQueries: ['GetUsersOrganizationsAccounts'], + onError: () => { + enqueueSnackbar(t('Invalid username or password.'), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar( + t('{{appName}} added your organization account', { appName }), + { + variant: 'success', + }, + ); + }, + }); + onDone(); + }; + + const showOrganizationHelp = () => { + showArticle('HS_SETUP_FIND_ORGANIZATION'); + }; + + return ( + + {({ + values: { selectedOrganization, username, password }, + handleChange, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }): ReactElement => ( +
+ + { + setOrganizationType( + getOrganizationType(value?.apiClass, value?.oauth), + ); + setFieldValue('selectedOrganization', value); + }} + options={ + organizations?.organizations?.filter( + (organization) => !organization?.disableNewUsers, + ) || [] + } + getOptionLabel={(option) => + organizations?.organizations?.find( + ({ id }) => String(id) === String(option.id), + )?.name ?? '' + } + fullWidth + renderInput={(params) => ( + + )} + /> + + {!selectedOrganization && !!articles.HS_SETUP_FIND_ORGANIZATION && ( + + )} + {organizationType === OrganizationTypesEnum.MINISTRY && ( + + + {t('You must log into {{appName}} with your ministry email', { + appName, + })} + + + {t( + 'This organization requires you to log into {{appName}} with your ministry email to access it.', + { appName }, + )} +
    +
  1. + {t('First you need to ')} + + {t('click here to log out of your personal Key account')} + +
  2. +
  3. + {t('Next, ')} + { + signOut({ callbackUrl: 'signOut' }).then(() => { + clearDataDogUser(); + client.clearStore(); + }); + }} + > + {t('click here to log out of {{appName}}', { + appName, + })} + + {t( + ' so you can log back in with your official key account.', + )} +
  4. +
+
+ + {t( + "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", + )} + {t( + "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", + { appName }, + )} + +
+ )} + {organizationType === OrganizationTypesEnum.OAUTH && ( + + + {t( + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + { appName }, + )} + + + )} + {organizationType === OrganizationTypesEnum.LOGIN && ( + <> + + + + + + + + + + + + )} + + + + + {organizationType === OrganizationTypesEnum.OAUTH + ? t('Connect') + : t('Add Account')} + + +
+ )} +
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index 10edba6e6..30228e4f0 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -1,28 +1,21 @@ -import { PropsWithChildren } from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { render, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import * as Types from 'src/graphql/types.generated'; import theme from 'src/theme'; -import { GetOrganizationsQuery } from '../Organizations.generated'; import { OrganizationAddAccountModal } from './OrganizationAddAccountModal'; jest.mock('next-auth/react'); const accountListId = 'account-list-1'; -const contactId = 'contact-1'; const router = { - query: { accountListId, contactId: [contactId] }, + query: { accountListId }, isReady: true, }; const mockEnqueue = jest.fn(); jest.mock('notistack', () => ({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore ...jest.requireActual('notistack'), useSnackbar: () => { return { @@ -31,348 +24,28 @@ jest.mock('notistack', () => ({ }, })); -const Components = ({ children }: PropsWithChildren) => ( - - - {children} - - -); - -const GetOrganizationsMock: Pick< - Types.Organization, - 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' | 'disableNewUsers' ->[] = [ - { - id: 'organizationId', - name: 'organizationName', - apiClass: 'OfflineOrg', - oauth: false, - giftAidPercentage: 0, - disableNewUsers: false, - }, - { - id: 'ministryId', - name: 'ministryName', - apiClass: 'Siebel', - oauth: false, - giftAidPercentage: 80, - disableNewUsers: false, - }, - { - id: 'loginId', - name: 'loginName', - apiClass: 'DataServer', - oauth: false, - giftAidPercentage: 70, - disableNewUsers: false, - }, - { - id: 'oAuthId', - name: 'oAuthName', - apiClass: 'DataServer', - oauth: true, - giftAidPercentage: 60, - disableNewUsers: false, - }, - { - id: 'disableNewUserOrgId', - name: 'Not Allowed Org Name', - apiClass: 'DataServer', - oauth: false, - giftAidPercentage: 60, - disableNewUsers: true, - }, - { - id: 'disableNewUserAsNull', - name: 'Org With DisableNewUsers As NULL', - apiClass: 'OfflineOrg', - oauth: false, - giftAidPercentage: 60, - disableNewUsers: null, - }, -]; - -const standardMocks = { - GetOrganizations: { - organizations: GetOrganizationsMock, - }, -}; - const handleClose = jest.fn(); -const refetchOrganizations = jest.fn(); describe('OrganizationAddAccountModal', () => { - process.env.OAUTH_URL = 'https://auth.mpdx.org'; - let mocks = { ...standardMocks }; - - beforeEach(() => { - handleClose.mockClear(); - refetchOrganizations.mockClear(); - mocks = { ...standardMocks }; - }); - it('should render modal and not show disabled Orgs', async () => { - const { getByText, getByTestId, getByRole, queryByRole } = render( - - - - - , + it('should render modal and handle close', () => { + const { getByText, getByTestId, getByRole } = render( + + + + + + + , ); expect(getByText('Add Organization Account')).toBeInTheDocument(); - userEvent.click(getByRole('combobox')); - await waitFor(() => - expect( - queryByRole('option', { name: 'Not Allowed Org Name' }), - ).not.toBeInTheDocument(), - ); - - userEvent.click(getByText(/cancel/i)); + userEvent.click(getByRole('button', { name: 'Cancel' })); expect(handleClose).toHaveBeenCalledTimes(1); userEvent.click(getByTestId('CloseIcon')); expect(handleClose).toHaveBeenCalledTimes(2); }); - - it('should select offline Organization and add it', async () => { - const mutationSpy = jest.fn(); - const { getByText, getByRole, findByRole } = render( - - - mocks={{ - getOrganizations: { - organizations: GetOrganizationsMock, - }, - }} - onCall={mutationSpy} - > - - - , - ); - - userEvent.click(getByRole('combobox')); - userEvent.click(await findByRole('option', { name: 'organizationName' })); - - await waitFor(() => { - expect(getByText('Add Account')).not.toBeDisabled(); - userEvent.click(getByText('Add Account')); - }); - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith( - '{{appName}} added your organization account', - { variant: 'success' }, - ); - expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( - 'CreateOrganizationAccount', - ); - - expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ - attributes: { - organizationId: mocks.GetOrganizations.organizations[0].id, - }, - }); - }); - }); - - it('allows offline Organization to be added if disableNewUsers is null', async () => { - const mutationSpy = jest.fn(); - const { getByText, getByRole, findByRole } = render( - - - mocks={{ - getOrganizations: { - organizations: GetOrganizationsMock, - }, - }} - onCall={mutationSpy} - > - - - , - ); - - userEvent.click(getByRole('combobox')); - userEvent.click( - await findByRole('option', { name: 'Org With DisableNewUsers As NULL' }), - ); - - await waitFor(() => { - expect(getByText('Add Account')).not.toBeDisabled(); - userEvent.click(getByText('Add Account')); - }); - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith( - '{{appName}} added your organization account', - { variant: 'success' }, - ); - }); - }); - - it('should select Ministry Organization and be unable to add it.', async () => { - const mutationSpy = jest.fn(); - const { getByText, getByRole } = render( - - - mocks={{ - getOrganizations: { - organizations: GetOrganizationsMock, - }, - }} - onCall={mutationSpy} - > - - - , - ); - - userEvent.click(getByRole('combobox')); - await waitFor(() => - expect(getByRole('option', { name: 'ministryName' })).toBeInTheDocument(), - ); - userEvent.click(getByRole('option', { name: 'ministryName' })); - - await waitFor(() => { - expect( - getByText('You must log into {{appName}} with your ministry email'), - ).toBeInTheDocument(); - expect(getByText('Add Account')).toBeDisabled(); - }); - }); - - it('should select Login Organization and add it.', async () => { - const mutationSpy = jest.fn(); - const { getByText, getByRole, getByTestId } = render( - - - mocks={{ - getOrganizations: { - organizations: GetOrganizationsMock, - }, - }} - onCall={mutationSpy} - > - - - , - ); - - userEvent.click(getByRole('combobox')); - await waitFor(() => - expect(getByRole('option', { name: 'loginName' })).toBeInTheDocument(), - ); - userEvent.click(getByRole('option', { name: 'loginName' })); - - await waitFor(() => { - expect(getByText('Username')).toBeInTheDocument(); - expect(getByText('Password')).toBeInTheDocument(); - expect(getByText('Add Account')).toBeDisabled(); - }); - - userEvent.type( - getByRole('textbox', { - name: /username/i, - }), - 'MyUsername', - ); - await waitFor(() => expect(getByText('Add Account')).toBeDisabled()); - userEvent.type(getByTestId('passwordInput'), 'MyPassword'); - - await waitFor(() => expect(getByText('Add Account')).not.toBeDisabled()); - userEvent.click(getByText('Add Account')); - - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith( - '{{appName}} added your organization account', - { variant: 'success' }, - ); - expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( - 'CreateOrganizationAccount', - ); - expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ - attributes: { - organizationId: mocks.GetOrganizations.organizations[2].id, - username: 'MyUsername', - password: 'MyPassword', - }, - }); - }); - }); - - it('should select OAuth Organization and add it.', async () => { - const mutationSpy = jest.fn(); - const { getByText, getByRole } = render( - - - mocks={{ - getOrganizations: { - organizations: GetOrganizationsMock, - }, - }} - onCall={mutationSpy} - > - - - , - ); - - userEvent.click(getByRole('combobox')); - await waitFor(() => - expect(getByRole('option', { name: 'oAuthName' })).toBeInTheDocument(), - ); - userEvent.click(getByRole('option', { name: 'oAuthName' })); - - await waitFor(() => { - expect( - getByText( - "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", - ), - ).toBeInTheDocument(); - expect(getByText('Connect')).toBeInTheDocument(); - expect(getByText('Connect')).not.toBeDisabled(); - }); - - userEvent.click(getByText('Connect')); - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith( - 'Redirecting you to complete authentication to connect.', - { variant: 'success' }, - ); - }); - }); }); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index 8dcc7b7ba..7778b31ee 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -1,377 +1,26 @@ -import React, { ReactElement, useState } from 'react'; -import { useApolloClient } from '@apollo/client'; -import { - Autocomplete, - Box, - Button, - DialogActions, - Link, - TextField, - Typography, -} from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { Formik } from 'formik'; -import { signOut } from 'next-auth/react'; -import { useSnackbar } from 'notistack'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import * as yup from 'yup'; -import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; -import { - CancelButton, - SubmitButton, -} from 'src/components/common/Modal/ActionButtons/ActionButtons'; import Modal from 'src/components/common/Modal/Modal'; -import { Organization } from 'src/graphql/types.generated'; -import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { clearDataDogUser } from 'src/lib/dataDog'; -import { articles, showArticle } from 'src/lib/helpScout'; -import theme from 'src/theme'; -import { useOauthUrl } from '../../useOauthUrl'; -import { - OrganizationTypesEnum, - getOrganizationType, -} from '../OrganizationAccordion'; -import { - useCreateOrganizationAccountMutation, - useGetOrganizationsQuery, -} from '../Organizations.generated'; +import { ConnectOrganization } from '../ConnectOrganization'; interface OrganizationAddAccountModalProps { handleClose: () => void; accountListId: string | undefined; - refetchOrganizations: () => void; } -export type OrganizationFormikSchema = { - selectedOrganization: Pick< - Organization, - | 'id' - | 'name' - | 'oauth' - | 'apiClass' - | 'giftAidPercentage' - | 'disableNewUsers' - >; - username: string | undefined; - password: string | undefined; -}; - -const OrganizationSchema: yup.SchemaOf = yup.object({ - selectedOrganization: yup - .object({ - id: yup.string().required(), - apiClass: yup.string().required(), - name: yup.string().required(), - oauth: yup.boolean().required(), - giftAidPercentage: yup.number().nullable(), - disableNewUsers: yup.boolean().nullable(), - }) - .required(), - username: yup - .string() - .when('selectedOrganization', (organization, schema) => { - if ( - getOrganizationType(organization?.apiClass, organization?.oauth) === - OrganizationTypesEnum.LOGIN - ) { - return schema.required('Must enter username'); - } - return schema; - }), - password: yup - .string() - .when('selectedOrganization', (organization, schema) => { - if ( - getOrganizationType(organization?.apiClass, organization?.oauth) === - OrganizationTypesEnum.LOGIN - ) { - return schema.required('Must enter password'); - } - return schema; - }), -}); - -const StyledBox = styled(Box)(() => ({ - padding: '0 10px', -})); - -const WarningBox = styled(Box)(() => ({ - padding: '15px', - background: theme.palette.mpdxYellow.main, - maxWidth: 'calc(100% - 20px)', - margin: '10px auto 0', -})); - -const StyledTypography = styled(Typography)(() => ({ - marginTop: '10px', - color: theme.palette.mpdxYellow.dark, -})); - export const OrganizationAddAccountModal: React.FC< OrganizationAddAccountModalProps -> = ({ handleClose, refetchOrganizations, accountListId }) => { +> = ({ handleClose, accountListId }) => { const { t } = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); - const { appName } = useGetAppSettings(); - const client = useApolloClient(); - const [organizationType, setOrganizationType] = - useState(); - const [createOrganizationAccount] = useCreateOrganizationAccountMutation(); - const { data: organizations, loading } = useGetOrganizationsQuery(); - const { getOrganizationOauthUrl: getOauthUrl } = useOauthUrl(); - - const onSubmit = async (attributes: Partial) => { - if (!attributes?.selectedOrganization) { - return; - } - const { apiClass, oauth, id } = attributes.selectedOrganization; - const type = getOrganizationType(apiClass, oauth); - - if (type === OrganizationTypesEnum.OAUTH) { - enqueueSnackbar( - t('Redirecting you to complete authentication to connect.'), - { variant: 'success' }, - ); - window.location.href = getOauthUrl(id); - return; - } - - if (!accountListId) { - return; - } - - const createAccountAttributes: { - organizationId: string; - password?: string; - username?: string; - } = { - organizationId: id, - }; - if (attributes.password) { - createAccountAttributes.password = attributes.password; - } - if (attributes.username) { - createAccountAttributes.username = attributes.username; - } - - await createOrganizationAccount({ - variables: { - input: { - attributes: createAccountAttributes, - }, - }, - update: () => refetchOrganizations(), - onError: () => { - enqueueSnackbar(t('Invalid username or password.'), { - variant: 'error', - }); - }, - onCompleted: () => { - enqueueSnackbar( - t('{{appName}} added your organization account', { appName }), - { - variant: 'success', - }, - ); - }, - }); - handleClose(); - return; - }; - - const showOrganizationHelp = () => { - showArticle('HS_SETUP_FIND_ORGANIZATION'); - }; return ( - - {({ - values: { selectedOrganization, username, password }, - handleChange, - handleSubmit, - setFieldValue, - isSubmitting, - isValid, - }): ReactElement => ( -
- - { - setOrganizationType( - getOrganizationType(value?.apiClass, value?.oauth), - ); - setFieldValue('selectedOrganization', value); - }} - options={ - organizations?.organizations?.filter( - (organization) => !organization?.disableNewUsers, - ) || [] - } - getOptionLabel={(option) => - organizations?.organizations?.find( - ({ id }) => String(id) === String(option.id), - )?.name ?? '' - } - fullWidth - renderInput={(params) => ( - - )} - /> - - {!selectedOrganization && !!articles.HS_SETUP_FIND_ORGANIZATION && ( - - )} - {organizationType === OrganizationTypesEnum.MINISTRY && ( - - - {t('You must log into {{appName}} with your ministry email', { - appName, - })} - - - {t( - 'This organization requires you to log into {{appName}} with your ministry email to access it.', - { appName }, - )} -
    -
  1. - {t('First you need to ')} - - {t( - 'click here to log out of your personal Key account', - )} - -
  2. -
  3. - {t('Next, ')} - { - signOut({ callbackUrl: 'signOut' }).then(() => { - clearDataDogUser(); - client.clearStore(); - }); - }} - > - {t('click here to log out of {{appName}}', { - appName, - })} - - {t( - ' so you can log back in with your official key account.', - )} -
  4. -
-
- - {t( - "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", - )} - {t( - "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", - { appName }, - )} - -
- )} - {organizationType === OrganizationTypesEnum.OAUTH && ( - - - {t( - "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", - { appName }, - )} - - - )} - {organizationType === OrganizationTypesEnum.LOGIN && ( - <> - - - - - - - - - - - - )} - - - - - {organizationType !== OrganizationTypesEnum.OAUTH && - t('Add Account')} - {organizationType === OrganizationTypesEnum.OAUTH && - t('Connect')} - - -
- )} -
+
); }; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx index 95f5fb211..ac35cbecf 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx @@ -13,7 +13,7 @@ import { import Modal from 'src/components/common/Modal/Modal'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useUpdateOrganizationAccountMutation } from '../Organizations.generated'; -import { OrganizationFormikSchema } from './OrganizationAddAccountModal'; +import { OrganizationFormikSchema } from '../schema'; interface OrganizationEditAccountModalProps { handleClose: () => void; diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx index bd5bff654..0067ab352 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -339,7 +339,6 @@ export const OrganizationAccordion: React.FC = ({ setShowAddAccountModal(false)} accountListId={accountListId} - refetchOrganizations={refetchOrganizations} /> )} diff --git a/src/components/Settings/integrations/Organization/schema.ts b/src/components/Settings/integrations/Organization/schema.ts new file mode 100644 index 000000000..8fb964693 --- /dev/null +++ b/src/components/Settings/integrations/Organization/schema.ts @@ -0,0 +1,56 @@ +import * as yup from 'yup'; +import { Organization } from 'src/graphql/types.generated'; +import { + OrganizationTypesEnum, + getOrganizationType, +} from './OrganizationAccordion'; + +export type OrganizationFormikSchema = { + selectedOrganization: Pick< + Organization, + | 'id' + | 'name' + | 'oauth' + | 'apiClass' + | 'giftAidPercentage' + | 'disableNewUsers' + >; + username: string | undefined; + password: string | undefined; +}; + +export const OrganizationSchema: yup.SchemaOf = + yup.object({ + selectedOrganization: yup + .object({ + id: yup.string().required(), + apiClass: yup.string().required(), + name: yup.string().required(), + oauth: yup.boolean().required(), + giftAidPercentage: yup.number().nullable(), + disableNewUsers: yup.boolean().nullable(), + }) + .required(), + username: yup + .string() + .when('selectedOrganization', (organization, schema) => { + if ( + getOrganizationType(organization?.apiClass, organization?.oauth) === + OrganizationTypesEnum.LOGIN + ) { + return schema.required('Must enter username'); + } + return schema; + }), + password: yup + .string() + .when('selectedOrganization', (organization, schema) => { + if ( + getOrganizationType(organization?.apiClass, organization?.oauth) === + OrganizationTypesEnum.LOGIN + ) { + return schema.required('Must enter password'); + } + return schema; + }), + }); From a349e506e78565fac5ca1aa31147278b17930af0 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 20 Aug 2024 13:40:22 -0500 Subject: [PATCH 2/8] Simplify action buttons Any button prop can now be overridden --- .../Modal/ActionButtons/ActionButtons.tsx | 86 ++++--------------- 1 file changed, 15 insertions(+), 71 deletions(-) diff --git a/src/components/common/Modal/ActionButtons/ActionButtons.tsx b/src/components/common/Modal/ActionButtons/ActionButtons.tsx index 58a2e685a..e2fa11787 100644 --- a/src/components/common/Modal/ActionButtons/ActionButtons.tsx +++ b/src/components/common/Modal/ActionButtons/ActionButtons.tsx @@ -7,109 +7,53 @@ const StyledButton = styled(Button)(() => ({ fontWeight: 700, })); -export interface ActionButtonProps { - onClick?: () => void; - size?: ButtonProps['size']; - color?: ButtonProps['color']; - disabled?: ButtonProps['disabled']; - type?: ButtonProps['type']; - sx?: ButtonProps['sx']; - variant?: ButtonProps['variant']; +export interface ActionButtonProps extends ButtonProps { dataTestId?: string; - children?: ButtonProps['children']; } export const ActionButton: React.FC = ({ - onClick, - size, - color, - disabled, - type, - sx, - variant = 'text', dataTestId = 'action-button', - children, -}) => { - return ( - - {children} - - ); -}; + ...props +}) => ; export const SubmitButton: React.FC = ({ - onClick, - size, - disabled, - type = 'submit', - variant, children, + ...props }) => { const { t } = useTranslation(); return ( - - {children ? children : t('Submit')} + + {children ?? t('Submit')} ); }; export const DeleteButton: React.FC = ({ - onClick, - size, - disabled, - variant, - sx = { marginRight: 'auto' }, + dataTestId = 'modal-delete-button', children, + ...props }) => { const { t } = useTranslation(); return ( - {children ? children : t('Delete')} + {children ?? t('Delete')} ); }; export const CancelButton: React.FC = ({ - onClick, - size, - disabled, - variant, children, + ...props }) => { const { t } = useTranslation(); return ( - - {children ? children : t('Cancel')} + + {children ?? t('Cancel')} ); }; From 9223adbdea0323b121d1b260e03b4a31433f5f88 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 20 Aug 2024 14:07:04 -0500 Subject: [PATCH 3/8] Make ConnectOrganization more customizable --- .../Organization/ConnectOrganization.tsx | 258 +++++++++--------- .../Modals/OrganizationAddAccountModal.tsx | 14 +- 2 files changed, 147 insertions(+), 125 deletions(-) diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx index 4d4bad1c2..4e45e182d 100644 --- a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx @@ -1,10 +1,10 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, ReactNode, useState } from 'react'; import { useApolloClient } from '@apollo/client'; import { Autocomplete, Box, Button, - DialogActions, + ButtonProps, Link, TextField, Typography, @@ -15,10 +15,6 @@ import { signOut } from 'next-auth/react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; -import { - CancelButton, - SubmitButton, -} from 'src/components/common/Modal/ActionButtons/ActionButtons'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { clearDataDogUser } from 'src/lib/dataDog'; import { articles, showArticle } from 'src/lib/helpScout'; @@ -37,8 +33,14 @@ import { OrganizationFormikSchema, OrganizationSchema } from './schema'; interface ConnectOrganizationProps { onDone: () => void; accountListId: string | undefined; + ButtonContainer?: React.FC<{ children: ReactNode }>; + CancelButton?: React.FC; + ConnectButton?: React.FC; + ContentContainer?: React.FC<{ children: ReactNode }>; } +const StyledForm = styled('form')({ width: '100%' }); + const StyledBox = styled(Box)(() => ({ padding: '0 10px', })); @@ -58,6 +60,10 @@ const StyledTypography = styled(Typography)(() => ({ export const ConnectOrganization: React.FC = ({ onDone, accountListId, + ButtonContainer = Box, + CancelButton = Button, + ConnectButton = Button, + ContentContainer = Box, }) => { const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); @@ -150,8 +156,8 @@ export const ConnectOrganization: React.FC = ({ isSubmitting, isValid, }): ReactElement => ( -
- + + = ({ /> )} /> - - {!selectedOrganization && !!articles.HS_SETUP_FIND_ORGANIZATION && ( - - )} - {organizationType === OrganizationTypesEnum.MINISTRY && ( - - - {t('You must log into {{appName}} with your ministry email', { - appName, - })} - - - {t( - 'This organization requires you to log into {{appName}} with your ministry email to access it.', - { appName }, - )} -
    + {t("Can't find your organization?")} + + )} + {organizationType === OrganizationTypesEnum.MINISTRY && ( + + -
  1. - {t('First you need to ')} - - {t('click here to log out of your personal Key account')} - -
  2. -
  3. - {t('Next, ')} - { - signOut({ callbackUrl: 'signOut' }).then(() => { - clearDataDogUser(); - client.clearStore(); - }); - }} - > - {t('click here to log out of {{appName}}', { - appName, - })} - - {t( - ' so you can log back in with your official key account.', - )} -
  4. -
-
- - {t( - "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", - )} - {t( - "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", - { appName }, - )} - -
- )} - {organizationType === OrganizationTypesEnum.OAUTH && ( - - - {t( - "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", - { appName }, - )} - - - )} - {organizationType === OrganizationTypesEnum.LOGIN && ( - <> - - - - - - - - + + {t( + 'This organization requires you to log into {{appName}} with your ministry email to access it.', + { appName }, + )} +
    - - - - )} - - - - +
  1. + {t('First you need to ')} + + {t( + 'click here to log out of your personal Key account', + )} + +
  2. +
  3. + {t('Next, ')} + { + signOut({ callbackUrl: 'signOut' }).then(() => { + clearDataDogUser(); + client.clearStore(); + }); + }} + > + {t('click here to log out of {{appName}}', { + appName, + })} + + {t( + ' so you can log back in with your official key account.', + )} +
  4. +
+
+ + {t( + "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", + )} + {t( + "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", + { appName }, + )} + + + )} + {organizationType === OrganizationTypesEnum.OAUTH && ( + + + {t( + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + { appName }, + )} + + + )} + {organizationType === OrganizationTypesEnum.LOGIN && ( + <> + + + + + + + + + + + + )} + + + + {t('Cancel')} + + = ({ {organizationType === OrganizationTypesEnum.OAUTH ? t('Connect') : t('Add Account')} - - - + + + )} ); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index 7778b31ee..7ce0c4edb 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -1,5 +1,10 @@ import React from 'react'; +import { DialogActions, DialogContent } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; import Modal from 'src/components/common/Modal/Modal'; import { ConnectOrganization } from '../ConnectOrganization'; @@ -20,7 +25,14 @@ export const OrganizationAddAccountModal: React.FC< handleClose={handleClose} size="sm" > - + ); }; From 2217798d816186b49c6da9ebdef20aab1a2985c9 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 20 Aug 2024 14:12:07 -0500 Subject: [PATCH 4/8] Remove unused accountListId prop --- .../Organization/ConnectOrganization.test.tsx | 12 ++++++------ .../Organization/ConnectOrganization.tsx | 6 ------ .../Modals/OrganizationAddAccountModal.test.tsx | 5 +---- .../Modals/OrganizationAddAccountModal.tsx | 4 +--- .../Organization/OrganizationAccordion.tsx | 1 - 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx index 1e7a6efd8..453ece41f 100644 --- a/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.test.tsx @@ -113,7 +113,7 @@ describe('Connect Organization', () => { const { getByText, getByRole, queryByRole } = render( - + , ); @@ -143,7 +143,7 @@ describe('Connect Organization', () => { }} onCall={mutationSpy} > - + , ); @@ -186,7 +186,7 @@ describe('Connect Organization', () => { }} onCall={mutationSpy} > - + , ); @@ -222,7 +222,7 @@ describe('Connect Organization', () => { }} onCall={mutationSpy} > - + , ); @@ -255,7 +255,7 @@ describe('Connect Organization', () => { }} onCall={mutationSpy} > - + , ); @@ -316,7 +316,7 @@ describe('Connect Organization', () => { }} onCall={mutationSpy} > - + , ); diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx index 4e45e182d..ac8d51017 100644 --- a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx @@ -32,7 +32,6 @@ import { OrganizationFormikSchema, OrganizationSchema } from './schema'; interface ConnectOrganizationProps { onDone: () => void; - accountListId: string | undefined; ButtonContainer?: React.FC<{ children: ReactNode }>; CancelButton?: React.FC; ConnectButton?: React.FC; @@ -59,7 +58,6 @@ const StyledTypography = styled(Typography)(() => ({ export const ConnectOrganization: React.FC = ({ onDone, - accountListId, ButtonContainer = Box, CancelButton = Button, ConnectButton = Button, @@ -91,10 +89,6 @@ export const ConnectOrganization: React.FC = ({ return; } - if (!accountListId) { - return; - } - const createAccountAttributes: { organizationId: string; password?: string; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index 30228e4f0..f3ad1bfc0 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -32,10 +32,7 @@ describe('OrganizationAddAccountModal', () => { - + , diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index 7ce0c4edb..ba6dd2786 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -10,12 +10,11 @@ import { ConnectOrganization } from '../ConnectOrganization'; interface OrganizationAddAccountModalProps { handleClose: () => void; - accountListId: string | undefined; } export const OrganizationAddAccountModal: React.FC< OrganizationAddAccountModalProps -> = ({ handleClose, accountListId }) => { +> = ({ handleClose }) => { const { t } = useTranslation(); return ( @@ -27,7 +26,6 @@ export const OrganizationAddAccountModal: React.FC< > = ({ {showAddAccountModal && ( setShowAddAccountModal(false)} - accountListId={accountListId} /> )} From 514930efe74f74f2129701c12f221bf73b45001f Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 20 Aug 2024 14:18:37 -0500 Subject: [PATCH 5/8] Simplify styled components --- .../Organization/ConnectOrganization.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx index ac8d51017..7a5338212 100644 --- a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx @@ -40,21 +40,21 @@ interface ConnectOrganizationProps { const StyledForm = styled('form')({ width: '100%' }); -const StyledBox = styled(Box)(() => ({ +const StyledBox = styled(Box)({ padding: '0 10px', -})); +}); -const WarningBox = styled(Box)(() => ({ +const WarningBox = styled(Box)({ padding: '15px', background: theme.palette.mpdxYellow.main, maxWidth: 'calc(100% - 20px)', margin: '10px auto 0', -})); +}); -const StyledTypography = styled(Typography)(() => ({ +const StyledTypography = styled(Typography)({ marginTop: '10px', color: theme.palette.mpdxYellow.dark, -})); +}); export const ConnectOrganization: React.FC = ({ onDone, From a716d703d23636c7b1ca8abdc7a14bc0aa90c522 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 26 Aug 2024 13:18:42 -0500 Subject: [PATCH 6/8] Add connect setup page --- pages/setup/connect.page.tsx | 28 +++++ src/components/Setup/Connect.tsx | 174 +++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 pages/setup/connect.page.tsx create mode 100644 src/components/Setup/Connect.tsx diff --git a/pages/setup/connect.page.tsx b/pages/setup/connect.page.tsx new file mode 100644 index 000000000..770dcbb03 --- /dev/null +++ b/pages/setup/connect.page.tsx @@ -0,0 +1,28 @@ +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +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. +const ConnectPage = (): ReactElement => { + const { t } = useTranslation(); + const { appName } = useGetAppSettings(); + + return ( + <> + + + {appName} | {t('Setup - Get Connected')} + + + + + ); +}; + +export const getServerSideProps = loadSession; + +export default ConnectPage; diff --git a/src/components/Setup/Connect.tsx b/src/components/Setup/Connect.tsx new file mode 100644 index 000000000..6ab6ef005 --- /dev/null +++ b/src/components/Setup/Connect.tsx @@ -0,0 +1,174 @@ +import { useRouter } from 'next/router'; +import React, { useCallback, useState } from 'react'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + Box, + ButtonProps, + CircularProgress, + IconButton, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { ConnectOrganization } from 'src/components/Settings/integrations/Organization/ConnectOrganization'; +import { + useDeleteOrganizationAccountMutation, + useGetUsersOrganizationsAccountsQuery, +} from 'src/components/Settings/integrations/Organization/Organizations.generated'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { SetupPage } from './SetupPage'; +import { LargeButton } from './styledComponents'; + +const ButtonGroup = styled(Box)(({ theme }) => ({ + width: '100%', + display: 'flex', + gap: theme.spacing(1), + '.MuiButton-root': { + flex: 1, + }, +})); + +const ButtonContainer = styled(ButtonGroup)(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +const ConnectButton = (props: ButtonProps) => ( + +); + +export const Connect: React.FC = () => { + const { t } = useTranslation(); + const { appName } = useGetAppSettings(); + const { enqueueSnackbar } = useSnackbar(); + const { push } = useRouter(); + + const { data, refetch } = useGetUsersOrganizationsAccountsQuery(); + const organizationAccounts = data?.userOrganizationAccounts; + const [deleteOrganizationAccount, { loading: deleting }] = + useDeleteOrganizationAccountMutation(); + + const [adding, setAdding] = useState(false); + + const handleDelete = async (organizationAccountId: string) => { + await deleteOrganizationAccount({ + variables: { + input: { + id: organizationAccountId, + }, + }, + onError: () => { + enqueueSnackbar( + t('{{appName}} could not remove your organization account', { + appName, + }), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t('{{appName}} removed your organization account', { appName }), + { + variant: 'success', + }, + ); + }, + }); + await refetch(); + }; + + const handleContinue = () => { + push('/setup/account'); + }; + + const CancelButton = useCallback( + (props: ButtonProps) => { + // Remove the cancel button when adding the first organization account + if (!organizationAccounts || !organizationAccounts.length) { + return null; + } + + return ; + }, + [organizationAccounts], + ); + + if (!organizationAccounts) { + return ; + } + + return ( + + {!organizationAccounts.length && ( + <> +

+ {t( + 'First, connect your organization to your {{appName}} account.', + { appName }, + )} +

+

+ {t( + 'This will allow {{appName}} to automatically synchronize your donation information.', + { appName }, + )} +

+ + )} + + {(adding || !organizationAccounts.length) && ( + setAdding(false)} + ButtonContainer={ButtonContainer} + CancelButton={CancelButton} + ConnectButton={ConnectButton} + /> + )} + + {!!organizationAccounts.length && !adding && ( + <> + {t("Sweet! You're connected.")} + {organizationAccounts.map(({ id, organization }) => ( +

+ {organization.name} + handleDelete(id)} + disabled={deleting} + aria-label={t('Disconnect organization')} + > + + +

+ ))} + {t( + 'Do you receive donations in any other country or from any other organizations?', + )} + + setAdding(true)} + disabled={deleting} + > + {t('Yes')} + + + {t('No')} + + + + )} +
+ ); +}; From a028cd1ed89f60b4838c7595cd2bb7a65734f26b Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 26 Aug 2024 14:22:00 -0500 Subject: [PATCH 7/8] Add tests --- pages/setup/connect.page.test.tsx | 29 ++++ .../Organization/ConnectOrganization.tsx | 6 +- src/components/Setup/Connect.test.tsx | 149 ++++++++++++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 pages/setup/connect.page.test.tsx create mode 100644 src/components/Setup/Connect.test.tsx diff --git a/pages/setup/connect.page.test.tsx b/pages/setup/connect.page.test.tsx new file mode 100644 index 000000000..a088d8ced --- /dev/null +++ b/pages/setup/connect.page.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import Connect from './connect.page'; + +const push = jest.fn(); +const router = { + push, +}; + +describe('Setup connect page', () => { + it('renders', async () => { + const mutationSpy = jest.fn(); + const { findByRole } = render( + + + + + + + , + ); + + expect( + await findByRole('heading', { name: "It's time for awesome!" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx index 7a5338212..9fd85eb42 100644 --- a/src/components/Settings/integrations/Organization/ConnectOrganization.tsx +++ b/src/components/Settings/integrations/Organization/ConnectOrganization.tsx @@ -175,11 +175,7 @@ export const ConnectOrganization: React.FC = ({ } fullWidth renderInput={(params) => ( - + )} /> {!selectedOrganization && !!articles.HS_SETUP_FIND_ORGANIZATION && ( diff --git a/src/components/Setup/Connect.test.tsx b/src/components/Setup/Connect.test.tsx new file mode 100644 index 000000000..32dd3335a --- /dev/null +++ b/src/components/Setup/Connect.test.tsx @@ -0,0 +1,149 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { + GetOrganizationsQuery, + GetUsersOrganizationsAccountsQuery, +} from '../Settings/integrations/Organization/Organizations.generated'; +import { Connect } from './Connect'; + +const push = jest.fn(); +const router = { + push, +}; + +interface TestComponentProps { + hasOrganizations?: boolean; +} + +const mutationSpy = jest.fn(); +const TestComponent: React.FC = ({ + hasOrganizations = true, +}) => ( + + + + mocks={{ + GetUsersOrganizationsAccounts: { + userOrganizationAccounts: hasOrganizations + ? [ + { + id: 'organization-a', + organization: { name: 'Organization A' }, + }, + ] + : [], + }, + getOrganizations: { + organizations: [1, 2, 3].map((id) => ({ + id: `org-${id}`, + name: `Organization ${id}`, + disableNewUsers: false, + })), + }, + }} + onCall={mutationSpy} + > + + + + +); + +describe('Connect', () => { + it('renders loading spinner', () => { + const { getByRole } = render(); + + expect(getByRole('progressbar')).toBeInTheDocument(); + }); + + describe('with no connected organizations', () => { + it('renders header, organization picker, and no cancel button', async () => { + const { findByRole, getByRole, getByText, queryByRole } = render( + , + ); + + expect( + await findByRole('heading', { name: "It's time to connect!" }), + ).toBeInTheDocument(); + expect( + getByText( + 'First, connect your organization to your {{appName}} account.', + ), + ).toBeInTheDocument(); + expect( + getByRole('combobox', { name: 'Organization' }), + ).toBeInTheDocument(); + expect(queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + }); + + it('add button connects the organization and refreshes the list', async () => { + const { findByRole, getByRole } = render( + , + ); + + userEvent.click(await findByRole('combobox', { name: 'Organization' })); + mutationSpy.mockClear(); + userEvent.click(getByRole('option', { name: 'Organization 1' })); + userEvent.click(getByRole('button', { name: 'Add Account' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'CreateOrganizationAccount', + { input: { attributes: { organizationId: 'org-1' } } }, + ), + ); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'GetUsersOrganizationsAccounts', + ), + ); + }); + }); + + describe('with connected organizations', () => { + it('renders header and organization list', async () => { + const { findByRole, getByText } = render(); + + expect( + await findByRole('heading', { name: "It's time for awesome!" }), + ).toBeInTheDocument(); + expect(getByText('Organization A')).toBeInTheDocument(); + }); + + it('trash can icon disconnects the organization', async () => { + const { findByRole } = render(); + + userEvent.click( + await findByRole('button', { name: 'Disconnect organization' }), + ); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'DeleteOrganizationAccount', + { input: { id: 'organization-a' } }, + ), + ); + }); + + it('yes button opens organization picker', async () => { + const { findByRole, getByRole } = render(); + + userEvent.click(await findByRole('button', { name: 'Yes' })); + expect( + getByRole('combobox', { name: 'Organization' }), + ).toBeInTheDocument(); + }); + + it('no button navigates to the next step', async () => { + const { findByRole } = render(); + + userEvent.click(await findByRole('button', { name: 'No' })); + expect(push).toHaveBeenCalledWith('/setup/account'); + }); + }); +}); From 2490da993cb45638b4c7453c3db969ae13056f9b Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 27 Aug 2024 15:24:58 -0500 Subject: [PATCH 8/8] Put loading spinner inside of the card --- src/components/Setup/Connect.test.tsx | 10 +++++----- src/components/Setup/Connect.tsx | 13 +++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/components/Setup/Connect.test.tsx b/src/components/Setup/Connect.test.tsx index 32dd3335a..6057de199 100644 --- a/src/components/Setup/Connect.test.tsx +++ b/src/components/Setup/Connect.test.tsx @@ -64,18 +64,18 @@ describe('Connect', () => { describe('with no connected organizations', () => { it('renders header, organization picker, and no cancel button', async () => { - const { findByRole, getByRole, getByText, queryByRole } = render( + const { findByText, getByRole, queryByRole } = render( , ); expect( - await findByRole('heading', { name: "It's time to connect!" }), - ).toBeInTheDocument(); - expect( - getByText( + await findByText( 'First, connect your organization to your {{appName}} account.', ), ).toBeInTheDocument(); + expect( + getByRole('heading', { name: "It's time to connect!" }), + ).toBeInTheDocument(); expect( getByRole('combobox', { name: 'Organization' }), ).toBeInTheDocument(); diff --git a/src/components/Setup/Connect.tsx b/src/components/Setup/Connect.tsx index 6ab6ef005..c316b8c5e 100644 --- a/src/components/Setup/Connect.tsx +++ b/src/components/Setup/Connect.tsx @@ -95,19 +95,16 @@ export const Connect: React.FC = () => { [organizationAccounts], ); - if (!organizationAccounts) { - return ; - } - return ( - {!organizationAccounts.length && ( + {!organizationAccounts && } + {organizationAccounts?.length === 0 && ( <>

{t( @@ -124,7 +121,7 @@ export const Connect: React.FC = () => { )} - {(adding || !organizationAccounts.length) && ( + {(adding || organizationAccounts?.length === 0) && ( setAdding(false)} ButtonContainer={ButtonContainer} @@ -133,7 +130,7 @@ export const Connect: React.FC = () => { /> )} - {!!organizationAccounts.length && !adding && ( + {organizationAccounts && !!organizationAccounts.length && !adding && ( <> {t("Sweet! You're connected.")} {organizationAccounts.map(({ id, organization }) => (