From 722be7ba59f014b843a7215568c9b88e6379cbbf Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 10 Nov 2023 08:54:37 -0500 Subject: [PATCH 01/53] Adding shared forms that we use on the Preferences --- .../Forms/Accordions/AccordianGroup.test.tsx | 13 ++ .../Forms/Accordions/AccordionGroup.tsx | 21 +++ .../Forms/Accordions/AccordionItem.test.tsx | 152 ++++++++++++++++ .../Shared/Forms/Accordions/AccordionItem.tsx | 169 ++++++++++++++++++ .../Shared/Forms/DialogActions.test.tsx | 26 +++ src/components/Shared/Forms/DialogActions.tsx | 16 ++ src/components/Shared/Forms/FieldHelper.tsx | 33 ++++ .../Shared/Forms/FieldWrapper.test.tsx | 69 +++++++ src/components/Shared/Forms/FieldWrapper.tsx | 72 ++++++++ 9 files changed, 571 insertions(+) create mode 100644 src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx create mode 100644 src/components/Shared/Forms/Accordions/AccordionGroup.tsx create mode 100644 src/components/Shared/Forms/Accordions/AccordionItem.test.tsx create mode 100644 src/components/Shared/Forms/Accordions/AccordionItem.tsx create mode 100644 src/components/Shared/Forms/DialogActions.test.tsx create mode 100644 src/components/Shared/Forms/DialogActions.tsx create mode 100644 src/components/Shared/Forms/FieldHelper.tsx create mode 100644 src/components/Shared/Forms/FieldWrapper.test.tsx create mode 100644 src/components/Shared/Forms/FieldWrapper.tsx diff --git a/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx b/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx new file mode 100644 index 000000000..682243e09 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx @@ -0,0 +1,13 @@ +import { render } from '@testing-library/react'; +import { AccordionGroup } from './AccordionGroup'; + +describe('AccordionGroup', () => { + it('Should load title and children', () => { + const { getByText } = render( + Children, + ); + + expect(getByText('AccordionGroupTitle')).toBeInTheDocument(); + expect(getByText('Children')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shared/Forms/Accordions/AccordionGroup.tsx b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx new file mode 100644 index 000000000..3c3a93cb4 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +interface AccordionGroupProps { + title: string; + children?: React.ReactNode; +} + +export const AccordionGroup: React.FC = ({ + title, + children, +}) => { + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx new file mode 100644 index 000000000..03b6f4fbc --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx @@ -0,0 +1,152 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { AccordionItem } from './AccordionItem'; + +const expandedPanel = 'expandedPanel'; + +describe('AccordionItem', () => { + const onAccordionChange = jest.fn(); + beforeEach(() => { + onAccordionChange.mockClear(); + }); + it('Should not render Accordian Details', () => { + const { queryByText } = render( + + + Children + + , + ); + + expect(queryByText('Children')).not.toBeInTheDocument(); + }); + it('Should not render value', () => { + const { queryByTestId } = render( + + + Children + + , + ); + + expect(queryByTestId('AccordionSummaryValue')).not.toBeInTheDocument(); + }); + + it('Should render label', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('expandedPanel')).toBeInTheDocument(); + }); + + it('Should render value', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('AccordianValue')).toBeInTheDocument(); + }); + + it('Should render Accordian Details', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render Children with FullWidth', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render Children and Image', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + expect(getByText('image.png')).toBeInTheDocument(); + }); + it('Should run onAccordionChange()', () => { + const { getByTestId } = render( + + + Children + + , + ); + + userEvent.click(getByTestId('AccordionSummaryValue')); + expect(onAccordionChange).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.tsx new file mode 100644 index 000000000..47981f7b6 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionItem.tsx @@ -0,0 +1,169 @@ +import React, { useMemo } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { ExpandMore } from '@mui/icons-material'; + +export const accordionShared = { + '&:before': { + content: 'none', + }, + '& .MuiAccordionSummary-root.Mui-expanded': { + minHeight: 'unset', + }, +}; + +const StyledAccordion = styled(Accordion)(() => ({ + overflow: 'hidden', + ...accordionShared, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + '&.Mui-expanded': { + backgroundColor: theme.palette.mpdxYellow.main, + }, + '& .MuiAccordionSummary-content': { + [theme.breakpoints.only('xs')]: { + flexDirection: 'column', + }, + }, +})); + +const StyledAccordionColumn = styled(Box)(({ theme }) => ({ + paddingRight: theme.spacing(2), + flexBasis: '100%', + [theme.breakpoints.only('xs')]: { + '&:nth-child(2)': { + fontStyle: 'italic', + }, + }, + [theme.breakpoints.up('md')]: { + '&:first-child:not(:last-child)': { + flexBasis: '33.33%', + }, + '&:nth-child(2)': { + flexBasis: '66.66%', + }, + }, +})); + +const StyledAccordionDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + flexBasis: 'calc((100% - 36px) * 0.661)', + marginLeft: 'calc((100% - 36px) * 0.338)', + }, +})); + +const AccordionLeftDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.338)', + }, + [theme.breakpoints.down('md')]: { + width: '200px', + }, + [theme.breakpoints.down('sm')]: { + marginBottom: '10px', + width: '100%', + }, +})); + +const AccordionRightDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.661)', + }, + [theme.breakpoints.down('md')]: { + width: 'calc(100% - 200px)', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, +})); + +const AccordionLImageDetails = styled(Box)(({ theme }) => ({ + display: 'flex', + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, +})); + +const AccordionLeftDetailsImage = styled(Box)(({ theme }) => ({ + maxWidth: '200px', + ' & > img': { + width: '100%', + }, + + [theme.breakpoints.down('md')]: { + ' & > img': { + maxWidth: '150px', + }, + }, + [theme.breakpoints.down('sm')]: { + ' & > img': { + maxWidth: '100px', + }, + }, +})); + +interface AccordionItemProps { + onAccordionChange: (label: string) => void; + expandedPanel: string; + label: string; + value: string; + children?: React.ReactNode; + fullWidth?: boolean; + image?: React.ReactNode; +} + +export const AccordionItem: React.FC = ({ + onAccordionChange, + expandedPanel, + label, + value, + children, + fullWidth = false, + image, +}) => { + const expanded = useMemo( + () => expandedPanel.toLowerCase() === label.toLowerCase(), + [expandedPanel, label], + ); + return ( + onAccordionChange(label)} + expanded={expanded} + disableGutters + > + }> + + {label} + + {value && ( + + {value} + + )} + + {expanded && ( + + {!fullWidth && !image && ( + {children} + )} + {fullWidth && !image && {children}} + {image && ( + + + {image} + + {children} + + )} + + )} + + ); +}; diff --git a/src/components/Shared/Forms/DialogActions.test.tsx b/src/components/Shared/Forms/DialogActions.test.tsx new file mode 100644 index 000000000..5249b87e2 --- /dev/null +++ b/src/components/Shared/Forms/DialogActions.test.tsx @@ -0,0 +1,26 @@ +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { DialogActionsLeft } from './DialogActions'; + +describe('DialogActionsLeft', () => { + it('Should render children', () => { + const { getByText } = render( + + Children + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should pass down args', () => { + const { getByTestId } = render( + + Children + , + ); + + expect(getByTestId('dataTestId')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shared/Forms/DialogActions.tsx b/src/components/Shared/Forms/DialogActions.tsx new file mode 100644 index 000000000..8f9ac2e4d --- /dev/null +++ b/src/components/Shared/Forms/DialogActions.tsx @@ -0,0 +1,16 @@ +import { DialogActions, DialogActionsProps } from '@mui/material'; +import React from 'react'; + +export const DialogActionsLeft: React.FC = ({ + children, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/FieldHelper.tsx b/src/components/Shared/Forms/FieldHelper.tsx new file mode 100644 index 000000000..529a8a170 --- /dev/null +++ b/src/components/Shared/Forms/FieldHelper.tsx @@ -0,0 +1,33 @@ +import { FormLabel, FormHelperText, OutlinedInput, Theme } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export enum helperPositionEnum { + Top = 'top', + Bottom = 'bottom', +} + +const SharedFieldStyles = ({ theme }: { theme: Theme }) => ({ + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +}); + +export const StyledOutlinedInput = styled(OutlinedInput)(SharedFieldStyles); + +export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 700, + marginBottom: theme.spacing(1), + '& .MuiFormControlLabel-label': { + fontWeight: '700', + }, +})); + +export const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + margin: 0, + fontSize: 16, + color: theme.palette.text.primary, + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +})); diff --git a/src/components/Shared/Forms/FieldWrapper.test.tsx b/src/components/Shared/Forms/FieldWrapper.test.tsx new file mode 100644 index 000000000..99d1f275c --- /dev/null +++ b/src/components/Shared/Forms/FieldWrapper.test.tsx @@ -0,0 +1,69 @@ +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { FieldWrapper } from './FieldWrapper'; +import { helperPositionEnum } from './FieldHelper'; + +describe('FieldWrapper', () => { + it('Should render children', () => { + const { getByText } = render( + + Children + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render labelText', () => { + const { getByText } = render( + + + , + ); + + expect(getByText('labelText')).toBeInTheDocument(); + }); + + it('Should render Helper Text before Children', () => { + const { getByTestId, getByText } = render( + + +

Children

+
+
, + ); + + expect( + getByTestId('helper-text-top').compareDocumentPosition( + getByText('Children'), + ), + ).toBe(4); + }); + it('Should render Helper Text after Children', () => { + const { getByTestId, getByText } = render( + + +

Children

+
+
, + ); + + expect( + getByTestId('helper-text-bottom').compareDocumentPosition( + getByText('Children'), + ), + ).toBe(2); + }); +}); diff --git a/src/components/Shared/Forms/FieldWrapper.tsx b/src/components/Shared/Forms/FieldWrapper.tsx new file mode 100644 index 000000000..e8fd13ec4 --- /dev/null +++ b/src/components/Shared/Forms/FieldWrapper.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FormControl, FormControlProps } from '@mui/material'; +import { + helperPositionEnum, + StyledFormHelperText, + StyledFormLabel, +} from './FieldHelper'; + +interface FieldWrapperProps { + labelText?: string; + helperText?: string; + helperPosition?: helperPositionEnum; + formControlDisabled?: FormControlProps['disabled']; + formControlError?: FormControlProps['error']; + formControlFullWidth?: FormControlProps['fullWidth']; + formControlRequired?: FormControlProps['required']; + formControlVariant?: FormControlProps['variant']; + formHelperTextProps?: object; + children?: React.ReactNode; +} + +export const FieldWrapper: React.FC = ({ + labelText = '', + helperText = '', + helperPosition = helperPositionEnum.Top, + formControlDisabled = false, + formControlError = false, + formControlFullWidth = true, + formControlRequired = false, + formControlVariant = 'outlined', + formHelperTextProps = { variant: 'standard' }, + children, +}) => { + const { t } = useTranslation(); + const labelOutput = labelText ? ( + + {t(labelText)} + + ) : ( + '' + ); + + const helperTextOutput = helperText ? ( + + {t(helperText)} + + ) : ( + '' + ); + + return ( + + {labelOutput} + {helperPosition === helperPositionEnum.Top && helperTextOutput} + {children} + {helperPosition === helperPositionEnum.Bottom && helperTextOutput} + + ); +}; From 6f8940cffe1b3be5efee1688f2c2be3aec4b8a6f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 10:07:23 -0500 Subject: [PATCH 02/53] Fixing small errors and combining tests --- .../Shared/Forms/DialogActions.test.tsx | 15 +++------------ src/components/Shared/Forms/FieldHelper.tsx | 2 +- src/components/Shared/Forms/FieldWrapper.test.tsx | 4 ++-- src/components/Shared/Forms/FieldWrapper.tsx | 10 +++++----- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/components/Shared/Forms/DialogActions.test.tsx b/src/components/Shared/Forms/DialogActions.test.tsx index 5249b87e2..cd549b1bb 100644 --- a/src/components/Shared/Forms/DialogActions.test.tsx +++ b/src/components/Shared/Forms/DialogActions.test.tsx @@ -4,23 +4,14 @@ import theme from 'src/theme'; import { DialogActionsLeft } from './DialogActions'; describe('DialogActionsLeft', () => { - it('Should render children', () => { - const { getByText } = render( - - Children - , - ); - - expect(getByText('Children')).toBeInTheDocument(); - }); - - it('Should pass down args', () => { - const { getByTestId } = render( + it('Should render children and pass down args', () => { + const { getByText, getByTestId } = render( Children , ); + expect(getByText('Children')).toBeInTheDocument(); expect(getByTestId('dataTestId')).toBeInTheDocument(); }); }); diff --git a/src/components/Shared/Forms/FieldHelper.tsx b/src/components/Shared/Forms/FieldHelper.tsx index 529a8a170..912cf12fb 100644 --- a/src/components/Shared/Forms/FieldHelper.tsx +++ b/src/components/Shared/Forms/FieldHelper.tsx @@ -1,7 +1,7 @@ import { FormLabel, FormHelperText, OutlinedInput, Theme } from '@mui/material'; import { styled } from '@mui/material/styles'; -export enum helperPositionEnum { +export enum HelperPositionEnum { Top = 'top', Bottom = 'bottom', } diff --git a/src/components/Shared/Forms/FieldWrapper.test.tsx b/src/components/Shared/Forms/FieldWrapper.test.tsx index 99d1f275c..7c26c66f2 100644 --- a/src/components/Shared/Forms/FieldWrapper.test.tsx +++ b/src/components/Shared/Forms/FieldWrapper.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import theme from 'src/theme'; import { FieldWrapper } from './FieldWrapper'; -import { helperPositionEnum } from './FieldHelper'; +import { HelperPositionEnum } from './FieldHelper'; describe('FieldWrapper', () => { it('Should render children', () => { @@ -50,7 +50,7 @@ describe('FieldWrapper', () => { = ({ labelText = '', helperText = '', - helperPosition = helperPositionEnum.Top, + helperPosition = HelperPositionEnum.Top, formControlDisabled = false, formControlError = false, formControlFullWidth = true, @@ -64,9 +64,9 @@ export const FieldWrapper: React.FC = ({ variant={formControlVariant} > {labelOutput} - {helperPosition === helperPositionEnum.Top && helperTextOutput} + {helperPosition === HelperPositionEnum.Top && helperTextOutput} {children} - {helperPosition === helperPositionEnum.Bottom && helperTextOutput} + {helperPosition === HelperPositionEnum.Bottom && helperTextOutput} ); }; From 55165f8910ec6e7fde9e06a6a32930b26822fe35 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:18:07 -0500 Subject: [PATCH 03/53] Creating page and context --- next.config.js | 4 + .../integrations/integrations.page.tsx | 94 +++++++++++++++++++ .../integrations/integrationsContext.tsx | 21 +++++ .../[accountListId]/settings/wrapper.tsx | 74 +++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx create mode 100644 pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx create mode 100644 pages/accountLists/[accountListId]/settings/wrapper.tsx diff --git a/next.config.js b/next.config.js index 21556856c..00e965300 100644 --- a/next.config.js +++ b/next.config.js @@ -50,6 +50,7 @@ module.exports = withPlugins([ API_URL: process.env.API_URL ?? 'https://api.stage.mpdx.org/graphql', REST_API_URL: process.env.REST_API_URL ?? 'https://api.stage.mpdx.org/api/v2/', + OAUTH_URL: process.env.OAUTH_URL ?? 'https://auth.stage.mpdx.org', SITE_URL: siteUrl, CLIENT_ID: process.env.CLIENT_ID ?? '4027334344069527005', CLIENT_SECRET: process.env.CLIENT_SECRET, @@ -94,6 +95,9 @@ module.exports = withPlugins([ HS_HOME_SUGGESTIONS: process.env.HS_HOME_SUGGESTIONS, HS_REPORTS_SUGGESTIONS: process.env.HS_REPORTS_SUGGESTIONS, HS_TASKS_SUGGESTIONS: process.env.HS_TASKS_SUGGESTIONS, + HS_SETTINGS_SERVICES_SUGGESTIONS: + process.env.HS_SETTINGS_SERVICES_SUGGESTIONS, + HS_SETUP_FIND_ORGANIZATION: process.env.HS_SETUP_FIND_ORGANIZATION, ALERT_MESSAGE: process.env.ALERT_MESSAGE, }, experimental: { diff --git a/pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx new file mode 100644 index 000000000..ac55320ac --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx @@ -0,0 +1,94 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GetServerSideProps } from 'next'; +import { getToken } from 'next-auth/jwt'; +import { suggestArticles } from 'src/lib/helpScout'; +import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; +import { TheKeyAccordian } from 'src/components/Settings/integrations/Key/TheKeyAccordian'; +import { OrganizationAccordian } from 'src/components/Settings/integrations/Organization/OrganizationAccordian'; +import { GoogleAccordian } from 'src/components/Settings/integrations/Google/GoogleAccordian'; +import { MailchimpAccordian } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccordian'; +import { PrayerlettersAccordian } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian'; +import { ChalklineAccordian } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordian'; +import { SettingsWrapper } from '../wrapper'; +import { IntegrationsContextProvider } from './integrationsContext'; + +interface Props { + apiToken: string; + selectedTab: string; +} + +const Integrations = ({ apiToken, selectedTab }: Props): ReactElement => { + const { t } = useTranslation(); + const [expandedPanel, setExpandedPanel] = useState(''); + + useEffect(() => { + suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); + setExpandedPanel(selectedTab); + }, []); + + const handleAccordionChange = (panel: string) => { + const panelLowercase = panel.toLowerCase(); + setExpandedPanel(expandedPanel === panelLowercase ? '' : panelLowercase); + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async ({ + query, + req, +}) => { + const jwtToken = (await getToken({ + req, + secret: process.env.JWT_SECRET as string, + })) as { apiToken: string } | null; + + const apiToken = jwtToken?.apiToken; + const selectedTab = query?.selectedTab ?? ''; + + return { + props: { + apiToken, + selectedTab, + }, + }; +}; + +export default Integrations; diff --git a/pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx b/pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx new file mode 100644 index 000000000..80afc801c --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export type IntegrationsContextType = { + apiToken: string; +}; +export const IntegrationsContext = + React.createContext(null); + +interface IntegrationsContextProviderProps { + children: React.ReactNode; + apiToken: string; +} +export const IntegrationsContextProvider: React.FC< + IntegrationsContextProviderProps +> = ({ children, apiToken }) => { + return ( + + {children} + + ); +}; diff --git a/pages/accountLists/[accountListId]/settings/wrapper.tsx b/pages/accountLists/[accountListId]/settings/wrapper.tsx new file mode 100644 index 000000000..558503ac6 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/wrapper.tsx @@ -0,0 +1,74 @@ +import { Box, Container } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import React, { useState } from 'react'; +import Head from 'next/head'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; +import { + MultiPageHeader, + HeaderTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; + +const PageContentWrapper = styled(Container)(({ theme }) => ({ + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), +})); + +interface SettingsWrapperProps { + pageTitle: string; + pageHeading: string; + children?: React.ReactNode; +} + +export const SettingsWrapper: React.FC = ({ + pageTitle, + pageHeading, + children, +}) => { + const { appName } = useGetAppSettings(); + const [isNavListOpen, setNavListOpen] = useState(false); + const handleNavListToggle = () => { + setNavListOpen(!isNavListOpen); + }; + + return ( + <> + + + {appName} | {pageTitle} + + + + + } + leftOpen={isNavListOpen} + leftWidth="290px" + mainContent={ + <> + + {children} + + } + /> + + + ); +}; From 2881f90ace76c33dd4b744ee4782a07f68b2a113 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:19:02 -0500 Subject: [PATCH 04/53] Helper functions also added variables to helpscout --- src/lib/getErrorFromCatch.ts | 6 ++++++ src/lib/helpScout.ts | 13 ++++++++++--- src/lib/snakeToCamel.ts | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/lib/getErrorFromCatch.ts create mode 100644 src/lib/snakeToCamel.ts diff --git a/src/lib/getErrorFromCatch.ts b/src/lib/getErrorFromCatch.ts new file mode 100644 index 000000000..65972171c --- /dev/null +++ b/src/lib/getErrorFromCatch.ts @@ -0,0 +1,6 @@ +export const getErrorMessage = (err: unknown) => { + let message; + if (err instanceof Error) message = err.message; + else message = String(err); + return message; +}; diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 81f49782e..fa21ac9d1 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -39,15 +39,22 @@ export const identifyUser = (id: string, email: string, name: string) => { }); }; -export const suggestArticles = (envVar: keyof typeof env) => { - const articleIds = env[envVar]; +export const showArticle = (articleId) => { + callBeacon('article', articleId); +}; + +export const suggestArticles = (envVar: keyof typeof variables) => { + const articleIds = variables[envVar]; callBeacon('suggest', articleIds?.split(',') ?? []); }; -const env = { +export const variables = { HS_CONTACTS_SUGGESTIONS: process.env.HS_CONTACTS_SUGGESTIONS, HS_CONTACTS_CONTACT_SUGGESTIONS: process.env.HS_CONTACTS_CONTACT_SUGGESTIONS, HS_HOME_SUGGESTIONS: process.env.HS_HOME_SUGGESTIONS, HS_REPORTS_SUGGESTIONS: process.env.HS_REPORTS_SUGGESTIONS, HS_TASKS_SUGGESTIONS: process.env.HS_TASKS_SUGGESTIONS, + HS_SETTINGS_SERVICES_SUGGESTIONS: + process.env.HS_SETTINGS_SERVICES_SUGGESTIONS, + HS_SETUP_FIND_ORGANIZATION: process.env.HS_SETUP_FIND_ORGANIZATION, }; diff --git a/src/lib/snakeToCamel.ts b/src/lib/snakeToCamel.ts new file mode 100644 index 000000000..7d57a6bfd --- /dev/null +++ b/src/lib/snakeToCamel.ts @@ -0,0 +1,15 @@ +export const snakeToCamel = (inputKey: string): string => { + const stringParts = inputKey.split('_'); + + return stringParts.reduce((outputKey, part, index) => { + if (index === 0) { + return part; + } + + return `${outputKey}${part.charAt(0).toUpperCase()}${part.slice(1)}`; + }, ''); +}; + +export const camelToSnake = (inputKey: string): string => { + return inputKey.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +}; From 4fda0ee12d7cffcb0ca6a6b0c34884e7f0f74fc1 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:21:02 -0500 Subject: [PATCH 05/53] GraphQL Proxys needed for functionailty. --- .../Chalkine/sendToChalkline/datahandler.ts | 3 + .../Chalkine/sendToChalkline/resolvers.ts | 15 + .../sendToChalkline/sendToChalkline.graphql | 7 + .../createGoogleIntegration.graphql | 29 ++ .../createGoogleIntegration/datahandler.ts | 55 ++++ .../createGoogleIntegration/resolvers.ts | 19 ++ .../Google/deleteGoogleAccount/datahandler.ts | 9 + .../deleteGoogleAccount.graphql | 13 + .../Google/deleteGoogleAccount/resolvers.ts | 15 + .../datahandler.ts | 56 ++++ .../getGoogleAccountIntegrations.graphql | 27 ++ .../getGoogleAccountIntegrations/resolvers.ts | 18 ++ .../Google/getGoogleAccounts/datahandler.ts | 53 ++++ .../getGoogleAccounts.graphql | 17 ++ .../Google/getGoogleAccounts/resolvers.ts | 11 + .../syncGoogleIntegration/datahandler.ts | 3 + .../Google/syncGoogleIntegration/resolvers.ts | 19 ++ .../syncGoogleIntegration.graphql | 9 + .../updateGoogleIntegration/datahandler.ts | 58 ++++ .../updateGoogleIntegration/resolvers.ts | 19 ++ .../updateGoogleIntegration.graphql | 11 + .../deleteMailchimpAccount/datahandler.ts | 3 + .../deleteMailchimpAccount.graphql | 7 + .../deleteMailchimpAccount/resolvers.ts | 15 + .../getMailchimpAccount/datahandler.ts | 64 +++++ .../getMailchimpAccount.graphql | 29 ++ .../getMailchimpAccount/resolvers.ts | 15 + .../syncMailchimpAccount/datahandler.ts | 3 + .../syncMailchimpAccount/resolvers.ts | 15 + .../syncMailchimpAccount.graphql | 7 + .../updateMailchimpAccount/datahandler.ts | 59 ++++ .../updateMailchimpAccount/resolvers.ts | 19 ++ .../updateMailchimpAccount.graphql | 14 + .../deletePrayerlettersAccount/datahandler.ts | 3 + .../deletePrayerlettersAccount.graphql | 7 + .../deletePrayerlettersAccount/resolvers.ts | 15 + .../getPrayerlettersAccount/datahandler.ts | 32 +++ .../getPrayerlettersAccount.graphql | 13 + .../getPrayerlettersAccount/resolvers.ts | 15 + .../syncPrayerlettersAccount/datahandler.ts | 3 + .../syncPrayerlettersAccount/resolvers.ts | 15 + .../syncPrayerlettersAccount.graphql | 7 + .../api/Schema/SubgraphSchema/Integrations.ts | 112 ++++++++ pages/api/Schema/index.ts | 2 + pages/api/graphql-rest.page.ts | 258 +++++++++++++++++- 45 files changed, 1187 insertions(+), 11 deletions(-) create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql create mode 100644 pages/api/Schema/SubgraphSchema/Integrations.ts diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts new file mode 100644 index 000000000..33d68440d --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts @@ -0,0 +1,3 @@ +export const SendToChalkline = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts new file mode 100644 index 000000000..f60d9e844 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SendToChalklineResolvers: Resolvers = { + Mutation: { + sendToChalkline: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.sendToChalkline(accountListId); + }, + }, +}; + +export { SendToChalklineResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql new file mode 100644 index 000000000..6beaa05d3 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + sendToChalkline(input: SendToChalklineInput!): String! +} + +input SendToChalklineInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql new file mode 100644 index 000000000..a16638ce6 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql @@ -0,0 +1,29 @@ +extend type Mutation { + createGoogleIntegration( + input: CreateGoogleIntegrationInput! + ): GoogleAccountIntegration! +} + +input CreateGoogleIntegrationInput { + googleAccountId: ID! + googleIntegration: GoogleAccountIntegrationInput + accountListID: String! +} + +input GoogleAccountIntegrationInput { + overwrite: Boolean + calendarIntegration: Boolean + calendarId: String + calendarIntegrations: [String] + calendarName: String + calendars: [GoogleAccountIntegrationCalendarsInput] + createdAt: String + updatedAt: String + id: String + updatedInDbAt: String +} + +input GoogleAccountIntegrationCalendarsInput { + id: String + name: String +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..b8d0ff624 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts @@ -0,0 +1,55 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface CreateGoogleIntegrationResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} + +type relationships = { + account_list: object[]; + google_account: object[]; +}; + +export interface CreateGoogleIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} + +interface CreateGoogleIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const CreateGoogleIntegration = ( + data: CreateGoogleIntegrationResponse, +): CreateGoogleIntegrationAttributesCamel => { + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..39c6501bf --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const CreateGoogleIntegrationResolvers: Resolvers = { + Mutation: { + createGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegration, accountListID } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.createGoogleIntegration( + googleAccountId, + googleIntegration, + accountListID, + ); + }, + }, +}; + +export { CreateGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts new file mode 100644 index 000000000..09b7e2acb --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts @@ -0,0 +1,9 @@ +export type DeleteGoogleAccountResponse = { + success: boolean; +}; + +export const DeleteGoogleAccount = (): DeleteGoogleAccountResponse => { + return { + success: true, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql new file mode 100644 index 000000000..48e0e4558 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql @@ -0,0 +1,13 @@ +extend type Mutation { + deleteGoogleAccount( + input: DeleteGoogleAccountInput! + ): GoogleAccountDeletionResponse! +} + +input DeleteGoogleAccountInput { + accountId: ID! +} + +type GoogleAccountDeletionResponse { + success: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts new file mode 100644 index 000000000..2a97f2e41 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeleteGoogleAccountResolvers: Resolvers = { + Mutation: { + deleteGoogleAccount: async ( + _source, + { input: { accountId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deleteGoogleAccount(accountId); + }, + }, +}; + +export { DeleteGoogleAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts new file mode 100644 index 000000000..ef6d46800 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts @@ -0,0 +1,56 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetGoogleAccountIntegrationsResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} +type relationships = { + account_list: object[]; + google_account: object[]; +}; +export interface GetGoogleAccountIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} +interface GetGoogleAccountIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const GetGoogleAccountIntegrations = ( + data: GetGoogleAccountIntegrationsResponse[], +): GetGoogleAccountIntegrationAttributesCamel[] => { + return data.reduce( + (prev: GetGoogleAccountIntegrationAttributesCamel[], current) => { + const attributes = {} as Omit< + GetGoogleAccountIntegrationAttributesCamel, + 'id' + >; + Object.keys(current.attributes).map((key) => { + attributes[snakeToCamel(key)] = current.attributes[key]; + }); + return prev.concat([{ id: current.id, ...attributes }]); + }, + [], + ); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql new file mode 100644 index 000000000..0e25a71c5 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql @@ -0,0 +1,27 @@ +extend type Query { + getGoogleAccountIntegrations( + input: GetGoogleAccountIntegrationsInput! + ): [GoogleAccountIntegration]! +} + +input GetGoogleAccountIntegrationsInput { + googleAccountId: ID! + accountListId: ID! +} + +type GoogleAccountIntegration { + calendarId: String + calendarIntegration: Boolean + calendarIntegrations: [String]! + calendarName: String + calendars: [GoogleAccountIntegrationCalendars]! + createdAt: String! + updatedAt: String! + id: String! + updatedInDbAt: String! +} + +type GoogleAccountIntegrationCalendars { + id: String! + name: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts new file mode 100644 index 000000000..d98d25c5f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts @@ -0,0 +1,18 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetGoogleAccountIntegrationsResolvers: Resolvers = { + Query: { + getGoogleAccountIntegrations: async ( + _source, + { input: { googleAccountId, accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getGoogleAccountIntegrations( + googleAccountId, + accountListId, + ); + }, + }, +}; + +export { GetGoogleAccountIntegrationsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts new file mode 100644 index 000000000..cc34f600b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts @@ -0,0 +1,53 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetGoogleAccountsResponse { + attributes: Omit; + id: string; + relationships: { + contact_groups: { + data: unknown[]; + }; + }; + type: string; +} + +export interface GetGoogleAccountAttributes { + id: string; + created_at: string; + email: string; + expires_at: string; + last_download: string; + last_email_sync: string; + primary: boolean; + remote_id: string; + token_expired: boolean; + updated_at: string; + updated_in_db_at: string; +} + +interface GetGoogleAccountAttributesCamel { + id: string; + createdAt: string; + email: string; + expiresAt: string; + lastDownload: string; + lastEmailSync: string; + primary: boolean; + remoteId: string; + tokenExpired: boolean; + updatedAt: string; + updatedInDbAt: string; +} + +export const GetGoogleAccounts = ( + data: GetGoogleAccountsResponse[], +): GetGoogleAccountAttributesCamel[] => { + return data.reduce((prev: GetGoogleAccountAttributesCamel[], current) => { + const attributes = {} as Omit; + Object.keys(current.attributes).map((key) => { + attributes[snakeToCamel(key)] = current.attributes[key]; + }); + + return prev.concat([{ id: current.id, ...attributes }]); + }, []); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql new file mode 100644 index 000000000..a1150b926 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql @@ -0,0 +1,17 @@ +extend type Query { + getGoogleAccounts: [GoogleAccountAttributes]! +} + +type GoogleAccountAttributes { + createdAt: String! + email: String! + expiresAt: String! + lastDownload: String + lastEmailSync: String + primary: Boolean! + id: ID! + remoteId: String! + tokenExpired: Boolean! + updatedAt: String! + updatedInDbAt: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts new file mode 100644 index 000000000..6565ff4ff --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts @@ -0,0 +1,11 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetGoogleAccountsResolvers: Resolvers = { + Query: { + getGoogleAccounts: async (_source, {}, { dataSources }) => { + return dataSources.mpdxRestApi.getGoogleAccounts(); + }, + }, +}; + +export { GetGoogleAccountsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..63df50bb8 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncGoogleIntegration = (data: string): string => { + return data; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..50ad4034c --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncGoogleIntegrationResolvers: Resolvers = { + Mutation: { + syncGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegrationId, integrationName } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncGoogleIntegration( + googleAccountId, + googleIntegrationId, + integrationName, + ); + }, + }, +}; + +export { SyncGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql new file mode 100644 index 000000000..9611004a0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql @@ -0,0 +1,9 @@ +extend type Mutation { + syncGoogleAccount(input: SyncGoogleAccountInput!): String +} + +input SyncGoogleAccountInput { + googleAccountId: ID! + googleIntegrationId: ID! + integrationName: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..b6438ae4b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts @@ -0,0 +1,58 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface UpdateGoogleIntegrationResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} + +type relationships = { + account_list: object[]; + google_account: object[]; +}; + +export interface SaveGoogleIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} + +interface GetGoogleAccountIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const UpdateGoogleIntegration = ( + data: UpdateGoogleIntegrationResponse, +): GetGoogleAccountIntegrationAttributesCamel => { + const attributes = {} as Omit< + GetGoogleAccountIntegrationAttributesCamel, + 'id' + >; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..f8e1eedcd --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const UpdateGoogleIntegrationResolvers: Resolvers = { + Mutation: { + updateGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegrationId, googleIntegration } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.updateGoogleIntegration( + googleAccountId, + googleIntegrationId, + googleIntegration, + ); + }, + }, +}; + +export { UpdateGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql new file mode 100644 index 000000000..2fddd2105 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql @@ -0,0 +1,11 @@ +extend type Mutation { + updateGoogleIntegration( + input: UpdateGoogleIntegrationInput! + ): GoogleAccountIntegration! +} + +input UpdateGoogleIntegrationInput { + googleAccountId: ID! + googleIntegrationId: ID! + googleIntegration: GoogleAccountIntegrationInput! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..428e54365 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const DeleteMailchimpAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql new file mode 100644 index 000000000..ea2694f8c --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + deleteMailchimpAccount(input: DeleteMailchimpAccountInput!): String! +} + +input DeleteMailchimpAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..27aa036cf --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeleteMailchimpAccountResolvers: Resolvers = { + Mutation: { + deleteMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deleteMailchimpAccount(accountListId); + }, + }, +}; + +export { DeleteMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..f386f47d3 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts @@ -0,0 +1,64 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetMailchimpAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +export interface GetMailchimpAccount { + id: string; + active: boolean; + auto_log_campaigns: boolean; + created_at: string; + lists_available_for_newsletters: GetMailchimpAccountNewsletters; + lists_link: string; + lists_present: boolean; + primary_list_id: string; + primary_list_name: string; + updated_at: string; + updated_in_db_at; + valid: boolean; + validate_key: boolean; + validation_error: string; +} + +interface GetMailchimpAccountNewsletters { + id: string; + name: string; +} + +interface GetMailchimpAccountCamel { + id: string; + active: boolean; + autoLogCampaigns: boolean; + createdAt: string; + listsAvailableForNewsletters: GetMailchimpAccountNewsletters[]; + listsLink: string; + listsPresent: boolean; + primaryListId: string; + primaryListName: string; + updatedAt: string; + updatedInDbAt: string; + valid: boolean; + validateKey: boolean; + validationError: string; +} + +export const GetMailchimpAccount = ( + data: GetMailchimpAccountResponse | null, +): GetMailchimpAccountCamel[] => { + // Returning inside an array so I can mock an empty response from GraphQL + // without the test thinking I want it to create custom random test data. + if (!data) return []; + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + return [ + { + id: data.id, + ...attributes, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql new file mode 100644 index 000000000..d192dc180 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql @@ -0,0 +1,29 @@ +extend type Query { + getMailchimpAccount(input: MailchimpAccountInput!): [MailchimpAccount] +} + +input MailchimpAccountInput { + accountListId: ID! +} + +type MailchimpAccount { + id: ID! + active: Boolean! + autoLogCampaigns: Boolean! + createdAt: String + listsAvailableForNewsletters: [listsAvailableForNewsletters] + listsLink: String! + listsPresent: Boolean! + primaryListId: ID + primaryListName: String + updatedAt: String! + updatedInDbAt: String! + valid: Boolean! + validateKey: Boolean! + validationError: String +} + +type listsAvailableForNewsletters { + id: ID! + name: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..1a0f3447b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetMailchimpAccountResolvers: Resolvers = { + Query: { + getMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getMailchimpAccount(accountListId); + }, + }, +}; + +export { GetMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..5e3491d60 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncMailchimpAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..16d1382aa --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncMailchimpAccountResolvers: Resolvers = { + Mutation: { + syncMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncMailchimpAccount(accountListId); + }, + }, +}; + +export { SyncMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql new file mode 100644 index 000000000..f3990a2d4 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + syncMailchimpAccount(input: SyncMailchimpAccountInput!): String +} + +input SyncMailchimpAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..b666a9cc7 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts @@ -0,0 +1,59 @@ +import { snakeToCamel } from '../../../../../../../../src//lib/snakeToCamel'; + +export interface UpdateMailchimpAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +export interface UpdateMailchimpAccount { + id: string; + active: boolean; + auto_log_campaigns: boolean; + created_at: string; + lists_available_for_newsletters?: UpdateMailchimpAccountNewsletters[]; + lists_link: string; + lists_present: boolean; + primary_list_id: string; + primary_list_name: string; + updated_at: string; + updated_in_db_at; + valid: boolean; + validate_key: boolean; + validation_error: string; +} + +interface UpdateMailchimpAccountNewsletters { + id: string; + name: string; +} + +interface UpdateMailchimpAccountCamel { + id: string; + active: boolean; + autoLogCampaigns: boolean; + createdAt: string; + listsAvailableForNewsletters?: UpdateMailchimpAccountNewsletters[]; + listsLink: string; + listsPresent: boolean; + primaryListId: string; + primaryListName: string; + updatedAt: string; + updatedInDbAt: string; + valid: boolean; + validateKey: boolean; + validationError: string; +} + +export const UpdateMailchimpAccount = ( + data: UpdateMailchimpAccountResponse, +): UpdateMailchimpAccountCamel => { + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..b050d9fca --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const UpdateMailchimpAccountResolvers: Resolvers = { + Mutation: { + updateMailchimpAccount: async ( + _source, + { input: { accountListId, mailchimpAccountId, mailchimpAccount } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.updateMailchimpAccount( + accountListId, + mailchimpAccountId, + mailchimpAccount, + ); + }, + }, +}; + +export { UpdateMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql new file mode 100644 index 000000000..d34fd36c0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql @@ -0,0 +1,14 @@ +extend type Mutation { + updateMailchimpAccount(input: UpdateMailchimpAccountInput!): MailchimpAccount! +} + +input UpdateMailchimpAccountInput { + accountListId: ID! + mailchimpAccountId: ID! + mailchimpAccount: UpdateMailchimpAccountInputAccount! +} + +input UpdateMailchimpAccountInputAccount { + primaryListId: ID + autoLogCampaigns: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..1c5db913f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const DeletePrayerlettersAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql new file mode 100644 index 000000000..f4837df8f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + deletePrayerlettersAccount(input: DeletePrayerlettersAccountInput!): String! +} + +input DeletePrayerlettersAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..00c3c7df0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeletePrayerlettersAccountResolvers: Resolvers = { + Mutation: { + deletePrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deletePrayerlettersAccount(accountListId); + }, + }, +}; + +export { DeletePrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..694d77e2b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts @@ -0,0 +1,32 @@ +export interface GetPrayerlettersAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +interface GetPrayerlettersAccount { + id: string; + created_at: string; + updated_at: string; + updated_in_db_at; + valid_token: boolean; +} + +interface GetPrayerlettersAccountCamel { + id: string; + validToken: boolean; +} + +export const GetPrayerlettersAccount = ( + data: GetPrayerlettersAccountResponse | null, +): GetPrayerlettersAccountCamel[] => { + // Returning inside an array so I can mock an empty response from GraphQL + // without the test thinking I want it to create custom random test data. + if (!data) return []; + return [ + { + id: data.id, + validToken: data.attributes.valid_token, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql new file mode 100644 index 000000000..3873036ad --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql @@ -0,0 +1,13 @@ +extend type Query { + getPrayerlettersAccount( + input: PrayerlettersAccountInput! + ): [PrayerlettersAccount] +} + +input PrayerlettersAccountInput { + accountListId: ID! +} + +type PrayerlettersAccount { + validToken: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..e55ebda3b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetPrayerlettersAccountResolvers: Resolvers = { + Query: { + getPrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getPrayerlettersAccount(accountListId); + }, + }, +}; + +export { GetPrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..8a6d09ec5 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncPrayerlettersAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..5e54f3d67 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncPrayerlettersAccountResolvers: Resolvers = { + Mutation: { + syncPrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncPrayerlettersAccount(accountListId); + }, + }, +}; + +export { SyncPrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql new file mode 100644 index 000000000..763bde47d --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + syncPrayerlettersAccount(input: SyncPrayerlettersAccountInput!): String +} + +input SyncPrayerlettersAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/SubgraphSchema/Integrations.ts b/pages/api/Schema/SubgraphSchema/Integrations.ts new file mode 100644 index 000000000..e33d1424a --- /dev/null +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -0,0 +1,112 @@ +// GOOGLE INTEGRATION +// +// Get Accounts +import GetGoogleAccountsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql'; +import { GetGoogleAccountsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers'; +// account integrations +import GetGoogleAccountIntegrationsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql'; +import { GetGoogleAccountIntegrationsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers'; +// create +import CreateGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql'; +import { CreateGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers'; +// update +import UpdateGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql'; +import { UpdateGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers'; +// sync +import SyncGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql'; +import { SyncGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers'; +// delete +import DeleteGoogleAccountTypeDefs from '../Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql'; +import { DeleteGoogleAccountResolvers } from '../Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers'; + +// MAILCHIMP INTEGRATION +// +// Get Account +import GetMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql'; +import { GetMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers'; +// Update Account +import UpdateMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql'; +import { UpdateMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers'; +// Sync Account +import SyncMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql'; +import { SyncMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers'; +// Delete Account +import DeleteMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql'; +import { DeleteMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers'; + +// Prayerletters INTEGRATION +// +// Get Account +import GetPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql'; +import { GetPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers'; +// Sync Account +import SyncPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql'; +import { SyncPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers'; +// Delete Account +import DeletePrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql'; +import { DeletePrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers'; + +// Chalkkine INTEGRATION +// +// Get Account +import SendToChalklineTypeDefs from '../Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql'; +import { SendToChalklineResolvers } from '../Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers'; + +export const integrationSchema = [ + { + typeDefs: GetGoogleAccountsTypeDefs, + resolvers: GetGoogleAccountsResolvers, + }, + { + typeDefs: GetGoogleAccountIntegrationsTypeDefs, + resolvers: GetGoogleAccountIntegrationsResolvers, + }, + { + typeDefs: UpdateGoogleIntegrationTypeDefs, + resolvers: UpdateGoogleIntegrationResolvers, + }, + { + typeDefs: SyncGoogleIntegrationTypeDefs, + resolvers: SyncGoogleIntegrationResolvers, + }, + { + typeDefs: DeleteGoogleAccountTypeDefs, + resolvers: DeleteGoogleAccountResolvers, + }, + { + typeDefs: CreateGoogleIntegrationTypeDefs, + resolvers: CreateGoogleIntegrationResolvers, + }, + { + typeDefs: GetMailchimpAccountTypeDefs, + resolvers: GetMailchimpAccountResolvers, + }, + { + typeDefs: UpdateMailchimpAccountTypeDefs, + resolvers: UpdateMailchimpAccountResolvers, + }, + { + typeDefs: SyncMailchimpAccountTypeDefs, + resolvers: SyncMailchimpAccountResolvers, + }, + { + typeDefs: DeleteMailchimpAccountTypeDefs, + resolvers: DeleteMailchimpAccountResolvers, + }, + { + typeDefs: GetPrayerlettersAccountTypeDefs, + resolvers: GetPrayerlettersAccountResolvers, + }, + { + typeDefs: SyncPrayerlettersAccountTypeDefs, + resolvers: SyncPrayerlettersAccountResolvers, + }, + { + typeDefs: DeletePrayerlettersAccountTypeDefs, + resolvers: DeletePrayerlettersAccountResolvers, + }, + { + typeDefs: SendToChalklineTypeDefs, + resolvers: SendToChalklineResolvers, + }, +]; diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index 2040bbf3e..20f729369 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -45,6 +45,7 @@ import DestroyDonorAccountTypeDefs from './Contacts/DonorAccounts/Destroy/destro import { DestroyDonorAccountResolvers } from './Contacts/DonorAccounts/Destroy/resolvers'; import DeleteTagsTypeDefs from './Tags/Delete/deleteTags.graphql'; import { DeleteTagsResolvers } from './Tags/Delete/resolvers'; +import { integrationSchema } from './SubgraphSchema/Integrations'; const schema = buildSubgraphSchema([ { @@ -127,6 +128,7 @@ const schema = buildSubgraphSchema([ typeDefs: DeleteTagsTypeDefs, resolvers: DeleteTagsResolvers, }, + ...integrationSchema, ]); export default schema; diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 1f8eea078..9502cc9ca 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -1,3 +1,13 @@ +import { DateTime, Duration, Interval } from 'luxon'; +import { + RequestOptions, + Response, + RESTDataSource, +} from 'apollo-datasource-rest'; +import Cors from 'micro-cors'; +import { PageConfig, NextApiRequest } from 'next'; +import { ApolloServer } from 'apollo-server-micro'; +import schema from './Schema'; import { ExportFormatEnum, ExportLabelTypeEnum, @@ -13,7 +23,6 @@ import { CoachingAnswerSet, ContactFilterNotesInput, } from './graphql-rest.page.generated'; -import schema from './Schema'; import { getTaskAnalytics } from './Schema/TaskAnalytics/dataHandler'; import { getCoachingAnswer, @@ -57,15 +66,6 @@ import { getAccountListDonorAccounts } from './Schema/AccountListDonorAccounts/d import { getAccountListCoachUsers } from './Schema/AccountListCoachUser/dataHandler'; import { getAccountListCoaches } from './Schema/AccountListCoaches/dataHandler'; import { getReportsPledgeHistories } from './Schema/reports/pledgeHistories/dataHandler'; -import { DateTime, Duration, Interval } from 'luxon'; -import { - RequestOptions, - Response, - RESTDataSource, -} from 'apollo-datasource-rest'; -import Cors from 'micro-cors'; -import { PageConfig, NextApiRequest } from 'next'; -import { ApolloServer } from 'apollo-server-micro'; import { DonationReponseData, DonationReponseIncluded, @@ -75,9 +75,45 @@ import { DestroyDonorAccount, DestroyDonorAccountResponse, } from './Schema/Contacts/DonorAccounts/Destroy/datahander'; +import { + GetGoogleAccounts, + GetGoogleAccountsResponse, +} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler'; +import { + GetGoogleAccountIntegrationsResponse, + GetGoogleAccountIntegrations, +} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler'; +import { SyncGoogleIntegration } from './Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler'; +import { + UpdateGoogleIntegrationResponse, + UpdateGoogleIntegration, +} from './Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler'; +import { DeleteGoogleAccount } from './Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler'; +import { + CreateGoogleIntegrationResponse, + CreateGoogleIntegration, +} from './Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler'; + +import { + GetMailchimpAccountResponse, + GetMailchimpAccount, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler'; +import { SyncMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler'; +import { DeleteMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler'; +import { + UpdateMailchimpAccount, + UpdateMailchimpAccountResponse, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler'; +import { + GetPrayerlettersAccountResponse, + GetPrayerlettersAccount, +} from './Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler'; +import { SyncPrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler'; +import { DeletePrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler'; +import { SendToChalkline } from './Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler'; function camelToSnake(str: string): string { - return str.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); } class MpdxRestApi extends RESTDataSource { @@ -819,6 +855,206 @@ class MpdxRestApi extends RESTDataSource { ); return data; } + + // Google Integration + // + // + + async getGoogleAccounts() { + const { data }: { data: GetGoogleAccountsResponse[] } = await this.get( + 'user/google_accounts', + { + sort: 'created_at', + include: 'contact_groups', + }, + ); + return GetGoogleAccounts(data); + } + + async getGoogleAccountIntegrations( + googleAccountId: string, + accountListId: string, + ) { + const { data }: { data: GetGoogleAccountIntegrationsResponse[] } = + await this.get( + `user/google_accounts/${googleAccountId}/google_integrations?${encodeURI( + `filter[account_list_id]=${accountListId}`, + )}`, + ); + return GetGoogleAccountIntegrations(data); + } + + async syncGoogleIntegration( + googleAccountId, + googleIntegrationId, + integrationName, + ) { + const { data }: { data: string } = await this.get( + `user/google_accounts/${googleAccountId}/google_integrations/${googleIntegrationId}/sync?integration=${integrationName}`, + ); + return SyncGoogleIntegration(data); + } + + async createGoogleIntegration( + googleAccountId, + googleIntegration, + accountListID, + ) { + const attributes = {}; + Object.keys(googleIntegration).map((key) => { + attributes[camelToSnake(key)] = googleIntegration[key]; + }); + const { data }: { data: CreateGoogleIntegrationResponse } = await this.post( + `user/google_accounts/${googleAccountId}/google_integrations`, + { + data: { + attributes: { + ...attributes, + }, + relationships: { + account_list: { + data: { + type: 'account_lists', + id: accountListID, + }, + }, + }, + type: 'google_integrations', + }, + }, + ); + return CreateGoogleIntegration(data); + } + + async updateGoogleIntegration( + googleAccountId, + googleIntegrationId, + googleIntegration, + ) { + const attributes = {}; + Object.keys(googleIntegration).map((key) => { + attributes[camelToSnake(key)] = googleIntegration[key]; + }); + + const { data }: { data: UpdateGoogleIntegrationResponse } = await this.put( + `user/google_accounts/${googleAccountId}/google_integrations/${googleIntegrationId}`, + { + data: { + attributes: { + ...attributes, + }, + id: googleIntegrationId, + type: 'google_integrations', + }, + }, + ); + return UpdateGoogleIntegration(data); + } + + async deleteGoogleAccount(accountId) { + await this.delete( + `user/google_accounts/${accountId}`, + {}, + { + body: JSON.stringify({ + data: { + type: 'google_accounts', + }, + }), + }, + ); + return DeleteGoogleAccount(); + } + + // Mailchimp Integration + // + // + async getMailchimpAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: GetMailchimpAccountResponse } = await this.get( + `account_lists/${accountListId}/mail_chimp_account`, + ); + return GetMailchimpAccount(data); + } catch { + return GetMailchimpAccount(null); + } + } + + async updateMailchimpAccount( + accountListId, + mailchimpAccountId, + mailchimpAccount, + ) { + const attributes = {}; + Object.keys(mailchimpAccount).map((key) => { + attributes[camelToSnake(key)] = mailchimpAccount[key]; + }); + + const { data }: { data: UpdateMailchimpAccountResponse } = await this.put( + `account_lists/${accountListId}/mail_chimp_account`, + { + data: { + attributes: { + overwrite: true, + ...attributes, + }, + id: mailchimpAccountId, + type: 'mail_chimp_accounts', + }, + }, + ); + return UpdateMailchimpAccount(data); + } + + async syncMailchimpAccount(accountListId) { + await this.get(`account_lists/${accountListId}/mail_chimp_account/sync`); + return SyncMailchimpAccount(); + } + + async deleteMailchimpAccount(accountListId) { + await this.delete(`account_lists/${accountListId}/mail_chimp_account`); + return DeleteMailchimpAccount(); + } + + // Prayerletters Integration + // + // + async getPrayerlettersAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: GetPrayerlettersAccountResponse } = + await this.get(`account_lists/${accountListId}/prayer_letters_account`); + return GetPrayerlettersAccount(data); + } catch { + return GetPrayerlettersAccount(null); + } + } + + async syncPrayerlettersAccount(accountListId) { + await this.get( + `account_lists/${accountListId}/prayer_letters_account/sync`, + ); + return SyncPrayerlettersAccount(); + } + + async deletePrayerlettersAccount(accountListId) { + await this.delete(`account_lists/${accountListId}/prayer_letters_account`); + return DeletePrayerlettersAccount(); + } + + // Chalkline Integration + // + // + + async sendToChalkline(accountListId) { + await this.post(`account_lists/${accountListId}/chalkline_mail`, { + data: { + type: 'chalkline_mails', + }, + }); + return SendToChalkline(); + } } export interface Context { From d4ae98581e9a548776c7b4c6a26e6c1b5c0d7ca1 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:22:23 -0500 Subject: [PATCH 06/53] The Key Integration --- .../Settings/integrations/Key/Key.graphql | 7 +++ .../integrations/Key/TheKeyAccordian.tsx | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/components/Settings/integrations/Key/Key.graphql create mode 100644 src/components/Settings/integrations/Key/TheKeyAccordian.tsx diff --git a/src/components/Settings/integrations/Key/Key.graphql b/src/components/Settings/integrations/Key/Key.graphql new file mode 100644 index 000000000..365e8e4ed --- /dev/null +++ b/src/components/Settings/integrations/Key/Key.graphql @@ -0,0 +1,7 @@ +query GetKeyAccounts { + user { + keyAccounts { + email + } + } +} diff --git a/src/components/Settings/integrations/Key/TheKeyAccordian.tsx b/src/components/Settings/integrations/Key/TheKeyAccordian.tsx new file mode 100644 index 000000000..db93a2ec7 --- /dev/null +++ b/src/components/Settings/integrations/Key/TheKeyAccordian.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; +import { Box, TextField } from '@mui/material'; +import Skeleton from '@mui/material/Skeleton'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { useGetKeyAccountsQuery } from './Key.generated'; + +interface TheKeyAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +export const TheKeyAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const { data, loading } = useGetKeyAccountsQuery(); + const keyAccounts = data?.user?.keyAccounts; + return ( + + } + > + {loading && } + {!loading && + keyAccounts?.map((account, idx) => ( + + + + + + ))} + + ); +}; From 878959d4f050e219d098935e0d0c844c8e1ebb23 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:26:03 -0500 Subject: [PATCH 07/53] Previously changed integrations page. Now fixing URL issue. --- .../integrations/{integrations.page.tsx => index.page.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pages/accountLists/[accountListId]/settings/integrations/{integrations.page.tsx => index.page.tsx} (100%) diff --git a/pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx similarity index 100% rename from pages/accountLists/[accountListId]/settings/integrations/integrations.page.tsx rename to pages/accountLists/[accountListId]/settings/integrations/index.page.tsx From 0a32381516026d50332c70d658655464b691fb6f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:27:18 -0500 Subject: [PATCH 08/53] The Oragnization integration --- ...references-intergrations-organizations.png | Bin 0 -> 491 bytes .../OrganizationAddAccountModal.test.tsx | 320 ++++++++++++++ .../Modals/OrganizationAddAccountModal.tsx | 359 +++++++++++++++ .../OrganizationEditAccountModal.test.tsx | 123 ++++++ .../Modals/OrganizationEditAccountModal.tsx | 149 +++++++ .../OrganizationImportDataSyncModal.test.tsx | 142 ++++++ .../OrganizationImportDataSyncModal.tsx | 170 ++++++++ .../OrganizationAccordian.test.tsx | 412 ++++++++++++++++++ .../Organization/OrganizationAccordian.tsx | 343 +++++++++++++++ .../Organization/OrganizationService.ts | 19 + .../Organization/Organizations.graphql | 67 +++ 11 files changed, 2104 insertions(+) create mode 100644 public/images/settings-preferences-intergrations-organizations.png create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx create mode 100644 src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx create mode 100644 src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx create mode 100644 src/components/Settings/integrations/Organization/OrganizationAccordian.tsx create mode 100644 src/components/Settings/integrations/Organization/OrganizationService.ts create mode 100644 src/components/Settings/integrations/Organization/Organizations.graphql diff --git a/public/images/settings-preferences-intergrations-organizations.png b/public/images/settings-preferences-intergrations-organizations.png new file mode 100644 index 0000000000000000000000000000000000000000..28a1deef3d1a835dcc1bbd26c6efcd1ff957d62b GIT binary patch literal 491 zcmeAS@N?(olHy`uVBq!ia0vp^0YEIr!3-pO=4LhnDdPa25LX~gDp<_wb`q#Zpd`pI znBhQ!z=sD07ZMyM1Sk|N*f1$}`!xmz#&Ay;$B>G+w^x1p?kWhh2CmZhxAE`W`mHaI zeLJk{%6am%O3?kAp);AAC%yUCTys3Xz2i#J)wo~m7ean#Xem_}>rLD7BKMf&%f;HS zk{ImrSA0Liz3JK8gsoGyH6P{9ahPDyHp%-YQ>JK6!H>W$!9NVE3uDZFGweB_l%Zmu zE8DeDJy}Nl!C|Et3-@#huvN(4U2vZN`hjKx?`Dew74Z|EY3VdI{>s=;+w@K970V^5 z6SZkvU4L>kRwq=NL^5BG&xm*Y&26yp{3Ats$A>c*vLE=(P<5_v68$rOop19Wt;83? zJ!fP+ydOB6iBGlu$Z1s>yL~g4m`9;Vs)zptX^+>o>OoN~v21)-r4)X0&U@KueDbu` z3$F)0U*EB%f0}1-vN$(PD0Fq&|J|&s-`y>_`n)eMznS$`tjMBj>&6G?g?J0!FK^8J t^FZry<9l`K@HKx|*Kd=Uk+c6sqx}0~!8DfRyB$GM=IQF^vd$@?2>|tz!kqvB literal 0 HcmV?d00001 diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx new file mode 100644 index 000000000..7a3d1f08f --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -0,0 +1,320 @@ +import { render, waitFor } from '@testing-library/react'; +import { SnackbarProvider } from 'notistack'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import theme from 'src/theme'; +import * as Types from '../../../../../../graphql/types.generated'; +import { OrganizationAddAccountModal } from './OrganizationAddAccountModal'; +import { GetOrganizationsQuery } from '../Organizations.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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: React.ReactElement) => ( + + + + + {children} + + + + +); + +const GetOrganizationsMock: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' +>[] = [ + { + id: 'organizationId', + name: 'organizationName', + apiClass: 'OfflineOrg', + oauth: false, + giftAidPercentage: 0, + }, + { + id: 'ministryId', + name: 'ministryName', + apiClass: 'Siebel', + oauth: false, + giftAidPercentage: 80, + }, + { + id: 'loginId', + name: 'loginName', + apiClass: 'DataServer', + oauth: false, + giftAidPercentage: 70, + }, + { + id: 'oAuthId', + name: 'oAuthName', + apiClass: 'DataServer', + oauth: true, + giftAidPercentage: 60, + }, +]; + +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', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Add Organization Account')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + 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 } = render( + Components( + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect( + getByRole('option', { name: 'organizationName' }), + ).toBeInTheDocument(), + ); + + await waitFor(() => { + expect(getByText('Add Account')).not.toBeDisabled(); + userEvent.click(getByText('Add Account')); + }); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX 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('should select Ministry Organization and be unable to add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + 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 MPDX 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( + Components( + + 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( + 'MPDX 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( + Components( + + 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 MPDX 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 authenication to connect.', + { variant: 'success' }, + ); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx new file mode 100644 index 000000000..cd52d694e --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -0,0 +1,359 @@ +import React, { useState, ReactElement } from 'react'; +import { signOut } from 'next-auth/react'; +import { useTranslation } from 'react-i18next'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { useSnackbar } from 'notistack'; +import { + Box, + DialogActions, + Autocomplete, + TextField, + Button, + Typography, + Link, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { + useGetOrganizationsQuery, + useCreateOrganizationAccountMutation, +} from '../Organizations.generated'; +import { showArticle, variables } from 'src/lib/helpScout'; +import theme from 'src/theme'; +import { Organization } from '../../../../../../graphql/types.generated'; +import { clearDataDogUser } from 'src/hooks/useDataDog'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { + getOrganizationType, + OrganizationTypesEnum, +} from '../OrganizationAccordian'; +import { oAuth } from '../OrganizationService'; + +interface OrganizationAddAccountModalProps { + handleClose: () => void; + accountListId: string | undefined; + refetchOrganizations: () => void; +} + +export type OrganizationFormikSchema = { + selectedOrganization: Pick< + Organization, + 'id' | 'name' | 'oauth' | 'apiClass' | 'giftAidPercentage' + >; + username: string | undefined; + password: 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 OrganizationAddAccountModal: React.FC< + OrganizationAddAccountModalProps +> = ({ handleClose, refetchOrganizations, accountListId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [organizationType, setOrganizationType] = + useState(); + const [createOrganizationAccount] = useCreateOrganizationAccountMutation(); + const { data: organizations, loading } = useGetOrganizationsQuery(); + + 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 authenication to connect.'), + { variant: 'success' }, + ); + window.location.href = await oAuth(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('MPDX added your organization account'), { + variant: 'success', + }); + }, + }); + handleClose(); + return; + }; + + const showOrganizationHelp = () => { + showArticle(variables.HS_SETUP_FIND_ORGANIZATION); + }; + + 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(), + }) + .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; + }), + }, + ); + + return ( + + + {({ + values: { selectedOrganization, username, password }, + handleChange, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }): ReactElement => ( +
+ + { + setOrganizationType( + getOrganizationType(value?.apiClass, value?.oauth), + ); + setFieldValue('selectedOrganization', value); + }} + options={ + organizations?.organizations?.map( + (organization) => organization, + ) || [] + } + getOptionLabel={(option) => + organizations?.organizations?.find( + ({ id }) => String(id) === String(option.id), + )?.name ?? '' + } + filterSelectedOptions + fullWidth + renderInput={(params) => ( + + )} + /> + + + {!selectedOrganization && ( + + )} + + {organizationType === OrganizationTypesEnum.MINISTRY && ( + + + {t('You must log into MPDX with your ministry email')} + + + {t( + 'This organization requires you to log into MPDX with your ministry email to access it.', + )} +
    +
  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(); + }); + }} + > + {t('click here to log out of MPDX')} + + {t( + ' so you can log back in with your offical 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 MPDX to sync your data.", + )} + +
+ )} + + {organizationType === OrganizationTypesEnum.OAUTH && ( + + + {t( + "You will be taken to your organization's donation services system to grant MPDX permission to access your donation data.", + )} + + + )} + + {organizationType === OrganizationTypesEnum.LOGIN && ( + <> + + + + + + + + + + + + )} + + + + + + {organizationType !== OrganizationTypesEnum.OAUTH && + t('Add Account')} + {organizationType === OrganizationTypesEnum.OAUTH && + t('Connect')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx new file mode 100644 index 000000000..16abf5d8c --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -0,0 +1,123 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '../../../../../theme'; +import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import TestRouter from '__tests__/util/TestRouter'; +import { OrganizationEditAccountModal } from './OrganizationEditAccountModal'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const organizationId = 'organization-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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: React.ReactElement) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); +const refetchOrganizations = jest.fn(); + +describe('OrganizationEditAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + refetchOrganizations.mockClear(); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Edit Organization Account')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should enter login details.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, getByTestId } = render( + Components( + + + , + ), + ); + + await waitFor(() => { + expect(getByText('Username')).toBeInTheDocument(); + expect(getByText('Password')).toBeInTheDocument(); + }); + + userEvent.type( + getByRole('textbox', { + name: /username/i, + }), + 'MyUsername', + ); + await waitFor(() => expect(getByText('Save')).toBeDisabled()); + userEvent.type(getByTestId('passwordInput'), 'MyPassword'); + + await waitFor(() => expect(getByText('Save')).not.toBeDisabled()); + userEvent.click(getByText('Save')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX updated your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'UpdateOrganizationAccount', + ); + expect(mutationSpy.mock.calls[0][0].operation.variables.input).toEqual({ + attributes: { + id: organizationId, + username: 'MyUsername', + password: 'MyPassword', + }, + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx new file mode 100644 index 000000000..353b73189 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx @@ -0,0 +1,149 @@ +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { Box, DialogActions, TextField, FormHelperText } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useUpdateOrganizationAccountMutation } from '../Organizations.generated'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { OrganizationFormikSchema } from './OrganizationAddAccountModal'; + +interface OrganizationEditAccountModalProps { + handleClose: () => void; + organizationId: string; +} + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +export const OrganizationEditAccountModal: React.FC< + OrganizationEditAccountModalProps +> = ({ handleClose, organizationId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [updateOrganizationAccount] = useUpdateOrganizationAccountMutation(); + + const onSubmit = async ( + attributes: Omit, + ) => { + const { password, username } = attributes; + + const createAccountAttributes = { + id: organizationId, + username, + password, + }; + + await updateOrganizationAccount({ + variables: { + input: { + attributes: createAccountAttributes, + }, + }, + onError: () => { + enqueueSnackbar(t('Unable to update your organization account'), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX updated your organization account'), { + variant: 'success', + }); + }, + }); + + handleClose(); + return; + }; + + const OrganizationSchema: yup.SchemaOf< + Omit + > = yup.object({ + username: yup.string().required(), + password: yup.string().required(), + }); + + return ( + + + {({ + values: { username, password }, + handleChange, + handleSubmit, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + + {errors.username && ( + + {errors.username} + + )} + + + + + + {errors.password && ( + + {errors.password} + + )} + + + + + + + {t('Save')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx new file mode 100644 index 000000000..4f5fef66b --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -0,0 +1,142 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { ThemeProvider } from '@mui/material/styles'; +import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import TestRouter from '__tests__/util/TestRouter'; +import theme from '../../../../../theme'; +import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; +import { OrganizationImportDataSyncModal } from './OrganizationImportDataSyncModal'; + +jest.mock('src/components/Shared/FileUploads/tntConnectDataSync'); +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const organizationId = 'organizationId'; +const organizationName = 'organizationName'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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: React.ReactElement) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); +const refetchOrganizations = jest.fn(); + +describe('OrganizationImportDataSyncModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + refetchOrganizations.mockClear(); + (validateFile as jest.Mock).mockReturnValue({ success: true }); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should return error when no file present', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + userEvent.click(getByText('Upload File')); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Please select a file to upload.', + { + variant: 'error', + }, + ), + ); + }); + + it('should inform user of the error when uploadiung file.', async () => { + (validateFile as jest.Mock).mockReturnValue({ + success: false, + message: 'Invalid file', + }); + const mutationSpy = jest.fn(); + const { getByTestId, getByText } = render( + Components( + + + , + ), + ); + + const file = new File(['contents'], 'image.png', { + type: 'image/png', + }); + userEvent.upload(getByTestId('importFileUploader'), file); + + userEvent.click(getByText('Upload File')); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Invalid file', { + variant: 'error', + }), + ); + }); + // TODO: Need more tests with uploading correct file. + // Issue with node-fetch. +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx new file mode 100644 index 000000000..e6dabb4c9 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { + Box, + DialogActions, + Typography, + Button, + Paper, + Grid, +} from '@mui/material'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import theme from 'src/theme'; +import Modal from 'src/components/common/Modal/Modal'; +import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; +import { getErrorMessage } from 'src/lib/getErrorFromCatch'; + +interface OrganizationImportDataSyncModalProps { + handleClose: () => void; + organizationId: string; + organizationName: string; + accountListId: string; +} + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +const StyledTypography = styled(Typography)(() => ({ + marginTop: '10px', +})); + +export const OrganizationImportDataSyncModal: React.FC< + OrganizationImportDataSyncModalProps +> = ({ handleClose, organizationId, organizationName, accountListId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [importFile, setImportFile] = useState(null); + const handleSubmit = async (event) => { + event.preventDefault(); + try { + if (!importFile) throw new Error('Please select a file to upload.'); + // TODO + setIsSubmitting(true); + setIsSubmitting(false); + + const form = new FormData(); + form.append('accountListId', accountListId); + form.append('organizationId', organizationId); + form.append('tntDataSync', importFile); + + const res = await fetch(`/api/uploads/tnt-data-sync`, { + method: 'POST', + body: form, + }).catch(() => { + throw new Error(t('Cannot upload avatar: server error')); + }); + + if (res.status === 201) { + enqueueSnackbar( + `File successfully uploaded. The import to ${organizationName} will begin in the background.`, + { + variant: 'success', + }, + ); + } + + setIsSubmitting(false); + handleClose(); + } catch (err) { + enqueueSnackbar(getErrorMessage(err), { + variant: 'error', + }); + } + }; + + const handleFileChange: React.ChangeEventHandler = ( + event, + ) => { + try { + const file = event.target.files?.[0]; + if (!file) return; + + const validationResult = validateFile({ file, t }); + if (!validationResult.success) throw new Error(validationResult.message); + setImportFile(file); + } catch (err) { + enqueueSnackbar(getErrorMessage(err), { + variant: 'error', + }); + } + }; + + return ( + +
+ + + {t( + 'This file should be a TntConnect DataSync file (.tntdatasync or .tntmpd) from your organization, not your local TntConnect database file (.mpddb).', + )} + + + {t( + 'To import your TntConnect database, go to Import from TntConnect', + )} + + + + + + + + + + + {importFile?.name ?? 'No File Chosen'} + + + + + + + + + + + {t('Upload File')} + + +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx new file mode 100644 index 000000000..65289e1fa --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx @@ -0,0 +1,412 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import { + GetUsersOrganizationsQuery, + GetOrganizationsQuery, +} from './Organizations.generated'; +import * as Types from '../../../../../graphql/types.generated'; +import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import theme from '../../../../theme'; +import TestRouter from '__tests__/util/TestRouter'; +import { OrganizationAccordian } from './OrganizationAccordian'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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 handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const GetOrganizationsMock: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' +>[] = [ + { + id: 'organizationId', + name: 'organizationName', + apiClass: 'organizationApiClass', + oauth: false, + giftAidPercentage: 0, + }, +]; + +const GetUsersOrganizationsMock: Array< + Pick< + Types.OrganizationAccount, + 'latestDonationDate' | 'lastDownloadedAt' | 'username' | 'id' + > & { + organization: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' + >; + } +> = [ + { + id: 'id', + latestDonationDate: 'latestDonationDate', + lastDownloadedAt: 'lastDownloadedAt', + username: 'username', + organization: { + id: 'organizationId', + name: 'organizationName', + apiClass: 'OfflineOrg', + oauth: false, + }, + }, +]; + +const standardMocks = { + GetOrganizations: { + organizations: GetOrganizationsMock, + }, + GetUsersOrganizations: { + userOrganizationAccounts: GetUsersOrganizationsMock, + }, +}; + +describe('OrganizationAccordian', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Organization')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('No Organizations connected', () => { + it('should render Organization Overview', async () => { + const { getByText } = render( + Components( + + mocks={{ + GetOrganizations: { + organizations: [], + }, + GetUsersOrganizations: { + userOrganizationAccounts: [], + }, + }} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText("Let's start by connecting to your first organization"), + ).toBeInTheDocument(); + }); + userEvent.click(getByText('Add Account')); + expect(getByText('Add Organization Account')).toBeInTheDocument(); + }); + }); + + describe('Organizations connected', () => { + let mocks = { ...standardMocks }; + beforeEach(() => { + mocks = { ...standardMocks }; + }); + + it('should render Offline Organization', async () => { + const { getByText, queryByText } = render( + Components( + + mocks={mocks} + > + + , + ), + ); + + expect( + queryByText("Let's start by connecting to your first organization"), + ).not.toBeInTheDocument(); + + await waitFor(() => { + expect( + getByText(GetUsersOrganizationsMock[0].organization.name), + ).toBeInTheDocument(); + + expect(getByText('Last Updated')).toBeInTheDocument(); + + expect(getByText('Last Gift Date')).toBeInTheDocument(); + }); + + userEvent.click(getByText('Import TntConnect DataSync file')); + + await waitFor(() => { + expect( + getByText( + 'To import your TntConnect database, go to Import from TntConnect', + ), + ).toBeInTheDocument(); + }); + }); + + it('should render Ministry Account Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'Siebel'; + const { getByText, queryByText } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('Sync')).toBeInTheDocument(); + + expect( + queryByText('Import TntConnect DataSync file'), + ).not.toBeInTheDocument(); + }); + + userEvent.click(getByText('Sync')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'SyncOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + id: mocks.GetUsersOrganizations.userOrganizationAccounts[0].id, + }); + }); + + it('should render Login Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'DataServer'; + const { getByText, getByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('Sync')).toBeInTheDocument(); + expect(getByTestId('EditIcon')).toBeInTheDocument(); + }); + + userEvent.click(getByTestId('EditIcon')); + + await waitFor(() => { + expect(getByText('Edit Organization Account')).toBeInTheDocument(); + }); + }); + + it('should render OAuth Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'DataServer'; + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.oauth = + true; + const { getByText, queryByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(queryByTestId('EditIcon')).not.toBeInTheDocument(); + expect(getByText('Sync')).toBeInTheDocument(); + expect(getByText('Reconnect')).toBeInTheDocument(); + }); + + userEvent.click(getByText('Reconnect')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to complete authenication to reconnect.', + { + variant: 'success', + }, + ); + }); + }); + + it('should delete Organization', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByTestId('DeleteIcon')).toBeInTheDocument(); + }); + + userEvent.click(getByTestId('DeleteIcon')); + + await waitFor(() => { + expect( + getByText('Are you sure you wish to disconnect this organization?'), + ).toBeInTheDocument(); + }); + userEvent.click(getByText('Yes')); + + await waitFor(() => { + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeleteOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + id: mocks.GetUsersOrganizations.userOrganizationAccounts[0].id, + }); + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your organization integration', + { variant: 'success' }, + ); + }); + }); + + it("should not render Organization's download and last gift date", async () => { + mocks.GetUsersOrganizations.userOrganizationAccounts[0].lastDownloadedAt = + null; + mocks.GetUsersOrganizations.userOrganizationAccounts[0].latestDonationDate = + null; + const { queryByText } = render( + Components( + + mocks={mocks} + > + + , + ), + ); + + expect(queryByText('Last Updated')).not.toBeInTheDocument(); + + expect(queryByText('Last Gift Date')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx new file mode 100644 index 000000000..cef10200a --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx @@ -0,0 +1,343 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { + Grid, + Box, + IconButton, + Typography, + Card, + Divider, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + useGetUsersOrganizationsQuery, + useDeleteOrganizationAccountMutation, + useSyncOrganizationAccountMutation, +} from './Organizations.generated'; +import theme from 'src/theme'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import { OrganizationAddAccountModal } from './Modals/OrganizationAddAccountModal'; +import { OrganizationImportDataSyncModal } from './Modals/OrganizationImportDataSyncModal'; +import { OrganizationEditAccountModal } from './Modals/OrganizationEditAccountModal'; +import { oAuth } from './OrganizationService'; +import { StyledServicesButton } from '../integrationsHelper'; + +interface OrganizationAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const OrganizationDeleteIconButton = styled(IconButton)(() => ({ + color: theme.palette.cruGrayMedium.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); + +export enum OrganizationTypesEnum { + MINISTRY = 'ministry', + LOGIN = 'login', + OAUTH = 'oauth', + OFFLINE = 'offline', +} + +export const getOrganizationType = (apiClass, oauth) => { + const ministryAccount = [ + 'Siebel', + 'Remote::Import::OrganizationAccountService', + ]; + const loginRequired = [ + 'DataServer', + 'DataServerPtc', + 'DataServerNavigators', + 'DataServerStumo', + ]; + const offline = ['OfflineOrg']; + + if (apiClass) { + if (ministryAccount.indexOf(apiClass) !== -1) { + return OrganizationTypesEnum.MINISTRY; + } else if (loginRequired.indexOf(apiClass) !== -1 && !oauth) { + return OrganizationTypesEnum.LOGIN; + } else if (oauth) { + return OrganizationTypesEnum.OAUTH; + } else if (offline.indexOf(apiClass) !== -1) { + return OrganizationTypesEnum.OFFLINE; + } + } + return undefined; +}; + +export const OrganizationAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const [showImportDataSyncModal, setShowImportDataSyncModal] = useState(false); + const [showDeleteOrganizationModal, setShowDeleteOrganizationModal] = + useState(false); + const [showEditOrganizationModal, setShowEditOrganizationModal] = + useState(false); + const [deleteOrganizationAccount] = useDeleteOrganizationAccountMutation(); + const [syncOrganizationAccount] = useSyncOrganizationAccountMutation(); + + const { + data, + loading, + refetch: refetchOrganizations, + } = useGetUsersOrganizationsQuery(); + const organizations = data?.userOrganizationAccounts; + + const handleReconnect = async (organizationId) => { + enqueueSnackbar( + t('Redirecting you to complete authenication to reconnect.'), + { variant: 'success' }, + ); + const oAuthUrl = await oAuth(organizationId); + window.location.href = oAuthUrl; + }; + + const handleSync = async (accountId: string) => { + await syncOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + onError: () => { + enqueueSnackbar(t("MPDX couldn't sync your organization account"), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + ), + { + variant: 'success', + }, + ); + }, + }); + }; + + const handleDelete = async (accountId: string) => { + await deleteOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + update: () => refetchOrganizations(), + onError: () => { + enqueueSnackbar( + t( + "MPDX couldn't save your configuration changes for that organization", + ), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your organization integration'), { + variant: 'success', + }); + }, + }); + }; + + return ( + + } + > + + {t(`Add or change the organizations that sync donation information with this + MPDX account. Removing an organization will not remove past information, + but will prevent future donations and contacts from syncing.`)} + + + {!loading && !organizations?.length && ( + + {t("Let's start by connecting to your first organization")} + + )} + + {!loading && !!organizations?.length && ( + + {organizations.map( + ({ organization, lastDownloadedAt, latestDonationDate, id }) => { + const type = getOrganizationType( + organization.apiClass, + organization.oauth, + ); + + return ( + + + + + {organization.name} + + + + {type !== OrganizationTypesEnum.OFFLINE && ( + handleSync(id)} + > + Sync + + )} + + {type === OrganizationTypesEnum.OFFLINE && ( + setShowImportDataSyncModal(true)} + > + Import TntConnect DataSync file + + )} + + {type === OrganizationTypesEnum.OAUTH && ( + handleReconnect(organization.id)} + > + Reconnect + + )} + {type === OrganizationTypesEnum.LOGIN && ( + setShowEditOrganizationModal(true)} + > + + + )} + setShowDeleteOrganizationModal(true)} + > + + + + + + {lastDownloadedAt && ( + + + + Last Updated + + + {DateTime.fromISO(lastDownloadedAt).toRelative()} + + + + )} + {latestDonationDate && ( + + + + Last Gift Date + + + {DateTime.fromISO(latestDonationDate).toRelative()} + + + + )} + setShowDeleteOrganizationModal(false)} + mutation={() => handleDelete(id)} + /> + {showEditOrganizationModal && ( + setShowEditOrganizationModal(false)} + organizationId={id} + /> + )} + {showImportDataSyncModal && ( + setShowImportDataSyncModal(false)} + organizationId={id} + organizationName={organization.name} + accountListId={accountListId ?? ''} + /> + )} + + ); + }, + )} + + )} + + setShowAddAccountModal(true)} + > + Add Account + + + {showAddAccountModal && ( + setShowAddAccountModal(false)} + accountListId={accountListId} + refetchOrganizations={refetchOrganizations} + /> + )} + + ); +}; diff --git a/src/components/Settings/integrations/Organization/OrganizationService.ts b/src/components/Settings/integrations/Organization/OrganizationService.ts new file mode 100644 index 000000000..3128ba78c --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationService.ts @@ -0,0 +1,19 @@ +import { getSession } from 'next-auth/react'; +import Router from 'next/router'; +import { getQueryParam } from 'src/utils/queryParam'; + +export const oAuth = async ( + organizationId, + route = 'preferences/integrations?selectedTab=organization', +) => { + const session = await getSession(); + const redirectUrl = encodeURIComponent(`${window.location.origin}/${route}`); + const token = session?.user.apiToken; + const accountListId = getQueryParam(Router.query, 'accountListId'); + return ( + `${process.env.OAUTH_URL}/auth/user/donorhub?account_list_id=${accountListId}` + + `&redirect_to=${redirectUrl}` + + `&access_token=${token}` + + `&organization_id=${organizationId}` + ); +}; diff --git a/src/components/Settings/integrations/Organization/Organizations.graphql b/src/components/Settings/integrations/Organization/Organizations.graphql new file mode 100644 index 000000000..cf0ffdb84 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Organizations.graphql @@ -0,0 +1,67 @@ +query getOrganizations { + organizations { + id + name + apiClass + oauth + giftAidPercentage + } +} + +query GetUsersOrganizations { + userOrganizationAccounts { + organization { + apiClass + id + name + oauth + } + latestDonationDate + lastDownloadedAt + username + id + } +} + +mutation DeleteOrganizationAccount( + $input: OrganizationAccountDeleteMutationInput! +) { + deleteOrganizationAccount(input: $input) { + clientMutationId + id + } +} + +mutation CreateOrganizationAccount( + $input: OrganizationAccountCreateMutationInput! +) { + createOrganizationAccount(input: $input) { + clientMutationId + organizationAccount { + username + person { + id + } + } + } +} + +mutation SyncOrganizationAccount( + $input: OrganizationAccountSyncMutationInput! +) { + syncOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} + +mutation UpdateOrganizationAccount( + $input: OrganizationAccountUpdateMutationInput! +) { + updateOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} From aac4426a3aa6816d3fccfa1828321ce1e0e5bc6f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:28:13 -0500 Subject: [PATCH 09/53] Organization Helper functions --- pages/api/uploads/tnt-data-sync.page.ts | 101 ++++++++++++++++++ .../integrations/integrationsHelper.ts | 20 ++++ .../Shared/FileUploads/tntConnectDataSync.ts | 60 +++++++++++ 3 files changed, 181 insertions(+) create mode 100644 pages/api/uploads/tnt-data-sync.page.ts create mode 100644 src/components/Settings/integrations/integrationsHelper.ts create mode 100644 src/components/Shared/FileUploads/tntConnectDataSync.ts diff --git a/pages/api/uploads/tnt-data-sync.page.ts b/pages/api/uploads/tnt-data-sync.page.ts new file mode 100644 index 000000000..1f26e5a90 --- /dev/null +++ b/pages/api/uploads/tnt-data-sync.page.ts @@ -0,0 +1,101 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { getToken } from 'next-auth/jwt'; +import { readFile } from 'fs/promises'; +import fetch, { File, FormData } from 'node-fetch'; +import formidable, { IncomingForm } from 'formidable'; + +export const config = { + api: { + bodyParser: false, + responseLimit: '100MB', + }, +}; + +const parseBody = async ( + req: NextApiRequest, +): Promise<{ fields: formidable.Fields; files: formidable.Files }> => { + return new Promise((resolve, reject) => { + const form = new IncomingForm(); + form.parse(req, (err, fields, files) => { + if (err) { + reject(err); + } else { + resolve({ fields, files }); + } + }); + }); +}; + +const importTntDataSyncFile = async ( + req: NextApiRequest, + res: NextApiResponse, +): Promise => { + try { + if (req.method !== 'POST') { + res.status(405).send('Method Not Found'); + return; + } + + const jwt = await getToken({ + req, + secret: process.env.JWT_SECRET, + }); + const apiToken = (jwt as { apiToken: string } | null)?.apiToken; + if (!apiToken) { + res.status(401).send('Unauthorized'); + return; + } + + const { + fields: { accountListId, organizationId }, + files: { tntDataSync }, + } = await parseBody(req); + + if (typeof accountListId !== 'string') { + res.status(400).send('Missing accountListId'); + return; + } + if (typeof organizationId !== 'string') { + res.status(400).send('Missing organizationId'); + return; + } + if (!tntDataSync || Array.isArray(tntDataSync)) { + res.status(400).send('Missing tnt data sync file'); + return; + } + + const file = new File( + [await readFile(tntDataSync.filepath)], + tntDataSync.originalFilename ?? 'tntDataSync', + ); + + const form = new FormData(); + form.append('data[type]', 'imports'); + form.append('data[attributes][file]', file); + form.append( + 'data[relationships][source_account][data][id]', + organizationId, + ); + form.append( + 'data[relationships][source_account][data][type]', + 'organization_accounts', + ); + + const fetchRes = await fetch( + `${process.env.REST_API_URL}account_lists/${accountListId}/imports/tnt_data_sync`, + { + method: 'POST', + headers: { + authorization: `Bearer ${apiToken}`, + }, + body: form, + }, + ); + + res.status(fetchRes.status).json({ success: fetchRes.status === 200 }); + } catch (err) { + res.status(500).json({ success: false, error: err }); + } +}; + +export default importTntDataSyncFile; diff --git a/src/components/Settings/integrations/integrationsHelper.ts b/src/components/Settings/integrations/integrationsHelper.ts new file mode 100644 index 000000000..14918131a --- /dev/null +++ b/src/components/Settings/integrations/integrationsHelper.ts @@ -0,0 +1,20 @@ +import { styled } from '@mui/material/styles'; +import { Button, List, ListItemText } from '@mui/material'; + +export const StyledListItem = styled(ListItemText)(() => ({ + display: 'list-item', +})); + +export const StyledList = styled(List)(({ theme }) => ({ + listStyleType: 'disc', + paddingLeft: theme.spacing(4), +})); + +export const StyledServicesButton = styled(Button)(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +export interface AccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} diff --git a/src/components/Shared/FileUploads/tntConnectDataSync.ts b/src/components/Shared/FileUploads/tntConnectDataSync.ts new file mode 100644 index 000000000..e8d8047d9 --- /dev/null +++ b/src/components/Shared/FileUploads/tntConnectDataSync.ts @@ -0,0 +1,60 @@ +import { TFunction } from 'i18next'; + +export const validateFile = ({ + file, + t, +}: { + file: File; + t: TFunction; +}): { success: true } | { success: false; message: string } => { + if (!new RegExp(/.*\.tntmpd$|.*\.tntdatasync$/).test(file.name)) { + return { + success: false, + message: t( + 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', + ), + }; + } + // TODO: Test how the server handles uploading a 100mb file + // The /api/upload-person-avatar lambda appears to truncate the source body at 2^20 bytes + // Conservatively set the limit at 1MB (1,000,000 bytes), which is a little lower than 1MiB (1,048,576 bytes) because of the + // overhead of encoding multipart/form-data and the other fields in the POST body + if (file.size > 100_000_000) { + return { + success: false, + message: t('Cannot upload file: file size cannot exceed 100MB'), + }; + } + + return { success: true }; +}; + +export const uploadFile = async ({ + oranizationId, + file, + t, +}: { + oranizationId: string; + file: File; + t: TFunction; +}): Promise => { + const validationResult = validateFile({ file, t }); + if (!validationResult.success) { + throw new Error(validationResult.message); + } + + const form = new FormData(); + form.append('oranizationId', oranizationId); + form.append('importFile', file); + + const res = await fetch(`/api/upload-tnt-connect-data-sync`, { + method: 'POST', + body: form, + }).catch(() => { + throw new Error(t('Cannot upload file: server error')); + }); + const data: { success: boolean } = await res.json(); + if (!data.success) { + throw new Error(t('Cannot upload file: server error')); + } +}; From 4c860f63920feb1f062dd5879583dc2fb9df52e3 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:28:28 -0500 Subject: [PATCH 10/53] The key Image --- .../settings-preferences-intergrations-key.png | Bin 0 -> 4375 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/images/settings-preferences-intergrations-key.png diff --git a/public/images/settings-preferences-intergrations-key.png b/public/images/settings-preferences-intergrations-key.png new file mode 100644 index 0000000000000000000000000000000000000000..3799ef1e46a01c331e4dcbafd86015e1fbed0711 GIT binary patch literal 4375 zcmZ`-c{CK>-<}PF!C1;R7$Qqkwk9U)AO;nqY<=w#N|wQQ>}JLik$p>(WE8SQSyRY5 zmM9__Sz?exW62g?zjJ=)egAs@_-yw+=RTi%?m5rB=O$WSHR0hB;{pHxJf=7!tK&%j zk3rdweWhJh0RRA>GBv_p4*{-BN|#=973rBJ5`0x9RBqg=5ph88R_31A7)ufxc%)PM z&Wmp%`JcMVagLMk2`HJy8SZ_XE{RFa%tf-^+5;(vC|73k_Z0`&(xn(V6{xGoW-2y3^` z>wGeDu5Gey>e8RCFAaYjZcm#vYA8~klA04~^xUd+K*4RA1isK5)NYYgQh&aNofCq( z`<;Zsvh?+lNH`n{g+M_6F%XbU`u~Q0vuqTKhB|P?HTEe);c3}^3UPWSIs=I++hIy3lxGC zm?(kN>tu!T$-yC}WM{A~iAV< zSY{yUg-mW&kP$Y6P4cXbjcLXm?F@If&1Zhfxw5Z59h!|zKoVd!@1H-VjQ6`vUkHgA z;F+iA*VF!F%U(}|9QRz{`CAO=$^~NtyMG{nuyU?dPms8dmWi&HubIDU%b`dm6Glao zoq(QQgp@o~K+CBG0aP#ijf2tpo+7lKYw!lj%nXlOAULo^ybB?<*)wlV-14k>J%-=& zur9-k3pmqe3NA-~3r!7-KNR{0lj4{OG&Kng6oFvE2IXm5MUu7;?$`GvH+G!p?^tF| zBXL~GODO_*+qSUH5+UBzSLz4nWG2;DNBYLl@A@RZseVbST5%oRd842zQ(s9JesaPg z{2)F`K7iv(yU|91fMnCV&#rgSbrDhSHDz^JgaAyfMKRKcWI(*d(7Wf{`LO^m2Y=!7erjE8SI4DhABMS2Fb>39VlK|iZ7)pLw#?dPa8}otQG75?x}Q}T zO(J&_tYp+m%yq+vgC8265XXT_f`tP%*fcupKK<=Kn3hH}#i|=w&~$K`udt|Y#F@pM z=6GH$GV2BO6`N5CLn((A7I8)3hh4_@bRL1BqpCs>8s}&WddQYY;-0#dvOuv*5O{xN zUgXJ3>7?$-;R3cp&bxDzwu__8mjBya9@im++M+*CQ>`uWX z?*nX=yFf*og$7E@a=A~ejkXgjP1O|?7{WT$5m}-U0W@HY(#VK~jDf_IwfseX{u29n zF(d!>i3w;p!R4N=bP^Z-jM*nGj8LELzX?~VU;!FOw~%O)AS3|UQzMih!Ayyg=H>=$ z&8>>6JbMBopD}h@;&V$AqUX{m6*Y-EIeym&ulQb(6a?X6ioJ}Xp{9+fyLA&nhWyy3 z8DsdJlmjf}=6D9f3H4_m$@5|W@}opntVlzg$RlIz7U5U`OCYyzb7f$li-tmaO1L}y zErLdS#NQL*t*EBOr;aY`zZi;`Wj!f?R*Pp^An&S8oP5t_?b*KCrsGXrK8EnaH3Y%> zDLdPM^eZks(_DlP!DGLyIal104|H*0JgngKdHr(S92JPm7Ew^efd(Qgk)ri0&CaW} z(;Q_-+bk?^$bs;+)yT%8`=6b5M%S;~=3n^-+sXLzV)MJUV|qQKLLmL_Uu-!YgaN-` zmK_eMyD+|RWw`ag6np}QPx@�&eFqhv>ue>J8(O(>PiiuX z@QCP?3>O8|D%>ylb%`wHz}_pi04p6*R!rOfQ@73R=wvidff{-~Ax=I^uRCW$i-5pCvrC`{R1o7Yslt#!eM zVzJ&m2}}!N8*DhWBj+XOjWpv_y7vSjlYt%(4YrnJDaLe;TQ2oDuZDWj@92R=P&p=V zbHPTp+VU1KG~botEw&_d{UPIfh#Bh;pt;Xe%Si{2!|;>+uHC)z(GTDn{i1cd9$@Pho$rOZ$i{887_JMf67pNG~1 z_P*-a#0}{ch<|Vg8tGn7d;K&Fk`l7!+UqH0Kj2QbtF@DXfKqy~!#RsJ6$W8HWyk$- zZJsy0N^TzpTKqeF3Ph&CF_7UuHZPT8xzF0P1uiT9HNMvJ=FyZn*0)(+4qi(0-Qogz zYcn4KLc=LLhS;{~{Ke$1^`uC4P70(VZDH1sqmF}sBzg~WGaKGY`xx--P#*B!rM zcTYEV6QpH@l%G*goYl5zdUZA@1A^5ykyWMkh)5@H+CqP^>5(Y&CKmY8{AI z6769_q1#Lm9FJ2@%pnyRVvEh{kEa+-wEu!Hsj3;Y`M*7YxAnj%6FToxxZ9j8%5gJ~ zbKP73Zs;keJx8q5QE2bG)B%GN_TQFWcU~c`rwrS`yLv8|ivmZgi$>L_LRHz=6YSoh zdTws@9n|Q(Jn-p@Oidv}wYu9vK?m5I5CzKnegc=vE#(C9TWtU>wSxg@&=5n}2k0F` zb*&C#_B7e;=TH;`k$mKLff$)Ie4NPH)P~oo;4zWE;#V~f$AM{X{ixa-j@;{i<&f&3 zmE^*Ns3CJJY*rF4q_@`U3TNYD8A$g6G-OzpM3{Q{|;>j(9w#N~;oy+=j!_Xhm&V8k#n|nWDoa3yqm-5gu+842v?|JA8N1}_=ko7P6$=pW& zb|~Dpzf;GS%tJ7?j#4CXSiTuTPTBGUfi5?#@;mO20z|}_$x5N|L2Y8uhZaHYy(K}; zonM>R;;|WH7r-1yK^he=jlfi6IUEd$K44o!0$B~1$na{QATB2pjN|;jS@TsQk8qyU zh%lJXr{w3JZ>}2p`)NPbVz0lUQ&pndI_`nvo62nAhLIB3&ufnalW@fuSq>6pqH{hH zCRTUt!(t7WODeDZ+|VKUDrW04yTkH~1S2rkbv)o@NgR`1p|Z<5f5n`=MVN}gOH$DAY>k2Kqm>2SKe$5i;0o*>*n&fTAJTHt{g z@4H^b<&S72Okt=`VcL*w2+JjL1dG1-@`AMj}7XKJe!C z8BrhG$!w(&sZpn~^MHg2X9;gv2jDC1qKKV8xt*o0za=K~%sxn5;$!<-;gdI+d9JE^s>U_Xv7z#Hs!3QZ+#Sx}_A6d$%fzSPk8vU8M zEf9Mp4G2d&@2VePof0xUl%>yWYjXTT!Z<>@0G zW5x#Oof9`xrveS+yqyLmzMb7t@z8|yKHmELBoYBG^9^~f?}Lt?`hMg$&9uZGhVUtC z?#=`7rVeYF=bt#8t$TrxQS)PZD7PM<*kq8vP~P2j2qo|O)fn+>%>)QGOVGlrnHhx{ zb_uh-#P29KxBJr3Yr7+N;*>O6=<6&fAsvB*Gk4}Dd@;@`*NefkllOggL@I3wrAT+c zv}`Z@;d$rmfNW<-+uL6({doaRzIi)A2!1sj1^jl|C#c~(U75YTX?ok+ao&KL2ZmrB z2y3)-vy7lt3%9dWEW6J{#aW!hx!_juy;LZBJEaevLE5`y!0K!=@oeL1wSBv$E>;4Gj zicLa}&^m~4S%1bmejK}xOvbrg-Cm(tHI-Ay`xgL>=>hNqzhW?qk zRq~^?gzGu4p^ETuwRk75k1Z8?=AebtN%rFh1z9NlwLVBz=c~M;b*!P$I=nsxc`zIQ z&$Tp>1pYyak-l4_g_T#EF63c3j1BmbH-+0Fd@lD=BGxT77}?bK?+ZxF{xgZY2cmY5 zw__k6ojH-C5nT!H<39`lY`sCUyMO?NLm)t`KKy^ff0Gc14sXXGv;EDD|A0*|T{WsQ HaH0GUGP3$$ literal 0 HcmV?d00001 From 212c0ef6c2f02c4491772ac70d86d3ae5e089d8d Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:30:32 -0500 Subject: [PATCH 11/53] The Google Integration --- ...tings-preferences-intergrations-google.png | Bin 0 -> 13207 bytes .../Google/GoogleAccordian.test.tsx | 237 ++++++++ .../integrations/Google/GoogleAccordian.tsx | 225 ++++++++ .../Modals/DeleteGoogleAccountModal.test.tsx | 128 +++++ .../Modals/DeleteGoogleAccountModal.tsx | 102 ++++ .../Modals/EditGoogleAccountModal.test.tsx | 506 ++++++++++++++++++ .../Google/Modals/EditGoogleAccountModal.tsx | 282 ++++++++++ .../Modals/EditGoogleIntegrationForm.tsx | 264 +++++++++ .../Google/Modals/googleIntegrations.graphql | 42 ++ .../Modals/updateGoogleIntegration.graphql | 16 + .../Google/googleAccounts.graphql | 19 + 11 files changed, 1821 insertions(+) create mode 100644 public/images/settings-preferences-intergrations-google.png create mode 100644 src/components/Settings/integrations/Google/GoogleAccordian.test.tsx create mode 100644 src/components/Settings/integrations/Google/GoogleAccordian.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx create mode 100644 src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql create mode 100644 src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql create mode 100644 src/components/Settings/integrations/Google/googleAccounts.graphql diff --git a/public/images/settings-preferences-intergrations-google.png b/public/images/settings-preferences-intergrations-google.png new file mode 100644 index 0000000000000000000000000000000000000000..c60525bdaa9f59a297cc4aa32f8d519364342318 GIT binary patch literal 13207 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 ToZT$Je*w}G3gYFW27&(v1}Q`! literal 0 HcmV?d00001 diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx new file mode 100644 index 000000000..2d3c3ee36 --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -0,0 +1,237 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getSession } from 'next-auth/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import theme from '../../../../theme'; +import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { GoogleAccordian } from './GoogleAccordian'; +import { GoogleAccountsQuery } from './googleAccounts.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; + +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 handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardGoogleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +describe('GoogleAccordian', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Google')).toBeInTheDocument(); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render Mailchimp Overview', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(/google integration overview/i)).toBeInTheDocument(); + }); + userEvent.click(getByText(/add account/i)); + + expect(getByText(/add account/i)).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let googleAccount = { ...standardGoogleAccount }; + + beforeEach(() => { + googleAccount = { ...standardGoogleAccount }; + }); + it('shows one connected account', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByTestId } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [googleAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(standardGoogleAccount.email)).toBeInTheDocument(); + }); + + userEvent.click(getByText(/import contacts/i)); + expect(getByText(/import contacts/i)).toHaveAttribute( + 'href', + `https://stage.mpdx.org/tools/import/google`, + ); + + userEvent.click(getByTestId('EditIcon')); + await waitFor(() => + expect(getByText(/edit google integration/i)).toBeInTheDocument(), + ); + userEvent.click(getByTestId('CloseIcon')); + await waitFor(() => + expect(queryByText(/edit google integration/i)).not.toBeInTheDocument(), + ); + + userEvent.click(getByTestId('DeleteIcon')); + await waitFor(() => + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(), + ); + userEvent.click(getByTestId('CloseIcon')); + await waitFor(() => + expect( + queryByText(/confirm to disconnect google account/i), + ).not.toBeInTheDocument(), + ); + }); + + it('shows account with expired token', async () => { + const mutationSpy = jest.fn(); + googleAccount.tokenExpired = true; + const { getByText, getAllByText } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [googleAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(standardGoogleAccount.email)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText(/click "refresh google account/i)).toBeInTheDocument(); + expect(getAllByText(/refresh google account/i)[1]).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + ); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.tsx new file mode 100644 index 000000000..451a2f07a --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordian.tsx @@ -0,0 +1,225 @@ +import { useState, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Box, Card, IconButton, Typography } from '@mui/material'; +import Skeleton from '@mui/material/Skeleton'; +import { styled } from '@mui/material/styles'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useGoogleAccountsQuery } from './googleAccounts.generated'; +import { GoogleAccountAttributes } from '../../../../../graphql/types.generated'; +import theme from 'src/theme'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import HandoffLink from 'src/components/HandoffLink'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; +import { EditGoogleAccountModal } from './Modals/EditGoogleAccountModal'; +import { DeleteGoogleAccountModal } from './Modals/DeleteGoogleAccountModal'; +import { + StyledListItem, + StyledList, + StyledServicesButton, +} from '../integrationsHelper'; + +interface GoogleAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const EditIconButton = styled(IconButton)(() => ({ + color: theme.palette.primary.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); +const DeleteIconButton = styled(IconButton)(() => ({ + color: theme.palette.cruGrayMedium.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); + +const Holder = styled(Box)(() => ({ + display: 'flex', + gap: '10px', + justifyContent: 'spaceBetween', + alignItems: 'center', +})); + +const Left = styled(Box)(() => ({ + width: 'calc(100% - 80px)', +})); + +const Right = styled(Box)(() => ({ + width: '120px', +})); + +export type GoogleAccountAttributesSlimmed = Pick< + GoogleAccountAttributes, + 'id' | 'email' | 'primary' | 'remoteId' | 'tokenExpired' +>; + +export const GoogleAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [openEditGoogleAccount, setOpenEditGoogleAccount] = useState(false); + const [openDeleteGoogleAccount, setOpenDeleteGoogleAccount] = useState(false); + const [selectedAccount, setSelectedAccount] = useState< + GoogleAccountAttributesSlimmed | undefined + >(); + const { data, loading } = useGoogleAccountsQuery({ + skip: !expandedPanel, + }); + const googleAccounts = data?.getGoogleAccounts; + const accountListId = useAccountListId(); + const [oAuth, setOAuth] = useState(''); + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/google?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=Google`, + )}&access_token=${apiToken}`, + ); + }, []); + + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + + const handleEditAccount = (account) => { + setSelectedAccount(account); + setOpenEditGoogleAccount(true); + }; + const handleDeleteAccount = async (account) => { + setSelectedAccount(account); + setOpenDeleteGoogleAccount(true); + }; + return ( + <> + + } + > + {loading && } + {!loading && !googleAccounts?.length && !!expandedPanel && ( + <> + Google Integration Overview + + Google’s suite of tools are great at connecting you to your + Ministry Partners. + + + By synchronizing your Google services with MPDX, you will be able + to: + + + + See MPDX tasks in your Google Calendar + + Import Google Contacts into MPDX + + Keep your Contacts in sync with your Google Contacts + + + + Connect your Google account to begin, and then setup specific + settings for Google Calendar and Contacts. MPDX leaves you in + control of how each service stays in sync. + + + )} + + {!loading && + googleAccounts?.map((account) => ( + + + + {account?.email} + + + handleEditAccount(account)}> + + + handleDeleteAccount(account)} + > + + + + + {account?.tokenExpired && ( + <> + + {t(`The link between MPDX and your Google account stopped working. Click "Refresh Google Account" to + re-enable it. After that, you'll need to manually re-enable any integrations that you had set + already.`)} + + + {t('Refresh Google Account')} + + + )} + + ))} + + + {t('Add Account')} + + + {!!googleAccounts?.length && ( + + + {t('Import contacts')} + + + )} + + + {openEditGoogleAccount && selectedAccount && ( + setOpenEditGoogleAccount(false)} + account={selectedAccount} + oAuth={oAuth} + /> + )} + {openDeleteGoogleAccount && selectedAccount && ( + setOpenDeleteGoogleAccount(false)} + account={selectedAccount} + /> + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx new file mode 100644 index 000000000..5490bf5cd --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -0,0 +1,128 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getSession } from 'next-auth/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; +import theme from '../../../../../theme'; +import { DeleteGoogleAccountModal } from './DeleteGoogleAccountModal'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; + +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 handleClose = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardGoogleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +describe('DeleteGoogleAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + let googleAccount = { ...standardGoogleAccount }; + + beforeEach(() => { + googleAccount = { ...standardGoogleAccount }; + handleClose.mockClear(); + }); + + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(); + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should run deleteGoogleAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(); + userEvent.click(getByText('Confirm')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Google.', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'DeleteGoogleAccount', + ); + expect( + mutationSpy.mock.calls[0][0].operation.variables.input.accountId, + ).toEqual(standardGoogleAccount.id); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx new file mode 100644 index 000000000..e7a6ccef3 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import { + useDeleteGoogleAccountMutation, + GoogleAccountsDocument, + GoogleAccountsQuery, +} from '../googleAccounts.generated'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; + +interface DeleteGoogleAccountModalProps { + handleClose: () => void; + account: GoogleAccountAttributesSlimmed; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeleteGoogleAccountModal: React.FC< + DeleteGoogleAccountModalProps +> = ({ account, handleClose }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deleteGoogleAccount] = useDeleteGoogleAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + + await deleteGoogleAccount({ + variables: { + input: { + accountId: account.id, + }, + }, + update: (cache) => { + const query = { + query: GoogleAccountsDocument, + }; + const dataFromCache = cache.readQuery(query); + + if (dataFromCache) { + const removedAccountFromCache = + dataFromCache?.getGoogleAccounts.filter( + (acc) => acc?.id !== account.id, + ); + const data = { + getGoogleAccounts: [...removedAccountFromCache], + }; + cache.writeQuery({ ...query, data }); + } + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your integration with Google.'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Google."), + { + variant: 'error', + }, + ); + }, + }); + + setIsSubmitting(false); + }; + + return ( + + + + {t(`Are you sure you wish to disconnect this Google account?`)} + + + + + + + {t('Confirm')} + + + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx new file mode 100644 index 000000000..1f40b4c7c --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -0,0 +1,506 @@ +import { render, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getSession } from 'next-auth/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; +import * as Types from '../../../../../../graphql/types.generated'; +import theme from '../../../../../theme'; +import { + GetGoogleAccountIntegrationsQuery, + GetIntegrationActivitiesQuery, +} from './googleIntegrations.generated'; +import { EditGoogleAccountModal } from './EditGoogleAccountModal'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; + +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 handleClose = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const googleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +const standardGoogleIntegration: Pick< + Types.GoogleAccountIntegration, + | '__typename' + | 'calendarId' + | 'calendarIntegration' + | 'calendarIntegrations' + | 'calendarName' + | 'createdAt' + | 'updatedAt' + | 'id' + | 'updatedInDbAt' +> & { + calendars: Array< + Types.Maybe< + { __typename?: 'GoogleAccountIntegrationCalendars' } & Pick< + Types.GoogleAccountIntegrationCalendars, + 'id' | 'name' + > + > + >; +} = { + __typename: 'GoogleAccountIntegration', + calendarId: null, + calendarIntegration: true, + calendarIntegrations: ['Appointment'], + calendarName: 'calendar', + calendars: [ + { + __typename: 'GoogleAccountIntegrationCalendars', + id: 'calendarsID', + name: 'calendarsName@cru.org', + }, + ], + createdAt: '08/08/2023', + updatedAt: '08/08/2023', + id: 'ID', + updatedInDbAt: '08/08/2023', +}; + +const oAuth = `https://auth.mpdx.org/urlpath/to/authenicate`; +describe('EditGoogleAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + let googleIntegration = { ...standardGoogleIntegration }; + + beforeEach(() => { + googleIntegration = { ...standardGoogleIntegration }; + handleClose.mockClear(); + }); + + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should switch tabs', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + + , + ), + ); + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); + const setupTab = getByRole('tab', { name: /setup/i }); + expect(setupTab).toBeInTheDocument(); + userEvent.click(setupTab); + + const button = getByRole('link', { name: /refresh google account/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('href', oAuth); + }); + + it('should enable Calendar Integration', async () => { + googleIntegration.calendarIntegration = false; + googleIntegration.calendarIntegrations = []; + googleIntegration.calendarName = null; + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + + , + ), + ); + await waitFor(() => + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(), + ); + + userEvent.click( + getByRole('button', { name: /enable calendar integration/i }), + ); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Enabled Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarIntegration: true, + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + }); + }); + + it('should update Integrations calendar', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, queryByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + __typename: 'IdValue', + }, + { + id: 'Appointment', + value: 'Appointment', + __typename: 'IdValue', + }, + { + id: 'Email', + value: 'Email', + __typename: 'IdValue', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /update/i })); + }); + await waitFor(() => + expect(getByText(/this field is required/i)).toBeInTheDocument(), + ); + await act(async () => { + userEvent.click(getByRole('button', { name: /​/i })); + }); + const calendarOption = getByRole('option', { + name: /calendarsName@cru\.org/i, + }); + await waitFor(() => expect(calendarOption).toBeInTheDocument()); + await act(async () => { + userEvent.click(calendarOption); + }); + + await waitFor(() => + expect(queryByRole(/this field is required/i)).not.toBeInTheDocument(), + ); + + userEvent.click(getByRole('button', { name: /update/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Updated Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarId: 'calendarsID', + calendarIntegrations: ['Appointment'], + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + + expect(handleClose).toHaveBeenCalled(); + }); + }); + + it('should update calendar checkboxes', async () => { + googleIntegration.calendarId = 'calendarsID'; + const mutationSpy = jest.fn(); + let getByText, getByRole, getByTestId; + await act(async () => { + const { + getByText: getByTextFromRender, + getByRole: getByRoleFromRender, + getByTestId: getByTestIdFromRender, + } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + __typename: 'IdValue', + }, + { + id: 'Appointment', + value: 'Appointment', + __typename: 'IdValue', + }, + { + id: 'Email', + value: 'Email', + __typename: 'IdValue', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + getByText = getByTextFromRender; + getByRole = getByRoleFromRender; + getByTestId = getByTestIdFromRender; + }); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click(getByTestId('Call-Checkbox')); + userEvent.click(getByRole('button', { name: /update/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Updated Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarId: 'calendarsID', + calendarIntegrations: ['Appointment', 'Call'], + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + + expect(handleClose).toHaveBeenCalled(); + }); + }); + + it('should delete Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click( + getByRole('button', { name: /Disable Calendar Integration/i }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Disabled Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarIntegration: false, + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + }); + }); + + it('should sync Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click(getByRole('button', { name: /sync calendar/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully Synced Calendar!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'SyncGoogleAccount', + ); + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + integrationName: 'calendar', + googleIntegrationId: googleIntegration.id, + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx new file mode 100644 index 000000000..0e3727688 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -0,0 +1,282 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { + DialogContent, + DialogActions, + Typography, + Tabs, + Tab, + Box, + Skeleton, + Button, +} from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + useGetGoogleAccountIntegrationsQuery, + GetGoogleAccountIntegrationsDocument, + GetGoogleAccountIntegrationsQuery, + useCreateGoogleIntegrationMutation, +} from './googleIntegrations.generated'; +import { useSyncGoogleAccountMutation } from '../googleAccounts.generated'; +import { useUpdateGoogleIntegrationMutation } from './updateGoogleIntegration.generated'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, + ActionButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; +import { EditGoogleIntegrationForm } from './EditGoogleIntegrationForm'; + +interface EditGoogleAccountModalProps { + handleClose: () => void; + account: GoogleAccountAttributesSlimmed; + oAuth: string; +} + +enum tabs { + calendar = 'calendar', + setup = 'setup', +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const EditGoogleAccountModal: React.FC = ({ + account, + handleClose, + oAuth, +}) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tabSelected, setTabSelected] = useState(tabs.calendar); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + + const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); + const [createGoogleIntegration] = useCreateGoogleIntegrationMutation(); + const [syncGoogleAccountQuery] = useSyncGoogleAccountMutation(); + const { + data, + loading, + refetch: refetchGoogleIntegrations, + } = useGetGoogleAccountIntegrationsQuery({ + variables: { + input: { + googleAccountId: account.id, + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const googleAccountDetails = data?.getGoogleAccountIntegrations[0]; + + const handleTabChange = (_, tab) => { + setTabSelected(tab); + }; + + const handleToogleCalendarIntegration = async ( + enableIntegration: boolean, + ) => { + if (!tabSelected) return; + setIsSubmitting(true); + + if (!googleAccountDetails && enableIntegration) { + // Create Google Integration + await createGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + accountListID: accountListId ?? '', + googleIntegration: { + [`${tabSelected}Integration`]: enableIntegration, + }, + }, + }, + update: () => refetchGoogleIntegrations(), + }); + } else if (googleAccountDetails) { + // Update Google Inetgration + await updateGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails.id, + googleIntegration: { + [`${tabSelected}Integration`]: enableIntegration, + overwrite: true, + }, + }, + }, + update: (cache) => { + const query = { + query: GetGoogleAccountIntegrationsDocument, + variables: { + googleAccountId: account.id, + accountListId, + }, + }; + const dataFromCache = + cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + [`${tabSelected}Integration`]: enableIntegration, + }; + cache.writeQuery({ ...query, data }); + } + }, + }); + } else { + return; + } + + enqueueSnackbar( + enableIntegration + ? t('Enabled Google Calendar Integration!') + : t('Disabled Google Calendar Integration!'), + { + variant: 'success', + }, + ); + setIsSubmitting(false); + }; + + const handleSyncCalendar = async () => { + await syncGoogleAccountQuery({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails?.id ?? '', + integrationName: tabs.calendar, + }, + }, + }); + enqueueSnackbar(t('Successfully Synced Calendar!'), { + variant: 'success', + }); + }; + + return ( + + + + {t('You are currently editing settings for {{email}}', { + email: account.email, + })} + + + + + + + + + {loading && googleAccountDetails?.calendarIntegration && ( + <> + + + + )} + + {!loading && + googleAccountDetails?.calendarIntegration && + tabSelected === tabs.calendar && ( + + )} + + {!loading && + !googleAccountDetails?.calendarIntegration && + tabSelected === tabs.calendar && ( + + {t(`MPDX can automatically update your google calendar with your tasks. + Once you enable this feature, you'll be able to choose which + types of tasks you want to sync. By default MPDX will add + 'Appointment' tasks to your calendar.`)} + + )} + + {tabSelected === tabs.setup && ( + + {t( + `If the link between MPDX and your Google account breaks, + click the button below to re-establish the connection. + (You should only need to do this if you receive an email + from MPDX)`, + )} + + )} + + + {tabSelected === tabs.calendar && + !googleAccountDetails?.calendarIntegration && ( + + + handleToogleCalendarIntegration(true)} + > + {t('Enable Calendar Integration')} + + + )} + {tabSelected === tabs.calendar && + googleAccountDetails?.calendarIntegration && ( + + + + {t('Sync Calendar')} + + + )} + {tabSelected === tabs.setup && ( + + + + + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx new file mode 100644 index 000000000..48389f774 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -0,0 +1,264 @@ +import React, { ReactElement } from 'react'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { styled } from '@mui/material/styles'; +import { + Box, + DialogActions, + Typography, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Skeleton, + FormHelperText, +} from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + GetGoogleAccountIntegrationsDocument, + GetGoogleAccountIntegrationsQuery, + useGetIntegrationActivitiesQuery, +} from './googleIntegrations.generated'; +import { useUpdateGoogleIntegrationMutation } from './updateGoogleIntegration.generated'; +import { GoogleAccountIntegration } from '../../../../../../graphql/types.generated'; +import { + SubmitButton, + DeleteButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; + +type GoogleAccountIntegrationSlimmed = Pick< + GoogleAccountIntegration, + 'calendarId' | 'id' | 'calendarIntegrations' | 'calendars' +>; +interface EditGoogleIntegrationFormProps { + account: GoogleAccountAttributesSlimmed; + googleAccountDetails: GoogleAccountIntegrationSlimmed; + loading: boolean; + setIsSubmitting: (boolean) => void; + handleToogleCalendarIntegration: (boolean) => void; + handleClose: () => void; +} + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: '20px', + marginBottom: '20px', +})); + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + flex: '0 1 50%', + margin: '0 0 0 -11px', +})); + +export const EditGoogleIntegrationForm: React.FC< + EditGoogleIntegrationFormProps +> = ({ + account, + googleAccountDetails, + loading, + setIsSubmitting, + handleToogleCalendarIntegration, + handleClose, +}) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + + const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); + + const { data: actvitiesData } = useGetIntegrationActivitiesQuery(); + const actvities = actvitiesData?.constant?.activities; + + const IntegrationSchema: yup.SchemaOf = + yup.object({ + id: yup.string().required(), + calendarId: yup.string().required(), + calendarIntegrations: yup.array().of(yup.string().required()).required(), + calendars: yup + .array() + .of( + yup.object({ + __typename: yup + .string() + .equals(['GoogleAccountIntegrationCalendars']), + id: yup.string().required(), + name: yup.string().required(), + }), + ) + .required(), + }); + + const onSubmit = async (attributes: GoogleAccountIntegrationSlimmed) => { + setIsSubmitting(true); + const googleIntegration = { + calendarId: attributes.calendarId, + calendarIntegrations: attributes.calendarIntegrations, + }; + + await updateGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails?.id ?? '', + googleIntegration: { + ...googleIntegration, + overwrite: true, + }, + }, + }, + update: (cache) => { + const query = { + query: GetGoogleAccountIntegrationsDocument, + variables: { + googleAccountId: account.id, + accountListId, + }, + }; + const dataFromCache = + cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + ...googleIntegration, + }; + cache.writeQuery({ ...query, data }); + } + }, + }); + setIsSubmitting(false); + enqueueSnackbar(t('Updated Google Calendar Integration!'), { + variant: 'success', + }); + handleClose(); + }; + + return ( + <> + {loading && ( + <> + + + + )} + + {!loading && ( + <> + + {t('Choose a calendar for MPDX to push tasks to:')} + + + + {({ + values: { calendarId, calendarIntegrations, calendars }, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + {errors.calendarId && ( + + {t('This field is required')} + + )} + + + + {actvities?.map((activity) => { + if (!activity?.id || !activity?.value) return null; + const activityId = `${activity.value}-Checkbox`; + const isChecked = calendarIntegrations.includes( + activity?.id ?? '', + ); + return ( + { + let newCalendarInetgrations; + if (value) { + // Add to calendarIntegrations + newCalendarInetgrations = [ + ...calendarIntegrations, + activity.value, + ]; + } else { + // Remove from calendarIntegrations + newCalendarInetgrations = + calendarIntegrations.filter( + (act) => act !== activity?.id, + ); + } + setFieldValue( + `calendarIntegrations`, + newCalendarInetgrations, + ); + }} + /> + } + label={activity.value} + /> + ); + })} + + + + handleToogleCalendarIntegration(false)} + variant="outlined" + > + {t('Disable Calendar Integration')} + + + {t('Update')} + + +
+ )} +
+ + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql new file mode 100644 index 000000000..1cdd50479 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql @@ -0,0 +1,42 @@ +query GetGoogleAccountIntegrations($input: GetGoogleAccountIntegrationsInput!) { + getGoogleAccountIntegrations(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} + +query GetIntegrationActivities { + constant { + activities { + id + value + } + } +} + +mutation CreateGoogleIntegration($input: CreateGoogleIntegrationInput!) { + createGoogleIntegration(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} diff --git a/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql b/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql new file mode 100644 index 000000000..c5da9658d --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql @@ -0,0 +1,16 @@ +mutation UpdateGoogleIntegration($input: UpdateGoogleIntegrationInput!) { + updateGoogleIntegration(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} diff --git a/src/components/Settings/integrations/Google/googleAccounts.graphql b/src/components/Settings/integrations/Google/googleAccounts.graphql new file mode 100644 index 000000000..183fd5d3e --- /dev/null +++ b/src/components/Settings/integrations/Google/googleAccounts.graphql @@ -0,0 +1,19 @@ +query GoogleAccounts { + getGoogleAccounts { + email + primary + remoteId + id + tokenExpired + } +} + +mutation SyncGoogleAccount($input: SyncGoogleAccountInput!) { + syncGoogleAccount(input: $input) +} + +mutation DeleteGoogleAccount($input: DeleteGoogleAccountInput!) { + deleteGoogleAccount(input: $input) { + success + } +} From e9e3b98025892b00961bd65b76296e6dfe93b5a0 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:31:19 -0500 Subject: [PATCH 12/53] The Mailchimp Integration --- ...gs-preferences-intergrations-mailchimp.svg | 1 + .../Mailchimp/MailchimpAccordian.test.tsx | 516 ++++++++++++++++++ .../Mailchimp/MailchimpAccordian.tsx | 390 +++++++++++++ .../Mailchimp/MailchimpAccount.graphql | 51 ++ .../Mailchimp/Modals/DeleteMailchimpModal.tsx | 81 +++ 5 files changed, 1039 insertions(+) create mode 100644 public/images/settings-preferences-intergrations-mailchimp.svg create mode 100644 src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx create mode 100644 src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx create mode 100644 src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql create mode 100644 src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx diff --git a/public/images/settings-preferences-intergrations-mailchimp.svg b/public/images/settings-preferences-intergrations-mailchimp.svg new file mode 100644 index 000000000..9e25905a6 --- /dev/null +++ b/public/images/settings-preferences-intergrations-mailchimp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx new file mode 100644 index 000000000..47245f9b6 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -0,0 +1,516 @@ +import { render, waitFor, within, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import { GetMailchimpAccountQuery } from './MailchimpAccount.generated'; +import * as Types from '../../../../../graphql/types.generated'; +import theme from '../../../../theme'; +import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import TestRouter from '__tests__/util/TestRouter'; +import { MailchimpAccordian } from './MailchimpAccordian'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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 handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardMailchimpAccount: Types.MailchimpAccount = { + __typename: 'MailchimpAccount', + id: '123456789', + active: true, + autoLogCampaigns: false, + createdAt: 'DATETIME', + listsAvailableForNewsletters: [ + { + __typename: 'listsAvailableForNewsletters', + id: '11111111', + name: 'Newsletter list 1', + }, + { + __typename: 'listsAvailableForNewsletters', + id: '2222222', + name: 'Newsletter list 2', + }, + { + __typename: 'listsAvailableForNewsletters', + id: '33333333', + name: 'Newsletter list 3', + }, + ], + listsLink: 'https://listsLink.com', + listsPresent: true, + primaryListId: '11111111', + primaryListName: 'primaryListName', + updatedAt: 'DATETIME', + updatedInDbAt: 'DATETIME', + valid: false, + validateKey: true, + validationError: null, +}; + +describe('MailchimpAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('MailChimp')).toBeInTheDocument(); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render Mailchimp Overview', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('MailChimp Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Connect MailChimp')); + + expect(getByText('Connect MailChimp')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/mailchimp?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dmailchimp&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let mailchimpAccount = { ...standardMailchimpAccount }; + + beforeEach(() => { + mailchimpAccount = { ...standardMailchimpAccount }; + }); + it('is connected but no lists present', async () => { + mailchimpAccount.listsPresent = false; + const mutationSpy = jest.fn(); + const { queryByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('Please choose a list to sync with MailChimp.'), + ).toBeInTheDocument(); + expect( + queryByText( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + ), + ).toBeInTheDocument(); + expect( + queryByText('Go to MailChimp to create a list.'), + ).toBeInTheDocument(); + }); + + expect( + queryByText('Pick a list to use for your newsletter'), + ).not.toBeInTheDocument(); + }); + + it('is connected but no lists present & no lists link', async () => { + mailchimpAccount.listsPresent = false; + mailchimpAccount.listsLink = ''; + const mutationSpy = jest.fn(); + const { queryByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + ), + ).toBeInTheDocument(); + }); + + expect( + queryByText('Go to MailChimp to create a list.'), + ).not.toBeInTheDocument(); + }); + + it('should call updateMailchimpAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + + userEvent.click(getByRole('button', { name: /Newsletter list 1/i })); + await waitFor(() => + expect( + getByRole('option', { name: /Newsletter list 2/i }), + ).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: /Newsletter list 2/i })); + + userEvent.click( + getByRole('checkbox', { + name: /automatically log sent mailchimp campaigns in contact task history/i, + }), + ); + + userEvent.click( + getByRole('button', { + name: /save/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'UpdateMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + mailchimpAccount: { primaryListId: '2222222', autoLogCampaigns: true }, + mailchimpAccountId: '123456789', + }); + }); + + it('should call deleteMailchimpAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + + await waitFor(() => { + expect( + getByText('Confirm to Disconnect Mailchimp Account'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with MailChimp', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeleteMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + }); + // refetch account + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'GetMailchimpAccount', + ); + }); + it('should show settings overview', async () => { + mailchimpAccount.valid = true; + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + expect(getByText(/primaryListName/i)).toBeInTheDocument(); + expect(getByText(/off/i)).toBeInTheDocument(); + }); + + it('should call syncMailchimpAccount', async () => { + mailchimpAccount.valid = true; + mailchimpAccount.autoLogCampaigns = true; + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + const list = getByRole('list'); + within(list).getByText(/on/i); + + userEvent.click( + getByRole('button', { + name: /sync now/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'SyncMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + }); + }); + it('should show settings', async () => { + mailchimpAccount.valid = true; + mailchimpAccount.autoLogCampaigns = true; + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + await act(async () => { + userEvent.click( + getByRole('button', { + name: /modify settings/i, + }), + ); + }); + + await waitFor(() => { + expect( + queryByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).not.toBeInTheDocument(); + expect( + queryByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx new file mode 100644 index 000000000..74ec917c6 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx @@ -0,0 +1,390 @@ +import { useState, useContext, useEffect, useMemo, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { styled } from '@mui/material/styles'; +import { + Box, + Typography, + Skeleton, + Alert, + Button, + Select, + MenuItem, + Checkbox, + FormControlLabel, + FormHelperText, + List, + ListItem, + ListItemText, +} from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + useGetMailchimpAccountQuery, + useUpdateMailchimpAccountMutation, + GetMailchimpAccountDocument, + GetMailchimpAccountQuery, + useSyncMailchimpAccountMutation, +} from './MailchimpAccount.generated'; +import * as Types from '../../../../../graphql/types.generated'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { SubmitButton } from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { DeleteMailchimpAccountModal } from './Modals/DeleteMailchimpModal'; +import { + StyledListItem, + StyledList, + StyledServicesButton, +} from '../integrationsHelper'; + +interface MailchimpAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + flex: '0 1 50%', + margin: '0 0 0 -11px', +})); + +const StyledButton = styled(Button)(() => ({ + marginLeft: '15px', +})); + +export const MailchimpAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [oAuth, setOAuth] = useState(''); + const [showSettings, setShowSettings] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const [updateMailchimpAccount] = useUpdateMailchimpAccountMutation(); + const [syncMailchimpAccount] = useSyncMailchimpAccountMutation(); + const { + data, + loading, + refetch: refetchGetMailchimpAccount, + } = useGetMailchimpAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const mailchimpAccount = data?.getMailchimpAccount + ? data.getMailchimpAccount[0] + : null; + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/mailchimp?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=mailchimp`, + )}&access_token=${apiToken}`, + ); + }, []); + + const MailchimpSchema: yup.SchemaOf< + Pick + > = yup.object({ + autoLogCampaigns: yup.boolean().required(), + primaryListId: yup.string().required(), + }); + + const onSubmit = async ( + attributes: Pick< + Types.MailchimpAccount, + 'autoLogCampaigns' | 'primaryListId' + >, + ) => { + await updateMailchimpAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + mailchimpAccount: attributes, + mailchimpAccountId: mailchimpAccount?.id ?? '', + }, + }, + update: (cache) => { + const query = { + query: GetMailchimpAccountDocument, + variables: { + accountListId, + }, + }; + const dataFromCache = cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + ...attributes, + }; + cache.writeQuery({ ...query, data }); + } + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + ), + { + variant: 'success', + }, + ); + }, + }); + setShowSettings(false); + }; + + const handleSync = async () => { + await syncMailchimpAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + }); + enqueueSnackbar( + t( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + ), + { + variant: 'success', + }, + ); + }; + const handleShowSettings = () => setShowSettings(true); + + const handleDisconnect = async () => setShowDeleteModal(true); + + const handleDeleteModalClose = () => { + setShowDeleteModal(false); + }; + + const availableNewsletterLists = useMemo(() => { + return ( + mailchimpAccount?.listsAvailableForNewsletters?.filter( + (list) => !!list?.id, + ) ?? [] + ); + }, [mailchimpAccount]); + + return ( + + } + > + {loading && } + {!loading && !mailchimpAccount && ( + <> + MailChimp Overview + + MailChimp makes keeping in touch with your ministry partners easy + and streamlined. Here’s how it works: + + + + If you have an existing MailChimp list you’d like to use, Great! + Or, create a new one for your MPDX connection. + + + Select your MPDX MailChimp list to stream your MPDX contacts into. + + + + That's it! Set it and leave it! Now your MailChimp list is + continuously up to date with your MPDX Contacts. That's just + the surface. Click over to the MPDX Help site for more in-depth + details. + + + {t('Connect MailChimp')} + + + )} + {!loading && + ((mailchimpAccount?.validateKey && !mailchimpAccount?.valid) || + showSettings) && ( + + + {t('Please choose a list to sync with MailChimp.')} + + + {mailchimpAccount?.listsPresent && ( + + {({ + values: { primaryListId, autoLogCampaigns }, + handleSubmit, + setFieldValue, + handleChange, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + {t('Pick a list to use for your newsletter')} + + + {errors.primaryListId && ( + + {t('This field is required')} + + )} + + } + label={t( + 'Automatically log sent MailChimp campaigns in contact task history', + )} + /> + + + + {t('Save')} + + + + {t('Disconnect')} + + + +
+ )} +
+ )} + + {!mailchimpAccount?.listsPresent && ( + + + {t( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + )} + + {mailchimpAccount?.listsLink && ( + + )} + + )} +
+ )} + + {!loading && + mailchimpAccount?.validateKey && + mailchimpAccount?.valid && + !showSettings && ( + + + {t('Your contacts are now automatically syncing with MailChimp')} + + + + + + + + + + + + + + {t('Modify Settings')} + + + {t('Disconnect')} + + + )} + + {showDeleteModal && ( + + )} +
+ ); +}; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql new file mode 100644 index 000000000..c0c0e48b3 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql @@ -0,0 +1,51 @@ +query GetMailchimpAccount($input: MailchimpAccountInput!) { + getMailchimpAccount(input: $input) { + id + active + autoLogCampaigns + createdAt + listsAvailableForNewsletters { + id + name + } + listsLink + listsPresent + primaryListId + primaryListName + updatedAt + updatedInDbAt + valid + validateKey + validationError + } +} + +mutation UpdateMailchimpAccount($input: UpdateMailchimpAccountInput!) { + updateMailchimpAccount(input: $input) { + id + active + autoLogCampaigns + createdAt + listsAvailableForNewsletters { + id + name + } + listsLink + listsPresent + primaryListId + primaryListName + updatedAt + updatedInDbAt + valid + validateKey + validationError + } +} + +mutation SyncMailchimpAccount($input: SyncMailchimpAccountInput!) { + syncMailchimpAccount(input: $input) +} + +mutation DeleteMailchimpAccount($input: DeleteMailchimpAccountInput!) { + deleteMailchimpAccount(input: $input) +} diff --git a/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx new file mode 100644 index 000000000..7256f49a6 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { useDeleteMailchimpAccountMutation } from '../MailchimpAccount.generated'; + +interface DeleteMailchimpAccountModalProps { + handleClose: () => void; + accountListId: string; + refetchMailchimpAccount: () => void; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeleteMailchimpAccountModal: React.FC< + DeleteMailchimpAccountModalProps +> = ({ handleClose, accountListId, refetchMailchimpAccount }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deleteMailchimpAccount] = useDeleteMailchimpAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + + await deleteMailchimpAccount({ + variables: { + input: { + accountListId: accountListId, + }, + }, + update: () => refetchMailchimpAccount(), + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your integration with MailChimp'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for MailChimp"), + { + variant: 'error', + }, + ); + }, + }); + setIsSubmitting(false); + }; + + return ( + + + + {t(`Are you sure you wish to disconnect your Mailchimp account?`)} + + + + + + + {t('Confirm')} + + + + ); +}; From 815beef4bfe0019033834db41434115f9c328826 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:31:47 -0500 Subject: [PATCH 13/53] The PrayerLetter Integration --- ...references-intergrations-prayerletters.svg | 1 + .../Modals/DeletePrayerlettersModal.tsx | 81 +++++ .../PrayerlettersAccordian.test.tsx | 283 ++++++++++++++++++ .../Prayerletters/PrayerlettersAccordian.tsx | 203 +++++++++++++ .../PrayerlettersAccount.graphql | 13 + 5 files changed, 581 insertions(+) create mode 100644 public/images/settings-preferences-intergrations-prayerletters.svg create mode 100644 src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx create mode 100644 src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx create mode 100644 src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx create mode 100644 src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql diff --git a/public/images/settings-preferences-intergrations-prayerletters.svg b/public/images/settings-preferences-intergrations-prayerletters.svg new file mode 100644 index 000000000..24128ce75 --- /dev/null +++ b/public/images/settings-preferences-intergrations-prayerletters.svg @@ -0,0 +1 @@ +prayerletterscom \ No newline at end of file diff --git a/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx new file mode 100644 index 000000000..0b5400445 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import { useDeletePrayerlettersAccountMutation } from '../PrayerlettersAccount.generated'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; + +interface DeletePrayerlettersAccountModalProps { + handleClose: () => void; + accountListId: string; + refetchPrayerlettersAccount: () => void; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeletePrayerlettersAccountModal: React.FC< + DeletePrayerlettersAccountModalProps +> = ({ handleClose, accountListId, refetchPrayerlettersAccount }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deletePrayerlettersAccount] = useDeletePrayerlettersAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + try { + await deletePrayerlettersAccount({ + variables: { + input: { + accountListId: accountListId, + }, + }, + update: () => refetchPrayerlettersAccount(), + }); + enqueueSnackbar(t('MPDX removed your integration with Prayer Letters'), { + variant: 'success', + }); + } catch { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Prayer Letters"), + { + variant: 'error', + }, + ); + } + setIsSubmitting(false); + handleClose(); + }; + + return ( + + + + {t( + `Are you sure you wish to disconnect this Prayer Letters account?`, + )} + + + + + + + {t('Confirm')} + + + + ); +}; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx new file mode 100644 index 000000000..581d99136 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -0,0 +1,283 @@ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import * as Types from '../../../../../graphql/types.generated'; +import { PrayerlettersAccordian } from './PrayerlettersAccordian'; +import { GetPrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +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 handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardPrayerlettersAccount: Types.PrayerlettersAccount = { + __typename: 'PrayerlettersAccount', + validToken: true, +}; + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('prayerletters.com')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render PrayerLetters.com Overview', async () => { + const { getByText } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [], + }, + }} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('PrayerLetters.com Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Connect prayerletters.com Account')); + + expect(getByText('Connect prayerletters.com Account')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let prayerlettersAccount = { ...standardPrayerlettersAccount }; + + beforeEach(() => { + prayerlettersAccount = { ...standardPrayerlettersAccount }; + }); + it('is connected but token is not valid', async () => { + prayerlettersAccount.validToken = false; + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByRole } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('Refresh prayerletters.com Account'), + ).toBeInTheDocument(); + }); + + expect(getByText('Refresh prayerletters.com Account')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + ); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + + await waitFor(() => { + expect( + queryByText( + 'Are you sure you wish to disconnect this Prayer Letters account?', + ), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeletePrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); + + it('is connected but token is valid', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('We strongly recommend only making changes in MPDX.'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + await waitFor(() => { + expect( + queryByText( + 'Are you sure you wish to disconnect this Prayer Letters account?', + ), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeletePrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + + userEvent.click( + getByRole('button', { + name: /sync now/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX is now syncing your newsletter recipients with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[3][0].operation.operationName).toEqual( + 'SyncPrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[3][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx new file mode 100644 index 000000000..a4773b253 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx @@ -0,0 +1,203 @@ +import { useState, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { Box, Typography, Skeleton, Alert, Button } from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + useGetPrayerlettersAccountQuery, + useSyncPrayerlettersAccountMutation, +} from './PrayerlettersAccount.generated'; +import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { DeletePrayerlettersAccountModal } from './Modals/DeletePrayerlettersModal'; +import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; + +export const PrayerlettersAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [oAuth, setOAuth] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + showDeleteModal; + const { enqueueSnackbar } = useSnackbar(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const accordianName = t('prayerletters.com'); + const [syncPrayerlettersAccount] = useSyncPrayerlettersAccountMutation(); + const { + data, + loading, + refetch: refetchPrayerlettersAccount, + } = useGetPrayerlettersAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: expandedPanel !== accordianName, + }); + + const prayerlettersAccount = data?.getPrayerlettersAccount + ? data?.getPrayerlettersAccount[0] + : null; + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/prayer_letters?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=prayerletters.com`, + )}&access_token=${apiToken}`, + ); + }, []); + + const handleSync = async () => { + setIsSaving(true); + + await syncPrayerlettersAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Prayer Letters"), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'MPDX is now syncing your newsletter recipients with Prayer Letters', + ), + { + variant: 'success', + }, + ); + }, + }); + + setIsSaving(false); + }; + + const handleDeleteModal = () => { + setShowDeleteModal(false); + }; + + return ( + + } + > + {loading && } + {!loading && !prayerlettersAccount && ( + <> + {t('PrayerLetters.com Overview')} + + {t(`prayerletters.com is a significant way to save valuable ministry + time while more effectively connecting with your partners. Keep your + physical newsletter list up to date in MPDX and then sync it to your + prayerletters.com account with this integration.`)} + + + {t(`By clicking "Connect prayerletters.com Account" you will + replace your entire prayerletters.com list with what is in MPDX. Any + contacts or information that are in your current prayerletters.com + list that are not in MPDX will be deleted. We strongly recommend + only making changes in MPDX.`)} + + + {t('Connect prayerletters.com Account')} + + + )} + {!loading && prayerlettersAccount && !prayerlettersAccount?.validToken && ( + <> + + {t( + 'The link between MPDX and your prayerletters.com account stopped working. Click "Refresh prayerletters.com Account" to re-enable it.', + )} + + + + + + + + + )} + {!loading && prayerlettersAccount && prayerlettersAccount?.validToken && ( + <> + + + {t( + `By clicking "Sync Now" you will replace your entire prayerletters.com list with what is in MPDX. + Any contacts or information that are in your current prayerletters.com list that are not in MPDX + will be deleted.`, + )} + + + {t('We strongly recommend only making changes in MPDX.')} + + + + + + + + + + )} + {showDeleteModal && ( + + )} + + ); +}; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql new file mode 100644 index 000000000..23c367da3 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql @@ -0,0 +1,13 @@ +query GetPrayerlettersAccount($input: PrayerlettersAccountInput!) { + getPrayerlettersAccount(input: $input) { + validToken + } +} + +mutation SyncPrayerlettersAccount($input: SyncPrayerlettersAccountInput!) { + syncPrayerlettersAccount(input: $input) +} + +mutation DeletePrayerlettersAccount($input: DeletePrayerlettersAccountInput!) { + deletePrayerlettersAccount(input: $input) +} From 0a4b9e9241f7193a1f393d9bef0549b6931786b4 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 13 Nov 2023 14:32:08 -0500 Subject: [PATCH 14/53] The Chalkline Integration --- ...gs-preferences-intergrations-chalkline.png | Bin 0 -> 110150 bytes .../Chalkline/ChalklineAccordian.test.tsx | 119 ++++++++++++++++++ .../Chalkline/ChalklineAccordian.tsx | 84 +++++++++++++ .../Chalkline/SendToChalkline.graphql | 3 + 4 files changed, 206 insertions(+) create mode 100644 public/images/settings-preferences-intergrations-chalkline.png create mode 100644 src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx create mode 100644 src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx create mode 100644 src/components/Settings/integrations/Chalkline/SendToChalkline.graphql diff --git a/public/images/settings-preferences-intergrations-chalkline.png b/public/images/settings-preferences-intergrations-chalkline.png new file mode 100644 index 0000000000000000000000000000000000000000..54500787d5ab4b17a4ca6d89225676576937ed0d GIT binary patch literal 110150 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 ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Chalk Line')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).toBeInTheDocument(); + }); + + it('should send contacts to Chalkline', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + await waitFor(() => { + expect(getByText('Chalkline Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Send my current Contacts to Chalkline')); + await waitFor(() => { + expect(getByText('Confirm')).toBeInTheDocument(); + }); + userEvent.click(getByText('Yes')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully Emailed Chalkine', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'SendToChalkline', + ); + expect(mutationSpy.mock.calls[0][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx new file mode 100644 index 000000000..a2cd34cd5 --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useSendToChalklineMutation } from './SendToChalkline.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; + +export const ChalklineAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const accordianName = t('Chalk Line'); + const [showModal, setShowModal] = useState(false); + const accountListId = useAccountListId(); + const [sendToChalkline] = useSendToChalklineMutation(); + const { enqueueSnackbar } = useSnackbar(); + const handleOpenModal = () => setShowModal(true); + + const handleCloseModal = () => { + setShowModal(false); + }; + + const handleSendListToChalkLine = async () => { + await sendToChalkline({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + onCompleted: () => { + enqueueSnackbar(t('Successfully Emailed Chalkine'), { + variant: 'success', + }); + enqueueSnackbar(t('Redirecting you to Chalkine.'), { + variant: 'success', + }); + setTimeout(() => { + window.open('https://chalkline.org/order_mpdx/', '_blank'); + }, 1000); + }, + }); + }; + + return ( + + } + > + {t('Chalkline Overview')} + + {t(`Chalkline is a significant way to save valuable ministry time while more effectively + connecting with your partners. Send physical newsletters to your current list using + Chalkline with a simple click. Chalkline is a one way send available anytime you’re + ready to send a new newsletter out.`)} + + + {t('Send my current Contacts to Chalkline')} + + + + + ); +}; diff --git a/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql b/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql new file mode 100644 index 000000000..24dbb52f9 --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql @@ -0,0 +1,3 @@ +mutation SendToChalkline($input: SendToChalklineInput!) { + sendToChalkline(input: $input) +} From 6f630021056c9b5c589e50659d253c4f6ed6040e Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 14 Nov 2023 08:46:55 -0500 Subject: [PATCH 15/53] 2 test broke on Github but worked on local. Hoping this will fix them --- .../Settings/integrations/Google/GoogleAccordian.test.tsx | 1 + .../integrations/Prayerletters/PrayerlettersAccordian.test.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx index 2d3c3ee36..426730125 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -139,6 +139,7 @@ describe('GoogleAccordian', () => { describe('Connected', () => { let googleAccount = { ...standardGoogleAccount }; + process.env.REWRITE_DOMAIN = 'stage.mpdx.org'; beforeEach(() => { googleAccount = { ...standardGoogleAccount }; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx index 581d99136..f9cc52ebd 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -260,7 +260,7 @@ describe('PrayerlettersAccount', () => { userEvent.click( getByRole('button', { - name: /sync now/i, + name: 'sync now', }), ); From 6d7f3a07e82f99c8e2aee7ce665ac2721e434c2a Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 14 Nov 2023 14:36:38 -0500 Subject: [PATCH 16/53] Ensure sync test is working --- .../PrayerlettersAccordian.test.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx index f9cc52ebd..e14ff4a6c 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -257,10 +257,39 @@ describe('PrayerlettersAccount', () => { accountListId: accountListId, }); }); + }); + + it('should sync contacts', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('We strongly recommend only making changes in MPDX.'), + ).toBeInTheDocument(); + }); userEvent.click( getByRole('button', { - name: 'sync now', + name: /sync now/i, }), ); @@ -271,10 +300,10 @@ describe('PrayerlettersAccount', () => { variant: 'success', }, ); - expect(mutationSpy.mock.calls[3][0].operation.operationName).toEqual( + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( 'SyncPrayerlettersAccount', ); - expect(mutationSpy.mock.calls[3][0].operation.variables.input).toEqual({ + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ accountListId: accountListId, }); }); From 60c57fdf84fb921b95d54389b5b322768c193788 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:01:31 -0500 Subject: [PATCH 17/53] Capitlaized IntegrationsContext --- .../Settings/integrations/Chalkline/ChalklineAccordian.test.tsx | 2 +- .../Settings/integrations/Google/GoogleAccordian.test.tsx | 2 +- src/components/Settings/integrations/Google/GoogleAccordian.tsx | 2 +- .../Google/Modals/DeleteGoogleAccountModal.test.tsx | 2 +- .../integrations/Google/Modals/EditGoogleAccountModal.test.tsx | 2 +- .../Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx | 2 +- .../Settings/integrations/Mailchimp/MailchimpAccordian.tsx | 2 +- .../Organization/Modals/OrganizationAddAccountModal.test.tsx | 2 +- .../Organization/Modals/OrganizationEditAccountModal.test.tsx | 2 +- .../Modals/OrganizationImportDataSyncModal.test.tsx | 2 +- .../integrations/Organization/OrganizationAccordian.test.tsx | 2 +- .../integrations/Prayerletters/PrayerlettersAccordian.test.tsx | 2 +- .../integrations/Prayerletters/PrayerlettersAccordian.tsx | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx index a2e3c6a09..81c268e2e 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx @@ -5,7 +5,7 @@ import { ThemeProvider } from '@mui/material/styles'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; import theme from '../../../../theme'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { ChalklineAccordian } from './ChalklineAccordian'; jest.mock('next-auth/react'); diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx index 426730125..0dd096ad6 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -6,7 +6,7 @@ import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import theme from '../../../../theme'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { GoogleAccordian } from './GoogleAccordian'; import { GoogleAccountsQuery } from './googleAccounts.generated'; diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.tsx index 451a2f07a..25cc3299a 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.tsx @@ -12,7 +12,7 @@ import theme from 'src/theme'; import { IntegrationsContext, IntegrationsContextType, -} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +} from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import HandoffLink from 'src/components/HandoffLink'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx index 5490bf5cd..9883237bc 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -4,7 +4,7 @@ import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; import theme from '../../../../../theme'; import { DeleteGoogleAccountModal } from './DeleteGoogleAccountModal'; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 1f40b4c7c..64e887cba 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -4,7 +4,7 @@ import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; import * as Types from '../../../../../../graphql/types.generated'; import theme from '../../../../../theme'; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx index 47245f9b6..d3f6c61ad 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -6,7 +6,7 @@ import { GetMailchimpAccountQuery } from './MailchimpAccount.generated'; import * as Types from '../../../../../graphql/types.generated'; import theme from '../../../../theme'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import TestRouter from '__tests__/util/TestRouter'; import { MailchimpAccordian } from './MailchimpAccordian'; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx index 74ec917c6..90587e453 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx @@ -31,7 +31,7 @@ import * as Types from '../../../../../graphql/types.generated'; import { IntegrationsContext, IntegrationsContextType, -} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +} from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { SubmitButton } from 'src/components/common/Modal/ActionButtons/ActionButtons'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index 7a3d1f08f..f7d0bdf81 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import theme from 'src/theme'; import * as Types from '../../../../../../graphql/types.generated'; import { OrganizationAddAccountModal } from './OrganizationAddAccountModal'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx index 16abf5d8c..40716a9f5 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -4,7 +4,7 @@ import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; import theme from '../../../../../theme'; import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import TestRouter from '__tests__/util/TestRouter'; import { OrganizationEditAccountModal } from './OrganizationEditAccountModal'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index 4f5fef66b..fdeef9124 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import TestRouter from '__tests__/util/TestRouter'; import theme from '../../../../../theme'; import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx index 65289e1fa..179dc7791 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx @@ -8,7 +8,7 @@ import { } from './Organizations.generated'; import * as Types from '../../../../../graphql/types.generated'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import theme from '../../../../theme'; import TestRouter from '__tests__/util/TestRouter'; import { OrganizationAccordian } from './OrganizationAccordian'; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx index e14ff4a6c..bad5d2eaa 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -5,7 +5,7 @@ import { ThemeProvider } from '@mui/material/styles'; import theme from 'src/theme'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; -import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import * as Types from '../../../../../graphql/types.generated'; import { PrayerlettersAccordian } from './PrayerlettersAccordian'; import { GetPrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx index a4773b253..696522fe2 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx @@ -11,7 +11,7 @@ import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; import { IntegrationsContext, IntegrationsContextType, -} from 'pages/accountLists/[accountListId]/settings/integrations/integrationsContext'; +} from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { DeletePrayerlettersAccountModal } from './Modals/DeletePrayerlettersModal'; import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; From 27c88caaf1d97f0ab5d9d07b5fbb9236f6d37393 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:02:22 -0500 Subject: [PATCH 18/53] Renaming to something random so GIT picks up on the case change --- .../{integrationsContext.tsx => RenamentegrationsContext.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pages/accountLists/[accountListId]/settings/integrations/{integrationsContext.tsx => RenamentegrationsContext.tsx} (100%) diff --git a/pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx b/pages/accountLists/[accountListId]/settings/integrations/RenamentegrationsContext.tsx similarity index 100% rename from pages/accountLists/[accountListId]/settings/integrations/integrationsContext.tsx rename to pages/accountLists/[accountListId]/settings/integrations/RenamentegrationsContext.tsx From 8f2fd5901074d87c46909d5d3825ff03f907a901 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:02:49 -0500 Subject: [PATCH 19/53] Capitial I in IntegrationsContext.tsx --- .../{RenamentegrationsContext.tsx => IntegrationsContext.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pages/accountLists/[accountListId]/settings/integrations/{RenamentegrationsContext.tsx => IntegrationsContext.tsx} (100%) diff --git a/pages/accountLists/[accountListId]/settings/integrations/RenamentegrationsContext.tsx b/pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext.tsx similarity index 100% rename from pages/accountLists/[accountListId]/settings/integrations/RenamentegrationsContext.tsx rename to pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext.tsx From 4fcec9993d6e55dbb0507dcfa321f94bb4c0a255 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:04:12 -0500 Subject: [PATCH 20/53] Moving query.selectTab into the component. --- .../settings/integrations/index.page.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index ac55320ac..86bb1b778 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -2,6 +2,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GetServerSideProps } from 'next'; import { getToken } from 'next-auth/jwt'; +import { useRouter } from 'next/router'; import { suggestArticles } from 'src/lib/helpScout'; import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; import { TheKeyAccordian } from 'src/components/Settings/integrations/Key/TheKeyAccordian'; @@ -11,20 +12,19 @@ import { MailchimpAccordian } from 'src/components/Settings/integrations/Mailchi import { PrayerlettersAccordian } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian'; import { ChalklineAccordian } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordian'; import { SettingsWrapper } from '../wrapper'; -import { IntegrationsContextProvider } from './integrationsContext'; +import { IntegrationsContextProvider } from './IntegrationsContext'; interface Props { apiToken: string; - selectedTab: string; } -const Integrations = ({ apiToken, selectedTab }: Props): ReactElement => { +const Integrations = ({ apiToken }: Props): ReactElement => { const { t } = useTranslation(); - const [expandedPanel, setExpandedPanel] = useState(''); + const { query } = useRouter(); + const [expandedPanel, setExpandedPanel] = useState(query?.selectedTab || ''); useEffect(() => { suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); - setExpandedPanel(selectedTab); }, []); const handleAccordionChange = (panel: string) => { @@ -71,22 +71,16 @@ const Integrations = ({ apiToken, selectedTab }: Props): ReactElement => { ); }; -export const getServerSideProps: GetServerSideProps = async ({ - query, - req, -}) => { +export const getServerSideProps: GetServerSideProps = async ({ req }) => { const jwtToken = (await getToken({ req, secret: process.env.JWT_SECRET as string, })) as { apiToken: string } | null; - const apiToken = jwtToken?.apiToken; - const selectedTab = query?.selectedTab ?? ''; return { props: { apiToken, - selectedTab, }, }; }; From c1613ada42751adf506ecee9ee3b6ca73b5ad436 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:04:38 -0500 Subject: [PATCH 21/53] Removing unused type --- pages/accountLists/[accountListId]/settings/wrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/accountLists/[accountListId]/settings/wrapper.tsx b/pages/accountLists/[accountListId]/settings/wrapper.tsx index 558503ac6..84cee3b10 100644 --- a/pages/accountLists/[accountListId]/settings/wrapper.tsx +++ b/pages/accountLists/[accountListId]/settings/wrapper.tsx @@ -30,7 +30,7 @@ export const SettingsWrapper: React.FC = ({ children, }) => { const { appName } = useGetAppSettings(); - const [isNavListOpen, setNavListOpen] = useState(false); + const [isNavListOpen, setNavListOpen] = useState(false); const handleNavListToggle = () => { setNavListOpen(!isNavListOpen); }; From 04e1d2df4396232861f7ee1c2513eae2408cacb7 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 15:40:37 -0500 Subject: [PATCH 22/53] Switching accountListID to accountListId --- .../createGoogleIntegration/createGoogleIntegration.graphql | 2 +- .../Intergrations/Google/createGoogleIntegration/resolvers.ts | 4 ++-- pages/api/graphql-rest.page.ts | 4 ++-- .../integrations/Google/Modals/EditGoogleAccountModal.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql index a16638ce6..8d1e00e59 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql @@ -7,7 +7,7 @@ extend type Mutation { input CreateGoogleIntegrationInput { googleAccountId: ID! googleIntegration: GoogleAccountIntegrationInput - accountListID: String! + accountListId: String! } input GoogleAccountIntegrationInput { diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts index 39c6501bf..29276346e 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts @@ -4,13 +4,13 @@ const CreateGoogleIntegrationResolvers: Resolvers = { Mutation: { createGoogleIntegration: async ( _source, - { input: { googleAccountId, googleIntegration, accountListID } }, + { input: { googleAccountId, googleIntegration, accountListId } }, { dataSources }, ) => { return dataSources.mpdxRestApi.createGoogleIntegration( googleAccountId, googleIntegration, - accountListID, + accountListId, ); }, }, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 9502cc9ca..55cc83580 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -898,7 +898,7 @@ class MpdxRestApi extends RESTDataSource { async createGoogleIntegration( googleAccountId, googleIntegration, - accountListID, + accountListId, ) { const attributes = {}; Object.keys(googleIntegration).map((key) => { @@ -915,7 +915,7 @@ class MpdxRestApi extends RESTDataSource { account_list: { data: { type: 'account_lists', - id: accountListID, + id: accountListId, }, }, }, diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 0e3727688..50a4c4ee5 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -91,7 +91,7 @@ export const EditGoogleAccountModal: React.FC = ({ variables: { input: { googleAccountId: account.id, - accountListID: accountListId ?? '', + accountListId: accountListId ?? '', googleIntegration: { [`${tabSelected}Integration`]: enableIntegration, }, From afb2cd5874b83fc1cd01c09ea34a9e56d432524f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 16:20:05 -0500 Subject: [PATCH 23/53] Removing 'get' from GraphQL name --- .../datahandler.ts | 56 ------------------- .../Google/getGoogleAccounts/resolvers.ts | 11 ---- .../googleAccountIntegrations/datahandler.ts | 53 ++++++++++++++++++ .../googleAccountIntegrations.graphql} | 6 +- .../resolvers.ts | 8 +-- .../datahandler.ts | 24 ++++---- .../googleAccounts.graphql} | 2 +- .../Google/googleAccounts/resolvers.ts | 11 ++++ .../api/Schema/SubgraphSchema/Integrations.ts | 16 +++--- pages/api/graphql-rest.page.ts | 24 ++++---- .../Google/GoogleAccordian.test.tsx | 6 +- .../integrations/Google/GoogleAccordian.tsx | 2 +- .../Modals/DeleteGoogleAccountModal.tsx | 9 ++- .../Modals/EditGoogleAccountModal.test.tsx | 30 +++++----- .../Google/Modals/EditGoogleAccountModal.tsx | 14 ++--- .../Modals/EditGoogleIntegrationForm.tsx | 8 +-- .../Google/Modals/googleIntegrations.graphql | 4 +- .../Google/googleAccounts.graphql | 2 +- 18 files changed, 141 insertions(+), 145 deletions(-) delete mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts delete mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts rename pages/api/Schema/Settings/Preferences/Intergrations/Google/{getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql => googleAccountIntegrations/googleAccountIntegrations.graphql} (78%) rename pages/api/Schema/Settings/Preferences/Intergrations/Google/{getGoogleAccountIntegrations => googleAccountIntegrations}/resolvers.ts (54%) rename pages/api/Schema/Settings/Preferences/Intergrations/Google/{getGoogleAccounts => googleAccounts}/datahandler.ts (51%) rename pages/api/Schema/Settings/Preferences/Intergrations/Google/{getGoogleAccounts/getGoogleAccounts.graphql => googleAccounts/googleAccounts.graphql} (85%) create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/resolvers.ts diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts deleted file mode 100644 index ef6d46800..000000000 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { snakeToCamel } from 'src/lib/snakeToCamel'; - -export interface GetGoogleAccountIntegrationsResponse { - id: string; - type: string; - attributes: Omit; - relationships: relationships; -} -type relationships = { - account_list: object[]; - google_account: object[]; -}; -export interface GetGoogleAccountIntegrationAttributes { - calendar_id: string; - calendar_integration: boolean; - calendar_integrations: string[]; - calendar_name: string; - calendars: calendars[]; - created_at: string; - updated_at: string; - id: string; - updated_in_db_at: string; -} -interface GetGoogleAccountIntegrationAttributesCamel { - calendarId: string; - calendarIntegration: boolean; - calendarIntegrations: string[]; - calendarName: string; - calendars: calendars[]; - createdAt: string; - updatedAt: string; - id: string; - updatedInDbAt: string; -} -type calendars = { - id: string; - name: string; -}; - -export const GetGoogleAccountIntegrations = ( - data: GetGoogleAccountIntegrationsResponse[], -): GetGoogleAccountIntegrationAttributesCamel[] => { - return data.reduce( - (prev: GetGoogleAccountIntegrationAttributesCamel[], current) => { - const attributes = {} as Omit< - GetGoogleAccountIntegrationAttributesCamel, - 'id' - >; - Object.keys(current.attributes).map((key) => { - attributes[snakeToCamel(key)] = current.attributes[key]; - }); - return prev.concat([{ id: current.id, ...attributes }]); - }, - [], - ); -}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts deleted file mode 100644 index 6565ff4ff..000000000 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Resolvers } from '../../../../../../graphql-rest.page.generated'; - -const GetGoogleAccountsResolvers: Resolvers = { - Query: { - getGoogleAccounts: async (_source, {}, { dataSources }) => { - return dataSources.mpdxRestApi.getGoogleAccounts(); - }, - }, -}; - -export { GetGoogleAccountsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts new file mode 100644 index 000000000..562e16015 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts @@ -0,0 +1,53 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GoogleAccountIntegrationsResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} +type relationships = { + account_list: object[]; + google_account: object[]; +}; +export interface GoogleAccountIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} +interface GoogleAccountIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const GoogleAccountIntegrations = ( + data: GoogleAccountIntegrationsResponse[], +): GoogleAccountIntegrationAttributesCamel[] => { + return data.map((integrations) => { + const attributes = {} as Omit< + GoogleAccountIntegrationAttributesCamel, + 'id' + >; + Object.keys(integrations.attributes).forEach((key) => { + attributes[snakeToCamel(key)] = integrations.attributes[key]; + }); + return { id: integrations.id, ...attributes }; + }, []); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql similarity index 78% rename from pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql rename to pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql index 0e25a71c5..9d77642ec 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql @@ -1,10 +1,10 @@ extend type Query { - getGoogleAccountIntegrations( - input: GetGoogleAccountIntegrationsInput! + googleAccountIntegrations( + input: GoogleAccountIntegrationsInput! ): [GoogleAccountIntegration]! } -input GetGoogleAccountIntegrationsInput { +input GoogleAccountIntegrationsInput { googleAccountId: ID! accountListId: ID! } diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers.ts similarity index 54% rename from pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts rename to pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers.ts index d98d25c5f..bdb1c790b 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers.ts @@ -1,13 +1,13 @@ import { Resolvers } from '../../../../../../graphql-rest.page.generated'; -const GetGoogleAccountIntegrationsResolvers: Resolvers = { +const GoogleAccountIntegrationsResolvers: Resolvers = { Query: { - getGoogleAccountIntegrations: async ( + googleAccountIntegrations: async ( _source, { input: { googleAccountId, accountListId } }, { dataSources }, ) => { - return dataSources.mpdxRestApi.getGoogleAccountIntegrations( + return dataSources.mpdxRestApi.googleAccountIntegrations( googleAccountId, accountListId, ); @@ -15,4 +15,4 @@ const GetGoogleAccountIntegrationsResolvers: Resolvers = { }, }; -export { GetGoogleAccountIntegrationsResolvers }; +export { GoogleAccountIntegrationsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts similarity index 51% rename from pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts rename to pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts index cc34f600b..d6ea969b7 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts @@ -1,7 +1,7 @@ import { snakeToCamel } from 'src/lib/snakeToCamel'; -export interface GetGoogleAccountsResponse { - attributes: Omit; +export interface GoogleAccountsResponse { + attributes: Omit; id: string; relationships: { contact_groups: { @@ -11,7 +11,7 @@ export interface GetGoogleAccountsResponse { type: string; } -export interface GetGoogleAccountAttributes { +export interface GoogleAccountAttributes { id: string; created_at: string; email: string; @@ -25,7 +25,7 @@ export interface GetGoogleAccountAttributes { updated_in_db_at: string; } -interface GetGoogleAccountAttributesCamel { +interface GoogleAccountAttributesCamel { id: string; createdAt: string; email: string; @@ -39,15 +39,15 @@ interface GetGoogleAccountAttributesCamel { updatedInDbAt: string; } -export const GetGoogleAccounts = ( - data: GetGoogleAccountsResponse[], -): GetGoogleAccountAttributesCamel[] => { - return data.reduce((prev: GetGoogleAccountAttributesCamel[], current) => { - const attributes = {} as Omit; - Object.keys(current.attributes).map((key) => { - attributes[snakeToCamel(key)] = current.attributes[key]; +export const GoogleAccounts = ( + data: GoogleAccountsResponse[], +): GoogleAccountAttributesCamel[] => { + return data.map((accounts) => { + const attributes = {} as Omit; + Object.keys(accounts.attributes).map((key) => { + attributes[snakeToCamel(key)] = accounts.attributes[key]; }); - return prev.concat([{ id: current.id, ...attributes }]); + return { id: accounts.id, ...attributes }; }, []); }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql similarity index 85% rename from pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql rename to pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql index a1150b926..38ae1cc19 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql @@ -1,5 +1,5 @@ extend type Query { - getGoogleAccounts: [GoogleAccountAttributes]! + googleAccounts: [GoogleAccountAttributes]! } type GoogleAccountAttributes { diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/resolvers.ts new file mode 100644 index 000000000..d9477855f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/resolvers.ts @@ -0,0 +1,11 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GoogleAccountsResolvers: Resolvers = { + Query: { + googleAccounts: async (_source, {}, { dataSources }) => { + return dataSources.mpdxRestApi.googleAccounts(); + }, + }, +}; + +export { GoogleAccountsResolvers }; diff --git a/pages/api/Schema/SubgraphSchema/Integrations.ts b/pages/api/Schema/SubgraphSchema/Integrations.ts index e33d1424a..b5882131e 100644 --- a/pages/api/Schema/SubgraphSchema/Integrations.ts +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -1,11 +1,11 @@ // GOOGLE INTEGRATION // // Get Accounts -import GetGoogleAccountsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql'; -import { GetGoogleAccountsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers'; +import GoogleAccountsTypeDefs from '../Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql'; +import { GoogleAccountsResolvers } from '../Settings/Preferences/Intergrations/Google/googleAccounts/resolvers'; // account integrations -import GetGoogleAccountIntegrationsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql'; -import { GetGoogleAccountIntegrationsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers'; +import GoogleAccountIntegrationsTypeDefs from '../Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql'; +import { GoogleAccountIntegrationsResolvers } from '../Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers'; // create import CreateGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql'; import { CreateGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers'; @@ -54,12 +54,12 @@ import { SendToChalklineResolvers } from '../Settings/Preferences/Intergrations/ export const integrationSchema = [ { - typeDefs: GetGoogleAccountsTypeDefs, - resolvers: GetGoogleAccountsResolvers, + typeDefs: GoogleAccountsTypeDefs, + resolvers: GoogleAccountsResolvers, }, { - typeDefs: GetGoogleAccountIntegrationsTypeDefs, - resolvers: GetGoogleAccountIntegrationsResolvers, + typeDefs: GoogleAccountIntegrationsTypeDefs, + resolvers: GoogleAccountIntegrationsResolvers, }, { typeDefs: UpdateGoogleIntegrationTypeDefs, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 55cc83580..246c76c06 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -76,13 +76,13 @@ import { DestroyDonorAccountResponse, } from './Schema/Contacts/DonorAccounts/Destroy/datahander'; import { - GetGoogleAccounts, - GetGoogleAccountsResponse, -} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler'; + GoogleAccounts, + GoogleAccountsResponse, +} from './Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler'; import { - GetGoogleAccountIntegrationsResponse, - GetGoogleAccountIntegrations, -} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler'; + GoogleAccountIntegrationsResponse, + GoogleAccountIntegrations, +} from './Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler'; import { SyncGoogleIntegration } from './Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler'; import { UpdateGoogleIntegrationResponse, @@ -860,28 +860,28 @@ class MpdxRestApi extends RESTDataSource { // // - async getGoogleAccounts() { - const { data }: { data: GetGoogleAccountsResponse[] } = await this.get( + async googleAccounts() { + const { data }: { data: GoogleAccountsResponse[] } = await this.get( 'user/google_accounts', { sort: 'created_at', include: 'contact_groups', }, ); - return GetGoogleAccounts(data); + return GoogleAccounts(data); } - async getGoogleAccountIntegrations( + async googleAccountIntegrations( googleAccountId: string, accountListId: string, ) { - const { data }: { data: GetGoogleAccountIntegrationsResponse[] } = + const { data }: { data: GoogleAccountIntegrationsResponse[] } = await this.get( `user/google_accounts/${googleAccountId}/google_integrations?${encodeURI( `filter[account_list_id]=${accountListId}`, )}`, ); - return GetGoogleAccountIntegrations(data); + return GoogleAccountIntegrations(data); } async syncGoogleIntegration( diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx index 0dd096ad6..cb614b1cb 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -112,7 +112,7 @@ describe('GoogleAccordian', () => { }> mocks={{ GoogleAccounts: { - getGoogleAccounts: [], + googleAccounts: [], }, }} onCall={mutationSpy} @@ -153,7 +153,7 @@ describe('GoogleAccordian', () => { }> mocks={{ GoogleAccounts: { - getGoogleAccounts: [googleAccount], + googleAccounts: [googleAccount], }, }} onCall={mutationSpy} @@ -209,7 +209,7 @@ describe('GoogleAccordian', () => { }> mocks={{ GoogleAccounts: { - getGoogleAccounts: [googleAccount], + googleAccounts: [googleAccount], }, }} onCall={mutationSpy} diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.tsx index 25cc3299a..21ca26e2b 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.tsx @@ -79,7 +79,7 @@ export const GoogleAccordian: React.FC = ({ const { data, loading } = useGoogleAccountsQuery({ skip: !expandedPanel, }); - const googleAccounts = data?.getGoogleAccounts; + const googleAccounts = data?.googleAccounts; const accountListId = useAccountListId(); const [oAuth, setOAuth] = useState(''); diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx index e7a6ccef3..a7177f1bf 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -49,12 +49,11 @@ export const DeleteGoogleAccountModal: React.FC< const dataFromCache = cache.readQuery(query); if (dataFromCache) { - const removedAccountFromCache = - dataFromCache?.getGoogleAccounts.filter( - (acc) => acc?.id !== account.id, - ); + const removedAccountFromCache = dataFromCache?.googleAccounts.filter( + (acc) => acc?.id !== account.id, + ); const data = { - getGoogleAccounts: [...removedAccountFromCache], + googleAccounts: [...removedAccountFromCache], }; cache.writeQuery({ ...query, data }); } diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 64e887cba..4c7f0a9b6 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -9,7 +9,7 @@ import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocki import * as Types from '../../../../../../graphql/types.generated'; import theme from '../../../../../theme'; import { - GetGoogleAccountIntegrationsQuery, + GoogleAccountIntegrationsQuery, GetIntegrationActivitiesQuery, } from './googleIntegrations.generated'; import { EditGoogleAccountModal } from './EditGoogleAccountModal'; @@ -169,8 +169,8 @@ describe('EditGoogleAccountModal', () => { Components( { Components( mocks={{ - GetGoogleAccountIntegrations: { - getGoogleAccountIntegrations: [googleIntegration], + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], }, GetIntegrationActivities: { constant: { @@ -322,12 +322,12 @@ describe('EditGoogleAccountModal', () => { } = render( Components( mocks={{ - GetGoogleAccountIntegrations: { - getGoogleAccountIntegrations: [googleIntegration], + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], }, GetIntegrationActivities: { constant: { @@ -406,11 +406,11 @@ describe('EditGoogleAccountModal', () => { const { getByText, getByRole } = render( Components( mocks={{ - GetGoogleAccountIntegrations: { - getGoogleAccountIntegrations: [googleIntegration], + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], }, }} onCall={mutationSpy} @@ -460,11 +460,11 @@ describe('EditGoogleAccountModal', () => { const { getByText, getByRole } = render( Components( mocks={{ - GetGoogleAccountIntegrations: { - getGoogleAccountIntegrations: [googleIntegration], + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], }, }} onCall={mutationSpy} diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 50a4c4ee5..3e75ea406 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -14,9 +14,9 @@ import { } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { - useGetGoogleAccountIntegrationsQuery, - GetGoogleAccountIntegrationsDocument, - GetGoogleAccountIntegrationsQuery, + useGoogleAccountIntegrationsQuery, + GoogleAccountIntegrationsDocument, + GoogleAccountIntegrationsQuery, useCreateGoogleIntegrationMutation, } from './googleIntegrations.generated'; import { useSyncGoogleAccountMutation } from '../googleAccounts.generated'; @@ -63,7 +63,7 @@ export const EditGoogleAccountModal: React.FC = ({ data, loading, refetch: refetchGoogleIntegrations, - } = useGetGoogleAccountIntegrationsQuery({ + } = useGoogleAccountIntegrationsQuery({ variables: { input: { googleAccountId: account.id, @@ -73,7 +73,7 @@ export const EditGoogleAccountModal: React.FC = ({ skip: !accountListId, }); - const googleAccountDetails = data?.getGoogleAccountIntegrations[0]; + const googleAccountDetails = data?.googleAccountIntegrations[0]; const handleTabChange = (_, tab) => { setTabSelected(tab); @@ -114,14 +114,14 @@ export const EditGoogleAccountModal: React.FC = ({ }, update: (cache) => { const query = { - query: GetGoogleAccountIntegrationsDocument, + query: GoogleAccountIntegrationsDocument, variables: { googleAccountId: account.id, accountListId, }, }; const dataFromCache = - cache.readQuery(query); + cache.readQuery(query); if (dataFromCache) { const data = { diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx index 48389f774..385fd9543 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -17,8 +17,8 @@ import { } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { - GetGoogleAccountIntegrationsDocument, - GetGoogleAccountIntegrationsQuery, + GoogleAccountIntegrationsDocument, + GoogleAccountIntegrationsQuery, useGetIntegrationActivitiesQuery, } from './googleIntegrations.generated'; import { useUpdateGoogleIntegrationMutation } from './updateGoogleIntegration.generated'; @@ -114,14 +114,14 @@ export const EditGoogleIntegrationForm: React.FC< }, update: (cache) => { const query = { - query: GetGoogleAccountIntegrationsDocument, + query: GoogleAccountIntegrationsDocument, variables: { googleAccountId: account.id, accountListId, }, }; const dataFromCache = - cache.readQuery(query); + cache.readQuery(query); if (dataFromCache) { const data = { diff --git a/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql index 1cdd50479..a77e572df 100644 --- a/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql +++ b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql @@ -1,5 +1,5 @@ -query GetGoogleAccountIntegrations($input: GetGoogleAccountIntegrationsInput!) { - getGoogleAccountIntegrations(input: $input) { +query GoogleAccountIntegrations($input: GoogleAccountIntegrationsInput!) { + googleAccountIntegrations(input: $input) { calendarId calendarIntegration calendarIntegrations diff --git a/src/components/Settings/integrations/Google/googleAccounts.graphql b/src/components/Settings/integrations/Google/googleAccounts.graphql index 183fd5d3e..7abaed3d6 100644 --- a/src/components/Settings/integrations/Google/googleAccounts.graphql +++ b/src/components/Settings/integrations/Google/googleAccounts.graphql @@ -1,5 +1,5 @@ query GoogleAccounts { - getGoogleAccounts { + googleAccounts { email primary remoteId From 35c4d3e960e448e427f6439f47c3da1cc82a5b9e Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 16:20:21 -0500 Subject: [PATCH 24/53] .map to .forEach --- .../Intergrations/Google/createGoogleIntegration/datahandler.ts | 2 +- .../Intergrations/Google/updateGoogleIntegration/datahandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts index b8d0ff624..22f17c5c2 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts @@ -44,7 +44,7 @@ export const CreateGoogleIntegration = ( data: CreateGoogleIntegrationResponse, ): CreateGoogleIntegrationAttributesCamel => { const attributes = {} as Omit; - Object.keys(data.attributes).map((key) => { + Object.keys(data.attributes).forEach((key) => { attributes[snakeToCamel(key)] = data.attributes[key]; }); diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts index b6438ae4b..dec9271d7 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts @@ -47,7 +47,7 @@ export const UpdateGoogleIntegration = ( GetGoogleAccountIntegrationAttributesCamel, 'id' >; - Object.keys(data.attributes).map((key) => { + Object.keys(data.attributes).forEach((key) => { attributes[snakeToCamel(key)] = data.attributes[key]; }); From 03ff6c44e585731f8600288a2cebae938e443469 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 16:29:38 -0500 Subject: [PATCH 25/53] getMailchimpAccount - to - mailchimpAccount --- .../getMailchimpAccount/resolvers.ts | 15 ------ .../datahandler.ts | 24 ++++----- .../mailchimpAccount.graphql} | 6 +-- .../Mailchimp/mailchimpAccount/resolvers.ts | 15 ++++++ .../api/Schema/SubgraphSchema/Integrations.ts | 8 +-- pages/api/graphql-rest.page.ts | 14 ++--- .../Mailchimp/MailchimpAccordian.test.tsx | 52 +++++++++---------- .../Mailchimp/MailchimpAccordian.tsx | 20 +++---- .../Mailchimp/MailchimpAccount.graphql | 4 +- 9 files changed, 79 insertions(+), 79 deletions(-) delete mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts rename pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/{getMailchimpAccount => mailchimpAccount}/datahandler.ts (64%) rename pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/{getMailchimpAccount/getMailchimpAccount.graphql => mailchimpAccount/mailchimpAccount.graphql} (70%) create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/resolvers.ts diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts deleted file mode 100644 index 1a0f3447b..000000000 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Resolvers } from '../../../../../../graphql-rest.page.generated'; - -const GetMailchimpAccountResolvers: Resolvers = { - Query: { - getMailchimpAccount: async ( - _source, - { input: { accountListId } }, - { dataSources }, - ) => { - return dataSources.mpdxRestApi.getMailchimpAccount(accountListId); - }, - }, -}; - -export { GetMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler.ts similarity index 64% rename from pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts rename to pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler.ts index f386f47d3..05ef3a835 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler.ts @@ -1,17 +1,17 @@ import { snakeToCamel } from 'src/lib/snakeToCamel'; -export interface GetMailchimpAccountResponse { - attributes: Omit; +export interface MailchimpAccountResponse { + attributes: Omit; id: string; type: string; } -export interface GetMailchimpAccount { +export interface MailchimpAccount { id: string; active: boolean; auto_log_campaigns: boolean; created_at: string; - lists_available_for_newsletters: GetMailchimpAccountNewsletters; + lists_available_for_newsletters: MailchimpAccountNewsletters; lists_link: string; lists_present: boolean; primary_list_id: string; @@ -23,17 +23,17 @@ export interface GetMailchimpAccount { validation_error: string; } -interface GetMailchimpAccountNewsletters { +interface MailchimpAccountNewsletters { id: string; name: string; } -interface GetMailchimpAccountCamel { +interface MailchimpAccountCamel { id: string; active: boolean; autoLogCampaigns: boolean; createdAt: string; - listsAvailableForNewsletters: GetMailchimpAccountNewsletters[]; + listsAvailableForNewsletters: MailchimpAccountNewsletters[]; listsLink: string; listsPresent: boolean; primaryListId: string; @@ -45,14 +45,14 @@ interface GetMailchimpAccountCamel { validationError: string; } -export const GetMailchimpAccount = ( - data: GetMailchimpAccountResponse | null, -): GetMailchimpAccountCamel[] => { +export const MailchimpAccount = ( + data: MailchimpAccountResponse | null, +): MailchimpAccountCamel[] => { // Returning inside an array so I can mock an empty response from GraphQL // without the test thinking I want it to create custom random test data. if (!data) return []; - const attributes = {} as Omit; - Object.keys(data.attributes).map((key) => { + const attributes = {} as Omit; + Object.keys(data.attributes).forEach((key) => { attributes[snakeToCamel(key)] = data.attributes[key]; }); return [ diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql similarity index 70% rename from pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql rename to pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql index d192dc180..1f44c7f72 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql @@ -1,5 +1,5 @@ extend type Query { - getMailchimpAccount(input: MailchimpAccountInput!): [MailchimpAccount] + mailchimpAccount(input: MailchimpAccountInput!): [MailchimpAccount] } input MailchimpAccountInput { @@ -11,7 +11,7 @@ type MailchimpAccount { active: Boolean! autoLogCampaigns: Boolean! createdAt: String - listsAvailableForNewsletters: [listsAvailableForNewsletters] + listsAvailableForNewsletters: [ListsAvailableForNewsletters] listsLink: String! listsPresent: Boolean! primaryListId: ID @@ -23,7 +23,7 @@ type MailchimpAccount { validationError: String } -type listsAvailableForNewsletters { +type ListsAvailableForNewsletters { id: ID! name: String! } diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/resolvers.ts new file mode 100644 index 000000000..89d608b82 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const MailchimpAccountResolvers: Resolvers = { + Query: { + mailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.mailchimpAccount(accountListId); + }, + }, +}; + +export { MailchimpAccountResolvers }; diff --git a/pages/api/Schema/SubgraphSchema/Integrations.ts b/pages/api/Schema/SubgraphSchema/Integrations.ts index b5882131e..7215cd005 100644 --- a/pages/api/Schema/SubgraphSchema/Integrations.ts +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -22,8 +22,8 @@ import { DeleteGoogleAccountResolvers } from '../Settings/Preferences/Intergrati // MAILCHIMP INTEGRATION // // Get Account -import GetMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql'; -import { GetMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers'; +import MailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql'; +import { MailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/resolvers'; // Update Account import UpdateMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql'; import { UpdateMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers'; @@ -78,8 +78,8 @@ export const integrationSchema = [ resolvers: CreateGoogleIntegrationResolvers, }, { - typeDefs: GetMailchimpAccountTypeDefs, - resolvers: GetMailchimpAccountResolvers, + typeDefs: MailchimpAccountTypeDefs, + resolvers: MailchimpAccountResolvers, }, { typeDefs: UpdateMailchimpAccountTypeDefs, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 246c76c06..ff3cd35c7 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -95,9 +95,9 @@ import { } from './Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler'; import { - GetMailchimpAccountResponse, - GetMailchimpAccount, -} from './Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler'; + MailchimpAccountResponse, + MailchimpAccount, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler'; import { SyncMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler'; import { DeleteMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler'; import { @@ -969,15 +969,15 @@ class MpdxRestApi extends RESTDataSource { // Mailchimp Integration // // - async getMailchimpAccount(accountListId) { + async mailchimpAccount(accountListId) { // Catch since it will return an error if no account found try { - const { data }: { data: GetMailchimpAccountResponse } = await this.get( + const { data }: { data: MailchimpAccountResponse } = await this.get( `account_lists/${accountListId}/mail_chimp_account`, ); - return GetMailchimpAccount(data); + return MailchimpAccount(data); } catch { - return GetMailchimpAccount(null); + return MailchimpAccount(null); } } diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx index d3f6c61ad..5f6530199 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -2,7 +2,7 @@ import { render, waitFor, within, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; -import { GetMailchimpAccountQuery } from './MailchimpAccount.generated'; +import { MailchimpAccountQuery } from './MailchimpAccount.generated'; import * as Types from '../../../../../graphql/types.generated'; import theme from '../../../../theme'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; @@ -122,11 +122,11 @@ describe('MailchimpAccount', () => { const { getByText } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [], + MailchimpAccount: { + mailchimpAccount: [], }, }} onCall={mutationSpy} @@ -163,11 +163,11 @@ describe('MailchimpAccount', () => { const { queryByText } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -206,11 +206,11 @@ describe('MailchimpAccount', () => { const { queryByText } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -241,11 +241,11 @@ describe('MailchimpAccount', () => { const { getByText, getByRole } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -308,11 +308,11 @@ describe('MailchimpAccount', () => { const { getByText, getByRole } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -366,7 +366,7 @@ describe('MailchimpAccount', () => { }); // refetch account expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( - 'GetMailchimpAccount', + 'MailchimpAccount', ); }); it('should show settings overview', async () => { @@ -375,11 +375,11 @@ describe('MailchimpAccount', () => { const { getByText } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -411,11 +411,11 @@ describe('MailchimpAccount', () => { const { getByText, getByRole } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} @@ -468,11 +468,11 @@ describe('MailchimpAccount', () => { const { queryByText, getByRole } = render( Components( mocks={{ - GetMailchimpAccount: { - getMailchimpAccount: [mailchimpAccount], + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], }, }} onCall={mutationSpy} diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx index 90587e453..436a00f33 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx @@ -21,10 +21,10 @@ import { } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { - useGetMailchimpAccountQuery, + useMailchimpAccountQuery, useUpdateMailchimpAccountMutation, - GetMailchimpAccountDocument, - GetMailchimpAccountQuery, + MailchimpAccountDocument, + MailchimpAccountQuery, useSyncMailchimpAccountMutation, } from './MailchimpAccount.generated'; import * as Types from '../../../../../graphql/types.generated'; @@ -74,8 +74,8 @@ export const MailchimpAccordian: React.FC = ({ const { data, loading, - refetch: refetchGetMailchimpAccount, - } = useGetMailchimpAccountQuery({ + refetch: refetchMailchimpAccount, + } = useMailchimpAccountQuery({ variables: { input: { accountListId: accountListId ?? '', @@ -84,8 +84,8 @@ export const MailchimpAccordian: React.FC = ({ skip: !accountListId, }); - const mailchimpAccount = data?.getMailchimpAccount - ? data.getMailchimpAccount[0] + const mailchimpAccount = data?.mailchimpAccount + ? data.mailchimpAccount[0] : null; useEffect(() => { @@ -121,12 +121,12 @@ export const MailchimpAccordian: React.FC = ({ }, update: (cache) => { const query = { - query: GetMailchimpAccountDocument, + query: MailchimpAccountDocument, variables: { accountListId, }, }; - const dataFromCache = cache.readQuery(query); + const dataFromCache = cache.readQuery(query); if (dataFromCache) { const data = { @@ -382,7 +382,7 @@ export const MailchimpAccordian: React.FC = ({ )} diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql index c0c0e48b3..27e0923e5 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql @@ -1,5 +1,5 @@ -query GetMailchimpAccount($input: MailchimpAccountInput!) { - getMailchimpAccount(input: $input) { +query MailchimpAccount($input: MailchimpAccountInput!) { + mailchimpAccount(input: $input) { id active autoLogCampaigns From e3978b4e1ad0f1e00d2cf3985d6f3f5a43ed5f69 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 16:42:57 -0500 Subject: [PATCH 26/53] getPrayerlettersAccount - to - PrayerlettersAccount --- .../getPrayerlettersAccount/resolvers.ts | 15 ----------- .../datahandler.ts | 14 +++++----- .../prayerlettersAccount.graphql} | 2 +- .../prayerlettersAccount/resolvers.ts | 15 +++++++++++ .../api/Schema/SubgraphSchema/Integrations.ts | 8 +++--- pages/api/graphql-rest.page.ts | 17 ++++++------ .../PrayerlettersAccordian.test.tsx | 26 +++++++++---------- .../Prayerletters/PrayerlettersAccordian.tsx | 8 +++--- .../PrayerlettersAccount.graphql | 4 +-- 9 files changed, 55 insertions(+), 54 deletions(-) delete mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts rename pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/{getPrayerlettersAccount => prayerlettersAccount}/datahandler.ts (59%) rename pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/{getPrayerlettersAccount/getPrayerlettersAccount.graphql => prayerlettersAccount/prayerlettersAccount.graphql} (88%) create mode 100644 pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/resolvers.ts diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts deleted file mode 100644 index e55ebda3b..000000000 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Resolvers } from '../../../../../../graphql-rest.page.generated'; - -const GetPrayerlettersAccountResolvers: Resolvers = { - Query: { - getPrayerlettersAccount: async ( - _source, - { input: { accountListId } }, - { dataSources }, - ) => { - return dataSources.mpdxRestApi.getPrayerlettersAccount(accountListId); - }, - }, -}; - -export { GetPrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler.ts similarity index 59% rename from pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts rename to pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler.ts index 694d77e2b..86bcf9d47 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler.ts @@ -1,10 +1,10 @@ -export interface GetPrayerlettersAccountResponse { - attributes: Omit; +export interface PrayerlettersAccountResponse { + attributes: Omit; id: string; type: string; } -interface GetPrayerlettersAccount { +interface PrayerlettersAccount { id: string; created_at: string; updated_at: string; @@ -12,14 +12,14 @@ interface GetPrayerlettersAccount { valid_token: boolean; } -interface GetPrayerlettersAccountCamel { +interface PrayerlettersAccountCamel { id: string; validToken: boolean; } -export const GetPrayerlettersAccount = ( - data: GetPrayerlettersAccountResponse | null, -): GetPrayerlettersAccountCamel[] => { +export const PrayerlettersAccount = ( + data: PrayerlettersAccountResponse | null, +): PrayerlettersAccountCamel[] => { // Returning inside an array so I can mock an empty response from GraphQL // without the test thinking I want it to create custom random test data. if (!data) return []; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql similarity index 88% rename from pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql rename to pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql index 3873036ad..ef63faf41 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql @@ -1,5 +1,5 @@ extend type Query { - getPrayerlettersAccount( + prayerlettersAccount( input: PrayerlettersAccountInput! ): [PrayerlettersAccount] } diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..6ecbf0baf --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const PrayerlettersAccountResolvers: Resolvers = { + Query: { + prayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.prayerlettersAccount(accountListId); + }, + }, +}; + +export { PrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/SubgraphSchema/Integrations.ts b/pages/api/Schema/SubgraphSchema/Integrations.ts index 7215cd005..811d2c8f4 100644 --- a/pages/api/Schema/SubgraphSchema/Integrations.ts +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -37,8 +37,8 @@ import { DeleteMailchimpAccountResolvers } from '../Settings/Preferences/Intergr // Prayerletters INTEGRATION // // Get Account -import GetPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql'; -import { GetPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers'; +import PrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql'; +import { PrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/resolvers'; // Sync Account import SyncPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql'; import { SyncPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers'; @@ -94,8 +94,8 @@ export const integrationSchema = [ resolvers: DeleteMailchimpAccountResolvers, }, { - typeDefs: GetPrayerlettersAccountTypeDefs, - resolvers: GetPrayerlettersAccountResolvers, + typeDefs: PrayerlettersAccountTypeDefs, + resolvers: PrayerlettersAccountResolvers, }, { typeDefs: SyncPrayerlettersAccountTypeDefs, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index ff3cd35c7..b6d0f73ca 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -105,9 +105,9 @@ import { UpdateMailchimpAccountResponse, } from './Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler'; import { - GetPrayerlettersAccountResponse, - GetPrayerlettersAccount, -} from './Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler'; + PrayerlettersAccountResponse, + PrayerlettersAccount, +} from './Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler'; import { SyncPrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler'; import { DeletePrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler'; import { SendToChalkline } from './Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler'; @@ -1020,14 +1020,15 @@ class MpdxRestApi extends RESTDataSource { // Prayerletters Integration // // - async getPrayerlettersAccount(accountListId) { + async prayerlettersAccount(accountListId) { // Catch since it will return an error if no account found try { - const { data }: { data: GetPrayerlettersAccountResponse } = - await this.get(`account_lists/${accountListId}/prayer_letters_account`); - return GetPrayerlettersAccount(data); + const { data }: { data: PrayerlettersAccountResponse } = await this.get( + `account_lists/${accountListId}/prayer_letters_account`, + ); + return PrayerlettersAccount(data); } catch { - return GetPrayerlettersAccount(null); + return PrayerlettersAccount(null); } } diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx index bad5d2eaa..40badc1c6 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -8,7 +8,7 @@ import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking' import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import * as Types from '../../../../../graphql/types.generated'; import { PrayerlettersAccordian } from './PrayerlettersAccordian'; -import { GetPrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; +import { PrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; jest.mock('next-auth/react'); @@ -92,11 +92,11 @@ describe('PrayerlettersAccount', () => { const { getByText } = render( Components( mocks={{ - GetPrayerlettersAccount: { - getPrayerlettersAccount: [], + PrayerlettersAccount: { + prayerlettersAccount: [], }, }} > @@ -132,11 +132,11 @@ describe('PrayerlettersAccount', () => { const { queryByText, getByText, getByRole } = render( Components( mocks={{ - GetPrayerlettersAccount: { - getPrayerlettersAccount: [prayerlettersAccount], + PrayerlettersAccount: { + prayerlettersAccount: [prayerlettersAccount], }, }} onCall={mutationSpy} @@ -201,11 +201,11 @@ describe('PrayerlettersAccount', () => { const { queryByText, getByRole } = render( Components( mocks={{ - GetPrayerlettersAccount: { - getPrayerlettersAccount: [prayerlettersAccount], + PrayerlettersAccount: { + prayerlettersAccount: [prayerlettersAccount], }, }} onCall={mutationSpy} @@ -264,11 +264,11 @@ describe('PrayerlettersAccount', () => { const { queryByText, getByRole } = render( Components( mocks={{ - GetPrayerlettersAccount: { - getPrayerlettersAccount: [prayerlettersAccount], + PrayerlettersAccount: { + prayerlettersAccount: [prayerlettersAccount], }, }} onCall={mutationSpy} diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx index 696522fe2..e91d251c1 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx @@ -4,7 +4,7 @@ import { useSnackbar } from 'notistack'; import { Box, Typography, Skeleton, Alert, Button } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { - useGetPrayerlettersAccountQuery, + usePrayerlettersAccountQuery, useSyncPrayerlettersAccountMutation, } from './PrayerlettersAccount.generated'; import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; @@ -36,7 +36,7 @@ export const PrayerlettersAccordian: React.FC = ({ data, loading, refetch: refetchPrayerlettersAccount, - } = useGetPrayerlettersAccountQuery({ + } = usePrayerlettersAccountQuery({ variables: { input: { accountListId: accountListId ?? '', @@ -45,8 +45,8 @@ export const PrayerlettersAccordian: React.FC = ({ skip: expandedPanel !== accordianName, }); - const prayerlettersAccount = data?.getPrayerlettersAccount - ? data?.getPrayerlettersAccount[0] + const prayerlettersAccount = data?.prayerlettersAccount + ? data?.prayerlettersAccount[0] : null; useEffect(() => { diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql index 23c367da3..77fd59f1d 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql @@ -1,5 +1,5 @@ -query GetPrayerlettersAccount($input: PrayerlettersAccountInput!) { - getPrayerlettersAccount(input: $input) { +query PrayerlettersAccount($input: PrayerlettersAccountInput!) { + prayerlettersAccount(input: $input) { validToken } } From aa016f23b4fe10ee9867a446dbf36644fa6638bc Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 15 Nov 2023 16:52:22 -0500 Subject: [PATCH 27/53] forEach --- pages/api/graphql-rest.page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index b6d0f73ca..88b2f3121 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -901,7 +901,7 @@ class MpdxRestApi extends RESTDataSource { accountListId, ) { const attributes = {}; - Object.keys(googleIntegration).map((key) => { + Object.keys(googleIntegration).forEach((key) => { attributes[camelToSnake(key)] = googleIntegration[key]; }); const { data }: { data: CreateGoogleIntegrationResponse } = await this.post( From e811a0d255c9b6710e16bd10e3b4b19a8d08733a Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:00:45 -0500 Subject: [PATCH 28/53] Google amendments --- .../settings/integrations/index.page.tsx | 4 +- .../Google/GoogleAccordian.test.tsx | 40 ++--- .../integrations/Google/GoogleAccordian.tsx | 44 +++-- .../Modals/DeleteGoogleAccountModal.tsx | 23 +-- .../Modals/EditGoogleAccountModal.test.tsx | 154 ++++++++---------- .../Google/Modals/EditGoogleAccountModal.tsx | 32 ++-- .../Modals/EditGoogleIntegrationForm.tsx | 6 +- 7 files changed, 137 insertions(+), 166 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index 86bb1b778..4d9fac543 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -21,7 +21,9 @@ interface Props { const Integrations = ({ apiToken }: Props): ReactElement => { const { t } = useTranslation(); const { query } = useRouter(); - const [expandedPanel, setExpandedPanel] = useState(query?.selectedTab || ''); + const [expandedPanel, setExpandedPanel] = useState( + (query?.selectedTab as string) || '', + ); useEffect(() => { suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx index cb614b1cb..f41dee6fb 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -43,12 +43,12 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = (props: { children: JSX.Element }) => ( - {children} + {props.children} @@ -70,14 +70,14 @@ describe('GoogleAccordian', () => { it('should render accordian closed', async () => { const { getByText, queryByRole } = render( - Components( + - , - ), + + , ); expect(getByText('Google')).toBeInTheDocument(); const Image = queryByRole('img', { @@ -87,14 +87,14 @@ describe('GoogleAccordian', () => { }); it('should render accordian open', async () => { const { queryByRole } = render( - Components( + - , - ), + + , ); const Image = queryByRole('img', { name: /google/i, @@ -103,10 +103,11 @@ describe('GoogleAccordian', () => { }); describe('Not Connected', () => { + process.env.SITE_URL = 'https://next.mpdx.org'; it('should render Mailchimp Overview', async () => { const mutationSpy = jest.fn(); const { getByText } = render( - Components( + @@ -121,8 +122,8 @@ describe('GoogleAccordian', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Google'} /> - , - ), + + , ); await waitFor(() => { @@ -132,7 +133,7 @@ describe('GoogleAccordian', () => { expect(getByText(/add account/i)).toHaveAttribute( 'href', - `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, ); }); }); @@ -147,7 +148,7 @@ describe('GoogleAccordian', () => { it('shows one connected account', async () => { const mutationSpy = jest.fn(); const { queryByText, getByText, getByTestId } = render( - Components( + @@ -162,8 +163,8 @@ describe('GoogleAccordian', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Google'} /> - , - ), + + , ); await waitFor(() => { @@ -200,10 +201,11 @@ describe('GoogleAccordian', () => { }); it('shows account with expired token', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; const mutationSpy = jest.fn(); googleAccount.tokenExpired = true; const { getByText, getAllByText } = render( - Components( + @@ -218,8 +220,8 @@ describe('GoogleAccordian', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Google'} /> - , - ), + + , ); await waitFor(() => { @@ -230,7 +232,7 @@ describe('GoogleAccordian', () => { expect(getByText(/click "refresh google account/i)).toBeInTheDocument(); expect(getAllByText(/refresh google account/i)[1]).toHaveAttribute( 'href', - `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, ); }); }); diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.tsx index 21ca26e2b..ad5a03fb1 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordian.tsx @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect } from 'react'; +import { useState, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Box, Card, IconButton, Typography } from '@mui/material'; import Skeleton from '@mui/material/Skeleton'; @@ -81,22 +81,16 @@ export const GoogleAccordian: React.FC = ({ }); const googleAccounts = data?.googleAccounts; const accountListId = useAccountListId(); - const [oAuth, setOAuth] = useState(''); - - useEffect(() => { - setOAuth( - `${ - process.env.OAUTH_URL - }/auth/user/google?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( - `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=Google`, - )}&access_token=${apiToken}`, - ); - }, []); - const { apiToken } = useContext( IntegrationsContext, ) as IntegrationsContextType; + const oAuth = `${ + process.env.OAUTH_URL + }/auth/user/google?account_list_id=${accountListId}&redirect_to=${encodeURIComponent( + `${process.env.SITE_URL}/accountLists/${accountListId}/settings/integrations?selectedTab=Google`, + )}&access_token=${apiToken}`; + const handleEditAccount = (account) => { setSelectedAccount(account); setOpenEditGoogleAccount(true); @@ -125,28 +119,32 @@ export const GoogleAccordian: React.FC = ({ {loading && } {!loading && !googleAccounts?.length && !!expandedPanel && ( <> - Google Integration Overview + + {t('Google Integration Overview')} + - Google’s suite of tools are great at connecting you to your - Ministry Partners. + {t(`Google’s suite of tools are great at connecting you to your + Ministry Partners.`)} - By synchronizing your Google services with MPDX, you will be able - to: + {t(`By synchronizing your Google services with MPDX, you will be able + to:`)} - See MPDX tasks in your Google Calendar + {t('See MPDX tasks in your Google Calendar')} + + + {t('Import Google Contacts into MPDX')} - Import Google Contacts into MPDX - Keep your Contacts in sync with your Google Contacts + {t('Keep your Contacts in sync with your Google Contacts')} - Connect your Google account to begin, and then setup specific + {t(`Connect your Google account to begin, and then setup specific settings for Google Calendar and Contacts. MPDX leaves you in - control of how each service stays in sync. + control of how each service stays in sync.`)} )} diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx index a7177f1bf..592876a24 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -3,11 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; import { styled } from '@mui/material/styles'; import { DialogContent, DialogActions, Typography } from '@mui/material'; -import { - useDeleteGoogleAccountMutation, - GoogleAccountsDocument, - GoogleAccountsQuery, -} from '../googleAccounts.generated'; +import { useDeleteGoogleAccountMutation } from '../googleAccounts.generated'; import Modal from 'src/components/common/Modal/Modal'; import { SubmitButton, @@ -43,20 +39,9 @@ export const DeleteGoogleAccountModal: React.FC< }, }, update: (cache) => { - const query = { - query: GoogleAccountsDocument, - }; - const dataFromCache = cache.readQuery(query); - - if (dataFromCache) { - const removedAccountFromCache = dataFromCache?.googleAccounts.filter( - (acc) => acc?.id !== account.id, - ); - const data = { - googleAccounts: [...removedAccountFromCache], - }; - cache.writeQuery({ ...query, data }); - } + cache.evict({ + id: `GoogleAccountAttributes:${account.id}`, + }); }, onCompleted: () => { enqueueSnackbar(t('MPDX removed your integration with Google.'), { diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 4c7f0a9b6..2c68a3d73 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -65,12 +65,10 @@ const googleAccount = { remoteId: '111222333444', id: 'abcd1234', tokenExpired: false, - __typename: 'GoogleAccountAttributes', }; const standardGoogleIntegration: Pick< Types.GoogleAccountIntegration, - | '__typename' | 'calendarId' | 'calendarIntegration' | 'calendarIntegrations' @@ -81,22 +79,15 @@ const standardGoogleIntegration: Pick< | 'updatedInDbAt' > & { calendars: Array< - Types.Maybe< - { __typename?: 'GoogleAccountIntegrationCalendars' } & Pick< - Types.GoogleAccountIntegrationCalendars, - 'id' | 'name' - > - > + Types.Maybe> >; } = { - __typename: 'GoogleAccountIntegration', calendarId: null, calendarIntegration: true, calendarIntegrations: ['Appointment'], calendarName: 'calendar', calendars: [ { - __typename: 'GoogleAccountIntegrationCalendars', id: 'calendarsID', name: 'calendarsName@cru.org', }, @@ -229,17 +220,14 @@ describe('EditGoogleAccountModal', () => { { id: 'Call', value: 'Call', - __typename: 'IdValue', }, { id: 'Appointment', value: 'Appointment', - __typename: 'IdValue', }, { id: 'Email', value: 'Email', - __typename: 'IdValue', }, ], }, @@ -313,59 +301,45 @@ describe('EditGoogleAccountModal', () => { it('should update calendar checkboxes', async () => { googleIntegration.calendarId = 'calendarsID'; const mutationSpy = jest.fn(); - let getByText, getByRole, getByTestId; - await act(async () => { - const { - getByText: getByTextFromRender, - getByRole: getByRoleFromRender, - getByTestId: getByTestIdFromRender, - } = render( - Components( - - mocks={{ - GoogleAccountIntegrations: { - googleAccountIntegrations: [googleIntegration], - }, - GetIntegrationActivities: { - constant: { - activities: [ - { - id: 'Call', - value: 'Call', - __typename: 'IdValue', - }, - { - id: 'Appointment', - value: 'Appointment', - __typename: 'IdValue', - }, - { - id: 'Email', - value: 'Email', - __typename: 'IdValue', - }, - ], - }, + const { getByText, getByRole, getByTestId } = render( + Components( + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + }, + { + id: 'Appointment', + value: 'Appointment', + }, + { + id: 'Email', + value: 'Email', + }, + ], }, - }} - onCall={mutationSpy} - > - - , - ), - ); - - getByText = getByTextFromRender; - getByRole = getByRoleFromRender; - getByTestId = getByTestIdFromRender; - }); + }, + }} + onCall={mutationSpy} + > + + , + ), + ); await waitFor(() => expect( @@ -373,31 +347,37 @@ describe('EditGoogleAccountModal', () => { ).toBeInTheDocument(), ); - userEvent.click(getByTestId('Call-Checkbox')); - userEvent.click(getByRole('button', { name: /update/i })); + await waitFor(() => + expect(getByTestId('Call-Checkbox')).toBeInTheDocument(), + ); - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith( - 'Updated Google Calendar Integration!', - { - variant: 'success', - }, - ); - expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( - 'UpdateGoogleIntegration', - ); + await act(async () => { + userEvent.click(getByTestId('Call-Checkbox')); + userEvent.click(getByRole('button', { name: /update/i })); - expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ - googleAccountId: googleAccount.id, - googleIntegration: { - calendarId: 'calendarsID', - calendarIntegrations: ['Appointment', 'Call'], - overwrite: true, - }, - googleIntegrationId: googleIntegration.id, + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Updated Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarId: 'calendarsID', + calendarIntegrations: ['Appointment', 'Call'], + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + + expect(handleClose).toHaveBeenCalled(); }); - - expect(handleClose).toHaveBeenCalled(); }); }); diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 3e75ea406..211394abd 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -36,7 +36,7 @@ interface EditGoogleAccountModalProps { oAuth: string; } -enum tabs { +enum tabsEnum { calendar = 'calendar', setup = 'setup', } @@ -52,7 +52,7 @@ export const EditGoogleAccountModal: React.FC = ({ }) => { const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); - const [tabSelected, setTabSelected] = useState(tabs.calendar); + const [tabSelected, setTabSelected] = useState(tabsEnum.calendar); const accountListId = useAccountListId(); const { enqueueSnackbar } = useSnackbar(); @@ -79,7 +79,7 @@ export const EditGoogleAccountModal: React.FC = ({ setTabSelected(tab); }; - const handleToogleCalendarIntegration = async ( + const handleToggleCalendarIntegration = async ( enableIntegration: boolean, ) => { if (!tabSelected) return; @@ -153,7 +153,7 @@ export const EditGoogleAccountModal: React.FC = ({ input: { googleAccountId: account.id, googleIntegrationId: googleAccountDetails?.id ?? '', - integrationName: tabs.calendar, + integrationName: tabsEnum.calendar, }, }, }); @@ -189,11 +189,15 @@ export const EditGoogleAccountModal: React.FC = ({ aria-label="tabs" > - + @@ -206,20 +210,20 @@ export const EditGoogleAccountModal: React.FC = ({ {!loading && googleAccountDetails?.calendarIntegration && - tabSelected === tabs.calendar && ( + tabSelected === tabsEnum.calendar && ( )} {!loading && !googleAccountDetails?.calendarIntegration && - tabSelected === tabs.calendar && ( + tabSelected === tabsEnum.calendar && ( {t(`MPDX can automatically update your google calendar with your tasks. Once you enable this feature, you'll be able to choose which @@ -228,7 +232,7 @@ export const EditGoogleAccountModal: React.FC = ({ )} - {tabSelected === tabs.setup && ( + {tabSelected === tabsEnum.setup && ( {t( `If the link between MPDX and your Google account breaks, @@ -240,19 +244,19 @@ export const EditGoogleAccountModal: React.FC = ({ )} - {tabSelected === tabs.calendar && + {tabSelected === tabsEnum.calendar && !googleAccountDetails?.calendarIntegration && ( handleToogleCalendarIntegration(true)} + onClick={() => handleToggleCalendarIntegration(true)} > {t('Enable Calendar Integration')} )} - {tabSelected === tabs.calendar && + {tabSelected === tabsEnum.calendar && googleAccountDetails?.calendarIntegration && ( = ({ )} - {tabSelected === tabs.setup && ( + {tabSelected === tabsEnum.setup && ( void; - handleToogleCalendarIntegration: (boolean) => void; + handleToggleCalendarIntegration: (boolean) => void; handleClose: () => void; } @@ -63,7 +63,7 @@ export const EditGoogleIntegrationForm: React.FC< googleAccountDetails, loading, setIsSubmitting, - handleToogleCalendarIntegration, + handleToggleCalendarIntegration, handleClose, }) => { const { t } = useTranslation(); @@ -242,7 +242,7 @@ export const EditGoogleIntegrationForm: React.FC< handleToogleCalendarIntegration(false)} + onClick={() => handleToggleCalendarIntegration(false)} variant="outlined" > {t('Disable Calendar Integration')} From 34af2d77157b74d73634b3a5eb64003ada388d8a Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:01:33 -0500 Subject: [PATCH 29/53] Fixing Lint issue with previous MailChimp change --- .../integrations/Mailchimp/MailchimpAccordian.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx index 5f6530199..e1465ecca 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -47,24 +47,20 @@ const Components = (children: React.ReactElement) => ( ); const standardMailchimpAccount: Types.MailchimpAccount = { - __typename: 'MailchimpAccount', id: '123456789', active: true, autoLogCampaigns: false, createdAt: 'DATETIME', listsAvailableForNewsletters: [ { - __typename: 'listsAvailableForNewsletters', id: '11111111', name: 'Newsletter list 1', }, { - __typename: 'listsAvailableForNewsletters', id: '2222222', name: 'Newsletter list 2', }, { - __typename: 'listsAvailableForNewsletters', id: '33333333', name: 'Newsletter list 3', }, From 26ed75b0bc990ba375dfd9a60309e671f777235e Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:19:16 -0500 Subject: [PATCH 30/53] MailChimp amendments --- .../Mailchimp/MailchimpAccordian.test.tsx | 8 ++-- .../Mailchimp/MailchimpAccordian.tsx | 43 ++++++++++++++----- .../Mailchimp/Modals/DeleteMailchimpModal.tsx | 17 +++++--- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx index e1465ecca..3bd6dc785 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -182,7 +182,7 @@ describe('MailchimpAccount', () => { ).toBeInTheDocument(); expect( queryByText( - 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + 'You need to create a list on MailChimp that {{appName}} can use for your newsletter.', ), ).toBeInTheDocument(); expect( @@ -202,7 +202,7 @@ describe('MailchimpAccount', () => { const { queryByText } = render( Components( mocks={{ MailchimpAccount: { @@ -222,7 +222,7 @@ describe('MailchimpAccount', () => { await waitFor(() => { expect( queryByText( - 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + 'You need to create a list on MailChimp that {{appName}} can use for your newsletter.', ), ).toBeInTheDocument(); }); @@ -347,7 +347,7 @@ describe('MailchimpAccount', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX removed your integration with MailChimp', + '{{appName}} removed your integration with MailChimp', { variant: 'success', }, diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx index 436a00f33..7b17d7e3f 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx @@ -20,6 +20,7 @@ import { ListItemText, } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useMailchimpAccountQuery, useUpdateMailchimpAccountMutation, @@ -65,6 +66,7 @@ export const MailchimpAccordian: React.FC = ({ const [showSettings, setShowSettings] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const { apiToken } = useContext( IntegrationsContext, ) as IntegrationsContextType; @@ -199,25 +201,40 @@ export const MailchimpAccordian: React.FC = ({ {loading && } {!loading && !mailchimpAccount && ( <> - MailChimp Overview + {t('MailChimp Overview')} - MailChimp makes keeping in touch with your ministry partners easy - and streamlined. Here’s how it works: + {t(`MailChimp makes keeping in touch with your ministry partners easy + and streamlined. Here's how it works:`)} - If you have an existing MailChimp list you’d like to use, Great! - Or, create a new one for your MPDX connection. + {t( + `If you have an existing MailChimp list you'd like to use, Great! + Or, create a new one for your {{appName}} connection.`, + { + appName, + }, + )} - Select your MPDX MailChimp list to stream your MPDX contacts into. + {t( + 'Select your {{appName}} MailChimp list to stream your {{appName}} contacts into.', + { + appName, + }, + )} - That's it! Set it and leave it! Now your MailChimp list is - continuously up to date with your MPDX Contacts. That's just - the surface. Click over to the MPDX Help site for more in-depth - details. + {t( + `That's it! Set it and leave it! Now your MailChimp list is + continuously up to date with your {{appName}} Contacts. That's just + the surface. Click over to the {{appName}} Help site for more in-depth + details.`, + { + appName, + }, + )} {t('Connect MailChimp')} @@ -317,7 +334,10 @@ export const MailchimpAccordian: React.FC = ({ {t( - 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + 'You need to create a list on MailChimp that {{appName}} can use for your newsletter.', + { + appName, + }, )} {mailchimpAccount?.listsLink && ( @@ -383,6 +403,7 @@ export const MailchimpAccordian: React.FC = ({ accountListId={accountListId ?? ''} handleClose={handleDeleteModalClose} refetchMailchimpAccount={refetchMailchimpAccount} + appName={appName} /> )} diff --git a/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx index 7256f49a6..702759f82 100644 --- a/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx +++ b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx @@ -14,6 +14,7 @@ interface DeleteMailchimpAccountModalProps { handleClose: () => void; accountListId: string; refetchMailchimpAccount: () => void; + appName: string; } const StyledDialogActions = styled(DialogActions)(() => ({ @@ -22,7 +23,7 @@ const StyledDialogActions = styled(DialogActions)(() => ({ export const DeleteMailchimpAccountModal: React.FC< DeleteMailchimpAccountModalProps -> = ({ handleClose, accountListId, refetchMailchimpAccount }) => { +> = ({ handleClose, accountListId, refetchMailchimpAccount, appName }) => { const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -40,14 +41,20 @@ export const DeleteMailchimpAccountModal: React.FC< }, update: () => refetchMailchimpAccount(), onCompleted: () => { - enqueueSnackbar(t('MPDX removed your integration with MailChimp'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} removed your integration with MailChimp', { appName }), + { + variant: 'success', + }, + ); handleClose(); }, onError: () => { enqueueSnackbar( - t("MPDX couldn't save your configuration changes for MailChimp"), + t( + "{{appName}} couldn't save your configuration changes for MailChimp", + { appName }, + ), { variant: 'error', }, From cbafc02f4118cbc0f996f722772c7d8a07a3165b Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:28:57 -0500 Subject: [PATCH 31/53] Changing Accordian to Accordion --- .../settings/integrations/index.page.tsx | 24 ++++++++--------- ...n.test.tsx => ChalklineAccordion.test.tsx} | 12 ++++----- ...neAccordian.tsx => ChalklineAccordion.tsx} | 10 +++---- ...dian.test.tsx => GoogleAccordion.test.tsx} | 18 ++++++------- ...oogleAccordian.tsx => GoogleAccordion.tsx} | 4 +-- .../Modals/DeleteGoogleAccountModal.tsx | 2 +- .../Google/Modals/EditGoogleAccountModal.tsx | 2 +- .../Modals/EditGoogleIntegrationForm.tsx | 2 +- ...heKeyAccordian.tsx => TheKeyAccordion.tsx} | 4 +-- ...n.test.tsx => MailchimpAccordion.test.tsx} | 26 +++++++++---------- ...mpAccordian.tsx => MailchimpAccordion.tsx} | 4 +-- .../Modals/OrganizationAddAccountModal.tsx | 2 +- ...est.tsx => OrganizationAccordion.test.tsx} | 26 +++++++++---------- ...ccordian.tsx => OrganizationAccordion.tsx} | 4 +-- ...st.tsx => PrayerlettersAccordion.test.tsx} | 18 ++++++------- ...cordian.tsx => PrayerlettersAccordion.tsx} | 12 ++++----- .../integrations/integrationsHelper.ts | 2 +- .../Forms/Accordions/AccordionItem.test.tsx | 16 ++++++------ 18 files changed, 94 insertions(+), 94 deletions(-) rename src/components/Settings/integrations/Chalkline/{ChalklineAccordian.test.tsx => ChalklineAccordion.test.tsx} (92%) rename src/components/Settings/integrations/Chalkline/{ChalklineAccordian.tsx => ChalklineAccordion.tsx} (92%) rename src/components/Settings/integrations/Google/{GoogleAccordian.test.tsx => GoogleAccordion.test.tsx} (95%) rename src/components/Settings/integrations/Google/{GoogleAccordian.tsx => GoogleAccordion.tsx} (98%) rename src/components/Settings/integrations/Key/{TheKeyAccordian.tsx => TheKeyAccordion.tsx} (93%) rename src/components/Settings/integrations/Mailchimp/{MailchimpAccordian.test.tsx => MailchimpAccordion.test.tsx} (96%) rename src/components/Settings/integrations/Mailchimp/{MailchimpAccordian.tsx => MailchimpAccordion.tsx} (99%) rename src/components/Settings/integrations/Organization/{OrganizationAccordian.test.tsx => OrganizationAccordion.test.tsx} (95%) rename src/components/Settings/integrations/Organization/{OrganizationAccordian.tsx => OrganizationAccordion.tsx} (98%) rename src/components/Settings/integrations/Prayerletters/{PrayerlettersAccordian.test.tsx => PrayerlettersAccordion.test.tsx} (95%) rename src/components/Settings/integrations/Prayerletters/{PrayerlettersAccordian.tsx => PrayerlettersAccordion.tsx} (95%) diff --git a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx index 4d9fac543..ef9d1980c 100644 --- a/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -5,12 +5,12 @@ import { getToken } from 'next-auth/jwt'; import { useRouter } from 'next/router'; import { suggestArticles } from 'src/lib/helpScout'; import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; -import { TheKeyAccordian } from 'src/components/Settings/integrations/Key/TheKeyAccordian'; -import { OrganizationAccordian } from 'src/components/Settings/integrations/Organization/OrganizationAccordian'; -import { GoogleAccordian } from 'src/components/Settings/integrations/Google/GoogleAccordian'; -import { MailchimpAccordian } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccordian'; -import { PrayerlettersAccordian } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian'; -import { ChalklineAccordian } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordian'; +import { TheKeyAccordion } from 'src/components/Settings/integrations/Key/TheKeyAccordion'; +import { OrganizationAccordion } from 'src/components/Settings/integrations/Organization/OrganizationAccordion'; +import { GoogleAccordion } from 'src/components/Settings/integrations/Google/GoogleAccordion'; +import { MailchimpAccordion } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccordion'; +import { PrayerlettersAccordion } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion'; +import { ChalklineAccordion } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordion'; import { SettingsWrapper } from '../wrapper'; import { IntegrationsContextProvider } from './IntegrationsContext'; @@ -41,29 +41,29 @@ const Integrations = ({ apiToken }: Props): ReactElement => { > - - - - - - diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx similarity index 92% rename from src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx rename to src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx index 81c268e2e..a902a4be9 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx @@ -6,7 +6,7 @@ import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; import theme from '../../../../theme'; import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; -import { ChalklineAccordian } from './ChalklineAccordian'; +import { ChalklineAccordion } from './ChalklineAccordion'; jest.mock('next-auth/react'); @@ -46,11 +46,11 @@ const Components = (children: React.ReactElement) => ( describe('PrayerlettersAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; - it('should render accordian closed', async () => { + it('should render accordion closed', async () => { const { getByText, queryByRole } = render( Components( - @@ -63,11 +63,11 @@ describe('PrayerlettersAccount', () => { }); expect(image).not.toBeInTheDocument(); }); - it('should render accordian open', async () => { + it('should render accordion open', async () => { const { queryByRole } = render( Components( - @@ -85,7 +85,7 @@ describe('PrayerlettersAccount', () => { const { getByText } = render( Components( - diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx similarity index 92% rename from src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx rename to src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx index a2cd34cd5..4b9baa099 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx @@ -7,14 +7,14 @@ import { useAccountListId } from 'src/hooks/useAccountListId'; import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; -import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; +import { StyledServicesButton, AccordionProps } from '../integrationsHelper'; -export const ChalklineAccordian: React.FC = ({ +export const ChalklineAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { const { t } = useTranslation(); - const accordianName = t('Chalk Line'); + const accordionName = t('Chalk Line'); const [showModal, setShowModal] = useState(false); const accountListId = useAccountListId(); const [sendToChalkline] = useSendToChalklineMutation(); @@ -50,12 +50,12 @@ export const ChalklineAccordian: React.FC = ({ } > diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx similarity index 95% rename from src/components/Settings/integrations/Google/GoogleAccordian.test.tsx rename to src/components/Settings/integrations/Google/GoogleAccordion.test.tsx index f41dee6fb..3449df746 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx @@ -7,7 +7,7 @@ import TestRouter from '__tests__/util/TestRouter'; import theme from '../../../../theme'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; -import { GoogleAccordian } from './GoogleAccordian'; +import { GoogleAccordion } from './GoogleAccordion'; import { GoogleAccountsQuery } from './googleAccounts.generated'; jest.mock('next-auth/react'); @@ -64,15 +64,15 @@ const standardGoogleAccount = { __typename: 'GoogleAccountAttributes', }; -describe('GoogleAccordian', () => { +describe('GoogleAccordion', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; (getSession as jest.Mock).mockResolvedValue(session); - it('should render accordian closed', async () => { + it('should render accordion closed', async () => { const { getByText, queryByRole } = render( - @@ -85,11 +85,11 @@ describe('GoogleAccordian', () => { }); expect(Image).not.toBeInTheDocument(); }); - it('should render accordian open', async () => { + it('should render accordion open', async () => { const { queryByRole } = render( - @@ -118,7 +118,7 @@ describe('GoogleAccordian', () => { }} onCall={mutationSpy} > - @@ -159,7 +159,7 @@ describe('GoogleAccordian', () => { }} onCall={mutationSpy} > - @@ -216,7 +216,7 @@ describe('GoogleAccordian', () => { }} onCall={mutationSpy} > - diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.tsx similarity index 98% rename from src/components/Settings/integrations/Google/GoogleAccordian.tsx rename to src/components/Settings/integrations/Google/GoogleAccordion.tsx index ad5a03fb1..7d2ca8daf 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordian.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.tsx @@ -24,7 +24,7 @@ import { StyledServicesButton, } from '../integrationsHelper'; -interface GoogleAccordianProps { +interface GoogleAccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; } @@ -66,7 +66,7 @@ export type GoogleAccountAttributesSlimmed = Pick< 'id' | 'email' | 'primary' | 'remoteId' | 'tokenExpired' >; -export const GoogleAccordian: React.FC = ({ +export const GoogleAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx index 592876a24..f29ac171a 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -9,7 +9,7 @@ import { SubmitButton, CancelButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; -import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordion'; interface DeleteGoogleAccountModalProps { handleClose: () => void; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 211394abd..71096fc1e 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -27,7 +27,7 @@ import { CancelButton, ActionButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; -import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordion'; import { EditGoogleIntegrationForm } from './EditGoogleIntegrationForm'; interface EditGoogleAccountModalProps { diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx index ab400ac08..e557d88ec 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -27,7 +27,7 @@ import { SubmitButton, DeleteButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; -import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordion'; type GoogleAccountIntegrationSlimmed = Pick< GoogleAccountIntegration, diff --git a/src/components/Settings/integrations/Key/TheKeyAccordian.tsx b/src/components/Settings/integrations/Key/TheKeyAccordion.tsx similarity index 93% rename from src/components/Settings/integrations/Key/TheKeyAccordian.tsx rename to src/components/Settings/integrations/Key/TheKeyAccordion.tsx index db93a2ec7..cd3c77851 100644 --- a/src/components/Settings/integrations/Key/TheKeyAccordian.tsx +++ b/src/components/Settings/integrations/Key/TheKeyAccordion.tsx @@ -5,12 +5,12 @@ import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionI import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; import { useGetKeyAccountsQuery } from './Key.generated'; -interface TheKeyAccordianProps { +interface TheKeyAccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; } -export const TheKeyAccordian: React.FC = ({ +export const TheKeyAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx similarity index 96% rename from src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx rename to src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx index 3bd6dc785..e388cd710 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx @@ -8,7 +8,7 @@ import theme from '../../../../theme'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import TestRouter from '__tests__/util/TestRouter'; -import { MailchimpAccordian } from './MailchimpAccordian'; +import { MailchimpAccordion } from './MailchimpAccordion'; jest.mock('next-auth/react'); @@ -78,11 +78,11 @@ const standardMailchimpAccount: Types.MailchimpAccount = { describe('MailchimpAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; - it('should render accordian closed', async () => { + it('should render accordion closed', async () => { const { getByText, queryByRole } = render( Components( - @@ -95,11 +95,11 @@ describe('MailchimpAccount', () => { }); expect(mailchimpImage).not.toBeInTheDocument(); }); - it('should render accordian open', async () => { + it('should render accordion open', async () => { const { queryByRole } = render( Components( - @@ -127,7 +127,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -168,7 +168,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -211,7 +211,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -246,7 +246,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -313,7 +313,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -380,7 +380,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -416,7 +416,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - @@ -473,7 +473,7 @@ describe('MailchimpAccount', () => { }} onCall={mutationSpy} > - diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx similarity index 99% rename from src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx rename to src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx index 7b17d7e3f..944849070 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx @@ -43,7 +43,7 @@ import { StyledServicesButton, } from '../integrationsHelper'; -interface MailchimpAccordianProps { +interface MailchimpAccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; } @@ -57,7 +57,7 @@ const StyledButton = styled(Button)(() => ({ marginLeft: '15px', })); -export const MailchimpAccordian: React.FC = ({ +export const MailchimpAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index cd52d694e..ca670f51c 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -31,7 +31,7 @@ import { import { getOrganizationType, OrganizationTypesEnum, -} from '../OrganizationAccordian'; +} from '../OrganizationAccordion'; import { oAuth } from '../OrganizationService'; interface OrganizationAddAccountModalProps { diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx similarity index 95% rename from src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx rename to src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx index 179dc7791..2d878d829 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -11,7 +11,7 @@ import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking' import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import theme from '../../../../theme'; import TestRouter from '__tests__/util/TestRouter'; -import { OrganizationAccordian } from './OrganizationAccordian'; +import { OrganizationAccordion } from './OrganizationAccordion'; jest.mock('next-auth/react'); @@ -96,13 +96,13 @@ const standardMocks = { }, }; -describe('OrganizationAccordian', () => { +describe('OrganizationAccordion', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; - it('should render accordian closed', async () => { + it('should render accordion closed', async () => { const { getByText, queryByRole } = render( Components( - @@ -115,11 +115,11 @@ describe('OrganizationAccordian', () => { }); expect(image).not.toBeInTheDocument(); }); - it('should render accordian open', async () => { + it('should render accordion open', async () => { const { queryByRole } = render( Components( - @@ -149,7 +149,7 @@ describe('OrganizationAccordian', () => { }, }} > - @@ -182,7 +182,7 @@ describe('OrganizationAccordian', () => { }> mocks={mocks} > - @@ -228,7 +228,7 @@ describe('OrganizationAccordian', () => { mocks={mocks} onCall={mutationSpy} > - @@ -276,7 +276,7 @@ describe('OrganizationAccordian', () => { mocks={mocks} onCall={mutationSpy} > - @@ -311,7 +311,7 @@ describe('OrganizationAccordian', () => { mocks={mocks} onCall={mutationSpy} > - @@ -348,7 +348,7 @@ describe('OrganizationAccordian', () => { mocks={mocks} onCall={mutationSpy} > - @@ -396,7 +396,7 @@ describe('OrganizationAccordian', () => { }> mocks={mocks} > - diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx similarity index 98% rename from src/components/Settings/integrations/Organization/OrganizationAccordian.tsx rename to src/components/Settings/integrations/Organization/OrganizationAccordion.tsx index cef10200a..2603f2c4c 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -28,7 +28,7 @@ import { OrganizationEditAccountModal } from './Modals/OrganizationEditAccountMo import { oAuth } from './OrganizationService'; import { StyledServicesButton } from '../integrationsHelper'; -interface OrganizationAccordianProps { +interface OrganizationAccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; } @@ -76,7 +76,7 @@ export const getOrganizationType = (apiClass, oauth) => { return undefined; }; -export const OrganizationAccordian: React.FC = ({ +export const OrganizationAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx similarity index 95% rename from src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx rename to src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx index 40badc1c6..b002dcbef 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx @@ -7,7 +7,7 @@ import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '../../../../../__tests__/util/graphqlMocking'; import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import * as Types from '../../../../../graphql/types.generated'; -import { PrayerlettersAccordian } from './PrayerlettersAccordian'; +import { PrayerlettersAccordion } from './PrayerlettersAccordion'; import { PrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; jest.mock('next-auth/react'); @@ -53,11 +53,11 @@ const standardPrayerlettersAccount: Types.PrayerlettersAccount = { describe('PrayerlettersAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; - it('should render accordian closed', async () => { + it('should render accordion closed', async () => { const { getByText, queryByRole } = render( Components( - @@ -70,11 +70,11 @@ describe('PrayerlettersAccount', () => { }); expect(image).not.toBeInTheDocument(); }); - it('should render accordian open', async () => { + it('should render accordion open', async () => { const { queryByRole } = render( Components( - @@ -100,7 +100,7 @@ describe('PrayerlettersAccount', () => { }, }} > - @@ -141,7 +141,7 @@ describe('PrayerlettersAccount', () => { }} onCall={mutationSpy} > - @@ -210,7 +210,7 @@ describe('PrayerlettersAccount', () => { }} onCall={mutationSpy} > - @@ -273,7 +273,7 @@ describe('PrayerlettersAccount', () => { }} onCall={mutationSpy} > - diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx similarity index 95% rename from src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx rename to src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx index e91d251c1..d4e3c0ab8 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx @@ -14,9 +14,9 @@ import { } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { DeletePrayerlettersAccountModal } from './Modals/DeletePrayerlettersModal'; -import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; +import { StyledServicesButton, AccordionProps } from '../integrationsHelper'; -export const PrayerlettersAccordian: React.FC = ({ +export const PrayerlettersAccordion: React.FC = ({ handleAccordionChange, expandedPanel, }) => { @@ -30,7 +30,7 @@ export const PrayerlettersAccordian: React.FC = ({ IntegrationsContext, ) as IntegrationsContextType; const accountListId = useAccountListId(); - const accordianName = t('prayerletters.com'); + const accordionName = t('prayerletters.com'); const [syncPrayerlettersAccount] = useSyncPrayerlettersAccountMutation(); const { data, @@ -42,7 +42,7 @@ export const PrayerlettersAccordian: React.FC = ({ accountListId: accountListId ?? '', }, }, - skip: expandedPanel !== accordianName, + skip: expandedPanel !== accordionName, }); const prayerlettersAccount = data?.prayerlettersAccount @@ -99,12 +99,12 @@ export const PrayerlettersAccordian: React.FC = ({ } > diff --git a/src/components/Settings/integrations/integrationsHelper.ts b/src/components/Settings/integrations/integrationsHelper.ts index 14918131a..8e9d81a1b 100644 --- a/src/components/Settings/integrations/integrationsHelper.ts +++ b/src/components/Settings/integrations/integrationsHelper.ts @@ -14,7 +14,7 @@ export const StyledServicesButton = styled(Button)(({ theme }) => ({ marginTop: theme.spacing(2), })); -export interface AccordianProps { +export interface AccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; } diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx index 03b6f4fbc..6f8a3ee32 100644 --- a/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx +++ b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx @@ -11,7 +11,7 @@ describe('AccordionItem', () => { beforeEach(() => { onAccordionChange.mockClear(); }); - it('Should not render Accordian Details', () => { + it('Should not render Accordion Details', () => { const { queryByText } = render( { @@ -75,15 +75,15 @@ describe('AccordionItem', () => { , ); - expect(getByText('AccordianValue')).toBeInTheDocument(); + expect(getByText('AccordionValue')).toBeInTheDocument(); }); - it('Should render Accordian Details', () => { + it('Should render Accordion Details', () => { const { getByText } = render( @@ -100,7 +100,7 @@ describe('AccordionItem', () => { { { From 70e4243174c405a2344fa38f4376a7f7673b5302 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:40:47 -0500 Subject: [PATCH 32/53] Fixing MailChimp lint issue --- .../Settings/integrations/Mailchimp/MailchimpAccordion.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx index 944849070..c5091526f 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx @@ -400,10 +400,10 @@ export const MailchimpAccordion: React.FC = ({ {showDeleteModal && ( )} From 71a5e7d627a50db440bfcd5e1c7996012456db69 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 10:54:13 -0500 Subject: [PATCH 33/53] Switching mpdx to appName --- .../OrganizationAddAccountModal.test.tsx | 8 ++-- .../Modals/OrganizationAddAccountModal.tsx | 30 +++++++++----- .../OrganizationEditAccountModal.test.tsx | 2 +- .../Modals/OrganizationEditAccountModal.tsx | 11 ++++-- .../OrganizationImportDataSyncModal.tsx | 3 +- .../OrganizationAccordion.test.tsx | 4 +- .../Organization/OrganizationAccordion.tsx | 39 ++++++++++++------- .../Organization/OrganizationService.ts | 4 +- 8 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index f7d0bdf81..6c5922396 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -153,7 +153,7 @@ describe('OrganizationAddAccountModal', () => { }); await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX added your organization account', + '{{appName}} added your organization account', { variant: 'success' }, ); expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( @@ -199,7 +199,7 @@ describe('OrganizationAddAccountModal', () => { await waitFor(() => { expect( - getByText('You must log into MPDX with your ministry email'), + getByText('You must log into {{appName}} with your ministry email'), ).toBeInTheDocument(); expect(getByText('Add Account')).toBeDisabled(); }); @@ -254,7 +254,7 @@ describe('OrganizationAddAccountModal', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX added your organization account', + '{{appName}} added your organization account', { variant: 'success' }, ); expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( @@ -302,7 +302,7 @@ describe('OrganizationAddAccountModal', () => { await waitFor(() => { expect( getByText( - "You will be taken to your organization's donation services system to grant MPDX permission to access your donation data.", + "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(); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index ca670f51c..0dfe231bd 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -14,6 +14,7 @@ import { Link, } from '@mui/material'; import { styled } from '@mui/material/styles'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useGetOrganizationsQuery, useCreateOrganizationAccountMutation, @@ -32,7 +33,7 @@ import { getOrganizationType, OrganizationTypesEnum, } from '../OrganizationAccordion'; -import { oAuth } from '../OrganizationService'; +import { getOauthUrl } from '../OrganizationService'; interface OrganizationAddAccountModalProps { handleClose: () => void; @@ -70,6 +71,7 @@ export const OrganizationAddAccountModal: React.FC< > = ({ handleClose, refetchOrganizations, accountListId }) => { const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const [organizationType, setOrganizationType] = useState(); const [createOrganizationAccount] = useCreateOrganizationAccountMutation(); @@ -85,7 +87,7 @@ export const OrganizationAddAccountModal: React.FC< t('Redirecting you to complete authenication to connect.'), { variant: 'success' }, ); - window.location.href = await oAuth(id); + window.location.href = await getOauthUrl(id); return; } @@ -118,9 +120,12 @@ export const OrganizationAddAccountModal: React.FC< }); }, onCompleted: () => { - enqueueSnackbar(t('MPDX added your organization account'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} added your organization account', { appName }), + { + variant: 'success', + }, + ); }, }); handleClose(); @@ -239,11 +244,14 @@ export const OrganizationAddAccountModal: React.FC< variant="h6" color={theme.palette.mpdxYellow.contrastText} > - {t('You must log into MPDX with your ministry email')} + {t('You must log into {{appName}} with your ministry email', { + appName, + })} {t( - 'This organization requires you to log into MPDX with your ministry email to access it.', + 'This organization requires you to log into {{appName}} with your ministry email to access it.', + { appName }, )}
    - {t('click here to log out of MPDX')} + {t('click here to log out of {{appName}}', { appName })} {t( ' so you can log back in with your offical key account.', @@ -284,7 +292,8 @@ export const OrganizationAddAccountModal: React.FC< "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 MPDX to sync your data.", + "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", + { appName }, )} @@ -294,7 +303,8 @@ export const OrganizationAddAccountModal: React.FC< {t( - "You will be taken to your organization's donation services system to grant MPDX permission to access your donation data.", + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + { appName }, )} diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx index 40716a9f5..95e69eee4 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -105,7 +105,7 @@ describe('OrganizationEditAccountModal', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX updated your organization account', + '{{appName}} updated your organization account', { variant: 'success' }, ); expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx index 353b73189..01cfc6634 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx @@ -5,6 +5,7 @@ import { Formik } from 'formik'; import * as yup from 'yup'; import { Box, DialogActions, TextField, FormHelperText } from '@mui/material'; import { styled } from '@mui/material/styles'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useUpdateOrganizationAccountMutation } from '../Organizations.generated'; import Modal from 'src/components/common/Modal/Modal'; import { @@ -28,6 +29,7 @@ export const OrganizationEditAccountModal: React.FC< > = ({ handleClose, organizationId }) => { const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const [updateOrganizationAccount] = useUpdateOrganizationAccountMutation(); const onSubmit = async ( @@ -53,9 +55,12 @@ export const OrganizationEditAccountModal: React.FC< }); }, onCompleted: () => { - enqueueSnackbar(t('MPDX updated your organization account'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} updated your organization account', { appName }), + { + variant: 'success', + }, + ); }, }); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx index e6dabb4c9..a22408abc 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -47,7 +47,6 @@ export const OrganizationImportDataSyncModal: React.FC< if (!importFile) throw new Error('Please select a file to upload.'); // TODO setIsSubmitting(true); - setIsSubmitting(false); const form = new FormData(); form.append('accountListId', accountListId); @@ -58,7 +57,7 @@ export const OrganizationImportDataSyncModal: React.FC< method: 'POST', body: form, }).catch(() => { - throw new Error(t('Cannot upload avatar: server error')); + throw new Error(t('Cannot upload file: server error')); }); if (res.status === 201) { diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx index 2d878d829..5e9073656 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -248,7 +248,7 @@ describe('OrganizationAccordion', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + '{{appName}} started syncing your organization account. This will occur in the background over the next 24-hours.', { variant: 'success', }, @@ -377,7 +377,7 @@ describe('OrganizationAccordion', () => { id: mocks.GetUsersOrganizations.userOrganizationAccounts[0].id, }); expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX removed your organization integration', + '{{appName}} removed your organization integration', { variant: 'success' }, ); }); diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx index 2603f2c4c..a663b47f7 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -14,6 +14,7 @@ import { import DeleteIcon from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useGetUsersOrganizationsQuery, useDeleteOrganizationAccountMutation, @@ -25,7 +26,7 @@ import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmat import { OrganizationAddAccountModal } from './Modals/OrganizationAddAccountModal'; import { OrganizationImportDataSyncModal } from './Modals/OrganizationImportDataSyncModal'; import { OrganizationEditAccountModal } from './Modals/OrganizationEditAccountModal'; -import { oAuth } from './OrganizationService'; +import { getOauthUrl } from './OrganizationService'; import { StyledServicesButton } from '../integrationsHelper'; interface OrganizationAccordionProps { @@ -83,6 +84,7 @@ export const OrganizationAccordion: React.FC = ({ const { t } = useTranslation(); const accountListId = useAccountListId(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const [showAddAccountModal, setShowAddAccountModal] = useState(false); const [showImportDataSyncModal, setShowImportDataSyncModal] = useState(false); const [showDeleteOrganizationModal, setShowDeleteOrganizationModal] = @@ -104,7 +106,7 @@ export const OrganizationAccordion: React.FC = ({ t('Redirecting you to complete authenication to reconnect.'), { variant: 'success' }, ); - const oAuthUrl = await oAuth(organizationId); + const oAuthUrl = await getOauthUrl(organizationId); window.location.href = oAuthUrl; }; @@ -116,14 +118,18 @@ export const OrganizationAccordion: React.FC = ({ }, }, onError: () => { - enqueueSnackbar(t("MPDX couldn't sync your organization account"), { - variant: 'error', - }); + enqueueSnackbar( + t("{{appName}} couldn't sync your organization account", { appName }), + { + variant: 'error', + }, + ); }, onCompleted: () => { enqueueSnackbar( t( - 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + '{{appName}} started syncing your organization account. This will occur in the background over the next 24-hours.', + { appName }, ), { variant: 'success', @@ -144,7 +150,8 @@ export const OrganizationAccordion: React.FC = ({ onError: () => { enqueueSnackbar( t( - "MPDX couldn't save your configuration changes for that organization", + "{{appName}} couldn't save your configuration changes for that organization", + { appName }, ), { variant: 'error', @@ -152,9 +159,12 @@ export const OrganizationAccordion: React.FC = ({ ); }, onCompleted: () => { - enqueueSnackbar(t('MPDX removed your organization integration'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} removed your organization integration', { appName }), + { + variant: 'success', + }, + ); }, }); }; @@ -176,9 +186,12 @@ export const OrganizationAccordion: React.FC = ({ } > - {t(`Add or change the organizations that sync donation information with this - MPDX account. Removing an organization will not remove past information, - but will prevent future donations and contacts from syncing.`)} + {t( + `Add or change the organizations that sync donation information with this + {{appName}} account. Removing an organization will not remove past information, + but will prevent future donations and contacts from syncing.`, + { appName }, + )} {!loading && !organizations?.length && ( diff --git a/src/components/Settings/integrations/Organization/OrganizationService.ts b/src/components/Settings/integrations/Organization/OrganizationService.ts index 3128ba78c..737d7c9b1 100644 --- a/src/components/Settings/integrations/Organization/OrganizationService.ts +++ b/src/components/Settings/integrations/Organization/OrganizationService.ts @@ -2,8 +2,8 @@ import { getSession } from 'next-auth/react'; import Router from 'next/router'; import { getQueryParam } from 'src/utils/queryParam'; -export const oAuth = async ( - organizationId, +export const getOauthUrl = async ( + organizationId: string, route = 'preferences/integrations?selectedTab=organization', ) => { const session = await getSession(); From 7fc1c62847125bb5b5a6d4bdd46fed4cfc9511aa Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 14:47:32 -0500 Subject: [PATCH 34/53] Organization amendments --- .../OrganizationAddAccountModal.test.tsx | 2 +- .../Modals/OrganizationAddAccountModal.tsx | 2 +- .../OrganizationEditAccountModal.test.tsx | 2 -- .../OrganizationImportDataSyncModal.test.tsx | 4 +--- .../Organization/OrganizationAccordion.tsx | 20 ++++++++++--------- .../Organization/Organizations.graphql | 2 -- 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index 6c5922396..ae6c31848 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -312,7 +312,7 @@ describe('OrganizationAddAccountModal', () => { userEvent.click(getByText('Connect')); await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'Redirecting you to complete authenication to connect.', + '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 0dfe231bd..d8eadc5f0 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -84,7 +84,7 @@ export const OrganizationAddAccountModal: React.FC< if (type === OrganizationTypesEnum.OAUTH) { enqueueSnackbar( - t('Redirecting you to complete authenication to connect.'), + t('Redirecting you to complete authentication to connect.'), { variant: 'success' }, ); window.location.href = await getOauthUrl(id); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx index 95e69eee4..9548bece2 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -44,14 +44,12 @@ const Components = (children: React.ReactElement) => ( ); const handleClose = jest.fn(); -const refetchOrganizations = jest.fn(); describe('OrganizationEditAccountModal', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; beforeEach(() => { handleClose.mockClear(); - refetchOrganizations.mockClear(); }); it('should render modal', async () => { const { getByText, getByTestId } = render( diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index fdeef9124..0d0e5a883 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -47,14 +47,12 @@ const Components = (children: React.ReactElement) => ( ); const handleClose = jest.fn(); -const refetchOrganizations = jest.fn(); describe('OrganizationImportDataSyncModal', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; beforeEach(() => { handleClose.mockClear(); - refetchOrganizations.mockClear(); (validateFile as jest.Mock).mockReturnValue({ success: true }); }); it('should render modal', async () => { @@ -105,7 +103,7 @@ describe('OrganizationImportDataSyncModal', () => { ); }); - it('should inform user of the error when uploadiung file.', async () => { + it('should inform user of the error when uploading file.', async () => { (validateFile as jest.Mock).mockReturnValue({ success: false, message: 'Invalid file', diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx index a663b47f7..8a9a553cf 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -50,27 +50,29 @@ export enum OrganizationTypesEnum { OFFLINE = 'offline', } -export const getOrganizationType = (apiClass, oauth) => { - const ministryAccount = [ +export const getOrganizationType = ( + apiClass: string | undefined, + oauth = false, +) => { + const ministryAccount = new Set([ 'Siebel', 'Remote::Import::OrganizationAccountService', - ]; - const loginRequired = [ + ]); + const loginRequired = new Set([ 'DataServer', 'DataServerPtc', 'DataServerNavigators', 'DataServerStumo', - ]; - const offline = ['OfflineOrg']; + ]); if (apiClass) { - if (ministryAccount.indexOf(apiClass) !== -1) { + if (ministryAccount.has(apiClass)) { return OrganizationTypesEnum.MINISTRY; - } else if (loginRequired.indexOf(apiClass) !== -1 && !oauth) { + } else if (loginRequired.has(apiClass) && !oauth) { return OrganizationTypesEnum.LOGIN; } else if (oauth) { return OrganizationTypesEnum.OAUTH; - } else if (offline.indexOf(apiClass) !== -1) { + } else if (apiClass === 'OfflineOrg') { return OrganizationTypesEnum.OFFLINE; } } diff --git a/src/components/Settings/integrations/Organization/Organizations.graphql b/src/components/Settings/integrations/Organization/Organizations.graphql index cf0ffdb84..553655763 100644 --- a/src/components/Settings/integrations/Organization/Organizations.graphql +++ b/src/components/Settings/integrations/Organization/Organizations.graphql @@ -27,7 +27,6 @@ mutation DeleteOrganizationAccount( $input: OrganizationAccountDeleteMutationInput! ) { deleteOrganizationAccount(input: $input) { - clientMutationId id } } @@ -36,7 +35,6 @@ mutation CreateOrganizationAccount( $input: OrganizationAccountCreateMutationInput! ) { createOrganizationAccount(input: $input) { - clientMutationId organizationAccount { username person { From f3dfcfdd30d7ce3ecad320f8443f3d8f6deea885 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 15:22:58 -0500 Subject: [PATCH 35/53] PrayerLetter amendments --- .../Modals/DeletePrayerlettersModal.tsx | 20 +++++-- .../PrayerlettersAccordion.test.tsx | 22 ++++--- .../Prayerletters/PrayerlettersAccordion.tsx | 59 +++++++++++-------- 3 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx index 0b5400445..93bee9471 100644 --- a/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx +++ b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; import { styled } from '@mui/material/styles'; import { DialogContent, DialogActions, Typography } from '@mui/material'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useDeletePrayerlettersAccountMutation } from '../PrayerlettersAccount.generated'; import Modal from 'src/components/common/Modal/Modal'; import { @@ -24,6 +25,7 @@ export const DeletePrayerlettersAccountModal: React.FC< DeletePrayerlettersAccountModalProps > = ({ handleClose, accountListId, refetchPrayerlettersAccount }) => { const { t } = useTranslation(); + const { appName } = useGetAppSettings(); const [isSubmitting, setIsSubmitting] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -35,17 +37,25 @@ export const DeletePrayerlettersAccountModal: React.FC< await deletePrayerlettersAccount({ variables: { input: { - accountListId: accountListId, + accountListId, }, }, update: () => refetchPrayerlettersAccount(), }); - enqueueSnackbar(t('MPDX removed your integration with Prayer Letters'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} removed your integration with Prayer Letters', { + appName, + }), + { + variant: 'success', + }, + ); } catch { enqueueSnackbar( - t("MPDX couldn't save your configuration changes for Prayer Letters"), + t( + "{{appName}} couldn't save your configuration changes for Prayer Letters", + { appName }, + ), { variant: 'error', }, diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx index b002dcbef..310a5c76e 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx @@ -89,10 +89,11 @@ describe('PrayerlettersAccount', () => { describe('Not Connected', () => { it('should render PrayerLetters.com Overview', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; const { getByText } = render( Components( mocks={{ PrayerlettersAccount: { @@ -115,7 +116,7 @@ describe('PrayerlettersAccount', () => { expect(getByText('Connect prayerletters.com Account')).toHaveAttribute( 'href', - `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, ); }); }); @@ -127,6 +128,7 @@ describe('PrayerlettersAccount', () => { prayerlettersAccount = { ...standardPrayerlettersAccount }; }); it('is connected but token is not valid', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; prayerlettersAccount.validToken = false; const mutationSpy = jest.fn(); const { queryByText, getByText, getByRole } = render( @@ -157,7 +159,7 @@ describe('PrayerlettersAccount', () => { expect(getByText('Refresh prayerletters.com Account')).toHaveAttribute( 'href', - `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, ); userEvent.click( @@ -182,7 +184,7 @@ describe('PrayerlettersAccount', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX removed your integration with Prayer Letters', + '{{appName}} removed your integration with Prayer Letters', { variant: 'success', }, @@ -220,7 +222,9 @@ describe('PrayerlettersAccount', () => { await waitFor(() => { expect( - queryByText('We strongly recommend only making changes in MPDX.'), + queryByText( + 'We strongly recommend only making changes in {{appName}}.', + ), ).toBeInTheDocument(); }); @@ -245,7 +249,7 @@ describe('PrayerlettersAccount', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX removed your integration with Prayer Letters', + '{{appName}} removed your integration with Prayer Letters', { variant: 'success', }, @@ -283,7 +287,9 @@ describe('PrayerlettersAccount', () => { await waitFor(() => { expect( - queryByText('We strongly recommend only making changes in MPDX.'), + queryByText( + 'We strongly recommend only making changes in {{appName}}.', + ), ).toBeInTheDocument(); }); @@ -295,7 +301,7 @@ describe('PrayerlettersAccount', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX is now syncing your newsletter recipients with Prayer Letters', + '{{appName}} is now syncing your newsletter recipients with Prayer Letters', { variant: 'success', }, diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx index d4e3c0ab8..4674b4f62 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx @@ -1,8 +1,9 @@ -import { useState, useContext, useEffect } from 'react'; +import { useState, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; import { Box, Typography, Skeleton, Alert, Button } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { usePrayerlettersAccountQuery, useSyncPrayerlettersAccountMutation, @@ -21,11 +22,11 @@ export const PrayerlettersAccordion: React.FC = ({ expandedPanel, }) => { const { t } = useTranslation(); - const [oAuth, setOAuth] = useState(''); const [isSaving, setIsSaving] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); showDeleteModal; const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const { apiToken } = useContext( IntegrationsContext, ) as IntegrationsContextType; @@ -49,15 +50,11 @@ export const PrayerlettersAccordion: React.FC = ({ ? data?.prayerlettersAccount[0] : null; - useEffect(() => { - setOAuth( - `${ - process.env.OAUTH_URL - }/auth/user/prayer_letters?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( - `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=prayerletters.com`, - )}&access_token=${apiToken}`, - ); - }, []); + const oAuth = `${ + process.env.OAUTH_URL + }/auth/user/prayer_letters?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${process.env.SITE_URL}/accountLists/${accountListId}/settings/integrations?selectedTab=prayerletters.com`, + )}&access_token=${apiToken}`; const handleSync = async () => { setIsSaving(true); @@ -70,7 +67,10 @@ export const PrayerlettersAccordion: React.FC = ({ }, onError: () => { enqueueSnackbar( - t("MPDX couldn't save your configuration changes for Prayer Letters"), + t( + "{{appName}} couldn't save your configuration changes for Prayer Letters", + { appName }, + ), { variant: 'error', }, @@ -79,7 +79,8 @@ export const PrayerlettersAccordion: React.FC = ({ onCompleted: () => { enqueueSnackbar( t( - 'MPDX is now syncing your newsletter recipients with Prayer Letters', + '{{appName}} is now syncing your newsletter recipients with Prayer Letters', + { appName }, ), { variant: 'success', @@ -113,17 +114,23 @@ export const PrayerlettersAccordion: React.FC = ({ <> {t('PrayerLetters.com Overview')} - {t(`prayerletters.com is a significant way to save valuable ministry + {t( + `prayerletters.com is a significant way to save valuable ministry time while more effectively connecting with your partners. Keep your - physical newsletter list up to date in MPDX and then sync it to your - prayerletters.com account with this integration.`)} + physical newsletter list up to date in {{appName}} and then sync it to your + prayerletters.com account with this integration.`, + { appName }, + )} - {t(`By clicking "Connect prayerletters.com Account" you will - replace your entire prayerletters.com list with what is in MPDX. Any + {t( + `By clicking "Connect prayerletters.com Account" you will + replace your entire prayerletters.com list with what is in {{appName}}. Any contacts or information that are in your current prayerletters.com - list that are not in MPDX will be deleted. We strongly recommend - only making changes in MPDX.`)} + list that are not in {{appName}} will be deleted. We strongly recommend + only making changes in {{appName}}.`, + { appName }, + )} {t('Connect prayerletters.com Account')} @@ -134,7 +141,8 @@ export const PrayerlettersAccordion: React.FC = ({ <> {t( - 'The link between MPDX and your prayerletters.com account stopped working. Click "Refresh prayerletters.com Account" to re-enable it.', + 'The link between {{appName}} and your prayerletters.com account stopped working. Click "Refresh prayerletters.com Account" to re-enable it.', + { appName }, )} @@ -160,13 +168,16 @@ export const PrayerlettersAccordion: React.FC = ({ {t( - `By clicking "Sync Now" you will replace your entire prayerletters.com list with what is in MPDX. - Any contacts or information that are in your current prayerletters.com list that are not in MPDX + `By clicking "Sync Now" you will replace your entire prayerletters.com list with what is in {{appName}}. + Any contacts or information that are in your current prayerletters.com list that are not in {{appName}} will be deleted.`, + { appName }, )} - {t('We strongly recommend only making changes in MPDX.')} + {t('We strongly recommend only making changes in {{appName}}.', { + appName, + })} From 05d2fd87b7cc65319a348bc4873dcbc6b6685ff2 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 16 Nov 2023 15:24:02 -0500 Subject: [PATCH 36/53] Odd amendments that I previously missed --- .../Chalkline/ChalklineAccordion.tsx | 5 ++- .../integrations/Google/GoogleAccordion.tsx | 31 +++++++++++++------ .../Modals/DeleteGoogleAccountModal.test.tsx | 2 +- .../Modals/DeleteGoogleAccountModal.tsx | 11 ++++--- .../Modals/EditGoogleAccountModal.test.tsx | 8 ++--- .../Google/Modals/EditGoogleAccountModal.tsx | 19 ++++++++---- .../Modals/EditGoogleIntegrationForm.tsx | 7 +++-- .../Mailchimp/MailchimpAccordion.test.tsx | 3 +- .../Mailchimp/MailchimpAccordion.tsx | 17 ++++------ .../Modals/OrganizationAddAccountModal.tsx | 11 ++++--- src/lib/helpScout.ts | 6 +++- 11 files changed, 75 insertions(+), 45 deletions(-) diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx index 4b9baa099..678e6d868 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx @@ -4,6 +4,7 @@ import { Typography } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useSendToChalklineMutation } from './SendToChalkline.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { StyledFormLabel } from 'src/components/Shared/Forms/FieldHelper'; import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; @@ -19,6 +20,7 @@ export const ChalklineAccordion: React.FC = ({ const accountListId = useAccountListId(); const [sendToChalkline] = useSendToChalklineMutation(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); const handleOpenModal = () => setShowModal(true); const handleCloseModal = () => { @@ -74,7 +76,8 @@ export const ChalklineAccordion: React.FC = ({ isOpen={showModal} title={t('Confirm')} message={t( - 'Would you like MPDX to email Chalkline your newsletter list and open their order form in a new tab?', + 'Would you like {{appName}} to email Chalkline your newsletter list and open their order form in a new tab?', + { appName }, )} handleClose={handleCloseModal} mutation={handleSendListToChalkLine} diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.tsx index 7d2ca8daf..1f5e440e9 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordion.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.tsx @@ -6,6 +6,7 @@ import { styled } from '@mui/material/styles'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useGoogleAccountsQuery } from './googleAccounts.generated'; import { GoogleAccountAttributes } from '../../../../../graphql/types.generated'; import theme from 'src/theme'; @@ -81,6 +82,7 @@ export const GoogleAccordion: React.FC = ({ }); const googleAccounts = data?.googleAccounts; const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); const { apiToken } = useContext( IntegrationsContext, ) as IntegrationsContextType; @@ -127,24 +129,32 @@ export const GoogleAccordion: React.FC = ({ Ministry Partners.`)} - {t(`By synchronizing your Google services with MPDX, you will be able - to:`)} + {t( + `By synchronizing your Google services with {{appName}}, you will be able + to:`, + { appName }, + )} - {t('See MPDX tasks in your Google Calendar')} + {t('See {{appName}} tasks in your Google Calendar', { + appName, + })} - {t('Import Google Contacts into MPDX')} + {t('Import Google Contacts into {{appName}}', { appName })} {t('Keep your Contacts in sync with your Google Contacts')} - {t(`Connect your Google account to begin, and then setup specific - settings for Google Calendar and Contacts. MPDX leaves you in - control of how each service stays in sync.`)} + {t( + `Connect your Google account to begin, and then setup specific + settings for Google Calendar and Contacts. {{appName}} leaves you in + control of how each service stays in sync.`, + { appName }, + )} )} @@ -177,9 +187,12 @@ export const GoogleAccordion: React.FC = ({ {account?.tokenExpired && ( <> - {t(`The link between MPDX and your Google account stopped working. Click "Refresh Google Account" to + {t( + `The link between {{appName}} and your Google account stopped working. Click "Refresh Google Account" to re-enable it. After that, you'll need to manually re-enable any integrations that you had set - already.`)} + already.`, + { appName }, + )} {t('Refresh Google Account')} diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx index 9883237bc..f9a8ad84e 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -112,7 +112,7 @@ describe('DeleteGoogleAccountModal', () => { await waitFor(() => { expect(mockEnqueue).toHaveBeenCalledWith( - 'MPDX removed your integration with Google.', + '{{appName}} removed your integration with Google.', { variant: 'success', }, diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx index f29ac171a..964d054b9 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -44,14 +44,17 @@ export const DeleteGoogleAccountModal: React.FC< }); }, onCompleted: () => { - enqueueSnackbar(t('MPDX removed your integration with Google.'), { - variant: 'success', - }); + enqueueSnackbar( + t('{{appName}} removed your integration with Google.'), + { + variant: 'success', + }, + ); handleClose(); }, onError: () => { enqueueSnackbar( - t("MPDX couldn't save your configuration changes for Google."), + t("{{appName}} couldn't save your configuration changes for Google."), { variant: 'error', }, diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 2c68a3d73..471e088e9 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -246,7 +246,7 @@ describe('EditGoogleAccountModal', () => { await waitFor(() => expect( - getByText(/choose a calendar for mpdx to push tasks to:/i), + getByText(/choose a calendar for {{appName}} to push tasks to:/i), ).toBeInTheDocument(), ); @@ -343,7 +343,7 @@ describe('EditGoogleAccountModal', () => { await waitFor(() => expect( - getByText(/choose a calendar for mpdx to push tasks to:/i), + getByText(/choose a calendar for {{appName}} to push tasks to:/i), ).toBeInTheDocument(), ); @@ -406,7 +406,7 @@ describe('EditGoogleAccountModal', () => { await waitFor(() => expect( - getByText(/choose a calendar for mpdx to push tasks to:/i), + getByText(/choose a calendar for {{appName}} to push tasks to:/i), ).toBeInTheDocument(), ); @@ -460,7 +460,7 @@ describe('EditGoogleAccountModal', () => { await waitFor(() => expect( - getByText(/choose a calendar for mpdx to push tasks to:/i), + getByText(/choose a calendar for {{appName}} to push tasks to:/i), ).toBeInTheDocument(), ); diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 71096fc1e..887125a8b 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -13,6 +13,7 @@ import { Button, } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { useGoogleAccountIntegrationsQuery, GoogleAccountIntegrationsDocument, @@ -51,10 +52,12 @@ export const EditGoogleAccountModal: React.FC = ({ oAuth, }) => { const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); - const [tabSelected, setTabSelected] = useState(tabsEnum.calendar); const accountListId = useAccountListId(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + const [tabSelected, setTabSelected] = useState(tabsEnum.calendar); const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); const [createGoogleIntegration] = useCreateGoogleIntegrationMutation(); @@ -225,20 +228,24 @@ export const EditGoogleAccountModal: React.FC = ({ !googleAccountDetails?.calendarIntegration && tabSelected === tabsEnum.calendar && ( - {t(`MPDX can automatically update your google calendar with your tasks. + {t( + `{{appName}} can automatically update your google calendar with your tasks. Once you enable this feature, you'll be able to choose which - types of tasks you want to sync. By default MPDX will add - 'Appointment' tasks to your calendar.`)} + types of tasks you want to sync. By default {{appName}} will add + 'Appointment' tasks to your calendar.`, + { appName }, + )} )} {tabSelected === tabsEnum.setup && ( {t( - `If the link between MPDX and your Google account breaks, + `If the link between {{appName}} and your Google account breaks, click the button below to re-establish the connection. (You should only need to do this if you receive an email - from MPDX)`, + from {{appName}})`, + { appName }, )} )} diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx index e557d88ec..973e9533e 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -16,6 +16,7 @@ import { FormHelperText, } from '@mui/material'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { GoogleAccountIntegrationsDocument, GoogleAccountIntegrationsQuery, @@ -69,7 +70,7 @@ export const EditGoogleIntegrationForm: React.FC< const { t } = useTranslation(); const accountListId = useAccountListId(); const { enqueueSnackbar } = useSnackbar(); - + const { appName } = useGetAppSettings(); const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); const { data: actvitiesData } = useGetIntegrationActivitiesQuery(); @@ -151,7 +152,9 @@ export const EditGoogleIntegrationForm: React.FC< {!loading && ( <> - {t('Choose a calendar for MPDX to push tasks to:')} + {t('Choose a calendar for {{appName}} to push tasks to:', { + appName, + })} { describe('Not Connected', () => { it('should render Mailchimp Overview', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; const mutationSpy = jest.fn(); const { getByText } = render( Components( @@ -142,7 +143,7 @@ describe('MailchimpAccount', () => { expect(getByText('Connect MailChimp')).toHaveAttribute( 'href', - `https://auth.mpdx.org/auth/user/mailchimp?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dmailchimp&access_token=apiToken`, + `https://auth.mpdx.org/auth/user/mailchimp?account_list_id=account-list-1&redirect_to=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dmailchimp&access_token=apiToken`, ); }); }); diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx index c5091526f..b2ff4cdb3 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect, useMemo, ReactElement } from 'react'; +import { useState, useContext, useMemo, ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; import { Formik } from 'formik'; @@ -62,7 +62,6 @@ export const MailchimpAccordion: React.FC = ({ expandedPanel, }) => { const { t } = useTranslation(); - const [oAuth, setOAuth] = useState(''); const [showSettings, setShowSettings] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -90,15 +89,11 @@ export const MailchimpAccordion: React.FC = ({ ? data.mailchimpAccount[0] : null; - useEffect(() => { - setOAuth( - `${ - process.env.OAUTH_URL - }/auth/user/mailchimp?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( - `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=mailchimp`, - )}&access_token=${apiToken}`, - ); - }, []); + const oAuth = `${ + process.env.OAUTH_URL + }/auth/user/mailchimp?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${process.env.SITE_URL}/accountLists/${accountListId}/settings/integrations?selectedTab=mailchimp`, + )}&access_token=${apiToken}`; const MailchimpSchema: yup.SchemaOf< Pick diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index d8eadc5f0..c6aa30ea7 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -232,11 +232,12 @@ export const OrganizationAddAccountModal: React.FC< /> - {!selectedOrganization && ( - - )} + {!selectedOrganization && + !!variables.HS_SETUP_FIND_ORGANIZATION && ( + + )} {organizationType === OrganizationTypesEnum.MINISTRY && ( diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index fa21ac9d1..3118544bf 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -39,7 +39,11 @@ export const identifyUser = (id: string, email: string, name: string) => { }); }; -export const showArticle = (articleId) => { +export const showArticle = (articleId: string | undefined) => { + if (!articleId) { + callBeacon('open'); + return ``; + } callBeacon('article', articleId); }; From 6c46a67de71b854e94af7c586f4964551c9d2dad Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 17 Nov 2023 10:45:48 -0500 Subject: [PATCH 37/53] Adding tests to Organization data sync upload file - Needed more tests --- .../OrganizationImportDataSyncModal.test.tsx | 291 +++++++++++++----- .../OrganizationImportDataSyncModal.tsx | 33 +- .../Shared/FileUploads/tntConnectDataSync.ts | 60 ---- 3 files changed, 238 insertions(+), 146 deletions(-) delete mode 100644 src/components/Shared/FileUploads/tntConnectDataSync.ts diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index 0d0e5a883..b538dc61a 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; @@ -6,10 +6,11 @@ import { GqlMockedProvider } from '../../../../../../__tests__/util/graphqlMocki import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/settings/integrations/IntegrationsContext'; import TestRouter from '__tests__/util/TestRouter'; import theme from '../../../../../theme'; -import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; -import { OrganizationImportDataSyncModal } from './OrganizationImportDataSyncModal'; +import { + OrganizationImportDataSyncModal, + validateFile, +} from './OrganizationImportDataSyncModal'; -jest.mock('src/components/Shared/FileUploads/tntConnectDataSync'); jest.mock('next-auth/react'); const accountListId = 'account-list-1'; @@ -48,93 +49,217 @@ const Components = (children: React.ReactElement) => ( const handleClose = jest.fn(); +const t = (text: string) => { + return text; +}; + describe('OrganizationImportDataSyncModal', () => { - process.env.OAUTH_URL = 'https://auth.mpdx.org'; + describe('ValidateFile()', () => { + it('File type is not correct', () => { + const file = new File(['contents'], 'image.png', { + type: 'image/png', + }); + const response = validateFile({ file, t }); - beforeEach(() => { - handleClose.mockClear(); - (validateFile as jest.Mock).mockReturnValue({ success: true }); - }); - it('should render modal', async () => { - const { getByText, getByTestId } = render( - Components( - - - , - ), - ); - - expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); - - userEvent.click(getByText(/cancel/i)); - expect(handleClose).toHaveBeenCalledTimes(1); - userEvent.click(getByTestId('CloseIcon')); - expect(handleClose).toHaveBeenCalledTimes(2); - }); + expect(response).toEqual({ + success: false, + message: + 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', + }); + }); + + it('File size is too big', () => { + const file = new File(['contents'], '.tntmpd', { + type: 'xml', + }); + Object.defineProperty(file, 'size', { value: 200_000_000 }); + const response = validateFile({ file, t }); - it('should return error when no file present', async () => { - const mutationSpy = jest.fn(); - const { getByText } = render( - Components( - - - , - ), - ); - userEvent.click(getByText('Upload File')); - - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith( - 'Please select a file to upload.', - { - variant: 'error', - }, - ), - ); + expect(response).toEqual({ + success: false, + message: 'Cannot upload file: file size cannot exceed 100MB', + }); + }); + + it('File type is correct', () => { + const file = new File(['contents'], '.tntmpd', { + type: 'xml', + }); + const response = validateFile({ file, t }); + + expect(response).toEqual({ + success: true, + }); + }); }); - it('should inform user of the error when uploading file.', async () => { - (validateFile as jest.Mock).mockReturnValue({ - success: false, - message: 'Invalid file', + describe('Render and upload file tests', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); }); - const mutationSpy = jest.fn(); - const { getByTestId, getByText } = render( - Components( - - - , - ), - ); - - const file = new File(['contents'], 'image.png', { - type: 'image/png', + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); }); - userEvent.upload(getByTestId('importFileUploader'), file); - userEvent.click(getByText('Upload File')); + describe('Send Files to API', () => { + const fetch = jest + .fn() + .mockResolvedValue(Promise.resolve({ status: 201 })); + beforeEach(() => { + window.fetch = fetch; + }); + + it('should return error when file is too large', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + const file = new File(['contents'], '.tntmpd', { + type: 'xml', + }); + Object.defineProperty(file, 'size', { + value: 200_000_000, + configurable: true, + }); + userEvent.upload(getByTestId('importFileUploader'), file); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Cannot upload file: file size cannot exceed 100MB', + { + variant: 'error', + }, + ), + ); + expect(getByText('Upload File')).toBeDisabled(); + }); + + it('should inform user of the error when uploading file.', async () => { + const mutationSpy = jest.fn(); + const { getByTestId, getByText } = render( + Components( + + + , + ), + ); + + const file = new File(['contents'], 'image.png', { + type: 'image/png', + }); + userEvent.upload(getByTestId('importFileUploader'), file); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', + { + variant: 'error', + }, + ), + ); + expect(getByText('Upload File')).toBeDisabled(); + }); + + it('should send formData and show successful banner', async () => { + const mutationSpy = jest.fn(); + const { getByTestId, getByText } = render( + Components( + + + , + ), + ); - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith('Invalid file', { - variant: 'error', - }), - ); + await waitFor(() => { + expect(getByText('Upload File')).toBeDisabled(); + }); + + const testValue = [{ isTest: 'It is a test' }]; + const str = JSON.stringify(testValue); + const blob = new Blob([str]); + const tntDataSync = new File([blob], '.tntmpd', { + type: 'xml', + }); + + await act(() => { + userEvent.upload(getByTestId('importFileUploader'), tntDataSync); + }); + await waitFor(() => { + expect(getByText('Upload File')).not.toBeDisabled(); + }); + userEvent.click(getByText('Upload File')); + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith( + '/api/uploads/tnt-data-sync', + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }), + ); + }); + + const formData = Array.from( + (window.fetch as jest.Mock).mock.calls[0][1].body.entries(), + ).reduce( + (acc, f) => ({ + ...(acc as Array), + [(f as Array)[0]]: (f as Array)[1], + }), + {}, + ); + + expect(formData).toEqual({ + accountListId, + organizationId, + tntDataSync, + }); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + `File successfully uploaded. The import to ${organizationName} will begin in the background.`, + { + variant: 'success', + }, + ), + ); + }); + }); }); - // TODO: Need more tests with uploading correct file. - // Issue with node-fetch. }); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx index a22408abc..ffd3294cc 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; +import { TFunction } from 'i18next'; import { styled } from '@mui/material/styles'; import { Box, @@ -16,9 +17,33 @@ import { } from 'src/components/common/Modal/ActionButtons/ActionButtons'; import theme from 'src/theme'; import Modal from 'src/components/common/Modal/Modal'; -import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; import { getErrorMessage } from 'src/lib/getErrorFromCatch'; +export const validateFile = ({ + file, + t, +}: { + file: File; + t: TFunction; +}): { success: true } | { success: false; message: string } => { + if (!new RegExp(/.*\.tntmpd$|.*\.tntdatasync$/).test(file.name)) { + return { + success: false, + message: t( + 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', + ), + }; + } + if (file.size > 100_000_000) { + return { + success: false, + message: t('Cannot upload file: file size cannot exceed 100MB'), + }; + } + + return { success: true }; +}; + interface OrganizationImportDataSyncModalProps { handleClose: () => void; organizationId: string; @@ -40,6 +65,7 @@ export const OrganizationImportDataSyncModal: React.FC< const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); const [isSubmitting, setIsSubmitting] = useState(false); + const [isValid, setIsValid] = useState(false); const [importFile, setImportFile] = useState(null); const handleSubmit = async (event) => { event.preventDefault(); @@ -77,7 +103,6 @@ export const OrganizationImportDataSyncModal: React.FC< }); } }; - const handleFileChange: React.ChangeEventHandler = ( event, ) => { @@ -88,7 +113,9 @@ export const OrganizationImportDataSyncModal: React.FC< const validationResult = validateFile({ file, t }); if (!validationResult.success) throw new Error(validationResult.message); setImportFile(file); + setIsValid(true); } catch (err) { + setIsValid(false); enqueueSnackbar(getErrorMessage(err), { variant: 'error', }); @@ -159,7 +186,7 @@ export const OrganizationImportDataSyncModal: React.FC< - + {t('Upload File')} diff --git a/src/components/Shared/FileUploads/tntConnectDataSync.ts b/src/components/Shared/FileUploads/tntConnectDataSync.ts deleted file mode 100644 index e8d8047d9..000000000 --- a/src/components/Shared/FileUploads/tntConnectDataSync.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { TFunction } from 'i18next'; - -export const validateFile = ({ - file, - t, -}: { - file: File; - t: TFunction; -}): { success: true } | { success: false; message: string } => { - if (!new RegExp(/.*\.tntmpd$|.*\.tntdatasync$/).test(file.name)) { - return { - success: false, - message: t( - 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', - ), - }; - } - // TODO: Test how the server handles uploading a 100mb file - // The /api/upload-person-avatar lambda appears to truncate the source body at 2^20 bytes - // Conservatively set the limit at 1MB (1,000,000 bytes), which is a little lower than 1MiB (1,048,576 bytes) because of the - // overhead of encoding multipart/form-data and the other fields in the POST body - if (file.size > 100_000_000) { - return { - success: false, - message: t('Cannot upload file: file size cannot exceed 100MB'), - }; - } - - return { success: true }; -}; - -export const uploadFile = async ({ - oranizationId, - file, - t, -}: { - oranizationId: string; - file: File; - t: TFunction; -}): Promise => { - const validationResult = validateFile({ file, t }); - if (!validationResult.success) { - throw new Error(validationResult.message); - } - - const form = new FormData(); - form.append('oranizationId', oranizationId); - form.append('importFile', file); - - const res = await fetch(`/api/upload-tnt-connect-data-sync`, { - method: 'POST', - body: form, - }).catch(() => { - throw new Error(t('Cannot upload file: server error')); - }); - const data: { success: boolean } = await res.json(); - if (!data.success) { - throw new Error(t('Cannot upload file: server error')); - } -}; From d26c6a681549b550a34cf061c41381a854ff631c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 20 Nov 2023 11:17:32 -0500 Subject: [PATCH 38/53] simplifing code --- .../Modals/OrganizationImportDataSyncModal.test.tsx | 8 +------- .../Modals/OrganizationImportDataSyncModal.tsx | 2 +- src/lib/helpScout.ts | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index b538dc61a..bc984af63 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -236,14 +236,8 @@ describe('OrganizationImportDataSyncModal', () => { ); }); - const formData = Array.from( + const formData = Object.fromEntries( (window.fetch as jest.Mock).mock.calls[0][1].body.entries(), - ).reduce( - (acc, f) => ({ - ...(acc as Array), - [(f as Array)[0]]: (f as Array)[1], - }), - {}, ); expect(formData).toEqual({ diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx index ffd3294cc..72d576e84 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -26,7 +26,7 @@ export const validateFile = ({ file: File; t: TFunction; }): { success: true } | { success: false; message: string } => { - if (!new RegExp(/.*\.tntmpd$|.*\.tntdatasync$/).test(file.name)) { + if (!file.name.endsWith('.tntmpd') && !file.name.endsWith('.tntdatasync')) { return { success: false, message: t( diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 3118544bf..60f4d031c 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -42,7 +42,7 @@ export const identifyUser = (id: string, email: string, name: string) => { export const showArticle = (articleId: string | undefined) => { if (!articleId) { callBeacon('open'); - return ``; + return; } callBeacon('article', articleId); }; From f39f9b8034ea5eec0d8a27fffb9a76b9badc07a2 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 20 Nov 2023 14:43:32 -0500 Subject: [PATCH 39/53] Adding tests for helpscout and env var to globalSetup for test --- __tests__/util/globalSetup.ts | 1 + src/lib/helpScout.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/__tests__/util/globalSetup.ts b/__tests__/util/globalSetup.ts index 8225f67fb..861ab70d5 100644 --- a/__tests__/util/globalSetup.ts +++ b/__tests__/util/globalSetup.ts @@ -1,6 +1,7 @@ const globalSetup = (): void => { process.env.TZ = 'UTC'; process.env.JWT_SECRET = 'test-environment-key'; + process.env.HS_CONTACTS_CONTACT_SUGGESTIONS = 'ContactArticleId'; }; export default globalSetup; diff --git a/src/lib/helpScout.test.ts b/src/lib/helpScout.test.ts index 358640444..7983b26e4 100644 --- a/src/lib/helpScout.test.ts +++ b/src/lib/helpScout.test.ts @@ -1,4 +1,10 @@ -import { BeaconFn, identifyUser, initBeacon } from './helpScout'; +import { + BeaconFn, + identifyUser, + initBeacon, + suggestArticles, + showArticle, +} from './helpScout'; describe('HelpScout', () => { const beacon = jest.fn() as BeaconFn; @@ -35,4 +41,30 @@ describe('HelpScout', () => { }); }); }); + + describe('suggestArticles', () => { + it('calls beacon with no envVar defined', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + suggestArticles(''); + expect(beacon).toHaveBeenCalledWith('suggest', []); + }); + + it('calls beacon wiht one envVar defined', () => { + suggestArticles('HS_CONTACTS_CONTACT_SUGGESTIONS'); + expect(beacon).toHaveBeenCalledWith('suggest', ['ContactArticleId']); + }); + }); + + describe('showArticle', () => { + it('articleId not defined', () => { + showArticle(''); + expect(beacon).toHaveBeenCalledWith('open', undefined); + }); + + it('articleId is defined', () => { + showArticle('TestArticleId'); + expect(beacon).toHaveBeenCalledWith('article', 'TestArticleId'); + }); + }); }); From bd4919f876cda8f60fb6c0725385e44aca235f67 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 20 Nov 2023 16:52:00 -0500 Subject: [PATCH 40/53] Adding tests to sync google integration --- .../Modals/EditGoogleAccountModal.test.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 471e088e9..2ca17adb7 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -435,6 +435,61 @@ describe('EditGoogleAccountModal', () => { }); }); + it('should create a Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect(getByText(/Enable Calendar Integration/i)).toBeInTheDocument(), + ); + + userEvent.click( + getByRole('button', { name: /Enable Calendar Integration/i }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Enabled Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateGoogleIntegration', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + accountListId: accountListId, + googleIntegration: { + calendarIntegration: true, + }, + }); + + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'GoogleAccountIntegrations', + ); + }); + }); + it('should sync Calendar Integration', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( From eee6562a4193a52525c7951d6ebf562f101f14e0 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 13:48:38 -0500 Subject: [PATCH 41/53] Removing leftover code --- .../Google/googleAccountIntegrations/datahandler.ts | 2 +- .../Intergrations/Google/googleAccounts/datahandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts index 562e16015..b83e45402 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts @@ -49,5 +49,5 @@ export const GoogleAccountIntegrations = ( attributes[snakeToCamel(key)] = integrations.attributes[key]; }); return { id: integrations.id, ...attributes }; - }, []); + }); }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts index d6ea969b7..f19c4a147 100644 --- a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts @@ -49,5 +49,5 @@ export const GoogleAccounts = ( }); return { id: accounts.id, ...attributes }; - }, []); + }); }; From bb2837ac12f51b193144829d569fa0d39de9dbfc Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 13:52:33 -0500 Subject: [PATCH 42/53] Removing Components() --- .../Chalkline/ChalklineAccordion.test.tsx | 22 +++--- .../Google/GoogleAccordion.test.tsx | 6 +- .../Modals/DeleteGoogleAccountModal.test.tsx | 16 ++--- .../Modals/EditGoogleAccountModal.test.tsx | 57 ++++++++------- .../Mailchimp/MailchimpAccordion.test.tsx | 70 ++++++++++--------- .../OrganizationAddAccountModal.test.tsx | 34 ++++----- .../OrganizationEditAccountModal.test.tsx | 16 ++--- .../OrganizationImportDataSyncModal.test.tsx | 33 +++++---- .../OrganizationAccordion.test.tsx | 58 +++++++-------- .../PrayerlettersAccordion.test.tsx | 40 +++++------ 10 files changed, 184 insertions(+), 168 deletions(-) diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx index a902a4be9..28a54b0e0 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; @@ -32,7 +32,7 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -48,14 +48,14 @@ describe('PrayerlettersAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; it('should render accordion closed', async () => { const { getByText, queryByRole } = render( - Components( + - , - ), + + , ); expect(getByText('Chalk Line')).toBeInTheDocument(); const image = queryByRole('img', { @@ -65,14 +65,14 @@ describe('PrayerlettersAccount', () => { }); it('should render accordion open', async () => { const { queryByRole } = render( - Components( + - , - ), + + , ); const image = queryByRole('img', { name: /Chalk Line/i, @@ -83,14 +83,14 @@ describe('PrayerlettersAccount', () => { it('should send contacts to Chalkline', async () => { const mutationSpy = jest.fn(); const { getByText } = render( - Components( + - , - ), + + , ); await waitFor(() => { expect(getByText('Chalkline Overview')).toBeInTheDocument(); diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx index 3449df746..79048d2c9 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; @@ -43,12 +43,12 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (props: { children: JSX.Element }) => ( +const Components = ({ children }: PropsWithChildren) => ( - {props.children} + {children} diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx index f9a8ad84e..e30c0ea06 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; @@ -42,7 +42,7 @@ jest.mock('notistack', () => ({ const handleClose = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -75,14 +75,14 @@ describe('DeleteGoogleAccountModal', () => { it('should render modal', async () => { const { getByText, getByTestId } = render( - Components( + - , - ), + + , ); expect( getByText(/confirm to disconnect google account/i), @@ -96,14 +96,14 @@ describe('DeleteGoogleAccountModal', () => { it('should run deleteGoogleAccount', async () => { const mutationSpy = jest.fn(); const { getByText } = render( - Components( + - , - ), + + , ); expect( getByText(/confirm to disconnect google account/i), diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 2ca17adb7..72457d043 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -1,4 +1,9 @@ -import { render, waitFor, act } from '@testing-library/react'; +import { + render, + waitFor, + act, + PropsWithChildren, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; @@ -47,7 +52,7 @@ jest.mock('notistack', () => ({ const handleClose = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -111,15 +116,15 @@ describe('EditGoogleAccountModal', () => { it('should render modal', async () => { const { getByText, getByTestId } = render( - Components( + - , - ), + + , ); expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); userEvent.click(getByText(/cancel/i)); @@ -131,15 +136,15 @@ describe('EditGoogleAccountModal', () => { it('should switch tabs', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + - , - ), + + , ); expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); const setupTab = getByRole('tab', { name: /setup/i }); @@ -157,7 +162,7 @@ describe('EditGoogleAccountModal', () => { googleIntegration.calendarName = null; const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(), @@ -205,7 +210,7 @@ describe('EditGoogleAccountModal', () => { it('should update Integrations calendar', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole, queryByRole } = render( - Components( + { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => @@ -302,7 +307,7 @@ describe('EditGoogleAccountModal', () => { googleIntegration.calendarId = 'calendarsID'; const mutationSpy = jest.fn(); const { getByText, getByRole, getByTestId } = render( - Components( + { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => @@ -384,7 +389,7 @@ describe('EditGoogleAccountModal', () => { it('should delete Calendar Integration', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -400,8 +405,8 @@ describe('EditGoogleAccountModal', () => { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => @@ -438,7 +443,7 @@ describe('EditGoogleAccountModal', () => { it('should create a Calendar Integration', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -454,8 +459,8 @@ describe('EditGoogleAccountModal', () => { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => @@ -493,7 +498,7 @@ describe('EditGoogleAccountModal', () => { it('should sync Calendar Integration', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -509,8 +514,8 @@ describe('EditGoogleAccountModal', () => { handleClose={handleClose} oAuth={oAuth} /> - , - ), + + , ); await waitFor(() => diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx index 8254f0411..15ea94957 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx @@ -1,4 +1,10 @@ -import { render, waitFor, within, act } from '@testing-library/react'; +import { + render, + waitFor, + within, + act, + PropsWithChildren, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; @@ -34,7 +40,7 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -80,14 +86,14 @@ describe('MailchimpAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; it('should render accordion closed', async () => { const { getByText, queryByRole } = render( - Components( + - , - ), + + , ); expect(getByText('MailChimp')).toBeInTheDocument(); const mailchimpImage = queryByRole('img', { @@ -97,14 +103,14 @@ describe('MailchimpAccount', () => { }); it('should render accordion open', async () => { const { queryByRole } = render( - Components( + - , - ), + + , ); const mailchimpImage = queryByRole('img', { name: /mailchimp/i, @@ -117,7 +123,7 @@ describe('MailchimpAccount', () => { process.env.SITE_URL = 'https://next.mpdx.org'; const mutationSpy = jest.fn(); const { getByText } = render( - Components( + @@ -132,8 +138,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -158,7 +164,7 @@ describe('MailchimpAccount', () => { mailchimpAccount.listsPresent = false; const mutationSpy = jest.fn(); const { queryByText } = render( - Components( + @@ -173,8 +179,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -201,7 +207,7 @@ describe('MailchimpAccount', () => { mailchimpAccount.listsLink = ''; const mutationSpy = jest.fn(); const { queryByText } = render( - Components( + @@ -216,8 +222,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -236,7 +242,7 @@ describe('MailchimpAccount', () => { it('should call updateMailchimpAccount', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -251,8 +257,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -303,7 +309,7 @@ describe('MailchimpAccount', () => { it('should call deleteMailchimpAccount', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -318,8 +324,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -370,7 +376,7 @@ describe('MailchimpAccount', () => { mailchimpAccount.valid = true; const mutationSpy = jest.fn(); const { getByText } = render( - Components( + @@ -385,8 +391,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -406,7 +412,7 @@ describe('MailchimpAccount', () => { mailchimpAccount.autoLogCampaigns = true; const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -421,8 +427,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { @@ -463,7 +469,7 @@ describe('MailchimpAccount', () => { mailchimpAccount.autoLogCampaigns = true; const mutationSpy = jest.fn(); const { queryByText, getByRole } = render( - Components( + @@ -478,8 +484,8 @@ describe('MailchimpAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'Mailchimp'} /> - , - ), + + , ); await waitFor(() => { diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index ae6c31848..20d154509 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; @@ -32,7 +32,7 @@ jest.mock('notistack', () => ({ }, })); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -98,15 +98,15 @@ describe('OrganizationAddAccountModal', () => { }); it('should render modal', async () => { const { getByText, getByTestId } = render( - Components( + - , - ), + + , ); expect(getByText('Add Organization Account')).toBeInTheDocument(); @@ -120,7 +120,7 @@ describe('OrganizationAddAccountModal', () => { it('should select offline Organization and add it', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -136,8 +136,8 @@ describe('OrganizationAddAccountModal', () => { refetchOrganizations={refetchOrganizations} accountListId={accountListId} /> - , - ), + + , ); userEvent.click(getByRole('combobox')); @@ -171,7 +171,7 @@ describe('OrganizationAddAccountModal', () => { it('should select Ministry Organization and be unable to add it.', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -187,8 +187,8 @@ describe('OrganizationAddAccountModal', () => { refetchOrganizations={refetchOrganizations} accountListId={accountListId} /> - , - ), + + , ); userEvent.click(getByRole('combobox')); @@ -208,7 +208,7 @@ describe('OrganizationAddAccountModal', () => { it('should select Login Organization and add it.', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole, getByTestId } = render( - Components( + @@ -224,8 +224,8 @@ describe('OrganizationAddAccountModal', () => { refetchOrganizations={refetchOrganizations} accountListId={accountListId} /> - , - ), + + , ); userEvent.click(getByRole('combobox')); @@ -273,7 +273,7 @@ describe('OrganizationAddAccountModal', () => { it('should select OAuth Organization and add it.', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole } = render( - Components( + @@ -289,8 +289,8 @@ describe('OrganizationAddAccountModal', () => { refetchOrganizations={refetchOrganizations} accountListId={accountListId} /> - , - ), + + , ); userEvent.click(getByRole('combobox')); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx index 9548bece2..883f204ca 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; @@ -31,7 +31,7 @@ jest.mock('notistack', () => ({ }, })); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -53,14 +53,14 @@ describe('OrganizationEditAccountModal', () => { }); it('should render modal', async () => { const { getByText, getByTestId } = render( - Components( + - , - ), + + , ); expect(getByText('Edit Organization Account')).toBeInTheDocument(); @@ -74,14 +74,14 @@ describe('OrganizationEditAccountModal', () => { it('should enter login details.', async () => { const mutationSpy = jest.fn(); const { getByText, getByRole, getByTestId } = render( - Components( + - , - ), + + , ); await waitFor(() => { diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index bc984af63..6891067f4 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -1,4 +1,9 @@ -import { render, waitFor, act } from '@testing-library/react'; +import { + render, + waitFor, + act, + PropsWithChildren, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; @@ -35,7 +40,7 @@ jest.mock('notistack', () => ({ }, })); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -101,7 +106,7 @@ describe('OrganizationImportDataSyncModal', () => { }); it('should render modal', async () => { const { getByText, getByTestId } = render( - Components( + { organizationName={organizationName} accountListId={accountListId} /> - , - ), + + , ); expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); @@ -132,7 +137,7 @@ describe('OrganizationImportDataSyncModal', () => { it('should return error when file is too large', async () => { const mutationSpy = jest.fn(); const { getByText, getByTestId } = render( - Components( + { organizationName={organizationName} accountListId={accountListId} /> - , - ), + + , ); const file = new File(['contents'], '.tntmpd', { type: 'xml', @@ -166,7 +171,7 @@ describe('OrganizationImportDataSyncModal', () => { it('should inform user of the error when uploading file.', async () => { const mutationSpy = jest.fn(); const { getByTestId, getByText } = render( - Components( + { organizationName={organizationName} accountListId={accountListId} /> - , - ), + + , ); const file = new File(['contents'], 'image.png', { @@ -196,7 +201,7 @@ describe('OrganizationImportDataSyncModal', () => { it('should send formData and show successful banner', async () => { const mutationSpy = jest.fn(); const { getByTestId, getByText } = render( - Components( + { organizationName={organizationName} accountListId={accountListId} /> - , - ), + + , ); await waitFor(() => { diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx index 5e9073656..ba197e4e7 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; @@ -37,7 +37,7 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -100,14 +100,14 @@ describe('OrganizationAccordion', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; it('should render accordion closed', async () => { const { getByText, queryByRole } = render( - Components( + - , - ), + + , ); expect(getByText('Organization')).toBeInTheDocument(); const image = queryByRole('img', { @@ -117,14 +117,14 @@ describe('OrganizationAccordion', () => { }); it('should render accordion open', async () => { const { queryByRole } = render( - Components( + - , - ), + + , ); const image = queryByRole('img', { name: /Organization/i, @@ -135,7 +135,7 @@ describe('OrganizationAccordion', () => { describe('No Organizations connected', () => { it('should render Organization Overview', async () => { const { getByText } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); await waitFor(() => { @@ -175,7 +175,7 @@ describe('OrganizationAccordion', () => { it('should render Offline Organization', async () => { const { getByText, queryByText } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); expect( @@ -220,7 +220,7 @@ describe('OrganizationAccordion', () => { mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = 'Siebel'; const { getByText, queryByText } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); await waitFor(() => { @@ -268,7 +268,7 @@ describe('OrganizationAccordion', () => { mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = 'DataServer'; const { getByText, getByTestId } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); await waitFor(() => { @@ -303,7 +303,7 @@ describe('OrganizationAccordion', () => { mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.oauth = true; const { getByText, queryByTestId } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); await waitFor(() => { @@ -340,7 +340,7 @@ describe('OrganizationAccordion', () => { it('should delete Organization', async () => { const mutationSpy = jest.fn(); const { getByText, getByTestId } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); await waitFor(() => { @@ -389,7 +389,7 @@ describe('OrganizationAccordion', () => { mocks.GetUsersOrganizations.userOrganizationAccounts[0].latestDonationDate = null; const { queryByText } = render( - Components( + { handleAccordionChange={handleAccordionChange} expandedPanel={'Organization'} /> - , - ), + + , ); expect(queryByText('Last Updated')).not.toBeInTheDocument(); diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx index 310a5c76e..d2b926b6c 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, PropsWithChildren } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; @@ -34,7 +34,7 @@ jest.mock('notistack', () => ({ const handleAccordionChange = jest.fn(); -const Components = (children: React.ReactElement) => ( +const Components = ({ children }: PropsWithChildren) => ( @@ -55,14 +55,14 @@ describe('PrayerlettersAccount', () => { process.env.OAUTH_URL = 'https://auth.mpdx.org'; it('should render accordion closed', async () => { const { getByText, queryByRole } = render( - Components( + - , - ), + + , ); expect(getByText('prayerletters.com')).toBeInTheDocument(); const image = queryByRole('img', { @@ -72,14 +72,14 @@ describe('PrayerlettersAccount', () => { }); it('should render accordion open', async () => { const { queryByRole } = render( - Components( + - , - ), + + , ); const image = queryByRole('img', { name: /prayerletters.com/i, @@ -91,7 +91,7 @@ describe('PrayerlettersAccount', () => { it('should render PrayerLetters.com Overview', async () => { process.env.SITE_URL = 'https://next.mpdx.org'; const { getByText } = render( - Components( + @@ -105,8 +105,8 @@ describe('PrayerlettersAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'prayerletters.com'} /> - , - ), + + , ); await waitFor(() => { @@ -132,7 +132,7 @@ describe('PrayerlettersAccount', () => { prayerlettersAccount.validToken = false; const mutationSpy = jest.fn(); const { queryByText, getByText, getByRole } = render( - Components( + @@ -147,8 +147,8 @@ describe('PrayerlettersAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'prayerletters.com'} /> - , - ), + + , ); await waitFor(() => { @@ -201,7 +201,7 @@ describe('PrayerlettersAccount', () => { it('is connected but token is valid', async () => { const mutationSpy = jest.fn(); const { queryByText, getByRole } = render( - Components( + @@ -216,8 +216,8 @@ describe('PrayerlettersAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'prayerletters.com'} /> - , - ), + + , ); await waitFor(() => { @@ -266,7 +266,7 @@ describe('PrayerlettersAccount', () => { it('should sync contacts', async () => { const mutationSpy = jest.fn(); const { queryByText, getByRole } = render( - Components( + @@ -281,8 +281,8 @@ describe('PrayerlettersAccount', () => { handleAccordionChange={handleAccordionChange} expandedPanel={'prayerletters.com'} /> - , - ), + + , ); await waitFor(() => { From 9e640d179a977fb9c79537b5f458882b50908414 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 14:33:44 -0500 Subject: [PATCH 43/53] Localized edits --- .../Modals/OrganizationImportDataSyncModal.tsx | 2 +- .../Organization/OrganizationAccordion.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx index 72d576e84..568ae5ae5 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -162,7 +162,7 @@ export const OrganizationImportDataSyncModal: React.FC< component="label" disabled={isSubmitting} > - Upload file here + {t('Upload file here')} = ({ sx={{ m: '0 0 0 10px' }} onClick={() => handleSync(id)} > - Sync + {t('Sync')} )} @@ -256,7 +256,7 @@ export const OrganizationAccordion: React.FC = ({ sx={{ m: '0 0 0 10px' }} onClick={() => setShowImportDataSyncModal(true)} > - Import TntConnect DataSync file + {t('Import TntConnect DataSync file')} )} @@ -267,7 +267,7 @@ export const OrganizationAccordion: React.FC = ({ sx={{ m: '0 0 0 10px' }} onClick={() => handleReconnect(organization.id)} > - Reconnect + {t('Reconnect')} )} {type === OrganizationTypesEnum.LOGIN && ( @@ -289,7 +289,7 @@ export const OrganizationAccordion: React.FC = ({ - Last Updated + {t('Last Updated')} {DateTime.fromISO(lastDownloadedAt).toRelative()} @@ -301,7 +301,7 @@ export const OrganizationAccordion: React.FC = ({ - Last Gift Date + {t('Last Gift Date')} {DateTime.fromISO(latestDonationDate).toRelative()} @@ -343,7 +343,7 @@ export const OrganizationAccordion: React.FC = ({ variant={!!organizations?.length ? 'outlined' : 'contained'} onClick={() => setShowAddAccountModal(true)} > - Add Account + {t('Add Account')} {showAddAccountModal && ( From 8ba9cb96963927f6b4e3fc182cff7a306d8a112a Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 14:33:55 -0500 Subject: [PATCH 44/53] Small fixes --- .../Settings/integrations/Google/GoogleAccordion.tsx | 2 +- .../Google/Modals/EditGoogleIntegrationForm.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.tsx index 1f5e440e9..67cb26d8a 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordion.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.tsx @@ -206,7 +206,7 @@ export const GoogleAccordion: React.FC = ({ {t('Add Account')} - {!!googleAccounts?.length && ( + {googleAccounts?.length && ( { - let newCalendarInetgrations; + let newCalendarIntegrations; if (value) { // Add to calendarIntegrations - newCalendarInetgrations = [ + newCalendarIntegrations = [ ...calendarIntegrations, activity.value, ]; } else { // Remove from calendarIntegrations - newCalendarInetgrations = + newCalendarIntegrations = calendarIntegrations.filter( (act) => act !== activity?.id, ); } setFieldValue( `calendarIntegrations`, - newCalendarInetgrations, + newCalendarIntegrations, ); }} /> From ae2ee65fae4c0f817318a90668dbf01cc138d6c4 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 14:34:18 -0500 Subject: [PATCH 45/53] Test fixes --- .../Modals/OrganizationImportDataSyncModal.test.tsx | 4 +--- .../integrations/Organization/OrganizationAccordion.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index 6891067f4..c927a4e50 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -127,9 +127,7 @@ describe('OrganizationImportDataSyncModal', () => { }); describe('Send Files to API', () => { - const fetch = jest - .fn() - .mockResolvedValue(Promise.resolve({ status: 201 })); + const fetch = jest.fn().mockResolvedValue({ status: 201 }); beforeEach(() => { window.fetch = fetch; }); diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx index ba197e4e7..b1fa4b9ce 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -12,6 +12,7 @@ import { IntegrationsContextProvider } from 'pages/accountLists/[accountListId]/ import theme from '../../../../theme'; import TestRouter from '__tests__/util/TestRouter'; import { OrganizationAccordion } from './OrganizationAccordion'; +import { cloneDeep } from 'lodash'; jest.mock('next-auth/react'); @@ -170,7 +171,7 @@ describe('OrganizationAccordion', () => { describe('Organizations connected', () => { let mocks = { ...standardMocks }; beforeEach(() => { - mocks = { ...standardMocks }; + mocks = cloneDeep(standardMocks); }); it('should render Offline Organization', async () => { From 3526957bb1df5c92bb7905ef98be480596731b29 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 28 Nov 2023 14:52:34 -0500 Subject: [PATCH 46/53] Added Id for graphQL cache Ensured TS safety on showArticle function --- .../integrations/Organization/Organizations.graphql | 1 + src/lib/helpScout.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Organizations.graphql b/src/components/Settings/integrations/Organization/Organizations.graphql index 553655763..9ab64a0d0 100644 --- a/src/components/Settings/integrations/Organization/Organizations.graphql +++ b/src/components/Settings/integrations/Organization/Organizations.graphql @@ -36,6 +36,7 @@ mutation CreateOrganizationAccount( ) { createOrganizationAccount(input: $input) { organizationAccount { + id username person { id diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 60f4d031c..1a5b853eb 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -39,12 +39,13 @@ export const identifyUser = (id: string, email: string, name: string) => { }); }; -export const showArticle = (articleId: string | undefined) => { - if (!articleId) { +export const showArticle = (envVar: keyof typeof variables) => { + const articleId = variables[envVar]; + if (articleId) { + callBeacon('article', articleId); + } else { callBeacon('open'); - return; } - callBeacon('article', articleId); }; export const suggestArticles = (envVar: keyof typeof variables) => { From cf204bb39edaf81126221609188056250ac581d9 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 29 Nov 2023 14:39:23 -0500 Subject: [PATCH 47/53] Button styles and margins --- .../integrations/Google/Modals/EditGoogleAccountModal.tsx | 2 +- .../Settings/integrations/Mailchimp/MailchimpAccordion.tsx | 2 +- .../integrations/Organization/OrganizationAccordion.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx index 887125a8b..058607dfc 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -283,7 +283,7 @@ export const EditGoogleAccountModal: React.FC = ({ disabled={isSubmitting} variant="contained" /> - diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx index b2ff4cdb3..b0d095eb3 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx @@ -231,7 +231,7 @@ export const MailchimpAccordion: React.FC = ({ }, )} - + {t('Connect MailChimp')} diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx index 3530ec909..a3dcd2745 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -212,7 +212,7 @@ export const OrganizationAccordion: React.FC = ({ ); return ( - + Date: Wed, 29 Nov 2023 15:18:58 -0500 Subject: [PATCH 48/53] Lint fixes --- .../integrations/Chalkline/ChalklineAccordion.test.tsx | 3 ++- .../integrations/Google/GoogleAccordion.test.tsx | 3 ++- .../Google/Modals/DeleteGoogleAccountModal.test.tsx | 3 ++- .../Google/Modals/EditGoogleAccountModal.test.tsx | 8 ++------ .../integrations/Mailchimp/MailchimpAccordion.test.tsx | 9 ++------- .../Modals/OrganizationAddAccountModal.test.tsx | 3 ++- .../Modals/OrganizationEditAccountModal.test.tsx | 3 ++- .../Modals/OrganizationImportDataSyncModal.test.tsx | 8 ++------ .../Organization/OrganizationAccordion.test.tsx | 3 ++- .../Prayerletters/PrayerlettersAccordion.test.tsx | 3 ++- 10 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx index 28a54b0e0..3889aa17e 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx index 79048d2c9..a83e1b80f 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx index e30c0ea06..99f2f757e 100644 --- a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx index 72457d043..3fc6b816e 100644 --- a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -1,9 +1,5 @@ -import { - render, - waitFor, - act, - PropsWithChildren, -} from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getSession } from 'next-auth/react'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx index 15ea94957..e6dc37547 100644 --- a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx @@ -1,10 +1,5 @@ -import { - render, - waitFor, - within, - act, - PropsWithChildren, -} from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor, within, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx index 20d154509..2b9f48532 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx index 883f204ca..360587af8 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx index c927a4e50..4b567aa47 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -1,9 +1,5 @@ -import { - render, - waitFor, - act, - PropsWithChildren, -} from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx index b1fa4b9ce..3e861ba6c 100644 --- a/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; import { SnackbarProvider } from 'notistack'; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx index d2b926b6c..2ff4d5336 100644 --- a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx @@ -1,4 +1,5 @@ -import { render, waitFor, PropsWithChildren } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { ThemeProvider } from '@mui/material/styles'; From 74a47125b69cb70aa9697434bb0d1652747c1181 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 29 Nov 2023 15:19:13 -0500 Subject: [PATCH 49/53] Helpscout test fixes --- .../Modals/OrganizationAddAccountModal.tsx | 2 +- src/lib/helpScout.test.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index c6aa30ea7..5ab7659e5 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -133,7 +133,7 @@ export const OrganizationAddAccountModal: React.FC< }; const showOrganizationHelp = () => { - showArticle(variables.HS_SETUP_FIND_ORGANIZATION); + showArticle('HS_SETUP_FIND_ORGANIZATION'); }; const OrganizationSchema: yup.SchemaOf = yup.object( diff --git a/src/lib/helpScout.test.ts b/src/lib/helpScout.test.ts index 7983b26e4..fdd0f5b19 100644 --- a/src/lib/helpScout.test.ts +++ b/src/lib/helpScout.test.ts @@ -58,13 +58,25 @@ describe('HelpScout', () => { describe('showArticle', () => { it('articleId not defined', () => { - showArticle(''); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + showArticle(); expect(beacon).toHaveBeenCalledWith('open', undefined); }); - it('articleId is defined', () => { + it('articleId is defined but is not env variable', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore showArticle('TestArticleId'); - expect(beacon).toHaveBeenCalledWith('article', 'TestArticleId'); + expect(beacon).toHaveBeenCalledWith('open', undefined); + }); + + it('should call beacon with article', () => { + process.env.HS_SETUP_FIND_ORGANIZATION; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + showArticle('HS_CONTACTS_CONTACT_SUGGESTIONS'); + expect(beacon).toHaveBeenCalledWith('article', 'ContactArticleId'); }); }); }); From 3fef441c804d42f0b248d76dee1516ff5e97ac2d Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 29 Nov 2023 15:41:18 -0500 Subject: [PATCH 50/53] Making Helpscout changes so it's easier to merge with Caleb C's PRs --- src/lib/helpScout.test.ts | 93 ++++++++++++++++++++++++++++----------- src/lib/helpScout.ts | 49 +++++++++++++++------ 2 files changed, 102 insertions(+), 40 deletions(-) diff --git a/src/lib/helpScout.test.ts b/src/lib/helpScout.test.ts index fdd0f5b19..95c1b4a96 100644 --- a/src/lib/helpScout.test.ts +++ b/src/lib/helpScout.test.ts @@ -6,11 +6,19 @@ import { showArticle, } from './helpScout'; +const env = process.env; + describe('HelpScout', () => { const beacon = jest.fn() as BeaconFn; beforeEach(() => { beacon.readyQueue = []; window.Beacon = beacon; + jest.resetModules(); + process.env = { ...env }; + }); + + afterEach(() => { + process.env = env; }); describe('initBeacon', () => { @@ -41,42 +49,75 @@ describe('HelpScout', () => { }); }); }); - describe('suggestArticles', () => { - it('calls beacon with no envVar defined', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - suggestArticles(''); - expect(beacon).toHaveBeenCalledWith('suggest', []); - }); + it('calls callBeacon when the suggestions exist', () => { + const makeSuggestions = (key: string) => `${key}-1,${key}-2`; + Object.assign(process.env, { + HS_COACHING_SUGGESTIONS: makeSuggestions('coaching'), + HS_CONTACTS_CONTACT_SUGGESTIONS: makeSuggestions('contacts-contact'), + HS_CONTACTS_SUGGESTIONS: makeSuggestions('contacts'), + HS_HOME_SUGGESTIONS: makeSuggestions('home'), + HS_REPORTS_SUGGESTIONS: makeSuggestions('reports'), + HS_TASKS_SUGGESTIONS: makeSuggestions('tasks'), + }); - it('calls beacon wiht one envVar defined', () => { suggestArticles('HS_CONTACTS_CONTACT_SUGGESTIONS'); - expect(beacon).toHaveBeenCalledWith('suggest', ['ContactArticleId']); + expect(beacon).toHaveBeenLastCalledWith('suggest', [ + 'contacts-contact-1', + 'contacts-contact-2', + ]); + + suggestArticles('HS_CONTACTS_SUGGESTIONS'); + expect(beacon).toHaveBeenLastCalledWith('suggest', [ + 'contacts-1', + 'contacts-2', + ]); + + suggestArticles('HS_HOME_SUGGESTIONS'); + expect(beacon).toHaveBeenLastCalledWith('suggest', ['home-1', 'home-2']); + + suggestArticles('HS_REPORTS_SUGGESTIONS'); + expect(beacon).toHaveBeenLastCalledWith('suggest', [ + 'reports-1', + 'reports-2', + ]); + + suggestArticles('HS_TASKS_SUGGESTIONS'); + expect(beacon).toHaveBeenLastCalledWith('suggest', [ + 'tasks-1', + 'tasks-2', + ]); + }); + + it('calls callBeacon when the suggestions do not exist', () => { + process.env.HS_HOME_SUGGESTIONS = undefined; + suggestArticles('HS_HOME_SUGGESTIONS'); + expect(beacon).toHaveBeenCalledWith('suggest', []); }); }); describe('showArticle', () => { - it('articleId not defined', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - showArticle(); - expect(beacon).toHaveBeenCalledWith('open', undefined); - }); + it('calls callBeacon when the article exists', () => { + Object.assign(process.env, { + HS_SETUP_FIND_ORGANIZATION: 'organization-activity', + HS_COACHING_ACTIVITY_SUMMARY: 'coaching-activity-summary', + HS_COACHING_APPOINTMENTS_AND_RESULTS: + 'coaching-appointments-and-results', + HS_COACHING_COMMITMENTS: 'coaching-commitments', + HS_COACHING_OUTSTANDING_RECURRING_COMMITMENTS: + 'coaching-outstanding-recurring-commitments', + HS_COACHING_OUTSTANDING_SPECIAL_NEEDS: + 'coaching-outstanding-special-needs', + }); - it('articleId is defined but is not env variable', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - showArticle('TestArticleId'); - expect(beacon).toHaveBeenCalledWith('open', undefined); + showArticle('HS_SETUP_FIND_ORGANIZATION'); + expect(beacon).toHaveBeenCalledWith('article', 'organization-activity'); }); - it('should call beacon with article', () => { - process.env.HS_SETUP_FIND_ORGANIZATION; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - showArticle('HS_CONTACTS_CONTACT_SUGGESTIONS'); - expect(beacon).toHaveBeenCalledWith('article', 'ContactArticleId'); + it('calls callBeacon when the article does not exist', () => { + process.env.HS_COACHING_ACTIVITY = undefined; + showArticle('HS_COACHING_ACTIVITY'); + expect(beacon).toHaveBeenCalledWith('open', undefined); }); }); }); diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 1a5b853eb..92fd0e7a7 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -39,8 +39,17 @@ export const identifyUser = (id: string, email: string, name: string) => { }); }; -export const showArticle = (envVar: keyof typeof variables) => { - const articleId = variables[envVar]; +export type SuggestionsVar = keyof typeof suggestions; + +export const suggestArticles = (envVar: SuggestionsVar) => { + const articleIds = suggestions[envVar]; + callBeacon('suggest', articleIds?.split(',') ?? []); +}; + +export type ArticleVar = keyof typeof articles; + +export const showArticle = (envVar: ArticleVar) => { + const articleId = articles[envVar]; if (articleId) { callBeacon('article', articleId); } else { @@ -48,18 +57,30 @@ export const showArticle = (envVar: keyof typeof variables) => { } }; -export const suggestArticles = (envVar: keyof typeof variables) => { - const articleIds = variables[envVar]; - callBeacon('suggest', articleIds?.split(',') ?? []); +// We are using getters so that when tests override environment variables, the changes will be picked up +const suggestions = { + get HS_CONTACTS_CONTACT_SUGGESTIONS() { + return process.env.HS_CONTACTS_CONTACT_SUGGESTIONS; + }, + get HS_CONTACTS_SUGGESTIONS() { + return process.env.HS_CONTACTS_SUGGESTIONS; + }, + get HS_HOME_SUGGESTIONS() { + return process.env.HS_HOME_SUGGESTIONS; + }, + get HS_REPORTS_SUGGESTIONS() { + return process.env.HS_REPORTS_SUGGESTIONS; + }, + get HS_TASKS_SUGGESTIONS() { + return process.env.HS_TASKS_SUGGESTIONS; + }, + get HS_SETTINGS_SERVICES_SUGGESTIONS() { + return process.env.HS_SETTINGS_SERVICES_SUGGESTIONS; + }, }; -export const variables = { - HS_CONTACTS_SUGGESTIONS: process.env.HS_CONTACTS_SUGGESTIONS, - HS_CONTACTS_CONTACT_SUGGESTIONS: process.env.HS_CONTACTS_CONTACT_SUGGESTIONS, - HS_HOME_SUGGESTIONS: process.env.HS_HOME_SUGGESTIONS, - HS_REPORTS_SUGGESTIONS: process.env.HS_REPORTS_SUGGESTIONS, - HS_TASKS_SUGGESTIONS: process.env.HS_TASKS_SUGGESTIONS, - HS_SETTINGS_SERVICES_SUGGESTIONS: - process.env.HS_SETTINGS_SERVICES_SUGGESTIONS, - HS_SETUP_FIND_ORGANIZATION: process.env.HS_SETUP_FIND_ORGANIZATION, +const articles = { + get HS_SETUP_FIND_ORGANIZATION() { + return process.env.HS_SETUP_FIND_ORGANIZATION; + }, }; From a6f55721c4769e3aa1f4e95ea9aee2bb6394ebef Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 29 Nov 2023 15:58:30 -0500 Subject: [PATCH 51/53] Helpscout - Fixing test errors Helpscout - ensuring the beacon shows and renders above modals --- __tests__/util/globalSetup.ts | 1 - pages/helpscout.css | 3 +++ .../Modals/OrganizationAddAccountModal.tsx | 13 ++++++------- src/lib/helpScout.test.ts | 12 ++---------- src/lib/helpScout.ts | 2 +- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/__tests__/util/globalSetup.ts b/__tests__/util/globalSetup.ts index 861ab70d5..8225f67fb 100644 --- a/__tests__/util/globalSetup.ts +++ b/__tests__/util/globalSetup.ts @@ -1,7 +1,6 @@ const globalSetup = (): void => { process.env.TZ = 'UTC'; process.env.JWT_SECRET = 'test-environment-key'; - process.env.HS_CONTACTS_CONTACT_SUGGESTIONS = 'ContactArticleId'; }; export default globalSetup; diff --git a/pages/helpscout.css b/pages/helpscout.css index aaf98d557..9727f5883 100644 --- a/pages/helpscout.css +++ b/pages/helpscout.css @@ -7,3 +7,6 @@ bottom: 90px !important; z-index: 1350 !important; } +.BeaconFabButtonFrame { + z-index: 1500 !important; +} diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx index 5ab7659e5..e069ddd82 100644 --- a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -19,7 +19,7 @@ import { useGetOrganizationsQuery, useCreateOrganizationAccountMutation, } from '../Organizations.generated'; -import { showArticle, variables } from 'src/lib/helpScout'; +import { showArticle, articles } from 'src/lib/helpScout'; import theme from 'src/theme'; import { Organization } from '../../../../../../graphql/types.generated'; import { clearDataDogUser } from 'src/hooks/useDataDog'; @@ -232,12 +232,11 @@ export const OrganizationAddAccountModal: React.FC< /> - {!selectedOrganization && - !!variables.HS_SETUP_FIND_ORGANIZATION && ( - - )} + {!selectedOrganization && !!articles.HS_SETUP_FIND_ORGANIZATION && ( + + )} {organizationType === OrganizationTypesEnum.MINISTRY && ( diff --git a/src/lib/helpScout.test.ts b/src/lib/helpScout.test.ts index 95c1b4a96..97b85575e 100644 --- a/src/lib/helpScout.test.ts +++ b/src/lib/helpScout.test.ts @@ -100,14 +100,6 @@ describe('HelpScout', () => { it('calls callBeacon when the article exists', () => { Object.assign(process.env, { HS_SETUP_FIND_ORGANIZATION: 'organization-activity', - HS_COACHING_ACTIVITY_SUMMARY: 'coaching-activity-summary', - HS_COACHING_APPOINTMENTS_AND_RESULTS: - 'coaching-appointments-and-results', - HS_COACHING_COMMITMENTS: 'coaching-commitments', - HS_COACHING_OUTSTANDING_RECURRING_COMMITMENTS: - 'coaching-outstanding-recurring-commitments', - HS_COACHING_OUTSTANDING_SPECIAL_NEEDS: - 'coaching-outstanding-special-needs', }); showArticle('HS_SETUP_FIND_ORGANIZATION'); @@ -115,8 +107,8 @@ describe('HelpScout', () => { }); it('calls callBeacon when the article does not exist', () => { - process.env.HS_COACHING_ACTIVITY = undefined; - showArticle('HS_COACHING_ACTIVITY'); + process.env.HS_SETUP_FIND_ORGANIZATION = undefined; + showArticle('HS_SETUP_FIND_ORGANIZATION'); expect(beacon).toHaveBeenCalledWith('open', undefined); }); }); diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 92fd0e7a7..bf139f83a 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -79,7 +79,7 @@ const suggestions = { }, }; -const articles = { +export const articles = { get HS_SETUP_FIND_ORGANIZATION() { return process.env.HS_SETUP_FIND_ORGANIZATION; }, From 006d4b76a5404a06a34edb62ac92d226065e975f Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 30 Nov 2023 08:58:17 -0500 Subject: [PATCH 52/53] Testing Chalkline opens new tab --- .../Chalkline/ChalklineAccordion.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx index 3889aa17e..ee1a240aa 100644 --- a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx @@ -82,6 +82,8 @@ describe('PrayerlettersAccount', () => { }); it('should send contacts to Chalkline', async () => { + const openMock = jest.fn(); + window.open = openMock; const mutationSpy = jest.fn(); const { getByText } = render( @@ -116,5 +118,14 @@ describe('PrayerlettersAccount', () => { accountListId: accountListId, }); }); + + await waitFor( + () => + expect(openMock).toHaveBeenCalledWith( + 'https://chalkline.org/order_mpdx/', + '_blank', + ), + { timeout: 3000 }, + ); }); }); From a09a6c640ee80f37e7492f6acf7e8ff166ca8e09 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 30 Nov 2023 09:00:14 -0500 Subject: [PATCH 53/53] Added article export after merge --- src/lib/helpScout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 0c86ecc87..f294b4153 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -82,7 +82,7 @@ const suggestions = { }, }; -const articles = { +export const articles = { get HS_SETUP_FIND_ORGANIZATION() { return process.env.HS_SETUP_FIND_ORGANIZATION; },