diff --git a/pages/accountLists/[accountListId]/coaching/[coachingId].page.tsx b/pages/accountLists/[accountListId]/coaching/[coachingId].page.tsx index 6da05b606..3cabdc268 100644 --- a/pages/accountLists/[accountListId]/coaching/[coachingId].page.tsx +++ b/pages/accountLists/[accountListId]/coaching/[coachingId].page.tsx @@ -3,7 +3,10 @@ import Head from 'next/head'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/router'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { CoachingDetail } from 'src/components/Coaching/CoachingDetail/CoachingDetail'; +import { + AccountListTypeEnum, + CoachingDetail, +} from 'src/components/Coaching/CoachingDetail/CoachingDetail'; import { useAccountListId } from 'src/hooks/useAccountListId'; import Loading from 'src/components/Loading'; @@ -23,8 +26,8 @@ const CoachingPage: React.FC = () => { {accountListId && coachingId && isReady ? ( ) : ( diff --git a/pages/accountLists/[accountListId]/reports/coaching.page.tsx b/pages/accountLists/[accountListId]/reports/coaching.page.tsx index 235c21eac..b11f59eb8 100644 --- a/pages/accountLists/[accountListId]/reports/coaching.page.tsx +++ b/pages/accountLists/[accountListId]/reports/coaching.page.tsx @@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next'; import useGetAppSettings from 'src/hooks/useGetAppSettings'; import Loading from '../../../../src/components/Loading'; import { useAccountListId } from '../../../../src/hooks/useAccountListId'; -import { CoachingDetail } from 'src/components/Coaching/CoachingDetail/CoachingDetail'; +import { + AccountListTypeEnum, + CoachingDetail, +} from 'src/components/Coaching/CoachingDetail/CoachingDetail'; import { suggestArticles } from 'src/lib/helpScout'; const CoachingReportPage = (): ReactElement => { @@ -24,7 +27,10 @@ const CoachingReportPage = (): ReactElement => { {accountListId ? ( - + ) : ( )} diff --git a/pages/api/Schema/AccountListCoachUser/accountListCoachUser.graphql b/pages/api/Schema/AccountListCoachUser/accountListCoachUser.graphql deleted file mode 100644 index 2cfe2cc95..000000000 --- a/pages/api/Schema/AccountListCoachUser/accountListCoachUser.graphql +++ /dev/null @@ -1,11 +0,0 @@ -extend type Query { - getAccountListCoachUsers(accountListId: ID!): [AccountListCoachUser] -} -type AccountListCoachUser { - id: ID! - createdAt: ISO8601DateTime - firstName: String - lastName: String - updatedAt: ISO8601DateTime - updatedInDbAt: ISO8601DateTime -} diff --git a/pages/api/Schema/AccountListCoachUser/dataHandler.ts b/pages/api/Schema/AccountListCoachUser/dataHandler.ts deleted file mode 100644 index 5e1d75774..000000000 --- a/pages/api/Schema/AccountListCoachUser/dataHandler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { AccountListCoachUser } from '../../../../graphql/types.generated'; - -const getAccountListCoachUsers = ( - data: [ - { - id: string; - type: string; - attributes: { - created_at: string; - first_name: string; - last_name: string; - updated_at: string; - updated_in_db_at: string; - }; - }, - ], -): AccountListCoachUser[] => { - const users: AccountListCoachUser[] = data.map( - ({ - id, - attributes: { - created_at, - first_name, - last_name, - updated_at, - updated_in_db_at, - }, - }) => { - return { - id, - createdAt: created_at, - firstName: first_name, - lastName: last_name, - updatedAt: updated_at, - updatedInDbAt: updated_in_db_at, - }; - }, - ); - return users; -}; - -export { getAccountListCoachUsers }; diff --git a/pages/api/Schema/AccountListCoachUser/resolvers.ts b/pages/api/Schema/AccountListCoachUser/resolvers.ts deleted file mode 100644 index ea2fefe42..000000000 --- a/pages/api/Schema/AccountListCoachUser/resolvers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Resolvers } from '../../graphql-rest.page.generated'; - -const AccountListCoachUserResolvers: Resolvers = { - Query: { - getAccountListCoachUsers: async ( - _source, - { accountListId }, - { dataSources }, - ) => { - return dataSources.mpdxRestApi.getAccountListCoachUsers(accountListId); - }, - }, -}; - -export { AccountListCoachUserResolvers }; diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index 2040bbf3e..df47c8bd1 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -33,8 +33,6 @@ import UpdateCommentTypeDefs from './Tasks/Comments/UpdateComments/updateComment import { UpdateCommentResolvers } from './Tasks/Comments/UpdateComments/resolvers'; import AccountListDonorAccountsTypeDefs from './AccountListDonorAccounts/accountListDonorAccounts.graphql'; import { AccountListDonorAccountsResolvers } from './AccountListDonorAccounts/resolvers'; -import AccountListCoachUserTypeDefs from './AccountListCoachUser/accountListCoachUser.graphql'; -import { AccountListCoachUserResolvers } from './AccountListCoachUser/resolvers'; import AccountListCoachesTypeDefs from './AccountListCoaches/accountListCoaches.graphql'; import { AccountListCoachesResolvers } from './AccountListCoaches/resolvers'; import ReportsPledgeHistoriesTyeDefs from './reports/pledgeHistories/pledgeHistories.graphql'; @@ -65,10 +63,6 @@ const schema = buildSubgraphSchema([ typeDefs: AccountListDonorAccountsTypeDefs, resolvers: AccountListDonorAccountsResolvers, }, - { - typeDefs: AccountListCoachUserTypeDefs, - resolvers: AccountListCoachUserResolvers, - }, { typeDefs: AppointmentResultsTypeDefs, resolvers: AppointmentResultsResolvers, diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 1f8eea078..4c4ee1200 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -54,7 +54,6 @@ import { UpdateComment, } from './Schema/Tasks/Comments/UpdateComments/datahandler'; import { getAccountListDonorAccounts } from './Schema/AccountListDonorAccounts/dataHandler'; -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'; @@ -173,11 +172,6 @@ class MpdxRestApi extends RESTDataSource { return getAccountListAnalytics(data); } - async getAccountListCoachUsers(accountListId: string) { - const { data } = await this.get(`account_lists/${accountListId}/coaches`); - return getAccountListCoachUsers(data); - } - async getAppointmentResults( accountListId: string, endDate: string, diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.stories.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.stories.tsx index 01f87b69f..be71d1dc2 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.stories.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.stories.tsx @@ -1,6 +1,9 @@ import React, { ReactElement } from 'react'; -import { LoadCoachingDetailQuery } from './LoadCoachingDetail.generated'; -import { CoachingDetail } from './CoachingDetail'; +import { + LoadAccountListCoachingDetailQuery, + LoadCoachingDetailQuery, +} from './LoadCoachingDetail.generated'; +import { AccountListTypeEnum, CoachingDetail } from './CoachingDetail'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; export default { @@ -21,14 +24,19 @@ export const Default = (): ReactElement => { }, }} > - + ); }; export const AccountListDetail = (): ReactElement => { return ( - + mocks={{ LoadAccountListCoachingDetail: { accountList: { @@ -38,7 +46,10 @@ export const AccountListDetail = (): ReactElement => { }, }} > - + ); }; diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx index d6e592c3c..cf3f28c99 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { DateTime } from 'luxon'; import { LoadCoachingDetailQuery, - useGetAccountListUsersQuery, - useGetAccountListCoachUsersQuery, + LoadAccountListCoachingDetailQuery, } from './LoadCoachingDetail.generated'; -import { CoachingDetail } from './CoachingDetail'; +import { AccountListTypeEnum, CoachingDetail } from './CoachingDetail'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { render } from '__tests__/util/testingLibraryReactMock'; +import { render, waitFor } from '__tests__/util/testingLibraryReactMock'; import TestRouter from '__tests__/util/TestRouter'; import { beforeTestResizeObserver, @@ -22,7 +21,7 @@ const router = { push, }; -const coachingId = 'coaching-id'; +const accountListId = 'account-list-1'; describe('LoadCoachingDetail', () => { beforeEach(() => { beforeTestResizeObserver(); @@ -31,6 +30,7 @@ describe('LoadCoachingDetail', () => { afterEach(() => { afterTestResizeObserver(); }); + it('view', async () => { const { findByText } = render( @@ -38,7 +38,7 @@ describe('LoadCoachingDetail', () => { mocks={{ LoadCoachingDetail: { coachingAccountList: { - id: coachingId, + id: accountListId, name: 'John Doe', currency: 'USD', monthlyGoal: 55, @@ -46,7 +46,10 @@ describe('LoadCoachingDetail', () => { }, }} > - + , ); @@ -61,7 +64,7 @@ describe('LoadCoachingDetail', () => { mocks={{ LoadCoachingDetail: { coachingAccountList: { - id: coachingId, + id: accountListId, name: 'John Doe', currency: 'USD', monthlyGoal: null, @@ -69,7 +72,10 @@ describe('LoadCoachingDetail', () => { }, }} > - + , ); @@ -81,11 +87,13 @@ describe('LoadCoachingDetail', () => { it('view isAccountList', async () => { const { findByText } = render( - + mocks={{ LoadAccountListCoachingDetail: { accountList: { - id: coachingId, + id: accountListId, name: 'John Doe', currency: 'USD', monthlyGoal: 55, @@ -93,7 +101,10 @@ describe('LoadCoachingDetail', () => { }, }} > - + , ); @@ -104,11 +115,13 @@ describe('LoadCoachingDetail', () => { it('null goal isAccountList', async () => { const { findByText } = render( - + mocks={{ LoadAccountListCoachingDetail: { accountList: { - id: coachingId, + id: accountListId, name: 'John Doe', currency: 'USD', monthlyGoal: null, @@ -116,7 +129,10 @@ describe('LoadCoachingDetail', () => { }, }} > - + , ); @@ -124,34 +140,369 @@ describe('LoadCoachingDetail', () => { expect(await findByText('Monthly $0')).toBeVisible(); expect(await findByText('Monthly Activity')).toBeVisible(); }); - it('query Users', async () => { - const { result, waitForNextUpdate } = renderHook( - () => - useGetAccountListCoachUsersQuery({ - variables: { accountListId: 'account-list-id' }, - }), - { - wrapper: GqlMockedProvider, - }, - ); - await waitForNextUpdate(); - expect( - result.current.data?.getAccountListCoachUsers?.length, - ).toMatchInlineSnapshot(`2`); + + describe('balance', () => { + it('displays the account list balance', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + balance: 1000, + currency: 'USD', + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('Balance')).toHaveTextContent('Balance: $1,000'), + ); + }); }); - it('query Coach Users', async () => { - const { result, waitForNextUpdate } = renderHook( - () => - useGetAccountListUsersQuery({ - variables: { accountListId: 'account-list-id' }, - }), - { - wrapper: GqlMockedProvider, - }, - ); - await waitForNextUpdate(); - expect( - result.current.data?.accountListUsers.nodes.length, - ).toMatchInlineSnapshot(`1`); + + describe('staff ids', () => { + it('displays comma-separated staff ids', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '123' }, + { accountNumber: '456' }, + ], + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('StaffIds')).toHaveTextContent('123, 456'), + ); + }); + + it('ignores empty staff ids', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '' }, + { accountNumber: '456' }, + ], + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('StaffIds')).toHaveTextContent('456'), + ); + }); + + it('displays none when there are no staff ids', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + designationAccounts: [ + { accountNumber: '123' }, + { accountNumber: '456' }, + ], + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('StaffIds')).toHaveTextContent('None'), + ); + }); + }); + + describe('last prayer letter', () => { + it('formats the prayer letter date', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + }, + }, + GetTaskAnalytics: { + taskAnalytics: { + lastElectronicNewsletterCompletedAt: DateTime.local( + 2023, + 1, + 1, + ).toISO(), + lastPhysicalNewsletterCompletedAt: DateTime.local( + 2023, + 1, + 2, + ).toISO(), + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('LastPrayerLetter')).toHaveTextContent( + 'Last Prayer Letter: Jan 2, 2023', + ), + ); + }); + + it('displays none when there are no prayer letters', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + }, + }, + GetTaskAnalytics: { + taskAnalytics: { + lastElectronicNewsletterCompletedAt: null, + lastPhysicalNewsletterCompletedAt: null, + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('LastPrayerLetter')).toHaveTextContent( + 'Last Prayer Letter: None', + ), + ); + }); + }); + + describe('MPD info', () => { + it('displays info', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + activeMpdStartAt: DateTime.local(2023, 1, 1).toISO(), + activeMpdFinishAt: DateTime.local(2024, 1, 1).toISO(), + activeMpdMonthlyGoal: 1000, + weeksOnMpd: 12, + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('WeeksOnMpd')).toHaveTextContent('Weeks on MPD: 12'), + ); + expect(getByTestId('MpdStartDate')).toHaveTextContent( + 'Start Date: Jan 1, 2023', + ); + expect(getByTestId('MpdEndDate')).toHaveTextContent( + 'End Date: Jan 1, 2024', + ); + expect(getByTestId('MpdCommitmentGoal')).toHaveTextContent( + 'Commitment Goal: $1,000', + ); + }); + + it('displays none when info is missing', async () => { + const { getByTestId } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + activeMpdStartAt: null, + activeMpdFinishAt: null, + activeMpdMonthlyGoal: null, + }, + }, + }} + > + + + , + ); + + await waitFor(() => + expect(getByTestId('MpdStartDate')).toHaveTextContent( + 'Start Date: None', + ), + ); + expect(getByTestId('MpdEndDate')).toHaveTextContent('End Date: None'); + expect(getByTestId('MpdCommitmentGoal')).toHaveTextContent( + 'Commitment Goal: None', + ); + }); + }); + + describe('users', () => { + it('shows the user names and contact info', async () => { + const { getByText } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + users: { + nodes: [ + { + firstName: 'John', + lastName: 'Doe', + emailAddresses: { + nodes: [ + { + email: 'john.doe@cru.org', + }, + ], + }, + phoneNumbers: { + nodes: [ + { + number: '111-111-1111', + }, + ], + }, + }, + ], + }, + }, + }, + }} + > + + + , + ); + + await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument()); + expect(getByText('john.doe@cru.org')).toBeInTheDocument(); + expect(getByText('111-111-1111')).toBeInTheDocument(); + }); + }); + + describe('coaches', () => { + it('shows the user names and contact info', async () => { + const { getByText } = render( + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + currency: 'USD', + coaches: { + nodes: [ + { + firstName: 'John', + lastName: 'Coach', + emailAddresses: { + nodes: [ + { + email: 'john.coach@cru.org', + }, + ], + }, + phoneNumbers: { + nodes: [ + { + number: '222-222-2222', + }, + ], + }, + }, + ], + }, + }, + }, + }} + > + + + , + ); + + await waitFor(() => expect(getByText('John Coach')).toBeInTheDocument()); + expect(getByText('john.coach@cru.org')).toBeInTheDocument(); + expect(getByText('222-222-2222')).toBeInTheDocument(); + }); }); }); diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index da74beeeb..073b3ae0c 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -1,28 +1,39 @@ -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useState, useMemo } from 'react'; import { Box, Button, ButtonGroup, Divider, Typography } from '@mui/material'; // TODO: EcoOutlined is not defined on @mui/icons-material, find replacement. import AccountCircle from '@mui/icons-material/AccountCircle'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import Skeleton from '@mui/material/Skeleton'; +import { DateTime } from 'luxon'; import { AppealProgress } from '../AppealProgress/AppealProgress'; import { MonthlyCommitment } from './MonthlyCommitment/MonthlyCommitment'; import { - useGetAccountListCoachUsersQuery, - useGetAccountListUsersQuery, useGetCoachingDonationGraphQuery, useLoadAccountListCoachingDetailQuery, useLoadCoachingDetailQuery, } from './LoadCoachingDetail.generated'; +import { useGetTaskAnalyticsQuery } from 'src/components/Dashboard/ThisWeek/NewsletterMenu/NewsletterMenu.generated'; import theme from 'src/theme'; import { currencyFormat } from 'src/lib/intlFormat'; +import { dateFormat } from 'src/lib/intlFormat/intlFormat'; import { useLocale } from 'src/hooks/useLocale'; import DonationHistories from 'src/components/Dashboard/DonationHistories'; import { useGetDonationGraphQuery } from 'src/components/Reports/DonationsReport/GetDonationGraph.generated'; +import { SideContainerText } from './StyledComponents'; +import { CollapsibleEmailList } from './CollapsibleEmailList'; +import { CollapsiblePhoneList } from './CollapsiblePhoneList'; +import { getLastNewsletter } from './helpers'; + +export enum AccountListTypeEnum { + Own = 'Own', + Coaching = 'Coaching', +} interface CoachingDetailProps { - coachingId: string; - isAccountListId: boolean; + accountListId: string; + // Whether the account list belongs to the user or someone that the user coaches + accountListType: AccountListTypeEnum; } const CoachingLoadingSkeleton = styled(Skeleton)(({ theme }) => ({ @@ -32,14 +43,14 @@ const CoachingLoadingSkeleton = styled(Skeleton)(({ theme }) => ({ })); const CoachingDetailContainer = styled(Box)(({}) => ({ - width: '100&', + width: '100%', minHeight: '100%', display: 'flex', })); const CoachingSideContainer = styled(Box)(({ theme }) => ({ + width: '20rem', minHeight: '100%', - flexGrow: 1, padding: theme.spacing(1), })); @@ -61,6 +72,7 @@ const CoachingItemContainer = styled(Box)(({ theme }) => ({ })); const CoachingMainTitleContainer = styled(Box)(({ theme }) => ({ + flexGrow: 1, display: 'flex', margin: theme.spacing(1), alignItems: 'center', @@ -72,66 +84,74 @@ const CoachingMonthYearButtonGroup = styled(ButtonGroup)(({ theme }) => ({ color: theme.palette.primary.contrastText, })); -const SideContainerText = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.contrastText, - margin: theme.spacing(0, 1), -})); - const SideContainerIcon = styled(AccountCircle)(({ theme }) => ({ color: theme.palette.primary.contrastText, margin: theme.spacing(0, 1), })); export const CoachingDetail: React.FC = ({ - coachingId, - isAccountListId = false, + accountListId, + accountListType, }) => { const { t } = useTranslation(); const locale = useLocale(); - const { data: ownData, loading } = useLoadAccountListCoachingDetailQuery({ - variables: { coachingId }, - skip: !isAccountListId, - }); + + const { data: ownData, loading: ownLoading } = + useLoadAccountListCoachingDetailQuery({ + variables: { accountListId }, + skip: accountListType !== AccountListTypeEnum.Own, + }); const { data: coachingData, loading: coachingLoading } = useLoadCoachingDetailQuery({ - variables: { coachingId }, - skip: isAccountListId, + variables: { coachingAccountListId: accountListId }, + skip: accountListType !== AccountListTypeEnum.Coaching, }); - const { data: coachingUsersData, loading: coachingUsersLoading } = - useGetAccountListCoachUsersQuery({ - variables: { accountListId: coachingId }, - }); + const loading = + accountListType === AccountListTypeEnum.Own ? ownLoading : coachingLoading; + const accountListData = + accountListType === AccountListTypeEnum.Own + ? ownData?.accountList + : coachingData?.coachingAccountList; - const { data: accountListUsersData, loading: accountListUsersLoading } = - useGetAccountListUsersQuery({ - variables: { accountListId: coachingId }, - }); + const staffIds = useMemo( + () => + accountListData?.designationAccounts + .map((account) => account.accountNumber) + .filter((number) => number.length > 0) ?? [], + [accountListData], + ); const { data: ownDonationGraphData } = useGetDonationGraphQuery({ variables: { - accountListId: coachingId, + accountListId, }, - skip: !isAccountListId, + skip: accountListType !== AccountListTypeEnum.Own, }); const { data: coachingDonationGraphData } = useGetCoachingDonationGraphQuery({ variables: { - coachingAccountListId: coachingId, + coachingAccountListId: accountListId, }, - skip: isAccountListId, + skip: accountListType !== AccountListTypeEnum.Coaching, }); - const accountListData = isAccountListId - ? ownData?.accountList - : coachingData?.coachingAccountList; + const donationGraphData = + accountListType === AccountListTypeEnum.Own + ? ownDonationGraphData + : coachingDonationGraphData; + + const { data: taskAnalyticsData } = useGetTaskAnalyticsQuery({ + variables: { + accountListId, + }, + }); - const donationGraphData = isAccountListId - ? ownDonationGraphData - : coachingDonationGraphData; + const formatOptionalDate = (isoDate: string | null | undefined): string => + isoDate ? dateFormat(DateTime.fromISO(isoDate), locale) : t('None'); - const [isMonthly, setIsMonthly] = useState(true); + const [isMonthly, setIsMonthly] = useState(false); return ( @@ -156,50 +176,76 @@ export const CoachingDetail: React.FC = ({ size="large" > + + {t('Balance:')}{' '} + {accountListData && + currencyFormat( + accountListData.balance, + accountListData.currency, + locale, + )} + {t('Staff IDs:')} - None - - {t('Last Prayer Letter:') /* TODO: Add value */} + + {staffIds.length ? staffIds.join(', ') : t('None')} + + + {t('Last Prayer Letter:')}{' '} + {taskAnalyticsData && + formatOptionalDate( + getLastNewsletter( + taskAnalyticsData.taskAnalytics + .lastElectronicNewsletterCompletedAt, + taskAnalyticsData.taskAnalytics + .lastPhysicalNewsletterCompletedAt, + ), + )} {t('MPD Info')} - - {t('Week on MPD:') /* TODO: Add Value */} + + {t('Weeks on MPD:')} {accountListData?.weeksOnMpd} - - {t('Start Date:') /* TODO: Add Value */} + + {t('Start Date:')}{' '} + {accountListData && + formatOptionalDate(accountListData?.activeMpdStartAt)} - - {t('End Date:') /* TODO: Add Value */} + + {t('End Date:')}{' '} + {accountListData && + formatOptionalDate(accountListData?.activeMpdFinishAt)} - - {t('Commitment Goal:') + - ' ' + - currencyFormat( - accountListData?.monthlyGoal ? accountListData?.monthlyGoal : 0, - accountListData?.currency, - locale, - )} + + {t('Commitment Goal:')}{' '} + {accountListData && + (typeof accountListData.activeMpdMonthlyGoal === 'number' + ? currencyFormat( + accountListData.activeMpdMonthlyGoal, + accountListData?.currency, + locale, + ) + : t('None'))} {t('Users')} - {accountListUsersLoading ? ( + {loading ? ( <> @@ -207,27 +253,23 @@ export const CoachingDetail: React.FC = ({ ) : ( - accountListUsersData?.accountListUsers.nodes.map( - (accountList, _index) => { - return ( - - - - {accountList.user.firstName + - ' ' + - accountList.user.lastName} - - - - ); - }, - ) + accountListData?.users.nodes.map((user) => ( + + + + {user.firstName + ' ' + user.lastName} + + + + + + )) )} {t('Coaches')} - {coachingUsersLoading ? ( + {loading ? ( <> @@ -235,21 +277,21 @@ export const CoachingDetail: React.FC = ({ ) : ( - coachingUsersData?.getAccountListCoachUsers?.map((user, _index) => { - return ( - <> - - - {user?.firstName + ' ' + user?.lastName} - - - - ); - }) + accountListData?.coaches.nodes.map((coach) => ( + + + + {coach.firstName + ' ' + coach.lastName} + + + + + + )) )} - {loading || coachingLoading ? ( + {ownLoading || coachingLoading ? ( <> @@ -272,7 +314,7 @@ export const CoachingDetail: React.FC = ({ = ({ /> diff --git a/src/components/Coaching/CoachingDetail/CollapsibleEmailList.test.tsx b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.test.tsx new file mode 100644 index 000000000..d739ce65f --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.test.tsx @@ -0,0 +1,96 @@ +import { render } from '@testing-library/react'; +import { CollapsibleEmailList } from './CollapsibleEmailList'; + +describe('CollapsibleEmailList', () => { + it('renders the primary email address and location', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('EmailAddress')).toHaveTextContent( + 'example2@example.com - Home', + ); + expect(getByTestId('ExpandMoreIcon')).toBeInTheDocument(); + }); + + it('renders the first email address if none are primary', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('example1@example.com')).toBeInTheDocument(); + expect(getByTestId('ExpandMoreIcon')).toBeInTheDocument(); + }); + + it('renders missing email locations', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('EmailAddress')).toHaveTextContent( + 'example1@example.com', + ); + }); + + it('hides the toggle when there are zero emails', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('ExpandMoreIcon')).not.toBeInTheDocument(); + }); + + it('hides the toggle when there is only one email', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('ExpandMoreIcon')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx new file mode 100644 index 000000000..5ab6a0855 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx @@ -0,0 +1,47 @@ +import { EmailAddress } from '../../../../graphql/types.generated'; +import { + ContrastLink, + ContactInfoText, + SideContainerText, +} from './StyledComponents'; +import { CollapsibleList } from './CollapsibleList'; + +interface EmailProps { + email: Pick; +} + +const Email: React.FC = ({ email }) => ( + + {email.email} + {email.location ? ` - ${email.location}` : null} + +); + +interface CollapsibleEmailListProps { + emails: Array>; +} + +export const CollapsibleEmailList: React.FC = ({ + emails, +}) => { + const primaryEmail = emails.find((email) => email.primary) ?? emails[0]; + if (!primaryEmail) { + return null; + } + const secondaryEmails = emails.filter((email) => email !== primaryEmail); + + return ( + } + secondaryItems={ + secondaryEmails.length + ? secondaryEmails.map((email) => ( + + + + )) + : null + } + /> + ); +}; diff --git a/src/components/Coaching/CoachingDetail/CollapsibleList.test.tsx b/src/components/Coaching/CoachingDetail/CollapsibleList.test.tsx new file mode 100644 index 000000000..d438a0838 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsibleList.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CollapsibleList } from './CollapsibleList'; + +describe('CollapsibleList', () => { + it('renders the primary items', () => { + const { getByText } = render(); + + expect(getByText('Primary')).toBeInTheDocument(); + }); + + it('hides the toggle if there are no secondary items', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('ExpandMoreIcon')).not.toBeInTheDocument(); + }); + + it('initially hides the secondary items', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Secondary')).not.toBeInTheDocument(); + }); + + it('toggles the secondary items visible', () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + userEvent.click(getByTestId('ExpandMoreIcon')); + expect(getByText('Secondary')).toBeInTheDocument(); + + userEvent.click(getByTestId('ExpandLessIcon')); + expect(queryByText('Secondary')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/CollapsibleList.tsx b/src/components/Coaching/CoachingDetail/CollapsibleList.tsx new file mode 100644 index 000000000..7a7f7fee4 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsibleList.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { styled } from '@mui/material/styles'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import { SideContainerText } from './StyledComponents'; + +const ExpandMoreIcon = styled(ExpandMore)(({ theme }) => ({ + color: theme.palette.primary.contrastText, + cursor: 'pointer', +})); + +const ExpandLessIcon = styled(ExpandLess)(({ theme }) => ({ + color: theme.palette.primary.contrastText, + cursor: 'pointer', +})); + +interface CollapsibleListProps { + primaryItem: React.ReactNode; + secondaryItems?: React.ReactNode; +} + +export const CollapsibleList: React.FC = ({ + primaryItem, + secondaryItems, +}) => { + const [moreVisible, setMoreVisible] = useState(false); + + return ( + <> + + {primaryItem} + {secondaryItems && + (moreVisible ? ( + setMoreVisible(false)} /> + ) : ( + setMoreVisible(true)} /> + ))} + + {moreVisible && secondaryItems} + + ); +}; diff --git a/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.test.tsx b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.test.tsx new file mode 100644 index 000000000..9d099c67f --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.test.tsx @@ -0,0 +1,92 @@ +import { render } from '@testing-library/react'; +import { CollapsiblePhoneList } from './CollapsiblePhoneList'; + +describe('CollapsiblePhoneList', () => { + it('renders the primary phone address and location', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('PhoneNumber')).toHaveTextContent('222-222-2222 - Home'); + expect(getByTestId('ExpandMoreIcon')).toBeInTheDocument(); + }); + + it('renders the first phone address if none are primary', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('111-111-1111')).toBeInTheDocument(); + expect(getByTestId('ExpandMoreIcon')).toBeInTheDocument(); + }); + + it('renders missing phone locations', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('PhoneNumber')).toHaveTextContent('111-111-1111'); + }); + + it('hides the toggle when there are zero phones', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('ExpandMoreIcon')).not.toBeInTheDocument(); + }); + + it('hides the toggle when there is only one phone', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('ExpandMoreIcon')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx new file mode 100644 index 000000000..691b937b8 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx @@ -0,0 +1,47 @@ +import { PhoneNumber } from '../../../../graphql/types.generated'; +import { + ContrastLink, + ContactInfoText, + SideContainerText, +} from './StyledComponents'; +import { CollapsibleList } from './CollapsibleList'; + +interface PhoneProps { + phone: Pick; +} + +const Phone: React.FC = ({ phone }) => ( + + {phone.number} + {phone.location ? ` - ${phone.location}` : null} + +); + +interface CollapsiblePhoneListProps { + phones: Array>; +} + +export const CollapsiblePhoneList: React.FC = ({ + phones, +}) => { + const primaryPhone = phones.find((phone) => phone.primary) ?? phones[0]; + if (!primaryPhone) { + return null; + } + const secondaryPhones = phones.filter((phone) => phone !== primaryPhone); + + return ( + } + secondaryItems={ + secondaryPhones.length + ? secondaryPhones.map((phone) => ( + + + + )) + : null + } + /> + ); +}; diff --git a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql index 841407701..005688f28 100644 --- a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql +++ b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql @@ -1,7 +1,33 @@ -query LoadCoachingDetail($coachingId: ID!) { - coachingAccountList(id: $coachingId) { +fragment UserContactInfo on UserScopedToAccountList { + id + firstName + lastName + emailAddresses { + nodes { + id + email + location + primary + } + } + phoneNumbers { + nodes { + id + number + location + primary + } + } +} + +query LoadCoachingDetail($coachingAccountListId: ID!) { + coachingAccountList(id: $coachingAccountListId) { id name + designationAccounts { + id + accountNumber + } primaryAppeal { active amount @@ -15,15 +41,33 @@ query LoadCoachingDetail($coachingId: ID!) { currency monthlyGoal balance + activeMpdStartAt + activeMpdFinishAt + activeMpdMonthlyGoal + weeksOnMpd receivedPledges totalPledges + coaches { + nodes { + ...UserContactInfo + } + } + users { + nodes { + ...UserContactInfo + } + } } } -query LoadAccountListCoachingDetail($coachingId: ID!) { - accountList(id: $coachingId) { +query LoadAccountListCoachingDetail($accountListId: ID!) { + accountList(id: $accountListId) { id name + designationAccounts { + id + accountNumber + } primaryAppeal { active amount @@ -37,36 +81,20 @@ query LoadAccountListCoachingDetail($coachingId: ID!) { currency monthlyGoal balance + activeMpdStartAt + activeMpdFinishAt + activeMpdMonthlyGoal + weeksOnMpd receivedPledges totalPledges - } -} - -query GetAccountListCoachUsers($accountListId: ID!) { - getAccountListCoachUsers(accountListId: $accountListId) { - id - firstName - lastName - createdAt - updatedAt - updatedInDbAt - } -} - -query GetAccountListUsers($accountListId: ID!) { - accountListUsers(accountListId: $accountListId) { - pageInfo { - endCursor - startCursor - hasNextPage - hasPreviousPage + coaches { + nodes { + ...UserContactInfo + } } - nodes { - id - user { - id - firstName - lastName + users { + nodes { + ...UserContactInfo } } } diff --git a/src/components/Coaching/CoachingDetail/StyledComponents.tsx b/src/components/Coaching/CoachingDetail/StyledComponents.tsx new file mode 100644 index 000000000..3836321e4 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/StyledComponents.tsx @@ -0,0 +1,16 @@ +import { Link, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const SideContainerText = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.contrastText, + margin: theme.spacing(0, 1), +})); + +export const ContrastLink = styled(Link)(({ theme }) => ({ + color: theme.palette.primary.contrastText, + textDecorationColor: theme.palette.primary.contrastText, +})); + +export const ContactInfoText = styled('span')({ + overflow: 'hidden', +}); diff --git a/src/components/Coaching/CoachingDetail/helpers.test.tsx b/src/components/Coaching/CoachingDetail/helpers.test.tsx new file mode 100644 index 000000000..c8a135e2a --- /dev/null +++ b/src/components/Coaching/CoachingDetail/helpers.test.tsx @@ -0,0 +1,22 @@ +import { getLastNewsletter } from './helpers'; + +describe('getLastNewsletter', () => { + it('returns the latest date when two dates are provided', () => { + expect( + getLastNewsletter('2023-01-01T00:00:00Z', '2023-01-02T00:00:00Z'), + ).toBe('2023-01-02T00:00:00Z'); + }); + + it('returns the other date when one is missing', () => { + expect(getLastNewsletter('2023-01-01T00:00:00Z', null)).toBe( + '2023-01-01T00:00:00Z', + ); + expect(getLastNewsletter(null, '2023-01-01T00:00:00Z')).toBe( + '2023-01-01T00:00:00Z', + ); + }); + + it('returns null when both dates are missing', () => { + expect(getLastNewsletter(null, null)).toBeNull(); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/helpers.ts b/src/components/Coaching/CoachingDetail/helpers.ts new file mode 100644 index 000000000..2e640368c --- /dev/null +++ b/src/components/Coaching/CoachingDetail/helpers.ts @@ -0,0 +1,14 @@ +export const getLastNewsletter = ( + electronicDate: string | null | undefined, + physicalDate: string | null | undefined, +): string | null => { + if (electronicDate && physicalDate) { + return electronicDate > physicalDate ? electronicDate : physicalDate; + } else if (electronicDate) { + return electronicDate; + } else if (physicalDate) { + return physicalDate; + } else { + return null; + } +}; diff --git a/src/components/Layouts/Primary/NavBar/NavBar.tsx b/src/components/Layouts/Primary/NavBar/NavBar.tsx index 1fab21adf..acfa204d0 100644 --- a/src/components/Layouts/Primary/NavBar/NavBar.tsx +++ b/src/components/Layouts/Primary/NavBar/NavBar.tsx @@ -139,14 +139,14 @@ export const NavBar: FC = ({ onMobileClose, openMobile }) => { }, { title: t('Tools'), - items: ToolsList.flatMap((toolsGroup) => [ - ...toolsGroup.items.map((tool) => ({ + items: ToolsList.flatMap((toolsGroup) => + toolsGroup.items.map((tool) => ({ title: tool.tool, href: `https://${process.env.REWRITE_DOMAIN}/tools/${ toolsRedirectLinks[tool.id] }`, })), - ]), + ), }, { title: t('Coaches'), diff --git a/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.test.tsx b/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.test.tsx index 9b276bdbc..2b694912f 100644 --- a/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.test.tsx +++ b/src/components/Reports/DesignationAccountsReport/DesignationAccountsReport.test.tsx @@ -1,5 +1,6 @@ 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 { DesignationAccountsDocument, @@ -154,4 +155,26 @@ describe('DesignationAccountsReport', () => { expect(getByText(title)).toBeInTheDocument(); expect(queryByTestId('EmptyReport')).toBeInTheDocument(); }); + + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + onNavListToggle.mockClear(); + const { getByTestId } = render( + + + mocks={mocks} + > + + + , + ); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); }); diff --git a/src/components/Reports/DonationsReport/DonationsReport.test.tsx b/src/components/Reports/DonationsReport/DonationsReport.test.tsx index b9cb0b27f..1054cf003 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.test.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.test.tsx @@ -1,5 +1,6 @@ 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 { DateTime } from 'luxon'; import theme from '../../../theme'; @@ -235,4 +236,26 @@ describe('DonationsReport', () => { }), ); }); + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + onNavListToggle.mockClear(); + const { getByTestId } = render( + + + mocks={mocks}> + + + + , + ); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); }); diff --git a/src/components/Reports/ExpectedMonthlyTotalReport/ExpectedMonthlyTotalReport.test.tsx b/src/components/Reports/ExpectedMonthlyTotalReport/ExpectedMonthlyTotalReport.test.tsx index 8e452ea76..f4bae0025 100644 --- a/src/components/Reports/ExpectedMonthlyTotalReport/ExpectedMonthlyTotalReport.test.tsx +++ b/src/components/Reports/ExpectedMonthlyTotalReport/ExpectedMonthlyTotalReport.test.tsx @@ -1,5 +1,6 @@ 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 theme from '../../../theme'; import { GetExpectedMonthlyTotalsQuery } from '../../../../pages/accountLists/[accountListId]/reports/GetExpectedMonthlyTotals.generated'; @@ -142,4 +143,25 @@ describe('ExpectedMonthlyTotalReport', () => { }), ); }); + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + onNavListToggle.mockClear(); + const { getByTestId } = render( + + + + + + + , + ); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); }); diff --git a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx index 15ae8206f..211953ea8 100644 --- a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx +++ b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.test.tsx @@ -213,6 +213,9 @@ const mocks: Mocks = { }; describe('PartnerGivingAnalysisReport', () => { + beforeEach(() => { + onNavListToggle.mockClear(); + }); it('loading', async () => { const { queryByTestId, queryByText } = render( @@ -581,4 +584,26 @@ describe('PartnerGivingAnalysisReport', () => { // Test that it rounds to two decimal points expect(getByText('CA$86.47')).toBeInTheDocument(); }); + + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + const { getByTestId } = render( + + + mocks={mocks} + > + + + , + ); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); }); diff --git a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx b/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx index e3fa91838..ad0f0f196 100644 --- a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx +++ b/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx @@ -1,5 +1,6 @@ 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 { FinancialAccountsDocument, @@ -64,6 +65,9 @@ const emptyMocks = { }; describe('ResponsibilityCentersReport', () => { + beforeEach(() => { + onNavListToggle.mockClear(); + }); it('default', async () => { const { getByText, getByTestId, queryByTestId } = render( @@ -93,6 +97,27 @@ describe('ResponsibilityCentersReport', () => { expect(getByTestId('ResponsibilityCentersScrollBox')).toBeInTheDocument(); }); + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + const { getByTestId } = render( + + + mocks={mocks} + > + + + , + ); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); + it('loading', async () => { const { queryByTestId, getByText } = render( diff --git a/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx b/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx new file mode 100644 index 000000000..682243e09 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordianGroup.test.tsx @@ -0,0 +1,13 @@ +import { render } from '@testing-library/react'; +import { AccordionGroup } from './AccordionGroup'; + +describe('AccordionGroup', () => { + it('Should load title and children', () => { + const { getByText } = render( + Children, + ); + + expect(getByText('AccordionGroupTitle')).toBeInTheDocument(); + expect(getByText('Children')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shared/Forms/Accordions/AccordionGroup.tsx b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx new file mode 100644 index 000000000..3c3a93cb4 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionGroup.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@mui/material'; +import React from 'react'; + +interface AccordionGroupProps { + title: string; + children?: React.ReactNode; +} + +export const AccordionGroup: React.FC = ({ + title, + children, +}) => { + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx new file mode 100644 index 000000000..03b6f4fbc --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionItem.test.tsx @@ -0,0 +1,152 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { AccordionItem } from './AccordionItem'; + +const expandedPanel = 'expandedPanel'; + +describe('AccordionItem', () => { + const onAccordionChange = jest.fn(); + beforeEach(() => { + onAccordionChange.mockClear(); + }); + it('Should not render Accordian Details', () => { + const { queryByText } = render( + + + Children + + , + ); + + expect(queryByText('Children')).not.toBeInTheDocument(); + }); + it('Should not render value', () => { + const { queryByTestId } = render( + + + Children + + , + ); + + expect(queryByTestId('AccordionSummaryValue')).not.toBeInTheDocument(); + }); + + it('Should render label', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('expandedPanel')).toBeInTheDocument(); + }); + + it('Should render value', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('AccordianValue')).toBeInTheDocument(); + }); + + it('Should render Accordian Details', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render Children with FullWidth', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render Children and Image', () => { + const { getByText } = render( + + + Children + + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + expect(getByText('image.png')).toBeInTheDocument(); + }); + it('Should run onAccordionChange()', () => { + const { getByTestId } = render( + + + Children + + , + ); + + userEvent.click(getByTestId('AccordionSummaryValue')); + expect(onAccordionChange).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.tsx new file mode 100644 index 000000000..47981f7b6 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/AccordionItem.tsx @@ -0,0 +1,169 @@ +import React, { useMemo } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { ExpandMore } from '@mui/icons-material'; + +export const accordionShared = { + '&:before': { + content: 'none', + }, + '& .MuiAccordionSummary-root.Mui-expanded': { + minHeight: 'unset', + }, +}; + +const StyledAccordion = styled(Accordion)(() => ({ + overflow: 'hidden', + ...accordionShared, +})); + +const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ + '&.Mui-expanded': { + backgroundColor: theme.palette.mpdxYellow.main, + }, + '& .MuiAccordionSummary-content': { + [theme.breakpoints.only('xs')]: { + flexDirection: 'column', + }, + }, +})); + +const StyledAccordionColumn = styled(Box)(({ theme }) => ({ + paddingRight: theme.spacing(2), + flexBasis: '100%', + [theme.breakpoints.only('xs')]: { + '&:nth-child(2)': { + fontStyle: 'italic', + }, + }, + [theme.breakpoints.up('md')]: { + '&:first-child:not(:last-child)': { + flexBasis: '33.33%', + }, + '&:nth-child(2)': { + flexBasis: '66.66%', + }, + }, +})); + +const StyledAccordionDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + flexBasis: 'calc((100% - 36px) * 0.661)', + marginLeft: 'calc((100% - 36px) * 0.338)', + }, +})); + +const AccordionLeftDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.338)', + }, + [theme.breakpoints.down('md')]: { + width: '200px', + }, + [theme.breakpoints.down('sm')]: { + marginBottom: '10px', + width: '100%', + }, +})); + +const AccordionRightDetails = styled(Box)(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + width: 'calc((100% - 36px) * 0.661)', + }, + [theme.breakpoints.down('md')]: { + width: 'calc(100% - 200px)', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, +})); + +const AccordionLImageDetails = styled(Box)(({ theme }) => ({ + display: 'flex', + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + }, +})); + +const AccordionLeftDetailsImage = styled(Box)(({ theme }) => ({ + maxWidth: '200px', + ' & > img': { + width: '100%', + }, + + [theme.breakpoints.down('md')]: { + ' & > img': { + maxWidth: '150px', + }, + }, + [theme.breakpoints.down('sm')]: { + ' & > img': { + maxWidth: '100px', + }, + }, +})); + +interface AccordionItemProps { + onAccordionChange: (label: string) => void; + expandedPanel: string; + label: string; + value: string; + children?: React.ReactNode; + fullWidth?: boolean; + image?: React.ReactNode; +} + +export const AccordionItem: React.FC = ({ + onAccordionChange, + expandedPanel, + label, + value, + children, + fullWidth = false, + image, +}) => { + const expanded = useMemo( + () => expandedPanel.toLowerCase() === label.toLowerCase(), + [expandedPanel, label], + ); + return ( + onAccordionChange(label)} + expanded={expanded} + disableGutters + > + }> + + {label} + + {value && ( + + {value} + + )} + + {expanded && ( + + {!fullWidth && !image && ( + {children} + )} + {fullWidth && !image && {children}} + {image && ( + + + {image} + + {children} + + )} + + )} + + ); +}; diff --git a/src/components/Shared/Forms/DialogActions.test.tsx b/src/components/Shared/Forms/DialogActions.test.tsx new file mode 100644 index 000000000..cd549b1bb --- /dev/null +++ b/src/components/Shared/Forms/DialogActions.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { DialogActionsLeft } from './DialogActions'; + +describe('DialogActionsLeft', () => { + it('Should render children and pass down args', () => { + const { getByText, getByTestId } = render( + + Children + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + expect(getByTestId('dataTestId')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Shared/Forms/DialogActions.tsx b/src/components/Shared/Forms/DialogActions.tsx new file mode 100644 index 000000000..8f9ac2e4d --- /dev/null +++ b/src/components/Shared/Forms/DialogActions.tsx @@ -0,0 +1,16 @@ +import { DialogActions, DialogActionsProps } from '@mui/material'; +import React from 'react'; + +export const DialogActionsLeft: React.FC = ({ + children, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Shared/Forms/FieldHelper.tsx b/src/components/Shared/Forms/FieldHelper.tsx new file mode 100644 index 000000000..912cf12fb --- /dev/null +++ b/src/components/Shared/Forms/FieldHelper.tsx @@ -0,0 +1,33 @@ +import { FormLabel, FormHelperText, OutlinedInput, Theme } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export enum HelperPositionEnum { + Top = 'top', + Bottom = 'bottom', +} + +const SharedFieldStyles = ({ theme }: { theme: Theme }) => ({ + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +}); + +export const StyledOutlinedInput = styled(OutlinedInput)(SharedFieldStyles); + +export const StyledFormLabel = styled(FormLabel)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 700, + marginBottom: theme.spacing(1), + '& .MuiFormControlLabel-label': { + fontWeight: '700', + }, +})); + +export const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + margin: 0, + fontSize: 16, + color: theme.palette.text.primary, + '&:not(:first-child)': { + marginTop: theme.spacing(1), + }, +})); diff --git a/src/components/Shared/Forms/FieldWrapper.test.tsx b/src/components/Shared/Forms/FieldWrapper.test.tsx new file mode 100644 index 000000000..7c26c66f2 --- /dev/null +++ b/src/components/Shared/Forms/FieldWrapper.test.tsx @@ -0,0 +1,69 @@ +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from 'src/theme'; +import { FieldWrapper } from './FieldWrapper'; +import { HelperPositionEnum } from './FieldHelper'; + +describe('FieldWrapper', () => { + it('Should render children', () => { + const { getByText } = render( + + Children + , + ); + + expect(getByText('Children')).toBeInTheDocument(); + }); + + it('Should render labelText', () => { + const { getByText } = render( + + + , + ); + + expect(getByText('labelText')).toBeInTheDocument(); + }); + + it('Should render Helper Text before Children', () => { + const { getByTestId, getByText } = render( + + +

