diff --git a/next.config.js b/next.config.js index e729e3f2f..3dfc6b9c7 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, @@ -104,6 +105,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/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/integrations/index.page.tsx b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx new file mode 100644 index 000000000..ef9d1980c --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/integrations/index.page.tsx @@ -0,0 +1,90 @@ +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 { 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'; + +interface Props { + apiToken: string; +} + +const Integrations = ({ apiToken }: Props): ReactElement => { + const { t } = useTranslation(); + const { query } = useRouter(); + const [expandedPanel, setExpandedPanel] = useState( + (query?.selectedTab as string) || '', + ); + + useEffect(() => { + suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); + }, []); + + const handleAccordionChange = (panel: string) => { + const panelLowercase = panel.toLowerCase(); + setExpandedPanel(expandedPanel === panelLowercase ? '' : panelLowercase); + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +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; + + return { + props: { + apiToken, + }, + }; +}; + +export default Integrations; diff --git a/pages/accountLists/[accountListId]/settings/wrapper.tsx b/pages/accountLists/[accountListId]/settings/wrapper.tsx new file mode 100644 index 000000000..84cee3b10 --- /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} + + } + /> + + + ); +}; 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..8d1e00e59 --- /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..22f17c5c2 --- /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).forEach((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..29276346e --- /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/googleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/datahandler.ts new file mode 100644 index 000000000..b83e45402 --- /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/googleAccountIntegrations/googleAccountIntegrations.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql new file mode 100644 index 000000000..9d77642ec --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/googleAccountIntegrations.graphql @@ -0,0 +1,27 @@ +extend type Query { + googleAccountIntegrations( + input: GoogleAccountIntegrationsInput! + ): [GoogleAccountIntegration]! +} + +input GoogleAccountIntegrationsInput { + 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/googleAccountIntegrations/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers.ts new file mode 100644 index 000000000..bdb1c790b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/resolvers.ts @@ -0,0 +1,18 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GoogleAccountIntegrationsResolvers: Resolvers = { + Query: { + googleAccountIntegrations: async ( + _source, + { input: { googleAccountId, accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.googleAccountIntegrations( + googleAccountId, + accountListId, + ); + }, + }, +}; + +export { GoogleAccountIntegrationsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts new file mode 100644 index 000000000..f19c4a147 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler.ts @@ -0,0 +1,53 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GoogleAccountsResponse { + attributes: Omit; + id: string; + relationships: { + contact_groups: { + data: unknown[]; + }; + }; + type: string; +} + +export interface GoogleAccountAttributes { + 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 GoogleAccountAttributesCamel { + id: string; + createdAt: string; + email: string; + expiresAt: string; + lastDownload: string; + lastEmailSync: string; + primary: boolean; + remoteId: string; + tokenExpired: boolean; + updatedAt: string; + updatedInDbAt: string; +} + +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 { id: accounts.id, ...attributes }; + }); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql new file mode 100644 index 000000000..38ae1cc19 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql @@ -0,0 +1,17 @@ +extend type Query { + googleAccounts: [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/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/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..dec9271d7 --- /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).forEach((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/mailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler.ts new file mode 100644 index 000000000..05ef3a835 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/datahandler.ts @@ -0,0 +1,64 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface MailchimpAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +export interface MailchimpAccount { + id: string; + active: boolean; + auto_log_campaigns: boolean; + created_at: string; + lists_available_for_newsletters: MailchimpAccountNewsletters; + 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 MailchimpAccountNewsletters { + id: string; + name: string; +} + +interface MailchimpAccountCamel { + id: string; + active: boolean; + autoLogCampaigns: boolean; + createdAt: string; + listsAvailableForNewsletters: MailchimpAccountNewsletters[]; + listsLink: string; + listsPresent: boolean; + primaryListId: string; + primaryListName: string; + updatedAt: string; + updatedInDbAt: string; + valid: boolean; + validateKey: boolean; + validationError: string; +} + +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).forEach((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + return [ + { + id: data.id, + ...attributes, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql new file mode 100644 index 000000000..1f44c7f72 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/mailchimpAccount/mailchimpAccount.graphql @@ -0,0 +1,29 @@ +extend type Query { + mailchimpAccount(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/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/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/prayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..86bcf9d47 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/datahandler.ts @@ -0,0 +1,32 @@ +export interface PrayerlettersAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +interface PrayerlettersAccount { + id: string; + created_at: string; + updated_at: string; + updated_in_db_at; + valid_token: boolean; +} + +interface PrayerlettersAccountCamel { + id: string; + validToken: boolean; +} + +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 []; + return [ + { + id: data.id, + validToken: data.attributes.valid_token, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql new file mode 100644 index 000000000..ef63faf41 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/prayerlettersAccount/prayerlettersAccount.graphql @@ -0,0 +1,13 @@ +extend type Query { + prayerlettersAccount( + input: PrayerlettersAccountInput! + ): [PrayerlettersAccount] +} + +input PrayerlettersAccountInput { + accountListId: ID! +} + +type PrayerlettersAccount { + validToken: Boolean! +} 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/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..811d2c8f4 --- /dev/null +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -0,0 +1,112 @@ +// GOOGLE INTEGRATION +// +// Get Accounts +import GoogleAccountsTypeDefs from '../Settings/Preferences/Intergrations/Google/googleAccounts/googleAccounts.graphql'; +import { GoogleAccountsResolvers } from '../Settings/Preferences/Intergrations/Google/googleAccounts/resolvers'; +// account integrations +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'; +// 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 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'; +// 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 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'; +// 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: GoogleAccountsTypeDefs, + resolvers: GoogleAccountsResolvers, + }, + { + typeDefs: GoogleAccountIntegrationsTypeDefs, + resolvers: GoogleAccountIntegrationsResolvers, + }, + { + typeDefs: UpdateGoogleIntegrationTypeDefs, + resolvers: UpdateGoogleIntegrationResolvers, + }, + { + typeDefs: SyncGoogleIntegrationTypeDefs, + resolvers: SyncGoogleIntegrationResolvers, + }, + { + typeDefs: DeleteGoogleAccountTypeDefs, + resolvers: DeleteGoogleAccountResolvers, + }, + { + typeDefs: CreateGoogleIntegrationTypeDefs, + resolvers: CreateGoogleIntegrationResolvers, + }, + { + typeDefs: MailchimpAccountTypeDefs, + resolvers: MailchimpAccountResolvers, + }, + { + typeDefs: UpdateMailchimpAccountTypeDefs, + resolvers: UpdateMailchimpAccountResolvers, + }, + { + typeDefs: SyncMailchimpAccountTypeDefs, + resolvers: SyncMailchimpAccountResolvers, + }, + { + typeDefs: DeleteMailchimpAccountTypeDefs, + resolvers: DeleteMailchimpAccountResolvers, + }, + { + typeDefs: PrayerlettersAccountTypeDefs, + resolvers: PrayerlettersAccountResolvers, + }, + { + 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 df47c8bd1..f174798da 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -43,6 +43,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([ { @@ -121,6 +122,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 7e61992d8..5767b783a 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, @@ -56,15 +65,6 @@ import { import { getAccountListDonorAccounts } from './Schema/AccountListDonorAccounts/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, @@ -74,9 +74,45 @@ import { DestroyDonorAccount, DestroyDonorAccountResponse, } from './Schema/Contacts/DonorAccounts/Destroy/datahander'; +import { + GoogleAccounts, + GoogleAccountsResponse, +} from './Schema/Settings/Preferences/Intergrations/Google/googleAccounts/datahandler'; +import { + GoogleAccountIntegrationsResponse, + GoogleAccountIntegrations, +} from './Schema/Settings/Preferences/Intergrations/Google/googleAccountIntegrations/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 { + 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 { + UpdateMailchimpAccount, + UpdateMailchimpAccountResponse, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler'; +import { + 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'; 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 { @@ -814,6 +850,207 @@ class MpdxRestApi extends RESTDataSource { ); return data; } + + // Google Integration + // + // + + async googleAccounts() { + const { data }: { data: GoogleAccountsResponse[] } = await this.get( + 'user/google_accounts', + { + sort: 'created_at', + include: 'contact_groups', + }, + ); + return GoogleAccounts(data); + } + + async googleAccountIntegrations( + googleAccountId: string, + accountListId: string, + ) { + const { data }: { data: GoogleAccountIntegrationsResponse[] } = + await this.get( + `user/google_accounts/${googleAccountId}/google_integrations?${encodeURI( + `filter[account_list_id]=${accountListId}`, + )}`, + ); + return GoogleAccountIntegrations(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).forEach((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 mailchimpAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: MailchimpAccountResponse } = await this.get( + `account_lists/${accountListId}/mail_chimp_account`, + ); + return MailchimpAccount(data); + } catch { + return MailchimpAccount(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 prayerlettersAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: PrayerlettersAccountResponse } = await this.get( + `account_lists/${accountListId}/prayer_letters_account`, + ); + return PrayerlettersAccount(data); + } catch { + return PrayerlettersAccount(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 { 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/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/public/images/settings-preferences-intergrations-chalkline.png b/public/images/settings-preferences-intergrations-chalkline.png new file mode 100644 index 000000000..54500787d Binary files /dev/null and b/public/images/settings-preferences-intergrations-chalkline.png differ diff --git a/public/images/settings-preferences-intergrations-google.png b/public/images/settings-preferences-intergrations-google.png new file mode 100644 index 000000000..c60525bda Binary files /dev/null and b/public/images/settings-preferences-intergrations-google.png differ diff --git a/public/images/settings-preferences-intergrations-key.png b/public/images/settings-preferences-intergrations-key.png new file mode 100644 index 000000000..3799ef1e4 Binary files /dev/null and b/public/images/settings-preferences-intergrations-key.png differ 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/public/images/settings-preferences-intergrations-organizations.png b/public/images/settings-preferences-intergrations-organizations.png new file mode 100644 index 000000000..28a1deef3 Binary files /dev/null and b/public/images/settings-preferences-intergrations-organizations.png differ 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/Chalkline/ChalklineAccordion.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx new file mode 100644 index 000000000..ee1a240aa --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.test.tsx @@ -0,0 +1,131 @@ +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'; +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 { ChalklineAccordion } from './ChalklineAccordion'; + +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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordion closed', async () => { + const { getByText, queryByRole } = render( + + + + + , + ); + expect(getByText('Chalk Line')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordion open', async () => { + const { queryByRole } = render( + + + + + , + ); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).toBeInTheDocument(); + }); + + it('should send contacts to Chalkline', async () => { + const openMock = jest.fn(); + window.open = openMock; + const mutationSpy = jest.fn(); + const { getByText } = render( + + + + + , + ); + 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, + }); + }); + + await waitFor( + () => + expect(openMock).toHaveBeenCalledWith( + 'https://chalkline.org/order_mpdx/', + '_blank', + ), + { timeout: 3000 }, + ); + }); +}); diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx new file mode 100644 index 000000000..678e6d868 --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordion.tsx @@ -0,0 +1,87 @@ +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 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'; +import { StyledServicesButton, AccordionProps } from '../integrationsHelper'; + +export const ChalklineAccordion: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const accordionName = t('Chalk Line'); + const [showModal, setShowModal] = useState(false); + const accountListId = useAccountListId(); + const [sendToChalkline] = useSendToChalklineMutation(); + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + 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) +} diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx new file mode 100644 index 000000000..a83e1b80f --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordion.test.tsx @@ -0,0 +1,241 @@ +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'; +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 { GoogleAccordion } from './GoogleAccordion'; +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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const standardGoogleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +describe('GoogleAccordion', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + + it('should render accordion closed', async () => { + const { getByText, queryByRole } = render( + + + + + , + ); + expect(getByText('Google')).toBeInTheDocument(); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).not.toBeInTheDocument(); + }); + it('should render accordion open', async () => { + const { queryByRole } = render( + + + + + , + ); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + process.env.SITE_URL = 'https://next.mpdx.org'; + it('should render Mailchimp Overview', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + + + mocks={{ + GoogleAccounts: { + googleAccounts: [], + }, + }} + 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=https%3A%2F%2Fnext.mpdx.org%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let googleAccount = { ...standardGoogleAccount }; + process.env.REWRITE_DOMAIN = 'stage.mpdx.org'; + + beforeEach(() => { + googleAccount = { ...standardGoogleAccount }; + }); + it('shows one connected account', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByTestId } = render( + + + mocks={{ + GoogleAccounts: { + googleAccounts: [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 () => { + process.env.SITE_URL = 'https://next.mpdx.org'; + const mutationSpy = jest.fn(); + googleAccount.tokenExpired = true; + const { getByText, getAllByText } = render( + + + mocks={{ + GoogleAccounts: { + googleAccounts: [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=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/GoogleAccordion.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.tsx new file mode 100644 index 000000000..67cb26d8a --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordion.tsx @@ -0,0 +1,236 @@ +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'; +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'; +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 GoogleAccordionProps { + 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 GoogleAccordion: 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?.googleAccounts; + const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); + 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); + }; + const handleDeleteAccount = async (account) => { + setSelectedAccount(account); + setOpenDeleteGoogleAccount(true); + }; + return ( + <> + + } + > + {loading && } + {!loading && !googleAccounts?.length && !!expandedPanel && ( + <> + + {t('Google Integration Overview')} + + + {t(`Google’s suite of tools are great at connecting you to your + Ministry Partners.`)} + + + {t( + `By synchronizing your Google services with {{appName}}, you will be able + to:`, + { appName }, + )} + + + + {t('See {{appName}} tasks in your Google Calendar', { + appName, + })} + + + {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. {{appName}} leaves you in + control of how each service stays in sync.`, + { appName }, + )} + + + )} + + {!loading && + googleAccounts?.map((account) => ( + + + + {account?.email} + + + handleEditAccount(account)}> + + + handleDeleteAccount(account)} + > + + + + + {account?.tokenExpired && ( + <> + + {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.`, + { appName }, + )} + + + {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..99f2f757e --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -0,0 +1,129 @@ +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'; +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 }: PropsWithChildren) => ( + + + + + {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( + + + + + , + ); + 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( + + + + + , + ); + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(); + userEvent.click(getByText('Confirm')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} 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..964d054b9 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -0,0 +1,89 @@ +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 } from '../googleAccounts.generated'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordion'; + +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) => { + cache.evict({ + id: `GoogleAccountAttributes:${account.id}`, + }); + }, + onCompleted: () => { + enqueueSnackbar( + t('{{appName}} removed your integration with Google.'), + { + variant: 'success', + }, + ); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t("{{appName}} 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..3fc6b816e --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -0,0 +1,542 @@ +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'; +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 { + GoogleAccountIntegrationsQuery, + 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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const googleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, +}; + +const standardGoogleIntegration: Pick< + Types.GoogleAccountIntegration, + | 'calendarId' + | 'calendarIntegration' + | 'calendarIntegrations' + | 'calendarName' + | 'createdAt' + | 'updatedAt' + | 'id' + | 'updatedInDbAt' +> & { + calendars: Array< + Types.Maybe> + >; +} = { + calendarId: null, + calendarIntegration: true, + calendarIntegrations: ['Appointment'], + calendarName: 'calendar', + calendars: [ + { + 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( + + + + + , + ); + 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( + + + + + , + ); + 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( + + + + + , + ); + 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( + + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + }, + { + id: 'Appointment', + value: 'Appointment', + }, + { + id: 'Email', + value: 'Email', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for {{appName}} 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(); + const { getByText, getByRole, getByTestId } = render( + + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + }, + { + id: 'Appointment', + value: 'Appointment', + }, + { + id: 'Email', + value: 'Email', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for {{appName}} to push tasks to:/i), + ).toBeInTheDocument(), + ); + + await waitFor(() => + expect(getByTestId('Call-Checkbox')).toBeInTheDocument(), + ); + + await act(async () => { + 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( + + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for {{appName}} 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 create a Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + + + 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( + + + mocks={{ + GoogleAccountIntegrations: { + googleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for {{appName}} 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..058607dfc --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -0,0 +1,293 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { + useGoogleAccountIntegrationsQuery, + GoogleAccountIntegrationsDocument, + GoogleAccountIntegrationsQuery, + 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 '../GoogleAccordion'; +import { EditGoogleIntegrationForm } from './EditGoogleIntegrationForm'; + +interface EditGoogleAccountModalProps { + handleClose: () => void; + account: GoogleAccountAttributesSlimmed; + oAuth: string; +} + +enum tabsEnum { + 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 accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + const [tabSelected, setTabSelected] = useState(tabsEnum.calendar); + + const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); + const [createGoogleIntegration] = useCreateGoogleIntegrationMutation(); + const [syncGoogleAccountQuery] = useSyncGoogleAccountMutation(); + const { + data, + loading, + refetch: refetchGoogleIntegrations, + } = useGoogleAccountIntegrationsQuery({ + variables: { + input: { + googleAccountId: account.id, + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const googleAccountDetails = data?.googleAccountIntegrations[0]; + + const handleTabChange = (_, tab) => { + setTabSelected(tab); + }; + + const handleToggleCalendarIntegration = 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: GoogleAccountIntegrationsDocument, + 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: tabsEnum.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 === tabsEnum.calendar && ( + + )} + + {!loading && + !googleAccountDetails?.calendarIntegration && + tabSelected === tabsEnum.calendar && ( + + {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 {{appName}} will add + 'Appointment' tasks to your calendar.`, + { appName }, + )} + + )} + + {tabSelected === tabsEnum.setup && ( + + {t( + `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 {{appName}})`, + { appName }, + )} + + )} + + + {tabSelected === tabsEnum.calendar && + !googleAccountDetails?.calendarIntegration && ( + + + handleToggleCalendarIntegration(true)} + > + {t('Enable Calendar Integration')} + + + )} + {tabSelected === tabsEnum.calendar && + googleAccountDetails?.calendarIntegration && ( + + + + {t('Sync Calendar')} + + + )} + {tabSelected === tabsEnum.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..d792d5cbd --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -0,0 +1,267 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { + GoogleAccountIntegrationsDocument, + GoogleAccountIntegrationsQuery, + 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 '../GoogleAccordion'; + +type GoogleAccountIntegrationSlimmed = Pick< + GoogleAccountIntegration, + 'calendarId' | 'id' | 'calendarIntegrations' | 'calendars' +>; +interface EditGoogleIntegrationFormProps { + account: GoogleAccountAttributesSlimmed; + googleAccountDetails: GoogleAccountIntegrationSlimmed; + loading: boolean; + setIsSubmitting: (boolean) => void; + handleToggleCalendarIntegration: (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, + handleToggleCalendarIntegration, + handleClose, +}) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + 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: GoogleAccountIntegrationsDocument, + 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 {{appName}} to push tasks to:', { + appName, + })} + + + + {({ + 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 newCalendarIntegrations; + if (value) { + // Add to calendarIntegrations + newCalendarIntegrations = [ + ...calendarIntegrations, + activity.value, + ]; + } else { + // Remove from calendarIntegrations + newCalendarIntegrations = + calendarIntegrations.filter( + (act) => act !== activity?.id, + ); + } + setFieldValue( + `calendarIntegrations`, + newCalendarIntegrations, + ); + }} + /> + } + label={activity.value} + /> + ); + })} + + + + handleToggleCalendarIntegration(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..a77e572df --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql @@ -0,0 +1,42 @@ +query GoogleAccountIntegrations($input: GoogleAccountIntegrationsInput!) { + googleAccountIntegrations(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..7abaed3d6 --- /dev/null +++ b/src/components/Settings/integrations/Google/googleAccounts.graphql @@ -0,0 +1,19 @@ +query GoogleAccounts { + googleAccounts { + email + primary + remoteId + id + tokenExpired + } +} + +mutation SyncGoogleAccount($input: SyncGoogleAccountInput!) { + syncGoogleAccount(input: $input) +} + +mutation DeleteGoogleAccount($input: DeleteGoogleAccountInput!) { + deleteGoogleAccount(input: $input) { + success + } +} 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/TheKeyAccordion.tsx b/src/components/Settings/integrations/Key/TheKeyAccordion.tsx new file mode 100644 index 000000000..cd3c77851 --- /dev/null +++ b/src/components/Settings/integrations/Key/TheKeyAccordion.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 TheKeyAccordionProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +export const TheKeyAccordion: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const { data, loading } = useGetKeyAccountsQuery(); + const keyAccounts = data?.user?.keyAccounts; + return ( + + } + > + {loading && } + {!loading && + keyAccounts?.map((account, idx) => ( + + + + + + ))} + + ); +}; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx new file mode 100644 index 000000000..e6dc37547 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.test.tsx @@ -0,0 +1,514 @@ +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'; +import { MailchimpAccountQuery } 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 { MailchimpAccordion } from './MailchimpAccordion'; + +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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const standardMailchimpAccount: Types.MailchimpAccount = { + id: '123456789', + active: true, + autoLogCampaigns: false, + createdAt: 'DATETIME', + listsAvailableForNewsletters: [ + { + id: '11111111', + name: 'Newsletter list 1', + }, + { + id: '2222222', + name: 'Newsletter list 2', + }, + { + 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 accordion closed', async () => { + const { getByText, queryByRole } = render( + + + + + , + ); + expect(getByText('MailChimp')).toBeInTheDocument(); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).not.toBeInTheDocument(); + }); + it('should render accordion open', async () => { + const { queryByRole } = render( + + + + + , + ); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render Mailchimp Overview', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; + const mutationSpy = jest.fn(); + const { getByText } = render( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [], + }, + }} + 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=https%3A%2F%2Fnext.mpdx.org%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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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 MailChimp that {{appName}} 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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => { + expect( + queryByText( + 'You need to create a list on MailChimp that {{appName}} 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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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( + '{{appName}} 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( + 'MailchimpAccount', + ); + }); + it('should show settings overview', async () => { + mailchimpAccount.valid = true; + const mutationSpy = jest.fn(); + const { getByText } = render( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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( + + + mocks={{ + MailchimpAccount: { + mailchimpAccount: [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/MailchimpAccordion.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx new file mode 100644 index 000000000..b0d095eb3 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordion.tsx @@ -0,0 +1,406 @@ +import { useState, useContext, 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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { + useMailchimpAccountQuery, + useUpdateMailchimpAccountMutation, + MailchimpAccountDocument, + MailchimpAccountQuery, + 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 MailchimpAccordionProps { + 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 MailchimpAccordion: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [showSettings, setShowSettings] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const [updateMailchimpAccount] = useUpdateMailchimpAccountMutation(); + const [syncMailchimpAccount] = useSyncMailchimpAccountMutation(); + const { + data, + loading, + refetch: refetchMailchimpAccount, + } = useMailchimpAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const mailchimpAccount = data?.mailchimpAccount + ? data.mailchimpAccount[0] + : null; + + 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 + > = 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: MailchimpAccountDocument, + 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 && ( + <> + {t('MailChimp Overview')} + + {t(`MailChimp makes keeping in touch with your ministry partners easy + and streamlined. Here's how it works:`)} + + + + {t( + `If you have an existing MailChimp list you'd like to use, Great! + Or, create a new one for your {{appName}} connection.`, + { + appName, + }, + )} + + + {t( + 'Select your {{appName}} MailChimp list to stream your {{appName}} contacts into.', + { + appName, + }, + )} + + + + {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')} + + + )} + {!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 MailChimp that {{appName}} can use for your newsletter.', + { + appName, + }, + )} + + {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..27e0923e5 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql @@ -0,0 +1,51 @@ +query MailchimpAccount($input: MailchimpAccountInput!) { + mailchimpAccount(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..702759f82 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx @@ -0,0 +1,88 @@ +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; + appName: string; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeleteMailchimpAccountModal: React.FC< + DeleteMailchimpAccountModalProps +> = ({ handleClose, accountListId, refetchMailchimpAccount, appName }) => { + 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('{{appName}} removed your integration with MailChimp', { appName }), + { + variant: 'success', + }, + ); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t( + "{{appName}} couldn't save your configuration changes for MailChimp", + { appName }, + ), + { + variant: 'error', + }, + ); + }, + }); + setIsSubmitting(false); + }; + + return ( + + + + {t(`Are you sure you wish to disconnect your Mailchimp account?`)} + + + + + + + {t('Confirm')} + + + + ); +}; 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..2b9f48532 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -0,0 +1,321 @@ +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'; +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 }: PropsWithChildren) => ( + + + + + {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( + + + + + , + ); + + 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( + + + 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( + '{{appName}} added your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateOrganizationAccount', + ); + + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + attributes: { + organizationId: mocks.GetOrganizations.organizations[0].id, + }, + }); + }); + }); + + it('should select Ministry Organization and be unable to add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'ministryName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'ministryName' })); + + await waitFor(() => { + expect( + getByText('You must log into {{appName}} with your ministry email'), + ).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + }); + + it('should select Login Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, getByTestId } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'loginName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'loginName' })); + + await waitFor(() => { + expect(getByText('Username')).toBeInTheDocument(); + expect(getByText('Password')).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + + userEvent.type( + getByRole('textbox', { + name: /username/i, + }), + 'MyUsername', + ); + await waitFor(() => expect(getByText('Add Account')).toBeDisabled()); + userEvent.type(getByTestId('passwordInput'), 'MyPassword'); + + await waitFor(() => expect(getByText('Add Account')).not.toBeDisabled()); + userEvent.click(getByText('Add Account')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} added your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + attributes: { + organizationId: mocks.GetOrganizations.organizations[2].id, + username: 'MyUsername', + password: 'MyPassword', + }, + }); + }); + }); + + it('should select OAuth Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + + , + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'oAuthName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'oAuthName' })); + + await waitFor(() => { + expect( + getByText( + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + ), + ).toBeInTheDocument(); + expect(getByText('Connect')).toBeInTheDocument(); + expect(getByText('Connect')).not.toBeDisabled(); + }); + + userEvent.click(getByText('Connect')); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to complete authentication to connect.', + { variant: 'success' }, + ); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx new file mode 100644 index 000000000..e069ddd82 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -0,0 +1,369 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { + useGetOrganizationsQuery, + useCreateOrganizationAccountMutation, +} from '../Organizations.generated'; +import { showArticle, articles } 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 '../OrganizationAccordion'; +import { getOauthUrl } 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 { appName } = useGetAppSettings(); + 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 authentication to connect.'), + { variant: 'success' }, + ); + window.location.href = await getOauthUrl(id); + return; + } + + if (!accountListId) return; + + const createAccountAttributes: { + organizationId: string; + password?: string; + username?: string; + } = { + organizationId: id, + }; + if (attributes.password) { + createAccountAttributes.password = attributes.password; + } + if (attributes.username) { + createAccountAttributes.username = attributes.username; + } + + await createOrganizationAccount({ + variables: { + input: { + attributes: createAccountAttributes, + }, + }, + update: () => refetchOrganizations(), + onError: () => { + enqueueSnackbar(t('Invalid username or password.'), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar( + t('{{appName}} added your organization account', { appName }), + { + variant: 'success', + }, + ); + }, + }); + handleClose(); + return; + }; + + const showOrganizationHelp = () => { + showArticle('HS_SETUP_FIND_ORGANIZATION'); + }; + + 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 && !!articles.HS_SETUP_FIND_ORGANIZATION && ( + + )} + + {organizationType === OrganizationTypesEnum.MINISTRY && ( + + + {t('You must log into {{appName}} with your ministry email', { + appName, + })} + + + {t( + 'This organization requires you to log into {{appName}} with your ministry email to access it.', + { appName }, + )} +
    +
  1. + {t('First you need to ')} + + {t( + 'click here to log out of your personal Key account', + )} + +
  2. +
  3. + {t('Next, ')} + { + signOut({ callbackUrl: 'signOut' }).then(() => { + clearDataDogUser(); + }); + }} + > + {t('click here to log out of {{appName}}', { appName })} + + {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 {{appName}} to sync your data.", + { appName }, + )} + +
+ )} + + {organizationType === OrganizationTypesEnum.OAUTH && ( + + + {t( + "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", + { appName }, + )} + + + )} + + {organizationType === OrganizationTypesEnum.LOGIN && ( + <> + + + + + + + + + + + + )} + + + + + + {organizationType !== OrganizationTypesEnum.OAUTH && + t('Add Account')} + {organizationType === OrganizationTypesEnum.OAUTH && + t('Connect')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx new file mode 100644 index 000000000..360587af8 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -0,0 +1,122 @@ +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'; +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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); + +describe('OrganizationEditAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + '{{appName}} 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..01cfc6634 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx @@ -0,0 +1,154 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +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 { appName } = useGetAppSettings(); + 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('{{appName}} updated your organization account', { appName }), + { + 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..4b567aa47 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -0,0 +1,258 @@ +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'; +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 { + OrganizationImportDataSyncModal, + validateFile, +} from './OrganizationImportDataSyncModal'; + +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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); + +const t = (text: string) => { + return text; +}; + +describe('OrganizationImportDataSyncModal', () => { + describe('ValidateFile()', () => { + it('File type is not correct', () => { + const file = new File(['contents'], 'image.png', { + type: 'image/png', + }); + const response = validateFile({ file, t }); + + 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 }); + + 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, + }); + }); + }); + + describe('Render and upload file tests', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + + + + + , + ); + + expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + describe('Send Files to API', () => { + const fetch = jest.fn().mockResolvedValue({ status: 201 }); + beforeEach(() => { + window.fetch = fetch; + }); + + it('should return error when file is too large', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByTestId } = render( + + + + + , + ); + 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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 = Object.fromEntries( + (window.fetch as jest.Mock).mock.calls[0][1].body.entries(), + ); + + 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', + }, + ), + ); + }); + }); + }); +}); 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..568ae5ae5 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -0,0 +1,196 @@ +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, + 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 { getErrorMessage } from 'src/lib/getErrorFromCatch'; + +export const validateFile = ({ + file, + t, +}: { + file: File; + t: TFunction; +}): { success: true } | { success: false; message: string } => { + if (!file.name.endsWith('.tntmpd') && !file.name.endsWith('.tntdatasync')) { + 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; + 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 [isValid, setIsValid] = 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); + + 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 file: 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); + setIsValid(true); + } catch (err) { + setIsValid(false); + 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/OrganizationAccordion.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx new file mode 100644 index 000000000..3e861ba6c --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.test.tsx @@ -0,0 +1,414 @@ +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'; +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 { OrganizationAccordion } from './OrganizationAccordion'; +import { cloneDeep } from 'lodash'; + +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 }: PropsWithChildren) => ( + + + + + {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('OrganizationAccordion', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordion closed', async () => { + const { getByText, queryByRole } = render( + + + + + , + ); + expect(getByText('Organization')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordion open', async () => { + const { queryByRole } = render( + + + + + , + ); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('No Organizations connected', () => { + it('should render Organization Overview', async () => { + const { getByText } = render( + + + 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 = cloneDeep(standardMocks); + }); + + it('should render Offline Organization', async () => { + const { getByText, queryByText } = render( + + + 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( + + + 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( + '{{appName}} 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( + + + 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( + + + 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( + + + 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( + '{{appName}} 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( + + + mocks={mocks} + > + + + , + ); + + expect(queryByText('Last Updated')).not.toBeInTheDocument(); + + expect(queryByText('Last Gift Date')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx new file mode 100644 index 000000000..a3dcd2745 --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordion.tsx @@ -0,0 +1,358 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +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 { getOauthUrl } from './OrganizationService'; +import { StyledServicesButton } from '../integrationsHelper'; + +interface OrganizationAccordionProps { + 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: string | undefined, + oauth = false, +) => { + const ministryAccount = new Set([ + 'Siebel', + 'Remote::Import::OrganizationAccountService', + ]); + const loginRequired = new Set([ + 'DataServer', + 'DataServerPtc', + 'DataServerNavigators', + 'DataServerStumo', + ]); + + if (apiClass) { + if (ministryAccount.has(apiClass)) { + return OrganizationTypesEnum.MINISTRY; + } else if (loginRequired.has(apiClass) && !oauth) { + return OrganizationTypesEnum.LOGIN; + } else if (oauth) { + return OrganizationTypesEnum.OAUTH; + } else if (apiClass === 'OfflineOrg') { + return OrganizationTypesEnum.OFFLINE; + } + } + return undefined; +}; + +export const OrganizationAccordion: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + 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] = + 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 getOauthUrl(organizationId); + window.location.href = oAuthUrl; + }; + + const handleSync = async (accountId: string) => { + await syncOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + onError: () => { + enqueueSnackbar( + t("{{appName}} couldn't sync your organization account", { appName }), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t( + '{{appName}} started syncing your organization account. This will occur in the background over the next 24-hours.', + { appName }, + ), + { + variant: 'success', + }, + ); + }, + }); + }; + + const handleDelete = async (accountId: string) => { + await deleteOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + update: () => refetchOrganizations(), + onError: () => { + enqueueSnackbar( + t( + "{{appName}} couldn't save your configuration changes for that organization", + { appName }, + ), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t('{{appName}} removed your organization integration', { appName }), + { + variant: 'success', + }, + ); + }, + }); + }; + + return ( + + } + > + + {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 && ( + + {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)} + > + {t('Sync')} + + )} + + {type === OrganizationTypesEnum.OFFLINE && ( + setShowImportDataSyncModal(true)} + > + {t('Import TntConnect DataSync file')} + + )} + + {type === OrganizationTypesEnum.OAUTH && ( + handleReconnect(organization.id)} + > + {t('Reconnect')} + + )} + {type === OrganizationTypesEnum.LOGIN && ( + setShowEditOrganizationModal(true)} + > + + + )} + setShowDeleteOrganizationModal(true)} + > + + + + + + {lastDownloadedAt && ( + + + + {t('Last Updated')} + + + {DateTime.fromISO(lastDownloadedAt).toRelative()} + + + + )} + {latestDonationDate && ( + + + + {t('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)} + > + {t('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..737d7c9b1 --- /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 getOauthUrl = async ( + organizationId: string, + 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..9ab64a0d0 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Organizations.graphql @@ -0,0 +1,66 @@ +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) { + id + } +} + +mutation CreateOrganizationAccount( + $input: OrganizationAccountCreateMutationInput! +) { + createOrganizationAccount(input: $input) { + organizationAccount { + id + username + person { + id + } + } + } +} + +mutation SyncOrganizationAccount( + $input: OrganizationAccountSyncMutationInput! +) { + syncOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} + +mutation UpdateOrganizationAccount( + $input: OrganizationAccountUpdateMutationInput! +) { + updateOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} 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..93bee9471 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx @@ -0,0 +1,91 @@ +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 useGetAppSettings from 'src/hooks/useGetAppSettings'; +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 { appName } = useGetAppSettings(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deletePrayerlettersAccount] = useDeletePrayerlettersAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + try { + await deletePrayerlettersAccount({ + variables: { + input: { + accountListId, + }, + }, + update: () => refetchPrayerlettersAccount(), + }); + enqueueSnackbar( + t('{{appName}} removed your integration with Prayer Letters', { + appName, + }), + { + variant: 'success', + }, + ); + } catch { + enqueueSnackbar( + t( + "{{appName}} couldn't save your configuration changes for Prayer Letters", + { appName }, + ), + { + 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/PrayerlettersAccordion.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx new file mode 100644 index 000000000..2ff4d5336 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.test.tsx @@ -0,0 +1,319 @@ +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'; +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 { PrayerlettersAccordion } from './PrayerlettersAccordion'; +import { PrayerlettersAccountQuery } 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 }: PropsWithChildren) => ( + + + + + {children} + + + + +); + +const standardPrayerlettersAccount: Types.PrayerlettersAccount = { + __typename: 'PrayerlettersAccount', + validToken: true, +}; + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordion closed', async () => { + const { getByText, queryByRole } = render( + + + + + , + ); + expect(getByText('prayerletters.com')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordion open', async () => { + const { queryByRole } = render( + + + + + , + ); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render PrayerLetters.com Overview', async () => { + process.env.SITE_URL = 'https://next.mpdx.org'; + const { getByText } = render( + + + mocks={{ + PrayerlettersAccount: { + prayerlettersAccount: [], + }, + }} + > + + + , + ); + + 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=https%3A%2F%2Fnext.mpdx.org%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 () => { + process.env.SITE_URL = 'https://next.mpdx.org'; + prayerlettersAccount.validToken = false; + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByRole } = render( + + + mocks={{ + PrayerlettersAccount: { + prayerlettersAccount: [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=https%3A%2F%2Fnext.mpdx.org%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( + '{{appName}} 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( + + + mocks={{ + PrayerlettersAccount: { + prayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => { + expect( + queryByText( + 'We strongly recommend only making changes in {{appName}}.', + ), + ).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( + '{{appName}} 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('should sync contacts', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + + + mocks={{ + PrayerlettersAccount: { + prayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => { + expect( + queryByText( + 'We strongly recommend only making changes in {{appName}}.', + ), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /sync now/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + '{{appName}} is now syncing your newsletter recipients with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'SyncPrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx new file mode 100644 index 000000000..4674b4f62 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordion.tsx @@ -0,0 +1,214 @@ +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, +} 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, AccordionProps } from '../integrationsHelper'; + +export const PrayerlettersAccordion: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [isSaving, setIsSaving] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + showDeleteModal; + const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const accordionName = t('prayerletters.com'); + const [syncPrayerlettersAccount] = useSyncPrayerlettersAccountMutation(); + const { + data, + loading, + refetch: refetchPrayerlettersAccount, + } = usePrayerlettersAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: expandedPanel !== accordionName, + }); + + const prayerlettersAccount = data?.prayerlettersAccount + ? data?.prayerlettersAccount[0] + : null; + + 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); + + await syncPrayerlettersAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + onError: () => { + enqueueSnackbar( + t( + "{{appName}} couldn't save your configuration changes for Prayer Letters", + { appName }, + ), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t( + '{{appName}} is now syncing your newsletter recipients with Prayer Letters', + { appName }, + ), + { + 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 {{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 {{appName}}. Any + contacts or information that are in your current prayerletters.com + list that are not in {{appName}} will be deleted. We strongly recommend + only making changes in {{appName}}.`, + { appName }, + )} + + + {t('Connect prayerletters.com Account')} + + + )} + {!loading && prayerlettersAccount && !prayerlettersAccount?.validToken && ( + <> + + {t( + 'The link between {{appName}} and your prayerletters.com account stopped working. Click "Refresh prayerletters.com Account" to re-enable it.', + { appName }, + )} + + + + + + + + + )} + {!loading && prayerlettersAccount && prayerlettersAccount?.validToken && ( + <> + + + {t( + `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 {{appName}}.', { + appName, + })} + + + + + + + + + + )} + {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..77fd59f1d --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql @@ -0,0 +1,13 @@ +query PrayerlettersAccount($input: PrayerlettersAccountInput!) { + prayerlettersAccount(input: $input) { + validToken + } +} + +mutation SyncPrayerlettersAccount($input: SyncPrayerlettersAccountInput!) { + syncPrayerlettersAccount(input: $input) +} + +mutation DeletePrayerlettersAccount($input: DeletePrayerlettersAccountInput!) { + deletePrayerlettersAccount(input: $input) +} diff --git a/src/components/Settings/integrations/integrationsHelper.ts b/src/components/Settings/integrations/integrationsHelper.ts new file mode 100644 index 000000000..8e9d81a1b --- /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 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', () => { { { 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.test.ts b/src/lib/helpScout.test.ts index bba9d50ad..ec4c33035 100644 --- a/src/lib/helpScout.test.ts +++ b/src/lib/helpScout.test.ts @@ -13,6 +13,12 @@ describe('HelpScout', () => { beforeEach(() => { beacon.readyQueue = []; window.Beacon = beacon; + jest.resetModules(); + process.env = { ...env }; + }); + + afterEach(() => { + process.env = env; }); beforeEach(() => { @@ -52,7 +58,6 @@ describe('HelpScout', () => { }); }); }); - describe('suggestArticles', () => { it('calls callBeacon when the suggestions exist', () => { const makeSuggestions = (key: string) => `${key}-1,${key}-2`; @@ -118,8 +123,12 @@ describe('HelpScout', () => { 'coaching-outstanding-recurring-commitments', HS_COACHING_OUTSTANDING_SPECIAL_NEEDS: 'coaching-outstanding-special-needs', + HS_SETUP_FIND_ORGANIZATION: 'organization-activity', }); + showArticle('HS_SETUP_FIND_ORGANIZATION'); + expect(beacon).toHaveBeenCalledWith('article', 'organization-activity'); + showArticle('HS_COACHING_ACTIVITY'); expect(beacon).toHaveBeenCalledWith('article', 'coaching-activity'); diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 8c7c729a6..f294b4153 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -77,9 +77,15 @@ const suggestions = { get HS_TASKS_SUGGESTIONS() { return process.env.HS_TASKS_SUGGESTIONS; }, + get HS_SETTINGS_SERVICES_SUGGESTIONS() { + return process.env.HS_SETTINGS_SERVICES_SUGGESTIONS; + }, }; -const articles = { +export const articles = { + get HS_SETUP_FIND_ORGANIZATION() { + return process.env.HS_SETUP_FIND_ORGANIZATION; + }, get HS_COACHING_ACTIVITY() { return process.env.HS_COACHING_ACTIVITY; }, 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()}`); +};