diff --git a/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx index 567f4b7a2..2a8986cd7 100644 --- a/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx @@ -1,8 +1,6 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { useEffect, useMemo, useState } from 'react'; -import Box from '@mui/material/Box'; -import { styled } from '@mui/material/styles'; import { sortBy } from 'lodash'; import { useTranslation } from 'react-i18next'; import { ReportContactFilterSetInput } from 'pages/api/graphql-rest.page.generated'; @@ -19,10 +17,6 @@ import { getQueryParam } from 'src/utils/queryParam'; import { useContactFiltersQuery } from '../../contacts/Contacts.generated'; import { ContactsPage } from '../../contacts/ContactsPage'; -const PartnerGivingAnalysisReportPageWrapper = styled(Box)(({ theme }) => ({ - backgroundColor: theme.palette.common.white, -})); - // The order here is also the sort order and the display order const reportFilters = [ 'designation_account_id', @@ -96,48 +90,46 @@ const PartnerGivingAnalysisReportPage: React.FC = () => { {accountListId ? ( - - - ) : ( - setNavListOpen(false)} - onSelectedFiltersChanged={setActiveFilters} - /> - ) - } - leftOpen={isNavListOpen} - leftWidth="290px" - mainContent={ - + ) : ( + setNavListOpen(false)} + onSelectedFiltersChanged={setActiveFilters} /> - } - rightPanel={ - selectedContactId ? ( - - handleSelectContact('')} /> - - ) : undefined - } - rightOpen={typeof selectedContactId !== 'undefined'} - rightWidth="60%" - /> - + ) + } + leftOpen={isNavListOpen} + leftWidth="290px" + mainContent={ + + } + rightPanel={ + selectedContactId ? ( + + handleSelectContact('')} /> + + ) : undefined + } + rightOpen={typeof selectedContactId !== 'undefined'} + rightWidth="60%" + /> ) : ( )} diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx index 5e8599093..97fd3f2f3 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx @@ -1,25 +1,17 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import userEvent from '@testing-library/user-event'; -import { DateTime } from 'luxon'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { render, waitFor } from '__tests__/util/testingLibraryReactMock'; +import matchMediaMock from '__tests__/util/matchMediaMock'; +import { render } from '__tests__/util/testingLibraryReactMock'; import theme from 'src/theme'; import { afterTestResizeObserver, beforeTestResizeObserver, } from 'src/utils/tests/windowResizeObserver'; -import { AppointmentResults } from './AppointmentResults/AppointmentResults'; -import { - AccountListTypeEnum, - CoachingDetail, - CoachingPeriodEnum, -} from './CoachingDetail'; -import { - LoadAccountListCoachingDetailQuery, - LoadCoachingDetailQuery, -} from './LoadCoachingDetail.generated'; +import { AccountListTypeEnum, CoachingDetail } from './CoachingDetail'; +import { LoadCoachingDetailQuery } from './LoadCoachingDetail.generated'; jest.mock('./AppointmentResults/AppointmentResults'); @@ -31,6 +23,44 @@ const router = { push, }; +interface TestComponentProps { + accountListType?: AccountListTypeEnum; + monthlyGoal?: number | null; +} + +const TestComponent: React.FC = ({ + accountListType = AccountListTypeEnum.Coaching, + monthlyGoal = null, +}) => ( + + + + mocks={{ + LoadCoachingDetail: { + coachingAccountList: { + name: 'John Doe', + currency: 'USD', + monthlyGoal, + }, + }, + LoadAccountListCoachingDetail: { + accountList: { + name: 'John Doe', + currency: 'USD', + monthlyGoal, + }, + }, + }} + > + + + + +); + const accountListId = 'account-list-1'; describe('LoadCoachingDetail', () => { beforeEach(() => { @@ -41,561 +71,66 @@ describe('LoadCoachingDetail', () => { afterTestResizeObserver(); }); - it('view', async () => { - const { findByText } = render( - - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: 55, - }, - }, - }} - > - - - - , - ); - expect(await findByText('John Doe')).toBeVisible(); - expect(await findByText('Monthly $55')).toBeVisible(); - expect(await findByText('Monthly Activity')).toBeVisible(); - }); - it('null goal', async () => { - const { findByText } = render( - - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: null, - }, - }, - }} - > - - - - , - ); - expect(await findByText('John Doe')).toBeVisible(); - expect(await findByText('Monthly $0')).toBeVisible(); - expect(await findByText('Monthly Activity')).toBeVisible(); - }); - - it('view isAccountList', async () => { - const { findByText } = render( - - - - mocks={{ - LoadAccountListCoachingDetail: { - accountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: 55, - }, - }, - }} - > - - - - , - ); - expect(await findByText('John Doe')).toBeVisible(); - expect(await findByText('Monthly $55')).toBeVisible(); - expect(await findByText('Monthly Activity')).toBeVisible(); - }); - it('null goal isAccountList', async () => { - const { findByText } = render( - - - - mocks={{ - LoadAccountListCoachingDetail: { - accountList: { - id: accountListId, - name: 'John Doe', - currency: 'USD', - monthlyGoal: null, - }, - }, - }} - > - - - - , - ); - expect(await findByText('John Doe')).toBeVisible(); - expect(await findByText('Monthly $0')).toBeVisible(); - expect(await findByText('Monthly Activity')).toBeVisible(); - }); - - describe('period', () => { - it('toggles between weekly and monthly', async () => { - const { getByRole } = render( - - - - mocks={{ - LoadCoachingDetail: { - coachingAccountList: { - balance: 1000, - currency: 'USD', - }, - }, - }} - > - - - - , - ); - - await waitFor(() => - expect(AppointmentResults).toHaveBeenLastCalledWith( - expect.objectContaining({ period: CoachingPeriodEnum.Weekly }), - expect.anything(), - ), - ); - - userEvent.click( - getByRole('button', { - name: 'Monthly', - }), - ); - - expect(AppointmentResults).toHaveBeenLastCalledWith( - expect.objectContaining({ period: CoachingPeriodEnum.Monthly }), - expect.anything(), - ); - - userEvent.click( - getByRole('button', { - name: 'Weekly', - }), - ); - - expect(AppointmentResults).toHaveBeenLastCalledWith( - expect.objectContaining({ period: CoachingPeriodEnum.Weekly }), - expect.anything(), - ); + describe.each([ + { type: AccountListTypeEnum.Coaching, name: 'coaching' }, + { type: AccountListTypeEnum.Own, name: 'own' }, + ])('$name account list', ({ type: accountListType }) => { + it('view', async () => { + const { findByRole, getByText } = render( + , + ); + expect(await findByRole('heading', { name: 'John Doe' })).toBeVisible(); + expect(getByText('Monthly $55')).toBeVisible(); + expect(getByText('Monthly Activity')).toBeVisible(); }); - }); - - 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('null goal', async () => { + const { findByRole, getByText } = render( + , ); + expect(await findByRole('heading', { name: 'John Doe' })).toBeVisible(); + expect(getByText('Monthly $0')).toBeVisible(); + expect(getByText('Monthly Activity')).toBeVisible(); }); }); - 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'), - ); - }); + describe('sidebar', () => { + it('is always visible on large screens', async () => { + matchMediaMock({ width: '1024px' }); - 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'), - ); - }); + const { findByRole, getByRole, queryByRole } = render(); - 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', - ); + expect(await findByRole('heading', { name: 'John Doe' })).toBeVisible(); + expect(getByRole('heading', { name: 'Coaching' })).toBeInTheDocument(); + expect( + queryByRole('button', { + name: 'Toggle account details', + }), + ).not.toBeInTheDocument(); + expect(queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); }); - 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', - ); - }); - }); + it('is a dismissible drawer on small screens', async () => { + matchMediaMock({ width: '256px' }); - 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', - }, - ], - }, - }, - ], - }, - }, - }, - }} - > - - - - , - ); + const { findByRole, getByRole, queryByRole } = render(); - await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument()); - expect(getByText('john.doe@cru.org')).toBeInTheDocument(); - expect(getByText('111-111-1111')).toBeInTheDocument(); - }); - }); + expect(await findByRole('heading', { name: 'John Doe' })).toBeVisible(); + expect( + queryByRole('heading', { name: 'Coaching' }), + ).not.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', - }, - ], - }, - }, - ], - }, - }, - }, - }} - > - - - - , + userEvent.click( + getByRole('button', { + name: 'Toggle account details', + }), ); + expect(getByRole('heading', { name: 'Coaching' })).toBeInTheDocument(); - await waitFor(() => expect(getByText('John Coach')).toBeInTheDocument()); - expect(getByText('john.coach@cru.org')).toBeInTheDocument(); - expect(getByText('222-222-2222')).toBeInTheDocument(); + userEvent.click(getByRole('button', { name: 'Close' })); + expect( + queryByRole('heading', { name: 'Coaching' }), + ).not.toBeInTheDocument(); }); }); }); diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index 13d22ae2c..add69909f 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -1,33 +1,34 @@ -import React, { Fragment, useMemo, useState } from 'react'; -// TODO: EcoOutlined is not defined on @mui/icons-material, find replacement. -import AccountCircle from '@mui/icons-material/AccountCircle'; -import { Box, Button, ButtonGroup, Divider, Typography } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import MenuOpenIcon from '@mui/icons-material/MenuOpen'; +import { + Box, + Divider, + Drawer, + Hidden, + IconButton, + Theme, + Typography, + useMediaQuery, +} from '@mui/material'; import { styled } from '@mui/material/styles'; -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import DonationHistories from 'src/components/Dashboard/DonationHistories'; import { useGetTaskAnalyticsQuery } from 'src/components/Dashboard/ThisWeek/NewsletterMenu/NewsletterMenu.generated'; +import { navBarHeight } from 'src/components/Layouts/Primary/Primary'; import { useGetDonationGraphQuery } from 'src/components/Reports/DonationsReport/GetDonationGraph.generated'; -import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat } from 'src/lib/intlFormat'; -import { dateFormat } from 'src/lib/intlFormat/intlFormat'; -import theme from 'src/theme'; import { MultilineSkeleton } from '../../Shared/MultilineSkeleton'; import { AppealProgress } from '../AppealProgress/AppealProgress'; import { Activity } from './Activity/Activity'; import { ActivitySummary } from './ActivitySummary/ActivitySummary'; import { AppointmentResults } from './AppointmentResults/AppointmentResults'; -import { CollapsibleEmailList } from './CollapsibleEmailList'; -import { CollapsiblePhoneList } from './CollapsiblePhoneList'; +import { CoachingSidebar } from './CoachingSidebar'; import { useGetCoachingDonationGraphQuery, useLoadAccountListCoachingDetailQuery, useLoadCoachingDetailQuery, } from './LoadCoachingDetail.generated'; import { MonthlyCommitment } from './MonthlyCommitment/MonthlyCommitment'; -import { SideContainerText } from './StyledComponents'; import { WeeklyReport } from './WeeklyReport/WeeklyReport'; -import { getLastNewsletter } from './helpers'; export enum CoachingPeriodEnum { Weekly = 'Weekly', @@ -45,30 +46,16 @@ interface CoachingDetailProps { accountListType: AccountListTypeEnum; } -const CoachingDetailContainer = styled(Box)(({}) => ({ - width: '100%', - minHeight: '100%', - display: 'flex', -})); - -const CoachingSideContainer = styled(Box)(({ theme }) => ({ - width: '20rem', - minHeight: '100%', - padding: theme.spacing(1), -})); - -const CoachingSideTitleContainer = styled(Box)(({ theme }) => ({ +const CoachingDetailContainer = styled(Box)({ + height: `calc(100vh - ${navBarHeight})`, display: 'flex', - width: '100%', - margin: theme.spacing(1), - alignItems: 'center', - alignContent: 'center', -})); +}); const CoachingMainContainer = styled(Box)(({ theme }) => ({ + flex: 1, padding: theme.spacing(1), paddingBottom: theme.spacing(6), // prevent the HelpScout beacon from obscuring content at the bottom - width: 'calc(100vw - 20rem)', + overflowY: 'scroll', })); const CoachingItemContainer = styled(Box)(({ theme }) => ({ @@ -82,18 +69,11 @@ const CoachingMainTitleContainer = styled(Box)(({ theme }) => ({ flexGrow: 1, display: 'flex', margin: theme.spacing(1), - alignItems: 'center', - alignContent: 'center', -})); - -const CoachingMonthYearButtonGroup = styled(ButtonGroup)(({ theme }) => ({ - margin: theme.spacing(2, 0), - color: theme.palette.primary.contrastText, -})); - -const SideContainerIcon = styled(AccountCircle)(({ theme }) => ({ - color: theme.palette.primary.contrastText, - margin: theme.spacing(0, 1), + flexDirection: 'column', + [theme.breakpoints.up('md')]: { + flexDirection: 'row', + alignItems: 'center', + }, })); export const CoachingDetail: React.FC = ({ @@ -101,7 +81,6 @@ export const CoachingDetail: React.FC = ({ accountListType, }) => { const { t } = useTranslation(); - const locale = useLocale(); const { data: ownData, loading: ownLoading } = useLoadAccountListCoachingDetailQuery({ @@ -122,14 +101,6 @@ export const CoachingDetail: React.FC = ({ ? ownData?.accountList : coachingData?.coachingAccountList; - const staffIds = useMemo( - () => - accountListData?.designationAccounts - .map((account) => account.accountNumber) - .filter((number) => number.length > 0) ?? [], - [accountListData], - ); - const { data: ownDonationGraphData } = useGetDonationGraphQuery({ variables: { accountListId, @@ -156,141 +127,39 @@ export const CoachingDetail: React.FC = ({ }); const [period, setPeriod] = useState(CoachingPeriodEnum.Weekly); + const [drawerVisible, setDrawerVisible] = useState(false); - const formatOptionalDate = (isoDate: string | null | undefined): string => - isoDate ? dateFormat(DateTime.fromISO(isoDate), locale) : t('None'); + const handleCloseDrawer = () => setDrawerVisible(false); + + const sidebarDrawer = useMediaQuery((theme) => + theme.breakpoints.down('md'), + ); + useEffect(() => { + if (sidebarDrawer) { + handleCloseDrawer(); + } + }, [sidebarDrawer]); + + const sidebar = ( + + ); return ( - - - {/* TODO: EcoOutlined is not defined on @mui/icons-material, find replacement. - */} - - {t('Coaching')} - - - - - - - - - {t('Balance:')}{' '} - {accountListData && - currencyFormat( - accountListData.balance, - accountListData.currency, - locale, - )} - - {t('Staff IDs:')} - - {staffIds.length ? staffIds.join(', ') : t('None')} - - - {t('Last Prayer Letter:')}{' '} - {taskAnalyticsData && - formatOptionalDate( - getLastNewsletter( - taskAnalyticsData.taskAnalytics - .lastElectronicNewsletterCompletedAt, - taskAnalyticsData.taskAnalytics - .lastPhysicalNewsletterCompletedAt, - ), - )} - - - - {t('MPD Info')} - - - {t('Weeks on MPD:')} {accountListData?.weeksOnMpd} - - - {t('Start Date:')}{' '} - {accountListData && - formatOptionalDate(accountListData?.activeMpdStartAt)} - - - {t('End Date:')}{' '} - {accountListData && - formatOptionalDate(accountListData?.activeMpdFinishAt)} - - - {t('Commitment Goal:')}{' '} - {accountListData && - (typeof accountListData.activeMpdMonthlyGoal === 'number' - ? currencyFormat( - accountListData.activeMpdMonthlyGoal, - accountListData?.currency, - locale, - ) - : t('None'))} - - - - {t('Users')} - - {loading ? ( - - ) : ( - accountListData?.users.nodes.map((user) => ( - - - - {user.firstName + ' ' + user.lastName} - - - - - - )) - )} - - - {t('Coaches')} - - {loading ? ( - - ) : ( - accountListData?.coaches.nodes.map((coach) => ( - - - - {coach.firstName + ' ' + coach.lastName} - - - - - - )) - )} - + + + {sidebar} + + + {sidebar} {loading ? ( @@ -298,13 +167,15 @@ export const CoachingDetail: React.FC = ({ <> - + + + setDrawerVisible(!drawerVisible)} + aria-label={t('Toggle account details')} + > + + + {accountListData?.name} diff --git a/src/components/Coaching/CoachingDetail/CoachingSidebar.test.tsx b/src/components/Coaching/CoachingDetail/CoachingSidebar.test.tsx new file mode 100644 index 000000000..0bb4308a5 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CoachingSidebar.test.tsx @@ -0,0 +1,364 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import { DeepPartial } from 'ts-essentials'; +import { gqlMock } from '__tests__/util/graphqlMocking'; +import { render } from '__tests__/util/testingLibraryReactMock'; +import { + GetTaskAnalyticsDocument, + GetTaskAnalyticsQuery, + GetTaskAnalyticsQueryVariables, +} from 'src/components/Dashboard/ThisWeek/NewsletterMenu/NewsletterMenu.generated'; +import { AccountListTypeEnum, CoachingPeriodEnum } from './CoachingDetail'; +import { CoachingSidebar } from './CoachingSidebar'; +import { + LoadAccountListCoachingDetailDocument, + LoadAccountListCoachingDetailQuery, + LoadAccountListCoachingDetailQueryVariables, + LoadCoachingDetailDocument, + LoadCoachingDetailQuery, + LoadCoachingDetailQueryVariables, +} from './LoadCoachingDetail.generated'; + +interface TestComponentProps { + accountListType?: AccountListTypeEnum; + showClose?: boolean; + accountListMocks?: DeepPartial< + Omit< + LoadCoachingDetailQuery['coachingAccountList'] | null | undefined, + '__typename' + > + >; + taskAnalyticsMocks?: Partial; +} + +const accountListId = 'account-list-1'; +const setPeriodMock = jest.fn(); +const handleCloseMock = jest.fn(); + +const TestComponent: React.FC = ({ + accountListType = AccountListTypeEnum.Coaching, + showClose = false, + accountListMocks, + taskAnalyticsMocks, +}) => { + const accountListData = + accountListType === AccountListTypeEnum.Coaching + ? gqlMock( + LoadCoachingDetailDocument, + { + mocks: { + coachingAccountList: { + currency: 'USD', + ...accountListMocks, + }, + }, + variables: { coachingAccountListId: accountListId }, + }, + ).coachingAccountList + : gqlMock< + LoadAccountListCoachingDetailQuery, + LoadAccountListCoachingDetailQueryVariables + >(LoadAccountListCoachingDetailDocument, { + mocks: { + accountList: { + currency: 'USD', + ...accountListMocks, + }, + }, + variables: { accountListId }, + }).accountList; + + return ( + (GetTaskAnalyticsDocument, { + mocks: { + taskAnalytics: taskAnalyticsMocks ?? {}, + }, + variables: { accountListId }, + })} + /> + ); +}; + +describe('CoachingSidebar', () => { + describe('close button', () => { + it('calls handleClose', () => { + const { getByRole } = render(); + + userEvent.click(getByRole('button', { name: 'Close' })); + expect(handleCloseMock).toHaveBeenCalled(); + }); + + it('hides when showClose is false', () => { + const { queryByRole } = render(); + + expect(queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + }); + }); + + describe('period', () => { + it('toggles between weekly and monthly', () => { + const { getByRole } = render(); + + userEvent.click( + getByRole('button', { + name: 'Monthly', + }), + ); + expect(setPeriodMock).toHaveBeenLastCalledWith( + CoachingPeriodEnum.Monthly, + ); + + userEvent.click( + getByRole('button', { + name: 'Weekly', + }), + ); + expect(setPeriodMock).toHaveBeenLastCalledWith(CoachingPeriodEnum.Weekly); + }); + }); + + describe.each([ + { type: AccountListTypeEnum.Coaching, name: 'coaching' }, + { type: AccountListTypeEnum.Own, name: 'own' }, + ])('$name account list', ({ type: accountListType }) => { + describe('balance', () => { + it('displays the account list balance', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('Balance')).toHaveTextContent('Balance: $1,000'); + }); + }); + + describe('staff ids', () => { + it('displays comma-separated staff ids', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('StaffIds')).toHaveTextContent('123, 456'); + }); + + it('ignores empty staff ids', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('StaffIds')).toHaveTextContent('456'); + }); + + it('displays none when there are no staff ids', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('StaffIds')).toHaveTextContent('None'); + }); + }); + + describe('last prayer letter', () => { + it('formats the prayer letter date', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('LastPrayerLetter')).toHaveTextContent( + 'Last Prayer Letter: Jan 2, 2023', + ); + }); + + it('displays none when there are no prayer letters', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('LastPrayerLetter')).toHaveTextContent( + 'Last Prayer Letter: None', + ); + }); + }); + + describe('MPD info', () => { + it('displays info', () => { + const { getByTestId } = render( + , + ); + + 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', () => { + const { getByTestId } = render( + , + ); + + 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', () => { + const { getByText } = render( + , + ); + + 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', () => { + const { getByText } = render( + , + ); + + 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/CoachingSidebar.tsx b/src/components/Coaching/CoachingDetail/CoachingSidebar.tsx new file mode 100644 index 000000000..4d0cb0c7d --- /dev/null +++ b/src/components/Coaching/CoachingDetail/CoachingSidebar.tsx @@ -0,0 +1,238 @@ +import React, { Fragment, useMemo } from 'react'; +// TODO: EcoOutlined is not defined on @mui/icons-material, find replacement. +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Button, ButtonGroup, Divider, IconButton } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import { GetTaskAnalyticsQuery } from 'src/components/Dashboard/ThisWeek/NewsletterMenu/NewsletterMenu.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; +import { dateFormat } from 'src/lib/intlFormat/intlFormat'; +import theme from 'src/theme'; +import { MultilineSkeleton } from '../../Shared/MultilineSkeleton'; +import { CoachingPeriodEnum } from './CoachingDetail'; +import { CollapsibleEmailList } from './CollapsibleEmailList'; +import { CollapsiblePhoneList } from './CollapsiblePhoneList'; +import { + LoadAccountListCoachingDetailQuery, + LoadCoachingDetailQuery, +} from './LoadCoachingDetail.generated'; +import { SideContainerText } from './StyledComponents'; +import { getLastNewsletter } from './helpers'; + +const Container = styled(Box)({ + display: 'flex', + flexDirection: 'column', + width: '20rem', + height: '100%', +}); + +const TitleContainer = styled('div')(({ theme }) => ({ + margin: theme.spacing(1), + display: 'flex', + alignItems: 'center', +})); + +const ContrastDivider = styled(Divider)(({ theme }) => ({ + background: theme.palette.primary.contrastText, +})); + +const ContentContainer = styled('div')({ + flex: 1, + overflow: 'scroll', + padding: theme.spacing(0, 1), +}); + +const MonthlyWeeklyButtonGroup = styled(ButtonGroup)(({ theme }) => ({ + margin: theme.spacing(2, 0), + color: theme.palette.primary.contrastText, +})); + +const SectionHeaderText = styled(SideContainerText)(({ theme }) => ({ + margin: theme.spacing(1), +})); + +const StyledCloseIcon = styled(CloseIcon)(({ theme }) => ({ + color: theme.palette.primary.contrastText, +})); + +const StyledUserIcon = styled(AccountCircleIcon)(({ theme }) => ({ + color: theme.palette.primary.contrastText, + margin: theme.spacing(0, 1), +})); + +const UserDivider = styled(Divider)(({ theme }) => ({ + margin: theme.spacing(1), +})); + +interface CoachingSidebarProps { + period: CoachingPeriodEnum; + setPeriod: React.Dispatch>; + showClose: boolean; + handleClose: () => void; + loading: boolean; + accountListData: + | LoadCoachingDetailQuery['coachingAccountList'] + | LoadAccountListCoachingDetailQuery['accountList'] + | undefined; + taskAnalyticsData: GetTaskAnalyticsQuery | undefined; +} + +export const CoachingSidebar: React.FC = ({ + period, + setPeriod, + showClose, + handleClose, + loading, + accountListData, + taskAnalyticsData, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const staffIds = useMemo( + () => + accountListData?.designationAccounts + .map((account) => account.accountNumber) + .filter((number) => number.length > 0) ?? [], + [accountListData], + ); + + const formatOptionalDate = (isoDate: string | null | undefined): string => + isoDate ? dateFormat(DateTime.fromISO(isoDate), locale) : t('None'); + + return ( + + + {/* TODO: EcoOutlined is not defined on @mui/icons-material, find replacement. + */} + + {t('Coaching')} + + {showClose && ( + + + + )} + + + + + + + + + {t('Balance:')}{' '} + {accountListData && + currencyFormat( + accountListData.balance, + accountListData.currency, + locale, + )} + + {t('Staff IDs:')} + + {staffIds.length ? staffIds.join(', ') : t('None')} + + + {t('Last Prayer Letter:')}{' '} + {taskAnalyticsData && + formatOptionalDate( + getLastNewsletter( + taskAnalyticsData.taskAnalytics + .lastElectronicNewsletterCompletedAt, + taskAnalyticsData.taskAnalytics + .lastPhysicalNewsletterCompletedAt, + ), + )} + + + {t('MPD Info')} + + {t('Weeks on MPD:')} {accountListData?.weeksOnMpd} + + + {t('Start Date:')}{' '} + {accountListData && + formatOptionalDate(accountListData?.activeMpdStartAt)} + + + {t('End Date:')}{' '} + {accountListData && + formatOptionalDate(accountListData?.activeMpdFinishAt)} + + + {t('Commitment Goal:')}{' '} + {accountListData && + (typeof accountListData.activeMpdMonthlyGoal === 'number' + ? currencyFormat( + accountListData.activeMpdMonthlyGoal, + accountListData?.currency, + locale, + ) + : t('None'))} + + + {t('Users')} + {loading ? ( + + ) : ( + accountListData?.users.nodes.map((user) => ( + + + + {user.firstName + ' ' + user.lastName} + + + + + + )) + )} + + {t('Coaches')} + {loading ? ( + + ) : ( + accountListData?.coaches.nodes.map((coach) => ( + + + + {coach.firstName + ' ' + coach.lastName} + + + + + + )) + )} + + + ); +}; diff --git a/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx index 280757544..dda90a5e0 100644 --- a/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx +++ b/src/components/Coaching/CoachingDetail/CollapsibleEmailList.tsx @@ -12,7 +12,9 @@ interface EmailProps { const Email: React.FC = ({ email }) => ( - {email.email} + + {email.email} + {email.location ? ` - ${email.location}` : null} ); diff --git a/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx index e3eac3446..364b40b7e 100644 --- a/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx +++ b/src/components/Coaching/CoachingDetail/CollapsiblePhoneList.tsx @@ -12,7 +12,9 @@ interface PhoneProps { const Phone: React.FC = ({ phone }) => ( - {phone.number} + + {phone.number} + {phone.location ? ` - ${phone.location}` : null} ); diff --git a/src/components/Dashboard/ThisWeek/TasksDueThisWeek/TasksDueThisWeek.tsx b/src/components/Dashboard/ThisWeek/TasksDueThisWeek/TasksDueThisWeek.tsx index b57477c6f..1bcc485f6 100644 --- a/src/components/Dashboard/ThisWeek/TasksDueThisWeek/TasksDueThisWeek.tsx +++ b/src/components/Dashboard/ThisWeek/TasksDueThisWeek/TasksDueThisWeek.tsx @@ -39,7 +39,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ list: { flex: 1, padding: 0, - overflow: 'auto', + overflow: 'hidden', }, card: { display: 'flex', diff --git a/src/components/Layouts/Basic/TopBar/TopBar.tsx b/src/components/Layouts/Basic/TopBar/TopBar.tsx index d6dd3de8b..a34d3a6a0 100644 --- a/src/components/Layouts/Basic/TopBar/TopBar.tsx +++ b/src/components/Layouts/Basic/TopBar/TopBar.tsx @@ -1,5 +1,6 @@ import React, { ReactElement, ReactNode } from 'react'; import { AppBar, Grid, Theme, Toolbar, useScrollTrigger } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; const useStyles = makeStyles()((theme: Theme) => ({ @@ -17,6 +18,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); +const Offset = styled('div')(({ theme }) => theme.mixins.toolbar); + interface Props { children?: ReactNode; } @@ -30,13 +33,16 @@ const TopBar = ({ children }: Props): ReactElement => { }); return ( - - - - {children} - - - + <> + + + + {children} + + + + + ); }; diff --git a/src/components/Layouts/Primary/NavBar/NavBar.tsx b/src/components/Layouts/Primary/NavBar/NavBar.tsx index bd2def8b1..fbe515dd6 100644 --- a/src/components/Layouts/Primary/NavBar/NavBar.tsx +++ b/src/components/Layouts/Primary/NavBar/NavBar.tsx @@ -2,7 +2,7 @@ import NextLink, { LinkProps } from 'next/link'; import { useRouter } from 'next/router'; import React, { ReactElement, useEffect } from 'react'; import type { FC } from 'react'; -import { Box, Drawer, Hidden, List, Theme } from '@mui/material'; +import { Box, Drawer, Hidden, List, Theme, useMediaQuery } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { reportNavItems } from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems'; @@ -106,7 +106,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ mobileDrawer: { width: 290, backgroundColor: theme.palette.cruGrayDark.main, - zIndex: theme.zIndex.drawer + 200, }, })); @@ -154,37 +153,15 @@ export const NavBar: FC = ({ onMobileClose, openMobile }) => { }, ]; + const drawerHidden = useMediaQuery((theme) => + theme.breakpoints.up('md'), + ); + // Close the drawer when the route changes or when the drawer is hidden because the screen is larger useEffect(() => { - if (openMobile && onMobileClose) { + if (drawerHidden || (openMobile && onMobileClose)) { onMobileClose(); } - }, [pathname]); - - const content = ( - - - - - logo - - - - - {renderNavItems({ - accountListId, - items: sections, - pathname, - })} - - - - - - ); + }, [pathname, drawerHidden]); return ( @@ -196,7 +173,25 @@ export const NavBar: FC = ({ onMobileClose, openMobile }) => { open={openMobile} variant="temporary" > - {content} + + + logo + + + + {renderNavItems({ + accountListId, + items: sections, + pathname, + })} + + + + ); diff --git a/src/components/Layouts/Primary/Primary.tsx b/src/components/Layouts/Primary/Primary.tsx index 03c957b49..2905ea6fc 100644 --- a/src/components/Layouts/Primary/Primary.tsx +++ b/src/components/Layouts/Primary/Primary.tsx @@ -8,15 +8,13 @@ export const navBarHeight = '64px'; const RootContainer = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.common.white, - display: 'flex', - height: '100%', - overflow: 'hidden', - width: '100%', + width: '100vw', + height: '100vh', + overflow: 'scroll', })); const ContentContainer = styled('div')(() => ({ display: 'flex', - flex: '1 1 auto', overflow: 'hidden', })); @@ -26,13 +24,6 @@ const Content = styled('div')(() => ({ overflow: 'auto', })); -const Wrapper = styled('div')(() => ({ - display: 'flex', - flex: '1 1 auto', - overflow: 'hidden', - paddingTop: navBarHeight, -})); - interface Props { children: ReactNode; } @@ -53,11 +44,9 @@ const Primary = ({ children }: Props): ReactElement => { openMobile={isMobileNavOpen} /> )} - - - {children} - - + + {children} + ); }; diff --git a/src/components/Layouts/Primary/TopBar/TopBar.tsx b/src/components/Layouts/Primary/TopBar/TopBar.tsx index 243a702d2..563a1d9f9 100644 --- a/src/components/Layouts/Primary/TopBar/TopBar.tsx +++ b/src/components/Layouts/Primary/TopBar/TopBar.tsx @@ -22,8 +22,9 @@ interface TopBarProps { onMobileNavOpen?: () => void; } +const Offset = styled('div')(({ theme }) => theme.mixins.toolbar); + const StyledAppBar = styled(AppBar)(({ theme }) => ({ - zIndex: theme.zIndex.drawer + 100, backgroundColor: theme.palette.cruGrayDark.main, })); @@ -37,18 +38,18 @@ const TopBar = ({ }); return ( - - - {accountListId && ( - - - - - - - - )} - + <> + + + {accountListId && ( + + + + + + + + )} - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); };