diff --git a/next.config.js b/next.config.js index 21556856c..f38734219 100644 --- a/next.config.js +++ b/next.config.js @@ -51,6 +51,7 @@ module.exports = withPlugins([ REST_API_URL: process.env.REST_API_URL ?? 'https://api.stage.mpdx.org/api/v2/', SITE_URL: siteUrl, + OAUTH_URL: process.env.OAUTH_URL ?? 'https://auth.stage.mpdx.org', CLIENT_ID: process.env.CLIENT_ID ?? '4027334344069527005', CLIENT_SECRET: process.env.CLIENT_SECRET, BEACON_TOKEN: process.env.BEACON_TOKEN, @@ -94,6 +95,7 @@ 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_SETUP_FIND_ORGANIZATION: process.env.HS_SETUP_FIND_ORGANIZATION, ALERT_MESSAGE: process.env.ALERT_MESSAGE, }, experimental: { diff --git a/pages/accountLists/[accountListId]/reports/designationAccounts.page.tsx b/pages/accountLists/[accountListId]/reports/designationAccounts.page.tsx index 008bffea4..511a76e77 100644 --- a/pages/accountLists/[accountListId]/reports/designationAccounts.page.tsx +++ b/pages/accountLists/[accountListId]/reports/designationAccounts.page.tsx @@ -8,7 +8,11 @@ import Loading from 'src/components/Loading'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; + import { suggestArticles } from 'src/lib/helpScout'; const DesignationAccountsReportPageWrapper = styled(Box)(({ theme }) => ({ @@ -42,12 +46,13 @@ const DesignationAccountsReportPage: React.FC = () => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/reports/donations/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/reports/donations/[[...contactId]].page.tsx index 71e863f2b..91a2cf3cc 100644 --- a/pages/accountLists/[accountListId]/reports/donations/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/donations/[[...contactId]].page.tsx @@ -9,11 +9,14 @@ import Loading from 'src/components/Loading'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; import { getQueryParam } from 'src/utils/queryParam'; import { ContactsPage } from '../../contacts/ContactsPage'; import { ContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/ContactsRightPanel'; import { suggestArticles } from 'src/lib/helpScout'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; const DonationsReportPageWrapper = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.common.white, @@ -55,12 +58,13 @@ const DonationsReportPage: React.FC = () => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/reports/expectedMonthlyTotal.page.tsx b/pages/accountLists/[accountListId]/reports/expectedMonthlyTotal.page.tsx index f55bfc603..9764d1ac1 100644 --- a/pages/accountLists/[accountListId]/reports/expectedMonthlyTotal.page.tsx +++ b/pages/accountLists/[accountListId]/reports/expectedMonthlyTotal.page.tsx @@ -10,7 +10,10 @@ import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { ExpectedMonthlyTotalReport } from '../../../../src/components/Reports/ExpectedMonthlyTotalReport/ExpectedMonthlyTotalReport'; import { suggestArticles } from 'src/lib/helpScout'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; const ExpectedMonthlyTotalReportPageWrapper = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.common.white, @@ -43,12 +46,13 @@ const ExpectedMonthlyTotalReportPage = (): ReactElement => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/reports/partnerCurrency/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/reports/partnerCurrency/[[...contactId]].page.tsx index 63c6206e9..84f7142a4 100644 --- a/pages/accountLists/[accountListId]/reports/partnerCurrency/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/partnerCurrency/[[...contactId]].page.tsx @@ -10,7 +10,10 @@ import Loading from 'src/components/Loading'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; import { suggestArticles } from 'src/lib/helpScout'; import { getQueryParam } from 'src/utils/queryParam'; import { ContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/ContactsRightPanel'; @@ -55,12 +58,13 @@ const PartnerCurrencyReportPage: React.FC = () => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx b/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx index cdf15802d..d2a3ee598 100644 --- a/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx +++ b/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx @@ -8,7 +8,10 @@ import Loading from 'src/components/Loading'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; import { suggestArticles } from 'src/lib/helpScout'; const ResponsibilityCentersReportPageWrapper = styled(Box)(({ theme }) => ({ @@ -42,12 +45,13 @@ const ResponsibilityCentersReportPage: React.FC = () => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/reports/salaryCurrency/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/reports/salaryCurrency/[[...contactId]].page.tsx index 321ff5d99..0e4c5a907 100644 --- a/pages/accountLists/[accountListId]/reports/salaryCurrency/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/salaryCurrency/[[...contactId]].page.tsx @@ -10,7 +10,10 @@ import Loading from 'src/components/Loading'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { useAccountListId } from 'src/hooks/useAccountListId'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { NavReportsList } from 'src/components/Reports/NavReportsList/NavReportsList'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; import { suggestArticles } from 'src/lib/helpScout'; import { getQueryParam } from 'src/utils/queryParam'; import { ContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/ContactsRightPanel'; @@ -55,12 +58,13 @@ const SalaryCurrencyReportPage: React.FC = () => { } leftOpen={isNavListOpen} diff --git a/pages/accountLists/[accountListId]/settings/integrations.page.tsx b/pages/accountLists/[accountListId]/settings/integrations.page.tsx new file mode 100644 index 000000000..eb665f323 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/integrations.page.tsx @@ -0,0 +1,109 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SettingsWrapper } from './wrapper'; +import { suggestArticles } from 'src/lib/helpScout'; +import { GetServerSideProps } from 'next'; +import { getSession } from 'next-auth/react'; +import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; +import { TheKeyAccordian } from 'src/components/Settings/integrations/Key/TheKeyAccordian'; +import { OrganizationAccordian } from 'src/components/Settings/integrations/Organization/OrganizationAccordian'; +import { GoogleAccordian } from 'src/components/Settings/integrations/Google/GoogleAccordian'; +import { MailchimpAccordian } from 'src/components/Settings/integrations/Mailchimp/MailchimpAccordian'; +import { PrayerlettersAccordian } from 'src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian'; +import { ChalklineAccordian } from 'src/components/Settings/integrations/Chalkline/ChalklineAccordian'; + +interface Props { + apiToken: string; + selectedTab: string; +} + +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} + + ); +}; + +const Integrations = ({ apiToken, selectedTab }: Props): ReactElement => { + const { t } = useTranslation(); + const [expandedPanel, setExpandedPanel] = useState(''); + + useEffect(() => { + suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); + setExpandedPanel(selectedTab); + }, []); + + const handleAccordionChange = (panel: string) => { + const panelLowercase = panel.toLowerCase(); + setExpandedPanel(expandedPanel === panelLowercase ? '' : panelLowercase); + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async ({ + query, + req, +}) => { + const session = await getSession({ req }); + const apiToken = session?.user?.apiToken ?? null; + const selectedTab = query?.selectedTab ?? ''; + + return { + props: { + apiToken, + selectedTab, + }, + }; +}; + +export default Integrations; diff --git a/pages/accountLists/[accountListId]/settings/manageAccounts.page.tsx b/pages/accountLists/[accountListId]/settings/manageAccounts.page.tsx new file mode 100644 index 000000000..9a5ea0425 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/manageAccounts.page.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SettingsWrapper } from './wrapper'; +import { suggestArticles } from 'src/lib/helpScout'; + +import { AccordionGroup } from 'src/components/Shared/Forms/Accordions/AccordionGroup'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { FormWrapper } from 'src/components/Shared/Forms/Fields/FormWrapper'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { StyledOutlinedInput } from 'src/components/Shared/Forms/Field'; +//import DeleteIcon from '@mui/icons-material/Delete'; +import { styled } from '@mui/material/styles'; +import { Typography, Card, List, ListItemText, Alert } from '@mui/material'; +import theme from 'src/theme'; + +const StyledListItem = styled(ListItemText)(() => ({ + display: 'list-item', +})); + +const StyledList = styled(List)(({ theme }) => ({ + listStyleType: 'disc', + paddingLeft: theme.spacing(4), +})); + +const ManageAccounts: React.FC = () => { + const { t } = useTranslation(); + const [expandedPanel, setExpandedPanel] = useState(''); + // const [isValid, setIsValid] = useState(false); + // const [isSubmitting, setIsSubmitting] = useState(false); + const manageAccountsMockData = [ + { + name: 'Jack Sparrow', + email: 'jack.sparrow@cru.org', + }, + ]; + + useEffect(() => { + suggestArticles('HS_SETTINGS_SERVICES_SUGGESTIONS'); + }, []); + + const handleAccordionChange = (panel: string) => { + setExpandedPanel(expandedPanel === panel ? '' : panel); + }; + + const handleSubmit = () => { + // eslint-disable-next-line no-console + console.log('handleSubmithandleSubmit'); + }; + + return ( + + + + + Share this ministry account with other team members. + + + If you want to allow another mpdx user to have access to this + ministry account, you can share access with them. Make sure you have + the proper permissions and leadership consensus around this sharing + before you do this. You will be able to remove access later. + + {manageAccountsMockData[0] ? ( + <> + + Account currently shared with: + + + {manageAccountsMockData.map((item, index) => ( + {item.name} + ))} + + + ) : ( + '' + )} + + + + + + + + + + + + ); +}; + +export default ManageAccounts; diff --git a/pages/accountLists/[accountListId]/settings/notifications.page.tsx b/pages/accountLists/[accountListId]/settings/notifications.page.tsx new file mode 100644 index 000000000..a44687af9 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/notifications.page.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box } from '@mui/material'; +import { SettingsWrapper } from './wrapper'; +import { suggestArticles } from 'src/lib/helpScout'; +import { NotificationsTable } from 'src/components/Settings/notifications/NotificationsTable'; + +const Notifications: React.FC = () => { + const { t } = useTranslation(); + + useEffect(() => { + suggestArticles('HS_SETTINGS_PREFERENCES_SUGGESTIONS'); + }, []); + + return ( + + +

+ Based on an analysis of a partner's giving history, MPDX can + notify you of events that you will probably want to follow up on. The + detection logic is based on a set of rules that are right most of the + time, but you will still want to verify an event manually before + contacting the partner. +

+
+ +

+ For each event MPDX can notify you via email and also create a task + entry reminding you to do something about it. The options below allow + you to control that behavior. +

+
+ +
+ ); +}; + +export default Notifications; diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.tsx new file mode 100644 index 000000000..c6e3ffc72 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/preferences.page.tsx @@ -0,0 +1,380 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Checkbox, + FormControlLabel, + MenuItem, + Grid, + TextField, + InputAdornment, +} from '@mui/material'; +import { SettingsWrapper } from './wrapper'; +import { ProfileInfo } from '../../../../src/components/Settings/preferences/info/ProfileInfo'; +import { PreferencesGroup } from '../../../../src/components/Settings/preferences/accordions/PreferencesGroup'; +import { PreferencesItem } from '../../../../src/components/Settings/preferences/accordions/PreferencesItem'; +import { PersPrefFormWrapper } from '../../../../src/components/Settings/preferences/forms/PreferencesFormWrapper'; +import { + PersPrefFieldWrapper, + StyledOutlinedInput, + StyledSelect, +} from '../../../../src/components/Settings/preferences/shared/PreferencesForms'; +import { + language, + options, + localeOptions, +} from '../../../../src/components/Settings/preferences/DemoContent'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { MobileDatePicker } from '@mui/x-date-pickers'; +import CalendarToday from '@mui/icons-material/CalendarToday'; +import { getDateFormatPattern } from 'src/lib/intlFormat/intlFormat'; +import { suggestArticles } from 'src/lib/helpScout'; + +import { useLocale } from 'src/hooks/useLocale'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { FormWrapper } from 'src/components/Shared/Forms/Fields/FormWrapper'; + +const Preferences: React.FC = () => { + const { t } = useTranslation(); + const [expandedPanel, setExpandedPanel] = useState(''); + const locale = useLocale(); + const accountListId = useAccountListId() ?? ''; + + useEffect(() => { + suggestArticles('HS_SETTINGS_PREFERENCES_SUGGESTIONS'); + }, []); + + const handleAccordionChange = (panel: string) => { + setExpandedPanel(expandedPanel === panel ? '' : panel); + }; + + const handleSubmit = () => { + // eslint-disable-next-line no-console + console.log('handleSubmithandleSubmit'); + }; + + return ( + + + + + {/* Language */} + + + + + {language.map(([languageCode, languageName], index) => ( + + {t(languageName)} + + ))} + + + + + + {/* Locale */} + + + + + {localeOptions.map(([localeCode, localeName], index) => ( + + {t(localeName)} + + ))} + + + + + + {/* Default Account */} + + + + + {options.map(([optionVal, optionLabel], index) => ( + + {t(optionLabel)} + + ))} + + + + + + {/* Timezone */} + + + + + {options.map(([optionVal, optionLabel], index) => ( + + {t(optionLabel)} + + ))} + + + + + + {/* Time to Send Notifications */} + + + + + {options.map(([optionVal, optionLabel], index) => ( + + {t(optionLabel)} + + ))} + + + + + + + + {/* Account Name */} + + + + + + + + + {/* Monthly Goal */} + + + + + + + + + {/* Home Country */} + + + + + {options.map(([optionVal, optionLabel], index) => ( + + {t(optionLabel)} + + ))} + + + + + + {/* Default Currency */} + + + + + {options.map(([optionVal, optionLabel], index) => ( + + {t(optionLabel)} + + ))} + + + + + + {/* Early Adopter */} + + + + } + label={t('Early Adopter')} + /> + + + + + {/* MPD Info */} + + + {/* */} + + + + ( + + )} + InputProps={{ + endAdornment: ( + + + + ), + }} + onChange={(): void => undefined} + value={null} + inputFormat={getDateFormatPattern(locale)} + label={t('Start Date')} + /> + + + + + ( + + )} + InputProps={{ + endAdornment: ( + + + + ), + }} + onChange={(): void => undefined} + value={null} + inputFormat={getDateFormatPattern(locale)} + label={t('End Date')} + /> + + + + {/* */} + + + + + + + + ); +}; + +export default Preferences; diff --git a/pages/accountLists/[accountListId]/settings/wrapper.tsx b/pages/accountLists/[accountListId]/settings/wrapper.tsx new file mode 100644 index 000000000..558503ac6 --- /dev/null +++ b/pages/accountLists/[accountListId]/settings/wrapper.tsx @@ -0,0 +1,74 @@ +import { Box, Container } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import React, { useState } from 'react'; +import Head from 'next/head'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; +import { + MultiPageHeader, + HeaderTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; + +const PageContentWrapper = styled(Container)(({ theme }) => ({ + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), +})); + +interface SettingsWrapperProps { + pageTitle: string; + pageHeading: string; + children?: React.ReactNode; +} + +export const SettingsWrapper: React.FC = ({ + pageTitle, + pageHeading, + children, +}) => { + const { appName } = useGetAppSettings(); + const [isNavListOpen, setNavListOpen] = useState(false); + const handleNavListToggle = () => { + setNavListOpen(!isNavListOpen); + }; + + return ( + <> + + + {appName} | {pageTitle} + + + + + } + leftOpen={isNavListOpen} + leftWidth="290px" + mainContent={ + <> + + {children} + + } + /> + + + ); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts new file mode 100644 index 000000000..33d68440d --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler.ts @@ -0,0 +1,3 @@ +export const SendToChalkline = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts new file mode 100644 index 000000000..f60d9e844 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SendToChalklineResolvers: Resolvers = { + Mutation: { + sendToChalkline: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.sendToChalkline(accountListId); + }, + }, +}; + +export { SendToChalklineResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql new file mode 100644 index 000000000..6beaa05d3 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + sendToChalkline(input: SendToChalklineInput!): String! +} + +input SendToChalklineInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql new file mode 100644 index 000000000..a16638ce6 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql @@ -0,0 +1,29 @@ +extend type Mutation { + createGoogleIntegration( + input: CreateGoogleIntegrationInput! + ): GoogleAccountIntegration! +} + +input CreateGoogleIntegrationInput { + googleAccountId: ID! + googleIntegration: GoogleAccountIntegrationInput + accountListID: String! +} + +input GoogleAccountIntegrationInput { + overwrite: Boolean + calendarIntegration: Boolean + calendarId: String + calendarIntegrations: [String] + calendarName: String + calendars: [GoogleAccountIntegrationCalendarsInput] + createdAt: String + updatedAt: String + id: String + updatedInDbAt: String +} + +input GoogleAccountIntegrationCalendarsInput { + id: String + name: String +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..b8d0ff624 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler.ts @@ -0,0 +1,55 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface CreateGoogleIntegrationResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} + +type relationships = { + account_list: object[]; + google_account: object[]; +}; + +export interface CreateGoogleIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} + +interface CreateGoogleIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const CreateGoogleIntegration = ( + data: CreateGoogleIntegrationResponse, +): CreateGoogleIntegrationAttributesCamel => { + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..39c6501bf --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const CreateGoogleIntegrationResolvers: Resolvers = { + Mutation: { + createGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegration, accountListID } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.createGoogleIntegration( + googleAccountId, + googleIntegration, + accountListID, + ); + }, + }, +}; + +export { CreateGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts new file mode 100644 index 000000000..09b7e2acb --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler.ts @@ -0,0 +1,9 @@ +export type DeleteGoogleAccountResponse = { + success: boolean; +}; + +export const DeleteGoogleAccount = (): DeleteGoogleAccountResponse => { + return { + success: true, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql new file mode 100644 index 000000000..48e0e4558 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql @@ -0,0 +1,13 @@ +extend type Mutation { + deleteGoogleAccount( + input: DeleteGoogleAccountInput! + ): GoogleAccountDeletionResponse! +} + +input DeleteGoogleAccountInput { + accountId: ID! +} + +type GoogleAccountDeletionResponse { + success: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts new file mode 100644 index 000000000..2a97f2e41 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeleteGoogleAccountResolvers: Resolvers = { + Mutation: { + deleteGoogleAccount: async ( + _source, + { input: { accountId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deleteGoogleAccount(accountId); + }, + }, +}; + +export { DeleteGoogleAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts new file mode 100644 index 000000000..ef6d46800 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler.ts @@ -0,0 +1,56 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetGoogleAccountIntegrationsResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} +type relationships = { + account_list: object[]; + google_account: object[]; +}; +export interface GetGoogleAccountIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} +interface GetGoogleAccountIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const GetGoogleAccountIntegrations = ( + data: GetGoogleAccountIntegrationsResponse[], +): GetGoogleAccountIntegrationAttributesCamel[] => { + return data.reduce( + (prev: GetGoogleAccountIntegrationAttributesCamel[], current) => { + const attributes = {} as Omit< + GetGoogleAccountIntegrationAttributesCamel, + 'id' + >; + Object.keys(current.attributes).map((key) => { + attributes[snakeToCamel(key)] = current.attributes[key]; + }); + return prev.concat([{ id: current.id, ...attributes }]); + }, + [], + ); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql new file mode 100644 index 000000000..0e25a71c5 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql @@ -0,0 +1,27 @@ +extend type Query { + getGoogleAccountIntegrations( + input: GetGoogleAccountIntegrationsInput! + ): [GoogleAccountIntegration]! +} + +input GetGoogleAccountIntegrationsInput { + googleAccountId: ID! + accountListId: ID! +} + +type GoogleAccountIntegration { + calendarId: String + calendarIntegration: Boolean + calendarIntegrations: [String]! + calendarName: String + calendars: [GoogleAccountIntegrationCalendars]! + createdAt: String! + updatedAt: String! + id: String! + updatedInDbAt: String! +} + +type GoogleAccountIntegrationCalendars { + id: String! + name: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts new file mode 100644 index 000000000..d98d25c5f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers.ts @@ -0,0 +1,18 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetGoogleAccountIntegrationsResolvers: Resolvers = { + Query: { + getGoogleAccountIntegrations: async ( + _source, + { input: { googleAccountId, accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getGoogleAccountIntegrations( + googleAccountId, + accountListId, + ); + }, + }, +}; + +export { GetGoogleAccountIntegrationsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts new file mode 100644 index 000000000..cc34f600b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler.ts @@ -0,0 +1,53 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetGoogleAccountsResponse { + attributes: Omit; + id: string; + relationships: { + contact_groups: { + data: unknown[]; + }; + }; + type: string; +} + +export interface GetGoogleAccountAttributes { + id: string; + created_at: string; + email: string; + expires_at: string; + last_download: string; + last_email_sync: string; + primary: boolean; + remote_id: string; + token_expired: boolean; + updated_at: string; + updated_in_db_at: string; +} + +interface GetGoogleAccountAttributesCamel { + id: string; + createdAt: string; + email: string; + expiresAt: string; + lastDownload: string; + lastEmailSync: string; + primary: boolean; + remoteId: string; + tokenExpired: boolean; + updatedAt: string; + updatedInDbAt: string; +} + +export const GetGoogleAccounts = ( + data: GetGoogleAccountsResponse[], +): GetGoogleAccountAttributesCamel[] => { + return data.reduce((prev: GetGoogleAccountAttributesCamel[], current) => { + const attributes = {} as Omit; + Object.keys(current.attributes).map((key) => { + attributes[snakeToCamel(key)] = current.attributes[key]; + }); + + return prev.concat([{ id: current.id, ...attributes }]); + }, []); +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql new file mode 100644 index 000000000..a1150b926 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql @@ -0,0 +1,17 @@ +extend type Query { + getGoogleAccounts: [GoogleAccountAttributes]! +} + +type GoogleAccountAttributes { + createdAt: String! + email: String! + expiresAt: String! + lastDownload: String + lastEmailSync: String + primary: Boolean! + id: ID! + remoteId: String! + tokenExpired: Boolean! + updatedAt: String! + updatedInDbAt: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts new file mode 100644 index 000000000..6565ff4ff --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers.ts @@ -0,0 +1,11 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetGoogleAccountsResolvers: Resolvers = { + Query: { + getGoogleAccounts: async (_source, {}, { dataSources }) => { + return dataSources.mpdxRestApi.getGoogleAccounts(); + }, + }, +}; + +export { GetGoogleAccountsResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..63df50bb8 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncGoogleIntegration = (data: string): string => { + return data; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..50ad4034c --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncGoogleIntegrationResolvers: Resolvers = { + Mutation: { + syncGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegrationId, integrationName } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncGoogleIntegration( + googleAccountId, + googleIntegrationId, + integrationName, + ); + }, + }, +}; + +export { SyncGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql new file mode 100644 index 000000000..9611004a0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql @@ -0,0 +1,9 @@ +extend type Mutation { + syncGoogleAccount(input: SyncGoogleAccountInput!): String +} + +input SyncGoogleAccountInput { + googleAccountId: ID! + googleIntegrationId: ID! + integrationName: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts new file mode 100644 index 000000000..b6438ae4b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler.ts @@ -0,0 +1,58 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface UpdateGoogleIntegrationResponse { + id: string; + type: string; + attributes: Omit; + relationships: relationships; +} + +type relationships = { + account_list: object[]; + google_account: object[]; +}; + +export interface SaveGoogleIntegrationAttributes { + calendar_id: string; + calendar_integration: boolean; + calendar_integrations: string[]; + calendar_name: string; + calendars: calendars[]; + created_at: string; + updated_at: string; + id: string; + updated_in_db_at: string; +} + +interface GetGoogleAccountIntegrationAttributesCamel { + calendarId: string; + calendarIntegration: boolean; + calendarIntegrations: string[]; + calendarName: string; + calendars: calendars[]; + createdAt: string; + updatedAt: string; + id: string; + updatedInDbAt: string; +} +type calendars = { + id: string; + name: string; +}; + +export const UpdateGoogleIntegration = ( + data: UpdateGoogleIntegrationResponse, +): GetGoogleAccountIntegrationAttributesCamel => { + const attributes = {} as Omit< + GetGoogleAccountIntegrationAttributesCamel, + 'id' + >; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts new file mode 100644 index 000000000..f8e1eedcd --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const UpdateGoogleIntegrationResolvers: Resolvers = { + Mutation: { + updateGoogleIntegration: async ( + _source, + { input: { googleAccountId, googleIntegrationId, googleIntegration } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.updateGoogleIntegration( + googleAccountId, + googleIntegrationId, + googleIntegration, + ); + }, + }, +}; + +export { UpdateGoogleIntegrationResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql new file mode 100644 index 000000000..2fddd2105 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql @@ -0,0 +1,11 @@ +extend type Mutation { + updateGoogleIntegration( + input: UpdateGoogleIntegrationInput! + ): GoogleAccountIntegration! +} + +input UpdateGoogleIntegrationInput { + googleAccountId: ID! + googleIntegrationId: ID! + googleIntegration: GoogleAccountIntegrationInput! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..428e54365 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const DeleteMailchimpAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql new file mode 100644 index 000000000..ea2694f8c --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + deleteMailchimpAccount(input: DeleteMailchimpAccountInput!): String! +} + +input DeleteMailchimpAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..27aa036cf --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeleteMailchimpAccountResolvers: Resolvers = { + Mutation: { + deleteMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deleteMailchimpAccount(accountListId); + }, + }, +}; + +export { DeleteMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..f386f47d3 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler.ts @@ -0,0 +1,64 @@ +import { snakeToCamel } from 'src/lib/snakeToCamel'; + +export interface GetMailchimpAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +export interface GetMailchimpAccount { + id: string; + active: boolean; + auto_log_campaigns: boolean; + created_at: string; + lists_available_for_newsletters: GetMailchimpAccountNewsletters; + lists_link: string; + lists_present: boolean; + primary_list_id: string; + primary_list_name: string; + updated_at: string; + updated_in_db_at; + valid: boolean; + validate_key: boolean; + validation_error: string; +} + +interface GetMailchimpAccountNewsletters { + id: string; + name: string; +} + +interface GetMailchimpAccountCamel { + id: string; + active: boolean; + autoLogCampaigns: boolean; + createdAt: string; + listsAvailableForNewsletters: GetMailchimpAccountNewsletters[]; + listsLink: string; + listsPresent: boolean; + primaryListId: string; + primaryListName: string; + updatedAt: string; + updatedInDbAt: string; + valid: boolean; + validateKey: boolean; + validationError: string; +} + +export const GetMailchimpAccount = ( + data: GetMailchimpAccountResponse | null, +): GetMailchimpAccountCamel[] => { + // Returning inside an array so I can mock an empty response from GraphQL + // without the test thinking I want it to create custom random test data. + if (!data) return []; + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + return [ + { + id: data.id, + ...attributes, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql new file mode 100644 index 000000000..d192dc180 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql @@ -0,0 +1,29 @@ +extend type Query { + getMailchimpAccount(input: MailchimpAccountInput!): [MailchimpAccount] +} + +input MailchimpAccountInput { + accountListId: ID! +} + +type MailchimpAccount { + id: ID! + active: Boolean! + autoLogCampaigns: Boolean! + createdAt: String + listsAvailableForNewsletters: [listsAvailableForNewsletters] + listsLink: String! + listsPresent: Boolean! + primaryListId: ID + primaryListName: String + updatedAt: String! + updatedInDbAt: String! + valid: Boolean! + validateKey: Boolean! + validationError: String +} + +type listsAvailableForNewsletters { + id: ID! + name: String! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..1a0f3447b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetMailchimpAccountResolvers: Resolvers = { + Query: { + getMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getMailchimpAccount(accountListId); + }, + }, +}; + +export { GetMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..5e3491d60 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncMailchimpAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..16d1382aa --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncMailchimpAccountResolvers: Resolvers = { + Mutation: { + syncMailchimpAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncMailchimpAccount(accountListId); + }, + }, +}; + +export { SyncMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql new file mode 100644 index 000000000..f3990a2d4 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + syncMailchimpAccount(input: SyncMailchimpAccountInput!): String +} + +input SyncMailchimpAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts new file mode 100644 index 000000000..b666a9cc7 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler.ts @@ -0,0 +1,59 @@ +import { snakeToCamel } from '../../../../../../../../src//lib/snakeToCamel'; + +export interface UpdateMailchimpAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +export interface UpdateMailchimpAccount { + id: string; + active: boolean; + auto_log_campaigns: boolean; + created_at: string; + lists_available_for_newsletters?: UpdateMailchimpAccountNewsletters[]; + lists_link: string; + lists_present: boolean; + primary_list_id: string; + primary_list_name: string; + updated_at: string; + updated_in_db_at; + valid: boolean; + validate_key: boolean; + validation_error: string; +} + +interface UpdateMailchimpAccountNewsletters { + id: string; + name: string; +} + +interface UpdateMailchimpAccountCamel { + id: string; + active: boolean; + autoLogCampaigns: boolean; + createdAt: string; + listsAvailableForNewsletters?: UpdateMailchimpAccountNewsletters[]; + listsLink: string; + listsPresent: boolean; + primaryListId: string; + primaryListName: string; + updatedAt: string; + updatedInDbAt: string; + valid: boolean; + validateKey: boolean; + validationError: string; +} + +export const UpdateMailchimpAccount = ( + data: UpdateMailchimpAccountResponse, +): UpdateMailchimpAccountCamel => { + const attributes = {} as Omit; + Object.keys(data.attributes).map((key) => { + attributes[snakeToCamel(key)] = data.attributes[key]; + }); + return { + id: data.id, + ...attributes, + }; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts new file mode 100644 index 000000000..b050d9fca --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers.ts @@ -0,0 +1,19 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const UpdateMailchimpAccountResolvers: Resolvers = { + Mutation: { + updateMailchimpAccount: async ( + _source, + { input: { accountListId, mailchimpAccountId, mailchimpAccount } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.updateMailchimpAccount( + accountListId, + mailchimpAccountId, + mailchimpAccount, + ); + }, + }, +}; + +export { UpdateMailchimpAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql new file mode 100644 index 000000000..d34fd36c0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql @@ -0,0 +1,14 @@ +extend type Mutation { + updateMailchimpAccount(input: UpdateMailchimpAccountInput!): MailchimpAccount! +} + +input UpdateMailchimpAccountInput { + accountListId: ID! + mailchimpAccountId: ID! + mailchimpAccount: UpdateMailchimpAccountInputAccount! +} + +input UpdateMailchimpAccountInputAccount { + primaryListId: ID + autoLogCampaigns: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..1c5db913f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const DeletePrayerlettersAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql new file mode 100644 index 000000000..f4837df8f --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + deletePrayerlettersAccount(input: DeletePrayerlettersAccountInput!): String! +} + +input DeletePrayerlettersAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..00c3c7df0 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const DeletePrayerlettersAccountResolvers: Resolvers = { + Mutation: { + deletePrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.deletePrayerlettersAccount(accountListId); + }, + }, +}; + +export { DeletePrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..694d77e2b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler.ts @@ -0,0 +1,32 @@ +export interface GetPrayerlettersAccountResponse { + attributes: Omit; + id: string; + type: string; +} + +interface GetPrayerlettersAccount { + id: string; + created_at: string; + updated_at: string; + updated_in_db_at; + valid_token: boolean; +} + +interface GetPrayerlettersAccountCamel { + id: string; + validToken: boolean; +} + +export const GetPrayerlettersAccount = ( + data: GetPrayerlettersAccountResponse | null, +): GetPrayerlettersAccountCamel[] => { + // Returning inside an array so I can mock an empty response from GraphQL + // without the test thinking I want it to create custom random test data. + if (!data) return []; + return [ + { + id: data.id, + validToken: data.attributes.valid_token, + }, + ]; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql new file mode 100644 index 000000000..3873036ad --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql @@ -0,0 +1,13 @@ +extend type Query { + getPrayerlettersAccount( + input: PrayerlettersAccountInput! + ): [PrayerlettersAccount] +} + +input PrayerlettersAccountInput { + accountListId: ID! +} + +type PrayerlettersAccount { + validToken: Boolean! +} diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..e55ebda3b --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const GetPrayerlettersAccountResolvers: Resolvers = { + Query: { + getPrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.getPrayerlettersAccount(accountListId); + }, + }, +}; + +export { GetPrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts new file mode 100644 index 000000000..8a6d09ec5 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler.ts @@ -0,0 +1,3 @@ +export const SyncPrayerlettersAccount = (): string => { + return 'success'; +}; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts new file mode 100644 index 000000000..5e54f3d67 --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers.ts @@ -0,0 +1,15 @@ +import { Resolvers } from '../../../../../../graphql-rest.page.generated'; + +const SyncPrayerlettersAccountResolvers: Resolvers = { + Mutation: { + syncPrayerlettersAccount: async ( + _source, + { input: { accountListId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.syncPrayerlettersAccount(accountListId); + }, + }, +}; + +export { SyncPrayerlettersAccountResolvers }; diff --git a/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql new file mode 100644 index 000000000..763bde47d --- /dev/null +++ b/pages/api/Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql @@ -0,0 +1,7 @@ +extend type Mutation { + syncPrayerlettersAccount(input: SyncPrayerlettersAccountInput!): String +} + +input SyncPrayerlettersAccountInput { + accountListId: ID! +} diff --git a/pages/api/Schema/SubgraphSchema/Integrations.ts b/pages/api/Schema/SubgraphSchema/Integrations.ts new file mode 100644 index 000000000..e33d1424a --- /dev/null +++ b/pages/api/Schema/SubgraphSchema/Integrations.ts @@ -0,0 +1,112 @@ +// GOOGLE INTEGRATION +// +// Get Accounts +import GetGoogleAccountsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/getGoogleAccounts.graphql'; +import { GetGoogleAccountsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccounts/resolvers'; +// account integrations +import GetGoogleAccountIntegrationsTypeDefs from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/getGoogleAccountIntegrations.graphql'; +import { GetGoogleAccountIntegrationsResolvers } from '../Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/resolvers'; +// create +import CreateGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/createGoogleIntegration.graphql'; +import { CreateGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/createGoogleIntegration/resolvers'; +// update +import UpdateGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/updateGoogleIntegration/updateGoogleIntegration.graphql'; +import { UpdateGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/updateGoogleIntegration/resolvers'; +// sync +import SyncGoogleIntegrationTypeDefs from '../Settings/Preferences/Intergrations/Google/syncGoogleIntegration/syncGoogleIntegration.graphql'; +import { SyncGoogleIntegrationResolvers } from '../Settings/Preferences/Intergrations/Google/syncGoogleIntegration/resolvers'; +// delete +import DeleteGoogleAccountTypeDefs from '../Settings/Preferences/Intergrations/Google/deleteGoogleAccount/deleteGoogleAccount.graphql'; +import { DeleteGoogleAccountResolvers } from '../Settings/Preferences/Intergrations/Google/deleteGoogleAccount/resolvers'; + +// MAILCHIMP INTEGRATION +// +// Get Account +import GetMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/getMailchimpAccount.graphql'; +import { GetMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/resolvers'; +// Update Account +import UpdateMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/updateMailchimpAccount.graphql'; +import { UpdateMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/resolvers'; +// Sync Account +import SyncMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/syncMailchimpAccount.graphql'; +import { SyncMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/resolvers'; +// Delete Account +import DeleteMailchimpAccountTypeDefs from '../Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/deleteMailchimpAccount.graphql'; +import { DeleteMailchimpAccountResolvers } from '../Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/resolvers'; + +// Prayerletters INTEGRATION +// +// Get Account +import GetPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/getPrayerlettersAccount.graphql'; +import { GetPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/resolvers'; +// Sync Account +import SyncPrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/syncPrayerlettersAccount.graphql'; +import { SyncPrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/resolvers'; +// Delete Account +import DeletePrayerlettersAccountTypeDefs from '../Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/deletePrayerlettersAccount.graphql'; +import { DeletePrayerlettersAccountResolvers } from '../Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/resolvers'; + +// Chalkkine INTEGRATION +// +// Get Account +import SendToChalklineTypeDefs from '../Settings/Preferences/Intergrations/Chalkine/sendToChalkline/sendToChalkline.graphql'; +import { SendToChalklineResolvers } from '../Settings/Preferences/Intergrations/Chalkine/sendToChalkline/resolvers'; + +export const integrationSchema = [ + { + typeDefs: GetGoogleAccountsTypeDefs, + resolvers: GetGoogleAccountsResolvers, + }, + { + typeDefs: GetGoogleAccountIntegrationsTypeDefs, + resolvers: GetGoogleAccountIntegrationsResolvers, + }, + { + typeDefs: UpdateGoogleIntegrationTypeDefs, + resolvers: UpdateGoogleIntegrationResolvers, + }, + { + typeDefs: SyncGoogleIntegrationTypeDefs, + resolvers: SyncGoogleIntegrationResolvers, + }, + { + typeDefs: DeleteGoogleAccountTypeDefs, + resolvers: DeleteGoogleAccountResolvers, + }, + { + typeDefs: CreateGoogleIntegrationTypeDefs, + resolvers: CreateGoogleIntegrationResolvers, + }, + { + typeDefs: GetMailchimpAccountTypeDefs, + resolvers: GetMailchimpAccountResolvers, + }, + { + typeDefs: UpdateMailchimpAccountTypeDefs, + resolvers: UpdateMailchimpAccountResolvers, + }, + { + typeDefs: SyncMailchimpAccountTypeDefs, + resolvers: SyncMailchimpAccountResolvers, + }, + { + typeDefs: DeleteMailchimpAccountTypeDefs, + resolvers: DeleteMailchimpAccountResolvers, + }, + { + typeDefs: GetPrayerlettersAccountTypeDefs, + resolvers: GetPrayerlettersAccountResolvers, + }, + { + typeDefs: SyncPrayerlettersAccountTypeDefs, + resolvers: SyncPrayerlettersAccountResolvers, + }, + { + typeDefs: DeletePrayerlettersAccountTypeDefs, + resolvers: DeletePrayerlettersAccountResolvers, + }, + { + typeDefs: SendToChalklineTypeDefs, + resolvers: SendToChalklineResolvers, + }, +]; diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index 2040bbf3e..20f729369 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -45,6 +45,7 @@ import DestroyDonorAccountTypeDefs from './Contacts/DonorAccounts/Destroy/destro import { DestroyDonorAccountResolvers } from './Contacts/DonorAccounts/Destroy/resolvers'; import DeleteTagsTypeDefs from './Tags/Delete/deleteTags.graphql'; import { DeleteTagsResolvers } from './Tags/Delete/resolvers'; +import { integrationSchema } from './SubgraphSchema/Integrations'; const schema = buildSubgraphSchema([ { @@ -127,6 +128,7 @@ const schema = buildSubgraphSchema([ typeDefs: DeleteTagsTypeDefs, resolvers: DeleteTagsResolvers, }, + ...integrationSchema, ]); export default schema; diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 1f8eea078..9502cc9ca 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -1,3 +1,13 @@ +import { DateTime, Duration, Interval } from 'luxon'; +import { + RequestOptions, + Response, + RESTDataSource, +} from 'apollo-datasource-rest'; +import Cors from 'micro-cors'; +import { PageConfig, NextApiRequest } from 'next'; +import { ApolloServer } from 'apollo-server-micro'; +import schema from './Schema'; import { ExportFormatEnum, ExportLabelTypeEnum, @@ -13,7 +23,6 @@ import { CoachingAnswerSet, ContactFilterNotesInput, } from './graphql-rest.page.generated'; -import schema from './Schema'; import { getTaskAnalytics } from './Schema/TaskAnalytics/dataHandler'; import { getCoachingAnswer, @@ -57,15 +66,6 @@ import { getAccountListDonorAccounts } from './Schema/AccountListDonorAccounts/d import { getAccountListCoachUsers } from './Schema/AccountListCoachUser/dataHandler'; import { getAccountListCoaches } from './Schema/AccountListCoaches/dataHandler'; import { getReportsPledgeHistories } from './Schema/reports/pledgeHistories/dataHandler'; -import { DateTime, Duration, Interval } from 'luxon'; -import { - RequestOptions, - Response, - RESTDataSource, -} from 'apollo-datasource-rest'; -import Cors from 'micro-cors'; -import { PageConfig, NextApiRequest } from 'next'; -import { ApolloServer } from 'apollo-server-micro'; import { DonationReponseData, DonationReponseIncluded, @@ -75,9 +75,45 @@ import { DestroyDonorAccount, DestroyDonorAccountResponse, } from './Schema/Contacts/DonorAccounts/Destroy/datahander'; +import { + GetGoogleAccounts, + GetGoogleAccountsResponse, +} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccounts/datahandler'; +import { + GetGoogleAccountIntegrationsResponse, + GetGoogleAccountIntegrations, +} from './Schema/Settings/Preferences/Intergrations/Google/getGoogleAccountIntegrations/datahandler'; +import { SyncGoogleIntegration } from './Schema/Settings/Preferences/Intergrations/Google/syncGoogleIntegration/datahandler'; +import { + UpdateGoogleIntegrationResponse, + UpdateGoogleIntegration, +} from './Schema/Settings/Preferences/Intergrations/Google/updateGoogleIntegration/datahandler'; +import { DeleteGoogleAccount } from './Schema/Settings/Preferences/Intergrations/Google/deleteGoogleAccount/datahandler'; +import { + CreateGoogleIntegrationResponse, + CreateGoogleIntegration, +} from './Schema/Settings/Preferences/Intergrations/Google/createGoogleIntegration/datahandler'; + +import { + GetMailchimpAccountResponse, + GetMailchimpAccount, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/getMailchimpAccount/datahandler'; +import { SyncMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/syncMailchimpAccount/datahandler'; +import { DeleteMailchimpAccount } from './Schema/Settings/Preferences/Intergrations/Mailchimp/deleteMailchimpAccount/datahandler'; +import { + UpdateMailchimpAccount, + UpdateMailchimpAccountResponse, +} from './Schema/Settings/Preferences/Intergrations/Mailchimp/updateMailchimpAccount/datahandler'; +import { + GetPrayerlettersAccountResponse, + GetPrayerlettersAccount, +} from './Schema/Settings/Preferences/Intergrations/Prayerletters/getPrayerlettersAccount/datahandler'; +import { SyncPrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/syncPrayerlettersAccount/datahandler'; +import { DeletePrayerlettersAccount } from './Schema/Settings/Preferences/Intergrations/Prayerletters/deletePrayerlettersAccount/datahandler'; +import { SendToChalkline } from './Schema/Settings/Preferences/Intergrations/Chalkine/sendToChalkline/datahandler'; function camelToSnake(str: string): string { - return str.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()); + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); } class MpdxRestApi extends RESTDataSource { @@ -819,6 +855,206 @@ class MpdxRestApi extends RESTDataSource { ); return data; } + + // Google Integration + // + // + + async getGoogleAccounts() { + const { data }: { data: GetGoogleAccountsResponse[] } = await this.get( + 'user/google_accounts', + { + sort: 'created_at', + include: 'contact_groups', + }, + ); + return GetGoogleAccounts(data); + } + + async getGoogleAccountIntegrations( + googleAccountId: string, + accountListId: string, + ) { + const { data }: { data: GetGoogleAccountIntegrationsResponse[] } = + await this.get( + `user/google_accounts/${googleAccountId}/google_integrations?${encodeURI( + `filter[account_list_id]=${accountListId}`, + )}`, + ); + return GetGoogleAccountIntegrations(data); + } + + async syncGoogleIntegration( + googleAccountId, + googleIntegrationId, + integrationName, + ) { + const { data }: { data: string } = await this.get( + `user/google_accounts/${googleAccountId}/google_integrations/${googleIntegrationId}/sync?integration=${integrationName}`, + ); + return SyncGoogleIntegration(data); + } + + async createGoogleIntegration( + googleAccountId, + googleIntegration, + accountListID, + ) { + const attributes = {}; + Object.keys(googleIntegration).map((key) => { + attributes[camelToSnake(key)] = googleIntegration[key]; + }); + const { data }: { data: CreateGoogleIntegrationResponse } = await this.post( + `user/google_accounts/${googleAccountId}/google_integrations`, + { + data: { + attributes: { + ...attributes, + }, + relationships: { + account_list: { + data: { + type: 'account_lists', + id: accountListID, + }, + }, + }, + type: 'google_integrations', + }, + }, + ); + return CreateGoogleIntegration(data); + } + + async updateGoogleIntegration( + googleAccountId, + googleIntegrationId, + googleIntegration, + ) { + const attributes = {}; + Object.keys(googleIntegration).map((key) => { + attributes[camelToSnake(key)] = googleIntegration[key]; + }); + + const { data }: { data: UpdateGoogleIntegrationResponse } = await this.put( + `user/google_accounts/${googleAccountId}/google_integrations/${googleIntegrationId}`, + { + data: { + attributes: { + ...attributes, + }, + id: googleIntegrationId, + type: 'google_integrations', + }, + }, + ); + return UpdateGoogleIntegration(data); + } + + async deleteGoogleAccount(accountId) { + await this.delete( + `user/google_accounts/${accountId}`, + {}, + { + body: JSON.stringify({ + data: { + type: 'google_accounts', + }, + }), + }, + ); + return DeleteGoogleAccount(); + } + + // Mailchimp Integration + // + // + async getMailchimpAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: GetMailchimpAccountResponse } = await this.get( + `account_lists/${accountListId}/mail_chimp_account`, + ); + return GetMailchimpAccount(data); + } catch { + return GetMailchimpAccount(null); + } + } + + async updateMailchimpAccount( + accountListId, + mailchimpAccountId, + mailchimpAccount, + ) { + const attributes = {}; + Object.keys(mailchimpAccount).map((key) => { + attributes[camelToSnake(key)] = mailchimpAccount[key]; + }); + + const { data }: { data: UpdateMailchimpAccountResponse } = await this.put( + `account_lists/${accountListId}/mail_chimp_account`, + { + data: { + attributes: { + overwrite: true, + ...attributes, + }, + id: mailchimpAccountId, + type: 'mail_chimp_accounts', + }, + }, + ); + return UpdateMailchimpAccount(data); + } + + async syncMailchimpAccount(accountListId) { + await this.get(`account_lists/${accountListId}/mail_chimp_account/sync`); + return SyncMailchimpAccount(); + } + + async deleteMailchimpAccount(accountListId) { + await this.delete(`account_lists/${accountListId}/mail_chimp_account`); + return DeleteMailchimpAccount(); + } + + // Prayerletters Integration + // + // + async getPrayerlettersAccount(accountListId) { + // Catch since it will return an error if no account found + try { + const { data }: { data: GetPrayerlettersAccountResponse } = + await this.get(`account_lists/${accountListId}/prayer_letters_account`); + return GetPrayerlettersAccount(data); + } catch { + return GetPrayerlettersAccount(null); + } + } + + async syncPrayerlettersAccount(accountListId) { + await this.get( + `account_lists/${accountListId}/prayer_letters_account/sync`, + ); + return SyncPrayerlettersAccount(); + } + + async deletePrayerlettersAccount(accountListId) { + await this.delete(`account_lists/${accountListId}/prayer_letters_account`); + return DeletePrayerlettersAccount(); + } + + // Chalkline Integration + // + // + + async sendToChalkline(accountListId) { + await this.post(`account_lists/${accountListId}/chalkline_mail`, { + data: { + type: 'chalkline_mails', + }, + }); + return SendToChalkline(); + } } export interface Context { 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..eb5acaa69 --- /dev/null +++ b/pages/api/uploads/tnt-data-sync.page.ts @@ -0,0 +1,101 @@ +import { readFile } from 'fs/promises'; +import fetch, { File, FormData } from 'node-fetch'; +import formidable, { IncomingForm } from 'formidable'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getToken } from 'next-auth/jwt'; + +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/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/src/components/Contacts/ContactDetails/ContactDetailContext.tsx b/src/components/Contacts/ContactDetails/ContactDetailContext.tsx index 6e5a05455..d8c3bb452 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailContext.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailContext.tsx @@ -20,10 +20,6 @@ export type ContactDetailsType = { setEditingAddressId: React.Dispatch>; addAddressModalOpen: boolean; setAddAddressModalOpen: React.Dispatch>; - personEditShowMore: boolean; - setPersonEditShowMore: React.Dispatch>; - removeDialogOpen: boolean; - handleRemoveDialogOpen: React.Dispatch>; editPersonModalOpen: string | undefined; setEditPersonModalOpen: React.Dispatch< React.SetStateAction @@ -72,9 +68,6 @@ export const ContactDetailProvider: React.FC = ({ children }) => { setSelectedTabKey(newKey); }; - const [personEditShowMore, setPersonEditShowMore] = useState(false); - const [removeDialogOpen, handleRemoveDialogOpen] = useState(false); - const [editPersonModalOpen, setEditPersonModalOpen] = useState(); const [createPersonModalOpen, setCreatePersonModalOpen] = useState(false); @@ -105,10 +98,6 @@ export const ContactDetailProvider: React.FC = ({ children }) => { selectedTabKey: selectedTabKey, setSelectedTabKey: setSelectedTabKey, handleTabChange: handleTabChange, - personEditShowMore: personEditShowMore, - setPersonEditShowMore: setPersonEditShowMore, - removeDialogOpen: removeDialogOpen, - handleRemoveDialogOpen: handleRemoveDialogOpen, editPersonModalOpen: editPersonModalOpen, setEditPersonModalOpen: setEditPersonModalOpen, createPersonModalOpen: createPersonModalOpen, diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonEmail/PersonEmail.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonEmail/PersonEmail.tsx index 12d88e46b..8ca121092 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonEmail/PersonEmail.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonEmail/PersonEmail.tsx @@ -41,6 +41,7 @@ const OptOutENewsletterLabel = styled(FormControlLabel)(() => ({ })); interface PersonEmailProps { + showOptOutENewsletter?: boolean; formikProps: FormikProps<(PersonUpdateInput | PersonCreateInput) & NewSocial>; sources: | { @@ -51,6 +52,7 @@ interface PersonEmailProps { } export const PersonEmail: React.FC = ({ + showOptOutENewsletter = false, formikProps, sources, }) => { @@ -130,18 +132,23 @@ export const PersonEmail: React.FC = ({ - - setFieldValue('optoutEnewsletter', !optoutEnewsletter) - } - /> - } - label={t('Opt-out of Email Newsletter')} - /> + {showOptOutENewsletter && ( + + setFieldValue( + 'optoutEnewsletter', + !optoutEnewsletter, + ) + } + /> + } + label={t('Opt-out of Email Newsletter')} + /> + )} diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.tsx index f46142f04..e6cee7f59 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal.tsx @@ -13,7 +13,6 @@ import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { useApolloClient } from '@apollo/client'; import { Formik } from 'formik'; -import * as yup from 'yup'; import { useSnackbar } from 'notistack'; import _ from 'lodash'; import { @@ -36,23 +35,18 @@ import { useDeletePersonMutation, useUpdatePersonMutation, } from './PersonModal.generated'; -import { - ContactDetailContext, - ContactDetailsType, -} from 'src/components/Contacts/ContactDetails/ContactDetailContext'; import { SubmitButton, CancelButton, DeleteButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; import { uploadAvatar, validateAvatar } from './uploadAvatar'; +import { getPersonSchema, formatSubmittedFields } from './personModalHelper'; +import { profile2 } from 'src/components/Settings/preferences/DemoContent'; export const ContactInputField = styled(TextField, { shouldForwardProp: (prop) => prop !== 'destroyed', })(({ destroyed }: { destroyed: boolean }) => ({ - // '&& > label': { - // textTransform: 'uppercase', - // }, textDecoration: destroyed ? 'line-through' : 'none', })); @@ -94,6 +88,7 @@ interface PersonModalProps { contactId: string; accountListId: string; handleClose: () => void; + userProfile?: boolean; } export interface NewSocial { @@ -109,18 +104,20 @@ export const PersonModal: React.FC = ({ contactId, accountListId, handleClose, + userProfile = false, }) => { const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); - const { - personEditShowMore, - setPersonEditShowMore, - removeDialogOpen, - handleRemoveDialogOpen, - } = React.useContext(ContactDetailContext) as ContactDetailsType; + const [personEditShowMore, setPersonEditShowMore] = useState(false); + const [removeDialogOpen, handleRemoveDialogOpen] = useState(false); const client = useApolloClient(); + // TODO + if (userProfile) + person = + profile2 as ContactDetailsTabQuery['contact']['people']['nodes'][0]; + const [avatar, setAvatar] = useState<{ file: File; blobUrl: string } | null>( null, ); @@ -150,85 +147,10 @@ export const PersonModal: React.FC = ({ const [updatePerson] = useUpdatePersonMutation(); const [createPerson] = useCreatePersonMutation(); const [deletePerson, { loading: deleting }] = useDeletePersonMutation(); + // TODO + // const [updateUserProfile] = useCreatePersonMutation(); - const personSchema: yup.SchemaOf< - Omit - > = yup.object({ - firstName: yup.string().required(), - lastName: yup.string().nullable(), - title: yup.string().nullable(), - suffix: yup.string().nullable(), - phoneNumbers: yup.array().of( - yup.object({ - id: yup.string().nullable(), - number: yup.string().required(t('This field is required')), - destroy: yup.boolean().default(false), - primary: yup.boolean().default(false), - historic: yup.boolean().default(false), - }), - ), - emailAddresses: yup.array().of( - yup.object({ - id: yup.string().nullable(), - email: yup - .string() - .email(t('Invalid email address')) - .required(t('This field is required')), - destroy: yup.boolean().default(false), - primary: yup.boolean().default(false), - historic: yup.boolean().default(false), - }), - ), - facebookAccounts: yup.array().of( - yup.object({ - id: yup.string().nullable(), - destroy: yup.boolean().default(false), - username: yup.string().required(), - }), - ), - linkedinAccounts: yup.array().of( - yup.object({ - id: yup.string().nullable(), - destroy: yup.boolean().default(false), - publicUrl: yup.string().required(), - }), - ), - twitterAccounts: yup.array().of( - yup.object({ - id: yup.string().nullable(), - destroy: yup.boolean().default(false), - screenName: yup.string().required(), - }), - ), - websites: yup.array().of( - yup.object({ - id: yup.string().nullable(), - destroy: yup.boolean().default(false), - url: yup.string().required(), - }), - ), - newSocials: yup.array().of( - yup.object({ - value: yup.string().required(), - type: yup.string().required(), - }), - ), - optoutEnewsletter: yup.boolean().default(false), - birthdayDay: yup.number().nullable(), - birthdayMonth: yup.number().nullable(), - birthdayYear: yup.number().nullable(), - maritalStatus: yup.string().nullable(), - gender: yup.string().nullable(), - anniversaryDay: yup.number().nullable(), - anniversaryMonth: yup.number().nullable(), - anniversaryYear: yup.number().nullable(), - almaMater: yup.string().nullable(), - employer: yup.string().nullable(), - occupation: yup.string().nullable(), - legalFirstName: yup.string().nullable(), - deceased: yup.boolean().default(false), - defaultAccountList: yup.string().nullable(), - }); + const { personSchema, initialPerson } = getPersonSchema(t, contactId, person); const personPhoneNumberSources = person?.phoneNumbers.nodes.map( (phoneNumber) => { @@ -248,153 +170,10 @@ export const PersonModal: React.FC = ({ }, ); - const personPhoneNumbers = person?.phoneNumbers.nodes.map((phoneNumber) => { - return { - id: phoneNumber.id, - primary: phoneNumber.primary, - number: phoneNumber.number, - historic: phoneNumber.historic, - location: phoneNumber.location, - destroy: false, - }; - }); - - const personEmails = person?.emailAddresses.nodes.map((emailAddress) => { - return { - id: emailAddress.id, - primary: emailAddress.primary, - email: emailAddress.email, - historic: emailAddress.historic, - location: emailAddress.location, - destroy: false, - }; - }); - - const personFacebookAccounts = person?.facebookAccounts.nodes.map( - (account) => ({ - id: account.id, - username: account.username, - destroy: false, - }), - ); - - const personTwitterAccounts = person?.twitterAccounts.nodes.map( - (account) => ({ - id: account.id, - screenName: account.screenName, - destroy: false, - }), - ); - - const personLinkedinAccounts = person?.linkedinAccounts.nodes.map( - (account) => ({ - id: account.id, - publicUrl: account.publicUrl, - destroy: false, - }), - ); - - const personWebsites = person?.websites.nodes.map((account) => ({ - id: account.id, - url: account.url, - destroy: false, - })); - - const initialPerson: (PersonCreateInput | PersonUpdateInput) & NewSocial = - person - ? { - id: person.id, - firstName: person.firstName, - lastName: person.lastName, - title: person.title, - suffix: person.suffix, - phoneNumbers: personPhoneNumbers, - emailAddresses: personEmails, - optoutEnewsletter: person.optoutEnewsletter, - birthdayDay: person.birthdayDay, - birthdayMonth: person.birthdayMonth, - birthdayYear: person.birthdayYear, - maritalStatus: person.maritalStatus, - gender: person.gender, - anniversaryDay: person.anniversaryDay, - anniversaryMonth: person.anniversaryMonth, - anniversaryYear: person.anniversaryYear, - almaMater: person.almaMater, - employer: person.employer, - occupation: person.occupation, - facebookAccounts: personFacebookAccounts, - twitterAccounts: personTwitterAccounts, - linkedinAccounts: personLinkedinAccounts, - websites: personWebsites, - legalFirstName: person.legalFirstName, - deceased: person.deceased, - newSocials: [], - } - : { - contactId, - id: null, - firstName: '', - lastName: null, - title: null, - suffix: null, - phoneNumbers: [], - emailAddresses: [], - optoutEnewsletter: false, - birthdayDay: null, - birthdayMonth: null, - birthdayYear: null, - maritalStatus: null, - gender: 'Male', - anniversaryDay: null, - anniversaryMonth: null, - anniversaryYear: null, - almaMater: null, - employer: null, - occupation: null, - facebookAccounts: [], - twitterAccounts: [], - linkedinAccounts: [], - websites: [], - legalFirstName: null, - deceased: false, - newSocials: [], - }; - const onSubmit = async ( fields: (PersonCreateInput | PersonUpdateInput) & NewSocial, ): Promise => { - const { newSocials, ...existingSocials } = fields; - const attributes: PersonCreateInput | PersonUpdateInput = { - ...existingSocials, - facebookAccounts: fields.facebookAccounts?.concat( - newSocials - .filter((social) => social.type === 'facebook' && !social.destroy) - .map((social) => ({ - username: social.value, - })), - ), - twitterAccounts: fields.twitterAccounts?.concat( - newSocials - .filter((social) => social.type === 'twitter' && !social.destroy) - .map((social) => ({ - screenName: social.value, - })), - ), - linkedinAccounts: fields.linkedinAccounts?.concat( - newSocials - .filter((social) => social.type === 'linkedin' && !social.destroy) - .map((social) => ({ - publicUrl: social.value, - })), - ), - websites: fields.websites?.concat( - newSocials - .filter((social) => social.type === 'website' && !social.destroy) - .map((social) => ({ - url: social.value, - })), - ), - }; + const attributes = formatSubmittedFields(fields); const isUpdate = ( attributes: PersonCreateInput | PersonUpdateInput, @@ -502,7 +281,13 @@ export const PersonModal: React.FC = ({ return ( @@ -535,6 +320,7 @@ export const PersonModal: React.FC = ({ {/* Birthday Section */} @@ -568,7 +354,7 @@ export const PersonModal: React.FC = ({ - {person && ( + {person && !userProfile && ( handleRemoveDialogOpen(true)} /> )} diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx index ed0d1e6f4..8a4b5f96d 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonShowMore/PersonShowMore.tsx @@ -34,10 +34,12 @@ const DeceasedLabel = styled(FormControlLabel)(() => ({ interface PersonShowMoreProps { formikProps: FormikProps<(PersonUpdateInput | PersonCreateInput) & NewSocial>; + showDeceased?: boolean; } export const PersonShowMore: React.FC = ({ formikProps, + showDeceased = true, }) => { const { t } = useTranslation(); const locale = useLocale(); @@ -198,22 +200,24 @@ export const PersonShowMore: React.FC = ({ fullWidth /> - - - - setFieldValue('deceased', !deceased)} - color="secondary" - /> - } - label={t('Deceased')} - /> + {showDeceased && ( + + + + setFieldValue('deceased', !deceased)} + color="secondary" + /> + } + label={t('Deceased')} + /> + - - + + )} ); }; diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx new file mode 100644 index 000000000..040200d9b --- /dev/null +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/personModalHelper.tsx @@ -0,0 +1,253 @@ +import * as yup from 'yup'; +import { + PersonUpdateInput, + PersonCreateInput, +} from '../../../../../../../../graphql/types.generated'; +import { TFunction } from 'react-i18next'; +import { ContactDetailsTabQuery } from '../../../ContactDetailsTab.generated'; +import { NewSocial } from './PersonModal'; + +interface getPersonSchemaReturnedValues { + personSchema: yup.SchemaOf< + Omit + >; + initialPerson: (PersonCreateInput | PersonUpdateInput) & NewSocial; +} + +export const getPersonSchema = ( + t: TFunction, + contactId: string, + person?: ContactDetailsTabQuery['contact']['people']['nodes'][0], +): getPersonSchemaReturnedValues => { + const personSchema = yup.object({ + firstName: yup.string().required(), + lastName: yup.string().nullable(), + title: yup.string().nullable(), + suffix: yup.string().nullable(), + phoneNumbers: yup.array().of( + yup.object({ + id: yup.string().nullable(), + number: yup.string().required(t('This field is required')), + destroy: yup.boolean().default(false), + primary: yup.boolean().default(false), + historic: yup.boolean().default(false), + }), + ), + emailAddresses: yup.array().of( + yup.object({ + id: yup.string().nullable(), + email: yup + .string() + .email(t('Invalid email address')) + .required(t('This field is required')), + destroy: yup.boolean().default(false), + primary: yup.boolean().default(false), + historic: yup.boolean().default(false), + }), + ), + facebookAccounts: yup.array().of( + yup.object({ + id: yup.string().nullable(), + destroy: yup.boolean().default(false), + username: yup.string().required(), + }), + ), + linkedinAccounts: yup.array().of( + yup.object({ + id: yup.string().nullable(), + destroy: yup.boolean().default(false), + publicUrl: yup.string().required(), + }), + ), + twitterAccounts: yup.array().of( + yup.object({ + id: yup.string().nullable(), + destroy: yup.boolean().default(false), + screenName: yup.string().required(), + }), + ), + websites: yup.array().of( + yup.object({ + id: yup.string().nullable(), + destroy: yup.boolean().default(false), + url: yup.string().required(), + }), + ), + newSocials: yup.array().of( + yup.object({ + value: yup.string().required(), + type: yup.string().required(), + }), + ), + optoutEnewsletter: yup.boolean().default(false), + birthdayDay: yup.number().nullable(), + birthdayMonth: yup.number().nullable(), + birthdayYear: yup.number().nullable(), + maritalStatus: yup.string().nullable(), + gender: yup.string().nullable(), + anniversaryDay: yup.number().nullable(), + anniversaryMonth: yup.number().nullable(), + anniversaryYear: yup.number().nullable(), + almaMater: yup.string().nullable(), + employer: yup.string().nullable(), + occupation: yup.string().nullable(), + legalFirstName: yup.string().nullable(), + deceased: yup.boolean().default(false), + defaultAccountList: yup.string().nullable(), + }); + + const personPhoneNumbers = person?.phoneNumbers.nodes.map((phoneNumber) => { + return { + id: phoneNumber.id, + primary: phoneNumber.primary, + number: phoneNumber.number, + historic: phoneNumber.historic, + location: phoneNumber.location, + destroy: false, + }; + }); + + const personEmails = person?.emailAddresses.nodes.map((emailAddress) => { + return { + id: emailAddress.id, + primary: emailAddress.primary, + email: emailAddress.email, + historic: emailAddress.historic, + location: emailAddress.location, + destroy: false, + }; + }); + + const personFacebookAccounts = person?.facebookAccounts.nodes.map( + (account) => ({ + id: account.id, + username: account.username, + destroy: false, + }), + ); + + const personTwitterAccounts = person?.twitterAccounts.nodes.map( + (account) => ({ + id: account.id, + screenName: account.screenName, + destroy: false, + }), + ); + + const personLinkedinAccounts = person?.linkedinAccounts.nodes.map( + (account) => ({ + id: account.id, + publicUrl: account.publicUrl, + destroy: false, + }), + ); + + const personWebsites = person?.websites.nodes.map((account) => ({ + id: account.id, + url: account.url, + destroy: false, + })); + + const initialPerson: (PersonCreateInput | PersonUpdateInput) & NewSocial = + person + ? { + id: person.id, + firstName: person.firstName, + lastName: person.lastName, + title: person.title, + suffix: person.suffix, + phoneNumbers: personPhoneNumbers, + emailAddresses: personEmails, + optoutEnewsletter: person.optoutEnewsletter, + birthdayDay: person.birthdayDay, + birthdayMonth: person.birthdayMonth, + birthdayYear: person.birthdayYear, + maritalStatus: person.maritalStatus, + gender: person.gender, + anniversaryDay: person.anniversaryDay, + anniversaryMonth: person.anniversaryMonth, + anniversaryYear: person.anniversaryYear, + almaMater: person.almaMater, + employer: person.employer, + occupation: person.occupation, + facebookAccounts: personFacebookAccounts, + twitterAccounts: personTwitterAccounts, + linkedinAccounts: personLinkedinAccounts, + websites: personWebsites, + legalFirstName: person.legalFirstName, + deceased: person.deceased, + newSocials: [], + } + : { + contactId, + id: null, + firstName: '', + lastName: null, + title: null, + suffix: null, + phoneNumbers: [], + emailAddresses: [], + optoutEnewsletter: false, + birthdayDay: null, + birthdayMonth: null, + birthdayYear: null, + maritalStatus: null, + gender: 'Male', + anniversaryDay: null, + anniversaryMonth: null, + anniversaryYear: null, + almaMater: null, + employer: null, + occupation: null, + facebookAccounts: [], + twitterAccounts: [], + linkedinAccounts: [], + websites: [], + legalFirstName: null, + deceased: false, + newSocials: [], + }; + + return { + personSchema, + initialPerson, + }; +}; + +export const formatSubmittedFields = ( + fields: (PersonCreateInput | PersonUpdateInput) & NewSocial, +): PersonCreateInput | PersonUpdateInput => { + const { newSocials, ...existingFields } = fields; + + return { + ...existingFields, + facebookAccounts: fields.facebookAccounts?.concat( + newSocials + .filter((social) => social.type === 'facebook' && !social.destroy) + .map((social) => ({ + username: social.value, + })), + ), + twitterAccounts: fields.twitterAccounts?.concat( + newSocials + .filter((social) => social.type === 'twitter' && !social.destroy) + .map((social) => ({ + screenName: social.value, + })), + ), + linkedinAccounts: fields.linkedinAccounts?.concat( + newSocials + .filter((social) => social.type === 'linkedin' && !social.destroy) + .map((social) => ({ + publicUrl: social.value, + })), + ), + websites: fields.websites?.concat( + newSocials + .filter((social) => social.type === 'website' && !social.destroy) + .map((social) => ({ + url: social.value, + })), + ), + }; +}; diff --git a/src/components/Layouts/Primary/NavBar/NavBar.tsx b/src/components/Layouts/Primary/NavBar/NavBar.tsx index f75c2b2fd..ef02d02df 100644 --- a/src/components/Layouts/Primary/NavBar/NavBar.tsx +++ b/src/components/Layouts/Primary/NavBar/NavBar.tsx @@ -5,10 +5,8 @@ import { makeStyles } from 'tss-react/mui'; import { useRouter } from 'next/router'; import NextLink, { LinkProps } from 'next/link'; import { useTranslation } from 'react-i18next'; -import { - filteredReportNavItems, - toolsRedirectLinks, -} from '../TopBar/Items/NavMenu/NavMenu'; +import { toolsRedirectLinks } from '../TopBar/Items/NavMenu/NavMenu'; +import { ReportNavItems } from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems'; import { NavItem } from './NavItem/NavItem'; import { NavTools } from './NavTools/NavTools'; import { ToolsList } from 'src/components/Tool/Home/ToolList'; @@ -133,7 +131,7 @@ export const NavBar: FC = ({ onMobileClose, openMobile }) => { }, { title: t('Reports'), - items: filteredReportNavItems.map((item) => ({ + items: ReportNavItems.map((item) => ({ ...item, title: item.title, href: `/accountLists/${accountListId}/reports/${item.id}`, diff --git a/src/components/Layouts/Primary/TopBar/GetTopBar.graphql b/src/components/Layouts/Primary/TopBar/GetTopBar.graphql index a819c6fba..bc51bece7 100644 --- a/src/components/Layouts/Primary/TopBar/GetTopBar.graphql +++ b/src/components/Layouts/Primary/TopBar/GetTopBar.graphql @@ -12,6 +12,7 @@ query GetTopBar { lastName admin developer + defaultAccountList keyAccounts { id email diff --git a/src/components/Layouts/Primary/TopBar/Items/NavMenu/NavMenu.tsx b/src/components/Layouts/Primary/TopBar/Items/NavMenu/NavMenu.tsx index 427877fd4..58ffced1d 100644 --- a/src/components/Layouts/Primary/TopBar/Items/NavMenu/NavMenu.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/NavMenu/NavMenu.tsx @@ -18,7 +18,7 @@ import NextLink from 'next/link'; import { useTranslation } from 'react-i18next'; import Icon from '@mdi/react'; import { useRouter } from 'next/router'; -import { ReportNavItems } from '../../../../../Reports/NavReportsList/ReportNavItems'; +import { ReportNavItems } from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems'; import { ToolsList } from '../../../../../Tool/Home/ToolList'; import { useCurrentToolId } from '../../../../../../hooks/useCurrentToolId'; import theme from '../../../../../../theme'; @@ -27,8 +27,6 @@ import { useGetToolNotificationsQuery } from './GetToolNotifcations.generated'; import HandoffLink from 'src/components/HandoffLink'; import { ReportLink } from './ReportLink'; -export const filteredReportNavItems = ReportNavItems; - const useStyles = makeStyles()(() => ({ navListItem: { order: 2, @@ -252,7 +250,7 @@ const NavMenu: React.FC = () => { - {filteredReportNavItems.map(({ id, title }) => ( + {ReportNavItems.map(({ id, title }) => ( { ? !!(accountListId && data.accountLists.nodes.length > 1) : false; + let accountListIdFallback = accountListId; + if (!accountListIdFallback) { + if (data?.accountLists?.nodes.length === 1) { + accountListIdFallback = data.accountLists.nodes[0]?.id; + } else if (data?.user.defaultAccountList) { + accountListIdFallback = data.user.defaultAccountList; + } + } + return ( <> { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchMenu.tsx b/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchMenu.tsx index 7b4cd6c2d..d5a9b8eea 100644 --- a/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchMenu.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchMenu.tsx @@ -129,7 +129,7 @@ const SearchMenu = (): ReactElement => { { name: t('Preferences - Connect Services'), icon: , - link: `/accountLists/${accountListId}/preferences/connectServices`, + link: `/accountLists/${accountListId}/preferences/integrations`, }, { name: t('Reports - Donations'), diff --git a/src/components/Reports/AccountsListLayout/Header/Header.tsx b/src/components/Reports/AccountsListLayout/Header/Header.tsx deleted file mode 100644 index 89aa35ca5..000000000 --- a/src/components/Reports/AccountsListLayout/Header/Header.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { FC, ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Box, IconButton, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import theme from 'src/theme'; -import FilterList from '@mui/icons-material/FilterList'; - -interface AccountsListHeaderProps { - showNavListButton?: boolean; - isNavListOpen: boolean; - onNavListToggle: () => void; - title: string; - rightExtra?: ReactNode; -} - -const StickyHeader = styled(Box)(({}) => ({ - position: 'sticky', - top: 0, -})); - -const NavListButton = styled(IconButton, { - shouldForwardProp: (prop) => prop !== 'panelOpen', -})(({ panelOpen }: { panelOpen: boolean }) => ({ - display: 'inline-block', - width: 48, - height: 48, - borderradius: 24, - margin: theme.spacing(1), - backgroundColor: panelOpen ? theme.palette.secondary.dark : 'transparent', -})); - -const NavListIcon = styled(FilterList)(({ theme }) => ({ - width: 24, - height: 24, - color: theme.palette.primary.dark, -})); - -export const AccountsListHeader: FC = ({ - title, - rightExtra, - isNavListOpen, - onNavListToggle, - showNavListButton = true, -}) => { - const { t } = useTranslation(); - - return ( - - - {showNavListButton && ( - - - - )} - - {title} - - {rightExtra} - - - ); -}; diff --git a/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.tsx b/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.tsx index b0ebaaaae..43efe182c 100644 --- a/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.tsx +++ b/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.tsx @@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next'; import { Box, CircularProgress, Divider, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { AccountsList as List } from '../AccountsListLayout/List/List'; -import { AccountsListHeader as Header } from '../AccountsListLayout/Header/Header'; +import { + MultiPageHeader, + HeaderTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; import type { Account } from '../AccountsListLayout/List/ListItem/ListItem'; import { useDesignationAccountsQuery } from './GetDesignationAccounts.generated'; import { useSetActiveDesignationAccountMutation } from './SetActiveDesignationAccount.generated'; @@ -85,11 +88,12 @@ export const DesignationAccountsReport: React.FC = ({ return ( -
{loading ? ( = ({ return ( -
= ({ return ( -
{ expect(queryByText(title)).toBeInTheDocument(); expect(getByTestId('FourteenMonthReport')).toBeInTheDocument(); - expect(queryByTestId('ReportNavList')).toBeNull(); + expect(queryByTestId('MultiPageMenu')).toBeNull(); }); it('filters report by designation account', async () => { diff --git a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx index 1f40037ad..15ae8206f 100644 --- a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx +++ b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx @@ -279,7 +279,7 @@ describe('PartnerGivingAnalysisReport', () => { expect(queryByText(title)).toBeInTheDocument(); expect(getByTestId('PartnerGivingAnalysisReport')).toBeInTheDocument(); - expect(queryByTestId('ReportNavList')).toBeNull(); + expect(queryByTestId('MultiPageMenu')).toBeNull(); }); it('shows a placeholder when there are zero contacts', async () => { diff --git a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx index e9fa403f5..30dfceaeb 100644 --- a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx +++ b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx @@ -2,6 +2,10 @@ import React, { useMemo, useState } from 'react'; import { Box, CircularProgress, TablePagination } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useDebouncedValue } from 'src/hooks/useDebounce'; +import { + MultiPageHeader, + HeaderTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; import { useMassSelection } from 'src/hooks/useMassSelection'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; import { useGetPartnerGivingAnalysisIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; @@ -13,7 +17,6 @@ import { import type { Order } from '../Reports.type'; import { useGetPartnerGivingAnalysisReportQuery } from './PartnerGivingAnalysisReport.generated'; import { PartnerGivingAnalysisReportTable as Table } from './Table/Table'; -import { AccountsListHeader as Header } from '../AccountsListLayout/Header/Header'; import { EmptyReport } from 'src/components/Reports/EmptyReport/EmptyReport'; import { ListHeader } from 'src/components/Shared/Header/ListHeader'; @@ -130,11 +133,11 @@ export const PartnerGivingAnalysisReport: React.FC = ({ return ( -
= ({ return ( -
{loading ? ( diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx new file mode 100644 index 000000000..b19f7454d --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.test.tsx @@ -0,0 +1,119 @@ +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.page'; +import { ChalklineAccordian } from './ChalklineAccordian'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Chalk Line')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /Chalk Line/i, + }); + expect(image).toBeInTheDocument(); + }); + + it('should send contacts to Chalkline', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + await waitFor(() => { + expect(getByText('Chalkline Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Send my current Contacts to Chalkline')); + await waitFor(() => { + expect(getByText('Confirm')).toBeInTheDocument(); + }); + userEvent.click(getByText('Yes')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully Emailed Chalkine', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'SendToChalkline', + ); + expect(mutationSpy.mock.calls[0][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx new file mode 100644 index 000000000..5d9fd1843 --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/ChalklineAccordian.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@mui/material'; +import { StyledFormLabel } from 'src/components/Shared/Forms/Field'; +import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { useSendToChalklineMutation } from './SendToChalkline.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useSnackbar } from 'notistack'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; + +export const ChalklineAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const accordianName = t('Chalk Line'); + const [showModal, setShowModal] = useState(false); + const accountListId = useAccountListId(); + const [sendToChalkline] = useSendToChalklineMutation(); + const { enqueueSnackbar } = useSnackbar(); + const handleOpenModal = () => setShowModal(true); + + const handleCloseModal = () => { + setShowModal(false); + }; + + const handleSendListToChalkLine = async () => { + await sendToChalkline({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + onCompleted: () => { + enqueueSnackbar(t('Successfully Emailed Chalkine'), { + variant: 'success', + }); + enqueueSnackbar(t('Redirecting you to Chalkine.'), { + variant: 'success', + }); + setTimeout(() => { + window.open('https://chalkline.org/order_mpdx/', '_blank'); + }, 1000); + }, + }); + }; + + return ( + + } + > + {t('Chalkline Overview')} + + {t(`Chalkline is a significant way to save valuable ministry time while more effectively + connecting with your partners. Send physical newsletters to your current list using + Chalkline with a simple click. Chalkline is a one way send available anytime you’re + ready to send a new newsletter out.`)} + + + {t('Send my current Contacts to Chalkline')} + + + + + ); +}; diff --git a/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql b/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql new file mode 100644 index 000000000..24dbb52f9 --- /dev/null +++ b/src/components/Settings/integrations/Chalkline/SendToChalkline.graphql @@ -0,0 +1,3 @@ +mutation SendToChalkline($input: SendToChalklineInput!) { + sendToChalkline(input: $input) +} diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx new file mode 100644 index 000000000..5c226dd86 --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordian.test.tsx @@ -0,0 +1,237 @@ +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.page'; +import { GoogleAccordian } from './GoogleAccordian'; +import { GoogleAccountsQuery } from './googleAccounts.generated'; +import { getSession } from 'next-auth/react'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardGoogleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +describe('GoogleAccordian', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Google')).toBeInTheDocument(); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const Image = queryByRole('img', { + name: /google/i, + }); + expect(Image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render Mailchimp Overview', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(/google integration overview/i)).toBeInTheDocument(); + }); + userEvent.click(getByText(/add account/i)); + + expect(getByText(/add account/i)).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let googleAccount = { ...standardGoogleAccount }; + + beforeEach(() => { + googleAccount = { ...standardGoogleAccount }; + }); + it('shows one connected account', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByTestId } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [googleAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(standardGoogleAccount.email)).toBeInTheDocument(); + }); + + userEvent.click(getByText(/import contacts/i)); + expect(getByText(/import contacts/i)).toHaveAttribute( + 'href', + `https://stage.mpdx.org/tools/import/google`, + ); + + userEvent.click(getByTestId('EditIcon')); + await waitFor(() => + expect(getByText(/edit google integration/i)).toBeInTheDocument(), + ); + userEvent.click(getByTestId('CloseIcon')); + await waitFor(() => + expect(queryByText(/edit google integration/i)).not.toBeInTheDocument(), + ); + + userEvent.click(getByTestId('DeleteIcon')); + await waitFor(() => + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(), + ); + userEvent.click(getByTestId('CloseIcon')); + await waitFor(() => + expect( + queryByText(/confirm to disconnect google account/i), + ).not.toBeInTheDocument(), + ); + }); + + it('shows account with expired token', async () => { + const mutationSpy = jest.fn(); + googleAccount.tokenExpired = true; + const { getByText, getAllByText } = render( + Components( + + mocks={{ + GoogleAccounts: { + getGoogleAccounts: [googleAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText(standardGoogleAccount.email)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText(/click "refresh google account/i)).toBeInTheDocument(); + expect(getAllByText(/refresh google account/i)[1]).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/google?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3DGoogle&access_token=apiToken`, + ); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/GoogleAccordian.tsx b/src/components/Settings/integrations/Google/GoogleAccordian.tsx new file mode 100644 index 000000000..d286f0e93 --- /dev/null +++ b/src/components/Settings/integrations/Google/GoogleAccordian.tsx @@ -0,0 +1,225 @@ +import { useState, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Box, Card, IconButton, Typography } from '@mui/material'; +import Skeleton from '@mui/material/Skeleton'; +import { styled } from '@mui/material/styles'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { StyledFormLabel } from 'src/components/Shared/Forms/Field'; +import { useGoogleAccountsQuery } from './googleAccounts.generated'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import theme from 'src/theme'; +import { GoogleAccountAttributes } from '../../../../../graphql/types.generated'; +import { EditGoogleAccountModal } from './Modals/EditGoogleAccountModal'; +import { DeleteGoogleAccountModal } from './Modals/DeleteGoogleAccountModal'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations.page'; +import HandoffLink from 'src/components/HandoffLink'; +import { + StyledListItem, + StyledList, + StyledServicesButton, +} from '../integrationsHelper'; + +interface GoogleAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const EditIconButton = styled(IconButton)(() => ({ + color: theme.palette.primary.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); +const DeleteIconButton = styled(IconButton)(() => ({ + color: theme.palette.cruGrayMedium.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); + +const Holder = styled(Box)(() => ({ + display: 'flex', + gap: '10px', + justifyContent: 'spaceBetween', + alignItems: 'center', +})); + +const Left = styled(Box)(() => ({ + width: 'calc(100% - 80px)', +})); + +const Right = styled(Box)(() => ({ + width: '120px', +})); + +export type GoogleAccountAttributesSlimmed = Pick< + GoogleAccountAttributes, + 'id' | 'email' | 'primary' | 'remoteId' | 'tokenExpired' +>; + +export const GoogleAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [openEditGoogleAccount, setOpenEditGoogleAccount] = useState(false); + const [openDeleteGoogleAccount, setOpenDeleteGoogleAccount] = useState(false); + const [selectedAccount, setSelectedAccount] = useState< + GoogleAccountAttributesSlimmed | undefined + >(); + const { data, loading } = useGoogleAccountsQuery({ + skip: !expandedPanel, + }); + const googleAccounts = data?.getGoogleAccounts; + const accountListId = useAccountListId(); + const [oAuth, setOAuth] = useState(''); + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/google?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=Google`, + )}&access_token=${apiToken}`, + ); + }, []); + + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + + const handleEditAccount = (account) => { + setSelectedAccount(account); + setOpenEditGoogleAccount(true); + }; + const handleDeleteAccount = async (account) => { + setSelectedAccount(account); + setOpenDeleteGoogleAccount(true); + }; + return ( + <> + + } + > + {loading && } + {!loading && !googleAccounts?.length && !!expandedPanel && ( + <> + Google Integration Overview + + Google’s suite of tools are great at connecting you to your + Ministry Partners. + + + By synchronizing your Google services with MPDX, you will be able + to: + + + + See MPDX tasks in your Google Calendar + + Import Google Contacts into MPDX + + Keep your Contacts in sync with your Google Contacts + + + + Connect your Google account to begin, and then setup specific + settings for Google Calendar and Contacts. MPDX leaves you in + control of how each service stays in sync. + + + )} + + {!loading && + googleAccounts?.map((account) => ( + + + + {account?.email} + + + handleEditAccount(account)}> + + + handleDeleteAccount(account)} + > + + + + + {account?.tokenExpired && ( + <> + + {t(`The link between MPDX and your Google account stopped working. Click "Refresh Google Account" to + re-enable it. After that, you'll need to manually re-enable any integrations that you had set + already.`)} + + + {t('Refresh Google Account')} + + + )} + + ))} + + + {t('Add Account')} + + + {!!googleAccounts?.length && ( + + + {t('Import contacts')} + + + )} + + + {openEditGoogleAccount && selectedAccount && ( + setOpenEditGoogleAccount(false)} + account={selectedAccount} + oAuth={oAuth} + /> + )} + {openDeleteGoogleAccount && selectedAccount && ( + setOpenDeleteGoogleAccount(false)} + account={selectedAccount} + /> + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx new file mode 100644 index 000000000..ebb8458db --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.test.tsx @@ -0,0 +1,128 @@ +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.page'; +import { DeleteGoogleAccountModal } from './DeleteGoogleAccountModal'; +import { getSession } from 'next-auth/react'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; +const session = { + expires: '2021-10-28T14:48:20.897Z', + user: { + email: 'Chair Library Bed', + image: null, + name: 'Dung Tapestry', + token: 'superLongJwtString', + }, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleClose = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardGoogleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +describe('DeleteGoogleAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + let googleAccount = { ...standardGoogleAccount }; + + beforeEach(() => { + googleAccount = { ...standardGoogleAccount }; + handleClose.mockClear(); + }); + + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(); + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should run deleteGoogleAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + expect( + getByText(/confirm to disconnect google account/i), + ).toBeInTheDocument(); + userEvent.click(getByText('Confirm')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Google.', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'DeleteGoogleAccount', + ); + expect( + mutationSpy.mock.calls[0][0].operation.variables.input.accountId, + ).toEqual(standardGoogleAccount.id); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx new file mode 100644 index 000000000..0535cb27b --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/DeleteGoogleAccountModal.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { + useDeleteGoogleAccountMutation, + GoogleAccountsDocument, + GoogleAccountsQuery, +} from '../googleAccounts.generated'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; + +interface DeleteGoogleAccountModalProps { + handleClose: () => void; + account: GoogleAccountAttributesSlimmed; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeleteGoogleAccountModal: React.FC< + DeleteGoogleAccountModalProps +> = ({ account, handleClose }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deleteGoogleAccount] = useDeleteGoogleAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + + await deleteGoogleAccount({ + variables: { + input: { + accountId: account.id, + }, + }, + update: (cache) => { + const query = { + query: GoogleAccountsDocument, + }; + const dataFromCache = cache.readQuery(query); + + if (dataFromCache) { + const removedAccountFromCache = + dataFromCache?.getGoogleAccounts.filter( + (acc) => acc?.id !== account.id, + ); + const data = { + getGoogleAccounts: [...removedAccountFromCache], + }; + cache.writeQuery({ ...query, data }); + } + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your integration with Google.'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Google."), + { + variant: 'error', + }, + ); + }, + }); + + setIsSubmitting(false); + }; + + return ( + + + + {t(`Are you sure you wish to disconnect this Google account?`)} + + + + + + + {t('Confirm')} + + + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx new file mode 100644 index 000000000..a6c5b0339 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.test.tsx @@ -0,0 +1,506 @@ +import { render, waitFor, act } from '@testing-library/react'; +import { 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.page'; +import { EditGoogleAccountModal } from './EditGoogleAccountModal'; +import { getSession } from 'next-auth/react'; +import * as Types from '../../../../../../graphql/types.generated'; +import { + GetGoogleAccountIntegrationsQuery, + GetIntegrationActivitiesQuery, +} from './googleIntegrations.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 handleClose = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const googleAccount = { + email: 'test-n-rest@cru.org', + primary: false, + remoteId: '111222333444', + id: 'abcd1234', + tokenExpired: false, + __typename: 'GoogleAccountAttributes', +}; + +const standardGoogleIntegration: Pick< + Types.GoogleAccountIntegration, + | '__typename' + | 'calendarId' + | 'calendarIntegration' + | 'calendarIntegrations' + | 'calendarName' + | 'createdAt' + | 'updatedAt' + | 'id' + | 'updatedInDbAt' +> & { + calendars: Array< + Types.Maybe< + { __typename?: 'GoogleAccountIntegrationCalendars' } & Pick< + Types.GoogleAccountIntegrationCalendars, + 'id' | 'name' + > + > + >; +} = { + __typename: 'GoogleAccountIntegration', + calendarId: null, + calendarIntegration: true, + calendarIntegrations: ['Appointment'], + calendarName: 'calendar', + calendars: [ + { + __typename: 'GoogleAccountIntegrationCalendars', + id: 'calendarsID', + name: 'calendarsName@cru.org', + }, + ], + createdAt: '08/08/2023', + updatedAt: '08/08/2023', + id: 'ID', + updatedInDbAt: '08/08/2023', +}; + +const oAuth = `https://auth.mpdx.org/urlpath/to/authenicate`; +describe('EditGoogleAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + (getSession as jest.Mock).mockResolvedValue(session); + let googleIntegration = { ...standardGoogleIntegration }; + + beforeEach(() => { + googleIntegration = { ...standardGoogleIntegration }; + handleClose.mockClear(); + }); + + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should switch tabs', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + + , + ), + ); + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(); + const setupTab = getByRole('tab', { name: /setup/i }); + expect(setupTab).toBeInTheDocument(); + userEvent.click(setupTab); + + const button = getByRole('link', { name: /refresh google account/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('href', oAuth); + }); + + it('should enable Calendar Integration', async () => { + googleIntegration.calendarIntegration = false; + googleIntegration.calendarIntegrations = []; + googleIntegration.calendarName = null; + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + + , + ), + ); + await waitFor(() => + expect(getByText(/Edit Google Integration/i)).toBeInTheDocument(), + ); + + userEvent.click( + getByRole('button', { name: /enable calendar integration/i }), + ); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Enabled Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarIntegration: true, + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + }); + }); + + it('should update Integrations calendar', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole, queryByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + __typename: 'IdValue', + }, + { + id: 'Appointment', + value: 'Appointment', + __typename: 'IdValue', + }, + { + id: 'Email', + value: 'Email', + __typename: 'IdValue', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + await act(async () => { + userEvent.click(getByRole('button', { name: /update/i })); + }); + await waitFor(() => + expect(getByText(/this field is required/i)).toBeInTheDocument(), + ); + await act(async () => { + userEvent.click(getByRole('button', { name: /​/i })); + }); + const calendarOption = getByRole('option', { + name: /calendarsName@cru\.org/i, + }); + await waitFor(() => expect(calendarOption).toBeInTheDocument()); + await act(async () => { + userEvent.click(calendarOption); + }); + + await waitFor(() => + expect(queryByRole(/this field is required/i)).not.toBeInTheDocument(), + ); + + userEvent.click(getByRole('button', { name: /update/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Updated Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarId: 'calendarsID', + calendarIntegrations: ['Appointment'], + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + + expect(handleClose).toHaveBeenCalled(); + }); + }); + + it('should update calendar checkboxes', async () => { + googleIntegration.calendarId = 'calendarsID'; + const mutationSpy = jest.fn(); + let getByText, getByRole, getByTestId; + await act(async () => { + const { + getByText: getByTextFromRender, + getByRole: getByRoleFromRender, + getByTestId: getByTestIdFromRender, + } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + GetIntegrationActivities: { + constant: { + activities: [ + { + id: 'Call', + value: 'Call', + __typename: 'IdValue', + }, + { + id: 'Appointment', + value: 'Appointment', + __typename: 'IdValue', + }, + { + id: 'Email', + value: 'Email', + __typename: 'IdValue', + }, + ], + }, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + getByText = getByTextFromRender; + getByRole = getByRoleFromRender; + getByTestId = getByTestIdFromRender; + }); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click(getByTestId('Call-Checkbox')); + userEvent.click(getByRole('button', { name: /update/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Updated Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarId: 'calendarsID', + calendarIntegrations: ['Appointment', 'Call'], + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + + expect(handleClose).toHaveBeenCalled(); + }); + }); + + it('should delete Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click( + getByRole('button', { name: /Disable Calendar Integration/i }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Disabled Google Calendar Integration!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'UpdateGoogleIntegration', + ); + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + googleIntegration: { + calendarIntegration: false, + overwrite: true, + }, + googleIntegrationId: googleIntegration.id, + }); + }); + }); + + it('should sync Calendar Integration', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetGoogleAccountIntegrations: { + getGoogleAccountIntegrations: [googleIntegration], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => + expect( + getByText(/choose a calendar for mpdx to push tasks to:/i), + ).toBeInTheDocument(), + ); + + userEvent.click(getByRole('button', { name: /sync calendar/i })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully Synced Calendar!', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'SyncGoogleAccount', + ); + expect(mutationSpy.mock.calls[2][0].operation.variables.input).toEqual({ + googleAccountId: googleAccount.id, + integrationName: 'calendar', + googleIntegrationId: googleIntegration.id, + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx new file mode 100644 index 000000000..26fc117e9 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleAccountModal.tsx @@ -0,0 +1,282 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { styled } from '@mui/material/styles'; +import { + DialogContent, + DialogActions, + Typography, + Tabs, + Tab, + Box, + Skeleton, + Button, +} from '@mui/material'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, + ActionButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; +import { + useGetGoogleAccountIntegrationsQuery, + GetGoogleAccountIntegrationsDocument, + GetGoogleAccountIntegrationsQuery, + useCreateGoogleIntegrationMutation, +} from './googleIntegrations.generated'; +import { useSyncGoogleAccountMutation } from '../googleAccounts.generated'; +import { useUpdateGoogleIntegrationMutation } from './updateGoogleIntegration.generated'; +import { EditGoogleIntegrationForm } from './EditGoogleIntegrationForm'; + +interface EditGoogleAccountModalProps { + handleClose: () => void; + account: GoogleAccountAttributesSlimmed; + oAuth: string; +} + +enum tabs { + calendar = 'calendar', + setup = 'setup', +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const EditGoogleAccountModal: React.FC = ({ + account, + handleClose, + oAuth, +}) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tabSelected, setTabSelected] = useState(tabs.calendar); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + + const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); + const [createGoogleIntegration] = useCreateGoogleIntegrationMutation(); + const [syncGoogleAccountQuery] = useSyncGoogleAccountMutation(); + const { + data, + loading, + refetch: refetchGoogleIntegrations, + } = useGetGoogleAccountIntegrationsQuery({ + variables: { + input: { + googleAccountId: account.id, + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const googleAccountDetails = data?.getGoogleAccountIntegrations[0]; + + const handleTabChange = (_, tab) => { + setTabSelected(tab); + }; + + const handleToogleCalendarIntegration = async ( + enableIntegration: boolean, + ) => { + if (!tabSelected) return; + setIsSubmitting(true); + + if (!googleAccountDetails && enableIntegration) { + // Create Google Integration + await createGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + accountListID: accountListId ?? '', + googleIntegration: { + [`${tabSelected}Integration`]: enableIntegration, + }, + }, + }, + update: () => refetchGoogleIntegrations(), + }); + } else if (googleAccountDetails) { + // Update Google Inetgration + await updateGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails.id, + googleIntegration: { + [`${tabSelected}Integration`]: enableIntegration, + overwrite: true, + }, + }, + }, + update: (cache) => { + const query = { + query: GetGoogleAccountIntegrationsDocument, + variables: { + googleAccountId: account.id, + accountListId, + }, + }; + const dataFromCache = + cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + [`${tabSelected}Integration`]: enableIntegration, + }; + cache.writeQuery({ ...query, data }); + } + }, + }); + } else { + return; + } + + enqueueSnackbar( + enableIntegration + ? t('Enabled Google Calendar Integration!') + : t('Disabled Google Calendar Integration!'), + { + variant: 'success', + }, + ); + setIsSubmitting(false); + }; + + const handleSyncCalendar = async () => { + await syncGoogleAccountQuery({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails?.id ?? '', + integrationName: tabs.calendar, + }, + }, + }); + enqueueSnackbar(t('Successfully Synced Calendar!'), { + variant: 'success', + }); + }; + + return ( + + + + {t('You are currently editing settings for {{email}}', { + email: account.email, + })} + + + + + + + + + {loading && googleAccountDetails?.calendarIntegration && ( + <> + + + + )} + + {!loading && + googleAccountDetails?.calendarIntegration && + tabSelected === tabs.calendar && ( + + )} + + {!loading && + !googleAccountDetails?.calendarIntegration && + tabSelected === tabs.calendar && ( + + {t(`MPDX can automatically update your google calendar with your tasks. + Once you enable this feature, you'll be able to choose which + types of tasks you want to sync. By default MPDX will add + 'Appointment' tasks to your calendar.`)} + + )} + + {tabSelected === tabs.setup && ( + + {t( + `If the link between MPDX and your Google account breaks, + click the button below to re-establish the connection. + (You should only need to do this if you receive an email + from MPDX)`, + )} + + )} + + + {tabSelected === tabs.calendar && + !googleAccountDetails?.calendarIntegration && ( + + + handleToogleCalendarIntegration(true)} + > + {t('Enable Calendar Integration')} + + + )} + {tabSelected === tabs.calendar && + googleAccountDetails?.calendarIntegration && ( + + + + {t('Sync Calendar')} + + + )} + {tabSelected === tabs.setup && ( + + + + + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx new file mode 100644 index 000000000..69a3b1591 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/EditGoogleIntegrationForm.tsx @@ -0,0 +1,264 @@ +import React, { ReactElement } from 'react'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import { styled } from '@mui/material/styles'; +import { + DialogActions, + Typography, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Skeleton, + FormHelperText, +} from '@mui/material'; +import { Box } from '@mui/system'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { GoogleAccountIntegration } from '../../../../../../graphql/types.generated'; +import { + SubmitButton, + DeleteButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { + GetGoogleAccountIntegrationsDocument, + GetGoogleAccountIntegrationsQuery, + useGetIntegrationActivitiesQuery, +} from './googleIntegrations.generated'; +import { useUpdateGoogleIntegrationMutation } from './updateGoogleIntegration.generated'; +import { GoogleAccountAttributesSlimmed } from '../GoogleAccordian'; + +type GoogleAccountIntegrationSlimmed = Pick< + GoogleAccountIntegration, + 'calendarId' | 'id' | 'calendarIntegrations' | 'calendars' +>; +interface EditGoogleIntegrationFormProps { + account: GoogleAccountAttributesSlimmed; + googleAccountDetails: GoogleAccountIntegrationSlimmed; + loading: boolean; + setIsSubmitting: (boolean) => void; + handleToogleCalendarIntegration: (boolean) => void; + handleClose: () => void; +} + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: '20px', + marginBottom: '20px', +})); + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + flex: '0 1 50%', + margin: '0 0 0 -11px', +})); + +export const EditGoogleIntegrationForm: React.FC< + EditGoogleIntegrationFormProps +> = ({ + account, + googleAccountDetails, + loading, + setIsSubmitting, + handleToogleCalendarIntegration, + handleClose, +}) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + + const [updateGoogleIntegration] = useUpdateGoogleIntegrationMutation(); + + const { data: actvitiesData } = useGetIntegrationActivitiesQuery(); + const actvities = actvitiesData?.constant?.activities; + + const IntegrationSchema: yup.SchemaOf = + yup.object({ + id: yup.string().required(), + calendarId: yup.string().required(), + calendarIntegrations: yup.array().of(yup.string().required()).required(), + calendars: yup + .array() + .of( + yup.object({ + __typename: yup + .string() + .equals(['GoogleAccountIntegrationCalendars']), + id: yup.string().required(), + name: yup.string().required(), + }), + ) + .required(), + }); + + const onSubmit = async (attributes: GoogleAccountIntegrationSlimmed) => { + setIsSubmitting(true); + const googleIntegration = { + calendarId: attributes.calendarId, + calendarIntegrations: attributes.calendarIntegrations, + }; + + await updateGoogleIntegration({ + variables: { + input: { + googleAccountId: account.id, + googleIntegrationId: googleAccountDetails?.id ?? '', + googleIntegration: { + ...googleIntegration, + overwrite: true, + }, + }, + }, + update: (cache) => { + const query = { + query: GetGoogleAccountIntegrationsDocument, + variables: { + googleAccountId: account.id, + accountListId, + }, + }; + const dataFromCache = + cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + ...googleIntegration, + }; + cache.writeQuery({ ...query, data }); + } + }, + }); + setIsSubmitting(false); + enqueueSnackbar(t('Updated Google Calendar Integration!'), { + variant: 'success', + }); + handleClose(); + }; + + return ( + <> + {loading && ( + <> + + + + )} + + {!loading && ( + <> + + {t('Choose a calendar for MPDX to push tasks to:')} + + + + {({ + values: { calendarId, calendarIntegrations, calendars }, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + {errors.calendarId && ( + + {t('This field is required')} + + )} + + + + {actvities?.map((activity) => { + if (!activity?.id || !activity?.value) return null; + const activityId = `${activity.value}-Checkbox`; + const isChecked = calendarIntegrations.includes( + activity?.id ?? '', + ); + return ( + { + let newCalendarInetgrations; + if (value) { + // Add to calendarIntegrations + newCalendarInetgrations = [ + ...calendarIntegrations, + activity.value, + ]; + } else { + // Remove from calendarIntegrations + newCalendarInetgrations = + calendarIntegrations.filter( + (act) => act !== activity?.id, + ); + } + setFieldValue( + `calendarIntegrations`, + newCalendarInetgrations, + ); + }} + /> + } + label={activity.value} + /> + ); + })} + + + + handleToogleCalendarIntegration(false)} + variant="outlined" + > + {t('Disable Calendar Integration')} + + + {t('Update')} + + +
+ )} +
+ + )} + + ); +}; diff --git a/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql new file mode 100644 index 000000000..1cdd50479 --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/googleIntegrations.graphql @@ -0,0 +1,42 @@ +query GetGoogleAccountIntegrations($input: GetGoogleAccountIntegrationsInput!) { + getGoogleAccountIntegrations(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} + +query GetIntegrationActivities { + constant { + activities { + id + value + } + } +} + +mutation CreateGoogleIntegration($input: CreateGoogleIntegrationInput!) { + createGoogleIntegration(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} diff --git a/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql b/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql new file mode 100644 index 000000000..c5da9658d --- /dev/null +++ b/src/components/Settings/integrations/Google/Modals/updateGoogleIntegration.graphql @@ -0,0 +1,16 @@ +mutation UpdateGoogleIntegration($input: UpdateGoogleIntegrationInput!) { + updateGoogleIntegration(input: $input) { + calendarId + calendarIntegration + calendarIntegrations + calendarName + calendars { + id + name + } + createdAt + updatedAt + id + updatedInDbAt + } +} diff --git a/src/components/Settings/integrations/Google/googleAccounts.graphql b/src/components/Settings/integrations/Google/googleAccounts.graphql new file mode 100644 index 000000000..183fd5d3e --- /dev/null +++ b/src/components/Settings/integrations/Google/googleAccounts.graphql @@ -0,0 +1,19 @@ +query GoogleAccounts { + getGoogleAccounts { + email + primary + remoteId + id + tokenExpired + } +} + +mutation SyncGoogleAccount($input: SyncGoogleAccountInput!) { + syncGoogleAccount(input: $input) +} + +mutation DeleteGoogleAccount($input: DeleteGoogleAccountInput!) { + deleteGoogleAccount(input: $input) { + success + } +} diff --git a/src/components/Settings/integrations/Key/Key.graphql b/src/components/Settings/integrations/Key/Key.graphql new file mode 100644 index 000000000..365e8e4ed --- /dev/null +++ b/src/components/Settings/integrations/Key/Key.graphql @@ -0,0 +1,7 @@ +query GetKeyAccounts { + user { + keyAccounts { + email + } + } +} diff --git a/src/components/Settings/integrations/Key/TheKeyAccordian.tsx b/src/components/Settings/integrations/Key/TheKeyAccordian.tsx new file mode 100644 index 000000000..db93a2ec7 --- /dev/null +++ b/src/components/Settings/integrations/Key/TheKeyAccordian.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; +import { Box, TextField } from '@mui/material'; +import Skeleton from '@mui/material/Skeleton'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { useGetKeyAccountsQuery } from './Key.generated'; + +interface TheKeyAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +export const TheKeyAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const { data, loading } = useGetKeyAccountsQuery(); + const keyAccounts = data?.user?.keyAccounts; + return ( + + } + > + {loading && } + {!loading && + keyAccounts?.map((account, idx) => ( + + + + + + ))} + + ); +}; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx new file mode 100644 index 000000000..bf28355b5 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.test.tsx @@ -0,0 +1,516 @@ +import { render, waitFor, within, act } from '@testing-library/react'; +import { 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.page'; +import { MailchimpAccordian } from './MailchimpAccordian'; +import { GetMailchimpAccountQuery } from './MailchimpAccount.generated'; +import * as Types from '../../../../../graphql/types.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardMailchimpAccount: Types.MailchimpAccount = { + __typename: 'MailchimpAccount', + id: '123456789', + active: true, + autoLogCampaigns: false, + createdAt: 'DATETIME', + listsAvailableForNewsletters: [ + { + __typename: 'listsAvailableForNewsletters', + id: '11111111', + name: 'Newsletter list 1', + }, + { + __typename: 'listsAvailableForNewsletters', + id: '2222222', + name: 'Newsletter list 2', + }, + { + __typename: 'listsAvailableForNewsletters', + id: '33333333', + name: 'Newsletter list 3', + }, + ], + listsLink: 'https://listsLink.com', + listsPresent: true, + primaryListId: '11111111', + primaryListName: 'primaryListName', + updatedAt: 'DATETIME', + updatedInDbAt: 'DATETIME', + valid: false, + validateKey: true, + validationError: null, +}; + +describe('MailchimpAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('MailChimp')).toBeInTheDocument(); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const mailchimpImage = queryByRole('img', { + name: /mailchimp/i, + }); + expect(mailchimpImage).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render Mailchimp Overview', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('MailChimp Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Connect MailChimp')); + + expect(getByText('Connect MailChimp')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/mailchimp?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dmailchimp&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let mailchimpAccount = { ...standardMailchimpAccount }; + + beforeEach(() => { + mailchimpAccount = { ...standardMailchimpAccount }; + }); + it('is connected but no lists present', async () => { + mailchimpAccount.listsPresent = false; + const mutationSpy = jest.fn(); + const { queryByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('Please choose a list to sync with MailChimp.'), + ).toBeInTheDocument(); + expect( + queryByText( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + ), + ).toBeInTheDocument(); + expect( + queryByText('Go to MailChimp to create a list.'), + ).toBeInTheDocument(); + }); + + expect( + queryByText('Pick a list to use for your newsletter'), + ).not.toBeInTheDocument(); + }); + + it('is connected but no lists present & no lists link', async () => { + mailchimpAccount.listsPresent = false; + mailchimpAccount.listsLink = ''; + const mutationSpy = jest.fn(); + const { queryByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + ), + ).toBeInTheDocument(); + }); + + expect( + queryByText('Go to MailChimp to create a list.'), + ).not.toBeInTheDocument(); + }); + + it('should call updateMailchimpAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + + userEvent.click(getByRole('button', { name: /Newsletter list 1/i })); + await waitFor(() => + expect( + getByRole('option', { name: /Newsletter list 2/i }), + ).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: /Newsletter list 2/i })); + + userEvent.click( + getByRole('checkbox', { + name: /automatically log sent mailchimp campaigns in contact task history/i, + }), + ); + + userEvent.click( + getByRole('button', { + name: /save/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'UpdateMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + mailchimpAccount: { primaryListId: '2222222', autoLogCampaigns: true }, + mailchimpAccountId: '123456789', + }); + }); + + it('should call deleteMailchimpAccount', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + + await waitFor(() => { + expect( + getByText('Confirm to Disconnect Mailchimp Account'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with MailChimp', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeleteMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + }); + // refetch account + expect(mutationSpy.mock.calls[2][0].operation.operationName).toEqual( + 'GetMailchimpAccount', + ); + }); + it('should show settings overview', async () => { + mailchimpAccount.valid = true; + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + expect(getByText(/primaryListName/i)).toBeInTheDocument(); + expect(getByText(/off/i)).toBeInTheDocument(); + }); + + it('should call syncMailchimpAccount', async () => { + mailchimpAccount.valid = true; + mailchimpAccount.autoLogCampaigns = true; + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + const list = getByRole('list'); + within(list).getByText(/on/i); + + userEvent.click( + getByRole('button', { + name: /sync now/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'SyncMailchimpAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: 'account-list-1', + }); + }); + it('should show settings', async () => { + mailchimpAccount.valid = true; + mailchimpAccount.autoLogCampaigns = true; + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + Components( + + mocks={{ + GetMailchimpAccount: { + getMailchimpAccount: [mailchimpAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).toBeInTheDocument(); + }); + + await act(async () => { + userEvent.click( + getByRole('button', { + name: /modify settings/i, + }), + ); + }); + + await waitFor(() => { + expect( + queryByText( + 'Your contacts are now automatically syncing with MailChimp', + ), + ).not.toBeInTheDocument(); + expect( + queryByText('Pick a list to use for your newsletter'), + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx new file mode 100644 index 000000000..2a34a115b --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccordian.tsx @@ -0,0 +1,390 @@ +import { useState, useContext, useEffect, useMemo, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { styled } from '@mui/material/styles'; +import * as Types from '../../../../../graphql/types.generated'; +import { + Box, + Typography, + Skeleton, + Alert, + Button, + Select, + MenuItem, + Checkbox, + FormControlLabel, + FormHelperText, + List, + ListItem, + ListItemText, +} from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { + useGetMailchimpAccountQuery, + useUpdateMailchimpAccountMutation, + GetMailchimpAccountDocument, + GetMailchimpAccountQuery, + useSyncMailchimpAccountMutation, +} from './MailchimpAccount.generated'; +import { StyledFormLabel } from 'src/components/Shared/Forms/Field'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations.page'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { SubmitButton } from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { DeleteMailchimpAccountModal } from './Modals/DeleteMailchimpModal'; +import { + StyledListItem, + StyledList, + StyledServicesButton, +} from '../integrationsHelper'; + +interface MailchimpAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ + flex: '0 1 50%', + margin: '0 0 0 -11px', +})); + +const StyledButton = styled(Button)(() => ({ + marginLeft: '15px', +})); + +export const MailchimpAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [oAuth, setOAuth] = useState(''); + const [showSettings, setShowSettings] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const [updateMailchimpAccount] = useUpdateMailchimpAccountMutation(); + const [syncMailchimpAccount] = useSyncMailchimpAccountMutation(); + const { + data, + loading, + refetch: refetchGetMailchimpAccount, + } = useGetMailchimpAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: !accountListId, + }); + + const mailchimpAccount = data?.getMailchimpAccount + ? data.getMailchimpAccount[0] + : null; + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/mailchimp?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=mailchimp`, + )}&access_token=${apiToken}`, + ); + }, []); + + const MailchimpSchema: yup.SchemaOf< + Pick + > = yup.object({ + autoLogCampaigns: yup.boolean().required(), + primaryListId: yup.string().required(), + }); + + const onSubmit = async ( + attributes: Pick< + Types.MailchimpAccount, + 'autoLogCampaigns' | 'primaryListId' + >, + ) => { + await updateMailchimpAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + mailchimpAccount: attributes, + mailchimpAccountId: mailchimpAccount?.id ?? '', + }, + }, + update: (cache) => { + const query = { + query: GetMailchimpAccountDocument, + variables: { + accountListId, + }, + }; + const dataFromCache = cache.readQuery(query); + + if (dataFromCache) { + const data = { + ...dataFromCache, + ...attributes, + }; + cache.writeQuery({ ...query, data }); + } + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + ), + { + variant: 'success', + }, + ); + }, + }); + setShowSettings(false); + }; + + const handleSync = async () => { + await syncMailchimpAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + }); + enqueueSnackbar( + t( + 'Your MailChimp sync has been started. This process may take up to 4 hours to complete.', + ), + { + variant: 'success', + }, + ); + }; + const handleShowSettings = () => setShowSettings(true); + + const handleDisconnect = async () => setShowDeleteModal(true); + + const handleDeleteModalClose = () => { + setShowDeleteModal(false); + }; + + const availableNewsletterLists = useMemo(() => { + return ( + mailchimpAccount?.listsAvailableForNewsletters?.filter( + (list) => !!list?.id, + ) ?? [] + ); + }, [mailchimpAccount]); + + return ( + + } + > + {loading && } + {!loading && !mailchimpAccount && ( + <> + MailChimp Overview + + MailChimp makes keeping in touch with your ministry partners easy + and streamlined. Here’s how it works: + + + + If you have an existing MailChimp list you’d like to use, Great! + Or, create a new one for your MPDX connection. + + + Select your MPDX MailChimp list to stream your MPDX contacts into. + + + + That's it! Set it and leave it! Now your MailChimp list is + continuously up to date with your MPDX Contacts. That's just + the surface. Click over to the MPDX Help site for more in-depth + details. + + + {t('Connect MailChimp')} + + + )} + {!loading && + ((mailchimpAccount?.validateKey && !mailchimpAccount?.valid) || + showSettings) && ( + + + {t('Please choose a list to sync with MailChimp.')} + + + {mailchimpAccount?.listsPresent && ( + + {({ + values: { primaryListId, autoLogCampaigns }, + handleSubmit, + setFieldValue, + handleChange, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + {t('Pick a list to use for your newsletter')} + + + {errors.primaryListId && ( + + {t('This field is required')} + + )} + + } + label={t( + 'Automatically log sent MailChimp campaigns in contact task history', + )} + /> + + + + {t('Save')} + + + + {t('Disconnect')} + + + +
+ )} +
+ )} + + {!mailchimpAccount?.listsPresent && ( + + + {t( + 'You need to create a list on Mail Chimp that MPDX can use for your newsletter.', + )} + + {mailchimpAccount?.listsLink && ( + + )} + + )} +
+ )} + + {!loading && + mailchimpAccount?.validateKey && + mailchimpAccount?.valid && + !showSettings && ( + + + {t('Your contacts are now automatically syncing with MailChimp')} + + + + + + + + + + + + + + {t('Modify Settings')} + + + {t('Disconnect')} + + + )} + + {showDeleteModal && ( + + )} +
+ ); +}; diff --git a/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql new file mode 100644 index 000000000..c0c0e48b3 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/MailchimpAccount.graphql @@ -0,0 +1,51 @@ +query GetMailchimpAccount($input: MailchimpAccountInput!) { + getMailchimpAccount(input: $input) { + id + active + autoLogCampaigns + createdAt + listsAvailableForNewsletters { + id + name + } + listsLink + listsPresent + primaryListId + primaryListName + updatedAt + updatedInDbAt + valid + validateKey + validationError + } +} + +mutation UpdateMailchimpAccount($input: UpdateMailchimpAccountInput!) { + updateMailchimpAccount(input: $input) { + id + active + autoLogCampaigns + createdAt + listsAvailableForNewsletters { + id + name + } + listsLink + listsPresent + primaryListId + primaryListName + updatedAt + updatedInDbAt + valid + validateKey + validationError + } +} + +mutation SyncMailchimpAccount($input: SyncMailchimpAccountInput!) { + syncMailchimpAccount(input: $input) +} + +mutation DeleteMailchimpAccount($input: DeleteMailchimpAccountInput!) { + deleteMailchimpAccount(input: $input) +} diff --git a/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx new file mode 100644 index 000000000..af015a9d3 --- /dev/null +++ b/src/components/Settings/integrations/Mailchimp/Modals/DeleteMailchimpModal.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { useDeleteMailchimpAccountMutation } from '../MailchimpAccount.generated'; + +interface DeleteMailchimpAccountModalProps { + handleClose: () => void; + accountListId: string; + refetchMailchimpAccount: () => void; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeleteMailchimpAccountModal: React.FC< + DeleteMailchimpAccountModalProps +> = ({ handleClose, accountListId, refetchMailchimpAccount }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deleteMailchimpAccount] = useDeleteMailchimpAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + + await deleteMailchimpAccount({ + variables: { + input: { + accountListId: accountListId, + }, + }, + update: () => refetchMailchimpAccount(), + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your integration with MailChimp'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for MailChimp"), + { + variant: 'error', + }, + ); + }, + }); + setIsSubmitting(false); + }; + + return ( + + + + {t(`Are you sure you wish to disconnect your Mailchimp account?`)} + + + + + + + {t('Confirm')} + + + + ); +}; 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..535233d83 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.test.tsx @@ -0,0 +1,323 @@ +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.page'; +import { OrganizationAddAccountModal } from './OrganizationAddAccountModal'; +import { GetOrganizationsQuery } from '../Organizations.generated'; +import * as Types from '../../../../../../graphql/types.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const GetOrganizationsMock: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' +>[] = [ + { + id: 'organizationId', + name: 'organizationName', + apiClass: 'OfflineOrg', + oauth: false, + giftAidPercentage: 0, + }, + { + id: 'ministryId', + name: 'ministryName', + apiClass: 'Siebel', + oauth: false, + giftAidPercentage: 80, + }, + { + id: 'loginId', + name: 'loginName', + apiClass: 'DataServer', + oauth: false, + giftAidPercentage: 70, + }, + { + id: 'oAuthId', + name: 'oAuthName', + apiClass: 'DataServer', + oauth: true, + giftAidPercentage: 60, + }, +]; + +const standardMocks = { + GetOrganizations: { + organizations: GetOrganizationsMock, + }, +}; + +const handleClose = jest.fn(); +const refetchOrganizations = jest.fn(); + +describe('OrganizationAddAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + let mocks = { ...standardMocks }; + + beforeEach(() => { + handleClose.mockClear(); + refetchOrganizations.mockClear(); + mocks = { ...standardMocks }; + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Add Organization Account')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should select offline Organization and add it', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect( + getByRole('option', { name: 'organizationName' }), + ).toBeInTheDocument(), + ); + + await waitFor(() => { + expect(getByText('Add Account')).not.toBeDisabled(); + userEvent.click(getByText('Add Account')); + }); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX added your organization account', + { variant: 'success' }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'CreateOrganizationAccount', + ); + + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + attributes: { + organizationId: mocks.GetOrganizations.organizations[0].id, + }, + }); + }); + }); + + it('should select Ministry Organization and be unable to add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'ministryName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'ministryName' })); + + await waitFor(() => { + expect( + getByText('You must log into MPDX with your ministry email'), + ).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + }); + + it('should select Login Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'loginName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'loginName' })); + + await waitFor(() => { + expect(getByText('Username')).toBeInTheDocument(); + expect(getByText('Password')).toBeInTheDocument(); + expect(getByText('Add Account')).toBeDisabled(); + }); + + userEvent.type( + getByRole('textbox', { + name: /username/i, + }), + 'MyUsername', + ); + await waitFor(() => expect(getByText('Add Account')).toBeDisabled()); + + // TODO Need a way to test the password field. + // Currently React-testing-library has a bug which doesn't see password inputs. + + // await waitFor(() => expect(getByText('Add Account')).not.toBeDisabled()); + // userEvent.click(getByText('Add Account')); + + // await waitFor(() => { + // expect(mockEnqueue).toHaveBeenCalledWith( + // 'MPDX added your organization account', + // { variant: 'success' }, + // ); + // expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + // 'CreateOrganizationAccount', + // ); + + // expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + // attributes: { + // organizationId: mocks.GetOrganizations.organizations[2].id, + // username: 'MyUsername', + // password: 'MyPassword', + // }, + // }); + // }); + }); + + it('should select OAuth Organization and add it.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + mocks={{ + getOrganizations: { + organizations: GetOrganizationsMock, + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + userEvent.click(getByRole('combobox')); + await waitFor(() => + expect(getByRole('option', { name: 'oAuthName' })).toBeInTheDocument(), + ); + userEvent.click(getByRole('option', { name: 'oAuthName' })); + + await waitFor(() => { + expect( + getByText( + "You will be taken to your organization's donation services system to grant MPDX permission to access your donation data.", + ), + ).toBeInTheDocument(); + expect(getByText('Connect')).toBeInTheDocument(); + expect(getByText('Connect')).not.toBeDisabled(); + }); + + userEvent.click(getByText('Connect')); + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to complete authenication to connect.', + { variant: 'success' }, + ); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx new file mode 100644 index 000000000..7160cc2b4 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationAddAccountModal.tsx @@ -0,0 +1,356 @@ +import React, { useState, ReactElement } from 'react'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { useTranslation } from 'react-i18next'; +import { + DialogActions, + Autocomplete, + TextField, + Button, + Typography, + Link, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Box } from '@mui/system'; +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 { + useGetOrganizationsQuery, + useCreateOrganizationAccountMutation, +} from '../Organizations.generated'; +import { showArticle, variables } from 'src/lib/helpScout'; +import { Organization } from '../../../../../../graphql/types.generated'; +import theme from 'src/theme'; +import { + getOrganizationType, + OrganizationTypesEnum, +} from '../OrganizationAccordian'; +import { oAuth } from '../OrganizationService'; +import { signOut } from 'next-auth/react'; +import { clearDataDogUser } from 'src/hooks/useDataDog'; +import { useSnackbar } from 'notistack'; + +interface OrganizationAddAccountModalProps { + handleClose: () => void; + accountListId: string | undefined; + refetchOrganizations: () => void; +} + +export type OrganizationFormikSchema = { + selectedOrganization: Pick< + Organization, + 'id' | 'name' | 'oauth' | 'apiClass' | 'giftAidPercentage' + >; + username: string | undefined; + password: string | undefined; +}; + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +const WarningBox = styled(Box)(() => ({ + padding: '15px', + background: theme.palette.mpdxYellow.main, + maxWidth: 'calc(100% - 20px)', + margin: '10px auto 0', +})); + +const StyledTypography = styled(Typography)(() => ({ + marginTop: '10px', + color: theme.palette.mpdxYellow.dark, +})); + +export const OrganizationAddAccountModal: React.FC< + OrganizationAddAccountModalProps +> = ({ handleClose, refetchOrganizations, accountListId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [organizationType, setOrganizationType] = + useState(); + const [createOrganizationAccount] = useCreateOrganizationAccountMutation(); + const { data: organizations, loading } = useGetOrganizationsQuery(); + + const onSubmit = async (attributes: Partial) => { + if (!attributes?.selectedOrganization) return; + const { apiClass, oauth, id } = attributes.selectedOrganization; + const type = getOrganizationType(apiClass, oauth); + + if (type === OrganizationTypesEnum.OAUTH) { + enqueueSnackbar( + t('Redirecting you to complete authenication to connect.'), + { variant: 'success' }, + ); + window.location.href = await oAuth(id); + return; + } + + if (!accountListId) return; + + const createAccountAttributes: { + organizationId: string; + password?: string; + username?: string; + } = { + organizationId: id, + }; + if (attributes.password) { + createAccountAttributes.password = attributes.password; + } + if (attributes.username) { + createAccountAttributes.username = attributes.username; + } + + await createOrganizationAccount({ + variables: { + input: { + attributes: createAccountAttributes, + }, + }, + update: () => refetchOrganizations(), + onError: () => { + enqueueSnackbar(t('Invalid username or password.'), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX added your organization account'), { + variant: 'success', + }); + }, + }); + handleClose(); + return; + }; + + const showOrganizationHelp = () => { + showArticle(variables.HS_SETUP_FIND_ORGANIZATION); + }; + + const OrganizationSchema: yup.SchemaOf = yup.object( + { + selectedOrganization: yup + .object({ + id: yup.string().required(), + apiClass: yup.string().required(), + name: yup.string().required(), + oauth: yup.boolean().required(), + giftAidPercentage: yup.number().nullable(), + }) + .required(), + username: yup + .string() + .when('selectedOrganization', (organization, schema) => { + if ( + getOrganizationType(organization?.apiClass, organization?.oauth) === + OrganizationTypesEnum.LOGIN + ) { + return schema.required('Must enter username'); + } + return schema; + }), + password: yup + .string() + .when('selectedOrganization', (organization, schema) => { + if ( + getOrganizationType(organization?.apiClass, organization?.oauth) === + OrganizationTypesEnum.LOGIN + ) { + return schema.required('Must enter password'); + } + return schema; + }), + }, + ); + + return ( + + + {({ + values: { selectedOrganization, username, password }, + handleChange, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }): ReactElement => ( +
+ + { + setOrganizationType( + getOrganizationType(value?.apiClass, value?.oauth), + ); + setFieldValue('selectedOrganization', value); + }} + options={ + organizations?.organizations?.map( + (organization) => organization, + ) || [] + } + getOptionLabel={(option) => + organizations?.organizations?.find( + ({ id }) => String(id) === String(option.id), + )?.name ?? '' + } + filterSelectedOptions + fullWidth + renderInput={(params) => ( + + )} + /> + + + {!selectedOrganization && ( + + )} + + {organizationType === OrganizationTypesEnum.MINISTRY && ( + + + {t('You must log into MPDX with your ministry email')} + + + {t( + 'This organization requires you to log into MPDX with your ministry email to access it.', + )} +
    +
  1. + {t('First you need to ')} + + {t( + 'click here to log out of your personal Key account', + )} + +
  2. +
  3. + {t('Next, ')} + { + signOut({ callbackUrl: 'signOut' }).then(() => { + clearDataDogUser(); + }); + }} + > + {t('click here to log out of MPDX')} + + {t( + ' so you can log back in with your offical key account.', + )} +
  4. +
+
+ + {t( + "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", + )} + {t( + "Once this is done you'll need to wait 24 hours for MPDX to sync your data.", + )} + +
+ )} + + {organizationType === OrganizationTypesEnum.OAUTH && ( + + + {t( + "You will be taken to your organization's donation services system to grant MPDX permission to access your donation data.", + )} + + + )} + + {organizationType === OrganizationTypesEnum.LOGIN && ( + <> + + + + + + + + + + + + )} + + + + + + {organizationType !== OrganizationTypesEnum.OAUTH && + t('Add Account')} + {organizationType === OrganizationTypesEnum.OAUTH && + t('Connect')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx new file mode 100644 index 000000000..b3df73244 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.test.tsx @@ -0,0 +1,127 @@ +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.page'; +import { OrganizationEditAccountModal } from './OrganizationEditAccountModal'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const organizationId = 'organization-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); +const refetchOrganizations = jest.fn(); + +describe('OrganizationEditAccountModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + refetchOrganizations.mockClear(); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Edit Organization Account')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should enter login details.', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByRole } = render( + Components( + + + , + ), + ); + + await waitFor(() => { + expect(getByText('Username')).toBeInTheDocument(); + expect(getByText('Password')).toBeInTheDocument(); + }); + + userEvent.type( + getByRole('textbox', { + name: /username/i, + }), + 'MyUsername', + ); + + await waitFor(() => expect(getByText('Save')).toBeDisabled()); + + // TODO Need a way to test the password field. + // Currently React-testing-library has a bug which doesn't see password inputs. + + // await waitFor(() => expect(getByText('Add Account')).not.toBeDisabled()); + // userEvent.click(getByText('Add Account')); + + // await waitFor(() => { + // expect(mockEnqueue).toHaveBeenCalledWith( + // 'MPDX added your organization account', + // { variant: 'success' }, + // ); + // expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + // 'CreateOrganizationAccount', + // ); + + // expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + // attributes: { + // organizationId: mocks.GetOrganizations.organizations[2].id, + // username: 'MyUsername', + // password: 'MyPassword', + // }, + // }); + // }); + }); +}); 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..e3e0dad76 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationEditAccountModal.tsx @@ -0,0 +1,147 @@ +import React, { ReactElement } from 'react'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { useTranslation } from 'react-i18next'; +import { DialogActions, TextField, FormHelperText } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Box } from '@mui/system'; +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 { useUpdateOrganizationAccountMutation } from '../Organizations.generated'; +import { useSnackbar } from 'notistack'; +import { OrganizationFormikSchema } from './OrganizationAddAccountModal'; + +interface OrganizationEditAccountModalProps { + handleClose: () => void; + organizationId: string; +} + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +export const OrganizationEditAccountModal: React.FC< + OrganizationEditAccountModalProps +> = ({ handleClose, organizationId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [updateOrganizationAccount] = useUpdateOrganizationAccountMutation(); + + const onSubmit = async ( + attributes: Omit, + ) => { + const { password, username } = attributes; + + const createAccountAttributes = { + id: organizationId, + username, + password, + }; + + await updateOrganizationAccount({ + variables: { + input: { + attributes: createAccountAttributes, + }, + }, + onError: () => { + enqueueSnackbar(t('Unable to update your organization account'), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX updated your organization account'), { + variant: 'success', + }); + }, + }); + + handleClose(); + return; + }; + + const OrganizationSchema: yup.SchemaOf< + Omit + > = yup.object({ + username: yup.string().required(), + password: yup.string().required(), + }); + + return ( + + + {({ + values: { username, password }, + handleChange, + handleSubmit, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + + {errors.username && ( + + {errors.username} + + )} + + + + + + {errors.password && ( + + {errors.password} + + )} + + + + + + + {t('Save')} + + +
+ )} +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx new file mode 100644 index 000000000..f9438da84 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.test.tsx @@ -0,0 +1,143 @@ +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.page'; +import { OrganizationImportDataSyncModal } from './OrganizationImportDataSyncModal'; +import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; + +jest.mock('src/components/Shared/FileUploads/tntConnectDataSync'); + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const organizationId = 'organizationId'; +const organizationName = 'organizationName'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const handleClose = jest.fn(); +const refetchOrganizations = jest.fn(); + +describe('OrganizationImportDataSyncModal', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + + beforeEach(() => { + handleClose.mockClear(); + refetchOrganizations.mockClear(); + (validateFile as jest.Mock).mockReturnValue({ success: true }); + }); + it('should render modal', async () => { + const { getByText, getByTestId } = render( + Components( + + + , + ), + ); + + expect(getByText('Import TntConnect DataSync file')).toBeInTheDocument(); + + userEvent.click(getByText(/cancel/i)); + expect(handleClose).toHaveBeenCalledTimes(1); + userEvent.click(getByTestId('CloseIcon')); + expect(handleClose).toHaveBeenCalledTimes(2); + }); + + it('should return error when no file present', async () => { + const mutationSpy = jest.fn(); + const { getByText } = render( + Components( + + + , + ), + ); + userEvent.click(getByText('Upload File')); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Please select a file to upload.', + { + variant: 'error', + }, + ), + ); + }); + + it('should inform user of the error when uploadiung file.', async () => { + (validateFile as jest.Mock).mockReturnValue({ + success: false, + message: 'Invalid file', + }); + const mutationSpy = jest.fn(); + const { getByTestId, getByText } = render( + Components( + + + , + ), + ); + + const file = new File(['contents'], 'image.png', { + type: 'image/png', + }); + userEvent.upload(getByTestId('importFileUploader'), file); + + userEvent.click(getByText('Upload File')); + + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith('Invalid file', { + variant: 'error', + }), + ); + }); + // TODO: Need more tests with uploading correct file. + // Issue with node-fetch. +}); diff --git a/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx new file mode 100644 index 000000000..cc3f15227 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Modals/OrganizationImportDataSyncModal.tsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DialogActions, Typography, Button, Paper, Grid } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Box } from '@mui/system'; +import { useSnackbar } from 'notistack'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import theme from 'src/theme'; +import { validateFile } from 'src/components/Shared/FileUploads/tntConnectDataSync'; +import { getErrorMessage } from 'src/lib/getErrorFromCatch'; + +interface OrganizationImportDataSyncModalProps { + handleClose: () => void; + organizationId: string; + organizationName: string; + accountListId: string; +} + +const StyledBox = styled(Box)(() => ({ + padding: '0 10px', +})); + +const StyledTypography = styled(Typography)(() => ({ + marginTop: '10px', +})); + +export const OrganizationImportDataSyncModal: React.FC< + OrganizationImportDataSyncModalProps +> = ({ handleClose, organizationId, organizationName, accountListId }) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [importFile, setImportFile] = useState(null); + const handleSubmit = async (event) => { + event.preventDefault(); + try { + if (!importFile) throw new Error('Please select a file to upload.'); + // TODO + setIsSubmitting(true); + setIsSubmitting(false); + + const form = new FormData(); + form.append('accountListId', accountListId); + form.append('organizationId', organizationId); + form.append('tntDataSync', importFile); + + const res = await fetch(`/api/uploads/tnt-data-sync`, { + method: 'POST', + body: form, + }).catch(() => { + throw new Error(t('Cannot upload avatar: server error')); + }); + + if (res.status === 201) { + enqueueSnackbar( + `File successfully uploaded. The import to ${organizationName} will begin in the background.`, + { + variant: 'success', + }, + ); + } + + setIsSubmitting(false); + handleClose(); + } catch (err) { + enqueueSnackbar(getErrorMessage(err), { + variant: 'error', + }); + } + }; + + const handleFileChange: React.ChangeEventHandler = ( + event, + ) => { + try { + const file = event.target.files?.[0]; + if (!file) return; + + const validationResult = validateFile({ file, t }); + if (!validationResult.success) throw new Error(validationResult.message); + setImportFile(file); + } catch (err) { + enqueueSnackbar(getErrorMessage(err), { + variant: 'error', + }); + } + }; + + return ( + +
+ + + {t( + 'This file should be a TntConnect DataSync file (.tntdatasync or .tntmpd) from your organization, not your local TntConnect database file (.mpddb).', + )} + + + {t( + 'To import your TntConnect database, go to Import from TntConnect', + )} + + + + + + + + + + + {importFile?.name ?? 'No File Chosen'} + + + + + + + + + + + {t('Upload File')} + + +
+
+ ); +}; diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx new file mode 100644 index 000000000..9be8a796f --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordian.test.tsx @@ -0,0 +1,412 @@ +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.page'; +import { OrganizationAccordian } from './OrganizationAccordian'; +import { + GetUsersOrganizationsQuery, + GetOrganizationsQuery, +} from './Organizations.generated'; +import * as Types from '../../../../../graphql/types.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const GetOrganizationsMock: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' | 'giftAidPercentage' +>[] = [ + { + id: 'organizationId', + name: 'organizationName', + apiClass: 'organizationApiClass', + oauth: false, + giftAidPercentage: 0, + }, +]; + +const GetUsersOrganizationsMock: Array< + Pick< + Types.OrganizationAccount, + 'latestDonationDate' | 'lastDownloadedAt' | 'username' | 'id' + > & { + organization: Pick< + Types.Organization, + 'apiClass' | 'id' | 'name' | 'oauth' + >; + } +> = [ + { + id: 'id', + latestDonationDate: 'latestDonationDate', + lastDownloadedAt: 'lastDownloadedAt', + username: 'username', + organization: { + id: 'organizationId', + name: 'organizationName', + apiClass: 'OfflineOrg', + oauth: false, + }, + }, +]; + +const standardMocks = { + GetOrganizations: { + organizations: GetOrganizationsMock, + }, + GetUsersOrganizations: { + userOrganizationAccounts: GetUsersOrganizationsMock, + }, +}; + +describe('OrganizationAccordian', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('Organization')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /Organization/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('No Organizations connected', () => { + it('should render Organization Overview', async () => { + const { getByText } = render( + Components( + + mocks={{ + GetOrganizations: { + organizations: [], + }, + GetUsersOrganizations: { + userOrganizationAccounts: [], + }, + }} + > + + , + ), + ); + + await waitFor(() => { + expect( + getByText("Let's start by connecting to your first organization"), + ).toBeInTheDocument(); + }); + userEvent.click(getByText('Add Account')); + expect(getByText('Add Organization Account')).toBeInTheDocument(); + }); + }); + + describe('Organizations connected', () => { + let mocks = { ...standardMocks }; + beforeEach(() => { + mocks = { ...standardMocks }; + }); + + it('should render Offline Organization', async () => { + const { getByText, queryByText } = render( + Components( + + mocks={mocks} + > + + , + ), + ); + + expect( + queryByText("Let's start by connecting to your first organization"), + ).not.toBeInTheDocument(); + + await waitFor(() => { + expect( + getByText(GetUsersOrganizationsMock[0].organization.name), + ).toBeInTheDocument(); + + expect(getByText('Last Updated')).toBeInTheDocument(); + + expect(getByText('Last Gift Date')).toBeInTheDocument(); + }); + + userEvent.click(getByText('Import TntConnect DataSync file')); + + await waitFor(() => { + expect( + getByText( + 'To import your TntConnect database, go to Import from TntConnect', + ), + ).toBeInTheDocument(); + }); + }); + + it('should render Ministry Account Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'Siebel'; + const { getByText, queryByText } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('Sync')).toBeInTheDocument(); + + expect( + queryByText('Import TntConnect DataSync file'), + ).not.toBeInTheDocument(); + }); + + userEvent.click(getByText('Sync')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + { + variant: 'success', + }, + ); + }); + + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'SyncOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + id: mocks.GetUsersOrganizations.userOrganizationAccounts[0].id, + }); + }); + + it('should render Login Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'DataServer'; + const { getByText, getByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('Sync')).toBeInTheDocument(); + expect(getByTestId('EditIcon')).toBeInTheDocument(); + }); + + userEvent.click(getByTestId('EditIcon')); + + await waitFor(() => { + expect(getByText('Edit Organization Account')).toBeInTheDocument(); + }); + }); + + it('should render OAuth Organization', async () => { + const mutationSpy = jest.fn(); + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.apiClass = + 'DataServer'; + mocks.GetUsersOrganizations.userOrganizationAccounts[0].organization.oauth = + true; + const { getByText, queryByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(queryByTestId('EditIcon')).not.toBeInTheDocument(); + expect(getByText('Sync')).toBeInTheDocument(); + expect(getByText('Reconnect')).toBeInTheDocument(); + }); + + userEvent.click(getByText('Reconnect')); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'Redirecting you to complete authenication to reconnect.', + { + variant: 'success', + }, + ); + }); + }); + + it('should delete Organization', async () => { + const mutationSpy = jest.fn(); + const { getByText, getByTestId } = render( + Components( + + mocks={mocks} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect(getByTestId('DeleteIcon')).toBeInTheDocument(); + }); + + userEvent.click(getByTestId('DeleteIcon')); + + await waitFor(() => { + expect( + getByText('Are you sure you wish to disconnect this organization?'), + ).toBeInTheDocument(); + }); + userEvent.click(getByText('Yes')); + + await waitFor(() => { + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeleteOrganizationAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + id: mocks.GetUsersOrganizations.userOrganizationAccounts[0].id, + }); + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your organization integration', + { variant: 'success' }, + ); + }); + }); + + it("should not render Organization's download and last gift date", async () => { + mocks.GetUsersOrganizations.userOrganizationAccounts[0].lastDownloadedAt = + null; + mocks.GetUsersOrganizations.userOrganizationAccounts[0].latestDonationDate = + null; + const { queryByText } = render( + Components( + + mocks={mocks} + > + + , + ), + ); + + expect(queryByText('Last Updated')).not.toBeInTheDocument(); + + expect(queryByText('Last Gift Date')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx b/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx new file mode 100644 index 000000000..3f1be3f34 --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationAccordian.tsx @@ -0,0 +1,343 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Grid, + Box, + IconButton, + Typography, + Card, + Divider, +} from '@mui/material'; +import { DateTime } from 'luxon'; +import { useSnackbar } from 'notistack'; +import theme from 'src/theme'; +import DeleteIcon from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import { styled } from '@mui/material/styles'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { OrganizationAddAccountModal } from './Modals/OrganizationAddAccountModal'; +import { OrganizationImportDataSyncModal } from './Modals/OrganizationImportDataSyncModal'; +import { + useGetUsersOrganizationsQuery, + useDeleteOrganizationAccountMutation, + useSyncOrganizationAccountMutation, +} from './Organizations.generated'; +import { oAuth } from './OrganizationService'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { OrganizationEditAccountModal } from './Modals/OrganizationEditAccountModal'; +import { StyledServicesButton } from '../integrationsHelper'; + +interface OrganizationAccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} + +const OrganizationDeleteIconButton = styled(IconButton)(() => ({ + color: theme.palette.cruGrayMedium.main, + marginLeft: '10px', + '&:disabled': { + cursor: 'not-allowed', + pointerEvents: 'all', + }, +})); + +export enum OrganizationTypesEnum { + MINISTRY = 'ministry', + LOGIN = 'login', + OAUTH = 'oauth', + OFFLINE = 'offline', +} + +export const getOrganizationType = (apiClass, oauth) => { + const ministryAccount = [ + 'Siebel', + 'Remote::Import::OrganizationAccountService', + ]; + const loginRequired = [ + 'DataServer', + 'DataServerPtc', + 'DataServerNavigators', + 'DataServerStumo', + ]; + const offline = ['OfflineOrg']; + + if (apiClass) { + if (ministryAccount.indexOf(apiClass) !== -1) { + return OrganizationTypesEnum.MINISTRY; + } else if (loginRequired.indexOf(apiClass) !== -1 && !oauth) { + return OrganizationTypesEnum.LOGIN; + } else if (oauth) { + return OrganizationTypesEnum.OAUTH; + } else if (offline.indexOf(apiClass) !== -1) { + return OrganizationTypesEnum.OFFLINE; + } + } + return undefined; +}; + +export const OrganizationAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const [showImportDataSyncModal, setShowImportDataSyncModal] = useState(false); + const [showDeleteOrganizationModal, setShowDeleteOrganizationModal] = + useState(false); + const [showEditOrganizationModal, setShowEditOrganizationModal] = + useState(false); + const [deleteOrganizationAccount] = useDeleteOrganizationAccountMutation(); + const [syncOrganizationAccount] = useSyncOrganizationAccountMutation(); + + const { + data, + loading, + refetch: refetchOrganizations, + } = useGetUsersOrganizationsQuery(); + const organizations = data?.userOrganizationAccounts; + + const handleReconnect = async (organizationId) => { + enqueueSnackbar( + t('Redirecting you to complete authenication to reconnect.'), + { variant: 'success' }, + ); + const oAuthUrl = await oAuth(organizationId); + window.location.href = oAuthUrl; + }; + + const handleSync = async (accountId: string) => { + await syncOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + onError: () => { + enqueueSnackbar(t("MPDX couldn't sync your organization account"), { + variant: 'error', + }); + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'MPDX started syncing your organization account. This will occur in the background over the next 24-hours.', + ), + { + variant: 'success', + }, + ); + }, + }); + }; + + const handleDelete = async (accountId: string) => { + await deleteOrganizationAccount({ + variables: { + input: { + id: accountId, + }, + }, + update: () => refetchOrganizations(), + onError: () => { + enqueueSnackbar( + t( + "MPDX couldn't save your configuration changes for that organization", + ), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar(t('MPDX removed your organization integration'), { + variant: 'success', + }); + }, + }); + }; + + return ( + + } + > + + {t(`Add or change the organizations that sync donation information with this + MPDX account. Removing an organization will not remove past information, + but will prevent future donations and contacts from syncing.`)} + + + {!loading && !organizations?.length && ( + + {t("Let's start by connecting to your first organization")} + + )} + + {!loading && !!organizations?.length && ( + + {organizations.map( + ({ organization, lastDownloadedAt, latestDonationDate, id }) => { + const type = getOrganizationType( + organization.apiClass, + organization.oauth, + ); + + return ( + + + + + {organization.name} + + + + {type !== OrganizationTypesEnum.OFFLINE && ( + handleSync(id)} + > + Sync + + )} + + {type === OrganizationTypesEnum.OFFLINE && ( + setShowImportDataSyncModal(true)} + > + Import TntConnect DataSync file + + )} + + {type === OrganizationTypesEnum.OAUTH && ( + handleReconnect(organization.id)} + > + Reconnect + + )} + {type === OrganizationTypesEnum.LOGIN && ( + setShowEditOrganizationModal(true)} + > + + + )} + setShowDeleteOrganizationModal(true)} + > + + + + + + {lastDownloadedAt && ( + + + + Last Updated + + + {DateTime.fromISO(lastDownloadedAt).toRelative()} + + + + )} + {latestDonationDate && ( + + + + Last Gift Date + + + {DateTime.fromISO(latestDonationDate).toRelative()} + + + + )} + setShowDeleteOrganizationModal(false)} + mutation={() => handleDelete(id)} + /> + {showEditOrganizationModal && ( + setShowEditOrganizationModal(false)} + organizationId={id} + /> + )} + {showImportDataSyncModal && ( + setShowImportDataSyncModal(false)} + organizationId={id} + organizationName={organization.name} + accountListId={accountListId ?? ''} + /> + )} + + ); + }, + )} + + )} + + setShowAddAccountModal(true)} + > + Add Account + + + {showAddAccountModal && ( + setShowAddAccountModal(false)} + accountListId={accountListId} + refetchOrganizations={refetchOrganizations} + /> + )} + + ); +}; diff --git a/src/components/Settings/integrations/Organization/OrganizationService.ts b/src/components/Settings/integrations/Organization/OrganizationService.ts new file mode 100644 index 000000000..ef123ba31 --- /dev/null +++ b/src/components/Settings/integrations/Organization/OrganizationService.ts @@ -0,0 +1,26 @@ +import { getSession } from 'next-auth/react'; +import Router from 'next/router'; +import { getQueryParam } from 'src/utils/queryParam'; + +export const oAuth = async ( + organizationId, + route = 'preferences/integrations?selectedTab=organization', +) => { + const session = await getSession(); + const redirectUrl = encodeURIComponent(`${window.location.origin}/${route}`); + const token = session?.user.apiToken; + const accountListId = getQueryParam(Router.query, 'accountListId'); + return ( + `${process.env.OAUTH_URL}/auth/user/donorhub?account_list_id=${accountListId}` + + `&redirect_to=${redirectUrl}` + + `&access_token=${token}` + + `&organization_id=${organizationId}` + ); +}; + +export const sync = async () => { + // TODO + return new Promise((resolve) => { + return resolve; + }); +}; diff --git a/src/components/Settings/integrations/Organization/Organizations.graphql b/src/components/Settings/integrations/Organization/Organizations.graphql new file mode 100644 index 000000000..cf0ffdb84 --- /dev/null +++ b/src/components/Settings/integrations/Organization/Organizations.graphql @@ -0,0 +1,67 @@ +query getOrganizations { + organizations { + id + name + apiClass + oauth + giftAidPercentage + } +} + +query GetUsersOrganizations { + userOrganizationAccounts { + organization { + apiClass + id + name + oauth + } + latestDonationDate + lastDownloadedAt + username + id + } +} + +mutation DeleteOrganizationAccount( + $input: OrganizationAccountDeleteMutationInput! +) { + deleteOrganizationAccount(input: $input) { + clientMutationId + id + } +} + +mutation CreateOrganizationAccount( + $input: OrganizationAccountCreateMutationInput! +) { + createOrganizationAccount(input: $input) { + clientMutationId + organizationAccount { + username + person { + id + } + } + } +} + +mutation SyncOrganizationAccount( + $input: OrganizationAccountSyncMutationInput! +) { + syncOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} + +mutation UpdateOrganizationAccount( + $input: OrganizationAccountUpdateMutationInput! +) { + updateOrganizationAccount(input: $input) { + organizationAccount { + id + } + } +} 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..99b8f6b64 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/Modals/DeletePrayerlettersModal.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material/styles'; +import { DialogContent, DialogActions, Typography } from '@mui/material'; +import Modal from 'src/components/common/Modal/Modal'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { useDeletePrayerlettersAccountMutation } from '../PrayerlettersAccount.generated'; + +interface DeletePrayerlettersAccountModalProps { + handleClose: () => void; + accountListId: string; + refetchPrayerlettersAccount: () => void; +} + +const StyledDialogActions = styled(DialogActions)(() => ({ + justifyContent: 'space-between', +})); + +export const DeletePrayerlettersAccountModal: React.FC< + DeletePrayerlettersAccountModalProps +> = ({ handleClose, accountListId, refetchPrayerlettersAccount }) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const [deletePrayerlettersAccount] = useDeletePrayerlettersAccountMutation(); + + const handleDelete = async () => { + setIsSubmitting(true); + try { + await deletePrayerlettersAccount({ + variables: { + input: { + accountListId: accountListId, + }, + }, + update: () => refetchPrayerlettersAccount(), + }); + enqueueSnackbar(t('MPDX removed your integration with Prayer Letters'), { + variant: 'success', + }); + } catch { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Prayer Letters"), + { + variant: 'error', + }, + ); + } + setIsSubmitting(false); + handleClose(); + }; + + return ( + + + + {t( + `Are you sure you wish to disconnect this Prayer Letters account?`, + )} + + + + + + + {t('Confirm')} + + + + ); +}; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx new file mode 100644 index 000000000..235a8d802 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.test.tsx @@ -0,0 +1,283 @@ +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.page'; +import { PrayerlettersAccordian } from './PrayerlettersAccordian'; +import { GetPrayerlettersAccountQuery } from './PrayerlettersAccount.generated'; +import * as Types from '../../../../../graphql/types.generated'; + +jest.mock('next-auth/react'); + +const accountListId = 'account-list-1'; +const contactId = 'contact-1'; +const apiToken = 'apiToken'; +const router = { + query: { accountListId, contactId: [contactId] }, + isReady: true, +}; + +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const handleAccordionChange = jest.fn(); + +const Components = (children: React.ReactElement) => ( + + + + + {children} + + + + +); + +const standardPrayerlettersAccount: Types.PrayerlettersAccount = { + __typename: 'PrayerlettersAccount', + validToken: true, +}; + +describe('PrayerlettersAccount', () => { + process.env.OAUTH_URL = 'https://auth.mpdx.org'; + it('should render accordian closed', async () => { + const { getByText, queryByRole } = render( + Components( + + + , + ), + ); + expect(getByText('prayerletters.com')).toBeInTheDocument(); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).not.toBeInTheDocument(); + }); + it('should render accordian open', async () => { + const { queryByRole } = render( + Components( + + + , + ), + ); + const image = queryByRole('img', { + name: /prayerletters.com/i, + }); + expect(image).toBeInTheDocument(); + }); + + describe('Not Connected', () => { + it('should render PrayerLetters.com Overview', async () => { + const { getByText } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [], + }, + }} + > + + , + ), + ); + + await waitFor(() => { + expect(getByText('PrayerLetters.com Overview')).toBeInTheDocument(); + }); + userEvent.click(getByText('Connect prayerletters.com Account')); + + expect(getByText('Connect prayerletters.com Account')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + ); + }); + }); + + describe('Connected', () => { + let prayerlettersAccount = { ...standardPrayerlettersAccount }; + + beforeEach(() => { + prayerlettersAccount = { ...standardPrayerlettersAccount }; + }); + it('is connected but token is not valid', async () => { + prayerlettersAccount.validToken = false; + const mutationSpy = jest.fn(); + const { queryByText, getByText, getByRole } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('Refresh prayerletters.com Account'), + ).toBeInTheDocument(); + }); + + expect(getByText('Refresh prayerletters.com Account')).toHaveAttribute( + 'href', + `https://auth.mpdx.org/auth/user/prayer_letters?account_list_id=account-list-1&redirect_to=http%3A%2F%2Flocalhost%2FaccountLists%2Faccount-list-1%2Fsettings%2Fintegrations%3FselectedTab%3Dprayerletters.com&access_token=apiToken`, + ); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + + await waitFor(() => { + expect( + queryByText( + 'Are you sure you wish to disconnect this Prayer Letters account?', + ), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeletePrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); + + it('is connected but token is valid', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByRole } = render( + Components( + + mocks={{ + GetPrayerlettersAccount: { + getPrayerlettersAccount: [prayerlettersAccount], + }, + }} + onCall={mutationSpy} + > + + , + ), + ); + + await waitFor(() => { + expect( + queryByText('We strongly recommend only making changes in MPDX.'), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /disconnect/i, + }), + ); + await waitFor(() => { + expect( + queryByText( + 'Are you sure you wish to disconnect this Prayer Letters account?', + ), + ).toBeInTheDocument(); + }); + + userEvent.click( + getByRole('button', { + name: /confirm/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX removed your integration with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'DeletePrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + + userEvent.click( + getByRole('button', { + name: /sync now/i, + }), + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX is now syncing your newsletter recipients with Prayer Letters', + { + variant: 'success', + }, + ); + expect(mutationSpy.mock.calls[3][0].operation.operationName).toEqual( + 'SyncPrayerlettersAccount', + ); + expect(mutationSpy.mock.calls[3][0].operation.variables.input).toEqual({ + accountListId: accountListId, + }); + }); + }); + }); +}); diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx new file mode 100644 index 000000000..bc028c32e --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccordian.tsx @@ -0,0 +1,203 @@ +import { useState, useContext, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import { Box, Typography, Skeleton, Alert, Button } from '@mui/material'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { StyledFormLabel } from 'src/components/Shared/Forms/Field'; +import { + IntegrationsContext, + IntegrationsContextType, +} from 'pages/accountLists/[accountListId]/settings/integrations.page'; +import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionItem'; +import { StyledServicesButton, AccordianProps } from '../integrationsHelper'; +import { + useGetPrayerlettersAccountQuery, + useSyncPrayerlettersAccountMutation, +} from './PrayerlettersAccount.generated'; +import { DeletePrayerlettersAccountModal } from './Modals/DeletePrayerlettersModal'; + +export const PrayerlettersAccordian: React.FC = ({ + handleAccordionChange, + expandedPanel, +}) => { + const { t } = useTranslation(); + const [oAuth, setOAuth] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + showDeleteModal; + const { enqueueSnackbar } = useSnackbar(); + const { apiToken } = useContext( + IntegrationsContext, + ) as IntegrationsContextType; + const accountListId = useAccountListId(); + const accordianName = t('prayerletters.com'); + const [syncPrayerlettersAccount] = useSyncPrayerlettersAccountMutation(); + const { + data, + loading, + refetch: refetchPrayerlettersAccount, + } = useGetPrayerlettersAccountQuery({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + skip: expandedPanel !== accordianName, + }); + + const prayerlettersAccount = data?.getPrayerlettersAccount + ? data?.getPrayerlettersAccount[0] + : null; + + useEffect(() => { + setOAuth( + `${ + process.env.OAUTH_URL + }/auth/user/prayer_letters?account_list_id=${accountListId}&redirect_to=${window.encodeURIComponent( + `${window.location.origin}/accountLists/${accountListId}/settings/integrations?selectedTab=prayerletters.com`, + )}&access_token=${apiToken}`, + ); + }, []); + + const handleSync = async () => { + setIsSaving(true); + + await syncPrayerlettersAccount({ + variables: { + input: { + accountListId: accountListId ?? '', + }, + }, + onError: () => { + enqueueSnackbar( + t("MPDX couldn't save your configuration changes for Prayer Letters"), + { + variant: 'error', + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'MPDX is now syncing your newsletter recipients with Prayer Letters', + ), + { + variant: 'success', + }, + ); + }, + }); + + setIsSaving(false); + }; + + const handleDeleteModal = () => { + setShowDeleteModal(false); + }; + + return ( + + } + > + {loading && } + {!loading && !prayerlettersAccount && ( + <> + {t('PrayerLetters.com Overview')} + + {t(`prayerletters.com is a significant way to save valuable ministry + time while more effectively connecting with your partners. Keep your + physical newsletter list up to date in MPDX and then sync it to your + prayerletters.com account with this integration.`)} + + + {t(`By clicking "Connect prayerletters.com Account" you will + replace your entire prayerletters.com list with what is in MPDX. Any + contacts or information that are in your current prayerletters.com + list that are not in MPDX will be deleted. We strongly recommend + only making changes in MPDX.`)} + + + {t('Connect prayerletters.com Account')} + + + )} + {!loading && prayerlettersAccount && !prayerlettersAccount?.validToken && ( + <> + + {t( + 'The link between MPDX and your prayerletters.com account stopped working. Click "Refresh prayerletters.com Account" to re-enable it.', + )} + + + + + + + + + )} + {!loading && prayerlettersAccount && prayerlettersAccount?.validToken && ( + <> + + + {t( + `By clicking "Sync Now" you will replace your entire prayerletters.com list with what is in MPDX. + Any contacts or information that are in your current prayerletters.com list that are not in MPDX + will be deleted.`, + )} + + + {t('We strongly recommend only making changes in MPDX.')} + + + + + + + + + + )} + {showDeleteModal && ( + + )} + + ); +}; diff --git a/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql new file mode 100644 index 000000000..23c367da3 --- /dev/null +++ b/src/components/Settings/integrations/Prayerletters/PrayerlettersAccount.graphql @@ -0,0 +1,13 @@ +query GetPrayerlettersAccount($input: PrayerlettersAccountInput!) { + getPrayerlettersAccount(input: $input) { + validToken + } +} + +mutation SyncPrayerlettersAccount($input: SyncPrayerlettersAccountInput!) { + syncPrayerlettersAccount(input: $input) +} + +mutation DeletePrayerlettersAccount($input: DeletePrayerlettersAccountInput!) { + deletePrayerlettersAccount(input: $input) +} diff --git a/src/components/Settings/integrations/integrationsHelper.ts b/src/components/Settings/integrations/integrationsHelper.ts new file mode 100644 index 000000000..14918131a --- /dev/null +++ b/src/components/Settings/integrations/integrationsHelper.ts @@ -0,0 +1,20 @@ +import { styled } from '@mui/material/styles'; +import { Button, List, ListItemText } from '@mui/material'; + +export const StyledListItem = styled(ListItemText)(() => ({ + display: 'list-item', +})); + +export const StyledList = styled(List)(({ theme }) => ({ + listStyleType: 'disc', + paddingLeft: theme.spacing(4), +})); + +export const StyledServicesButton = styled(Button)(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +export interface AccordianProps { + handleAccordionChange: (panel: string) => void; + expandedPanel: string; +} diff --git a/src/components/Settings/notifications/GetNotifications.graphql b/src/components/Settings/notifications/GetNotifications.graphql new file mode 100644 index 000000000..1b4814436 --- /dev/null +++ b/src/components/Settings/notifications/GetNotifications.graphql @@ -0,0 +1,13 @@ +query getPreferencesNotifications($accountListId: ID!) { + notificationPreferences(accountListId: $accountListId) { + nodes { + app + email + notificationType { + descriptionTemplate + type + } + task + } + } +} diff --git a/src/components/Settings/notifications/NotificationsTable.test.tsx b/src/components/Settings/notifications/NotificationsTable.test.tsx new file mode 100644 index 000000000..09a188306 --- /dev/null +++ b/src/components/Settings/notifications/NotificationsTable.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NotificationsTable } from './NotificationsTable'; +import { ThemeProvider } from '@mui/material/styles'; +import { SnackbarProvider } from 'notistack'; +import { GqlMockedProvider } from '../../../../__tests__/util/graphqlMocking'; +import TestRouter from '../../../../__tests__/util/TestRouter'; +import theme from '../../../../src/theme'; +import { NotificationTypeTypeEnum } from '../../../../graphql/types.generated'; + +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 accountListId = 'test121'; + +const router = { + query: { accountListId }, + isReady: true, +}; +const createNotification = (type) => ({ + app: false, + email: false, + task: false, + notificationType: { + descriptionTemplate: type, + type, + }, +}); +const mocks = { + getPreferencesNotifications: { + notificationPreferences: { + nodes: [ + createNotification(NotificationTypeTypeEnum.CallPartnerOncePerYear), + createNotification(NotificationTypeTypeEnum.LargerGift), + createNotification(NotificationTypeTypeEnum.LongTimeFrameGift), + ], + }, + }, +}; +const mutationSpy = jest.fn(); +const Components = ( + + + + + + + + + +); + +describe('NotificationsTable', () => { + beforeEach(() => { + mutationSpy.mockReset(); + }); + it('Should render the Table and request data', async () => { + const { getByTestId, queryByTestId, getByText } = render(Components); + + expect(getByTestId('skeleton-notifications')).toBeInTheDocument(); + + await waitFor(() => { + expect(queryByTestId('skeleton-notifications')).not.toBeInTheDocument(), + expect( + mutationSpy.mock.calls[0][0].operation.variables.accountListId, + ).toEqual(accountListId); + expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( + 'getPreferencesNotifications', + ); + }); + + await waitFor(() => { + expect(getByText('CALL_PARTNER_ONCE_PER_YEAR')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + getByTestId('CALL_PARTNER_ONCE_PER_YEAR-app-checkbox'), + ).not.toBeChecked(); + }); + + await waitFor(() => { + expect(getByText('LARGER_GIFT')).toBeInTheDocument(); + }); + }); + + it('Should select all', async () => { + const { queryByTestId, getByTestId, getAllByRole } = render(Components); + + await waitFor(() => + expect(queryByTestId('skeleton-notifications')).not.toBeInTheDocument(), + ); + expect(getAllByRole('checkbox')[0]).not.toBeChecked(); + expect(getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(getAllByRole('checkbox')[2]).not.toBeChecked(); + expect(getAllByRole('checkbox')[3]).not.toBeChecked(); + expect(getAllByRole('checkbox')[4]).not.toBeChecked(); + expect(getAllByRole('checkbox')[5]).not.toBeChecked(); + expect(getAllByRole('checkbox')[6]).not.toBeChecked(); + expect(getAllByRole('checkbox')[7]).not.toBeChecked(); + expect(getAllByRole('checkbox')[8]).not.toBeChecked(); + + // Select all app + userEvent.click(getByTestId('select-all-app')); + expect(getAllByRole('checkbox')[0]).toBeChecked(); + expect(getAllByRole('checkbox')[3]).toBeChecked(); + expect(getAllByRole('checkbox')[6]).toBeChecked(); + + // Select all email + userEvent.click(getByTestId('select-all-email')); + expect(getAllByRole('checkbox')[1]).toBeChecked(); + expect(getAllByRole('checkbox')[4]).toBeChecked(); + expect(getAllByRole('checkbox')[7]).toBeChecked(); + + // Select all tasks + userEvent.click(getByTestId('select-all-task')); + expect(getAllByRole('checkbox')[2]).toBeChecked(); + expect(getAllByRole('checkbox')[5]).toBeChecked(); + expect(getAllByRole('checkbox')[8]).toBeChecked(); + }); + + it('Should send data to server on submit', async () => { + const { queryByTestId, getByTestId, getByRole } = render(Components); + + await waitFor(() => + expect(queryByTestId('skeleton-notifications')).not.toBeInTheDocument(), + ); + // Select all app + userEvent.click(getByTestId('select-all-app')); + + userEvent.click( + getByRole('button', { + name: /save/i, + }), + ); + + await waitFor(() => { + // mutationSpy.mock.calls[1][0].operation.variables.input + expect(mutationSpy.mock.calls[1][0].operation.operationName).toEqual( + 'UpdateNotificationPreferences', + ); + + expect(mutationSpy.mock.calls[1][0].operation.variables.input).toEqual({ + accountListId: accountListId, + attributes: [ + { + app: true, + email: false, + task: false, + notificationType: 'CALL_PARTNER_ONCE_PER_YEAR', + }, + { + app: true, + email: false, + task: false, + notificationType: 'LARGER_GIFT', + }, + { + app: true, + email: false, + task: false, + notificationType: 'LONG_TIME_FRAME_GIFT', + }, + ], + }); + expect(mockEnqueue).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Settings/notifications/NotificationsTable.tsx b/src/components/Settings/notifications/NotificationsTable.tsx new file mode 100644 index 000000000..b8011d42b --- /dev/null +++ b/src/components/Settings/notifications/NotificationsTable.tsx @@ -0,0 +1,350 @@ +import * as yup from 'yup'; +import { Formik, FieldArray } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import React, { useState, ReactElement } from 'react'; +import TaskIcon from '@mui/icons-material/Task'; +import SmartphoneIcon from '@mui/icons-material/Smartphone'; +import EmailIcon from '@mui/icons-material/Email'; +import { styled } from '@mui/material/styles'; +import { + Box, + Checkbox, + TableContainer, + Table, + TableCell, + TableHead, + TableRow, + TableBody, + Paper, +} from '@mui/material'; +import * as Types from '../../../../graphql/types.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { SubmitButton } from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import { useGetPreferencesNotificationsQuery } from './GetNotifications.generated'; +import { NotificationsTableSkeleton } from './NotificationsTableSkeleton'; +import { useUpdateNotificationPreferencesMutation } from './UpdateNotifications.generated'; + +export enum notificationsEnum { + App = 'app', + Email = 'email', + Task = 'task', +} + +export const StyledTableHeadCell = styled(TableCell)(({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, +})); + +export const StyledTableHeadSelectCell = styled(TableCell)(() => ({ + cursor: 'pointer', + fontSize: 14, + paddingTop: 8, + paddingBottom: 8, + top: 88, +})); + +export const StyledTableCell = styled(TableCell)(() => ({ + fontSize: 14, + paddingTop: 8, + paddingBottom: 8, +})); + +export const StyledTableRow = styled(TableRow)(({ theme }) => ({ + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.action.hover, + }, + // hide last border + '&:last-child td, &:last-child th': { + border: 0, + }, +})); + +export const StyledSmartphoneIcon = styled(SmartphoneIcon)(() => ({ + marginRight: '8px', +})); +export const StyledEmailIcon = styled(EmailIcon)(() => ({ + marginRight: '6px', +})); +export const StyledTaskIcon = styled(TaskIcon)(() => ({ + marginRight: '3px', +})); + +export const SelectAllBox = styled(Box)(() => ({ + width: 120, + margin: '0 0 0 auto', +})); + +export const NotificationsTable: React.FC = () => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const [appSelectAll, setAppSelectAll] = useState(false); + const [emailSelectAll, setEmailSelectAll] = useState(false); + const [taskSelectAll, setTaskSelectAll] = useState(false); + const [updateNotifications] = useUpdateNotificationPreferencesMutation(); + + const NotificationSchema: yup.SchemaOf<{ + notifications: Array< + Pick & { + notificationType: Pick< + Types.NotificationType, + 'descriptionTemplate' | 'type' + >; + } + >; + }> = yup.object({ + notifications: yup.array( + yup.object({ + app: yup.boolean().required(), + email: yup.boolean().required(), + task: yup.boolean().required(), + notificationType: yup.object({ + descriptionTemplate: yup.string().required(), + type: yup + .mixed() + .oneOf(Object.values(Types.NotificationTypeTypeEnum)) + .required(), + }), + }), + ), + }); + + const { data, loading } = useGetPreferencesNotificationsQuery({ + variables: { + accountListId: accountListId ?? '', + }, + }); + + const selectAll = ( + type, + notifications, + setFieldValue, + selectAll, + setSelectAll, + ) => { + setSelectAll(!selectAll); + notifications.forEach((_, idx) => { + setFieldValue(`notifications.${idx}.${type}`, !selectAll); + }); + }; + + const onSubmit = async ({ + notifications, + }: { + notifications: Array< + Pick & { + notificationType: Pick< + Types.NotificationType, + 'descriptionTemplate' | 'type' + >; + } + >; + }) => { + const attributes = notifications.map((notification) => { + return { + app: notification.app, + email: notification.email, + task: notification.task, + notificationType: notification.notificationType.type, + }; + }); + + await updateNotifications({ + variables: { + input: { + accountListId: accountListId ?? '', + attributes, + }, + }, + }); + + enqueueSnackbar(t('Notifications updated successfully'), { + variant: 'success', + }); + }; + + return ( + + {loading && } + {!loading && ( + + {({ + values: { notifications }, + handleSubmit, + setFieldValue, + isSubmitting, + isValid, + }): ReactElement => ( +
+ + ( + + + + + {t( + "Select the types of notifications you'd like to receive", + )} + + + + {t('In App')} + + + + {t('Email')} + + + + {t('Task')} + + + + + + selectAll( + notificationsEnum.App, + notifications, + setFieldValue, + appSelectAll, + setAppSelectAll, + ) + } + > + + {appSelectAll + ? t('deselect all') + : t('select all')} + + + + selectAll( + notificationsEnum.Email, + notifications, + setFieldValue, + emailSelectAll, + setEmailSelectAll, + ) + } + > + + {emailSelectAll + ? t('deselect all') + : t('select all')} + + + + selectAll( + notificationsEnum.Task, + notifications, + setFieldValue, + taskSelectAll, + setTaskSelectAll, + ) + } + > + + {taskSelectAll + ? t('deselect all') + : t('select all')} + + + + + + + <> + {notifications.map((notification, idx) => { + const { type, descriptionTemplate } = + notification.notificationType; + return ( + + + {descriptionTemplate} + + + { + setFieldValue( + `notifications.${idx}.app`, + value, + ); + }} + /> + + + { + setFieldValue( + `notifications.${idx}.email`, + value, + ); + }} + /> + + + { + setFieldValue( + `notifications.${idx}.task`, + value, + ); + }} + /> + + + ); + })} + + +
+ )} + /> +
+ + + {t('Save')} + + +
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/Settings/notifications/NotificationsTableSkeleton.tsx b/src/components/Settings/notifications/NotificationsTableSkeleton.tsx new file mode 100644 index 000000000..7e9959eb6 --- /dev/null +++ b/src/components/Settings/notifications/NotificationsTableSkeleton.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { + Box, + TableContainer, + Table, + TableHead, + TableRow, + TableBody, + Paper, + Skeleton, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + StyledTableHeadCell, + StyledTableHeadSelectCell, + StyledTableCell, + StyledTableRow, + StyledSmartphoneIcon, + StyledEmailIcon, + StyledTaskIcon, + SelectAllBox, +} from './NotificationsTable'; + +export const NotificationsTableSkeleton: React.FC = () => { + const { t } = useTranslation(); + + return ( + + + + + + {t("Select the types of notifications you'd like to receive")} + + + + {t('In App')} + + + + {t('Email')} + + + + {t('Task')} + + + + + + {t('select all')} + + + {t('select all')} + + + {t('select all')} + + + + + + {new Array(5).fill(0).map((_, idx) => ( + + + + + + + + + + + + + + + ))} + +
+
+ ); +}; diff --git a/src/components/Settings/notifications/UpdateNotifications.graphql b/src/components/Settings/notifications/UpdateNotifications.graphql new file mode 100644 index 000000000..ca7130898 --- /dev/null +++ b/src/components/Settings/notifications/UpdateNotifications.graphql @@ -0,0 +1,11 @@ +mutation UpdateNotificationPreferences( + $input: NotificationPreferencesUpdateMutationInput! +) { + updateNotificationPreferences(input: $input) { + notificationPreferences { + notificationType { + id + } + } + } +} diff --git a/src/components/Settings/preferences/DemoContent.tsx b/src/components/Settings/preferences/DemoContent.tsx new file mode 100644 index 000000000..fe88265e9 --- /dev/null +++ b/src/components/Settings/preferences/DemoContent.tsx @@ -0,0 +1,285 @@ +export const info = { + id: '1', + alma_mater: 'Sac State', + anniversary_day: 18, + anniversary_month: 10, + anniversary_year: 1928, + avatar: + 'https://lumiere-a.akamaihd.net/v1/images/ct_mickeymouseandfriends_mickey_ddt-16970_4e99445d.jpeg?region=0,0,600,600&width=480', + birthday_day: 7, + birthday_month: 10, + birthday_year: 1983, + email: [ + { + value: 'personal@test.com', + type: 'personal', + primary: false, + invalid: true, + }, + { + value: 'work@test.com', + type: 'work', + primary: true, + invalid: false, + }, + ], + employer: 'Disney', + facebook_accounts: ['CruGlobal', 'jplastow2'], + family_relationships: [ + { + name: 'Minnie', + relation: 'Wife', + }, + { + name: 'Goofy', + relation: 'Brother', + }, + ], + first_name: 'Mickey', + gender: 'male', + last_name: 'Mouse', + legal_first_name: 'Michaelangelo', + linkedin_accounts: ['https://www.linkedin.com/company/cru-global/'], + marital_status: 'Married', + middle_name: '', + occupation: 'Head Mouse', + parent_contacts: null, + phone: [ + { + value: '1234567890', + type: 'home', + primary: false, + invalid: false, + }, + { + value: '0987654321', + type: 'mobile', + primary: true, + invalid: false, + }, + ], + preferences: null, + suffix: 'Sr.', + title: 'Mr.', + twitter_accounts: ['CruTweets'], + websites: ['https://cru.org'], +}; + +export const profile2 = { + emailAddresses: { + nodes: [ + { + email: 'test1234@test.com', + primary: true, + historic: false, + location: 'Work', + source: 'MPDX', + id: '1', + }, + { + email: 'secondemail@test.com', + location: 'Personal', + primary: false, + historic: false, + source: 'MPDX', + id: '2', + }, + ], + }, + phoneNumbers: { + nodes: [ + { + number: '777-777-7777', + location: 'Mobile', + primary: true, + historic: false, + source: 'MPDX', + id: '1', + }, + { + number: '999-999-9999', + location: 'Work', + primary: false, + historic: false, + source: 'MPDX', + id: '2', + }, + ], + }, + facebookAccounts: { + nodes: [ + { + username: 'test guy', + id: '1', + }, + { + username: 'test guy 2', + id: '2', + }, + ], + }, + twitterAccounts: { + nodes: [ + { + screenName: '@testguy', + id: '1', + }, + { + screenName: '@testguy2', + id: '2', + }, + ], + }, + linkedinAccounts: { + nodes: [ + { + publicUrl: 'Test Guy', + id: '1', + }, + { + publicUrl: 'Test Guy 2', + id: '2', + }, + ], + }, + websites: { + nodes: [ + { + url: 'testguy.com', + id: '1', + }, + { + url: 'testguy2.com', + id: '2', + }, + ], + }, + optoutEnewsletter: false, + anniversaryDay: 1, + anniversaryMonth: 1, + anniversaryYear: 1990, + birthdayDay: 1, + birthdayMonth: 1, + birthdayYear: 1990, + maritalStatus: 'Engaged', + gender: 'Male', + deceased: false, + id: '01', + firstName: 'Jack', + lastName: 'Sparrow', + title: 'Mr.', + suffix: 'Sr.', + avatar: '', + legalFirstName: '', + almaMater: '', + employer: '', + occupation: '', +}; + +export const language = [ + ['en-US', 'US English'], + ['ar', 'Arabic (العربية)'], + ['hy', 'Armenian'], + ['my', 'Myanmar Language'], + ['zh-Hans', 'Simplified Chinese (简体中文)'], + ['nl', 'Dutch (Nederlands)'], + ['fr-ca', 'Canadian French (français canadien)'], + ['fr', 'French (français)'], + ['de', 'German (Deutsch)'], + ['gsw', 'Swiss High German (Schweizer Hochdeutsch)'], + ['id', 'Indonesian (Indonesia)'], + ['it', 'Italian (italiano)'], + ['ko', 'Korean (한국어)'], + ['pl', 'Polish (polski)'], + ['pt-br', 'Brazilian Portuguese (português do Brasil)'], + ['ru', 'Russian (русский)'], + ['es-419', 'Latin American Spanish (español latinoamericano)'], + ['th', 'Thai (ไทย)'], + ['tr', 'Turkish (Türkçe)'], + ['uk', 'Ukrainian (українська)'], + ['vi', 'Vietnamese (Tiếng Việt)'], +]; + +export const localeOptions = [ + ['af', 'Afrikaans (af) (Afrikaans - af)'], + ['sq', 'Albanian (sq) (shqip - sq)'], + ['ar', 'Arabic (ar) (العربية - ar)'], + ['en-AU', 'Australian English (en-AU) (Australian English - en-au)'], + ['eu', 'Basque (eu) (euskara - eu)'], + ['be', 'Belarusian (be) (беларуская - be)'], + ['bn', 'Bengali (bn) (বাংলা - bn)'], + ['bg', 'Bulgarian (bg) (български - bg)'], + ['en-CA', 'Canadian English (en-CA) (Canadian English - en-ca)'], + ['fr-CA', 'Canadian French (fr-CA) (français canadien - fr-ca)'], + ['ca', 'Catalan (ca) (català - ca)'], + ['zh', 'Chinese (zh) (中文 - zh)'], + ['hr', 'Croatian (hr) (hrvatski - hr)'], + ['cs', 'Czech (cs) (čeština - cs)'], + ['da', 'Danish (da) (dansk - da)'], + ['nl', 'Dutch (nl) (Nederlands - nl)'], + ['en', 'English (en) (English - en)'], + ['fil', 'Filipino (fil) (Filipino - fil)'], + ['fi', 'Finnish (fi) (suomi - fi)'], + ['fr', 'French (fr) (français - fr)'], + ['gl', 'Galician (gl) (galego - gl)'], + ['de', 'German (de) (Deutsch - de)'], + ['el', 'Greek (el) (Ελληνικά - el)'], + ['gu', 'Gujarati (gu) (ગુજરાતી - gu)'], + ['he', 'Hebrew (he) (עברית - he)'], + ['hi', 'Hindi (hi) (हिन्दी - hi)'], + ['hu', 'Hungarian (hu) (magyar - hu)'], + ['is', 'Icelandic (is) (íslenska - is)'], + ['id', 'Indonesian (id) (Indonesia - id)'], + ['ga', 'Irish (ga) (Gaeilge - ga)'], + ['it', 'Italian (it) (italiano - it)'], + ['ja', 'Japanese (ja) (日本語 - ja)'], + ['kn', 'Kannada (kn) (ಕನ್ನಡ - kn)'], + ['ko', 'Korean (ko) (한국어 - ko)'], + [ + 'es-419', + 'Latin American Spanish (es-419) (español latinoamericano - es-419)', + ], + ['lv', 'Latvian (lv) (latviešu - lv)'], + ['ms', 'Malay (ms) (Bahasa Melayu - ms)'], + ['mr', 'Marathi (mr) (मराठी - mr)'], + ['es-MX', 'Mexican Spanish (es-MX) (español de México - es-mx)'], + ['nb', 'Norwegian Bokmål (nb) (norsk bokmål - nb)'], + ['fa', 'Persian (fa) (فارسی - fa)'], + ['pl', 'Polish (pl) (polski - pl)'], + ['pt', 'Portuguese (pt) (português - pt)'], + ['ro', 'Romanian (ro) (română - ro)'], + ['ru', 'Russian (ru) (русский - ru)'], + ['sr', 'Serbian (sr) (српски - sr)'], + ['sk', 'Slovak (sk) (slovenčina - sk)'], + ['sl', 'Slovenian (sl) (slovenščina - sl)'], + ['es', 'Spanish (es) (español - es)'], + ['sv', 'Swedish (sv) (svenska - sv)'], + ['fr-CH', 'Swiss French (fr-CH) (français suisse - fr-ch)'], + ['de-CH', 'Swiss High German (de-CH) (Schweizer Hochdeutsch - de-ch)'], + ['ta', 'Tamil (ta) (தமிழ் - ta)'], + ['th', 'Thai (th) (ไทย - th)'], + ['bo', 'Tibetan (bo) (བོད་སྐད་ - bo)'], + ['zh-Hant', 'Traditional Chinese (zh-Hant) (繁體中文 - zh-hant)'], + ['tr', 'Turkish (tr) (Türkçe - tr)'], + ['en-GB', 'UK English (en-GB) (UK English - en-gb)'], + ['uk', 'Ukrainian (uk) (українська - uk)'], + ['ur', 'Urdu (ur) (اردو - ur)'], + ['vi', 'Vietnamese (vi) (Tiếng Việt - vi)'], + ['cy', 'Welsh (cy) (Cymraeg - cy)'], +]; + +export const options = [ + ['opt1', 'Option 1'], + ['opt2', 'Option 2'], + ['opt3', 'Option 3'], + ['opt4', 'Option 4'], + ['opt5', 'Option 5'], +]; + +export const options2 = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, + { label: 'Option 4', value: 'opt4' }, + { label: 'Option 5', value: 'opt5' }, +]; diff --git a/src/components/Settings/preferences/accordions/PreferencesGroup.tsx b/src/components/Settings/preferences/accordions/PreferencesGroup.tsx new file mode 100644 index 000000000..b2a536f61 --- /dev/null +++ b/src/components/Settings/preferences/accordions/PreferencesGroup.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +interface PreferencesGroupProps { + title: string; + children?: React.ReactNode; +} + +export const PreferencesGroup: React.FC = ({ + title, + children, +}) => { + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/Settings/preferences/accordions/PreferencesItem.tsx b/src/components/Settings/preferences/accordions/PreferencesItem.tsx new file mode 100644 index 000000000..d14478b4c --- /dev/null +++ b/src/components/Settings/preferences/accordions/PreferencesItem.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { ExpandMore } from '@mui/icons-material'; +import { accordionShared } from '../shared/PreferencesShared'; + +const StyledAccordion = styled(Accordion)(() => ({ + overflow: 'hidden', + ...accordionShared, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + '&.Mui-expanded': { + backgroundColor: theme.palette.mpdxYellow.main, + }, + '& .MuiAccordionSummary-content': { + [theme.breakpoints.only('xs')]: { + flexDirection: 'column', + }, + }, +})); + +const StyledAccordionColumn = styled(Box)(({ theme }) => ({ + paddingRight: theme.spacing(2), + flexBasis: '100%', + [theme.breakpoints.only('xs')]: { + '&:nth-child(2)': { + fontStyle: 'italic', + }, + }, + [theme.breakpoints.up('md')]: { + '&:first-child:not(:last-child)': { + flexBasis: '33.33%', + }, + '&:nth-child(2)': { + flexBasis: '66.66%', + }, + }, +})); + +const StyledAccordionDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + flexBasis: 'calc((100% - 36px) * 0.661)', + marginLeft: 'calc((100% - 36px) * 0.338)', + }, +})); + +interface PreferencesItemProps { + onAccordionChange: (label: string) => void; + expandedPanel: string; + label: string; + value: string; + children?: React.ReactNode; +} + +export const PreferencesItem: React.FC = ({ + onAccordionChange, + expandedPanel, + label, + value, + children, +}) => { + return ( + onAccordionChange(label)} + expanded={expandedPanel === label} + disableGutters + > + }> + + {label} + + {value !== '' && ( + + {value} + + )} + + + {children} + + + ); +}; diff --git a/src/components/Settings/preferences/forms/PreferencesFieldWrapper.tsx b/src/components/Settings/preferences/forms/PreferencesFieldWrapper.tsx new file mode 100644 index 000000000..d0168804a --- /dev/null +++ b/src/components/Settings/preferences/forms/PreferencesFieldWrapper.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { + FormControl, + FormControlProps, + FormHelperText, + FormHelperTextProps, + FormLabel, + FormLabelProps, +} from '@mui/material'; + +interface PersPrefFieldWrapperProps { + children?: FormControlProps['children']; + disabled?: FormControlProps['disabled']; + error?: FormControlProps['error']; + fullWidth?: FormControlProps['fullWidth']; + helperPosition?: 'top' | 'bottom'; + helperText?: FormHelperTextProps['children']; + label?: FormLabelProps['children']; + required?: FormControlProps['required']; +} + +export const PersPrefFieldWrapper: React.FC = ({ + children, + disabled = false, + error = false, + fullWidth = true, + helperPosition = 'top', + helperText = '', + label = '', + required = false, +}) => { + const HelperText = () => {helperText}; + + return ( + + {/* Label */} + {label && ( + + {label} + + )} + + {/* Helper text - top */} + {helperText && helperPosition === 'top' && } + + {children} + + {/* Helper text - bottom */} + {helperText && helperPosition === 'bottom' && } + + ); +}; diff --git a/src/components/Settings/preferences/forms/PreferencesFormWrapper.tsx b/src/components/Settings/preferences/forms/PreferencesFormWrapper.tsx new file mode 100644 index 000000000..1647ed9f0 --- /dev/null +++ b/src/components/Settings/preferences/forms/PreferencesFormWrapper.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +interface PersPrefFormWrapperProps { + formAttrs?: { action?: string; method?: string }; + children: React.ReactNode; +} + +export const PersPrefFormWrapper: React.FC = ({ + formAttrs = {}, + children, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( +
+ {children} + +
+ ); +}; diff --git a/src/components/Settings/preferences/forms/PreferencesInput.tsx b/src/components/Settings/preferences/forms/PreferencesInput.tsx new file mode 100644 index 000000000..c25cd1fef --- /dev/null +++ b/src/components/Settings/preferences/forms/PreferencesInput.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { TextField, TextFieldProps } from '@mui/material'; + +export interface PersPrefInputProps { + children?: TextFieldProps['children']; + disabled?: TextFieldProps['disabled']; + error?: TextFieldProps['error']; + fullWidth?: TextFieldProps['fullWidth']; + helperText?: TextFieldProps['helperText']; + label?: TextFieldProps['label']; + required?: TextFieldProps['required']; + select?: TextFieldProps['select']; + value?: TextFieldProps['value']; +} + +export const PersPrefInput: React.FC = ({ + children, + disabled = false, + error = false, + fullWidth = true, + helperText = '', + label = '', + required = false, + select = false, + value = '', +}) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Settings/preferences/forms/PreferencesSelect.tsx b/src/components/Settings/preferences/forms/PreferencesSelect.tsx new file mode 100644 index 000000000..01f09ef7f --- /dev/null +++ b/src/components/Settings/preferences/forms/PreferencesSelect.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { MenuItem } from '@mui/material'; +import { PersPrefInput, PersPrefInputProps } from './PreferencesInput'; + +interface PersPrefSelectProps extends PersPrefInputProps { + selectOptions: Array<{ label: string; value: string }>; +} + +export const PersPrefSelect: React.FC = ({ + disabled = false, + error = false, + fullWidth = true, + helperText = '', + label = '', + required = false, + value = '', + selectOptions = [], +}) => { + return ( + + {selectOptions.map((option) => ( + + {option.label} + + ))} + + ); +}; diff --git a/src/components/Settings/preferences/info/PreferencesAnniversary.tsx b/src/components/Settings/preferences/info/PreferencesAnniversary.tsx new file mode 100644 index 000000000..6b7208b66 --- /dev/null +++ b/src/components/Settings/preferences/info/PreferencesAnniversary.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@mui/material'; +import { Info } from 'luxon'; + +interface AnniversaryProps { + marital_status: string; + anniversary_day: number; + anniversary_month: number; + anniversary_year: number; +} + +export const PersPrefAnniversary: React.FC = ({ + marital_status, + anniversary_day, + anniversary_month, + anniversary_year, +}) => { + const { t } = useTranslation(); + const anniversary = Boolean( + anniversary_month || anniversary_day || anniversary_year, + ); + + // Status string + let statusOutput = marital_status + ? marital_status + : anniversary + ? t('Anniversary') + : ''; + statusOutput += anniversary ? ': ' : ''; + + // Anniversary string + let dateOutput = ''; + if (anniversary) { + // Month + dateOutput += anniversary_month + ? Info.monthsFormat('short')[anniversary_month] + ' ' + : ''; + + // Day + dateOutput += anniversary_day ? anniversary_day : ''; + + // Spacer before year + dateOutput += + anniversary_month && anniversary_day && anniversary_year ? ', ' : ' '; + + // Year + dateOutput += anniversary_year ? anniversary_year : ''; + } + + if (marital_status || anniversary) { + return ( + + {statusOutput} + {dateOutput} + + ); + } + + return null; +}; diff --git a/src/components/Settings/preferences/info/PreferencesContactMethods.tsx b/src/components/Settings/preferences/info/PreferencesContactMethods.tsx new file mode 100644 index 000000000..804bc188a --- /dev/null +++ b/src/components/Settings/preferences/info/PreferencesContactMethods.tsx @@ -0,0 +1,118 @@ +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Link, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { ExpandMore } from '@mui/icons-material'; +import { accordionShared } from '../shared/PreferencesShared'; + +const StyledAccordion = styled(Accordion)({ + boxShadow: 'none', + '&.Mui-expanded': { + margin: 0, + }, + ...accordionShared, +}); + +const StyledAccordionSummary = styled(AccordionSummary)({ + display: 'inline-block', + padding: 0, + minHeight: 'unset', + paddingRight: 24, + position: 'relative', + '& .MuiAccordionSummary-content': { + flexGrow: 'unset', + margin: '0 !important', + }, + '& .MuiAccordionSummary-expandIconWrapper': { + position: 'absolute', + top: 0, + right: 0, + }, +}); + +const StyledAccordionDetails = styled(AccordionDetails)({ + padding: 0, +}); + +interface ContactMethodData { + value: string; + type: string; + primary: boolean; + invalid: boolean; +} + +// Single contact phone/email + +interface PersPrefContactMethodProps { + type: string; + method: ContactMethodData; +} + +const PersPrefContactMethod: React.FC = ({ + type, + method, +}) => { + const { t } = useTranslation(); + const prefix = type === 'email' ? 'mailto' : 'tel'; + const value = method.value; + + return ( + + {value}{' '} + - {t(method.type)} + + ); +}; + +// List of phone/email contacts + +interface PersPrefContactMethodsProps { + type: string; + methods: ContactMethodData[]; +} + +export const PersPrefContactMethods = ({ + type, + methods, +}: PersPrefContactMethodsProps): ReactElement => { + const validMethods = methods.filter((method) => method.invalid !== true); + const validMethodsPrimary = validMethods.filter( + (method) => method.primary === true, + ); + const validMethodsSecondary = validMethods.filter( + (method) => method.primary === false, + ); + + return ( + <> + {validMethods.length === 1 && ( + + )} + {validMethods.length > 1 && ( + + }> + + + + {validMethodsSecondary.map((contact) => ( + + ))} + + + )} + + ); +}; diff --git a/src/components/Settings/preferences/info/PreferencesSocials.tsx b/src/components/Settings/preferences/info/PreferencesSocials.tsx new file mode 100644 index 000000000..6678a76eb --- /dev/null +++ b/src/components/Settings/preferences/info/PreferencesSocials.tsx @@ -0,0 +1,67 @@ +import React, { ReactNode } from 'react'; +import { Box, IconButton, useMediaQuery } from '@mui/material'; +import { Theme } from '@mui/material/styles'; +import { Facebook, Language, LinkedIn, Twitter } from '@mui/icons-material'; + +interface SocialProps { + accounts: string[]; + icon: ReactNode; + url?: string; +} + +const SocialLinks: React.FC = ({ accounts, icon, url = '' }) => ( + <> + {accounts.map((account) => ( + + {icon} + + ))} + +); + +interface SocialMediaProps { + facebook_accounts: string[]; + twitter_accounts: string[]; + linkedin_accounts: string[]; + websites: string[]; +} + +export const PersPrefSocials: React.FC = ({ + facebook_accounts, + twitter_accounts, + linkedin_accounts, + websites, +}) => { + const isMobile = useMediaQuery((theme: Theme) => + theme.breakpoints.only('xs'), + ); + + return ( + + } + url="https://www.facebook.com/" + /> + } + url="https://www.twitter.com/" + /> + } /> + } /> + + ); +}; diff --git a/src/components/Settings/preferences/info/ProfileInfo.tsx b/src/components/Settings/preferences/info/ProfileInfo.tsx new file mode 100644 index 000000000..4372e18c4 --- /dev/null +++ b/src/components/Settings/preferences/info/ProfileInfo.tsx @@ -0,0 +1,193 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Avatar, + Box, + Button, + Typography, + useMediaQuery, + Link, +} from '@mui/material'; +import { Theme, styled, useTheme } from '@mui/material/styles'; +import { Edit } from '@mui/icons-material'; +import { profile2 } from '../DemoContent'; +//import { PersPrefModal } from '../modals/PreferencesModal'; +// import { PersPrefContactMethods } from './PreferencesContactMethods'; +// import { PersPrefAnniversary } from './PreferencesAnniversary'; +// import { PersPrefSocials } from './PreferencesSocials'; +import { PersonModal } from 'src/components/Contacts/ContactDetails/ContactDetailsTab/People/Items/PersonModal/PersonModal'; +// import { ProfileModal } from 'src/components/Modals/ProfileModal/ProfileModal'; +import Email from '@mui/icons-material/Email'; +import Phone from '@mui/icons-material/Phone'; +//import { ContactDetailsTabQuery } from 'src/components/Contacts/ContactDetails/ContactDetailsTab/ContactDetailsTab.generated'; + +const ProfileInfoWrapper = styled(Box)(({ theme }) => ({ + textAlign: 'center', + [theme.breakpoints.up('sm')]: { + position: 'relative', + textAlign: 'left', + paddingLeft: theme.spacing(14), + }, +})); + +const StyledAvatar = styled(Avatar)(({ theme }) => ({ + width: theme.spacing(12), + height: theme.spacing(12), + marginLeft: 'auto', + marginRight: 'auto', + marginBottom: theme.spacing(1), + [theme.breakpoints.up('sm')]: { + position: 'absolute', + top: 0, + left: 0, + }, +})); + +const StyledContactEdit = styled(Button)(({ theme }) => ({ + marginTop: theme.spacing(2), + [theme.breakpoints.up('sm')]: { + position: 'absolute', + bottom: 0, + right: 0, + }, +})); + +const ContactPersonRowContainer = styled(Box)(({ theme }) => ({ + margin: theme.spacing(1), + display: 'flex', + alignItems: 'center', +})); + +const ContactPersonIconContainer = styled(Box)(() => ({ + width: '18px', + height: '18px', + marginRight: '15px', +})); + +interface ProfileInfoProps { + //profile: ContactDetailsTabQuery['contact']['people']['nodes'][0]; + accountListId: string; +} + +export const ProfileInfo: React.FC = ({ accountListId }) => { + const { t } = useTranslation(); + const profile = profile2; + const theme = useTheme(); + const isMobile = useMediaQuery((theme: Theme) => + theme.breakpoints.down('sm'), + ); + + const [editProfileModalOpen, setEditProfileModalOpen] = useState(false); + + const primaryPhone = profile.phoneNumbers.nodes.filter( + (item) => item.primary === true, + )[0]; + + const primaryEmail = profile.emailAddresses.nodes.filter( + (item) => item.primary === true, + )[0]; + + // const handleOpen = () => { + // setEditProfileModalOpen(true); + // }; + + return ( + + + {/* Avatar */} + + + {/* Name */} + + {profile.title} {profile.firstName} {profile.lastName}{' '} + {profile.suffix} + + + {/* Work */} + {(profile?.occupation || profile?.employer) && ( + + {`${profile.occupation} ${ + profile.occupation && profile.employer ? '-' : '' + } ${profile.employer}`} + + )} + + + {/* Email */} + {/* */} + + {/* Phone Number */} + {primaryPhone !== null ? ( + + + + + + + {primaryPhone?.number} + + + {primaryPhone?.location ? ( + + {t(primaryPhone.location)} + + ) : null} + + ) : null} + {/* Email Section */} + {primaryEmail !== null ? ( + + + + + + + {primaryEmail?.email} + + + + ) : null} + + {/* Phone */} + {/* */} + + {/* Anniversay */} + {/* */} + + {/* Social Media */} + {/* */} + + {/* Edit Info Button */} + setEditProfileModalOpen(true)} + startIcon={} + variant="outlined" + > + {t('Edit')} + + + {/* Edit Info Modal */} + {editProfileModalOpen ? ( + setEditProfileModalOpen(false)} + userProfile={true} + contactId="" + /> + ) : null} + + ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModal.tsx b/src/components/Settings/preferences/modals/PreferencesModal.tsx new file mode 100644 index 000000000..d4e1cbb9b --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModal.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DialogActions, DialogContent, Tab } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import Modal from '../../../common/Modal/Modal'; +import { PersPrefModalContact } from './PreferencesModalContactInfo'; +import { PersPrefModalDetails } from './PreferencesModalDetails'; +import { PersPrefModalSocial } from './PreferencesModalSocial'; +import { PersPrefModalRelationships } from './PreferencesModalRelationships'; +import { PersPrefModalName } from './PreferencesModalName'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; + +const StyledTabList = styled(TabList)(({ theme }) => ({ + '& .MuiTabs-flexContainer > *': { + flexGrow: 1, + }, + '& .MuiTabs-indicator': { + backgroundColor: theme.palette.primary.main, + }, + [theme.breakpoints.down('xs')]: { + '& .MuiTabs-flexContainer': { display: 'block' }, + '& .MuiTabs-indicator': { display: 'none' }, + '& .MuiTab-root': { display: 'block', width: '100%', maxWidth: 'unset' }, + }, +})); + +const StyledTab = styled(Tab)(({ theme }) => ({ + fontSize: 16, + borderBottom: `${theme.palette.divider} 2px solid`, + '&.Mui-selected': { + color: theme.palette.primary.main, + }, +})); + +interface PersPrefModalProps { + handleClose: () => void; +} + +export const PersPrefModal: React.FC = ({ + handleClose, +}) => { + const { t } = useTranslation(); + const [openTab, setOpenTab] = useState('0'); + + const handleChange = ( + event: React.ChangeEvent>, + newValue: string, + ) => { + setOpenTab(newValue); + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {t('Save')} + +
+
+ ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModalContactInfo.tsx b/src/components/Settings/preferences/modals/PreferencesModalContactInfo.tsx new file mode 100644 index 000000000..99a788c69 --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalContactInfo.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Checkbox, + Grid, + Hidden, + MenuItem, + Radio, + Theme, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { AddCircle, Cancel, Check } from '@mui/icons-material'; +import { + PersPrefFieldWrapper, + StyledOutlinedInput, + StyledSelect, +} from '../shared/PreferencesForms'; +import { info } from '../DemoContent'; +import { + AddButtonBox, + DeleteButton, + EmptyIcon, + HiddenSmLabel, + OptionHeadings, + SectionHeading, + StyledDivider, + StyledGridContainer, + StyledGridItem, +} from './PreferencesModalShared'; + +const SharedFieldHoverStyles = ({ theme }: { theme: Theme }) => ({ + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, +}); + +const StyledRadio = styled(Radio)(SharedFieldHoverStyles); +const StyledCheckbox = styled(Checkbox)(SharedFieldHoverStyles); + +const GreenCheck = styled(Check)(({ theme }) => ({ + color: theme.palette.mpdxGreen.main, +})); + +const RedCancel = styled(Cancel)(({ theme }) => ({ + color: theme.palette.mpdxRed.main, +})); + +interface AddContactProps { + current?: { + value: string; + type: string; + primary: boolean; + invalid: boolean; + }; + isPhone: boolean; + type: string; + index?: number; +} + +const AddContact: React.FC = ({ + current, + isPhone, + type, + index, +}) => { + const { t } = useTranslation(); + + const contactTypes = isPhone + ? [ + ['mobile', 'Mobile'], + ['home', 'Home'], + ['work', 'Work'], + ['other', 'Other'], + ] + : [ + ['personal', 'Personal'], + ['work', 'Work'], + ['other', 'Other'], + ]; + + const value = current ? current.value : ''; + const category = current ? current.type : ''; + const primary = current ? current.primary : false; + const invalid = current ? current.invalid : false; + + return ( + + {/* Input field */} + + + + + + + {/* Contact category */} + + + + {contactTypes.map(([contactVal, contactLabel], index) => ( + + {t(contactLabel)} + + ))} + + + + + {/* Primary contact method selection */} + + {t('Primary')} + } + checkedIcon={} + checked={primary} + disableRipple + /> + + + {/* Inactive contact method */} + + {t('Invalid')} + } + checkedIcon={} + checked={invalid} + disableRipple + /> + + + {/* Delete contact method */} + + + + + ); +}; + +const ContactMethods: React.FC<{ type: string }> = ({ type }) => { + const { t } = useTranslation(); + const isPhone = type === 'phone' ? true : false; + const data = isPhone ? info.phone : info.email; + + data.sort((x, y) => { + return x.primary === true ? -1 : y.primary === true ? 1 : 0; + }); + + return ( + <> + + + + {isPhone ? t('Phone Numbers') : t('Email Addresses')} + + + + + {t('Type')} + + {t('Primary')} + {t('Invalid')} + {t('Delete')} + + + {data.map((current, index) => ( + + ))} + + + + + + ); +}; + +export const PersPrefModalContact: React.FC = () => { + return ( + <> + + + + + ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModalDetails.tsx b/src/components/Settings/preferences/modals/PreferencesModalDetails.tsx new file mode 100644 index 000000000..de65f5532 --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalDetails.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Grid, MenuItem } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Info } from 'luxon'; +import { + PersPrefFieldWrapper, + StyledOutlinedInput, + StyledSelect, +} from '../shared/PreferencesForms'; +import { info } from '../DemoContent'; +import { SectionHeading, StyledGridContainer } from './PreferencesModalShared'; + +const StyledGridContainerMobile = styled(Grid)(({ theme }) => ({ + [theme.breakpoints.down('xs')]: { + marginBottom: theme.spacing(1), + }, +})); + +interface DateSelectionProps { + month: number; + day: number; + year: number; +} + +const DateSelection: React.FC = ({ month, day, year }) => { + const { t } = useTranslation(); + + const months = Info.monthsFormat('long'); + + return ( + + + + + {months.map((current, index) => ( + + {t(current)} + + ))} + + + + + + + + + + + + + + + ); +}; + +export const PersPrefModalDetails: React.FC = () => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + Unspecified + Male + Female + + + + + {t('Birthday')} + + + + {t('Anniversary')} + + + + + + + + + ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModalName.tsx b/src/components/Settings/preferences/modals/PreferencesModalName.tsx new file mode 100644 index 000000000..9328fe084 --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalName.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Unstable_Grid2 as Grid } from '@mui/material'; +import { PersPrefInput } from '../forms/PreferencesInput'; +import { info } from '../DemoContent'; + +export const PersPrefModalName: React.FC = () => { + const { t } = useTranslation(); + + return ( + + {/* Title */} + + + + + {/* First name */} + + + + + {/* Last name */} + + + + + {/* Suffix */} + + + + + ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModalRelationships.tsx b/src/components/Settings/preferences/modals/PreferencesModalRelationships.tsx new file mode 100644 index 000000000..77f598eae --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalRelationships.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + DialogActions, + DialogContent, + Grid, + Hidden, + MenuItem, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { AddCircle, Search } from '@mui/icons-material'; +import Modal from '../../../common/Modal/Modal'; +import { + PersPrefFieldWrapper, + StyledOutlinedInput, + StyledSelect, +} from '../shared/PreferencesForms'; +import { info } from '../DemoContent'; +import { + AddButtonBox, + DeleteButton, + OptionHeadings, + SectionHeading, + StyledGridContainer, + StyledGridItem, +} from './PreferencesModalShared'; +import { + SubmitButton, + CancelButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; + +const AddRelationshipButton = styled(Button)({ + fontSize: 16, + padding: '17.5px 14px', + lineHeight: 1.1876, +}); + +interface RelationshipModalProps { + isOpen: boolean; + handleOpen: (val: boolean) => void; +} + +const RelationshipModal: React.FC = ({ + isOpen, + handleOpen, +}) => { + const { t } = useTranslation(); + + const handleClose = () => { + handleOpen(false); + }; + + return ( + +
+ + + } /> + + + + + {t('Save')} + +
+
+ ); +}; + +interface AddRelationshipProps { + relationship?: { + name: string; + relation: string; + }; +} + +const AddRelationship: React.FC = ({ + relationship = null, +}) => { + const { t } = useTranslation(); + const [relationshipOpen, setRelationshipOpen] = useState(false); + + const handleOpen = () => { + setRelationshipOpen(true); + }; + + const relations = [ + t('Husband'), + t('Son'), + t('Father'), + t('Brother'), + t('Uncle'), + t('Newphew'), + t('Cousin Male'), + t('Grandfather'), + t('Grandson'), + t('Wife'), + t('Daughter'), + t('Mother'), + t('Sister'), + t('Aunt'), + t('Niece'), + t('Cousin Female'), + t('Grandmother'), + t('Granddaughter'), + ]; + + return ( + + + {!relationship ? ( + <> + + Select Person + + + + ) : ( + + + + )} + + + + + {relations.map((relation) => ( + + {relation} + + ))} + + + + + + + + ); +}; + +const StyledGrid = styled(Grid)(({ theme }) => ({ + marginTop: theme.spacing(1), +})); + +export const PersPrefModalRelationships: React.FC = () => { + const { t } = useTranslation(); + + const statuses = [ + t('Single'), + t('Engaged'), + t('Married'), + t('Separated'), + t('Divorced'), + t('Widowed'), + ]; + + return ( + <> + + + + + + + + + + + + + + + {statuses.map((status) => ( + + {status} + + ))} + + + + + + + {t('Relationships')} + + + + {t('Type')} + + {t('Delete')} + + + {info.family_relationships.map((relationship, index) => ( + + ))} + + + + + + ); +}; diff --git a/src/components/Settings/preferences/modals/PreferencesModalShared.tsx b/src/components/Settings/preferences/modals/PreferencesModalShared.tsx new file mode 100644 index 000000000..2f8c7d2cf --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalShared.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Box, + Divider, + Grid, + GridProps, + Hidden, + IconButton, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Delete } from '@mui/icons-material'; + +export const SectionHeading = styled(Typography)(() => ({ + fontWeight: 700, + lineHeight: 1, + display: 'block', +})); + +const SmallColumnLabels = styled(Grid)(() => ({ + display: 'flex', + alignItems: 'flex-end', + '& span': { + fontSize: '0.6875em', + lineHeight: 1, + }, +})); + +interface OptionHeadingsProps { + smallCols: GridProps['sm']; + align?: GridProps['justifyContent']; + children?: React.ReactNode; +} + +export const OptionHeadings: React.FC = ({ + smallCols, + align = 'center', + children, +}) => ( + + {children} + +); + +export const EmptyIcon: React.FC<{ size?: number }> = ({ size = 24 }) => { + return ( + + ); +}; + +const btnBorder = '1px solid rgba(0, 0, 0, 0.23)'; + +export const StyledGridContainer = styled(Grid)(({ theme }) => ({ + [theme.breakpoints.down('xs')]: { + border: btnBorder, + borderRadius: theme.shape.borderRadius, + "&[class*='WithStyles']": { + marginTop: theme.spacing(1), + "& + [class*='WithStyles']": { + marginTop: theme.spacing(3), + }, + }, + }, +})); + +export const StyledGridItem = styled(Grid)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + '& .MuiButtonBase-root': { + border: btnBorder, + borderRadius: 4, + padding: 9, + }, + [theme.breakpoints.up('sm')]: { + justifyContent: 'center', + }, +})); + +interface HiddenSmLabelProps { + children?: React.ReactNode; +} + +export const HiddenSmLabel: React.FC = ({ children }) => ( + + {children} + +); + +export const DeleteButton: React.FC = () => ( + <> + Delete + + + + +); + +export const AddButtonBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(3), + [theme.breakpoints.up('sm')]: { marginTop: theme.spacing(1) }, +})); + +export const StyledDivider = styled(Divider)(({ theme }) => { + return { + marginTop: theme.spacing(3), + marginLeft: 0, + marginRight: 0, + marginBottom: theme.spacing(3), + }; +}); diff --git a/src/components/Settings/preferences/modals/PreferencesModalSocial.tsx b/src/components/Settings/preferences/modals/PreferencesModalSocial.tsx new file mode 100644 index 000000000..a517d14ce --- /dev/null +++ b/src/components/Settings/preferences/modals/PreferencesModalSocial.tsx @@ -0,0 +1,143 @@ +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Grid, Hidden, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Facebook, Language, LinkedIn, Twitter } from '@mui/icons-material'; + +import { + PersPrefFieldWrapper, + StyledOutlinedInput, +} from '../shared/PreferencesForms'; +import { info } from '../DemoContent'; +import { + AddButtonBox, + DeleteButton, + OptionHeadings, + SectionHeading, + StyledGridContainer, + StyledGridItem, +} from './PreferencesModalShared'; + +const StyledButton = styled(Button)(({ theme }) => ({ + [theme.breakpoints.down('xs')]: { + display: 'block', + width: '100%', + marginTop: theme.spacing(1), + '& .MuiButton-label': { + display: 'flex', + }, + }, + [theme.breakpoints.up('sm')]: { + marginLeft: theme.spacing(1), + }, +})); + +interface MediaRowProps { + localData: { + data: string[]; + mediaType: string; + placeholder: string; + icon: ReactElement; + inputType: string; + }; + savedData?: string; +} + +const MediaRow: React.FC = ({ localData, savedData }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + + ); +}; + +export const PersPrefModalSocial: React.FC = () => { + const { t } = useTranslation(); + + const connections = [ + { + data: info.facebook_accounts, + mediaType: 'Facebook', + placeholder: 'Username *', + icon: , + inputType: 'text', + }, + { + data: info.twitter_accounts, + mediaType: 'Twitter', + placeholder: 'Username *', + icon: , + inputType: 'text', + }, + { + data: info.linkedin_accounts, + mediaType: 'LinkedIn', + placeholder: 'http://linkedin.com/user1234 *', + icon: , + inputType: 'url', + }, + { + data: info.websites, + mediaType: 'Website', + placeholder: 'http://example.com *', + icon: , + inputType: 'url', + }, + ]; + + return ( + <> + + + {t('Social Connections')} + + + + {t('Type')} + + {t('Delete')} + + + {connections.map((current) => + current.data.map((current2, index) => ( + + )), + )} + + + + {t('Add')}: + + {connections.map((current, index) => ( + + {t(current.mediaType)} + + ))} + + + ); +}; diff --git a/src/components/Settings/preferences/shared/PreferencesForms.tsx b/src/components/Settings/preferences/shared/PreferencesForms.tsx new file mode 100644 index 000000000..281d74a0f --- /dev/null +++ b/src/components/Settings/preferences/shared/PreferencesForms.tsx @@ -0,0 +1,236 @@ +import React, { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormControlLabelProps, + FormControlProps, + FormHelperText, + FormHelperTextProps, + FormLabel, + MenuItem, + OutlinedInput, + OutlinedInputProps, + Radio, + Select, + Theme, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { + CheckBox, + CheckBoxOutlineBlank, + RadioButtonChecked, + RadioButtonUnchecked, +} from '@mui/icons-material'; + +const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 700, + marginBottom: theme.spacing(0), + '& .MuiFormControlLabel-label': { + fontWeight: '700', + }, +})); + +const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + margin: 0, + fontSize: 16, + color: theme.palette.text.primary, + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +})); + +const SharedFieldStyles = ({ theme }: { theme: Theme }) => ({ + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +}); + +export const StyledOutlinedInput = styled(OutlinedInput)(SharedFieldStyles); +export const StyledSelect = styled(Select)(SharedFieldStyles); + +interface PersPrefFieldProps { + label?: string; + helperText?: string; + helperPosition?: string; + type?: string; + inputType?: string; + inputValue?: string; + inputPlaceholder?: string; + inputStartIcon?: OutlinedInputProps['startAdornment'] | boolean; + options?: string[][]; + selectValue?: string; + labelPlacement?: FormControlLabelProps['labelPlacement']; + checkboxIcon?: ReactElement; + checkboxCheckedIcon?: ReactElement; + radioName?: string; + radioValue?: string; + radioIcon?: ReactElement; + radioCheckedIcon?: ReactElement; + checked?: boolean; + required?: boolean; + className?: string; + disabled?: boolean; +} + +export const PersPrefField: React.FC = ({ + label = '', + helperText = '', + helperPosition = 'top', + type = 'input', + inputType = 'text', + inputValue = '', + inputPlaceholder = '', + inputStartIcon = false, + options = [], + selectValue = '', + labelPlacement = 'end', + checkboxIcon = , + checkboxCheckedIcon = , + radioName = '', + radioValue = '', + radioIcon = , + radioCheckedIcon = , + checked = false, + required = false, + className = '', + disabled = false, +}) => { + const [selectValueState, setSelectValueState] = useState(selectValue); + + return ( + + {/* Label */} + {label !== '' && ( + {label} + )} + + {/* Helper text */} + {helperText !== '' && helperPosition === 'top' && ( + {helperText} + )} + + {/* Input field */} + {type === 'input' && ( + + )} + + {/* Select field */} + {type === 'select' && options.length > 0 && ( + setSelectValueState(e.target.value as string)} + > + {options.map(([optionVal, optionLabel], index) => { + return ( + + {optionLabel} + + ); + })} + + )} + + {/* Checkboxes or Radios */} + {(type === 'checkbox' || type === 'radio') && + options.map(([optionVal, optionLabel], index) => { + const icon = + type === 'checkbox' ? ( + + ) : ( + + ); + + const val = type === 'checkbox' ? optionVal : radioValue; + + return ( + + ); + })} + + {/* Helper text */} + {helperText !== '' && helperPosition === 'bottom' && ( + {helperText} + )} + + ); +}; + +interface PersPrefFieldWrapperProps { + labelText?: string; + helperText?: string; + helperPosition?: string; + formControlDisabled?: FormControlProps['disabled']; + formControlError?: FormControlProps['error']; + formControlFullWidth?: FormControlProps['fullWidth']; + formControlRequired?: FormControlProps['required']; + formControlVariant?: FormControlProps['variant']; + formHelperTextProps?: { variant?: FormHelperTextProps['variant'] }; + children?: React.ReactNode; +} + +export const PersPrefFieldWrapper: React.FC = ({ + labelText = '', + helperText = '', + helperPosition = 'top', + formControlDisabled = false, + formControlError = false, + formControlFullWidth = true, + formControlRequired = false, + formControlVariant = 'outlined', + formHelperTextProps = { variant: 'standard' }, + children, +}) => { + const { t } = useTranslation(); + const labelOutput = labelText ? ( + {t(labelText)} + ) : ( + '' + ); + const helperTextOutput = helperText ? ( + + {t(helperText)} + + ) : ( + '' + ); + + return ( + + {labelOutput} + {helperPosition === 'top' && helperTextOutput} + {children} + {helperPosition === 'bottom' && helperTextOutput} + + ); +}; diff --git a/src/components/Settings/preferences/shared/PreferencesShared.tsx b/src/components/Settings/preferences/shared/PreferencesShared.tsx new file mode 100644 index 000000000..adc0a5d30 --- /dev/null +++ b/src/components/Settings/preferences/shared/PreferencesShared.tsx @@ -0,0 +1,8 @@ +export const accordionShared = { + '&:before': { + content: 'none', + }, + '& .MuiAccordionSummary-root.Mui-expanded': { + minHeight: 'unset', + }, +}; diff --git a/src/components/Shared/FileUploads/tntConnectDataSync.ts b/src/components/Shared/FileUploads/tntConnectDataSync.ts new file mode 100644 index 000000000..49235d90f --- /dev/null +++ b/src/components/Shared/FileUploads/tntConnectDataSync.ts @@ -0,0 +1,61 @@ +import { TFunction } from 'i18next'; + +export const validateFile = ({ + file, + t, +}: { + file: File; + t: TFunction; +}): { success: true } | { success: false; message: string } => { + if (!new RegExp(/.*\.tntmpd$|.*\.tntdatasync$/).test(file.name)) { + return { + success: false, + message: t( + 'Cannot upload file: file must be an .tntmpd or .tntdatasync file.', + ), + }; + } + // TODO: NEED TO THINK THROUGH HOW WE'RE GOING TO UPLOAD 100MB FILE + // The /api/upload-person-avatar lambda appears to truncate the source body at 2^20 bytes + // Conservatively set the limit at 1MB (1,000,000 bytes), which is a little lower than 1MiB (1,048,576 bytes) because of the + // overhead of encoding multipart/form-data and the other fields in the POST body + if (file.size > 100_000_000) { + return { + success: false, + message: t('Cannot upload file: file size cannot exceed 100MB'), + }; + } + + return { success: true }; +}; + +export const uploadFile = async ({ + oranizationId, + file, + t, +}: { + oranizationId: string; + file: File; + t: TFunction; +}): Promise => { + const validationResult = validateFile({ file, t }); + if (!validationResult.success) { + throw new Error(validationResult.message); + } + + const form = new FormData(); + form.append('oranizationId', oranizationId); + form.append('importFile', file); + + // TODO + const res = await fetch(`/api/upload-tnt-connect-data-sync`, { + method: 'POST', + body: form, + }).catch(() => { + throw new Error(t('Cannot upload file: server error')); + }); + const data: { success: boolean } = await res.json(); + if (!data.success) { + throw new Error(t('Cannot upload file: server error')); + } +}; diff --git a/src/components/Shared/Filters/FilterPanel.tsx b/src/components/Shared/Filters/FilterPanel.tsx index ab6c9c1ff..2a8d263ab 100644 --- a/src/components/Shared/Filters/FilterPanel.tsx +++ b/src/components/Shared/Filters/FilterPanel.tsx @@ -41,6 +41,7 @@ import { FilterListItem } from './FilterListItem'; import { SaveFilterModal } from './SaveFilterModal/SaveFilterModal'; import { FilterPanelTagsSection } from './TagsSection/FilterPanelTagsSection'; import { sanitizeFilters } from 'src/lib/sanitizeFilters'; +import { snakeToCamel } from 'src/lib/snakeToCamel'; type ContactFilterKey = keyof ContactFilterSetInput; type ContactFilterValue = ContactFilterSetInput[ContactFilterKey]; @@ -57,18 +58,6 @@ export type FilterValue = | TaskFilterValue | ReportContactFilterValue; -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)}`; - }, ''); -}; - const ReverseFiltersOptions = { alma_mater: 'reverseAlmaMater', appeal: 'reverseAppeal', diff --git a/src/components/Shared/Forms/Accordions/AccordionGroup.tsx b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx new file mode 100644 index 000000000..3c3a93cb4 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +interface AccordionGroupProps { + title: string; + children?: React.ReactNode; +} + +export const AccordionGroup: React.FC = ({ + title, + children, +}) => { + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.tsx new file mode 100644 index 000000000..b471ad1b7 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionItem.tsx @@ -0,0 +1,167 @@ +import React, { useMemo } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { ExpandMore } from '@mui/icons-material'; + +export const accordionShared = { + '&:before': { + content: 'none', + }, + '& .MuiAccordionSummary-root.Mui-expanded': { + minHeight: 'unset', + }, +}; + +const StyledAccordion = styled(Accordion)(() => ({ + overflow: 'hidden', + ...accordionShared, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + '&.Mui-expanded': { + backgroundColor: theme.palette.mpdxYellow.main, + }, + '& .MuiAccordionSummary-content': { + [theme.breakpoints.only('xs')]: { + flexDirection: 'column', + }, + }, +})); + +const StyledAccordionColumn = styled(Box)(({ theme }) => ({ + paddingRight: theme.spacing(2), + flexBasis: '100%', + [theme.breakpoints.only('xs')]: { + '&:nth-child(2)': { + fontStyle: 'italic', + }, + }, + [theme.breakpoints.up('md')]: { + '&:first-child:not(:last-child)': { + flexBasis: '33.33%', + }, + '&:nth-child(2)': { + flexBasis: '66.66%', + }, + }, +})); + +const StyledAccordionDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + flexBasis: 'calc((100% - 36px) * 0.661)', + marginLeft: 'calc((100% - 36px) * 0.338)', + }, +})); + +const AccordionLeftDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.338)', + }, + [theme.breakpoints.down('md')]: { + width: '200px', + }, + [theme.breakpoints.down('sm')]: { + marginBottom: '10px', + width: '100%', + }, +})); + +const AccordionRightDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.661)', + }, + [theme.breakpoints.down('md')]: { + width: 'calc(100% - 200px)', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, +})); + +const AccordionLImageDetails = styled(Box)(({ theme }) => ({ + display: 'flex', + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, +})); + +const AccordionLeftDetailsImage = styled(Box)(({ theme }) => ({ + maxWidth: '200px', + ' & > img': { + width: '100%', + }, + + [theme.breakpoints.down('md')]: { + ' & > img': { + maxWidth: '150px', + }, + }, + [theme.breakpoints.down('sm')]: { + ' & > img': { + maxWidth: '100px', + }, + }, +})); + +interface AccordionItemProps { + onAccordionChange: (label: string) => void; + expandedPanel: string; + label: string; + value: string; + children?: React.ReactNode; + fullWidth?: boolean; + image?: React.ReactNode; +} + +export const AccordionItem: React.FC = ({ + onAccordionChange, + expandedPanel, + label, + value, + children, + fullWidth = false, + image, +}) => { + const expanded = useMemo( + () => expandedPanel.toLowerCase() === label.toLowerCase(), + [expandedPanel, label], + ); + return ( + onAccordionChange(label)} + expanded={expanded} + disableGutters + > + }> + + {label} + + {value && ( + + {value} + + )} + + + {!fullWidth && !image && ( + {children} + )} + {fullWidth && !image && {children}} + {image && ( + + + {image} + + {children} + + )} + + + ); +}; diff --git a/src/components/Shared/Forms/Field.tsx b/src/components/Shared/Forms/Field.tsx new file mode 100644 index 000000000..34afe4932 --- /dev/null +++ b/src/components/Shared/Forms/Field.tsx @@ -0,0 +1,187 @@ +import React, { ReactElement, useState } from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormControlLabelProps, + FormHelperText, + FormLabel, + MenuItem, + OutlinedInput, + OutlinedInputProps, + Radio, + Select, + Theme, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { + CheckBox, + CheckBoxOutlineBlank, + RadioButtonChecked, + RadioButtonUnchecked, +} from '@mui/icons-material'; + +export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 700, + marginBottom: theme.spacing(1), + '& .MuiFormControlLabel-label': { + fontWeight: '700', + }, +})); + +export const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + margin: 0, + fontSize: 16, + color: theme.palette.text.primary, + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +})); + +const SharedFieldStyles = ({ theme }: { theme: Theme }) => ({ + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +}); + +export const StyledOutlinedInput = styled(OutlinedInput)(SharedFieldStyles); +export const StyledSelect = styled(Select)(SharedFieldStyles); + +export enum TypeEnum { + Input = 'input', + Select = 'select', + Checkbox = 'checkbox', + Radio = 'radio', +} + +export enum helperPositionEnum { + Top = 'top', + Bottom = 'bottom', +} + +interface FormProps { + label?: string; + helperText?: string; + helperPosition?: helperPositionEnum; + type?: TypeEnum; + inputType?: string; + inputValue?: string; + inputPlaceholder?: string; + inputStartIcon?: OutlinedInputProps['startAdornment'] | boolean; + options?: string[][]; + selectValue?: string; + labelPlacement?: FormControlLabelProps['labelPlacement']; + checkboxIcon?: ReactElement; + checkboxCheckedIcon?: ReactElement; + radioName?: string; + radioValue?: string; + radioIcon?: ReactElement; + radioCheckedIcon?: ReactElement; + checked?: boolean; + required?: boolean; + className?: string; + disabled?: boolean; +} + +export const Form: React.FC = ({ + label = '', + helperText = '', + helperPosition = helperPositionEnum.Top, + type = 'input', + inputType = 'text', + inputValue = '', + inputPlaceholder = '', + inputStartIcon = false, + options = [], + selectValue = '', + labelPlacement = 'end', + checkboxIcon = , + checkboxCheckedIcon = , + radioName = '', + radioValue = '', + radioIcon = , + radioCheckedIcon = , + checked = false, + required = false, + className = '', + disabled = false, +}) => { + const [selectValueState, setSelectValueState] = useState(selectValue); + + return ( + + {label && {label}} + + {/* Helper text */} + {helperText && helperPosition === helperPositionEnum.Top && ( + {helperText} + )} + + {/* Input field */} + {type === TypeEnum.Input && ( + + )} + + {/* Select field */} + {type === TypeEnum.Select && options.length && ( + setSelectValueState(e.target.value as string)} + > + {options.map(([optionVal, optionLabel], index) => { + return ( + + {optionLabel} + + ); + })} + + )} + + {/* Checkboxes or Radios */} + {(type === TypeEnum.Checkbox || type === TypeEnum.Radio) && + options.map(([optionVal, optionLabel], index) => { + const icon = + type === TypeEnum.Checkbox ? ( + + ) : ( + + ); + + const val = type === TypeEnum.Checkbox ? optionVal : radioValue; + + return ( + + ); + })} + + {/* Helper text */} + {helperText !== '' && helperPosition === helperPositionEnum.Bottom && ( + {helperText} + )} + + ); +}; diff --git a/src/components/Shared/Forms/FieldWrapper.tsx b/src/components/Shared/Forms/FieldWrapper.tsx new file mode 100644 index 000000000..884e6e15d --- /dev/null +++ b/src/components/Shared/Forms/FieldWrapper.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + FormControlProps, + FormHelperTextProps, +} from '@mui/material'; +import { + helperPositionEnum, + StyledFormHelperText, + StyledFormLabel, +} from './Field'; + +interface FieldWrapperProps { + labelText?: string; + helperText?: string; + helperPosition?: helperPositionEnum; + formControlDisabled?: FormControlProps['disabled']; + formControlError?: FormControlProps['error']; + formControlFullWidth?: FormControlProps['fullWidth']; + formControlRequired?: FormControlProps['required']; + formControlVariant?: FormControlProps['variant']; + formHelperTextProps?: { variant?: FormHelperTextProps['variant'] }; + children?: React.ReactNode; +} + +export const FieldWrapper: React.FC = ({ + labelText = '', + helperText = '', + helperPosition = helperPositionEnum.Top, + formControlDisabled = false, + formControlError = false, + formControlFullWidth = true, + formControlRequired = false, + formControlVariant = 'outlined', + formHelperTextProps = { variant: 'standard' }, + children, +}) => { + const { t } = useTranslation(); + const labelOutput = labelText ? ( + + {t(labelText)} + + ) : ( + '' + ); + + const helperTextOutput = helperText ? ( + + {t(helperText)} + + ) : ( + '' + ); + + return ( + + {labelOutput} + {helperPosition === helperPositionEnum.Top && helperTextOutput} + {children} + {helperPosition === helperPositionEnum.Bottom && helperTextOutput} + + ); +}; diff --git a/src/components/Shared/Forms/Fields/FormWrapper.tsx b/src/components/Shared/Forms/Fields/FormWrapper.tsx new file mode 100644 index 000000000..e8388754f --- /dev/null +++ b/src/components/Shared/Forms/Fields/FormWrapper.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +interface FormWrapperProps { + onSubmit: () => void; + isValid: boolean; + isSubmitting: boolean; + formAttrs?: { action?: string; method?: string }; + children: React.ReactNode; + buttonText?: string; +} + +export const FormWrapper: React.FC = ({ + onSubmit, + isValid, + isSubmitting, + formAttrs = {}, + children, + buttonText = 'Save', +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + // TODO - Add Formik to this. + + return ( +
+ {children} + +
+ ); +}; diff --git a/src/components/Shared/Forms/Fields/Input.tsx b/src/components/Shared/Forms/Fields/Input.tsx new file mode 100644 index 000000000..5f9bdb24c --- /dev/null +++ b/src/components/Shared/Forms/Fields/Input.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { TextField, TextFieldProps } from '@mui/material'; + +export interface InputProps { + children?: TextFieldProps['children']; + disabled?: TextFieldProps['disabled']; + error?: TextFieldProps['error']; + fullWidth?: TextFieldProps['fullWidth']; + helperText?: TextFieldProps['helperText']; + label?: TextFieldProps['label']; + required?: TextFieldProps['required']; + select?: TextFieldProps['select']; + value?: TextFieldProps['value']; +} + +export const Input: React.FC = ({ + children, + disabled = false, + error = false, + fullWidth = true, + helperText = '', + label = '', + required = false, + select = false, + value = '', +}) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/Fields/Select.tsx b/src/components/Shared/Forms/Fields/Select.tsx new file mode 100644 index 000000000..437aa28ef --- /dev/null +++ b/src/components/Shared/Forms/Fields/Select.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { MenuItem } from '@mui/material'; +import { Input, InputProps } from './Input'; + +interface PersPrefSelectProps extends InputProps { + selectOptions: Array<{ label: string; value: string }>; +} + +export const Select: React.FC = ({ + disabled = false, + error = false, + fullWidth = true, + helperText = '', + label = '', + required = false, + value = '', + selectOptions = [], +}) => { + return ( + + {selectOptions.map((option) => ( + + {option.label} + + ))} + + ); +}; diff --git a/src/components/Reports/AccountsListLayout/Header/Header.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx similarity index 75% rename from src/components/Reports/AccountsListLayout/Header/Header.test.tsx rename to src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx index 8885184d1..0681c19ff 100644 --- a/src/components/Reports/AccountsListLayout/Header/Header.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx @@ -1,23 +1,24 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import userEvent from '@testing-library/user-event'; -import { AccountsListHeader as Header } from './Header'; +import { MultiPageHeader, HeaderTypeEnum } from './MultiPageHeader'; import theme from 'src/theme'; const totalBalance = 'CA111'; const title = 'test title'; const onNavListToggle = jest.fn(); -describe('AccountsListHeader', () => { +describe('MultiPageHeader', () => { it('default', async () => { const { getByRole, getByText } = render( -
, ); @@ -27,16 +28,18 @@ describe('AccountsListHeader', () => { userEvent.click( getByRole('button', { hidden: true, name: 'Toggle Filter Panel' }), ); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); }); it('should not render rightExtra if undefined', async () => { const { queryByText } = render( -
, ); diff --git a/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx new file mode 100644 index 000000000..90e5f2a09 --- /dev/null +++ b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx @@ -0,0 +1,100 @@ +import React, { FC, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, IconButton, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import FilterList from '@mui/icons-material/FilterList'; +import MenuIcon from '@mui/icons-material/Menu'; +import theme from 'src/theme'; + +export enum HeaderTypeEnum { + Report = 'reports', + Settings = 'settings', +} + +interface MultiPageHeaderProps { + isNavListOpen: boolean; + onNavListToggle: () => void; + title: string; + headerType: HeaderTypeEnum; + rightExtra?: ReactNode; +} + +const StickyHeader = styled(Box, { + shouldForwardProp: (prop) => prop !== 'headerType', +})(({ headerType }: { headerType: HeaderTypeEnum }) => ({ + position: 'sticky', + top: 0, + height: 96, + color: + headerType === HeaderTypeEnum.Settings ? theme.palette.common.white : '', + backgroundColor: + headerType === HeaderTypeEnum.Settings ? theme.palette.primary.main : '', + paddingTop: headerType === HeaderTypeEnum.Settings ? theme.spacing(3) : '', + paddingBottom: headerType === HeaderTypeEnum.Settings ? theme.spacing(3) : '', +})); + +const NavListButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== 'panelOpen', +})(({ panelOpen }: { panelOpen: boolean }) => ({ + display: 'inline-block', + width: 48, + height: 48, + borderradius: 24, + margin: theme.spacing(0), + backgroundColor: panelOpen ? theme.palette.secondary.dark : 'transparent', + marginRight: '8px', + padding: '11px', +})); + +const NavFilterIcon = styled(FilterList)(() => ({ + width: 24, + height: 24, + color: theme.palette.primary.dark, +})); + +const NavMenuIcon = styled(MenuIcon)(() => ({ + width: 24, + height: 24, + color: theme.palette.common.white, +})); + +export const MultiPageHeader: FC = ({ + title, + rightExtra, + isNavListOpen, + onNavListToggle, + headerType, +}) => { + const { t } = useTranslation(); + + let titleAccess; + if (headerType === HeaderTypeEnum.Report) { + titleAccess = t('Toggle Filter Panel'); + } else if (headerType === HeaderTypeEnum.Settings) { + titleAccess = t('Toggle Preferences Menu'); + } + + return ( + + + + {headerType === HeaderTypeEnum.Report && ( + + )} + {headerType === HeaderTypeEnum.Settings && ( + + )} + + + {title} + + {rightExtra} + + + ); +}; diff --git a/src/components/Reports/NavReportsList/Item/Item.stories.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.stories.tsx similarity index 64% rename from src/components/Reports/NavReportsList/Item/Item.stories.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.stories.tsx index 4b1a1d97f..5e8f99777 100644 --- a/src/components/Reports/NavReportsList/Item/Item.stories.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.stories.tsx @@ -1,5 +1,6 @@ import React, { ReactElement } from 'react'; import { Item } from './Item'; +import { NavTypeEnum } from '../MultiPageMenu'; export default { title: 'Reports/ReportLayout/NavReportsList/Item', @@ -12,9 +13,9 @@ const item = { }; export const Default = (): ReactElement => ( - + ); export const Selected = (): ReactElement => ( - + ); diff --git a/src/components/Reports/NavReportsList/Item/Item.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx similarity index 80% rename from src/components/Reports/NavReportsList/Item/Item.test.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx index 697bc044f..db020520e 100644 --- a/src/components/Reports/NavReportsList/Item/Item.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Item } from './Item'; import { render } from '__tests__/util/testingLibraryReactMock'; import TestWrapper from '__tests__/util/TestWrapper'; +import { NavTypeEnum } from '../MultiPageMenu'; const item = { id: 'testItem', @@ -13,7 +14,7 @@ describe('Item', () => { it('default', () => { const { queryByText } = render( - + , ); expect(queryByText(item.title)).toBeInTheDocument(); diff --git a/src/components/Reports/NavReportsList/Item/Item.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.tsx similarity index 78% rename from src/components/Reports/NavReportsList/Item/Item.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.tsx index fbe403672..d47c883f8 100644 --- a/src/components/Reports/NavReportsList/Item/Item.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.tsx @@ -6,6 +6,7 @@ import NextLink from 'next/link'; import { useTranslation } from 'react-i18next'; import { useAccountListId } from 'src/hooks/useAccountListId'; import HandoffLink from 'src/components/HandoffLink'; +import { NavTypeEnum } from '../MultiPageMenu'; interface ReportOption { id: string; @@ -16,9 +17,15 @@ interface ReportOption { interface Props { item: ReportOption; isSelected: boolean; + navType: NavTypeEnum; } -export const Item: React.FC = ({ item, isSelected, ...rest }) => { +export const Item: React.FC = ({ + item, + isSelected, + navType, + ...rest +}) => { const accountListId = useAccountListId(); const { t } = useTranslation(); @@ -37,11 +44,11 @@ export const Item: React.FC = ({ item, isSelected, ...rest }) => { ); if (item.id === 'coaching') { - return {children}; + return {children}; } else { return ( {children} diff --git a/src/components/Reports/NavReportsList/NavReportsList.stories.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.stories.tsx similarity index 75% rename from src/components/Reports/NavReportsList/NavReportsList.stories.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.stories.tsx index 6cd4c0b21..6e7d9dfe4 100644 --- a/src/components/Reports/NavReportsList/NavReportsList.stories.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.stories.tsx @@ -1,5 +1,5 @@ import React, { ReactElement } from 'react'; -import { NavReportsList } from './NavReportsList'; +import { MultiPageMenu, NavTypeEnum } from './MultiPageMenu'; const selected = 'salaryCurrency'; @@ -9,12 +9,13 @@ export default { export const Default = (): ReactElement => { return ( - {}} designationAccounts={[]} setDesignationAccounts={() => {}} + navType={NavTypeEnum.Reports} /> ); }; diff --git a/src/components/Reports/NavReportsList/NavReportsList.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx similarity index 90% rename from src/components/Reports/NavReportsList/NavReportsList.test.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx index f90c83cfb..98fc06f9b 100644 --- a/src/components/Reports/NavReportsList/NavReportsList.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ThemeProvider } from '@mui/material/styles'; -import { NavReportsList } from './NavReportsList'; +import { MultiPageMenu, NavTypeEnum } from './MultiPageMenu'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from 'src/theme'; -import { GetDesignationAccountsQuery } from '../DonationsReport/Table/Modal/EditDonation.generated'; +import { GetDesignationAccountsQuery } from 'src/components/Reports/DonationsReport/Table/Modal/EditDonation.generated'; const accountListId = 'account-list-1'; const selected = 'salaryCurrency'; @@ -16,18 +16,19 @@ const router = { isReady: true, }; -describe('NavReportsList', () => { +describe('MultiPageMenu', () => { it('default', async () => { const { getByText } = render( - {}} designationAccounts={[]} setDesignationAccounts={() => {}} + navType={NavTypeEnum.Reports} /> @@ -72,12 +73,13 @@ describe('NavReportsList', () => { mocks={mocks} onCall={mutationSpy} > - {}} designationAccounts={designationAccounts} setDesignationAccounts={setDesignationAccounts} + navType={NavTypeEnum.Reports} /> @@ -121,12 +123,13 @@ describe('NavReportsList', () => { mocks={mocks} onCall={mutationSpy} > - {}} designationAccounts={[]} setDesignationAccounts={jest.fn()} + navType={NavTypeEnum.Reports} /> diff --git a/src/components/Reports/NavReportsList/NavReportsList.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx similarity index 64% rename from src/components/Reports/NavReportsList/NavReportsList.tsx rename to src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx index 63146de85..94e67e7d7 100644 --- a/src/components/Reports/NavReportsList/NavReportsList.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx @@ -12,18 +12,24 @@ import { makeStyles } from 'tss-react/mui'; import Close from '@mui/icons-material/Close'; import { useTranslation } from 'react-i18next'; import { Item } from './Item/Item'; -import { ReportNavItems } from './ReportNavItems'; -import { MultiselectFilter } from '../../../../graphql/types.generated'; +import { MultiselectFilter } from '../../../../../graphql/types.generated'; import { FilterListItemMultiselect } from 'src/components/Shared/Filters/FilterListItemMultiselect'; -import { useGetDesignationAccountsQuery } from '../DonationsReport/Table/Modal/EditDonation.generated'; +import { useGetDesignationAccountsQuery } from 'src/components/Reports/DonationsReport/Table/Modal/EditDonation.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; +import { ReportNavItems, SettingsNavItems } from './MultiPageMenuItems'; + +export enum NavTypeEnum { + Reports = 'reports', + Settings = 'settings', +} interface Props { selectedId: string; isOpen: boolean; onClose: () => void; - designationAccounts: string[]; - setDesignationAccounts: (designationAccounts: string[]) => void; + navType: NavTypeEnum; + designationAccounts?: string[]; + setDesignationAccounts?: (designationAccounts: string[]) => void; } const useStyles = makeStyles()(() => ({ @@ -47,10 +53,11 @@ const FilterList = styled(List)(({ theme }) => ({ }, })); -export const NavReportsList: React.FC = ({ +export const MultiPageMenu: React.FC = ({ selectedId, isOpen, onClose, + navType, designationAccounts, setDesignationAccounts, ...BoxProps @@ -58,11 +65,16 @@ export const NavReportsList: React.FC = ({ const { classes } = useStyles(); const { t } = useTranslation(); const accountListId = useAccountListId(); + const navItems = + navType === NavTypeEnum.Reports ? ReportNavItems : SettingsNavItems; + const navTitle = + navType === NavTypeEnum.Reports ? t('Reports') : t('Settings'); const { data } = useGetDesignationAccountsQuery({ variables: { accountListId: accountListId ?? '', }, + skip: !designationAccounts && !setDesignationAccounts, }); const accounts = data?.designationAccounts @@ -80,7 +92,7 @@ export const NavReportsList: React.FC = ({ }; return ( - +
@@ -90,27 +102,30 @@ export const NavReportsList: React.FC = ({ justifyContent="space-between" alignItems="center" > - {t('Reports')} + {navTitle} - {accounts.length > 1 && ( - { - setDesignationAccounts(value ?? []); - }} - /> - )} - {ReportNavItems.map((item) => ( + {designationAccounts && + setDesignationAccounts && + accounts.length > 1 && ( + { + setDesignationAccounts(value ?? []); + }} + /> + )} + {navItems.map((item) => ( ))} diff --git a/src/components/Reports/NavReportsList/ReportNavItems.ts b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts similarity index 65% rename from src/components/Reports/NavReportsList/ReportNavItems.ts rename to src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts index 3c21790b4..ac884dd94 100644 --- a/src/components/Reports/NavReportsList/ReportNavItems.ts +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts @@ -34,3 +34,26 @@ export const ReportNavItems = [ title: 'Coaching', }, ]; + +export const SettingsNavItems = [ + { + id: 'preferences', + title: 'Preferences', + }, + { + id: 'notifications', + title: 'Notifications', + }, + { + id: 'integrations', + title: 'Connect Services', + }, + { + id: 'manageAccounts', + title: 'Manage Accounts', + }, + { + id: 'manageCoaches', + title: 'Manage Coaches', + }, +]; diff --git a/src/components/Task/Modal/Comments/Item/TaskModalCommentListItem.tsx b/src/components/Task/Modal/Comments/Item/TaskModalCommentListItem.tsx index 53a7a1848..3ccc49e9f 100644 --- a/src/components/Task/Modal/Comments/Item/TaskModalCommentListItem.tsx +++ b/src/components/Task/Modal/Comments/Item/TaskModalCommentListItem.tsx @@ -91,7 +91,8 @@ const TaskModalCommentsListItem: React.FC = ({ return ( - {comment?.person.firstName} {comment?.person.lastName}{' '} + {comment?.person && + `${comment.person.firstName} ${comment.person.lastName} `} diff --git a/src/components/common/Modal/Modal.tsx b/src/components/common/Modal/Modal.tsx index 05bc71b2e..98cdfacc5 100644 --- a/src/components/common/Modal/Modal.tsx +++ b/src/components/common/Modal/Modal.tsx @@ -53,7 +53,6 @@ const Modal = ({ handleClose()} aria-label={t('Close')}> - {children} ); diff --git a/src/lib/getErrorFromCatch.ts b/src/lib/getErrorFromCatch.ts new file mode 100644 index 000000000..65972171c --- /dev/null +++ b/src/lib/getErrorFromCatch.ts @@ -0,0 +1,6 @@ +export const getErrorMessage = (err: unknown) => { + let message; + if (err instanceof Error) message = err.message; + else message = String(err); + return message; +}; diff --git a/src/lib/helpScout.ts b/src/lib/helpScout.ts index 81f49782e..ec2054f30 100644 --- a/src/lib/helpScout.ts +++ b/src/lib/helpScout.ts @@ -39,15 +39,27 @@ export const identifyUser = (id: string, email: string, name: string) => { }); }; -export const suggestArticles = (envVar: keyof typeof env) => { - const articleIds = env[envVar]; +export const showArticle = (articleId) => { + callBeacon('article', articleId); +}; + +export const suggestArticles = (envVar: keyof typeof variables) => { + const articleIds = variables[envVar]; callBeacon('suggest', articleIds?.split(',') ?? []); }; -const env = { +export const variables = { + HS_SETUP_FIND_ORGANIZATION: process.env.HS_SETUP_FIND_ORGANIZATION, HS_CONTACTS_SUGGESTIONS: process.env.HS_CONTACTS_SUGGESTIONS, HS_CONTACTS_CONTACT_SUGGESTIONS: process.env.HS_CONTACTS_CONTACT_SUGGESTIONS, HS_HOME_SUGGESTIONS: process.env.HS_HOME_SUGGESTIONS, HS_REPORTS_SUGGESTIONS: process.env.HS_REPORTS_SUGGESTIONS, HS_TASKS_SUGGESTIONS: process.env.HS_TASKS_SUGGESTIONS, + HS_SETTINGS_PREFERENCES_SUGGESTIONS: + process.env.HS_SETTINGS_PREFERENCES_SUGGESTIONS, + HS_SETTINGS_ACCOUNTS_SUGGESTIONS: + process.env.HS_SETTINGS_ACCOUNTS_SUGGESTIONS, + HS_SETTINGS_COACHES_SUGGESTIONS: process.env.HS_SETTINGS_COACHES_SUGGESTIONS, + HS_SETTINGS_SERVICES_SUGGESTIONS: + process.env.HS_SETTINGS_SERVICES_SUGGESTIONS, }; 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()}`); +}; diff --git a/src/theme.ts b/src/theme.ts index 5f9218c7c..fb2d05b3b 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -14,6 +14,7 @@ const mpdxColors = { green: '#00CA99', blue: '#05699B', yellow: '#FFF5CD', + red: '#F44336', gray: '#DCDCDC', }; @@ -32,6 +33,7 @@ declare module '@mui/material/styles/createPalette' { mpdxGreen: Palette['primary']; mpdxBlue: Palette['primary']; mpdxYellow: Palette['primary']; + mpdxRed: Palette['primary']; mpdxGray: Palette['primary']; progressBarYellow: Palette['primary']; progressBarOrange: Palette['primary']; @@ -45,6 +47,7 @@ declare module '@mui/material/styles/createPalette' { mpdxGreen: PaletteOptions['primary']; mpdxBlue: PaletteOptions['primary']; mpdxYellow: PaletteOptions['primary']; + mpdxRed: PaletteOptions['primary']; mpdxGray: PaletteOptions['primary']; progressBarYellow: PaletteOptions['primary']; progressBarOrange: PaletteOptions['primary']; @@ -89,6 +92,10 @@ const theme = createTheme({ }, mpdxYellow: { main: mpdxColors.yellow, + dark: '#8a6d3b', + }, + mpdxRed: { + main: mpdxColors.red, }, mpdxGray: { main: mpdxColors.gray,