Children

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

Children

+
+
, + ); + + expect( + getByTestId('helper-text-bottom').compareDocumentPosition( + getByText('Children'), + ), + ).toBe(2); + }); +}); diff --git a/src/components/Shared/Forms/FieldWrapper.tsx b/src/components/Shared/Forms/FieldWrapper.tsx new file mode 100644 index 000000000..7eebbfc1c --- /dev/null +++ b/src/components/Shared/Forms/FieldWrapper.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FormControl, FormControlProps } from '@mui/material'; +import { + HelperPositionEnum, + StyledFormHelperText, + StyledFormLabel, +} from './FieldHelper'; + +interface FieldWrapperProps { + labelText?: string; + helperText?: string; + helperPosition?: HelperPositionEnum; + formControlDisabled?: FormControlProps['disabled']; + formControlError?: FormControlProps['error']; + formControlFullWidth?: FormControlProps['fullWidth']; + formControlRequired?: FormControlProps['required']; + formControlVariant?: FormControlProps['variant']; + formHelperTextProps?: object; + children?: React.ReactNode; +} + +export const FieldWrapper: React.FC = ({ + labelText = '', + helperText = '', + helperPosition = HelperPositionEnum.Top, + formControlDisabled = false, + formControlError = false, + formControlFullWidth = true, + formControlRequired = false, + formControlVariant = 'outlined', + formHelperTextProps = { variant: 'standard' }, + children, +}) => { + const { t } = useTranslation(); + const labelOutput = labelText ? ( + + {t(labelText)} + + ) : ( + '' + ); + + const helperTextOutput = helperText ? ( + + {t(helperText)} + + ) : ( + '' + ); + + return ( + + {labelOutput} + {helperPosition === HelperPositionEnum.Top && helperTextOutput} + {children} + {helperPosition === HelperPositionEnum.Bottom && helperTextOutput} + + ); +}; diff --git a/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx index 0681c19ff..9ef1d1b04 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageHeader.test.tsx @@ -46,4 +46,21 @@ describe('MultiPageHeader', () => { expect(queryByText('CA111')).toBeNull(); }); + + it('should render the Settings menu', async () => { + const { getByTestId, getByText } = render( + + + , + ); + + expect(getByText('Toggle Preferences Menu')).toBeInTheDocument(); + expect(getByTestId('SettingsMenuIcon')).toBeInTheDocument(); + }); }); diff --git a/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx index 9ce75ed00..8693299d3 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx @@ -84,10 +84,16 @@ export const MultiPageHeader: FC = ({ > {headerType === HeaderTypeEnum.Report && ( - + )} {headerType === HeaderTypeEnum.Settings && ( - + )} diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx index bbd8016fb..c1f587efa 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/Item/Item.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { Item } from './Item'; -import { render } from '__tests__/util/testingLibraryReactMock'; +import { render, waitFor } from '__tests__/util/testingLibraryReactMock'; import TestWrapper from '__tests__/util/TestWrapper'; import { NavTypeEnum } from '../MultiPageMenu'; +import userEvent from '@testing-library/user-event'; const item = { id: 'testItem', @@ -10,14 +11,74 @@ const item = { subTitle: 'test subTitle', }; +const itemWithSubitems = { + id: 'testItem', + title: 'test title', + subTitle: 'test subTitle', + subItems: [ + { + id: 'organizations', + title: 'Impersonate & Share', + grantedAccess: ['admin'], + }, + { + id: 'organizations/accountLists', + title: 'Account Lists', + grantedAccess: ['admin'], + }, + ], +}; + describe('Item', () => { it('default', () => { - const { queryByText } = render( + const { queryByText, queryByTestId } = render( , ); expect(queryByText(item.title)).toBeInTheDocument(); expect(queryByText(item.subTitle)).toBeInTheDocument(); + expect(queryByTestId('multiPageMenuCollapser')).not.toBeInTheDocument(); + }); + + it('should render subItems', async () => { + const { getByTestId, queryByText } = render( + + + , + ); + + const arrowForwardIosIcon = getByTestId('ArrowForwardIosIcon'); + + expect(arrowForwardIosIcon).toBeInTheDocument(); + + expect(queryByText('Impersonate & Share')).not.toBeInTheDocument(); + expect(queryByText('Account Lists')).not.toBeInTheDocument(); + + userEvent.click(arrowForwardIosIcon); + + await waitFor(() => { + expect(queryByText('Impersonate & Share')).toBeInTheDocument(); + expect(queryByText('Account Lists')).toBeInTheDocument(); + }); + }); + + it('should show subItems as one if selected', async () => { + const { getByText } = render( + + + , + ); + + expect(getByText('Impersonate & Share')).toBeInTheDocument(); + expect(getByText('Account Lists')).toBeInTheDocument(); }); }); diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx index 98fc06f9b..1ef0863ab 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.test.tsx @@ -7,6 +7,7 @@ import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from 'src/theme'; import { GetDesignationAccountsQuery } from 'src/components/Reports/DonationsReport/Table/Modal/EditDonation.generated'; +import { GetUserAccessQuery } from './MultiPageMenuItems.generated'; const accountListId = 'account-list-1'; const selected = 'salaryCurrency'; @@ -142,4 +143,92 @@ describe('MultiPageMenu', () => { queryByRole('combobox', { name: 'Designation Account' }), ).not.toBeInTheDocument(); }); + + it('shows the developer tools', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByText } = render( + + + + mocks={{ + GetUserAccess: { + user: { + admin: false, + developer: true, + }, + }, + }} + onCall={mutationSpy} + > + {}} + designationAccounts={[]} + setDesignationAccounts={jest.fn()} + navType={NavTypeEnum.Settings} + /> + + + , + ); + + await waitFor(() => expect(mutationSpy).toHaveBeenCalled()); + + await waitFor(() => { + expect(queryByText('Manage Organizations')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText('Admin Console')).toBeInTheDocument(); + expect(getByText('Backend Admin')).toBeInTheDocument(); + expect(getByText('Sidekiq')).toBeInTheDocument(); + }); + }); + + it('shows the admin tools', async () => { + const mutationSpy = jest.fn(); + const { queryByText, getByText } = render( + + + + mocks={{ + GetUserAccess: { + user: { + admin: true, + developer: false, + }, + }, + }} + onCall={mutationSpy} + > + {}} + designationAccounts={[]} + setDesignationAccounts={jest.fn()} + navType={NavTypeEnum.Settings} + /> + + + , + ); + + await waitFor(() => expect(mutationSpy).toHaveBeenCalled()); + + await waitFor(() => { + expect(queryByText('Sidekiq')).not.toBeInTheDocument(); + expect(queryByText('Backend Admin')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText('Manage Organizations')).toBeInTheDocument(); + expect(getByText('Admin Console')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx index bc030e15c..17b8f4e2e 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu.tsx @@ -139,7 +139,7 @@ export const MultiPageMenu: React.FC = ({ } } else return true; return false; - }, [item]); + }, [item, userPrivileges]); if (!showItem) return null; return (