From 3d157e1e9b0fcc1be6fb25110d9dca2b0d6e5be9 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 5 Dec 2024 09:55:51 -0600 Subject: [PATCH 001/101] Add config file for GraphQL language server --- graphql.config.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 graphql.config.json diff --git a/graphql.config.json b/graphql.config.json new file mode 100644 index 0000000000..9ff3207be8 --- /dev/null +++ b/graphql.config.json @@ -0,0 +1,4 @@ +{ + "schema": "src/graphql/schema.graphql", + "documents": "**/*.graphql" +} From 3c6d6948e274f45852dcd7b8116fd578807b337b Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 5 Dec 2024 11:36:54 -0600 Subject: [PATCH 002/101] Extract typeguard into lib --- pages/api/Schema/CoachingAnswerSets/dataHandler.ts | 7 +++---- src/hooks/useUpdateTasksQueries.ts | 5 ++--- src/lib/typeGuards.ts | 2 ++ 3 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 src/lib/typeGuards.ts diff --git a/pages/api/Schema/CoachingAnswerSets/dataHandler.ts b/pages/api/Schema/CoachingAnswerSets/dataHandler.ts index bf312a99ae..e752eb3d71 100644 --- a/pages/api/Schema/CoachingAnswerSets/dataHandler.ts +++ b/pages/api/Schema/CoachingAnswerSets/dataHandler.ts @@ -3,8 +3,7 @@ import { CoachingAnswerSet, CoachingQuestion, } from 'src/graphql/types.generated'; - -const isNotNull = (item: T | null): item is T => item !== null; +import { isNotNullish } from 'src/lib/typeGuards'; interface CoachingAnswerSetData { id: string; @@ -77,10 +76,10 @@ const parseCoachingAnswerSet = ( }; const answers = relationships.answers.data .map(({ id }) => getIncludedAnswer(id, included)) - .filter(isNotNull); + .filter(isNotNullish); const questions = relationships.questions.data .map(({ id }) => getIncludedQuestion(id, included)) - .filter(isNotNull); + .filter(isNotNullish); return { id, diff --git a/src/hooks/useUpdateTasksQueries.ts b/src/hooks/useUpdateTasksQueries.ts index 9ec4b09db1..05b6c3c7df 100644 --- a/src/hooks/useUpdateTasksQueries.ts +++ b/src/hooks/useUpdateTasksQueries.ts @@ -8,14 +8,13 @@ import { TaskRowFragment, TaskRowFragmentDoc, } from 'src/components/Task/TaskRow/TaskRow.generated'; +import { isNotNullish } from 'src/lib/typeGuards'; import { GetTaskIdsForMassSelectionDocument, GetTaskIdsForMassSelectionQuery, GetTaskIdsForMassSelectionQueryVariables, } from './GetIdsForMassSelection.generated'; -const isNotNull = (item: T | null): item is T => item !== null; - // The Apollo cache makes it trivial to update cached tasks after they are updated. However, we // need to update not ony the individual tasks but also the tasks list. Handling the tasks list is // is more complicated because modifying a task could cause the task to be filtered out of the list @@ -81,7 +80,7 @@ export const useUpdateTasksQueries = (): { }), ) // Ignore tasks that aren't in the cache - .filter(isNotNull); + .filter(isNotNullish); return { ...data, tasks: { ...data.tasks, nodes }, diff --git a/src/lib/typeGuards.ts b/src/lib/typeGuards.ts new file mode 100644 index 0000000000..2cf6e18ef7 --- /dev/null +++ b/src/lib/typeGuards.ts @@ -0,0 +1,2 @@ +export const isNotNullish = (item: T | null | undefined): item is T => + item !== null && item !== undefined; From d6ddc193190b9bcb904c195c340a7a2c694a58c0 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 5 Dec 2024 13:29:49 -0600 Subject: [PATCH 003/101] Refactor monthYearFormat to accept DateTime instances --- .../TopBar/Items/NotificationMenu/Item/Item.tsx | 3 +-- .../AccountSummary/AccountSummary.tsx | 3 +-- src/lib/intlFormat.test.ts | 14 +++++++------- src/lib/intlFormat.ts | 13 ++++++++----- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/Layouts/Primary/TopBar/Items/NotificationMenu/Item/Item.tsx b/src/components/Layouts/Primary/TopBar/Items/NotificationMenu/Item/Item.tsx index ac7bc772dc..8f3ba3f7a3 100644 --- a/src/components/Layouts/Primary/TopBar/Items/NotificationMenu/Item/Item.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/NotificationMenu/Item/Item.tsx @@ -222,8 +222,7 @@ const NotificationMenuItem = ({ ) && ( {monthYearFormat( - DateTime.fromISO(item.notification.occurredAt).month, - DateTime.fromISO(item.notification.occurredAt).year, + DateTime.fromISO(item.notification.occurredAt), locale, )} diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx index 6cf4d3336b..c53c4b9fd9 100644 --- a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx @@ -166,8 +166,7 @@ export const AccountSummary: React.FC = ({ // Periods const startDateFormatted = monthYearFormat( - DateTime.fromISO(item?.startDate ?? '').month, - DateTime.fromISO(item?.startDate ?? '').year, + DateTime.fromISO(item.startDate), locale, false, ); diff --git a/src/lib/intlFormat.test.ts b/src/lib/intlFormat.test.ts index 19d528c0d7..aabd05aa34 100644 --- a/src/lib/intlFormat.test.ts +++ b/src/lib/intlFormat.test.ts @@ -116,18 +116,18 @@ describe('intlFormat', () => { }); describe('monthYearFormat', () => { + const date = DateTime.local(2020, 6); + it('formats day and month as date', () => { - expect(monthYearFormat(6, 2020, 'en-US')).toEqual('Jun 2020'); + expect(monthYearFormat(date, 'en-US')).toEqual('Jun 2020'); }); - it('handles language', () => { - expect(monthYearFormat(6, 2020, 'fr')).toEqual('juin 2020'); + it('handles null date', () => { + expect(monthYearFormat(null, 'en-US')).toEqual(''); }); - describe('different language', () => { - it('handles language', () => { - expect(monthYearFormat(6, 2020, 'fr')).toEqual('juin 2020'); - }); + it('handles different language', () => { + expect(monthYearFormat(date, 'fr')).toEqual('juin 2020'); }); }); diff --git a/src/lib/intlFormat.ts b/src/lib/intlFormat.ts index 2c5c7c9194..01575aad39 100644 --- a/src/lib/intlFormat.ts +++ b/src/lib/intlFormat.ts @@ -47,15 +47,18 @@ export const dayMonthFormat = ( }).format(DateTime.local().set({ month, day }).toJSDate()); export const monthYearFormat = ( - month: number, - year: number, + date: DateTime | null, locale: string, fullYear = true, -): string => - new Intl.DateTimeFormat(locale, { +): string => { + if (date === null) { + return ''; + } + return new Intl.DateTimeFormat(locale, { month: 'short', year: fullYear ? 'numeric' : '2-digit', - }).format(DateTime.local(year, month, 1).toJSDate()); + }).format(date.toJSDate()); +}; export const dateFormat = (date: DateTime | null, locale: string): string => { if (date === null) { From f62690210a98722d19ba463bb226363cdae07f99 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 14:50:28 -0600 Subject: [PATCH 004/101] Remove unused prop --- src/components/Shared/Forms/FormWrapper.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Shared/Forms/FormWrapper.tsx b/src/components/Shared/Forms/FormWrapper.tsx index 60acba1431..de62e0b8cd 100644 --- a/src/components/Shared/Forms/FormWrapper.tsx +++ b/src/components/Shared/Forms/FormWrapper.tsx @@ -7,7 +7,6 @@ interface FormWrapperProps { onSubmit: () => void; isValid: boolean; isSubmitting: boolean; - formAttrs?: { action?: string; method?: string }; children: React.ReactNode; buttonText?: string; } @@ -16,7 +15,6 @@ export const FormWrapper: React.FC = ({ onSubmit, isValid, isSubmitting, - formAttrs = {}, children, buttonText, }) => { @@ -24,7 +22,7 @@ export const FormWrapper: React.FC = ({ const theme = useTheme(); return ( -
+ {children} + {calculatedGoal && monthlyGoal !== calculatedGoal && ( + + + + )} + +
)} From 8bae54bd95677ad1ff7914f2b82446a36143ba3a Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 15:46:23 -0600 Subject: [PATCH 006/101] Add tests --- .../MonthlyGoalAccordion.test.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index 4ba69eba8e..ca38c227df 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -5,6 +5,7 @@ import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from 'src/theme'; +import { MachineCalculatedGoalQuery } from './MachineCalculatedGoal.generated'; import { MonthlyGoalAccordion } from './MonthlyGoalAccordion'; jest.mock('next-auth/react'); @@ -33,17 +34,30 @@ const mutationSpy = jest.fn(); interface ComponentsProps { monthlyGoal: number | null; + machineCalculatedGoal?: number; expandedPanel: string; } const Components: React.FC = ({ monthlyGoal, + machineCalculatedGoal, expandedPanel, }) => ( - + + mocks={{ + MachineCalculatedGoal: { + healthIndicatorData: machineCalculatedGoal + ? [{ machineCalculatedGoal }] + : [], + }, + }} + onCall={mutationSpy} + > { ]); }); }); + + it('resets goal to calculated goal', async () => { + const { getByRole, findByText } = render( + , + ); + const input = getByRole('spinbutton', { name: label }); + + expect( + await findByText( + 'Based on the past year, NetSuite estimates that you need at least $1,500 of monthly support. You can use this amount or choose your own target monthly goal.', + ), + ).toBeInTheDocument(); + + const resetButton = getByRole('button', { name: /Reset/ }); + userEvent.click(resetButton); + expect(input).toHaveValue(1500); + expect(resetButton).not.toBeInTheDocument(); + + userEvent.clear(input); + userEvent.type(input, '500'); + expect(getByRole('button', { name: /Reset/ })).toBeInTheDocument(); + }); }); From 59cdef867f15699af4b1fd09855880ee96f2dd55 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 16:12:34 -0600 Subject: [PATCH 007/101] Submit the form immediately --- .../MonthlyGoalAccordion.test.tsx | 77 +++++++++++++------ .../MonthlyGoalAccordion.tsx | 6 +- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index ca38c227df..1467728d8f 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -149,29 +149,60 @@ describe('MonthlyGoalAccordion', () => { }); }); - it('resets goal to calculated goal', async () => { - const { getByRole, findByText } = render( - , - ); - const input = getByRole('spinbutton', { name: label }); - - expect( - await findByText( - 'Based on the past year, NetSuite estimates that you need at least $1,500 of monthly support. You can use this amount or choose your own target monthly goal.', - ), - ).toBeInTheDocument(); - - const resetButton = getByRole('button', { name: /Reset/ }); - userEvent.click(resetButton); - expect(input).toHaveValue(1500); - expect(resetButton).not.toBeInTheDocument(); + describe('calculated goal', () => { + it('resets goal to calculated goal', async () => { + const { getByRole, findByText } = render( + , + ); + const input = getByRole('spinbutton', { name: label }); + + expect( + await findByText( + 'Based on the past year, NetSuite estimates that you need at least $1,500 of monthly support. You can use this amount or choose your own target monthly goal.', + ), + ).toBeInTheDocument(); + + const resetButton = getByRole('button', { name: /Reset/ }); + userEvent.click(resetButton); + expect(input).toHaveValue(1500); + expect(resetButton).not.toBeInTheDocument(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateAccountPreferences', { + input: { + id: accountListId, + attributes: { + settings: { + monthlyGoal: 1500, + }, + }, + }, + }), + ); + }); - userEvent.clear(input); - userEvent.type(input, '500'); - expect(getByRole('button', { name: /Reset/ })).toBeInTheDocument(); + it('hides reset button if goal matches calculated goal', async () => { + const { getByRole, findByText, queryByRole } = render( + , + ); + + expect( + await findByText( + 'Based on the past year, NetSuite estimates that you need at least $1,000 of monthly support. You can use this amount or choose your own target monthly goal.', + ), + ).toBeInTheDocument(); + expect(queryByRole('button', { name: /Reset/ })).not.toBeInTheDocument(); + + userEvent.type(getByRole('spinbutton', { name: label }), '0'); + expect(getByRole('button', { name: /Reset/ })).toBeInTheDocument(); + }); }); }); diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index d0f5a9a435..64af8114c7 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -135,6 +135,7 @@ export const MonthlyGoalAccordion: React.FC = ({ values: { monthlyGoal }, errors, handleSubmit, + submitForm, isSubmitting, isValid, handleChange, @@ -180,7 +181,10 @@ export const MonthlyGoalAccordion: React.FC = ({ From c34e68e7ec8b822a7e95eeccb52f420126049565 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 10:11:49 -0600 Subject: [PATCH 008/101] Create reusable LegendReferenceLine component --- .../DonationHistories/DonationHistories.tsx | 103 ++++++------------ .../LegendReferenceLine.tsx | 38 +++++++ 2 files changed, 71 insertions(+), 70 deletions(-) create mode 100644 src/components/common/LegendReferenceLine/LegendReferenceLine.tsx diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 22a7989547..5446dd9d7a 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -25,6 +25,7 @@ import { } from 'recharts'; import { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart.d'; import { makeStyles } from 'tss-react/mui'; +import { LegendReferenceLine } from 'src/components/common/LegendReferenceLine/LegendReferenceLine'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useLocale } from 'src/hooks/useLocale'; import illustration15 from '../../../images/drawkit/grape/drawkit-grape-pack-illustration-15.svg'; @@ -36,23 +37,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ cardHeader: { textAlign: 'center', }, - lineKey: { - display: 'inline-block', - height: '5px', - width: '20px', - marginRight: '10px', - marginBottom: '4px', - borderRadius: '5px', - }, - lineKeyGoal: { - backgroundColor: '#17AEBF', - }, - lineKeyAverage: { - backgroundColor: '#9C9FA1', - }, - lineKeyPledged: { - backgroundColor: '#FFCF07', - }, boxImg: { display: 'flex', flex: 1, @@ -168,70 +152,49 @@ const DonationHistories = ({ {goal ? ( <> - - + - - {t('Goal')}{' '} - {currencyFormat(goal, currencyCode, locale)} - | ) : null} - - - - {t('Average')}{' '} - {loading || !reportsDonationHistories ? ( - - ) : ( - currencyFormat( - reportsDonationHistories.averageIgnoreCurrent, - currencyCode, - locale, + + + ) : ( + currencyFormat( + reportsDonationHistories.averageIgnoreCurrent, + currencyCode, + locale, + ) ) - )} - + } + color="#9C9FA1" + /> {pledged ? ( <> | - - + - - {t('Committed')}{' '} - {currencyFormat(pledged, currencyCode, locale)} - ) : null} diff --git a/src/components/common/LegendReferenceLine/LegendReferenceLine.tsx b/src/components/common/LegendReferenceLine/LegendReferenceLine.tsx new file mode 100644 index 0000000000..e402e54803 --- /dev/null +++ b/src/components/common/LegendReferenceLine/LegendReferenceLine.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +interface LegendReferenceLineProps { + name: string; + value: React.ReactNode; + color: string; +} + +const Container = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(1), +})); + +const Line = styled(Box)({ + display: 'inline-block', + height: '5px', + width: '20px', + borderRadius: '5px', +}); + +// This component shows the color, name, and value of a reference line in a recharts graph. It +// should be put in the header of the card containing the graph. +export const LegendReferenceLine: React.FC = ({ + name, + value, + color, +}) => ( + + + + {name} {value} + + +); From c202dcf0279908d28763f48bea59ee5e1951912d Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 10:39:42 -0600 Subject: [PATCH 009/101] Create reusable BarChartSkeleton component --- .../DonationHistories.test.tsx | 10 ++--- .../DonationHistories/DonationHistories.tsx | 42 ++----------------- .../DonationsReport/DonationsReport.test.tsx | 8 +--- .../BarChartSkeleton.test.tsx | 19 +++++++++ .../BarChartSkeleton/BarChartSkeleton.tsx | 29 +++++++++++++ 5 files changed, 57 insertions(+), 51 deletions(-) create mode 100644 src/components/common/BarChartSkeleton/BarChartSkeleton.test.tsx create mode 100644 src/components/common/BarChartSkeleton/BarChartSkeleton.tsx diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index aff7170cbe..af851fc0eb 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -29,9 +29,7 @@ describe('DonationHistories', () => { , ); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); - expect( - queryByTestId('DonationHistoriesGridLoading'), - ).not.toBeInTheDocument(); + expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('empty periods', () => { @@ -59,9 +57,7 @@ describe('DonationHistories', () => { , ); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); - expect( - queryByTestId('DonationHistoriesGridLoading'), - ).not.toBeInTheDocument(); + expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('loading', () => { @@ -70,7 +66,7 @@ describe('DonationHistories', () => { , ); - expect(getByTestId('DonationHistoriesGridLoading')).toBeInTheDocument(); + expect(getByTestId('BarChartSkeleton')).toBeInTheDocument(); expect(queryByTestId('DonationHistoriesBoxEmpty')).not.toBeInTheDocument(); }); diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 5446dd9d7a..a4153f3dd9 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -25,6 +25,7 @@ import { } from 'recharts'; import { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart.d'; import { makeStyles } from 'tss-react/mui'; +import { BarChartSkeleton } from 'src/components/common/BarChartSkeleton/BarChartSkeleton'; import { LegendReferenceLine } from 'src/components/common/LegendReferenceLine/LegendReferenceLine'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useLocale } from 'src/hooks/useLocale'; @@ -221,25 +222,7 @@ const DonationHistories = ({ <> {loading ? ( - - - - - - - - - - - - - - + ) : ( - {loading ? ( - - - - - - - - - - - - - - + {!loading ? ( + ) : ( diff --git a/src/components/Reports/DonationsReport/DonationsReport.test.tsx b/src/components/Reports/DonationsReport/DonationsReport.test.tsx index 2a2df53822..e99d4e22bb 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.test.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.test.tsx @@ -129,9 +129,7 @@ describe('DonationsReport', () => { ); expect(getByText(title)).toBeInTheDocument(); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); - expect( - queryByTestId('DonationHistoriesGridLoading'), - ).not.toBeInTheDocument(); + expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); expect(queryAllByRole('button')[1]).toBeInTheDocument(); }); @@ -157,9 +155,7 @@ describe('DonationsReport', () => { expect( getByTestId('DonationHistoriesTypographyAverage'), ).toBeInTheDocument(); - expect( - queryByTestId('DonationHistoriesGridLoading'), - ).not.toBeInTheDocument(); + expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); expect(await findByRole('cell', { name: 'John' })).toBeInTheDocument(); }); diff --git a/src/components/common/BarChartSkeleton/BarChartSkeleton.test.tsx b/src/components/common/BarChartSkeleton/BarChartSkeleton.test.tsx new file mode 100644 index 0000000000..2e3e5565cc --- /dev/null +++ b/src/components/common/BarChartSkeleton/BarChartSkeleton.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@testing-library/react'; +import { BarChartSkeleton } from './BarChartSkeleton'; + +describe('BarChartSkeleton', () => { + it('renders the right number of bars', () => { + const { getAllByTestId } = render(); + + expect(getAllByTestId('SkeletonBar')).toHaveLength(10); + }); + + it('sets the bar heights to ascend to 100%', () => { + const { getAllByTestId } = render(); + + const bars = getAllByTestId('SkeletonBar'); + expect(bars[0]).toHaveStyle({ height: '33%' }); + expect(bars[1]).toHaveStyle({ height: '67%' }); + expect(bars[2]).toHaveStyle({ height: '100%' }); + }); +}); diff --git a/src/components/common/BarChartSkeleton/BarChartSkeleton.tsx b/src/components/common/BarChartSkeleton/BarChartSkeleton.tsx new file mode 100644 index 0000000000..eae0040c62 --- /dev/null +++ b/src/components/common/BarChartSkeleton/BarChartSkeleton.tsx @@ -0,0 +1,29 @@ +import { Grid, Skeleton } from '@mui/material'; + +interface BarChartSkeletonProps { + bars: number; + width?: number; +} + +export const BarChartSkeleton: React.FC = ({ + bars, + width = 30, +}) => ( + + {new Array(bars).fill(undefined).map((_, index) => ( + + ))} + +); From 788db868d15e679af7fc0fcf7ecf8b63b61c8766 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 11:59:30 -0600 Subject: [PATCH 010/101] Unconditionally mock window.ResizeObserver window.ResizeObserver is undefined, so there is no reason to mock it sometimes and restore it afterwards. --- __tests__/util/setup.ts | 6 ++++++ __tests__/util/windowResizeObserver.ts | 14 -------------- .../CoachingDetail/CoachingDetail.test.tsx | 12 ------------ .../MonthlyCommitment/MonthlyCommitment.test.tsx | 12 ------------ .../DonationsGraph/DonationsGraph.test.tsx | 12 ------------ src/components/Dashboard/Dashboard.test.tsx | 9 --------- .../DonationHistories/DonationHistories.test.tsx | 9 --------- .../List/ListItem/Chart/Chart.test.tsx | 11 ----------- .../List/ListItem/ListItem.test.tsx | 12 ------------ .../DonationsReport/DonationsReport.test.tsx | 12 ------------ 10 files changed, 6 insertions(+), 103 deletions(-) delete mode 100644 __tests__/util/windowResizeObserver.ts diff --git a/__tests__/util/setup.ts b/__tests__/util/setup.ts index dfd6536544..06bcf4c17d 100644 --- a/__tests__/util/setup.ts +++ b/__tests__/util/setup.ts @@ -55,6 +55,12 @@ Object.defineProperty(window, 'location', { window.HTMLElement.prototype.scrollIntoView = jest.fn(); +window.ResizeObserver = jest.fn().mockReturnValue({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +}); + window.URL.revokeObjectURL = jest.fn(); beforeEach(() => { diff --git a/__tests__/util/windowResizeObserver.ts b/__tests__/util/windowResizeObserver.ts deleted file mode 100644 index 3b276f9fc4..0000000000 --- a/__tests__/util/windowResizeObserver.ts +++ /dev/null @@ -1,14 +0,0 @@ -const { ResizeObserver } = window; - -export const beforeTestResizeObserver = () => { - window.ResizeObserver = jest.fn().mockImplementation(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), - })); -}; - -export const afterTestResizeObserver = () => { - window.ResizeObserver = ResizeObserver; - jest.restoreAllMocks(); -}; diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx index 598a2bfad0..94db866072 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx @@ -5,10 +5,6 @@ import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import matchMediaMock from '__tests__/util/matchMediaMock'; import { render } from '__tests__/util/testingLibraryReactMock'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import theme from 'src/theme'; import { AccountListTypeEnum, CoachingDetail } from './CoachingDetail'; import { LevelOfEffortQuery } from './LevelOfEffort/LevelOfEffort.generated'; @@ -121,14 +117,6 @@ const TestComponent: React.FC = ({ const accountListId = 'account-list-1'; describe('LoadCoachingDetail', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); - describe.each([ { type: AccountListTypeEnum.Coaching, name: 'coaching' }, { type: AccountListTypeEnum.Own, name: 'own' }, diff --git a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx index ebe0434fae..4954f5cf6d 100644 --- a/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx +++ b/src/components/Coaching/CoachingDetail/MonthlyCommitment/MonthlyCommitment.test.tsx @@ -1,10 +1,6 @@ import { render } from '@testing-library/react'; import { DateTime } from 'luxon'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import { AccountListTypeEnum } from '../CoachingDetail'; import { MonthlyCommitment, MonthlyCommitmentProps } from './MonthlyCommitment'; import { GetReportsPledgeHistoriesQuery } from './MonthlyCommitment.generated'; @@ -60,14 +56,6 @@ const TestComponent: React.FC = ({ ); describe('MonthlyCommitment', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); - it('renders', async () => { const { findByTestId } = render(); diff --git a/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.test.tsx b/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.test.tsx index 69f608603d..f99a7c99c0 100644 --- a/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.test.tsx +++ b/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.test.tsx @@ -2,10 +2,6 @@ import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { render } from '__tests__/util/testingLibraryReactMock'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import { DonationsGraph } from './DonationsGraph'; import { GetDonationsGraphQuery, @@ -30,14 +26,6 @@ const donorAccountIds = ['donor-Account-Id']; const currency = 'USD'; describe('Donations Graph', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); - it('test renderer', async () => { const { findByText } = render( diff --git a/src/components/Dashboard/Dashboard.test.tsx b/src/components/Dashboard/Dashboard.test.tsx index de12a92a2c..2326d71c97 100644 --- a/src/components/Dashboard/Dashboard.test.tsx +++ b/src/components/Dashboard/Dashboard.test.tsx @@ -4,10 +4,6 @@ import { ThemeProvider } from '@mui/material/styles'; import { render, waitFor } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import matchMediaMock from '__tests__/util/matchMediaMock'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import { GetDashboardQuery } from 'pages/accountLists/GetDashboard.generated'; import useTaskModal from '../../hooks/useTaskModal'; import theme from '../../theme'; @@ -125,11 +121,6 @@ const data: GetDashboardQuery = { describe('Dashboard', () => { beforeEach(() => { matchMediaMock({ width: '1024px' }); - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); }); it('default', async () => { diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index af851fc0eb..97232305cf 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; import TestRouter from '__tests__/util/TestRouter'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import DonationHistories from '.'; const setTime = jest.fn(); @@ -87,11 +83,6 @@ describe('DonationHistories', () => { ], averageIgnoreCurrent: 1000, }; - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); }); it('shows references', () => { diff --git a/src/components/Reports/AccountsListLayout/List/ListItem/Chart/Chart.test.tsx b/src/components/Reports/AccountsListLayout/List/ListItem/Chart/Chart.test.tsx index 045a2616c6..5242ec1054 100644 --- a/src/components/Reports/AccountsListLayout/List/ListItem/Chart/Chart.test.tsx +++ b/src/components/Reports/AccountsListLayout/List/ListItem/Chart/Chart.test.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import theme from 'src/theme'; import { AccountListItemChart as Chart } from './Chart'; @@ -19,13 +15,6 @@ const dataMock = [ ]; describe('AccountItemChart', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); it('default', async () => { const { getByTestId, getByText } = render( diff --git a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx index 6c55ccb516..3ce4d86ea7 100644 --- a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx +++ b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx @@ -3,10 +3,6 @@ import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import theme from 'src/theme'; import { Account, AccountListItem as ListItem } from './ListItem'; @@ -56,14 +52,6 @@ describe('AccountItem', () => { }); describe('AccountItem Chart', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); - it('should render chart', async () => { const entryHistoriesMock = [ { diff --git a/src/components/Reports/DonationsReport/DonationsReport.test.tsx b/src/components/Reports/DonationsReport/DonationsReport.test.tsx index e99d4e22bb..a3ca360ebe 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.test.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.test.tsx @@ -5,10 +5,6 @@ import userEvent from '@testing-library/user-event'; import { DateTime } from 'luxon'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { - afterTestResizeObserver, - beforeTestResizeObserver, -} from '__tests__/util/windowResizeObserver'; import { DonationTableQuery } from 'src/components/DonationTable/DonationTable.generated'; import theme from 'src/theme'; import { GetDonationsGraphQuery } from '../../Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.generated'; @@ -83,14 +79,6 @@ interface Mocks { } describe('DonationsReport', () => { - beforeEach(() => { - beforeTestResizeObserver(); - }); - - afterEach(() => { - afterTestResizeObserver(); - }); - it('renders empty', async () => { const { getByTestId, From 71868d11bd14687b302da1de625916866d88f363 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 6 Dec 2024 14:17:46 -0600 Subject: [PATCH 011/101] Create HealthIndicatorGraph component --- .../HealthIndicatorGraph.graphql | 11 ++ .../HealthIndicatorGraph.test.tsx | 58 +++++++++ .../HealthIndicatorGraph.tsx | 121 ++++++++++++++++++ .../useGraphData.test.tsx | 119 +++++++++++++++++ .../HealthIndicatorGraph/useGraphData.ts | 76 +++++++++++ 5 files changed, 385 insertions(+) create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.graphql create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.graphql b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.graphql new file mode 100644 index 0000000000..a477294907 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.graphql @@ -0,0 +1,11 @@ +query HealthIndicatorGraph($accountListId: ID!) { + healthIndicatorData(accountListId: $accountListId) { + id + indicationPeriodBegin + consistencyHi + depthHi + ownershipHi + successHi + overallHi + } +} diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx new file mode 100644 index 0000000000..6aacd38b39 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx @@ -0,0 +1,58 @@ +import { render, waitForElementToBeRemoved } from '@testing-library/react'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { HealthIndicatorGraph } from './HealthIndicatorGraph'; +import { HealthIndicatorGraphQuery } from './HealthIndicatorGraph.generated'; + +const accountListId = 'account-list-1'; + +describe('HealthIndicatorGraph', () => { + it('renders nothing when there is no data', async () => { + const { getByTestId, queryByTestId } = render( + + mocks={{ + HealthIndicatorGraph: { + healthIndicatorData: [], + }, + }} + > + + , + ); + + const skeleton = getByTestId('BarChartSkeleton'); + expect(skeleton).toBeInTheDocument(); + await waitForElementToBeRemoved(skeleton); + + expect(queryByTestId('HealthIndicatorGraphHeader')).not.toBeInTheDocument(); + }); + + it('renders a skeleton while data is loading', async () => { + const { getByTestId } = render( + + + , + ); + + const skeleton = getByTestId('BarChartSkeleton'); + expect(skeleton).toBeInTheDocument(); + await waitForElementToBeRemoved(skeleton); + }); + + it('renders the average', async () => { + const { findByTestId } = render( + + mocks={{ + HealthIndicatorGraph: { + healthIndicatorData: [{ overallHi: 50 }], + }, + }} + > + + , + ); + + expect(await findByTestId('HealthIndicatorGraphHeader')).toHaveTextContent( + 'Average 50', + ); + }); +}); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx new file mode 100644 index 0000000000..2ccf3bffcc --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Card, CardContent, CardHeader } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ReferenceLine, + ResponsiveContainer, + Text, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { BarChartSkeleton } from 'src/components/common/BarChartSkeleton/BarChartSkeleton'; +import { LegendReferenceLine } from 'src/components/common/LegendReferenceLine/LegendReferenceLine'; +import { useGraphData } from './useGraphData'; + +interface HealthIndicatorGraphProps { + accountListId: string; +} + +export const HealthIndicatorGraph: React.FC = ({ + accountListId, +}) => { + const { t } = useTranslation(); + + const { loading, average, periods } = useGraphData(accountListId); + + const stacks = [ + { field: 'ownershipHi', label: t('Ownership'), color: '#FFCF07' }, + { field: 'successHi', label: t('Success'), color: '#30F2F2' }, + { + field: 'consistencyHi', + label: t('Consistency'), + color: '#1FC0D2', + }, + { field: 'depthHi', label: t('Depth'), color: '#007398' }, + ]; + + if (periods?.length === 0) { + // The account has no account list entries + return null; + } + + if (loading && !periods) { + // The account list is loading + return ( + + + + + + ); + } + + return ( + + + ) + } + /> + + + + + + + typeof item.dataKey === 'string' && + item.dataKey.endsWith('Scaled') + ? item.payload[item.dataKey.slice(0, -6)] + : scaledValue + } + /> + {average !== null && ( + + )} + + + (%) + + } + /> + {stacks.map(({ field, label, color }) => ( + + ))} + + + + + ); +}; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx new file mode 100644 index 0000000000..5c29a109e3 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx @@ -0,0 +1,119 @@ +import { ReactElement } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { HealthIndicatorGraphQuery } from './HealthIndicatorGraph.generated'; +import { useGraphData } from './useGraphData'; + +const Wrapper = ({ children }: { children: ReactElement }) => ( + + mocks={{ + HealthIndicatorGraph: { + healthIndicatorData: [ + { + indicationPeriodBegin: '2024-01-01', + overallHi: 33, + consistencyHi: 7, + depthHi: 21, + ownershipHi: 35, + successHi: 49, + }, + { + indicationPeriodBegin: '2024-02-01', + overallHi: null, + consistencyHi: null, + depthHi: null, + ownershipHi: null, + successHi: null, + }, + { + indicationPeriodBegin: '2024-03-01', + overallHi: 40, + consistencyHi: 14, + depthHi: 28, + ownershipHi: 42, + successHi: 56, + }, + ], + }, + }} + > + {children} + +); + +const accountListId = 'account-list-1'; + +describe('useGraphData', () => { + it('loading is true while the data is loading', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGraphData(accountListId), + { + wrapper: Wrapper, + }, + ); + + expect(result.current.loading).toBe(true); + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + }); + + it('calculates the average overall value ignoring missing months', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGraphData(accountListId), + { + wrapper: Wrapper, + }, + ); + + expect(result.current.average).toBe(null); + await waitForNextUpdate(); + expect(result.current.average).toBe(37); // (33 + 44) / 2, rounded + }); + + it('calculates the periods', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGraphData(accountListId), + { + wrapper: Wrapper, + }, + ); + + expect(result.current.periods).toBe(null); + await waitForNextUpdate(); + expect(result.current.periods).toEqual([ + { + month: 'Jan 2024', + consistencyHi: 7, + depthHi: 21, + ownershipHi: 35, + successHi: 49, + consistencyHiScaled: 1, + depthHiScaled: 3, + ownershipHiScaled: 15, + successHiScaled: 14, + }, + { + month: 'Feb 2024', + consistencyHi: null, + depthHi: null, + ownershipHi: null, + successHi: null, + consistencyHiScaled: null, + depthHiScaled: null, + ownershipHiScaled: null, + successHiScaled: null, + }, + { + month: 'Mar 2024', + consistencyHi: 14, + consistencyHiScaled: 2, + depthHi: 28, + depthHiScaled: 4, + ownershipHi: 42, + ownershipHiScaled: 18, + successHi: 56, + successHiScaled: 16, + }, + ]); + }); +}); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts new file mode 100644 index 0000000000..ac38a75d9e --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts @@ -0,0 +1,76 @@ +import { DateTime } from 'luxon'; +import { useLocale } from 'src/hooks/useLocale'; +import { monthYearFormat } from 'src/lib/intlFormat'; +import { isNotNullish } from 'src/lib/typeGuards'; +import { useHealthIndicatorGraphQuery } from './HealthIndicatorGraph.generated'; + +export interface Period { + month: string; + consistencyHi: number | null | undefined; + consistencyHiScaled: number | null | undefined; + depthHi: number | null | undefined; + depthHiScaled: number | null | undefined; + ownershipHi: number | null | undefined; + ownershipHiScaled: number | null | undefined; + successHi: number | null | undefined; + successHiScaled: number | null | undefined; +} + +interface UseGraphDataResult { + loading: boolean; + average: number | null; + periods: Period[] | null; +} + +// Scale a health indicator value by its weight in the overall calculation +const scale = ( + value: number | null | undefined, + weight = 1, +): number | null | undefined => { + return typeof value === 'number' ? (value * weight) / 7 : value; +}; + +export const useGraphData = (accountListId: string): UseGraphDataResult => { + const locale = useLocale(); + + const { data, loading } = useHealthIndicatorGraphQuery({ + variables: { + accountListId, + }, + }); + + const average = data + ? Math.round( + data.healthIndicatorData + .map((month) => month.overallHi) + .filter(isNotNullish) + .reduce( + (total, overallHi, _index, months) => + total + overallHi / months.length, + 0, + ), + ) + : null; + + const periods = + data?.healthIndicatorData.map((month) => ({ + month: monthYearFormat( + DateTime.fromISO(month.indicationPeriodBegin), + locale, + ), + consistencyHi: month.consistencyHi, + depthHi: month.depthHi, + ownershipHi: month.ownershipHi, + successHi: month.successHi, + consistencyHiScaled: scale(month.consistencyHi), + depthHiScaled: scale(month.depthHi), + ownershipHiScaled: scale(month.ownershipHi, 3), + successHiScaled: scale(month.successHi, 2), + })) ?? null; + + return { + loading, + average, + periods, + }; +}; From 45378908658104ff00057f06a923cd79b0b36c67 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 9 Dec 2024 13:55:40 -0600 Subject: [PATCH 012/101] Remove Hi suffix from field names --- .../HealthIndicatorGraph.tsx | 8 ++-- .../useGraphData.test.tsx | 48 +++++++++---------- .../HealthIndicatorGraph/useGraphData.ts | 35 +++++++------- 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx index 2ccf3bffcc..f0100b5066 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx @@ -29,14 +29,14 @@ export const HealthIndicatorGraph: React.FC = ({ const { loading, average, periods } = useGraphData(accountListId); const stacks = [ - { field: 'ownershipHi', label: t('Ownership'), color: '#FFCF07' }, - { field: 'successHi', label: t('Success'), color: '#30F2F2' }, + { field: 'ownership', label: t('Ownership'), color: '#FFCF07' }, + { field: 'success', label: t('Success'), color: '#30F2F2' }, { - field: 'consistencyHi', + field: 'consistency', label: t('Consistency'), color: '#1FC0D2', }, - { field: 'depthHi', label: t('Depth'), color: '#007398' }, + { field: 'depth', label: t('Depth'), color: '#007398' }, ]; if (periods?.length === 0) { diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx index 5c29a109e3..0c935d9ec7 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.test.tsx @@ -83,36 +83,36 @@ describe('useGraphData', () => { expect(result.current.periods).toEqual([ { month: 'Jan 2024', - consistencyHi: 7, - depthHi: 21, - ownershipHi: 35, - successHi: 49, - consistencyHiScaled: 1, - depthHiScaled: 3, - ownershipHiScaled: 15, - successHiScaled: 14, + consistency: 7, + depth: 21, + ownership: 35, + success: 49, + consistencyScaled: 1, + depthScaled: 3, + ownershipScaled: 15, + successScaled: 14, }, { month: 'Feb 2024', - consistencyHi: null, - depthHi: null, - ownershipHi: null, - successHi: null, - consistencyHiScaled: null, - depthHiScaled: null, - ownershipHiScaled: null, - successHiScaled: null, + consistency: null, + depth: null, + ownership: null, + success: null, + consistencyScaled: null, + depthScaled: null, + ownershipScaled: null, + successScaled: null, }, { month: 'Mar 2024', - consistencyHi: 14, - consistencyHiScaled: 2, - depthHi: 28, - depthHiScaled: 4, - ownershipHi: 42, - ownershipHiScaled: 18, - successHi: 56, - successHiScaled: 16, + consistency: 14, + consistencyScaled: 2, + depth: 28, + depthScaled: 4, + ownership: 42, + ownershipScaled: 18, + success: 56, + successScaled: 16, }, ]); }); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts index ac38a75d9e..3c92894411 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/useGraphData.ts @@ -6,14 +6,14 @@ import { useHealthIndicatorGraphQuery } from './HealthIndicatorGraph.generated'; export interface Period { month: string; - consistencyHi: number | null | undefined; - consistencyHiScaled: number | null | undefined; - depthHi: number | null | undefined; - depthHiScaled: number | null | undefined; - ownershipHi: number | null | undefined; - ownershipHiScaled: number | null | undefined; - successHi: number | null | undefined; - successHiScaled: number | null | undefined; + consistency: number | null | undefined; + consistencyScaled: number | null | undefined; + depth: number | null | undefined; + depthScaled: number | null | undefined; + ownership: number | null | undefined; + ownershipScaled: number | null | undefined; + success: number | null | undefined; + successScaled: number | null | undefined; } interface UseGraphDataResult { @@ -45,8 +45,7 @@ export const useGraphData = (accountListId: string): UseGraphDataResult => { .map((month) => month.overallHi) .filter(isNotNullish) .reduce( - (total, overallHi, _index, months) => - total + overallHi / months.length, + (total, overall, _index, months) => total + overall / months.length, 0, ), ) @@ -58,14 +57,14 @@ export const useGraphData = (accountListId: string): UseGraphDataResult => { DateTime.fromISO(month.indicationPeriodBegin), locale, ), - consistencyHi: month.consistencyHi, - depthHi: month.depthHi, - ownershipHi: month.ownershipHi, - successHi: month.successHi, - consistencyHiScaled: scale(month.consistencyHi), - depthHiScaled: scale(month.depthHi), - ownershipHiScaled: scale(month.ownershipHi, 3), - successHiScaled: scale(month.successHi, 2), + consistency: month.consistencyHi, + depth: month.depthHi, + ownership: month.ownershipHi, + success: month.successHi, + consistencyScaled: scale(month.consistencyHi), + depthScaled: scale(month.depthHi), + ownershipScaled: scale(month.ownershipHi, 3), + successScaled: scale(month.successHi, 2), })) ?? null; return { From 31a2a39fded386eb0abbdd552cf0c86ef427a754 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 9 Dec 2024 14:33:00 -0600 Subject: [PATCH 013/101] Add graph colors to theme --- .../DonationHistories/DonationHistories.tsx | 25 ++++++++--- .../HealthIndicatorGraph.tsx | 23 +++++++--- src/theme.ts | 43 ++++++++++++++++--- 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index a4153f3dd9..843907d990 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -8,6 +8,7 @@ import { Skeleton, Theme, Typography, + useTheme, } from '@mui/material'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; @@ -82,11 +83,17 @@ const DonationHistories = ({ setTime, }: Props): ReactElement => { const { classes } = useStyles(); + const { palette } = useTheme(); const { push } = useRouter(); const { t } = useTranslation(); const locale = useLocale(); const accountListId = useAccountListId(); - const fills = ['#FFCF07', '#30F2F2', '#1FC0D2', '#007398']; + const fills = [ + palette.cruYellow.main, + palette.graphBlue3.main, + palette.graphBlue2.main, + palette.graphBlue1.main, + ]; const currencies: { dataKey: string; fill: string }[] = []; const periods = reportsDonationHistories?.periods?.map((period) => { const data: { @@ -157,7 +164,7 @@ const DonationHistories = ({ | @@ -194,7 +201,7 @@ const DonationHistories = ({ @@ -238,19 +245,19 @@ const DonationHistories = ({ {goal && ( )} {pledged && ( )} @@ -294,7 +301,11 @@ const DonationHistories = ({ - + )} diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx index f0100b5066..50fbee415b 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card, CardContent, CardHeader } from '@mui/material'; +import { Card, CardContent, CardHeader, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Bar, @@ -25,18 +25,23 @@ export const HealthIndicatorGraph: React.FC = ({ accountListId, }) => { const { t } = useTranslation(); + const { palette } = useTheme(); const { loading, average, periods } = useGraphData(accountListId); const stacks = [ - { field: 'ownership', label: t('Ownership'), color: '#FFCF07' }, - { field: 'success', label: t('Success'), color: '#30F2F2' }, + { + field: 'ownership', + label: t('Ownership'), + color: palette.cruYellow.main, + }, + { field: 'success', label: t('Success'), color: palette.graphBlue1.main }, { field: 'consistency', label: t('Consistency'), - color: '#1FC0D2', + color: palette.graphBlue2.main, }, - { field: 'depth', label: t('Depth'), color: '#007398' }, + { field: 'depth', label: t('Depth'), color: palette.graphBlue3.main }, ]; if (periods?.length === 0) { @@ -64,7 +69,7 @@ export const HealthIndicatorGraph: React.FC = ({ ) } @@ -91,7 +96,11 @@ export const HealthIndicatorGraph: React.FC = ({ } /> {average !== null && ( - + )} Date: Tue, 10 Dec 2024 14:51:57 -0500 Subject: [PATCH 014/101] Create the bare bones of the Health Indicator widget --- .../HealthIndicatorWidget.graphql | 10 ++ .../HealthIndicatorWidget.tsx | 124 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql new file mode 100644 index 0000000000..c4056dfe7f --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql @@ -0,0 +1,10 @@ +query HealthIndicatorWidget($accountListId: ID!) { + healthIndicatorData(accountListId: $accountListId) { + id + overallHi + ownershipHi + consistencyHi + successHi + depthHi + } +} diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx new file mode 100644 index 0000000000..f30b948e42 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -0,0 +1,124 @@ +import NextLink from 'next/link'; +import React from 'react'; +import { + Box, + Button, + CardActions, + CardContent, + CardHeader, + Grid, + Skeleton, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import AnimatedCard from 'src/components/AnimatedCard'; +import StyledProgress from 'src/components/StyledProgress'; +import { Maybe } from 'src/graphql/types.generated'; +import { useHealthIndicatorWidgetQuery } from './HealthIndicatorWidget.generated'; + +interface HealthIndicatorWidgetProps { + accountListId: string; +} + +export const HealthIndicatorWidget: React.FC = ({ + accountListId, +}) => { + const { t } = useTranslation(); + + const { data, loading } = useHealthIndicatorWidgetQuery({ + variables: { + accountListId, + }, + }); + + const currentStats = + data?.healthIndicatorData[data?.healthIndicatorData.length - 1]; + + return ( + + + + + + {currentStats?.overallHi} + + + + {t('Overall Health Indicator')} + + + + + + + + + + + + + + + + + ); +}; + +interface WidgetStatProps { + loading: boolean; + stat?: Maybe; + statName: string; +} + +const WidgetStat: React.FC = ({ loading, stat, statName }) => ( + + {loading ? ( + + ) : ( + <> + {stat} + + {statName} + + + )} + +); From 4fd1765cce36349150918b72ef0e204961bd70bd Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 10 Dec 2024 14:52:50 -0500 Subject: [PATCH 015/101] Add the widget to the dashboard --- src/components/Dashboard/Dashboard.tsx | 2 +- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 229 +++++++++--------- 2 files changed, 121 insertions(+), 110 deletions(-) diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index a0abbdf054..e0c89c10b9 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -39,7 +39,7 @@ const Dashboard = ({ data, accountListId }: Props): ReactElement => { exit="exit" variants={variants} > - + - - - - - - - -
- {t('Goal')} - - - {loading ? ( - - ) : ( - currencyFormat(goal, currencyCode, locale) - )} - - - - - -
- {t('Gifts Started')} - - - {loading ? ( - - ) : isNaN(receivedPercentage) ? ( - '-' - ) : ( - percentageFormat(receivedPercentage, locale) - )} - - - {loading ? ( - - ) : ( - currencyFormat(received, currencyCode, locale) - )} - - - - -
- {t('Commitments')} - - - {loading ? ( - - ) : isNaN(pledgedPercentage) ? ( - '-' - ) : ( - percentageFormat(pledgedPercentage, locale) - )} - - - {loading ? ( - - ) : ( - currencyFormat(pledged, currencyCode, locale) - )} - - - - {!isNaN(belowGoal) && belowGoal > 0 ? ( - + + + + + + + + + +
+ {t('Goal')} + + + {loading ? ( + + ) : ( + currencyFormat(goal, currencyCode, locale) + )} + + + + - {t('Below Goal')} +
+ {t('Gifts Started')} - {percentageFormat(belowGoalPercentage, locale)} + {loading ? ( + + ) : isNaN(receivedPercentage) ? ( + '-' + ) : ( + percentageFormat(receivedPercentage, locale) + )} - {currencyFormat(belowGoal, currencyCode, locale)} + {loading ? ( + + ) : ( + currencyFormat(received, currencyCode, locale) + )} - ) : ( - + - {t('Above Goal')} +
+ {t('Commitments')} {loading ? ( - ) : isNaN(belowGoalPercentage) ? ( + ) : isNaN(pledgedPercentage) ? ( '-' ) : ( - percentageFormat(-belowGoalPercentage, locale) + percentageFormat(pledgedPercentage, locale) )} {loading ? ( ) : ( - currencyFormat(-belowGoal, currencyCode, locale) + currencyFormat(pledged, currencyCode, locale) )} - )} - - - - + + {!isNaN(belowGoal) && belowGoal > 0 ? ( + + + {t('Below Goal')} + + + {percentageFormat(belowGoalPercentage, locale)} + + + {currencyFormat(belowGoal, currencyCode, locale)} + + + ) : ( + + + {t('Above Goal')} + + + {loading ? ( + + ) : isNaN(belowGoalPercentage) ? ( + '-' + ) : ( + percentageFormat(-belowGoalPercentage, locale) + )} + + + {loading ? ( + + ) : ( + currencyFormat(-belowGoal, currencyCode, locale) + )} + + + )} + + + + + + + + + + ); }; From 00f42501b5dc9a7a941096458589d27cc5d139ca Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 10 Dec 2024 14:54:27 -0500 Subject: [PATCH 016/101] Switching from makeStyles() to styled() components so we can pass in parameters to change the height of the process bar. --- .../HealthIndicatorWidget.tsx | 1 + .../StyledProgress/StyledProgress.tsx | 119 +++++++++--------- 2 files changed, 63 insertions(+), 57 deletions(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index f30b948e42..5d31beac63 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -61,6 +61,7 @@ export const HealthIndicatorWidget: React.FC = ({ primary={ currentStats?.overallHi ? currentStats.overallHi / 100 : 0 } + barHeight={20} /> diff --git a/src/components/StyledProgress/StyledProgress.tsx b/src/components/StyledProgress/StyledProgress.tsx index d7ca20d1ac..f911702360 100644 --- a/src/components/StyledProgress/StyledProgress.tsx +++ b/src/components/StyledProgress/StyledProgress.tsx @@ -1,48 +1,55 @@ import React, { ReactElement } from 'react'; -import { Box, Skeleton, Theme, Typography } from '@mui/material'; +import { Box, Skeleton, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; import { useLocale } from 'src/hooks/useLocale'; import { percentageFormat } from '../../lib/intlFormat'; import MinimalSpacingTooltip from '../Shared/MinimalSpacingTooltip'; -const useStyles = makeStyles()((theme: Theme) => ({ - box: { - width: '100%', - height: '54px', - border: '2px solid #999999', - borderRadius: '50px', - padding: '2px', - position: 'relative', - marginBottom: theme.spacing(2), - display: 'flex', - alignItems: 'center', - }, - progress: { - position: 'absolute', - left: '2px', - height: '46px', - minWidth: '46px', - maxWidth: '99.6%', - borderRadius: '46px', - transition: 'width 1s ease-out', - width: '0%', - }, - skeleton: { - borderRadius: '46px', - height: '46px', - transform: 'none', - }, - primary: { - background: 'linear-gradient(180deg, #FFE67C 0%, #FFCF07 100%)', - }, - secondary: { - border: '5px solid #FFCF07', - }, - inline: { - display: 'inline', - }, - belowDetails: { position: 'absolute', right: '5px' }, +const BelowDetailsBox = styled(Box)(() => ({ + position: 'absolute', + right: '5px', +})); + +const InlineTypography = styled(Typography)(() => ({ + display: 'inline', +})); + +const StyledSkeleton = styled(Skeleton)(() => ({ + borderRadius: '20px', + height: '20px', + transform: 'none', +})); + +const ProgressBar = styled(Box, { + shouldForwardProp: (prop) => prop !== 'barHeight' && prop !== 'isPrimary', +})<{ barHeight: number; isPrimary: boolean }>(({ barHeight, isPrimary }) => ({ + position: 'absolute', + left: '2px', + height: barHeight + 'px', + minWidth: barHeight + 'px', + maxWidth: '99.6%', + borderRadius: barHeight + 'px', + transition: 'width 1s ease-out', + width: '0%', + background: isPrimary + ? 'linear-gradient(180deg, #FFE67C 0%, #FFCF07 100%)' + : 'initial', + border: isPrimary ? 'none' : '5px solid #FFCF07', +})); + +const ProcessBoxContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== 'barHeight', +})<{ barHeight: number }>(({ barHeight, theme }) => ({ + width: '100%', + height: barHeight + 8 + 'px', + border: '2px solid #999999', + borderRadius: '50px', + padding: '2px', + position: 'relative', + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'center', })); interface Props { @@ -51,6 +58,7 @@ interface Props { secondary?: number; receivedBelow?: string; committedBelow?: string; + barHeight?: number; } const StyledProgress = ({ @@ -59,53 +67,50 @@ const StyledProgress = ({ secondary = 0, receivedBelow = '', committedBelow = '', + barHeight = 46, }: Props): ReactElement => { const locale = useLocale(); const { t } = useTranslation(); - const { classes } = useStyles(); - return ( - + {loading ? ( - + ) : ( <> - - )} - + {receivedBelow && ( - {receivedBelow} + {receivedBelow} )} {committedBelow && receivedBelow && ( - {' / '} + {' / '} )} {committedBelow && ( - {committedBelow} + {committedBelow} )} - - + + ); }; From 17d45113ea2f2798211a80c9bdd50f41f60b1513 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Wed, 11 Dec 2024 07:41:43 -0500 Subject: [PATCH 017/101] Making dashboard responsive with new widget --- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 16 ++--- .../HealthIndicatorWidget.tsx | 48 +++++++++---- .../StyledProgress/StyledProgress.tsx | 68 ++++++++++--------- 3 files changed, 79 insertions(+), 53 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 697f48e77a..4e6b9c4552 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -105,7 +105,7 @@ const MonthlyGoal = ({ - + - + - +
- +
- +
{!isNaN(belowGoal) && belowGoal > 0 ? ( - + {t('Below Goal')} @@ -216,7 +216,7 @@ const MonthlyGoal = ({ ) : ( - + {t('Above Goal')} @@ -250,7 +250,7 @@ const MonthlyGoal = ({ - + diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index 5d31beac63..848c2a5433 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -10,12 +10,33 @@ import { Skeleton, Typography, } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import AnimatedCard from 'src/components/AnimatedCard'; import StyledProgress from 'src/components/StyledProgress'; import { Maybe } from 'src/graphql/types.generated'; import { useHealthIndicatorWidgetQuery } from './HealthIndicatorWidget.generated'; +const StyledCardContent = styled(CardContent)(({ theme }) => ({ + padding: theme.spacing(2), + paddingBottom: 0, + height: 'calc(100% - 103px)', +})); + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + gap: 2, + justifyContent: 'space-between', + alignItems: 'center', +})); + +const WidgetStatGrid = styled(Grid)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + marginBottom: theme.spacing(1.5), +})); + interface HealthIndicatorWidgetProps { accountListId: string; } @@ -35,25 +56,24 @@ export const HealthIndicatorWidget: React.FC = ({ data?.healthIndicatorData[data?.healthIndicatorData.length - 1]; return ( - + - - - + + + {currentStats?.overallHi} - + {t('Overall Health Indicator')} = ({ barHeight={20} /> - + = ({ statName={t('Depth')} /> - + - {calculatedGoal && monthlyGoal !== calculatedGoal && ( + {calculatedGoal && initialMonthlyGoal !== null && ( = ({ variant="outlined" type="button" onClick={() => { - setFieldValue('monthlyGoal', calculatedGoal); - submitForm(); + onSubmit({ monthlyGoal: null }); }} > {t('Reset to Calculated Goal')} From 0463bfa4ed29543a97338f3b180780e852a90109 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 6 Jan 2025 14:23:18 -0600 Subject: [PATCH 024/101] Show the machine calculated goal currency Previously the account currency was being used instead of the currency of the machine calculated goal --- .../MonthlyGoalAccordion/MachineCalculatedGoal.graphql | 1 + .../MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx | 7 +++++-- .../MonthlyGoalAccordion/MonthlyGoalAccordion.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql index 0e9e00ead3..5a28784fe8 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql @@ -6,5 +6,6 @@ query MachineCalculatedGoal($accountListId: ID!, $month: ISO8601Date!) { ) { id machineCalculatedGoal + machineCalculatedGoalCurrency } } diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index 3a6ad99c37..73713f73ce 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -35,12 +35,14 @@ const mutationSpy = jest.fn(); interface ComponentsProps { monthlyGoal: number | null; machineCalculatedGoal?: number; + machineCalculatedGoalCurrency?: string; expandedPanel: string; } const Components: React.FC = ({ monthlyGoal, machineCalculatedGoal, + machineCalculatedGoalCurrency = null, expandedPanel, }) => ( @@ -52,7 +54,7 @@ const Components: React.FC = ({ mocks={{ MachineCalculatedGoal: { healthIndicatorData: machineCalculatedGoal - ? [{ machineCalculatedGoal }] + ? [{ machineCalculatedGoal, machineCalculatedGoalCurrency }] : [], }, }} @@ -155,13 +157,14 @@ describe('MonthlyGoalAccordion', () => { , ); expect( await findByText( - /Based on the past year, NetSuite estimates that you need at least \$1,500 of monthly support./, + /Based on the past year, NetSuite estimates that you need at least €1,500 of monthly support./, ), ).toBeInTheDocument(); diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index d607122148..81d6f569a8 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -66,9 +66,11 @@ export const MonthlyGoalAccordion: React.FC = ({ }, }); const calculatedGoal = data?.healthIndicatorData[0]?.machineCalculatedGoal; + const calculatedCurrency = + data?.healthIndicatorData[0]?.machineCalculatedGoalCurrency ?? currency; const formattedCalculatedGoal = useMemo( - () => formatMonthlyGoal(calculatedGoal ?? null, currency, locale), - [calculatedGoal, currency, locale], + () => formatMonthlyGoal(calculatedGoal ?? null, calculatedCurrency, locale), + [calculatedGoal, calculatedCurrency, locale], ); const formattedMonthlyGoal = useMemo( From 580e7b1573c0caebf53cc2c15406f571556512ae Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 8 Jan 2025 15:32:27 -0600 Subject: [PATCH 025/101] Make currency nullable --- .../[accountListId]/settings/preferences.page.tsx | 4 ++-- .../CurrencyAccordion/CurrencyAccordion.tsx | 4 ++-- .../MonthlyGoalAccordion.test.tsx | 2 +- .../MonthlyGoalAccordion/MonthlyGoalAccordion.tsx | 15 ++++++++++----- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.tsx index 9733ef0fae..d8dd688187 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.tsx @@ -247,7 +247,7 @@ const Preferences: React.FC = () => { } accountListId={accountListId} currency={ - accountPreferencesData?.accountList?.settings?.currency || '' + accountPreferencesData?.accountList?.settings?.currency || null } disabled={onSetupTour && setup !== 1} handleSetupChange={handleSetupChange} @@ -267,7 +267,7 @@ const Preferences: React.FC = () => { handleAccordionChange={handleAccordionChange} expandedPanel={expandedPanel} currency={ - accountPreferencesData?.accountList?.settings?.currency || '' + accountPreferencesData?.accountList?.settings?.currency || null } accountListId={accountListId} disabled={onSetupTour} diff --git a/src/components/Settings/preferences/accordions/CurrencyAccordion/CurrencyAccordion.tsx b/src/components/Settings/preferences/accordions/CurrencyAccordion/CurrencyAccordion.tsx index f6c57ad581..c79220f5de 100644 --- a/src/components/Settings/preferences/accordions/CurrencyAccordion/CurrencyAccordion.tsx +++ b/src/components/Settings/preferences/accordions/CurrencyAccordion/CurrencyAccordion.tsx @@ -20,7 +20,7 @@ const preferencesSchema: yup.ObjectSchema< interface CurrencyAccordionProps { handleAccordionChange: (panel: string) => void; expandedPanel: string; - currency: string; + currency: string | null; accountListId: string; disabled?: boolean; } @@ -73,7 +73,7 @@ export const CurrencyAccordion: React.FC = ({ onAccordionChange={handleAccordionChange} expandedPanel={expandedPanel} label={label} - value={currency} + value={currency ?? ''} fullWidth disabled={disabled} > diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index 73713f73ce..89d33fc15a 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -42,7 +42,7 @@ interface ComponentsProps { const Components: React.FC = ({ monthlyGoal, machineCalculatedGoal, - machineCalculatedGoalCurrency = null, + machineCalculatedGoalCurrency = 'USD', expandedPanel, }) => ( diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index 81d6f569a8..1e818cbec6 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -21,14 +21,14 @@ const accountPreferencesSchema: yup.ObjectSchema< const formatMonthlyGoal = ( goal: number | null, - currency: string, + currency: string | null, locale: string, ): string => { if (goal === null) { return ''; } - if (currency && locale) { + if (currency) { return currencyFormat(goal, currency, locale); } return goal.toString(); @@ -39,7 +39,7 @@ interface MonthlyGoalAccordionProps { expandedPanel: string; monthlyGoal: number | null; accountListId: string; - currency: string; + currency: string | null; disabled?: boolean; handleSetupChange: () => Promise; } @@ -67,9 +67,14 @@ export const MonthlyGoalAccordion: React.FC = ({ }); const calculatedGoal = data?.healthIndicatorData[0]?.machineCalculatedGoal; const calculatedCurrency = - data?.healthIndicatorData[0]?.machineCalculatedGoalCurrency ?? currency; + data?.healthIndicatorData[0]?.machineCalculatedGoalCurrency; const formattedCalculatedGoal = useMemo( - () => formatMonthlyGoal(calculatedGoal ?? null, calculatedCurrency, locale), + () => + formatMonthlyGoal( + calculatedGoal ?? null, + calculatedCurrency ?? null, + locale, + ), [calculatedGoal, calculatedCurrency, locale], ); From 6eb69accf00209b96b75bace3ca633329d954341 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 8 Jan 2025 15:34:47 -0600 Subject: [PATCH 026/101] Load all months and use the most recent one --- .../MonthlyGoalAccordion/MachineCalculatedGoal.graphql | 8 ++------ .../MonthlyGoalAccordion/MonthlyGoalAccordion.tsx | 9 ++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql index 5a28784fe8..5a497bfd14 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.graphql @@ -1,9 +1,5 @@ -query MachineCalculatedGoal($accountListId: ID!, $month: ISO8601Date!) { - healthIndicatorData( - accountListId: $accountListId - beginDate: $month - endDate: $month - ) { +query MachineCalculatedGoal($accountListId: ID!) { + healthIndicatorData(accountListId: $accountListId) { id machineCalculatedGoal machineCalculatedGoalCurrency diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index 1e818cbec6..3d225ef637 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -1,7 +1,6 @@ import React, { ReactElement, useMemo } from 'react'; import { Box, Button, TextField, Tooltip } from '@mui/material'; import { Formik } from 'formik'; -import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; @@ -62,12 +61,12 @@ export const MonthlyGoalAccordion: React.FC = ({ const { data } = useMachineCalculatedGoalQuery({ variables: { accountListId, - month: DateTime.now().startOf('month').toISODate(), }, }); - const calculatedGoal = data?.healthIndicatorData[0]?.machineCalculatedGoal; - const calculatedCurrency = - data?.healthIndicatorData[0]?.machineCalculatedGoalCurrency; + const { + machineCalculatedGoal: calculatedGoal, + machineCalculatedGoalCurrency: calculatedCurrency, + } = data?.healthIndicatorData.at(-1) ?? {}; const formattedCalculatedGoal = useMemo( () => formatMonthlyGoal( From 259374eb6c3a047129dc8183bcc6fc2b608b1684 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 8 Jan 2025 15:35:37 -0600 Subject: [PATCH 027/101] Refactor nested ternary --- .../MonthlyGoalAccordion.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index 3d225ef637..d0323e1b06 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -110,19 +110,25 @@ export const MonthlyGoalAccordion: React.FC = ({ handleSetupChange(); }; - const instructions = calculatedGoal - ? initialMonthlyGoal === null - ? t( - 'Based on the past year, NetSuite estimates that you need at least {{goal}} of monthly support. You can choose your own target monthly goal or leave it blank to use the estimate.', - { goal: formattedCalculatedGoal }, - ) - : t( - 'Based on the past year, NetSuite estimates that you need at least {{goal}} of monthly support. You can choose your own target monthly goal or reset it to the estimate.', - { goal: formattedCalculatedGoal }, - ) - : t( + const getInstructions = () => { + if (typeof calculatedGoal !== 'number') { + return t( 'This amount should be set to the amount your organization has determined is your target monthly goal. If you do not know, make your best guess for now. You can change it at any time.', ); + } + + if (initialMonthlyGoal) { + return t( + 'Based on the past year, NetSuite estimates that you need at least {{goal}} of monthly support. You can choose your own target monthly goal or leave it blank to use the estimate.', + { goal: formattedCalculatedGoal }, + ); + } else { + return t( + 'Based on the past year, NetSuite estimates that you need at least {{goal}} of monthly support. You can choose your own target monthly goal or reset it to use the estimate.', + { goal: formattedCalculatedGoal }, + ); + } + }; return ( = ({ handleChange, }): ReactElement => ( - + Date: Thu, 9 Jan 2025 17:32:25 -0500 Subject: [PATCH 028/101] Create bare bones of the MPD Health Indicator report page --- .../healthIndicator/index.page.test.tsx | 54 +++++++++++++++ .../reports/healthIndicator/index.page.tsx | 66 +++++++++++++++++++ .../HealthIndicatorReport.tsx | 31 +++++++++ .../MultiPageMenu/MultiPageMenuItems.ts | 4 ++ 4 files changed, 155 insertions(+) create mode 100644 pages/accountLists/[accountListId]/reports/healthIndicator/index.page.test.tsx create mode 100644 pages/accountLists/[accountListId]/reports/healthIndicator/index.page.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx diff --git a/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.test.tsx b/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.test.tsx new file mode 100644 index 0000000000..f88c25dc21 --- /dev/null +++ b/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import HealthIndicatorPage from './index.page'; + +const accountListId = 'account-list-1'; +const router = { + query: { accountListId }, + isReady: true, +}; + +const Components = () => ( + + + + + + + + + + + + + +); + +describe('MPD Health Indicator Page', () => { + it('should show initial financial accounts page', async () => { + const { findByText } = render(); + + expect(await findByText('Overall Staff MPD Health')).toBeInTheDocument(); + }); + + it('should open and close menu', async () => { + const { findByRole, getByRole, queryByRole } = render(); + + userEvent.click( + await findByRole('button', { name: 'Toggle Navigation Panel' }), + ); + expect(getByRole('heading', { name: 'Reports' })).toBeInTheDocument(); + userEvent.click(getByRole('img', { name: 'Close' })); + expect(queryByRole('heading', { name: 'Reports' })).not.toBeInTheDocument(); + }); +}); diff --git a/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.tsx b/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.tsx new file mode 100644 index 0000000000..a05948a4dc --- /dev/null +++ b/pages/accountLists/[accountListId]/reports/healthIndicator/index.page.tsx @@ -0,0 +1,66 @@ +import Head from 'next/head'; +import React, { useState } from 'react'; +import { Box } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ensureSessionAndAccountList } from 'pages/api/utils/pagePropsHelpers'; +import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; +import Loading from 'src/components/Loading'; +import { HealthIndicatorReport } from 'src/components/Reports/HealthIndicatorReport/HealthIndicatorReport'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; + +const HealthIndicatorPage: React.FC = () => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); + const [navListOpen, setNavListOpen] = useState(false); + + const handleNavListToggle = () => { + setNavListOpen(!navListOpen); + }; + return ( + <> + + {`${appName} | ${t('Reports - MPD Health')}`} + + + {accountListId ? ( + + + } + leftPanel={ + + } + /> + + ) : ( + + )} + + ); +}; + +export const getServerSideProps = ensureSessionAndAccountList; + +export default HealthIndicatorPage; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx new file mode 100644 index 0000000000..995d2762f3 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { + HeaderTypeEnum, + MultiPageHeader, +} from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; + +interface HealthIndicatorReportProps { + accountListId: string; + isNavListOpen: boolean; + onNavListToggle: () => void; + title: string; +} + +export const HealthIndicatorReport: React.FC = ({ + accountListId, + isNavListOpen, + onNavListToggle, + title, +}) => { + return ( + + + + ); +}; diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts index 42a7dd6cb3..0df872e378 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts @@ -44,6 +44,10 @@ export const reportNavItems: NavItems[] = [ id: 'coaching', title: i18n.t('Coaching'), }, + { + id: 'healthIndicator', + title: i18n.t('MPD Health'), + }, ]; export const settingsNavItems: NavItems[] = [ From be34ecf24f43d48cdd9dd1f1d9686b36acada8a1 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 9 Jan 2025 17:37:16 -0500 Subject: [PATCH 029/101] Adding Monthly goal and HI graphs to report page --- src/components/Dashboard/Dashboard.tsx | 1 + .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 3 ++ .../HealthIndicatorReport.tsx | 36 +++++++++++++++++-- .../HealthIndicatorWidget.graphql | 8 ++--- .../HealthIndicatorWidget.test.tsx | 33 +++++++++++++++++ .../HealthIndicatorWidget.tsx | 36 ++++++++++--------- .../HealthIndicatorReport/MonthlyGoal.graphql | 15 ++++++++ 7 files changed, 107 insertions(+), 25 deletions(-) create mode 100644 src/components/Reports/HealthIndicatorReport/MonthlyGoal.graphql diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index e0c89c10b9..9fa42828c3 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -48,6 +48,7 @@ const Dashboard = ({ data, accountListId }: Props): ReactElement => { pledged={data.accountList.totalPledges} totalGiftsNotStarted={data.contacts.totalCount} currencyCode={data.accountList.currency} + onDashboard={true} /> diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index ac52790aa9..518e167f75 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -55,6 +55,7 @@ export interface MonthlyGoalProps { pledged?: number; totalGiftsNotStarted?: number; currencyCode?: string; + onDashboard?: boolean; } const MonthlyGoal = ({ @@ -65,6 +66,7 @@ const MonthlyGoal = ({ pledged = 0, totalGiftsNotStarted, currencyCode = 'USD', + onDashboard = false, }: MonthlyGoalProps): ReactElement => { const { t } = useTranslation(); const { classes } = useStyles(); @@ -286,6 +288,7 @@ const MonthlyGoal = ({ ({ + marginBottom: theme.spacing(2), +})); interface HealthIndicatorReportProps { accountListId: string; isNavListOpen: boolean; @@ -18,6 +25,12 @@ export const HealthIndicatorReport: React.FC = ({ onNavListToggle, title, }) => { + const { t } = useTranslation(); + const { data, loading } = useMonthlyGoalQuery({ + variables: { + accountListId, + }, + }); return ( = ({ title={title} headerType={HeaderTypeEnum.Report} /> + + + + + + + {t('Health Indicator')} + + + + ); }; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql index 3562f02271..41ae0640eb 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql @@ -1,9 +1,5 @@ -query HealthIndicatorWidget($accountListId: ID!, $month: ISO8601Date!) { - healthIndicatorData( - accountListId: $accountListId - beginDate: $month - endDate: $month - ) { +query HealthIndicatorWidget($accountListId: ID!) { + healthIndicatorData(accountListId: $accountListId) { id overallHi ownershipHi diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx index a61c85ccd5..0155b4e8cd 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx @@ -22,11 +22,13 @@ interface ComponentsProps { healthIndicatorData?: HealthIndicatorWidgetQuery['healthIndicatorData']; showHealthIndicator?: boolean; goal?: number; + onDashboard?: boolean; } const Components = ({ healthIndicatorData = [], showHealthIndicator = true, goal = 7000, + onDashboard = true, }: ComponentsProps) => ( mocks={{ @@ -39,6 +41,7 @@ const Components = ({ { expect(setShowHealthIndicator).toHaveBeenCalledWith(true); }); + describe('On Dashboard', () => { + it('should show the view details button', async () => { + const { findByRole } = render( + , + ); + + expect( + await findByRole('link', { name: 'View Details' }), + ).toBeInTheDocument(); + }); + + it('should not show view details button if not on dashboard', async () => { + const { findByText, queryByRole } = render( + , + ); + + expect(await findByText('Ownership')).toBeInTheDocument(); + + expect( + queryByRole('button', { name: 'View Details' }), + ).not.toBeInTheDocument(); + }); + }); + it('renders the data correctly', async () => { const { findByText, getByText } = render( , diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index e6cd4d2f27..90afca3faa 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -11,7 +11,6 @@ import { Typography, } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import AnimatedCard from 'src/components/AnimatedCard'; import StyledProgress from 'src/components/StyledProgress'; @@ -34,6 +33,7 @@ const StyledBox = styled(Box)(() => ({ interface HealthIndicatorWidgetProps { accountListId: string; goal: number; + onDashboard: boolean; showHealthIndicator: boolean; setShowHealthIndicator: Dispatch>; setUsingMachineCalculatedGoal: Dispatch>; @@ -42,6 +42,7 @@ interface HealthIndicatorWidgetProps { export const HealthIndicatorWidget: React.FC = ({ accountListId, goal, + onDashboard = true, showHealthIndicator, setShowHealthIndicator, setUsingMachineCalculatedGoal, @@ -51,14 +52,12 @@ export const HealthIndicatorWidget: React.FC = ({ const { data, loading } = useHealthIndicatorWidgetQuery({ variables: { accountListId, - month: DateTime.now().startOf('month').toISODate(), }, }); useEffect(() => { setShowHealthIndicator(!!data?.healthIndicatorData.length); - const machineCalculatedGoal = - data?.healthIndicatorData[0]?.machineCalculatedGoal; + const { machineCalculatedGoal } = data?.healthIndicatorData.at(-1) ?? {}; setUsingMachineCalculatedGoal( !!machineCalculatedGoal && goal === machineCalculatedGoal, ); @@ -68,7 +67,7 @@ export const HealthIndicatorWidget: React.FC = ({ return null; } - const currentStats = data?.healthIndicatorData[0]; + const currentStats = data?.healthIndicatorData.at(-1); return ( @@ -80,9 +79,10 @@ export const HealthIndicatorWidget: React.FC = ({ /> @@ -135,16 +135,18 @@ export const HealthIndicatorWidget: React.FC = ({ /> - - - + {onDashboard && ( + + + + )} ); }; diff --git a/src/components/Reports/HealthIndicatorReport/MonthlyGoal.graphql b/src/components/Reports/HealthIndicatorReport/MonthlyGoal.graphql new file mode 100644 index 0000000000..f947e2c58e --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/MonthlyGoal.graphql @@ -0,0 +1,15 @@ +query MonthlyGoal($accountListId: ID!) { + accountList(id: $accountListId) { + id + monthlyGoal + receivedPledges + totalPledges + currency + } + contacts( + accountListId: $accountListId + contactsFilter: { pledgeReceived: NOT_RECEIVED, status: PARTNER_FINANCIAL } + ) { + totalCount + } +} From c1107db3813c77ffdd7da197dc2bca5a02562099 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 9 Jan 2025 17:39:23 -0500 Subject: [PATCH 030/101] Health Formula component --- .../HealthIndicatorFormula.graphql | 9 ++ .../HealthIndicatorFormula.tsx | 101 ++++++++++++++++++ .../HealthIndicatorReport.tsx | 9 ++ 3 files changed, 119 insertions(+) create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.graphql create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.graphql b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.graphql new file mode 100644 index 0000000000..afdf2d12a0 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.graphql @@ -0,0 +1,9 @@ +query HealthIndicatorFormula($accountListId: ID!) { + healthIndicatorData(accountListId: $accountListId) { + id + consistencyHi + depthHi + ownershipHi + successHi + } +} diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx new file mode 100644 index 0000000000..63a174521e --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -0,0 +1,101 @@ +import React, { Dispatch, SetStateAction, useEffect } from 'react'; +import { Box, Card, Skeleton, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import { useHealthIndicatorFormulaQuery } from './HealthIndicatorFormula.generated'; + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: 2, + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: theme.spacing(2), +})); + +interface HealthIndicatorFormulaProps { + accountListId: string; + noHealthIndicatorData: boolean; +} + +export const HealthIndicatorFormula: React.FC = ({ + accountListId, + noHealthIndicatorData, +}) => { + const { t } = useTranslation(); + + const { data, loading } = useHealthIndicatorFormulaQuery({ + variables: { + accountListId, + }, + }); + + + const latestMpdHealthData = data?.healthIndicatorData.at(-1); + + if (noHealthIndicatorData) { + return null; + } + + return ( + + + {t('MPD Health')} = [({t('Ownership')} x 3) + ({t('Success')} x 2) + ( + {t('Consistency')} x 1) + ({t('Depth')} x 1)] / 7 + + + + + + + + + ); +}; + +interface FormulaItemProps { + name: string; + explanation: string; + value: number; + isLoading: boolean; +} + +const FormulaItem: React.FC = ({ + name, + explanation, + value, + isLoading, +}) => ( + + {isLoading ? ( + + ) : ( + + {value} + + )} + + {name} = + {explanation} + + +); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx index 7dd6f202c6..ebe01434e3 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx @@ -56,6 +56,15 @@ export const HealthIndicatorReport: React.FC = ({ {t('Health Indicator')} + + + {t('MPD Health Formula')} + + + From 091a9c94a2bdea04d581f734da864db7add8a256 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Thu, 9 Jan 2025 17:39:49 -0500 Subject: [PATCH 031/101] Show no data message if no HI data --- .../HealthIndicatorFormula.tsx | 7 +++++++ .../HealthIndicatorReport.tsx | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index 63a174521e..ca5e6a70a9 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -15,11 +15,13 @@ const StyledBox = styled(Box)(({ theme }) => ({ interface HealthIndicatorFormulaProps { accountListId: string; noHealthIndicatorData: boolean; + setNoHealthIndicatorData: Dispatch>; } export const HealthIndicatorFormula: React.FC = ({ accountListId, noHealthIndicatorData, + setNoHealthIndicatorData, }) => { const { t } = useTranslation(); @@ -29,6 +31,11 @@ export const HealthIndicatorFormula: React.FC = ({ }, }); + useEffect(() => { + if (!data?.healthIndicatorData?.length && !loading) { + setNoHealthIndicatorData(true); + } + }, [data, loading]); const latestMpdHealthData = data?.healthIndicatorData.at(-1); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx index ebe01434e3..b0c0f717c2 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorReport.tsx @@ -1,3 +1,4 @@ +import React, { useState } from 'react'; import { Box, Container, Grid, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; @@ -6,6 +7,7 @@ import { HeaderTypeEnum, MultiPageHeader, } from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; +import { HealthIndicatorFormula } from './HealthIndicatorFormula/HealthIndicatorFormula'; import { HealthIndicatorGraph } from './HealthIndicatorGraph/HealthIndicatorGraph'; import { useMonthlyGoalQuery } from './MonthlyGoal.generated'; @@ -26,6 +28,7 @@ export const HealthIndicatorReport: React.FC = ({ title, }) => { const { t } = useTranslation(); + const [noHealthIndicatorData, setNoHealthIndicatorData] = useState(false); const { data, loading } = useMonthlyGoalQuery({ variables: { accountListId, @@ -40,6 +43,20 @@ export const HealthIndicatorReport: React.FC = ({ headerType={HeaderTypeEnum.Report} /> + {noHealthIndicatorData ? ( + + + + {t('No Health Indicator data available')} + + + {t( + 'There is currently no Health Indicator data available for this account. Please switch to an account that is an MPD Global account. If you are unsure whether you have access to an MPD Global account or need assistance in switching accounts, please reach out to your coach or our support team for guidance.', + )} + + + + ) : ( = ({ + )} ); From 88812d6da49cd280158ce4a44fdd4d8aaccd98da Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 10 Jan 2025 11:43:23 -0500 Subject: [PATCH 032/101] Use the calculated support goal in the monthly goal thermometer --- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 44 ++++++++++--------- .../HealthIndicatorWidget.test.tsx | 27 +++++++----- .../HealthIndicatorWidget.tsx | 8 ++-- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 518e167f75..270022234b 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -72,26 +72,27 @@ const MonthlyGoal = ({ const { classes } = useStyles(); const locale = useLocale(); const [showHealthIndicator, setShowHealthIndicator] = useState(false); - const [usingMachineCalculatedGoal, setUsingMachineCalculatedGoal] = - useState(false); + const [machineCalculatedGoal, setMachineCalculatedGoal] = useState< + number | null + >(null); const toolTipText = useMemo(() => { - const formattedGoal = currencyFormat(goal, currencyCode, locale); - return usingMachineCalculatedGoal + return machineCalculatedGoal ? t( 'Your current goal of {{goal}} is machine-calculated based on the past year of NetSuite data. You can adjust this goal in the settings preferences.', - { goal: formattedGoal }, + { goal: currencyFormat(machineCalculatedGoal, currencyCode, locale) }, ) : t( 'Your current goal of {{goal}} is staff-entered, based on the value set in your settings preferences.', - { goal: formattedGoal }, + { goal: currencyFormat(goal, currencyCode, locale) }, ); - }, [usingMachineCalculatedGoal, goal, currencyCode, locale]); + }, [machineCalculatedGoal, goal, currencyCode, locale]); - const receivedPercentage = received / goal; - const pledgedPercentage = pledged / goal; - const belowGoal = goal - pledged; - const belowGoalPercentage = belowGoal / goal; + const monthlyGoal = machineCalculatedGoal ?? goal; + const receivedPercentage = received / monthlyGoal; + const pledgedPercentage = pledged / monthlyGoal; + const belowGoal = monthlyGoal - pledged; + const belowGoalPercentage = belowGoal / monthlyGoal; const cssProps = { containerGrid: showHealthIndicator ? { spacing: 2 } : {}, @@ -125,7 +126,8 @@ const MonthlyGoal = ({ - {!loading && currencyFormat(goal, currencyCode, locale)} + {!loading && + currencyFormat(monthlyGoal, currencyCode, locale)} @@ -161,7 +163,7 @@ const MonthlyGoal = ({ {loading ? ( ) : ( - currencyFormat(goal, currencyCode, locale) + currencyFormat(monthlyGoal, currencyCode, locale) )} @@ -285,14 +287,14 @@ const MonthlyGoal = ({ - + diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx index 0155b4e8cd..db00f0fd8a 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx @@ -5,7 +5,7 @@ import { HealthIndicatorWidgetQuery } from './HealthIndicatorWidget.generated'; const accountListId = 'account-list-1'; const setShowHealthIndicator = jest.fn(); -const setUsingMachineCalculatedGoal = jest.fn(); +const setMachineCalculatedGoal = jest.fn(); const mutationSpy = jest.fn(); const healthIndicatorScore = { @@ -44,7 +44,7 @@ const Components = ({ onDashboard={onDashboard} showHealthIndicator={showHealthIndicator} setShowHealthIndicator={setShowHealthIndicator} - setUsingMachineCalculatedGoal={setUsingMachineCalculatedGoal} + setMachineCalculatedGoal={setMachineCalculatedGoal} /> ); @@ -132,8 +132,8 @@ describe('HealthIndicatorWidget', () => { expect(getByText('Depth')).toBeInTheDocument(); }); - describe('setUsingMachineCalculatedGoal', () => { - it('should set to TRUE as machine goal is defined and the same as the monthly goal', async () => { + describe('setMachineCalculatedGoal', () => { + it('should set to NULL if the user has entered a monthly goal', async () => { const { findByText } = render( { ); expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setUsingMachineCalculatedGoal).toHaveBeenCalledWith(true); + expect(setMachineCalculatedGoal).toHaveBeenCalledWith(null); }); - it('should set to FALSE as machine goal is different than the monthly goal', async () => { + it('should set to 7000 if the monthly goal is not set', async () => { const { findByText } = render( - , + , ); expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setUsingMachineCalculatedGoal).toHaveBeenCalledWith(false); + expect(setMachineCalculatedGoal).toHaveBeenCalledWith(7000); }); - it('should set to FALSE as machine goal is not defined', async () => { + it('should set to NULL if both the machineCalculatedGoal and monthly goal are not set', async () => { const { findByText } = render( , ); expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setUsingMachineCalculatedGoal).toHaveBeenCalledWith(false); + expect(setMachineCalculatedGoal).toHaveBeenCalledWith(null); }); }); }); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index 90afca3faa..6e52d1a994 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -36,7 +36,7 @@ interface HealthIndicatorWidgetProps { onDashboard: boolean; showHealthIndicator: boolean; setShowHealthIndicator: Dispatch>; - setUsingMachineCalculatedGoal: Dispatch>; + setMachineCalculatedGoal: Dispatch>; } export const HealthIndicatorWidget: React.FC = ({ @@ -45,7 +45,7 @@ export const HealthIndicatorWidget: React.FC = ({ onDashboard = true, showHealthIndicator, setShowHealthIndicator, - setUsingMachineCalculatedGoal, + setMachineCalculatedGoal, }) => { const { t } = useTranslation(); @@ -58,8 +58,8 @@ export const HealthIndicatorWidget: React.FC = ({ useEffect(() => { setShowHealthIndicator(!!data?.healthIndicatorData.length); const { machineCalculatedGoal } = data?.healthIndicatorData.at(-1) ?? {}; - setUsingMachineCalculatedGoal( - !!machineCalculatedGoal && goal === machineCalculatedGoal, + setMachineCalculatedGoal( + machineCalculatedGoal && !goal ? machineCalculatedGoal : null, ); }, [data, goal]); From f295759a02f86cab5b32d1b14319207dfda35a2c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 10 Jan 2025 11:43:56 -0500 Subject: [PATCH 033/101] Animate MPD Health Indicator widget, so it is like the other cards and slowly fade in. --- src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 270022234b..c0b77e8051 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -287,6 +287,7 @@ const MonthlyGoal = ({ + + From a597578fa524aae4b6f25f7d640b9401f15e3542 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Fri, 10 Jan 2025 15:25:29 -0500 Subject: [PATCH 034/101] Moved the HI GQL query to Monthly Goal to make the code less confusing and abide by best practices. --- .../MonthlyGoal/HealthIndicator.graphql} | 2 +- .../MonthlyGoal/MonthlyGoal.test.tsx | 111 +++++++++++++++-- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 75 +++++++----- .../HealthIndicatorWidget.test.tsx | 115 +++--------------- .../HealthIndicatorWidget.tsx | 50 ++------ 5 files changed, 174 insertions(+), 179 deletions(-) rename src/components/{Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql => Dashboard/MonthlyGoal/HealthIndicator.graphql} (76%) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql b/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql similarity index 76% rename from src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql rename to src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql index 41ae0640eb..4a28769347 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.graphql +++ b/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql @@ -1,4 +1,4 @@ -query HealthIndicatorWidget($accountListId: ID!) { +query HealthIndicator($accountListId: ID!) { healthIndicatorData(accountListId: $accountListId) { id overallHi diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index ef8d8f5109..3bf6834ec4 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import matchMediaMock from '__tests__/util/matchMediaMock'; -import { HealthIndicatorWidgetQuery } from 'src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.generated'; +import { HealthIndicatorQuery } from './HealthIndicator.generated'; import MonthlyGoal, { MonthlyGoalProps } from './MonthlyGoal'; const accountListId = 'account-list-1'; @@ -11,9 +11,18 @@ const defaultProps = { received: 500, pledged: 750, }; +const healthIndicatorScore: HealthIndicatorQuery['healthIndicatorData'][0] = { + id: '1', + overallHi: 90, + ownershipHi: 80, + consistencyHi: 70, + successHi: 60, + depthHi: 50, + machineCalculatedGoal: 7000, +}; const mutationSpy = jest.fn(); interface ComponentsProps { - healthIndicatorData?: HealthIndicatorWidgetQuery['healthIndicatorData']; + healthIndicatorData?: HealthIndicatorQuery['healthIndicatorData']; monthlyGoalProps?: Omit; } @@ -21,9 +30,9 @@ const Components = ({ healthIndicatorData = [], monthlyGoalProps, }: ComponentsProps) => ( - + mocks={{ - HealthIndicatorWidget: { + HealthIndicator: { healthIndicatorData, }, }} @@ -200,16 +209,26 @@ describe('MonthlyGoal', () => { }); }); - describe('HealthIndicatorWidget', () => { - it('should not show the health indicator and keep Grid styles', async () => { - const { getByTestId, queryByText } = render( + describe('HealthIndicator', () => { + it('does not render HI widget if no data', async () => { + const { queryByText } = render( , ); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('HealthIndicatorWidget'); + expect(mutationSpy).toHaveGraphqlOperation('HealthIndicator'); }); expect(queryByText('MPD Health Indicator')).not.toBeInTheDocument(); + }); + + it('does not change Grid styles if no data', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('HealthIndicator'); + }); expect(getByTestId('containerGrid')).not.toHaveClass( 'MuiGrid-spacing-xs-2', ); @@ -234,11 +253,85 @@ describe('MonthlyGoal', () => { ); await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('HealthIndicatorWidget'); + expect(mutationSpy).toHaveGraphqlOperation('HealthIndicator'); }); expect(getByText('MPD Health Indicator')).toBeInTheDocument(); expect(getByTestId('containerGrid')).toHaveClass('MuiGrid-spacing-xs-2'); expect(getByTestId('goalGrid')).toHaveClass('MuiGrid-grid-xs-6'); }); }); + + describe('Monthly Goal', () => { + it('should set the monthly goal to the user-entered goal if it exists', async () => { + const { findByRole } = render( + , + ); + + expect( + await findByRole('heading', { + name: /\$999.50/i, + }), + ).toBeInTheDocument(); + }); + + it('should set the monthly goal to the machine calculated goal', async () => { + const { findByRole, queryByRole } = render( + , + ); + + expect( + await findByRole('heading', { + name: /\$7,000/i, + }), + ).toBeInTheDocument(); + + expect( + queryByRole('heading', { + name: /\$999.50/i, + }), + ).not.toBeInTheDocument(); + }); + it('should set the monthly goal to 0 if both the machineCalculatedGoal and monthly goal are unset', async () => { + const { findByRole, queryByRole } = render( + , + ); + + expect( + await findByRole('heading', { + name: /\$0/i, + }), + ).toBeInTheDocument(); + + expect( + queryByRole('heading', { + name: /\$7,000/i, + }), + ).not.toBeInTheDocument(); + + expect( + queryByRole('heading', { + name: /\$999.50/i, + }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index c0b77e8051..9d0d867fe6 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useMemo, useState } from 'react'; +import React, { ReactElement, useMemo } from 'react'; import { Box, Button, @@ -26,6 +26,7 @@ import { import AnimatedBox from '../../AnimatedBox'; import AnimatedCard from '../../AnimatedCard'; import StyledProgress from '../../StyledProgress'; +import { useHealthIndicatorQuery } from './HealthIndicator.generated'; const useStyles = makeStyles()((_theme: Theme) => ({ received: { @@ -61,7 +62,7 @@ export interface MonthlyGoalProps { const MonthlyGoal = ({ accountListId, loading, - goal = 0, + goal: staffEnteredGoal = 0, received = 0, pledged = 0, totalGiftsNotStarted, @@ -71,28 +72,43 @@ const MonthlyGoal = ({ const { t } = useTranslation(); const { classes } = useStyles(); const locale = useLocale(); - const [showHealthIndicator, setShowHealthIndicator] = useState(false); - const [machineCalculatedGoal, setMachineCalculatedGoal] = useState< - number | null - >(null); - const toolTipText = useMemo(() => { - return machineCalculatedGoal - ? t( - 'Your current goal of {{goal}} is machine-calculated based on the past year of NetSuite data. You can adjust this goal in the settings preferences.', - { goal: currencyFormat(machineCalculatedGoal, currencyCode, locale) }, - ) - : t( - 'Your current goal of {{goal}} is staff-entered, based on the value set in your settings preferences.', - { goal: currencyFormat(goal, currencyCode, locale) }, - ); - }, [machineCalculatedGoal, goal, currencyCode, locale]); + const { data, loading: healthIndicatorLoading } = useHealthIndicatorQuery({ + variables: { + accountListId, + }, + }); + + const latestHealthIndicatorData = useMemo( + () => data?.healthIndicatorData.at(-1), + [data], + ); + const showHealthIndicator = !!data?.healthIndicatorData.length; + const machineCalculatedGoal = + latestHealthIndicatorData?.machineCalculatedGoal ?? null; + const goal = staffEnteredGoal || machineCalculatedGoal || 0; + const receivedPercentage = received / goal; + const pledgedPercentage = pledged / goal; + const belowGoal = goal - pledged; + const belowGoalPercentage = belowGoal / goal; - const monthlyGoal = machineCalculatedGoal ?? goal; - const receivedPercentage = received / monthlyGoal; - const pledgedPercentage = pledged / monthlyGoal; - const belowGoal = monthlyGoal - pledged; - const belowGoalPercentage = belowGoal / monthlyGoal; + const toolTipText = useMemo(() => { + if (staffEnteredGoal) { + return t( + 'Your current goal of {{goal}} is staff-entered, based on the value set in your settings preferences.', + { goal: currencyFormat(staffEnteredGoal, currencyCode, locale) }, + ); + } else if (machineCalculatedGoal) { + return t( + 'Your current goal of {{goal}} is machine-calculated, based on the past year of NetSuite data. You can adjust this goal in your settings preferences.', + { goal: currencyFormat(machineCalculatedGoal, currencyCode, locale) }, + ); + } else { + return t( + 'Your current goal is set to "0" because a monthly goal has not been set. You can set your monthly goal in your settings preferences.', + ); + } + }, [machineCalculatedGoal, staffEnteredGoal, currencyCode, locale]); const cssProps = { containerGrid: showHealthIndicator ? { spacing: 2 } : {}, @@ -126,8 +142,7 @@ const MonthlyGoal = ({ - {!loading && - currencyFormat(monthlyGoal, currencyCode, locale)} + {!loading && currencyFormat(goal, currencyCode, locale)} @@ -163,7 +178,7 @@ const MonthlyGoal = ({ {loading ? ( ) : ( - currencyFormat(monthlyGoal, currencyCode, locale) + currencyFormat(goal, currencyCode, locale) )} @@ -287,16 +302,14 @@ const MonthlyGoal = ({ - + {showHealthIndicator && latestHealthIndicatorData && ( - + )} diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx index db00f0fd8a..8094595f83 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.test.tsx @@ -1,13 +1,8 @@ -import { render, waitFor } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { render } from '@testing-library/react'; +import { HealthIndicatorQuery } from 'src/components/Dashboard/MonthlyGoal/HealthIndicator.generated'; import { HealthIndicatorWidget } from './HealthIndicatorWidget'; -import { HealthIndicatorWidgetQuery } from './HealthIndicatorWidget.generated'; const accountListId = 'account-list-1'; -const setShowHealthIndicator = jest.fn(); -const setMachineCalculatedGoal = jest.fn(); -const mutationSpy = jest.fn(); - const healthIndicatorScore = { id: '1', overallHi: 90, @@ -19,70 +14,29 @@ const healthIndicatorScore = { }; interface ComponentsProps { - healthIndicatorData?: HealthIndicatorWidgetQuery['healthIndicatorData']; - showHealthIndicator?: boolean; - goal?: number; + healthIndicatorData?: HealthIndicatorQuery['healthIndicatorData'][0]; + loading?: boolean; onDashboard?: boolean; } const Components = ({ - healthIndicatorData = [], - showHealthIndicator = true, - goal = 7000, + healthIndicatorData = {} as unknown as HealthIndicatorQuery['healthIndicatorData'][0], + loading = false, onDashboard = true, }: ComponentsProps) => ( - - mocks={{ - HealthIndicatorWidget: { - healthIndicatorData, - }, - }} - onCall={mutationSpy} - > - - + ); describe('HealthIndicatorWidget', () => { - it('renders nothing when there is no data', async () => { - const { queryByText, container } = render( - , - ); - - await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('HealthIndicatorWidget'); - }); - - expect(setShowHealthIndicator).toHaveBeenCalledWith(false); - expect(container).toBeEmptyDOMElement(); - expect(queryByText('MPD Health Indicator')).not.toBeInTheDocument(); - }); - - it('shows the health indicator if data', async () => { - render( - , - ); - - await waitFor(() => { - expect(mutationSpy).toHaveGraphqlOperation('HealthIndicatorWidget'); - }); - expect(setShowHealthIndicator).toHaveBeenCalledWith(true); - }); - describe('On Dashboard', () => { it('should show the view details button', async () => { const { findByRole } = render( , ); @@ -95,7 +49,7 @@ describe('HealthIndicatorWidget', () => { it('should not show view details button if not on dashboard', async () => { const { findByText, queryByRole } = render( , ); @@ -110,7 +64,7 @@ describe('HealthIndicatorWidget', () => { it('renders the data correctly', async () => { const { findByText, getByText } = render( - , + , ); expect(await findByText('Ownership')).toBeInTheDocument(); @@ -131,43 +85,4 @@ describe('HealthIndicatorWidget', () => { expect(getByText('50')).toBeInTheDocument(); expect(getByText('Depth')).toBeInTheDocument(); }); - - describe('setMachineCalculatedGoal', () => { - it('should set to NULL if the user has entered a monthly goal', async () => { - const { findByText } = render( - , - ); - - expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setMachineCalculatedGoal).toHaveBeenCalledWith(null); - }); - - it('should set to 7000 if the monthly goal is not set', async () => { - const { findByText } = render( - , - ); - - expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setMachineCalculatedGoal).toHaveBeenCalledWith(7000); - }); - it('should set to NULL if both the machineCalculatedGoal and monthly goal are not set', async () => { - const { findByText } = render( - , - ); - - expect(await findByText('Ownership')).toBeInTheDocument(); - expect(setMachineCalculatedGoal).toHaveBeenCalledWith(null); - }); - }); }); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index 6e52d1a994..a6d86c51f1 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -1,5 +1,5 @@ import NextLink from 'next/link'; -import React, { Dispatch, SetStateAction, useEffect } from 'react'; +import React from 'react'; import { Box, Button, @@ -13,8 +13,8 @@ import { import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import AnimatedCard from 'src/components/AnimatedCard'; +import { HealthIndicatorQuery } from 'src/components/Dashboard/MonthlyGoal/HealthIndicator.generated'; import StyledProgress from 'src/components/StyledProgress'; -import { useHealthIndicatorWidgetQuery } from './HealthIndicatorWidget.generated'; import { WidgetStat } from './WidgetStat/WidgetStat'; const StyledCardContent = styled(CardContent)(({ theme }) => ({ @@ -32,43 +32,19 @@ const StyledBox = styled(Box)(() => ({ interface HealthIndicatorWidgetProps { accountListId: string; - goal: number; onDashboard: boolean; - showHealthIndicator: boolean; - setShowHealthIndicator: Dispatch>; - setMachineCalculatedGoal: Dispatch>; + loading: boolean; + data: HealthIndicatorQuery['healthIndicatorData'][0]; } export const HealthIndicatorWidget: React.FC = ({ accountListId, - goal, onDashboard = true, - showHealthIndicator, - setShowHealthIndicator, - setMachineCalculatedGoal, + loading, + data, }) => { const { t } = useTranslation(); - const { data, loading } = useHealthIndicatorWidgetQuery({ - variables: { - accountListId, - }, - }); - - useEffect(() => { - setShowHealthIndicator(!!data?.healthIndicatorData.length); - const { machineCalculatedGoal } = data?.healthIndicatorData.at(-1) ?? {}; - setMachineCalculatedGoal( - machineCalculatedGoal && !goal ? machineCalculatedGoal : null, - ); - }, [data, goal]); - - if (!showHealthIndicator) { - return null; - } - - const currentStats = data?.healthIndicatorData.at(-1); - return ( = ({ > - {currentStats?.overallHi} + {data?.overallHi} = ({ @@ -111,25 +85,25 @@ export const HealthIndicatorWidget: React.FC = ({ From 3dbf2529bcaf4968713677e7227d59d095ae5303 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 11 Feb 2025 14:55:14 -0600 Subject: [PATCH 035/101] REVERT BEFORE MERGE: use staging API for this branch --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dc998fcb7..13e90af073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: # Use production API for codegen to make sure production is compatible with the code to be merged - API_URL: 'https://api.mpdx.org/graphql' + API_URL: 'https://api.stage.mpdx.org/graphql' # TODO: Revert this before merging! SITE_URL: 'http://stage.mpdx.org' jobs: From c7984a0bc50256f1e6c640a52ddfb1c78d53b9fd Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 11 Feb 2025 15:40:02 -0600 Subject: [PATCH 036/101] Fix health indicator tests --- .../settings/preferences.page.test.tsx | 5 ++ .../AppealProgress/AppealProgress.test.tsx | 16 +++-- .../DonationHistories.test.tsx | 58 ++++++++----------- .../DonationHistories/DonationHistories.tsx | 6 +- .../HealthIndicatorGraph.test.tsx | 50 +++++++++------- .../StyledProgress/StyledProgress.test.tsx | 16 +++-- 6 files changed, 85 insertions(+), 66 deletions(-) diff --git a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx index 33c1b08f2d..b56b5846ab 100644 --- a/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx +++ b/pages/accountLists/[accountListId]/settings/preferences.page.test.tsx @@ -15,6 +15,7 @@ import { } from 'src/components/Settings/preferences/GetAccountPreferences.generated'; import { GetPersonalPreferencesQuery } from 'src/components/Settings/preferences/GetPersonalPreferences.generated'; import { GetProfileInfoQuery } from 'src/components/Settings/preferences/GetProfileInfo.generated'; +import { MachineCalculatedGoalQuery } from 'src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MachineCalculatedGoal.generated'; import { TestSetupProvider } from 'src/components/Setup/SetupProvider'; import theme from 'src/theme'; import Preferences from './preferences.page'; @@ -68,6 +69,7 @@ const MocksProviders: React.FC = ({ GetPersonalPreferences: GetPersonalPreferencesQuery; GetProfileInfo: GetProfileInfoQuery; CanUserExportData: CanUserExportDataQuery; + MachineCalculatedGoal: MachineCalculatedGoalQuery; }> mocks={{ GetAccountPreferences: { @@ -132,6 +134,9 @@ const MocksProviders: React.FC = ({ exportedAt: null, }, }, + MachineCalculatedGoal: { + healthIndicatorData: [], + }, }} onCall={mutationSpy} > diff --git a/src/components/Coaching/AppealProgress/AppealProgress.test.tsx b/src/components/Coaching/AppealProgress/AppealProgress.test.tsx index cfaddf3f14..3bffdd33fa 100644 --- a/src/components/Coaching/AppealProgress/AppealProgress.test.tsx +++ b/src/components/Coaching/AppealProgress/AppealProgress.test.tsx @@ -6,8 +6,12 @@ describe('AppealProgress', () => { it('has correct defaults', () => { const { getByTestId, queryByTestId } = render(); expect(queryByTestId('styledProgressLoading')).toBeNull(); - expect(getByTestId('styledProgressPrimary')).toHaveStyle('width: 0%;'); - expect(getByTestId('styledProgressSecondary')).toHaveStyle('width: 0%;'); + expect(getByTestId('styledProgressPrimary')).toHaveStyle( + 'width: calc(0% - 4px);', + ); + expect(getByTestId('styledProgressSecondary')).toHaveStyle( + 'width: calc(0% - 4px);', + ); }); it('has correct overrides', () => { @@ -15,8 +19,12 @@ describe('AppealProgress', () => { , ); expect(queryByTestId('styledProgressLoading')).toBeNull(); - expect(getByTestId('styledProgressPrimary')).toHaveStyle('width: 50%;'); - expect(getByTestId('styledProgressSecondary')).toHaveStyle('width: 75%;'); + expect(getByTestId('styledProgressPrimary')).toHaveStyle( + 'width: calc(50% - 4px);', + ); + expect(getByTestId('styledProgressSecondary')).toHaveStyle( + 'width: calc(75% - 4px);', + ); }); it('allows loading', () => { diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index 97232305cf..6f613c42f5 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import TestRouter from '__tests__/util/TestRouter'; +import theme from 'src/theme'; +import { DonationHistoriesProps } from './DonationHistories'; import DonationHistories from '.'; const setTime = jest.fn(); @@ -13,23 +16,23 @@ const router = { push, }; -describe('DonationHistories', () => { - let reportsDonationHistories: Parameters< - typeof DonationHistories - >[0]['reportsDonationHistories']; +const TestComponent: React.FC = (props) => ( + + + , + + +); +describe('DonationHistories', () => { it('default', () => { - const { getByTestId, queryByTestId } = render( - - , - , - ); + const { getByTestId, queryByTestId } = render(); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('empty periods', () => { - reportsDonationHistories = { + const reportsDonationHistories = { periods: [ { convertedTotal: 0, @@ -44,31 +47,23 @@ describe('DonationHistories', () => { ], averageIgnoreCurrent: 0, }; + const { getByTestId, queryByTestId } = render( - - - , + , ); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('loading', () => { - const { getByTestId, queryByTestId } = render( - - - , - ); - expect(getByTestId('BarChartSkeleton')).toBeInTheDocument(); + const { getAllByTestId, queryByTestId } = render(); + expect(getAllByTestId('BarChartSkeleton')).toHaveLength(2); expect(queryByTestId('DonationHistoriesBoxEmpty')).not.toBeInTheDocument(); }); describe('populated periods', () => { - beforeEach(() => { - reportsDonationHistories = { + it('shows references', () => { + const reportsDonationHistories = { periods: [ { convertedTotal: 50, @@ -83,18 +78,13 @@ describe('DonationHistories', () => { ], averageIgnoreCurrent: 1000, }; - }); - it('shows references', () => { const { getByTestId } = render( - - - , + , ); expect( getByTestId('DonationHistoriesTypographyGoal').textContent, diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 843907d990..d2b450c152 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -58,7 +58,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface Props { +export interface DonationHistoriesProps { loading?: boolean; reportsDonationHistories?: { periods: { @@ -81,7 +81,7 @@ const DonationHistories = ({ pledged, currencyCode = 'USD', setTime, -}: Props): ReactElement => { +}: DonationHistoriesProps): ReactElement => { const { classes } = useStyles(); const { palette } = useTheme(); const { push } = useRouter(); @@ -294,7 +294,7 @@ const DonationHistories = ({ )} - {!loading ? ( + {loading ? ( ) : ( diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx index 6aacd38b39..3c7c06fed6 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorGraph/HealthIndicatorGraph.test.tsx @@ -1,5 +1,7 @@ +import { ThemeProvider } from '@mui/material/styles'; import { render, waitForElementToBeRemoved } from '@testing-library/react'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import theme from 'src/theme'; import { HealthIndicatorGraph } from './HealthIndicatorGraph'; import { HealthIndicatorGraphQuery } from './HealthIndicatorGraph.generated'; @@ -8,15 +10,17 @@ const accountListId = 'account-list-1'; describe('HealthIndicatorGraph', () => { it('renders nothing when there is no data', async () => { const { getByTestId, queryByTestId } = render( - - mocks={{ - HealthIndicatorGraph: { - healthIndicatorData: [], - }, - }} - > - - , + + + mocks={{ + HealthIndicatorGraph: { + healthIndicatorData: [], + }, + }} + > + + + , ); const skeleton = getByTestId('BarChartSkeleton'); @@ -28,9 +32,11 @@ describe('HealthIndicatorGraph', () => { it('renders a skeleton while data is loading', async () => { const { getByTestId } = render( - - - , + + + + + , ); const skeleton = getByTestId('BarChartSkeleton'); @@ -40,15 +46,17 @@ describe('HealthIndicatorGraph', () => { it('renders the average', async () => { const { findByTestId } = render( - - mocks={{ - HealthIndicatorGraph: { - healthIndicatorData: [{ overallHi: 50 }], - }, - }} - > - - , + + + mocks={{ + HealthIndicatorGraph: { + healthIndicatorData: [{ overallHi: 50 }], + }, + }} + > + + + , ); expect(await findByTestId('HealthIndicatorGraphHeader')).toHaveTextContent( diff --git a/src/components/StyledProgress/StyledProgress.test.tsx b/src/components/StyledProgress/StyledProgress.test.tsx index 968ebd0b0c..1715715f55 100644 --- a/src/components/StyledProgress/StyledProgress.test.tsx +++ b/src/components/StyledProgress/StyledProgress.test.tsx @@ -7,8 +7,12 @@ describe('StyledProgress', () => { it('has correct defaults', () => { const { getByTestId, queryByTestId } = render(); expect(queryByTestId('styledProgressLoading')).toBeNull(); - expect(getByTestId('styledProgressPrimary')).toHaveStyle('width: 0%;'); - expect(getByTestId('styledProgressSecondary')).toHaveStyle('width: 0%;'); + expect(getByTestId('styledProgressPrimary')).toHaveStyle( + 'width: calc(0% - 4px);', + ); + expect(getByTestId('styledProgressSecondary')).toHaveStyle( + 'width: calc(0% - 4px);', + ); }); it('has correct overrides', () => { @@ -16,8 +20,12 @@ describe('StyledProgress', () => { , ); expect(queryByTestId('styledProgressLoading')).toBeNull(); - expect(getByTestId('styledProgressPrimary')).toHaveStyle('width: 50%;'); - expect(getByTestId('styledProgressSecondary')).toHaveStyle('width: 75%;'); + expect(getByTestId('styledProgressPrimary')).toHaveStyle( + 'width: calc(50% - 4px);', + ); + expect(getByTestId('styledProgressSecondary')).toHaveStyle( + 'width: calc(75% - 4px);', + ); }); it('allows loading', () => { From 17a9123dc9dda67bdbfe621685c03b92546aaed8 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 11 Feb 2025 14:48:58 -0600 Subject: [PATCH 037/101] Default to calculated goal on accounts list page --- pages/GetAccountLists.graphql | 5 + .../AccountLists/AccountLists.test.tsx | 163 ++++++++++++++---- src/components/AccountLists/AccountLists.tsx | 31 +++- 3 files changed, 155 insertions(+), 44 deletions(-) diff --git a/pages/GetAccountLists.graphql b/pages/GetAccountLists.graphql index 834e1a2288..cb7f60e240 100644 --- a/pages/GetAccountLists.graphql +++ b/pages/GetAccountLists.graphql @@ -11,6 +11,11 @@ query GetAccountLists { receivedPledges totalPledges currency + healthIndicatorData { + id + machineCalculatedGoal + machineCalculatedGoalCurrency + } } } } diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index 1ab7e5ea2d..3c540f6140 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -1,53 +1,140 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; +import { render, within } from '@testing-library/react'; +import { gqlMock } from '__tests__/util/graphqlMocking'; +import { + GetAccountListsDocument, + GetAccountListsQuery, +} from 'pages/GetAccountLists.generated'; import theme from '../../theme'; import AccountLists from '.'; describe('AccountLists', () => { it('has correct defaults', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { id: '1', name: 'My Personal Staff Account' }, + { id: '2', name: 'My Ministry Account' }, + { id: '3', name: "My Friend's Staff Account" }, + ], + }, + }, + }); + const { getByTestId } = render( - + , + ); + expect( + within(getByTestId('account-list-1')).getByRole('heading', { + name: 'My Personal Staff Account', + }), + ).toBeInTheDocument(); + expect( + within(getByTestId('account-list-2')).getByRole('heading', { + name: 'My Ministry Account', + }), + ).toBeInTheDocument(); + expect( + within(getByTestId('account-list-3')).getByRole('heading', { + name: "My Friend's Staff Account", + }), + ).toBeInTheDocument(); + }); + + it('ignores machine calculated goal when monthly goal is set', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + name: 'Account', + monthlyGoal: 1000, + receivedPledges: 600, + totalPledges: 800, + healthIndicatorData: { + machineCalculatedGoal: 2000, + machineCalculatedGoalCurrency: 'USD', + }, + currency: 'USD', }, - accountLists: { - nodes: [ - { - id: 'abc', - name: 'My Personal Staff Account', - monthlyGoal: 100, - receivedPledges: 10, - totalPledges: 20, - currency: 'USD', - }, - { - id: 'def', - name: 'My Ministry Account', - monthlyGoal: null, - receivedPledges: 10, - totalPledges: 20, - currency: 'USD', - }, - { - id: 'ghi', - name: "My Friend's Staff Account", - monthlyGoal: 100, - receivedPledges: 0, - totalPledges: 0, - currency: 'USD', - }, - ], + ], + }, + }, + }); + + const { getByRole } = render( + + + , + ); + expect(getByRole('link')).toHaveTextContent( + 'AccountGoal$1,000Gifts Started60%Committed80%', + ); + }); + + it('uses machine calculated goal when goal is not set', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + name: 'Account', + monthlyGoal: null, + receivedPledges: 600, + totalPledges: 800, + healthIndicatorData: { + machineCalculatedGoal: 2000, + machineCalculatedGoalCurrency: 'USD', + }, + currency: 'USD', }, - }} - /> + ], + }, + }, + }); + + const { getByRole } = render( + + , ); - expect(getByTestId('abc')).toHaveTextContent('My Personal Staff Account'); - expect(getByTestId('def')).toHaveTextContent('My Ministry Account'); - expect(getByTestId('ghi')).toHaveTextContent("My Friend's Staff Account"); + expect(getByRole('link')).toHaveTextContent( + 'AccountGoal$2,000Gifts Started30%Committed40%', + ); + }); + + it("hides percentages when machine calculated goal currency differs from user's currency", () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + name: 'Account', + monthlyGoal: null, + receivedPledges: 600, + totalPledges: 800, + healthIndicatorData: { + machineCalculatedGoal: 2000, + machineCalculatedGoalCurrency: 'EUR', + }, + currency: 'USD', + }, + ], + }, + }, + }); + + const { getByRole } = render( + + + , + ); + expect(getByRole('link')).toHaveTextContent( + 'AccountGoal€2,000Gifts Started-Committed-', + ); }); }); diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index 1c90d86c38..f947db8118 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -76,18 +76,37 @@ const AccountLists = ({ data }: Props): ReactElement => { ({ id, name, - monthlyGoal, + monthlyGoal: preferencesGoal, receivedPledges, totalPledges, - currency, + currency: preferencesCurrency, + healthIndicatorData, }) => { + const monthlyGoal = + preferencesGoal ?? healthIndicatorData?.machineCalculatedGoal; + const currency = + typeof preferencesGoal === 'number' + ? preferencesCurrency + : healthIndicatorData?.machineCalculatedGoalCurrency; + + // If the currency comes from the machine calculated goal and is different from the + // user's currency preference, we can't calculate the received or total percentages + // because the numbers are in different currencies const receivedPercentage = - receivedPledges / (monthlyGoal ?? NaN); - const totalPercentage = totalPledges / (monthlyGoal ?? NaN); + currency === preferencesCurrency && monthlyGoal + ? receivedPledges / monthlyGoal + : NaN; + const totalPercentage = + currency === preferencesCurrency && monthlyGoal + ? totalPledges / monthlyGoal + : NaN; return ( - + { - + {name} From 1b800f1f0413558e718ee39b6f2665254b4697a4 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 11 Feb 2025 16:41:38 -0600 Subject: [PATCH 038/101] Show calculated goal in preferences goal accordion --- .../MonthlyGoalAccordion.test.tsx | 16 ++++++++++++++++ .../MonthlyGoalAccordion.tsx | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index 89d33fc15a..8361208d28 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -80,6 +80,7 @@ describe('MonthlyGoalAccordion', () => { afterEach(() => { mutationSpy.mockClear(); }); + it('should render accordion closed', () => { const { getByText, queryByRole } = render( , @@ -88,6 +89,21 @@ describe('MonthlyGoalAccordion', () => { expect(getByText(label)).toBeInTheDocument(); expect(queryByRole('textbox')).not.toBeInTheDocument(); }); + + it('should render accordion closed with calculated goal', async () => { + const { findByText, queryByRole } = render( + , + ); + + expect(await findByText('€1,000')).toBeInTheDocument(); + expect(queryByRole('textbox')).not.toBeInTheDocument(); + }); + it('should render accordion open and textfield should have a value', () => { const { getByRole } = render( , diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index d0323e1b06..60b786dcbb 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -135,7 +135,7 @@ export const MonthlyGoalAccordion: React.FC = ({ onAccordionChange={handleAccordionChange} expandedPanel={expandedPanel} label={label} - value={formattedMonthlyGoal} + value={formattedMonthlyGoal || formattedCalculatedGoal} fullWidth disabled={disabled} > From 8489b328b38a5aae5cc7d12e9117aea5800d7d7a Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 13:49:06 -0600 Subject: [PATCH 039/101] Make explanations consistently lowercase --- .../HealthIndicatorFormula/HealthIndicatorFormula.tsx | 4 ++-- .../HealthIndicatorWidget/HealthIndicatorWidget.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index ca5e6a70a9..1f3c108ef0 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -52,13 +52,13 @@ export const HealthIndicatorFormula: React.FC = ({ diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx index a6d86c51f1..46254ee52e 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget.tsx @@ -87,7 +87,7 @@ export const HealthIndicatorWidget: React.FC = ({ loading={loading} stat={data?.ownershipHi} statName={t('Ownership')} - toolTip={t('% of Self-raised Funds over Total Funds')} + toolTip={t('% of self-raised funds over total funds')} /> = ({ loading={loading} stat={data?.successHi} statName={t('Success')} - toolTip={t('% of Self-raised Funds over Support Goal')} + toolTip={t('% of self-raised funds over support goal')} /> Date: Wed, 12 Feb 2025 14:38:59 -0600 Subject: [PATCH 040/101] Use additional boolean in calculations --- src/components/AccountLists/AccountLists.tsx | 29 ++++++++++---------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index f947db8118..319672f67c 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -82,24 +82,25 @@ const AccountLists = ({ data }: Props): ReactElement => { currency: preferencesCurrency, healthIndicatorData, }) => { - const monthlyGoal = - preferencesGoal ?? healthIndicatorData?.machineCalculatedGoal; - const currency = - typeof preferencesGoal === 'number' - ? preferencesCurrency - : healthIndicatorData?.machineCalculatedGoalCurrency; + const hasPreferencesGoal = typeof preferencesGoal === 'number'; + const monthlyGoal = hasPreferencesGoal + ? preferencesGoal + : healthIndicatorData?.machineCalculatedGoal; + const currency = hasPreferencesGoal + ? preferencesCurrency + : healthIndicatorData?.machineCalculatedGoalCurrency; // If the currency comes from the machine calculated goal and is different from the // user's currency preference, we can't calculate the received or total percentages // because the numbers are in different currencies - const receivedPercentage = - currency === preferencesCurrency && monthlyGoal - ? receivedPledges / monthlyGoal - : NaN; - const totalPercentage = - currency === preferencesCurrency && monthlyGoal - ? totalPledges / monthlyGoal - : NaN; + const hasValidGoal = + currency === preferencesCurrency && !!monthlyGoal; + const receivedPercentage = hasValidGoal + ? receivedPledges / monthlyGoal + : NaN; + const totalPercentage = hasValidGoal + ? totalPledges / monthlyGoal + : NaN; return ( From 9f740635b57411770a0c2b046298226f3ad09f64 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 14:42:44 -0600 Subject: [PATCH 041/101] Add estimated indicator --- .../MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx | 2 +- .../accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx index 8361208d28..897f6a511a 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.test.tsx @@ -100,7 +100,7 @@ describe('MonthlyGoalAccordion', () => { />, ); - expect(await findByText('€1,000')).toBeInTheDocument(); + expect(await findByText('€1,000 (estimated)')).toBeInTheDocument(); expect(queryByRole('textbox')).not.toBeInTheDocument(); }); diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index 60b786dcbb..c243ea2658 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -135,7 +135,9 @@ export const MonthlyGoalAccordion: React.FC = ({ onAccordionChange={handleAccordionChange} expandedPanel={expandedPanel} label={label} - value={formattedMonthlyGoal || formattedCalculatedGoal} + value={ + formattedMonthlyGoal || `${formattedCalculatedGoal} (${t('estimated')})` + } fullWidth disabled={disabled} > From c2df07981b5ecf9b66c30c38456f260d1d5f57f9 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 15:16:40 -0600 Subject: [PATCH 042/101] Add id field to query --- .../ContactDonationsTab/DonationsGraph/DonationsGraph.graphql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.graphql b/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.graphql index af94cdc8e7..70e21e0a5e 100644 --- a/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.graphql +++ b/src/components/Contacts/ContactDetails/ContactDonationsTab/DonationsGraph/DonationsGraph.graphql @@ -1,5 +1,6 @@ query GetDonationsGraph($accountListId: ID!, $donorAccountIds: [ID!]) { accountList(id: $accountListId) { + id currency } reportsDonationHistories( From 14e6cc79f98448ac201539569fa7c1f07b58537f Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 13 Feb 2025 09:58:16 -0600 Subject: [PATCH 043/101] Add preferences link when using calculated goal --- .../Dashboard/MonthlyGoal/MonthlyGoal.test.tsx | 14 ++++++++++++-- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index 3bf6834ec4..b48a375d9c 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -263,7 +263,7 @@ describe('MonthlyGoal', () => { describe('Monthly Goal', () => { it('should set the monthly goal to the user-entered goal if it exists', async () => { - const { findByRole } = render( + const { findByRole, queryByRole } = render( { name: /\$999.50/i, }), ).toBeInTheDocument(); + + expect( + queryByRole('link', { name: 'Set Monthly Goal' }), + ).not.toBeInTheDocument(); }); it('should set the monthly goal to the machine calculated goal', async () => { - const { findByRole, queryByRole } = render( + const { findByRole, getByRole, queryByRole } = render( { name: /\$999.50/i, }), ).not.toBeInTheDocument(); + + expect(getByRole('link', { name: 'Set Monthly Goal' })).toHaveAttribute( + 'href', + '/accountLists/account-list-1/settings/preferences?selectedTab=MonthlyGoal', + ); }); + it('should set the monthly goal to 0 if both the machineCalculatedGoal and monthly goal are unset', async () => { const { findByRole, queryByRole } = render( + {!staffEnteredGoal && ( + + )} From 5ac296f61f0e44502c45e33a93e6755ba305a8aa Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 14:27:49 -0600 Subject: [PATCH 044/101] Add health indicator explanations --- .../ConsistencyExplanation.tsx | 45 +++++++++++ .../DepthExplanation.tsx | 34 ++++++++ .../HealthIndicatorFormula.tsx | 77 +++++++++++++------ .../OwnershipExplanation.tsx | 50 ++++++++++++ .../SuccessExplanation.tsx | 70 +++++++++++++++++ 5 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/ConsistencyExplanation.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/DepthExplanation.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/OwnershipExplanation.tsx create mode 100644 src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/SuccessExplanation.tsx diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/ConsistencyExplanation.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/ConsistencyExplanation.tsx new file mode 100644 index 0000000000..5f313f1335 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/ConsistencyExplanation.tsx @@ -0,0 +1,45 @@ +import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export const ConsistencyExplanation: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + + {t( + 'This health indicator helps you understand the stability of your staff balance over a period of 12 months. It relates the months when your staff account balance was positive to the months when it was negative. If the consistency health score is 75, it indicates that you have experienced a negative balance for 3 months within the last 12-month period. This is not a healthy practice!', + )} + + {t('Note:')} + + {t( + 'Depending on your national financial policy, you may have a positive account balance but if that is lower than the national approved level for a healthy balance it is considered unhealthy.', + )} + + {t('Tips to Improve:')} +
    + + {t( + 'Review the accuracy of your support goal, as it appears you may be spending more than planned.', + )} + + + {t( + 'Review your monthly contributions and contact your partners if they missed a donation.', + )} + + + {t( + 'Examine your transactions to check for any charges related to future expense periods that could temporarily put your balance in deficit (for example, paying rent in advance for 2 months or similar).', + )} + + + {t( + 'Consider scheduling time for MPD in the coming months to prevent inadequate funds and a negative balance.', + )} + +
+ + ); +}; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/DepthExplanation.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/DepthExplanation.tsx new file mode 100644 index 0000000000..22645606a0 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/DepthExplanation.tsx @@ -0,0 +1,34 @@ +import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export const DepthExplanation: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + + {t( + "The Depth MPD Health Indicator is designed to evaluate the number of local partners involved in your ministry. Regardless of where you serve in the world, there are local believers around you, or by God's grace, there will be local believers as a result of your ministry efforts.", + )} + + + {t( + 'You will receive 100 points if more than 70% of your partners are locally based, or if there has been an increase of more than 5% in local partners over the last 14 months. You will earn 50 points if there is an increase of up to 5% in the number of local partners during that same timeframe.', + )} + + {t('Tips to Improve:')} +
    + + {t( + 'Throughout the year, add potential local contacts to your MPD database and reach out to them to join you.', + )} + + + {t( + 'Be intentional and consistent in sharing what God is doing in your ministry, as His work can inspire people to get involved.', + )} + +
+ + ); +}; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index 1f3c108ef0..78c50f7bed 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -1,15 +1,37 @@ import React, { Dispatch, SetStateAction, useEffect } from 'react'; -import { Box, Card, Skeleton, Typography } from '@mui/material'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Card, + Skeleton, + Typography, +} from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; +import { ConsistencyExplanation } from './ConsistencyExplanation'; +import { DepthExplanation } from './DepthExplanation'; import { useHealthIndicatorFormulaQuery } from './HealthIndicatorFormula.generated'; +import { OwnershipExplanation } from './OwnershipExplanation'; +import { SuccessExplanation } from './SuccessExplanation'; -const StyledBox = styled(Box)(({ theme }) => ({ +const StyledSummary = styled(AccordionSummary)({ + '.MuiAccordionSummary-content': { + display: 'flex', + gap: '0.5ch', + alignItems: 'center', + }, +}); + +const StyledDetails = styled(AccordionDetails)(({ theme }) => ({ display: 'flex', - gap: 2, - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: theme.spacing(2), + flexDirection: 'column', + gap: theme.spacing(2), + + ul: { + marginLeft: theme.spacing(2), + }, })); interface HealthIndicatorFormulaProps { @@ -52,25 +74,29 @@ export const HealthIndicatorFormula: React.FC = ({ } value={latestMpdHealthData?.ownershipHi ?? 0} isLoading={loading} /> } value={latestMpdHealthData?.successHi ?? 0} isLoading={loading} /> } isLoading={loading} /> } value={latestMpdHealthData?.depthHi ?? 0} isLoading={loading} /> @@ -81,28 +107,31 @@ export const HealthIndicatorFormula: React.FC = ({ interface FormulaItemProps { name: string; - explanation: string; + description: string; + explanation?: React.ReactNode; value: number; isLoading: boolean; } const FormulaItem: React.FC = ({ name, + description, explanation, value, isLoading, }) => ( - - {isLoading ? ( - - ) : ( - - {value} - - )} - - {name} = - {explanation} - - + + + {isLoading ? ( + + ) : ( + + {value} + + )} + {name} = + {description} + + {explanation} + ); diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/OwnershipExplanation.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/OwnershipExplanation.tsx new file mode 100644 index 0000000000..cf5070d010 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/OwnershipExplanation.tsx @@ -0,0 +1,50 @@ +import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export const OwnershipExplanation: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + + {t( + 'This indicator helps you understand the proportion of your support that comes from your own efforts versus what has been provided through funds given to you. Self-raised funds are those you receive based on your own initiative, rather than from national subsidies or matching funds, or financial help from the National (Global) ministry.', + )} + + + {t( + 'A higher score on this indicator signifies that you have ownership of your Ministry Partner Development (MPD) and suggests a healthy financial situation.', + )} + + + {t( + 'In most cases, this indicator will score 100 points; however, it may be lower in some situations. The recommendation is that it should not drop below 70. In some ministries, especially for new staff, initial funding is provided to help them during the first few years, but this financial subsidy decreases each year.', + )} + + {t('Tips to Improve:')} +
    + {t('For New Staff:')} +
      + + {t( + "Set your support goals without considering the subsidy. Don't limit yourself! During the first few years, you often have a larger contact base from friends, family, and your church community.", + )} + +
    + {t('For Senior Staff:')} +
      + + {t( + 'If your indicator is not already at 100, consider dedicating some intensive time to MPD.', + )} + +
    +
+ + {t( + "Remember, our mission's success is not only our responsibility; we are working together with a team of ministry partners. The Great Commission is something we accomplish collectively as the body of Christ.", + )} + + + ); +}; diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/SuccessExplanation.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/SuccessExplanation.tsx new file mode 100644 index 0000000000..833df43ab8 --- /dev/null +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/SuccessExplanation.tsx @@ -0,0 +1,70 @@ +import Link from 'next/link'; +import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export const SuccessExplanation: React.FC = () => { + const { t } = useTranslation(); + + return ( + <> + + {t( + 'Setting a financial support goal is crucial for every staff member to be effective in the field. Understanding and planning our financial needs helps ensure that everyone can succeed in their roles. Establishing a realistic support goal is key to effective ministry, as it ensures that our families are financially supported.', + )} + + + {t( + 'The success indicator allows us to assess our effectiveness in reaching our current support goal.', + )} + + + {t('Definition of 100% Staff Support:')} + + + {t( + 'The sum of Full Salary + Allowances + Reimbursements + Benefits + Administration Charge + Attrition.', + )} + + {t('Note:')} + + {t( + 'If you do not have a staff support goal set up in NetSuite, the automation calculation is as follows: All your salary expenses for the previous 12 months are multiplied by 1.6. This 1.6 multiplier is designed to ensure that you have enough resources to cover your salary and salary-related expenses, as well as some of your personal ministry expenses.', + )} + + + {t( + 'The desired Health score is always 100, meaning that you are fully funded!', + )} + + + {t( + 'If your MPD Success Health score is under 70, consider dedicating some intensive time to MPD.', + )} + + {t('Tips to Improve:')} +
    + + {t( + 'Follow up with your partners if they have made a commitment that has not yet been received in your account.', + )} + + + {t('Take time at least once a month to review your donations.')} + + + {t( + 'Maintain monthly or bi-monthly connections with your partners. For assistance with effective and time-efficient ideas, please visit the Global Staff Web', + )}{' '} + ( + + {t('Global Partner Development Section')} + + ). + + + {t('Review your Staff Support Goal and update it as necessary.')} + +
+ + ); +}; From 403dd837a5e7dc1cbf510bff13bc21bf50511c6b Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 13 Feb 2025 13:58:22 -0600 Subject: [PATCH 045/101] Add chevron to accordions --- .../HealthIndicatorFormula/HealthIndicatorFormula.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index 78c50f7bed..eda793f286 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -1,4 +1,5 @@ import React, { Dispatch, SetStateAction, useEffect } from 'react'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Accordion, AccordionDetails, @@ -121,7 +122,7 @@ const FormulaItem: React.FC = ({ isLoading, }) => ( - + }> {isLoading ? ( ) : ( From 8848f24d43a65ea3d9d770fa5a0207c96bd5016b Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 13 Feb 2025 13:59:11 -0600 Subject: [PATCH 046/101] Use cached HI data when available --- .../HealthIndicatorFormula/HealthIndicatorFormula.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index eda793f286..51ad87e72c 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -78,28 +78,28 @@ export const HealthIndicatorFormula: React.FC = ({ description={t('% of self-raised funds over total funds')} explanation={} value={latestMpdHealthData?.ownershipHi ?? 0} - isLoading={loading} + isLoading={loading && !data} /> } value={latestMpdHealthData?.successHi ?? 0} - isLoading={loading} + isLoading={loading && !data} /> } - isLoading={loading} + isLoading={loading && !data} /> } value={latestMpdHealthData?.depthHi ?? 0} - isLoading={loading} + isLoading={loading && !data} />
From 08d8a2710d518cbcd57354f589514618ccf501b5 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 13 Feb 2025 14:00:52 -0600 Subject: [PATCH 047/101] Remove additional padding --- .../HealthIndicatorFormula.tsx | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index 51ad87e72c..b221ec6a8c 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -4,7 +4,6 @@ import { Accordion, AccordionDetails, AccordionSummary, - Box, Card, Skeleton, Typography, @@ -72,36 +71,34 @@ export const HealthIndicatorFormula: React.FC = ({ {t('MPD Health')} = [({t('Ownership')} x 3) + ({t('Success')} x 2) + ( {t('Consistency')} x 1) + ({t('Depth')} x 1)] / 7 - - } - value={latestMpdHealthData?.ownershipHi ?? 0} - isLoading={loading && !data} - /> - } - value={latestMpdHealthData?.successHi ?? 0} - isLoading={loading && !data} - /> - } - isLoading={loading && !data} - /> - } - value={latestMpdHealthData?.depthHi ?? 0} - isLoading={loading && !data} - /> - + } + value={latestMpdHealthData?.ownershipHi ?? 0} + isLoading={loading && !data} + /> + } + value={latestMpdHealthData?.successHi ?? 0} + isLoading={loading && !data} + /> + } + isLoading={loading && !data} + /> + } + value={latestMpdHealthData?.depthHi ?? 0} + isLoading={loading && !data} + /> ); }; @@ -121,7 +118,14 @@ const FormulaItem: React.FC = ({ value, isLoading, }) => ( - + ({ + '&:not(:last-child)': { + borderBottom: 0, + }, + border: `1px solid ${theme.palette.divider}`, + })} + > }> {isLoading ? ( From 45f730691424fed6545c8ad3579703a9485838ec Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 13 Feb 2025 14:32:42 -0600 Subject: [PATCH 048/101] Style accordions like preferences --- .../HealthIndicatorFormula.tsx | 13 +++------- .../Shared/Forms/Accordions/AccordionItem.tsx | 24 +++---------------- .../Forms/Accordions/GroupedAccordion.tsx | 20 ++++++++++++++++ 3 files changed, 26 insertions(+), 31 deletions(-) create mode 100644 src/components/Shared/Forms/Accordions/GroupedAccordion.tsx diff --git a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx index b221ec6a8c..0d6dbebb2f 100644 --- a/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx +++ b/src/components/Reports/HealthIndicatorReport/HealthIndicatorFormula/HealthIndicatorFormula.tsx @@ -1,7 +1,6 @@ import React, { Dispatch, SetStateAction, useEffect } from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { - Accordion, AccordionDetails, AccordionSummary, Card, @@ -10,6 +9,7 @@ import { } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; +import { GroupedAccordion } from 'src/components/Shared/Forms/Accordions/GroupedAccordion'; import { ConsistencyExplanation } from './ConsistencyExplanation'; import { DepthExplanation } from './DepthExplanation'; import { useHealthIndicatorFormulaQuery } from './HealthIndicatorFormula.generated'; @@ -118,14 +118,7 @@ const FormulaItem: React.FC = ({ value, isLoading, }) => ( - ({ - '&:not(:last-child)': { - borderBottom: 0, - }, - border: `1px solid ${theme.palette.divider}`, - })} - > + }> {isLoading ? ( @@ -138,5 +131,5 @@ const FormulaItem: React.FC = ({ {description} {explanation} - + ); diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.tsx index d5e13f9a08..a921a0fb22 100644 --- a/src/components/Shared/Forms/Accordions/AccordionItem.tsx +++ b/src/components/Shared/Forms/Accordions/AccordionItem.tsx @@ -1,40 +1,22 @@ import React, { useMemo } from 'react'; import { ExpandMore } from '@mui/icons-material'; import { - Accordion, AccordionDetails, AccordionSummary, Box, Typography, } from '@mui/material'; import { styled } from '@mui/material/styles'; +import { GroupedAccordion } from './GroupedAccordion'; -export const accordionShared = { - '&:before': { - content: 'none', - }, - '& .MuiAccordionSummary-root.Mui-expanded': { - minHeight: 'unset', - }, -}; - -const StyledAccordion = styled(Accordion)(({ theme }) => ({ - overflow: 'hidden', +const StyledAccordion = styled(GroupedAccordion)(({ theme }) => ({ border: `1px solid ${theme.palette.divider}`, - '&:not(:last-child)': { - borderBottom: 0, - }, - '&.MuiAccordion-rounded.Mui-disabled': { - color: theme.palette.cruGrayDark, + '&.Mui-disabled': { backgroundColor: 'white', }, - ...accordionShared, })); const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ - '&.Mui-expanded': { - backgroundColor: theme.palette.mpdxYellow.main, - }, '& .MuiAccordionSummary-content': { [theme.breakpoints.only('xs')]: { flexDirection: 'column', diff --git a/src/components/Shared/Forms/Accordions/GroupedAccordion.tsx b/src/components/Shared/Forms/Accordions/GroupedAccordion.tsx new file mode 100644 index 0000000000..d396f18c44 --- /dev/null +++ b/src/components/Shared/Forms/Accordions/GroupedAccordion.tsx @@ -0,0 +1,20 @@ +import { Accordion } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const GroupedAccordion = styled(Accordion)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + + // Hide built-in borders + '&:before': { + content: 'none', + }, + + // Collapse adjacent borders by removing the top border of accordions that come right after another accordion + '& + &': { + borderTop: 0, + }, + + '.MuiAccordionSummary-root.Mui-expanded': { + backgroundColor: theme.palette.mpdxYellow.main, + }, +})); From 93c93a61a0691578eb43a01e59855738716097aa Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 16:06:38 -0600 Subject: [PATCH 049/101] Augment DonationHistories goal with health indicator data --- pages/accountLists/GetDashboard.graphql | 8 ++- pages/accountLists/[accountListId].page.tsx | 5 ++ .../CoachingDetail/CoachingDetail.tsx | 9 ++++ .../CoachingDetail/LoadCoachingDetail.graphql | 13 ++++- src/components/Dashboard/Dashboard.tsx | 1 + .../DonationHistories/DonationHistories.tsx | 49 +++++++++++++++---- .../DonationsReport/DonationsReport.tsx | 5 ++ .../DonationsReport/GetDonationGraph.graphql | 11 ++++- 8 files changed, 88 insertions(+), 13 deletions(-) diff --git a/pages/accountLists/GetDashboard.graphql b/pages/accountLists/GetDashboard.graphql index 33f41c256a..0f22d30d20 100644 --- a/pages/accountLists/GetDashboard.graphql +++ b/pages/accountLists/GetDashboard.graphql @@ -1,5 +1,6 @@ -query GetDashboard($accountListId: ID!) { +query GetDashboard($accountListId: ID!, $periodBegin: ISO8601Date!) { user { + id firstName } accountList(id: $accountListId) { @@ -28,4 +29,9 @@ query GetDashboard($accountListId: ID!) { } } } + healthIndicatorData(accountListId: $accountListId, beginDate: $periodBegin) { + id + indicationPeriodBegin + staffEnteredGoal + } } diff --git a/pages/accountLists/[accountListId].page.tsx b/pages/accountLists/[accountListId].page.tsx index e629549b81..afed7a8325 100644 --- a/pages/accountLists/[accountListId].page.tsx +++ b/pages/accountLists/[accountListId].page.tsx @@ -1,6 +1,7 @@ import Head from 'next/head'; import React, { ReactElement, useEffect, useState } from 'react'; import { ApolloError } from '@apollo/client'; +import { DateTime } from 'luxon'; import { GetDefaultAccountDocument, GetDefaultAccountQuery, @@ -106,6 +107,10 @@ export const getServerSideProps = makeGetServerSideProps( query: GetDashboardDocument, variables: { accountListId: query.accountListId, + periodBegin: DateTime.now() + .startOf('month') + .minus({ years: 1 }) + .toISODate(), // TODO: implement these variables in query // endOfDay: DateTime.local().endOf('day').toISO(), // today: DateTime.local().endOf('day').toISODate(), diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index 4aa8a0d579..2992ba185a 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -11,6 +11,7 @@ import { 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'; @@ -105,9 +106,15 @@ export const CoachingDetail: React.FC = ({ ? ownData?.accountList : coachingData?.coachingAccountList; + const periodBegin = DateTime.now() + .startOf('month') + .minus({ years: 1 }) + .toISODate(); + const { data: ownDonationGraphData } = useGetDonationGraphQuery({ variables: { accountListId, + periodBegin, }, skip: accountListType !== AccountListTypeEnum.Own, }); @@ -115,6 +122,7 @@ export const CoachingDetail: React.FC = ({ const { data: coachingDonationGraphData } = useGetCoachingDonationGraphQuery({ variables: { coachingAccountListId: accountListId, + periodBegin, }, skip: accountListType !== AccountListTypeEnum.Coaching, }); @@ -211,6 +219,7 @@ export const CoachingDetail: React.FC = ({ reportsDonationHistories={ donationGraphData?.reportsDonationHistories } + healthIndicatorData={donationGraphData?.healthIndicatorData} currencyCode={accountListData?.currency} /> { goal={data.accountList.monthlyGoal ?? undefined} pledged={data.accountList.totalPledges} reportsDonationHistories={data.reportsDonationHistories} + healthIndicatorData={data.healthIndicatorData} currencyCode={data.accountList.currency} />
diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index d2b450c152..d1e9a3cef3 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -16,7 +16,9 @@ import { Bar, BarChart, CartesianGrid, + ComposedChart, Legend, + Line, ReferenceLine, ResponsiveContainer, Text, @@ -68,6 +70,10 @@ export interface DonationHistoriesProps { }[]; averageIgnoreCurrent: number; }; + healthIndicatorData?: Array<{ + indicationPeriodBegin: string; + staffEnteredGoal?: number | null | undefined; + }>; currencyCode?: string; goal?: number; pledged?: number; @@ -77,6 +83,7 @@ export interface DonationHistoriesProps { const DonationHistories = ({ loading, reportsDonationHistories, + healthIndicatorData, goal, pledged, currencyCode = 'USD', @@ -94,25 +101,38 @@ const DonationHistories = ({ palette.graphBlue2.main, palette.graphBlue1.main, ]; - const currencies: { dataKey: string; fill: string }[] = []; + const currencies: { name: string; fill: string }[] = []; + const currentMonth = DateTime.now().startOf('month').toISODate(); const periods = reportsDonationHistories?.periods?.map((period) => { + // Use the goal from preferences for the last period, i.e. the current month + // For all other months, use the snapshot of the goal preference from the health indicator data + const periodGoal = + period.startDate === currentMonth + ? goal + : healthIndicatorData?.find( + (item) => item.indicationPeriodBegin === period.startDate, + )?.staffEnteredGoal; + const data: { - [key: string]: string | number | DateTime; + currencies: Record; startDate: string; total: number; + goal: number | null; period: DateTime; } = { + currencies: {}, startDate: DateTime.fromISO(period.startDate) .toJSDate() .toLocaleDateString(locale, { month: 'short', year: '2-digit' }), total: period.convertedTotal, + goal: periodGoal ?? null, period: DateTime.fromISO(period.startDate), }; period.totals.forEach((total) => { - if (!currencies.find((currency) => total.currency === currency.dataKey)) { - currencies.push({ dataKey: total.currency, fill: fills.pop() ?? '' }); + if (!currencies.find((currency) => total.currency === currency.name)) { + currencies.push({ name: total.currency, fill: fills.pop() ?? '' }); } - data[total.currency] = total.convertedAmount; + data.currencies[total.currency] = total.convertedAmount; }); return data; }); @@ -232,7 +252,7 @@ const DonationHistories = ({ ) : ( - - {goal && ( + {!healthIndicatorData?.length ? ( + ) : ( + )} {currencies.map((currency) => ( ))} - + )} diff --git a/src/components/Reports/DonationsReport/DonationsReport.tsx b/src/components/Reports/DonationsReport/DonationsReport.tsx index 346bbbb240..9aaeed57b5 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.tsx @@ -43,6 +43,10 @@ export const DonationsReport: React.FC = ({ designationAccountIds: designationAccounts?.length ? designationAccounts : null, + periodBegin: DateTime.now() + .startOf('month') + .minus({ years: 1 }) + .toISODate(), }, }); @@ -71,6 +75,7 @@ export const DonationsReport: React.FC = ({ goal={data?.accountList.monthlyGoal ?? undefined} pledged={data?.accountList.totalPledges} reportsDonationHistories={data?.reportsDonationHistories} + healthIndicatorData={data?.healthIndicatorData} currencyCode={data?.accountList.currency} setTime={setTime} /> diff --git a/src/components/Reports/DonationsReport/GetDonationGraph.graphql b/src/components/Reports/DonationsReport/GetDonationGraph.graphql index 8fa253b124..5278ad3c61 100644 --- a/src/components/Reports/DonationsReport/GetDonationGraph.graphql +++ b/src/components/Reports/DonationsReport/GetDonationGraph.graphql @@ -1,4 +1,8 @@ -query GetDonationGraph($accountListId: ID!, $designationAccountIds: [ID!]) { +query GetDonationGraph( + $accountListId: ID! + $designationAccountIds: [ID!] + $periodBegin: ISO8601Date! +) { accountList(id: $accountListId) { id currency @@ -11,6 +15,11 @@ query GetDonationGraph($accountListId: ID!, $designationAccountIds: [ID!]) { ) { ...DonationGraphHistories } + healthIndicatorData(accountListId: $accountListId, beginDate: $periodBegin) { + id + indicationPeriodBegin + staffEnteredGoal + } } fragment DonationGraphHistories on DonationHistories { From a9b449a613551fda8f56f832a73735f708474fc3 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 12 Feb 2025 16:44:54 -0600 Subject: [PATCH 050/101] Extract graph calculations into hook --- .../CoachingDetail/CoachingDetail.tsx | 11 +- .../CoachingDetail/LoadCoachingDetail.graphql | 6 + src/components/Dashboard/Dashboard.test.tsx | 2 + src/components/Dashboard/Dashboard.tsx | 8 +- .../DonationHistories.test.tsx | 94 +++++++------ .../DonationHistories/DonationHistories.tsx | 130 +++++++----------- .../Dashboard/DonationHistories/graphData.ts | 82 +++++++++++ .../DonationsReport/DonationsReport.tsx | 10 +- 8 files changed, 201 insertions(+), 142 deletions(-) create mode 100644 src/components/Dashboard/DonationHistories/graphData.ts diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index 2992ba185a..c4c71c7d1b 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -212,16 +212,7 @@ export const CoachingDetail: React.FC = ({ - + { const data: GetDashboardQuery = { user: { + id: 'user-1', firstName: 'Roger', }, accountList: { @@ -116,6 +117,7 @@ const data: GetDashboardQuery = { ], averageIgnoreCurrent: 750, }, + healthIndicatorData: [], }; describe('Dashboard', () => { diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index ec8c695c33..190b938443 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -58,13 +58,7 @@ const Dashboard = ({ data, accountListId }: Props): ReactElement => { /> - + diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index 6f613c42f5..e5a2722325 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -3,7 +3,10 @@ import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import TestRouter from '__tests__/util/TestRouter'; import theme from 'src/theme'; -import { DonationHistoriesProps } from './DonationHistories'; +import { + DonationHistoriesData, + DonationHistoriesProps, +} from './DonationHistories'; import DonationHistories from '.'; const setTime = jest.fn(); @@ -26,66 +29,79 @@ const TestComponent: React.FC = (props) => ( describe('DonationHistories', () => { it('default', () => { - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = render( + , + ); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('empty periods', () => { - const reportsDonationHistories = { - periods: [ - { - convertedTotal: 0, - startDate: '1-1-2019', - totals: [{ currency: 'USD', convertedAmount: 0 }], - }, - { - convertedTotal: 0, - startDate: '1-2-2019', - totals: [{ currency: 'NZD', convertedAmount: 0 }], - }, - ], - averageIgnoreCurrent: 0, + const data: DonationHistoriesData = { + accountList: { + currency: 'USD', + totalPledges: 1000, + }, + reportsDonationHistories: { + periods: [ + { + convertedTotal: 0, + startDate: '1-1-2019', + totals: [{ currency: 'USD', convertedAmount: 0 }], + }, + { + convertedTotal: 0, + startDate: '1-2-2019', + totals: [{ currency: 'NZD', convertedAmount: 0 }], + }, + ], + averageIgnoreCurrent: 0, + }, + healthIndicatorData: [], }; const { getByTestId, queryByTestId } = render( - , + , ); expect(getByTestId('DonationHistoriesBoxEmpty')).toBeInTheDocument(); expect(queryByTestId('BarChartSkeleton')).not.toBeInTheDocument(); }); it('loading', () => { - const { getAllByTestId, queryByTestId } = render(); + const { getAllByTestId, queryByTestId } = render( + , + ); expect(getAllByTestId('BarChartSkeleton')).toHaveLength(2); expect(queryByTestId('DonationHistoriesBoxEmpty')).not.toBeInTheDocument(); }); describe('populated periods', () => { it('shows references', () => { - const reportsDonationHistories = { - periods: [ - { - convertedTotal: 50, - startDate: '1-1-2019', - totals: [{ currency: 'USD', convertedAmount: 50 }], - }, - { - convertedTotal: 60, - startDate: '1-2-2019', - totals: [{ currency: 'NZD', convertedAmount: 60 }], - }, - ], - averageIgnoreCurrent: 1000, + const data: DonationHistoriesData = { + accountList: { + currency: 'USD', + monthlyGoal: 100, + totalPledges: 2500, + }, + reportsDonationHistories: { + periods: [ + { + convertedTotal: 50, + startDate: '1-1-2019', + totals: [{ currency: 'USD', convertedAmount: 50 }], + }, + { + convertedTotal: 60, + startDate: '1-2-2019', + totals: [{ currency: 'NZD', convertedAmount: 60 }], + }, + ], + averageIgnoreCurrent: 1000, + }, + healthIndicatorData: [], }; - const { getByTestId } = render( - , - ); + const { getByTestId } = render(); expect( getByTestId('DonationHistoriesTypographyGoal').textContent, ).toEqual('Goal $100'); diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index d1e9a3cef3..33ef9d0bda 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -30,12 +30,14 @@ import { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalCh import { makeStyles } from 'tss-react/mui'; import { BarChartSkeleton } from 'src/components/common/BarChartSkeleton/BarChartSkeleton'; import { LegendReferenceLine } from 'src/components/common/LegendReferenceLine/LegendReferenceLine'; +import * as Types from 'src/graphql/types.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useLocale } from 'src/hooks/useLocale'; import illustration15 from '../../../images/drawkit/grape/drawkit-grape-pack-illustration-15.svg'; import { currencyFormat } from '../../../lib/intlFormat'; import AnimatedBox from '../../AnimatedBox'; import AnimatedCard from '../../AnimatedCard'; +import { calculateGraphData } from './graphData'; const useStyles = makeStyles()((theme: Theme) => ({ cardHeader: { @@ -60,33 +62,38 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); +export interface DonationHistoriesData { + accountList: Pick< + Types.AccountList, + 'currency' | 'monthlyGoal' | 'totalPledges' + >; + reportsDonationHistories: Pick< + Types.DonationHistories, + 'averageIgnoreCurrent' + > & { + periods: Array< + Pick & { + totals: Array>; + } + >; + }; + healthIndicatorData: Array< + Pick< + Types.HealthIndicatorData, + 'indicationPeriodBegin' | 'staffEnteredGoal' + > + >; +} + export interface DonationHistoriesProps { loading?: boolean; - reportsDonationHistories?: { - periods: { - convertedTotal: number; - startDate: string; - totals: { currency: string; convertedAmount: number }[]; - }[]; - averageIgnoreCurrent: number; - }; - healthIndicatorData?: Array<{ - indicationPeriodBegin: string; - staffEnteredGoal?: number | null | undefined; - }>; - currencyCode?: string; - goal?: number; - pledged?: number; + data: DonationHistoriesData | undefined; setTime?: (time: DateTime) => void; } const DonationHistories = ({ loading, - reportsDonationHistories, - healthIndicatorData, - goal, - pledged, - currencyCode = 'USD', + data, setTime, }: DonationHistoriesProps): ReactElement => { const { classes } = useStyles(); @@ -96,56 +103,25 @@ const DonationHistories = ({ const locale = useLocale(); const accountListId = useAccountListId(); const fills = [ - palette.cruYellow.main, - palette.graphBlue3.main, - palette.graphBlue2.main, palette.graphBlue1.main, + palette.graphBlue2.main, + palette.graphBlue3.main, + palette.cruYellow.main, ]; - const currencies: { name: string; fill: string }[] = []; - const currentMonth = DateTime.now().startOf('month').toISODate(); - const periods = reportsDonationHistories?.periods?.map((period) => { - // Use the goal from preferences for the last period, i.e. the current month - // For all other months, use the snapshot of the goal preference from the health indicator data - const periodGoal = - period.startDate === currentMonth - ? goal - : healthIndicatorData?.find( - (item) => item.indicationPeriodBegin === period.startDate, - )?.staffEnteredGoal; - const data: { - currencies: Record; - startDate: string; - total: number; - goal: number | null; - period: DateTime; - } = { - currencies: {}, - startDate: DateTime.fromISO(period.startDate) - .toJSDate() - .toLocaleDateString(locale, { month: 'short', year: '2-digit' }), - total: period.convertedTotal, - goal: periodGoal ?? null, - period: DateTime.fromISO(period.startDate), - }; - period.totals.forEach((total) => { - if (!currencies.find((currency) => total.currency === currency.name)) { - currencies.push({ name: total.currency, fill: fills.pop() ?? '' }); - } - data.currencies[total.currency] = total.convertedAmount; - }); - return data; - }); - const empty = - !loading && - (periods === undefined || - periods.reduce((result, { total }) => result + total, 0) === 0); - const domainMax = Math.max( - ...(periods?.map((period) => period.total) || []), - goal ?? 0, - pledged ?? 0, - reportsDonationHistories?.averageIgnoreCurrent ?? 0, - ); + const { + monthlyGoal: goal, + totalPledges: pledged, + currency, + } = data?.accountList ?? {}; + + const { + periods, + currencies, + empty: periodsEmpty, + domainMax, + } = calculateGraphData({ locale, data: data, currencyColors: fills }); + const empty = !loading && periodsEmpty; const handleClick: CategoricalChartFunc = (period) => { if (!period?.activePayload) { @@ -183,7 +159,7 @@ const DonationHistories = ({ @@ -194,7 +170,7 @@ const DonationHistories = ({ ) : ( currencyFormat( - reportsDonationHistories.averageIgnoreCurrent, - currencyCode, + data.reportsDonationHistories.averageIgnoreCurrent, + currency, locale, ) ) @@ -220,7 +196,7 @@ const DonationHistories = ({ > @@ -262,9 +238,9 @@ const DonationHistories = ({ > - {!healthIndicatorData?.length ? ( + {!data?.healthIndicatorData?.length ? ( @@ -278,7 +254,7 @@ const DonationHistories = ({ /> )} @@ -301,8 +277,8 @@ const DonationHistories = ({ offset={0} angle={-90} > - {t('Amount ({{ currencyCode }})', { - currencyCode, + {t('Amount ({{ currency }})', { + currency, })} } diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts new file mode 100644 index 0000000000..d54e6d0418 --- /dev/null +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -0,0 +1,82 @@ +import { DateTime } from 'luxon'; +import { DonationHistoriesData } from './DonationHistories'; + +export interface CalculateGraphDataOptions { + locale: string; + data: DonationHistoriesData | undefined; + currencyColors: string[]; +} + +export interface Period { + currencies: Record; + startDate: string; + total: number; + goal: number | null; + period: DateTime; +} + +export interface CurrencyBar { + name: string; + fill: string; +} + +export interface CalculateGraphDataResult { + periods: Period[] | undefined; + currencies: CurrencyBar[]; + empty: boolean; + domainMax: number; +} + +export const calculateGraphData = ({ + locale, + data, + currencyColors, +}: CalculateGraphDataOptions): CalculateGraphDataResult => { + const { monthlyGoal: goal, totalPledges: pledged } = data?.accountList ?? {}; + const { healthIndicatorData, reportsDonationHistories } = data ?? {}; + const currentMonth = DateTime.now().startOf('month').toISODate(); + + const currencies: CurrencyBar[] = []; + const periods = reportsDonationHistories?.periods?.map((period) => { + // Use the goal from preferences for the last period, i.e. the current month + // For all other months, use the snapshot of the goal preference from the health indicator data + const periodGoal = + period.startDate === currentMonth + ? goal + : healthIndicatorData?.find( + (item) => item.indicationPeriodBegin === period.startDate, + )?.staffEnteredGoal; + + const periodData: Period = { + currencies: {}, + startDate: DateTime.fromISO(period.startDate) + .toJSDate() + .toLocaleDateString(locale, { month: 'short', year: '2-digit' }), + total: period.convertedTotal, + goal: periodGoal ?? null, + period: DateTime.fromISO(period.startDate), + }; + period.totals.forEach((total) => { + if (!currencies.find((currency) => total.currency === currency.name)) { + currencies.push({ + name: total.currency, + fill: currencyColors[currencies.length % currencyColors.length], + }); + } + periodData.currencies[total.currency] = total.convertedAmount; + }); + return periodData; + }); + + const empty = + (periods ?? []).reduce((result, { total }) => result + total, 0) === 0; + + const domainMax = Math.max( + ...(periods ?? [])?.map((period) => period.total), + goal ?? 0, + pledged ?? 0, + reportsDonationHistories?.averageIgnoreCurrent ?? 0, + ); + + return { periods, currencies, empty, domainMax }; +}; diff --git a/src/components/Reports/DonationsReport/DonationsReport.tsx b/src/components/Reports/DonationsReport/DonationsReport.tsx index 9aaeed57b5..d4b16e9807 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.tsx @@ -70,15 +70,7 @@ export const DonationsReport: React.FC = ({ headerType={HeaderTypeEnum.Report} /> - + Date: Wed, 12 Feb 2025 17:16:36 -0600 Subject: [PATCH 051/101] Add tests --- .../DonationHistories/graphData.test.ts | 279 ++++++++++++++++++ .../Dashboard/DonationHistories/graphData.ts | 7 +- 2 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/components/Dashboard/DonationHistories/graphData.test.ts diff --git a/src/components/Dashboard/DonationHistories/graphData.test.ts b/src/components/Dashboard/DonationHistories/graphData.test.ts new file mode 100644 index 0000000000..10cbbad53a --- /dev/null +++ b/src/components/Dashboard/DonationHistories/graphData.test.ts @@ -0,0 +1,279 @@ +import { Settings } from 'luxon'; +import { gqlMock } from '__tests__/util/graphqlMocking'; +import { + GetDonationGraphDocument, + GetDonationGraphQuery, + GetDonationGraphQueryVariables, +} from 'src/components/Reports/DonationsReport/GetDonationGraph.generated'; +import { calculateGraphData } from './graphData'; + +const variables: GetDonationGraphQueryVariables = { + accountListId: 'account-list-1', + periodBegin: '2020-12-01', + designationAccountIds: [], +}; + +const graphOptions = { + locale: 'en-US', + currencyColors: ['red', 'green', 'blue'], +}; + +describe('calculateGraphData', () => { + beforeEach(() => { + Settings.now = () => new Date(2020, 11, 1).valueOf(); + }); + + describe('periods', () => { + it('is the period data for the graph', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + monthlyGoal: 100, + }, + reportsDonationHistories: { + periods: [ + { + convertedTotal: 30, + startDate: '2020-11-01', + totals: [ + { currency: 'USD', convertedAmount: 10 }, + { currency: 'EUR', convertedAmount: 20 }, + ], + }, + { + convertedTotal: 40, + startDate: '2020-12-01', + totals: [ + { currency: 'EUR', convertedAmount: 15 }, + { currency: 'CAD', convertedAmount: 15 }, + ], + }, + ], + }, + healthIndicatorData: [ + { indicationPeriodBegin: '2020-11-01', staffEnteredGoal: 60 }, + { indicationPeriodBegin: '2020-12-01', staffEnteredGoal: 200 }, + ], + }, + variables, + }); + + expect( + calculateGraphData({ ...graphOptions, data }).periods, + ).toMatchObject([ + { + currencies: { USD: 10, EUR: 20 }, + goal: 60, + startDate: 'Nov 20', + total: 30, + }, + { + currencies: { EUR: 15, CAD: 15 }, + goal: 100, + startDate: 'Dec 20', + total: 40, + }, + ]); + }); + }); + + describe('empty', () => { + it('is true when data is undefined', () => { + expect( + calculateGraphData({ ...graphOptions, data: undefined }).empty, + ).toBe(true); + }); + + it('is true when periods are empty', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + reportsDonationHistories: { + periods: [], + }, + }, + variables, + }); + expect(calculateGraphData({ ...graphOptions, data }).empty).toBe(true); + }); + + it('is true when periods are all zeros', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + reportsDonationHistories: { + periods: [{ convertedTotal: 0 }, { convertedTotal: 0 }], + }, + }, + variables, + }); + expect(calculateGraphData({ ...graphOptions, data }).empty).toBe(true); + }); + + it('is false when a period has a non-zero total', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + reportsDonationHistories: { + periods: [{ convertedTotal: 0 }, { convertedTotal: 1 }], + }, + }, + variables, + }); + expect(calculateGraphData({ ...graphOptions, data }).empty).toBe(false); + }); + }); + + describe('domainMax', () => { + it('is zero when data is undefined', () => { + expect( + calculateGraphData({ ...graphOptions, data: undefined }).domainMax, + ).toBe(0); + }); + + it('is the period with the greatest total', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + monthlyGoal: 10, + totalPledges: 20, + }, + reportsDonationHistories: { + periods: [{ convertedTotal: 100 }, { convertedTotal: 40 }], + averageIgnoreCurrent: 50, + }, + healthIndicatorData: [ + { indicationPeriodBegin: '2020-11-01', staffEnteredGoal: 60 }, + { indicationPeriodBegin: '2020-12-01', staffEnteredGoal: 200 }, + ], + }, + variables, + }); + + expect(calculateGraphData({ ...graphOptions, data }).domainMax).toBe(100); + }); + + it('is the period with the greatest goal', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + monthlyGoal: 10, + totalPledges: 20, + }, + reportsDonationHistories: { + periods: [ + { convertedTotal: 30, startDate: '2020-11-01' }, + { convertedTotal: 40, startDate: '2020-12-01' }, + ], + averageIgnoreCurrent: 50, + }, + healthIndicatorData: [ + { indicationPeriodBegin: '2020-11-01', staffEnteredGoal: 100 }, + { indicationPeriodBegin: '2020-12-01', staffEnteredGoal: 200 }, + ], + }, + variables, + }); + + expect(calculateGraphData({ ...graphOptions, data }).domainMax).toBe(100); + }); + + it('is the monthly goal', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + monthlyGoal: 100, + totalPledges: 20, + }, + reportsDonationHistories: { + periods: [ + { convertedTotal: 30, startDate: '2020-11-01' }, + { convertedTotal: 40, startDate: '2020-12-01' }, + ], + averageIgnoreCurrent: 50, + }, + healthIndicatorData: [ + { indicationPeriodBegin: '2020-11-01', staffEnteredGoal: 60 }, + { indicationPeriodBegin: '2020-12-01', staffEnteredGoal: 200 }, + ], + }, + variables, + }); + + expect(calculateGraphData({ ...graphOptions, data }).domainMax).toBe(100); + }); + + it('is the total pledges', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + monthlyGoal: 10, + totalPledges: 100, + }, + reportsDonationHistories: { + periods: [ + { convertedTotal: 30, startDate: '2020-11-01' }, + { convertedTotal: 40, startDate: '2020-12-01' }, + ], + averageIgnoreCurrent: 50, + }, + healthIndicatorData: [ + { indicationPeriodBegin: '2020-11-01', staffEnteredGoal: 60 }, + { indicationPeriodBegin: '2020-12-01', staffEnteredGoal: 200 }, + ], + }, + variables, + }); + + expect(calculateGraphData({ ...graphOptions, data }).domainMax).toBe(100); + }); + }); + + describe('currencies', () => { + it('is the currencies used by all periods', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + reportsDonationHistories: { + periods: [ + { totals: [{ currency: 'USD' }, { currency: 'EUR' }] }, + { totals: [{ currency: 'EUR' }, { currency: 'CAD' }] }, + { totals: [{ currency: 'MXN' }, { currency: 'CAD' }] }, + ], + }, + }, + variables, + }); + + expect(calculateGraphData({ ...graphOptions, data }).currencies).toEqual([ + { fill: 'red', name: 'USD' }, + { fill: 'green', name: 'EUR' }, + { fill: 'blue', name: 'CAD' }, + { fill: 'red', name: 'MXN' }, + ]); + }); + }); +}); diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts index d54e6d0418..74d98015cd 100644 --- a/src/components/Dashboard/DonationHistories/graphData.ts +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -72,8 +72,11 @@ export const calculateGraphData = ({ (periods ?? []).reduce((result, { total }) => result + total, 0) === 0; const domainMax = Math.max( - ...(periods ?? [])?.map((period) => period.total), - goal ?? 0, + ...(periods ?? [])?.flatMap((period) => [ + period.total, + // Include the goal if it is present + ...(period.goal === null ? [] : [period.goal]), + ]), pledged ?? 0, reportsDonationHistories?.averageIgnoreCurrent ?? 0, ); From 0327c08ea2717fff52d3a547658bd213020b369c Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 14 Feb 2025 14:44:05 -0600 Subject: [PATCH 052/101] Show goal in legend even if it is not set --- src/components/Dashboard/Dashboard.test.tsx | 6 +++--- .../DonationHistories/DonationHistories.tsx | 20 ++++++++----------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/components/Dashboard/Dashboard.test.tsx b/src/components/Dashboard/Dashboard.test.tsx index d8de5f6900..2d7596fd05 100644 --- a/src/components/Dashboard/Dashboard.test.tsx +++ b/src/components/Dashboard/Dashboard.test.tsx @@ -199,9 +199,9 @@ describe('Dashboard', () => { '$400', ); expect(getByTestId('BalanceTypography').textContent).toEqual('$1,000'); - expect( - queryByTestId('DonationHistoriesTypographyGoal'), - ).not.toBeInTheDocument(); + expect(getByTestId('DonationHistoriesTypographyGoal')).toHaveTextContent( + 'Goal', + ); expect( getByTestId('DonationHistoriesTypographyAverage').textContent, ).toEqual('Average $750'); diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 33ef9d0bda..ecbdb41e2b 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -154,18 +154,14 @@ const DonationHistories = ({ className={classes.cardHeader} title={ - {goal ? ( - <> - - - - | - - ) : null} + + + + | Date: Fri, 14 Feb 2025 14:51:42 -0600 Subject: [PATCH 053/101] Fix clicking graph in coaching redirecting away --- .../CoachingDetail/CoachingDetail.tsx | 19 ++++++++++++++- src/components/Dashboard/Dashboard.tsx | 18 +++++++++++++- .../DonationHistories.test.tsx | 4 +--- .../DonationHistories/DonationHistories.tsx | 24 +++++-------------- .../DonationsReport/DonationsReport.tsx | 6 ++++- 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx index c4c71c7d1b..55ea8db1f3 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import MenuOpenIcon from '@mui/icons-material/MenuOpen'; import { @@ -86,6 +87,7 @@ export const CoachingDetail: React.FC = ({ accountListType, }) => { const { t } = useTranslation(); + const { push } = useRouter(); const { data: ownData, loading: ownLoading } = useLoadAccountListCoachingDetailQuery({ @@ -156,6 +158,17 @@ export const CoachingDetail: React.FC = ({ } }, [sidebarDrawer]); + const handlePeriodClick = (period: DateTime) => { + if (accountListType === AccountListTypeEnum.Own) { + push({ + pathname: `/accountLists/${accountListId}/reports/donations`, + query: { + month: period.toISODate(), + }, + }); + } + }; + const sidebar = ( = ({ - + { + const { push } = useRouter(); + + const handlePeriodClick = (period: DateTime) => { + push({ + pathname: `/accountLists/${accountListId}/reports/donations`, + query: { + month: period.toISODate(), + }, + }); + }; + return ( <> @@ -58,7 +71,10 @@ const Dashboard = ({ data, accountListId }: Props): ReactElement => { /> - + diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index e5a2722325..f9b8b0b626 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -9,8 +9,6 @@ import { } from './DonationHistories'; import DonationHistories from '.'; -const setTime = jest.fn(); - const push = jest.fn(); const router = { @@ -22,7 +20,7 @@ const router = { const TestComponent: React.FC = (props) => ( - , + , ); diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index ecbdb41e2b..58661a0d21 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -1,4 +1,3 @@ -import { useRouter } from 'next/router'; import React, { ReactElement } from 'react'; import { Box, @@ -31,7 +30,6 @@ import { makeStyles } from 'tss-react/mui'; import { BarChartSkeleton } from 'src/components/common/BarChartSkeleton/BarChartSkeleton'; import { LegendReferenceLine } from 'src/components/common/LegendReferenceLine/LegendReferenceLine'; import * as Types from 'src/graphql/types.generated'; -import { useAccountListId } from 'src/hooks/useAccountListId'; import { useLocale } from 'src/hooks/useLocale'; import illustration15 from '../../../images/drawkit/grape/drawkit-grape-pack-illustration-15.svg'; import { currencyFormat } from '../../../lib/intlFormat'; @@ -88,20 +86,18 @@ export interface DonationHistoriesData { export interface DonationHistoriesProps { loading?: boolean; data: DonationHistoriesData | undefined; - setTime?: (time: DateTime) => void; + onPeriodClick?: (period: DateTime) => void; } const DonationHistories = ({ loading, data, - setTime, + onPeriodClick, }: DonationHistoriesProps): ReactElement => { const { classes } = useStyles(); const { palette } = useTheme(); - const { push } = useRouter(); const { t } = useTranslation(); const locale = useLocale(); - const accountListId = useAccountListId(); const fills = [ palette.graphBlue1.main, palette.graphBlue2.main, @@ -123,21 +119,13 @@ const DonationHistories = ({ } = calculateGraphData({ locale, data: data, currencyColors: fills }); const empty = !loading && periodsEmpty; - const handleClick: CategoricalChartFunc = (period) => { - if (!period?.activePayload) { + const handleClick: CategoricalChartFunc = (state) => { + if (!state?.activePayload) { // The click was inside the chart but wasn't on a period return; } - if (setTime) { - setTime(period.activePayload[0].payload.period); - } else { - push({ - pathname: `/accountLists/${accountListId}/reports/donations`, - query: { - month: period.activePayload[0].payload.period.toISO(), - }, - }); - } + + onPeriodClick?.(state.activePayload[0].payload.period); }; return ( diff --git a/src/components/Reports/DonationsReport/DonationsReport.tsx b/src/components/Reports/DonationsReport/DonationsReport.tsx index d4b16e9807..9ff24577c0 100644 --- a/src/components/Reports/DonationsReport/DonationsReport.tsx +++ b/src/components/Reports/DonationsReport/DonationsReport.tsx @@ -70,7 +70,11 @@ export const DonationsReport: React.FC = ({ headerType={HeaderTypeEnum.Report} /> - + Date: Fri, 14 Feb 2025 15:01:11 -0600 Subject: [PATCH 054/101] Remove dots from goal line --- src/components/Dashboard/DonationHistories/DonationHistories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 58661a0d21..aaaf4ec2f0 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -233,6 +233,7 @@ const DonationHistories = ({ dataKey="goal" name={t('Goal')} connectNulls + dot={false} stroke={palette.graphTeal.main} strokeWidth={3} /> From 73e24ca0392035c629cd5509608c9b608b68bac6 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 14 Feb 2025 15:07:22 -0600 Subject: [PATCH 055/101] Use machine calculated goal when staff-entered goal is unavailable --- pages/accountLists/GetDashboard.graphql | 2 + .../CoachingDetail/LoadCoachingDetail.graphql | 2 + .../DonationHistories/DonationHistories.tsx | 5 +- .../DonationHistories/graphData.test.ts | 67 +++++++++++++++++++ .../Dashboard/DonationHistories/graphData.ts | 25 +++++-- .../DonationsReport/GetDonationGraph.graphql | 2 + 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/pages/accountLists/GetDashboard.graphql b/pages/accountLists/GetDashboard.graphql index 0f22d30d20..d50c741c43 100644 --- a/pages/accountLists/GetDashboard.graphql +++ b/pages/accountLists/GetDashboard.graphql @@ -32,6 +32,8 @@ query GetDashboard($accountListId: ID!, $periodBegin: ISO8601Date!) { healthIndicatorData(accountListId: $accountListId, beginDate: $periodBegin) { id indicationPeriodBegin + machineCalculatedGoal + machineCalculatedGoalCurrency staffEnteredGoal } } diff --git a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql index fa82c296cf..0a02e92088 100644 --- a/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql +++ b/src/components/Coaching/CoachingDetail/LoadCoachingDetail.graphql @@ -115,6 +115,8 @@ query GetCoachingDonationGraph( ) { id indicationPeriodBegin + machineCalculatedGoal + machineCalculatedGoalCurrency staffEnteredGoal } } diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index aaaf4ec2f0..d3a27c3bb4 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -78,7 +78,10 @@ export interface DonationHistoriesData { healthIndicatorData: Array< Pick< Types.HealthIndicatorData, - 'indicationPeriodBegin' | 'staffEnteredGoal' + | 'indicationPeriodBegin' + | 'machineCalculatedGoal' + | 'machineCalculatedGoalCurrency' + | 'staffEnteredGoal' > >; } diff --git a/src/components/Dashboard/DonationHistories/graphData.test.ts b/src/components/Dashboard/DonationHistories/graphData.test.ts index 10cbbad53a..8f73205e4e 100644 --- a/src/components/Dashboard/DonationHistories/graphData.test.ts +++ b/src/components/Dashboard/DonationHistories/graphData.test.ts @@ -78,6 +78,73 @@ describe('calculateGraphData', () => { }, ]); }); + + it('uses the machine calculated goal with the staff entered goal is missing', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + currency: 'USD', + monthlyGoal: null, + }, + reportsDonationHistories: { + periods: [ + { startDate: '2020-07-01' }, + { startDate: '2020-08-01' }, + { startDate: '2020-09-01' }, + { startDate: '2020-10-01' }, + { startDate: '2020-11-01' }, + { startDate: '2020-12-01' }, + ], + }, + healthIndicatorData: [ + { + indicationPeriodBegin: '2020-08-01', + staffEnteredGoal: null, + machineCalculatedGoal: null, + }, + { + indicationPeriodBegin: '2020-09-01', + staffEnteredGoal: null, + machineCalculatedGoal: 100, + machineCalculatedGoalCurrency: 'EUR', + }, + { + indicationPeriodBegin: '2020-10-01', + staffEnteredGoal: null, + machineCalculatedGoal: 100, + machineCalculatedGoalCurrency: 'USD', + }, + { + indicationPeriodBegin: '2020-11-01', + staffEnteredGoal: 200, + machineCalculatedGoal: 100, + machineCalculatedGoalCurrency: 'USD', + }, + { + indicationPeriodBegin: '2020-12-01', + staffEnteredGoal: 200, + machineCalculatedGoal: 100, + machineCalculatedGoalCurrency: 'USD', + }, + ], + }, + variables, + }); + + expect( + calculateGraphData({ ...graphOptions, data }).periods, + ).toMatchObject([ + { goal: null, startDate: 'Jul 20' }, // no health indicator period + { goal: null, startDate: 'Aug 20' }, // no staff-entered or estimated goal + { goal: null, startDate: 'Sep 20' }, // ignoring estimated goal because currency doesn't match + { goal: 100, startDate: 'Oct 20' }, // using estimated goal because no staff-entered goal available + { goal: 200, startDate: 'Nov 20' }, // using staff-entered goal + { goal: 100, startDate: 'Dec 20' }, // using estimated goal because no preference set + ]); + }); }); describe('empty', () => { diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts index 74d98015cd..e332951512 100644 --- a/src/components/Dashboard/DonationHistories/graphData.ts +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -32,20 +32,31 @@ export const calculateGraphData = ({ data, currencyColors, }: CalculateGraphDataOptions): CalculateGraphDataResult => { - const { monthlyGoal: goal, totalPledges: pledged } = data?.accountList ?? {}; + const { + currency, + monthlyGoal: goal, + totalPledges: pledged, + } = data?.accountList ?? {}; const { healthIndicatorData, reportsDonationHistories } = data ?? {}; const currentMonth = DateTime.now().startOf('month').toISODate(); const currencies: CurrencyBar[] = []; const periods = reportsDonationHistories?.periods?.map((period) => { - // Use the goal from preferences for the last period, i.e. the current month + const hiPeriod = healthIndicatorData?.find( + (item) => item.indicationPeriodBegin === period.startDate, + ); + // The machine calculated goal cannot be used if its currency differs from the user's currency + const machineCalculatedGoal = + currency && currency === hiPeriod?.machineCalculatedGoalCurrency + ? hiPeriod.machineCalculatedGoal + : null; + + // Use the goal from preferences for the current month // For all other months, use the snapshot of the goal preference from the health indicator data + // Regardless of the goal source, if it is missing, default to the machine calculated goal const periodGoal = - period.startDate === currentMonth - ? goal - : healthIndicatorData?.find( - (item) => item.indicationPeriodBegin === period.startDate, - )?.staffEnteredGoal; + (period.startDate === currentMonth ? goal : hiPeriod?.staffEnteredGoal) ?? + machineCalculatedGoal; const periodData: Period = { currencies: {}, diff --git a/src/components/Reports/DonationsReport/GetDonationGraph.graphql b/src/components/Reports/DonationsReport/GetDonationGraph.graphql index 5278ad3c61..e1560b28b8 100644 --- a/src/components/Reports/DonationsReport/GetDonationGraph.graphql +++ b/src/components/Reports/DonationsReport/GetDonationGraph.graphql @@ -18,6 +18,8 @@ query GetDonationGraph( healthIndicatorData(accountListId: $accountListId, beginDate: $periodBegin) { id indicationPeriodBegin + machineCalculatedGoal + machineCalculatedGoalCurrency staffEnteredGoal } } From ddcfc717737c56efbcdf241b94c32b088c281739 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Feb 2025 11:03:41 -0600 Subject: [PATCH 056/101] Use color from palette for reference line --- .../Dashboard/DonationHistories/DonationHistories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index d3a27c3bb4..9db7da9e04 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -171,7 +171,7 @@ const DonationHistories = ({ ) ) } - color="#9C9FA1" + color={palette.cruGrayMedium.main} /> {pledged ? ( From 876c0ce663dada8a6d708f52ca04287fadb57c7f Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Feb 2025 16:48:50 -0600 Subject: [PATCH 057/101] Mock ComposedCharts and add click handler tests --- .../DonationHistories.test.tsx | 119 +++++++++++++----- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx index f9b8b0b626..301c6c3398 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + CategoricalChartProps, + CategoricalChartState, +} from 'recharts/types/chart/generateCategoricalChart.d'; import TestRouter from '__tests__/util/TestRouter'; import theme from 'src/theme'; import { @@ -9,6 +14,31 @@ import { } from './DonationHistories'; import DonationHistories from '.'; +jest.mock('recharts', () => ({ + ...jest.requireActual('recharts'), + ComposedChart: (props: CategoricalChartProps) => ( + <> + + + + ), +})); + const push = jest.fn(); const router = { @@ -17,10 +47,34 @@ const router = { push, }; +const donationsData: DonationHistoriesData = { + accountList: { + currency: 'USD', + monthlyGoal: 100, + totalPledges: 2500, + }, + reportsDonationHistories: { + periods: [ + { + convertedTotal: 50, + startDate: '2019-01-01', + totals: [{ currency: 'USD', convertedAmount: 50 }], + }, + { + convertedTotal: 60, + startDate: '2019-02-01', + totals: [{ currency: 'NZD', convertedAmount: 60 }], + }, + ], + averageIgnoreCurrent: 1000, + }, + healthIndicatorData: [], +}; + const TestComponent: React.FC = (props) => ( - , + ); @@ -36,10 +90,7 @@ describe('DonationHistories', () => { it('empty periods', () => { const data: DonationHistoriesData = { - accountList: { - currency: 'USD', - totalPledges: 1000, - }, + ...donationsData, reportsDonationHistories: { periods: [ { @@ -55,7 +106,6 @@ describe('DonationHistories', () => { ], averageIgnoreCurrent: 0, }, - healthIndicatorData: [], }; const { getByTestId, queryByTestId } = render( @@ -75,31 +125,7 @@ describe('DonationHistories', () => { describe('populated periods', () => { it('shows references', () => { - const data: DonationHistoriesData = { - accountList: { - currency: 'USD', - monthlyGoal: 100, - totalPledges: 2500, - }, - reportsDonationHistories: { - periods: [ - { - convertedTotal: 50, - startDate: '1-1-2019', - totals: [{ currency: 'USD', convertedAmount: 50 }], - }, - { - convertedTotal: 60, - startDate: '1-2-2019', - totals: [{ currency: 'NZD', convertedAmount: 60 }], - }, - ], - averageIgnoreCurrent: 1000, - }, - healthIndicatorData: [], - }; - - const { getByTestId } = render(); + const { getByTestId } = render(); expect( getByTestId('DonationHistoriesTypographyGoal').textContent, ).toEqual('Goal $100'); @@ -111,4 +137,35 @@ describe('DonationHistories', () => { ).toEqual('Committed $2,500'); }); }); + + describe('onPeriodClick', () => { + it('is called when a period is clicked', () => { + const handlePeriodClick = jest.fn(); + const { getByRole } = render( + , + ); + + userEvent.click(getByRole('button', { name: 'Period 1' })); + expect(handlePeriodClick).toHaveBeenCalledTimes(1); + expect(handlePeriodClick.mock.calls[0][0].toISODate()).toBe( + donationsData.reportsDonationHistories.periods[0].startDate, + ); + }); + + it('is not called when the chart is clicked', () => { + const handlePeriodClick = jest.fn(); + const { getByRole } = render( + , + ); + + userEvent.click(getByRole('button', { name: 'Outside Period' })); + expect(handlePeriodClick).not.toHaveBeenCalled(); + }); + }); }); From 8e999b0b05a07f71e02ee9d74146a8b85da14699 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Feb 2025 16:49:21 -0600 Subject: [PATCH 058/101] Fix recharts warning by moving height to ResponsiveContainer --- .../Dashboard/DonationHistories/DonationHistories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 9db7da9e04..36bea2b897 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -210,11 +210,11 @@ const DonationHistories = ({ ) : ( <> - + {loading ? ( ) : ( - + )} - + {loading ? ( ) : ( - + From 2dfb4223a9896683f1bf66c4650ae2295f91da20 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 21 Feb 2025 10:11:26 -0600 Subject: [PATCH 059/101] Prefer staff-entered goal over machine-calculated goal in the current month --- .../DonationHistories/graphData.test.ts | 26 +++++++------------ .../Dashboard/DonationHistories/graphData.ts | 9 ++++--- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/components/Dashboard/DonationHistories/graphData.test.ts b/src/components/Dashboard/DonationHistories/graphData.test.ts index 8f73205e4e..9e9ef7458a 100644 --- a/src/components/Dashboard/DonationHistories/graphData.test.ts +++ b/src/components/Dashboard/DonationHistories/graphData.test.ts @@ -79,7 +79,7 @@ describe('calculateGraphData', () => { ]); }); - it('uses the machine calculated goal with the staff entered goal is missing', () => { + it('uses the machine calculated goal when the staff entered goal is missing', () => { const data = gqlMock< GetDonationGraphQuery, GetDonationGraphQueryVariables @@ -91,7 +91,6 @@ describe('calculateGraphData', () => { }, reportsDonationHistories: { periods: [ - { startDate: '2020-07-01' }, { startDate: '2020-08-01' }, { startDate: '2020-09-01' }, { startDate: '2020-10-01' }, @@ -100,26 +99,20 @@ describe('calculateGraphData', () => { ], }, healthIndicatorData: [ - { - indicationPeriodBegin: '2020-08-01', - staffEnteredGoal: null, - machineCalculatedGoal: null, - }, { indicationPeriodBegin: '2020-09-01', staffEnteredGoal: null, - machineCalculatedGoal: 100, - machineCalculatedGoalCurrency: 'EUR', + machineCalculatedGoal: null, }, { indicationPeriodBegin: '2020-10-01', staffEnteredGoal: null, machineCalculatedGoal: 100, - machineCalculatedGoalCurrency: 'USD', + machineCalculatedGoalCurrency: 'EUR', }, { indicationPeriodBegin: '2020-11-01', - staffEnteredGoal: 200, + staffEnteredGoal: null, machineCalculatedGoal: 100, machineCalculatedGoalCurrency: 'USD', }, @@ -137,12 +130,11 @@ describe('calculateGraphData', () => { expect( calculateGraphData({ ...graphOptions, data }).periods, ).toMatchObject([ - { goal: null, startDate: 'Jul 20' }, // no health indicator period - { goal: null, startDate: 'Aug 20' }, // no staff-entered or estimated goal - { goal: null, startDate: 'Sep 20' }, // ignoring estimated goal because currency doesn't match - { goal: 100, startDate: 'Oct 20' }, // using estimated goal because no staff-entered goal available - { goal: 200, startDate: 'Nov 20' }, // using staff-entered goal - { goal: 100, startDate: 'Dec 20' }, // using estimated goal because no preference set + { goal: null, startDate: 'Aug 20' }, // no health indicator period + { goal: null, startDate: 'Sep 20' }, // no staff-entered or estimated goal + { goal: null, startDate: 'Oct 20' }, // ignoring estimated goal because currency doesn't match + { goal: 100, startDate: 'Nov 20' }, // using estimated goal because no staff-entered goal available + { goal: 200, startDate: 'Dec 20' }, // using staff-entered goal ]); }); }); diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts index e332951512..920a80b8ba 100644 --- a/src/components/Dashboard/DonationHistories/graphData.ts +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -51,11 +51,12 @@ export const calculateGraphData = ({ ? hiPeriod.machineCalculatedGoal : null; - // Use the goal from preferences for the current month - // For all other months, use the snapshot of the goal preference from the health indicator data - // Regardless of the goal source, if it is missing, default to the machine calculated goal const periodGoal = - (period.startDate === currentMonth ? goal : hiPeriod?.staffEnteredGoal) ?? + // In the current month, give the goal from preferences the highest precedence + (period.startDate === currentMonth ? goal : null) ?? + // Fall back to the staff-entered goal if the preferences goal is unavailable or it is not the current month + hiPeriod?.staffEnteredGoal ?? + // Finally, fall back to the machine-calculated goal as a last resort machineCalculatedGoal; const periodData: Period = { From 0091d64f085dac4bbaeefc93a51f69cc1e389e1a Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 21 Feb 2025 10:15:54 -0600 Subject: [PATCH 060/101] Extrapolate missing health indicator periods --- .../DonationHistories/graphData.test.ts | 73 +++++++++++++++++-- .../Dashboard/DonationHistories/graphData.ts | 8 +- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/components/Dashboard/DonationHistories/graphData.test.ts b/src/components/Dashboard/DonationHistories/graphData.test.ts index 9e9ef7458a..1c8a6e070f 100644 --- a/src/components/Dashboard/DonationHistories/graphData.test.ts +++ b/src/components/Dashboard/DonationHistories/graphData.test.ts @@ -137,6 +137,62 @@ describe('calculateGraphData', () => { { goal: 200, startDate: 'Dec 20' }, // using staff-entered goal ]); }); + + it('extrapolates missing health indicator periods', () => { + const data = gqlMock< + GetDonationGraphQuery, + GetDonationGraphQueryVariables + >(GetDonationGraphDocument, { + mocks: { + accountList: { + currency: 'USD', + monthlyGoal: null, + }, + reportsDonationHistories: { + periods: [ + { startDate: '2020-07-01' }, + { startDate: '2020-08-01' }, + { startDate: '2020-09-01' }, + { startDate: '2020-10-01' }, + { startDate: '2020-11-01' }, + { startDate: '2020-12-01' }, + ], + }, + // August, September, and November are missing + healthIndicatorData: [ + { + indicationPeriodBegin: '2020-07-01', + staffEnteredGoal: 200, + machineCalculatedGoal: 100, + }, + { + indicationPeriodBegin: '2020-10-01', + staffEnteredGoal: null, + machineCalculatedGoal: 110, + machineCalculatedGoalCurrency: 'USD', + }, + { + indicationPeriodBegin: '2020-12-01', + staffEnteredGoal: 220, + machineCalculatedGoal: 120, + machineCalculatedGoalCurrency: 'USD', + }, + ], + }, + variables, + }); + + expect( + calculateGraphData({ ...graphOptions, data }).periods, + ).toMatchObject([ + { goal: 200, startDate: 'Jul 20' }, + { goal: 200, startDate: 'Aug 20' }, // extrapolated from July + { goal: 200, startDate: 'Sep 20' }, // extrapolated from July + { goal: 110, startDate: 'Oct 20' }, + { goal: 110, startDate: 'Nov 20' }, // extrapolated from October + { goal: 220, startDate: 'Dec 20' }, + ]); + }); }); describe('empty', () => { @@ -210,7 +266,10 @@ describe('calculateGraphData', () => { totalPledges: 20, }, reportsDonationHistories: { - periods: [{ convertedTotal: 100 }, { convertedTotal: 40 }], + periods: [ + { startDate: '2020-11-01', convertedTotal: 100 }, + { startDate: '2020-12-01', convertedTotal: 40 }, + ], averageIgnoreCurrent: 50, }, healthIndicatorData: [ @@ -236,8 +295,8 @@ describe('calculateGraphData', () => { }, reportsDonationHistories: { periods: [ - { convertedTotal: 30, startDate: '2020-11-01' }, - { convertedTotal: 40, startDate: '2020-12-01' }, + { startDate: '2020-11-01', convertedTotal: 30 }, + { startDate: '2020-12-01', convertedTotal: 40 }, ], averageIgnoreCurrent: 50, }, @@ -264,8 +323,8 @@ describe('calculateGraphData', () => { }, reportsDonationHistories: { periods: [ - { convertedTotal: 30, startDate: '2020-11-01' }, - { convertedTotal: 40, startDate: '2020-12-01' }, + { startDate: '2020-11-01', convertedTotal: 30 }, + { startDate: '2020-12-01', convertedTotal: 40 }, ], averageIgnoreCurrent: 50, }, @@ -292,8 +351,8 @@ describe('calculateGraphData', () => { }, reportsDonationHistories: { periods: [ - { convertedTotal: 30, startDate: '2020-11-01' }, - { convertedTotal: 40, startDate: '2020-12-01' }, + { startDate: '2020-11-01', convertedTotal: 30 }, + { startDate: '2020-12-01', convertedTotal: 40 }, ], averageIgnoreCurrent: 50, }, diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts index 920a80b8ba..bec2801c7e 100644 --- a/src/components/Dashboard/DonationHistories/graphData.ts +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -42,8 +42,12 @@ export const calculateGraphData = ({ const currencies: CurrencyBar[] = []; const periods = reportsDonationHistories?.periods?.map((period) => { - const hiPeriod = healthIndicatorData?.find( - (item) => item.indicationPeriodBegin === period.startDate, + // Look up the health indicator period that most closely matches the current period, without + // going over. This handles potentially missing periods because health indicator data is not + // guaranteed to be available for every month. Because health indicator periods are sorted + // in ascending order, if e.g. March has no health indicator data, February will be used instead. + const hiPeriod = healthIndicatorData?.findLast( + (item) => item.indicationPeriodBegin <= period.startDate, ); // The machine calculated goal cannot be used if its currency differs from the user's currency const machineCalculatedGoal = From 840ab706a833d81f73a766e1ff88d17b88f5747d Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Mar 2025 13:09:35 -0500 Subject: [PATCH 061/101] Visually indicate machine-calculated goals in accounts grid --- .../AccountLists/AccountLists.test.tsx | 32 +++++++++ src/components/AccountLists/AccountLists.tsx | 67 ++++++++++++++----- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index 3c540f6140..dfcc2b06c8 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -107,6 +107,38 @@ describe('AccountLists', () => { ); }); + it('adds color and label to machine calculated goals', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + name: 'Account', + monthlyGoal: null, + healthIndicatorData: { + machineCalculatedGoal: 2000, + machineCalculatedGoalCurrency: 'USD', + }, + currency: 'USD', + }, + ], + }, + }, + }); + + const { getByLabelText, getByText } = render( + + + , + ); + expect( + getByLabelText(/^Your current goal of \$2,000 is machine-calculated/), + ).toBeInTheDocument(); + expect(getByText('Goal')).toHaveStyle('color: rgb(169, 68, 66);'); + expect(getByText('$2,000')).toHaveStyle('color: rgb(169, 68, 66);'); + expect(getByText('(machine-calculated)')).toBeInTheDocument(); + }); + it("hides percentages when machine calculated goal currency differs from user's currency", () => { const data = gqlMock(GetAccountListsDocument, { mocks: { diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index 319672f67c..f69e0a84bf 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -8,6 +8,7 @@ import { Grid, Link, Theme, + Tooltip, Typography, } from '@mui/material'; import { motion } from 'framer-motion'; @@ -123,21 +124,48 @@ const AccountLists = ({ data }: Props): ReactElement => { {monthlyGoal && ( - - - {t('Goal')} - - - {currencyFormat( - monthlyGoal, - currency, - locale, - )} - - + + + + {t('Goal')} + + + {currencyFormat( + monthlyGoal, + currency, + locale, + )} + + + )} { + {!hasPreferencesGoal && + typeof monthlyGoal === 'number' && ( + + ({t('machine-calculated')}) + + )} From d6c6dbf7a2aa115817babfb56c5bf304c6d7495f Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Mar 2025 13:32:47 -0500 Subject: [PATCH 062/101] Use strings for name query --- .../MonthlyGoal/MonthlyGoal.test.tsx | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index b48a375d9c..6774457f7f 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -276,9 +276,13 @@ describe('MonthlyGoal', () => { ); expect( - await findByRole('heading', { - name: /\$999.50/i, - }), + await findByLabelText( + /^Your current goal of \$999.50 is staff-entered/, + ), + ).not.toHaveStyle('color: rgb(169, 68, 66)'); + + expect( + await findByRole('heading', { name: '$999.50' }), ).toBeInTheDocument(); expect( @@ -295,15 +299,11 @@ describe('MonthlyGoal', () => { ); expect( - await findByRole('heading', { - name: /\$7,000/i, - }), + await findByRole('heading', { name: '$7,000' }), ).toBeInTheDocument(); expect( - queryByRole('heading', { - name: /\$999.50/i, - }), + queryByRole('heading', { name: '$999.50' }), ).not.toBeInTheDocument(); expect(getByRole('link', { name: 'Set Monthly Goal' })).toHaveAttribute( @@ -325,22 +325,14 @@ describe('MonthlyGoal', () => { />, ); - expect( - await findByRole('heading', { - name: /\$0/i, - }), - ).toBeInTheDocument(); + expect(await findByRole('heading', { name: '$0' })).toBeInTheDocument(); expect( - queryByRole('heading', { - name: /\$7,000/i, - }), + queryByRole('heading', { name: '$7,000' }), ).not.toBeInTheDocument(); expect( - queryByRole('heading', { - name: /\$999.50/i, - }), + queryByRole('heading', { name: '$999.50' }), ).not.toBeInTheDocument(); }); }); From 4446ed04ddf99e76eb114ad053d1825f8178666d Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 18 Mar 2025 13:39:16 -0500 Subject: [PATCH 063/101] Visually indicate machine-calculated goals on the dashboard --- .../MonthlyGoal/MonthlyGoal.test.tsx | 34 ++++++++++++------- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 9 ++++- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index 6774457f7f..aa0c985e9b 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; import { render, waitFor } from '@testing-library/react'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import matchMediaMock from '__tests__/util/matchMediaMock'; +import theme from 'src/theme'; import { HealthIndicatorQuery } from './HealthIndicator.generated'; import MonthlyGoal, { MonthlyGoalProps } from './MonthlyGoal'; @@ -30,16 +32,18 @@ const Components = ({ healthIndicatorData = [], monthlyGoalProps, }: ComponentsProps) => ( - - mocks={{ - HealthIndicator: { - healthIndicatorData, - }, - }} - onCall={mutationSpy} - > - - + + + mocks={{ + HealthIndicator: { + healthIndicatorData, + }, + }} + onCall={mutationSpy} + > + + + ); describe('MonthlyGoal', () => { @@ -263,7 +267,7 @@ describe('MonthlyGoal', () => { describe('Monthly Goal', () => { it('should set the monthly goal to the user-entered goal if it exists', async () => { - const { findByRole, queryByRole } = render( + const { findByLabelText, findByRole, queryByRole } = render( { }); it('should set the monthly goal to the machine calculated goal', async () => { - const { findByRole, getByRole, queryByRole } = render( + const { findByRole, findByLabelText, getByRole, queryByRole } = render( , ); + expect( + await findByLabelText( + /^Your current goal of \$7,000 is machine-calculated/, + ), + ).toHaveStyle('color: rgb(169, 68, 66)'); + expect( await findByRole('heading', { name: '$7,000' }), ).toBeInTheDocument(); diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 63b32c6114..94bd0c7e56 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -163,7 +163,14 @@ const MonthlyGoal = ({ - +
Date: Wed, 19 Mar 2025 14:45:31 -0500 Subject: [PATCH 064/101] Remove unused method --- .../Coaching/CoachingDetail/helpers.test.ts | 18 +----------------- .../Coaching/CoachingDetail/helpers.ts | 12 ------------ 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/components/Coaching/CoachingDetail/helpers.test.ts b/src/components/Coaching/CoachingDetail/helpers.test.ts index 246506a6c8..c8a135e2ab 100644 --- a/src/components/Coaching/CoachingDetail/helpers.test.ts +++ b/src/components/Coaching/CoachingDetail/helpers.test.ts @@ -1,4 +1,4 @@ -import { getLastNewsletter, getResultColor } from './helpers'; +import { getLastNewsletter } from './helpers'; describe('getLastNewsletter', () => { it('returns the latest date when two dates are provided', () => { @@ -20,19 +20,3 @@ describe('getLastNewsletter', () => { expect(getLastNewsletter(null, null)).toBeNull(); }); }); - -describe('getResultColor', () => { - it('is green when at or above the goal', () => { - expect(getResultColor(10, 10)).toBe('#5CB85C'); - expect(getResultColor(11, 10)).toBe('#5CB85C'); - }); - - it('is yellow when at or above 80% of the goal', () => { - expect(getResultColor(8, 10)).toBe('#8A6D3B'); - expect(getResultColor(9, 10)).toBe('#8A6D3B'); - }); - - it('is red when below 80% of the goal', () => { - expect(getResultColor(7, 10)).toBe('#A94442'); - }); -}); diff --git a/src/components/Coaching/CoachingDetail/helpers.ts b/src/components/Coaching/CoachingDetail/helpers.ts index 6b14bd7904..51d62a6a92 100644 --- a/src/components/Coaching/CoachingDetail/helpers.ts +++ b/src/components/Coaching/CoachingDetail/helpers.ts @@ -1,6 +1,5 @@ import { DateTime } from 'luxon'; import { dateFormatMonthOnly } from 'src/lib/intlFormat'; -import theme from 'src/theme'; import { CoachingPeriodEnum } from './CoachingDetail'; export const getLastNewsletter = ( @@ -18,17 +17,6 @@ export const getLastNewsletter = ( } }; -// Calculate the color of a result based on how close it is to the goal -export const getResultColor = (amount: number, goal: number): string => { - if (amount >= goal) { - return theme.palette.statusSuccess.main; - } else if (amount >= goal * 0.8) { - return theme.palette.statusWarning.main; - } else { - return theme.palette.statusDanger.main; - } -}; - export const getMonthOrWeekDateRange = ( locale: string, period: CoachingPeriodEnum, From 22f1ad8594d19fe6cb73ba99b6bbb688941d2b5c Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 19 Mar 2025 14:51:01 -0500 Subject: [PATCH 065/101] Use status colors from brand guidelines --- src/components/AccountLists/AccountLists.test.tsx | 3 +-- src/components/AccountLists/AccountLists.tsx | 10 +++------- .../Dashboard/MonthlyGoal/MonthlyGoal.test.tsx | 2 +- src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx | 2 +- src/theme.ts | 9 ++++++--- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index dfcc2b06c8..f2876b0d5b 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -134,8 +134,7 @@ describe('AccountLists', () => { expect( getByLabelText(/^Your current goal of \$2,000 is machine-calculated/), ).toBeInTheDocument(); - expect(getByText('Goal')).toHaveStyle('color: rgb(169, 68, 66);'); - expect(getByText('$2,000')).toHaveStyle('color: rgb(169, 68, 66);'); + expect(getByText('$2,000')).toHaveStyle('color: rgb(211, 68, 0);'); expect(getByText('(machine-calculated)')).toBeInTheDocument(); }); diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index f69e0a84bf..351282b664 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -142,11 +142,7 @@ const AccountLists = ({ data }: Props): ReactElement => { {t('Goal')} @@ -155,7 +151,7 @@ const AccountLists = ({ data }: Props): ReactElement => { color={ hasPreferencesGoal ? undefined - : 'statusDanger.main' + : 'statusWarning.main' } > {currencyFormat( @@ -201,7 +197,7 @@ const AccountLists = ({ data }: Props): ReactElement => { typeof monthlyGoal === 'number' && ( ({t('machine-calculated')}) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index aa0c985e9b..58e9f071b3 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -306,7 +306,7 @@ describe('MonthlyGoal', () => { await findByLabelText( /^Your current goal of \$7,000 is machine-calculated/, ), - ).toHaveStyle('color: rgb(169, 68, 66)'); + ).toHaveStyle('color: rgb(211, 68, 0)'); expect( await findByRole('heading', { name: '$7,000' }), diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 94bd0c7e56..2baceba5d5 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -167,7 +167,7 @@ const MonthlyGoal = ({ title={toolTipText} color={ !staffEnteredGoal && machineCalculatedGoal - ? 'statusDanger.main' + ? 'statusWarning.main' : undefined } > diff --git a/src/theme.ts b/src/theme.ts index 585752ac80..598fc98e35 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -25,9 +25,12 @@ const progressBarColors = { }; const statusColors = { - success: '#5CB85C', - warning: '#8A6D3B', - danger: '#A94442', + // Green from the Cru brand colors + success: '#24C976', + // Vermillion from the Cru brand colors + warning: '#D34400', + // Ruby from the Cru brand colors + danger: '#991313', }; const graphColors = { From 63778213fb0677e1e8e05749a664835e063b45bd Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 20 Mar 2025 14:34:22 -0500 Subject: [PATCH 066/101] Use an asterisk to link goal and error message --- .../AccountLists/AccountLists.test.tsx | 9 +++---- src/components/AccountLists/AccountLists.tsx | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index f2876b0d5b..2eb5be0440 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -103,7 +103,7 @@ describe('AccountLists', () => { , ); expect(getByRole('link')).toHaveTextContent( - 'AccountGoal$2,000Gifts Started30%Committed40%', + 'AccountGoal$2,000*Gifts Started30%Committed40%*machine-calculated', ); }); @@ -134,8 +134,9 @@ describe('AccountLists', () => { expect( getByLabelText(/^Your current goal of \$2,000 is machine-calculated/), ).toBeInTheDocument(); - expect(getByText('$2,000')).toHaveStyle('color: rgb(211, 68, 0);'); - expect(getByText('(machine-calculated)')).toBeInTheDocument(); + expect(getByText('machine-calculated')).toHaveStyle( + 'color: rgb(211, 68, 0);', + ); }); it("hides percentages when machine calculated goal currency differs from user's currency", () => { @@ -165,7 +166,7 @@ describe('AccountLists', () => { , ); expect(getByRole('link')).toHaveTextContent( - 'AccountGoal€2,000Gifts Started-Committed-', + 'AccountGoal€2,000*Gifts Started-Committed-*machine-calculated', ); }); }); diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index 351282b664..f04f0f8ed3 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -90,6 +90,8 @@ const AccountLists = ({ data }: Props): ReactElement => { const currency = hasPreferencesGoal ? preferencesCurrency : healthIndicatorData?.machineCalculatedGoalCurrency; + const hasMachineCalculatedGoal = + !hasPreferencesGoal && typeof monthlyGoal === 'number'; // If the currency comes from the machine calculated goal and is different from the // user's currency preference, we can't calculate the received or total percentages @@ -103,6 +105,8 @@ const AccountLists = ({ data }: Props): ReactElement => { ? totalPledges / monthlyGoal : NaN; + const ariaId = `goal-${id}`; + return ( { {currencyFormat( monthlyGoal, currency, locale, )} + {hasMachineCalculatedGoal && ( + + * + + )} @@ -196,10 +206,12 @@ const AccountLists = ({ data }: Props): ReactElement => { {!hasPreferencesGoal && typeof monthlyGoal === 'number' && ( - ({t('machine-calculated')}) + * + {t('machine-calculated')} )} From 71ddbc0a3d7367abcb38799251dbe045dc149dbf Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 20 Mar 2025 16:41:56 -0500 Subject: [PATCH 067/101] Indicate when the goal was last updated --- pages/GetAccountLists.graphql | 1 + pages/accountLists/GetDashboard.graphql | 1 + .../AccountLists/AccountLists.test.tsx | 41 +++++ src/components/AccountLists/AccountLists.tsx | 45 +++-- src/components/Dashboard/Dashboard.tsx | 5 +- .../MonthlyGoal/HealthIndicator.graphql | 20 ++- .../MonthlyGoal/MonthlyGoal.test.tsx | 163 ++++++++++-------- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 86 +++++---- .../HealthIndicatorReport.tsx | 2 +- 9 files changed, 232 insertions(+), 132 deletions(-) diff --git a/pages/GetAccountLists.graphql b/pages/GetAccountLists.graphql index cb7f60e240..3432993df7 100644 --- a/pages/GetAccountLists.graphql +++ b/pages/GetAccountLists.graphql @@ -8,6 +8,7 @@ query GetAccountLists { id name monthlyGoal + monthlyGoalUpdatedAt receivedPledges totalPledges currency diff --git a/pages/accountLists/GetDashboard.graphql b/pages/accountLists/GetDashboard.graphql index d50c741c43..beb75eddbf 100644 --- a/pages/accountLists/GetDashboard.graphql +++ b/pages/accountLists/GetDashboard.graphql @@ -7,6 +7,7 @@ query GetDashboard($accountListId: ID!, $periodBegin: ISO8601Date!) { id name monthlyGoal + monthlyGoalUpdatedAt receivedPledges totalPledges currency diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index 2eb5be0440..a7411d0da4 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -169,4 +169,45 @@ describe('AccountLists', () => { 'AccountGoal€2,000*Gifts Started-Committed-*machine-calculated', ); }); + + describe('updated date', () => { + it('is the date the goal was last updated', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [{ monthlyGoalUpdatedAt: '2019-12-30T00:00:00Z' }], + }, + }, + }); + + const { getByText } = render( + + + , + ); + expect(getByText('Last updated Dec 30, 2019')).toBeInTheDocument(); + }); + + it('is hidden if the goal is missing', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + monthlyGoal: null, + monthlyGoalUpdatedAt: '2019-12-30T00:00:00Z', + }, + ], + }, + }, + }); + + const { queryByText } = render( + + + , + ); + expect(queryByText('Last updated Dec 30, 2019')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index f04f0f8ed3..9841dfa7e5 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -12,11 +12,16 @@ import { Typography, } from '@mui/material'; import { motion } from 'framer-motion'; +import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { GetAccountListsQuery } from 'pages/GetAccountLists.generated'; import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat, percentageFormat } from '../../lib/intlFormat'; +import { + currencyFormat, + dateFormat, + percentageFormat, +} from 'src/lib/intlFormat'; import AnimatedCard from '../AnimatedCard'; import PageHeading from '../PageHeading'; @@ -78,6 +83,7 @@ const AccountLists = ({ data }: Props): ReactElement => { id, name, monthlyGoal: preferencesGoal, + monthlyGoalUpdatedAt: preferencesGoalUpdatedAt, receivedPledges, totalPledges, currency: preferencesCurrency, @@ -92,6 +98,10 @@ const AccountLists = ({ data }: Props): ReactElement => { : healthIndicatorData?.machineCalculatedGoalCurrency; const hasMachineCalculatedGoal = !hasPreferencesGoal && typeof monthlyGoal === 'number'; + const preferencesGoalDate = + typeof preferencesGoal === 'number' && + preferencesGoalUpdatedAt && + DateTime.fromISO(preferencesGoalUpdatedAt); // If the currency comes from the machine calculated goal and is different from the // user's currency preference, we can't calculate the received or total percentages @@ -105,7 +115,8 @@ const AccountLists = ({ data }: Props): ReactElement => { ? totalPledges / monthlyGoal : NaN; - const ariaId = `goal-${id}`; + const machineCalculatedId = `machine-calculated-${id}`; + const lastUpdatedId = `last-updated-${id}`; return ( @@ -152,7 +163,7 @@ const AccountLists = ({ data }: Props): ReactElement => { {currencyFormat( monthlyGoal, @@ -203,17 +214,23 @@ const AccountLists = ({ data }: Props): ReactElement => { - {!hasPreferencesGoal && - typeof monthlyGoal === 'number' && ( - - * - {t('machine-calculated')} - - )} + {hasMachineCalculatedGoal && ( + + * + {t('machine-calculated')} + + )} + {preferencesGoalDate && ( + + {t('Last updated {{date}}', { + date: dateFormat(preferencesGoalDate, locale), + })} + + )} diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index a6af3cb51e..99e0a87287 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -56,11 +56,8 @@ const Dashboard = ({ data, accountListId }: Props): ReactElement => { diff --git a/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql b/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql index 4a28769347..4ebd75475f 100644 --- a/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql +++ b/src/components/Dashboard/MonthlyGoal/HealthIndicator.graphql @@ -1,11 +1,15 @@ query HealthIndicator($accountListId: ID!) { - healthIndicatorData(accountListId: $accountListId) { - id - overallHi - ownershipHi - consistencyHi - successHi - depthHi - machineCalculatedGoal + accountList(id: $accountListId) { + healthIndicatorData { + id + indicationPeriodBegin + overallHi + ownershipHi + consistencyHi + successHi + depthHi + machineCalculatedGoal + staffEnteredGoal + } } } diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index 58e9f071b3..beeddb3cd0 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -8,40 +8,44 @@ import { HealthIndicatorQuery } from './HealthIndicator.generated'; import MonthlyGoal, { MonthlyGoalProps } from './MonthlyGoal'; const accountListId = 'account-list-1'; -const defaultProps = { - goal: 999.5, - received: 500, - pledged: 750, -}; -const healthIndicatorScore: HealthIndicatorQuery['healthIndicatorData'][0] = { - id: '1', - overallHi: 90, - ownershipHi: 80, - consistencyHi: 70, - successHi: 60, - depthHi: 50, - machineCalculatedGoal: 7000, -}; + const mutationSpy = jest.fn(); interface ComponentsProps { - healthIndicatorData?: HealthIndicatorQuery['healthIndicatorData']; - monthlyGoalProps?: Omit; + healthIndicatorData?: Partial< + HealthIndicatorQuery['accountList']['healthIndicatorData'] + >; + accountList?: Partial | null; } const Components = ({ - healthIndicatorData = [], - monthlyGoalProps, + healthIndicatorData = null, + accountList, }: ComponentsProps) => ( mocks={{ HealthIndicator: { - healthIndicatorData, + accountList: { + healthIndicatorData, + }, }, }} onCall={mutationSpy} > - + ); @@ -51,8 +55,12 @@ describe('MonthlyGoal', () => { matchMediaMock({ width: '1024px' }); }); - it('default', () => { - const { getByTestId, queryByTestId } = render(); + it('zeros', () => { + const { getByTestId, queryByTestId } = render( + , + ); expect( queryByTestId('MonthlyGoalTypographyGoalMobile'), @@ -85,9 +93,7 @@ describe('MonthlyGoal', () => { }); it('loading', () => { - const { getByTestId } = render( - , - ); + const { getByTestId } = render(); expect( getByTestId('MonthlyGoalTypographyGoal').children[0].className, ).toContain('MuiSkeleton-root'); @@ -116,9 +122,7 @@ describe('MonthlyGoal', () => { it('props', () => { const { getByTestId, queryByTestId } = render( - , + , ); expect(getByTestId('MonthlyGoalTypographyGoal').textContent).toEqual( '€999.50', @@ -152,11 +156,10 @@ describe('MonthlyGoal', () => { it('props above goal', () => { const { getByTestId, queryByTestId } = render( , ); @@ -186,7 +189,9 @@ describe('MonthlyGoal', () => { }); it('default', () => { - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = render( + , + ); expect( getByTestId('MonthlyGoalTypographyGoalMobile').textContent, ).toEqual('$0'); @@ -203,9 +208,7 @@ describe('MonthlyGoal', () => { it('props', () => { const { getByTestId } = render( - , + , ); expect( getByTestId('MonthlyGoalTypographyGoalMobile').textContent, @@ -215,9 +218,7 @@ describe('MonthlyGoal', () => { describe('HealthIndicator', () => { it('does not render HI widget if no data', async () => { - const { queryByText } = render( - , - ); + const { queryByText } = render(); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('HealthIndicator'); @@ -226,9 +227,7 @@ describe('MonthlyGoal', () => { }); it('does not change Grid styles if no data', async () => { - const { getByTestId } = render( - , - ); + const { getByTestId } = render(); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('HealthIndicator'); @@ -241,19 +240,7 @@ describe('MonthlyGoal', () => { it('should show the health indicator and change Grid styles', async () => { const { getByTestId, getByText } = render( - , + , ); await waitFor(() => { @@ -268,15 +255,7 @@ describe('MonthlyGoal', () => { describe('Monthly Goal', () => { it('should set the monthly goal to the user-entered goal if it exists', async () => { const { findByLabelText, findByRole, queryByRole } = render( - , + , ); expect( @@ -294,11 +273,48 @@ describe('MonthlyGoal', () => { ).not.toBeInTheDocument(); }); + describe('updated date', () => { + it('is the date that the monthly goal was updated', () => { + const { getByText } = render( + , + ); + + expect(getByText('Last updated Jan 1, 2024')).toBeInTheDocument(); + }); + + it('is hidden if the goal is missing', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Last updated Jan 1, 2024')).not.toBeInTheDocument(); + }); + }); + it('should set the monthly goal to the machine calculated goal', async () => { - const { findByRole, findByLabelText, getByRole, queryByRole } = render( + const { + findByRole, + findByLabelText, + getByRole, + queryByRole, + queryByText, + } = render( , ); @@ -316,6 +332,8 @@ describe('MonthlyGoal', () => { queryByRole('heading', { name: '$999.50' }), ).not.toBeInTheDocument(); + expect(queryByText('Last updated Jan 1, 2024')).not.toBeInTheDocument(); + expect(getByRole('link', { name: 'Set Monthly Goal' })).toHaveAttribute( 'href', '/accountLists/account-list-1/settings/preferences?selectedTab=MonthlyGoal', @@ -325,13 +343,8 @@ describe('MonthlyGoal', () => { it('should set the monthly goal to 0 if both the machineCalculatedGoal and monthly goal are unset', async () => { const { findByRole, queryByRole } = render( , ); diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 2baceba5d5..071e82c1a0 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -1,5 +1,5 @@ import NextLink from 'next/link'; -import React, { ReactElement, useMemo } from 'react'; +import React, { ReactElement, useId, useMemo } from 'react'; import { Box, Button, @@ -11,17 +11,20 @@ import { Tooltip, Typography, } from '@mui/material'; +import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { HealthIndicatorWidget } from 'src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget'; import { PreferenceAccordion } from 'src/components/Shared/Forms/Accordions/AccordionEnum'; import { + AccountList, ContactFilterPledgeReceivedEnum, StatusEnum, } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat, + dateFormat, numberFormat, percentageFormat, } from '../../../lib/intlFormat'; @@ -52,65 +55,74 @@ const useStyles = makeStyles()((_theme: Theme) => ({ export interface MonthlyGoalProps { accountListId: string; - loading?: boolean; - goal?: number; - received?: number; - pledged?: number; + accountList: Pick< + AccountList, + | 'currency' + | 'monthlyGoal' + | 'monthlyGoalUpdatedAt' + | 'receivedPledges' + | 'totalPledges' + > | null; totalGiftsNotStarted?: number; - currencyCode?: string; onDashboard?: boolean; } const MonthlyGoal = ({ accountListId, - loading, - goal: staffEnteredGoal = 0, - received = 0, - pledged = 0, + accountList, totalGiftsNotStarted, - currencyCode = 'USD', onDashboard = false, }: MonthlyGoalProps): ReactElement => { const { t } = useTranslation(); const { classes } = useStyles(); const locale = useLocale(); + const loading = accountList === null; + const { + monthlyGoal: preferencesGoal, + monthlyGoalUpdatedAt: preferencesGoalUpdatedAt, + receivedPledges: received = 0, + totalPledges: pledged = 0, + currency, + } = accountList ?? {}; + const { data, loading: healthIndicatorLoading } = useHealthIndicatorQuery({ variables: { accountListId, }, }); - const latestHealthIndicatorData = useMemo( - () => data?.healthIndicatorData.at(-1), - [data], - ); - const showHealthIndicator = !!data?.healthIndicatorData.length; + const latestHealthIndicatorData = data?.accountList.healthIndicatorData; + const showHealthIndicator = !!latestHealthIndicatorData; const machineCalculatedGoal = latestHealthIndicatorData?.machineCalculatedGoal ?? null; - const goal = staffEnteredGoal || machineCalculatedGoal || 0; + const goal = preferencesGoal ?? machineCalculatedGoal ?? 0; + const preferencesGoalDate = + typeof preferencesGoal === 'number' && + preferencesGoalUpdatedAt && + DateTime.fromISO(preferencesGoalUpdatedAt); const receivedPercentage = received / goal; const pledgedPercentage = pledged / goal; const belowGoal = goal - pledged; const belowGoalPercentage = belowGoal / goal; const toolTipText = useMemo(() => { - if (staffEnteredGoal) { + if (preferencesGoal) { return t( 'Your current goal of {{goal}} is staff-entered, based on the value set in your settings preferences.', - { goal: currencyFormat(staffEnteredGoal, currencyCode, locale) }, + { goal: currencyFormat(preferencesGoal, currency, locale) }, ); } else if (machineCalculatedGoal) { return t( 'Your current goal of {{goal}} is machine-calculated, based on the past year of NetSuite data. You can adjust this goal in your settings preferences.', - { goal: currencyFormat(machineCalculatedGoal, currencyCode, locale) }, + { goal: currencyFormat(machineCalculatedGoal, currency, locale) }, ); } else { return t( 'Your current goal is set to "0" because a monthly goal has not been set. You can set your monthly goal in your settings preferences.', ); } - }, [machineCalculatedGoal, staffEnteredGoal, currencyCode, locale]); + }, [machineCalculatedGoal, preferencesGoal, currency, locale]); const cssProps = { containerGrid: showHealthIndicator ? { spacing: 2 } : {}, @@ -120,6 +132,9 @@ const MonthlyGoal = ({ statGrid: showHealthIndicator ? { xs: 6 } : { sm: 6, md: 3 }, hIGrid: showHealthIndicator ? { xs: 12, md: 6, lg: 5 } : { xs: 0 }, }; + + const lastUpdatedId = useId(); + return ( <> @@ -144,7 +159,7 @@ const MonthlyGoal = ({ - {!loading && currencyFormat(goal, currencyCode, locale)} + {!loading && currencyFormat(goal, currency, locale)} @@ -166,13 +181,17 @@ const MonthlyGoal = ({ - +
) : ( - currencyFormat(goal, currencyCode, locale) + currencyFormat(goal, currency, locale) )} - {!staffEnteredGoal && ( + {preferencesGoalDate && ( + + {t('Last updated {{date}}', { + date: dateFormat(preferencesGoalDate, locale), + })} + + )} + {preferencesGoal === null && ( From 4f01759c1b3e4a9dfdfbdf4d8a72a2e679d2bedf Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Fri, 21 Mar 2025 15:43:05 -0500 Subject: [PATCH 083/101] Extract HI logic into reusable module --- .../MonthlyGoal/MonthlyGoal.test.tsx | 19 +-- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 61 +++---- src/lib/healthIndicator.test.ts | 159 ++++++++++++++++++ src/lib/healthIndicator.ts | 111 ++++++++++++ 4 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 src/lib/healthIndicator.test.ts create mode 100644 src/lib/healthIndicator.ts diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index 0a33211567..3570fa2fac 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -338,28 +338,13 @@ describe('MonthlyGoal', () => { }); it('should set the monthly goal to the machine-calculated goal', async () => { - const { - findByRole, - findByLabelText, - getByRole, - queryByRole, - queryByText, - } = render( + const { findByRole, getByRole, queryByRole, queryByText } = render( , ); - expect( - await findByLabelText( - /^Your current goal of \$7,000 is machine-calculated/, - ), - ).toHaveStyle('color: rgb(211, 68, 0)'); - expect( await findByRole('heading', { name: '$7,000' }), ).toBeInTheDocument(); diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index 6f3c983766..c1f724a130 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -12,7 +12,6 @@ import { Typography, TypographyProps, } from '@mui/material'; -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { HealthIndicatorWidget } from 'src/components/Reports/HealthIndicatorReport/HealthIndicatorWidget/HealthIndicatorWidget'; @@ -23,6 +22,7 @@ import { StatusEnum, } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; +import { GoalSource, getHealthIndicatorInfo } from 'src/lib/healthIndicator'; import { currencyFormat, dateFormat, @@ -85,8 +85,6 @@ const MonthlyGoal = ({ const loading = accountList === null; const { - monthlyGoal: preferencesGoal, - monthlyGoalUpdatedAt: preferencesGoalUpdatedAt, receivedPledges: received = 0, totalPledges: pledged = 0, currency, @@ -100,26 +98,21 @@ const MonthlyGoal = ({ const latestHealthIndicatorData = data?.accountList.healthIndicatorData; const showHealthIndicator = !!latestHealthIndicatorData; - const machineCalculatedGoal = - latestHealthIndicatorData?.machineCalculatedGoal && - typeof latestHealthIndicatorData.machineCalculatedGoalCurrency === - 'string' && - latestHealthIndicatorData.machineCalculatedGoalCurrency === currency - ? latestHealthIndicatorData.machineCalculatedGoal - : null; - const goal = preferencesGoal ?? machineCalculatedGoal ?? 0; - const preferencesGoalDate = - typeof preferencesGoal === 'number' && - preferencesGoalUpdatedAt && - DateTime.fromISO(preferencesGoalUpdatedAt); - const preferencesGoalLow = - typeof preferencesGoal === 'number' && - typeof machineCalculatedGoal === 'number' && - preferencesGoal < machineCalculatedGoal; - const receivedPercentage = received / goal; - const pledgedPercentage = pledged / goal; - const belowGoal = goal - pledged; - const belowGoalPercentage = belowGoal / goal; + const { + goal, + goalSource, + machineCalculatedGoal, + preferencesGoal, + preferencesGoalUpdatedAt, + preferencesGoalLow, + preferencesGoalOld, + } = getHealthIndicatorInfo(accountList, latestHealthIndicatorData); + const goalOrZero = goal ?? 0; + const hasValidGoal = goal !== null; + const receivedPercentage = hasValidGoal ? received / goal : NaN; + const pledgedPercentage = hasValidGoal ? pledged / goal : NaN; + const belowGoal = goalOrZero - pledged; + const belowGoalPercentage = hasValidGoal ? belowGoal / goal : NaN; const toolTipText = useMemo(() => { if (preferencesGoal) { @@ -153,20 +146,17 @@ const MonthlyGoal = ({ label: t('Below machine-calculated goal'), color: 'statusWarning.main', } - : typeof preferencesGoal !== 'number' + : goalSource === GoalSource.MachineCalculated ? { label: t('Machine-calculated goal'), color: 'statusWarning.main', } - : preferencesGoalDate + : preferencesGoalUpdatedAt ? { label: t('Last updated {{date}}', { - date: dateFormat(preferencesGoalDate, locale), + date: dateFormat(preferencesGoalUpdatedAt, locale), }), - color: - preferencesGoalDate <= DateTime.now().minus({ year: 1 }) - ? 'statusWarning.main' - : 'textSecondary', + color: preferencesGoalOld ? 'statusWarning.main' : 'textSecondary', } : null; const annotationId = useId(); @@ -195,7 +185,7 @@ const MonthlyGoal = ({ - {!loading && currencyFormat(goal, currency, locale)} + {!loading && currencyFormat(goalOrZero, currency, locale)} @@ -217,7 +207,7 @@ const MonthlyGoal = ({ ) : ( <> - {currencyFormat(goal, currency, locale)} + {currencyFormat(goalOrZero, currency, locale)} {annotation && ( )} - {(preferencesGoal === null || preferencesGoalLow) && ( + {(goalSource === GoalSource.MachineCalculated || + preferencesGoalLow) && ( - {calculatedGoal && initialMonthlyGoal !== null && ( - - - - )} + + + )} )} From 100da018498d647f8bc17bb3ff8bff2b5216c925 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 24 Mar 2025 13:47:29 -0500 Subject: [PATCH 086/101] Add low goal warning to My Accounts grid --- .../AccountLists/AccountLists.test.tsx | 58 ++++++++++++++++++- src/components/AccountLists/AccountLists.tsx | 37 +++++++----- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index ba7472f778..ca0dbd9513 100644 --- a/src/components/AccountLists/AccountLists.test.tsx +++ b/src/components/AccountLists/AccountLists.test.tsx @@ -73,7 +73,7 @@ describe('AccountLists', () => { , ); expect(getByRole('link')).toHaveTextContent( - 'AccountGoal$1,000*Gifts Started60%Committed80%*Last updated Jan 1, 2024', + 'AccountGoal$1,000*Gifts Started60%Committed80%*Below machine-calculated goal', ); }); @@ -211,4 +211,60 @@ describe('AccountLists', () => { expect(queryByText('Last updated Dec 30, 2019')).not.toBeInTheDocument(); }); }); + + describe('below machine-calculated warning', () => { + it('is shown if goal is less than the machine-calculated goal', () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + currency: 'USD', + monthlyGoal: 5000, + healthIndicatorData: { + machineCalculatedGoal: 10000, + machineCalculatedGoalCurrency: 'USD', + }, + }, + ], + }, + }, + }); + + const { getByText } = render( + + + , + ); + expect(getByText('Below machine-calculated goal')).toBeInTheDocument(); + }); + + it('is hidden if goal is greater than or equal to the machine-calculated goal', async () => { + const data = gqlMock(GetAccountListsDocument, { + mocks: { + accountLists: { + nodes: [ + { + currency: 'USD', + monthlyGoal: 5000, + healthIndicatorData: { + machineCalculatedGoal: 5000, + machineCalculatedGoalCurrency: 'USD', + }, + }, + ], + }, + }, + }); + + const { queryByText } = render( + + + , + ); + expect( + queryByText('Below machine-calculated goal'), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/AccountLists/AccountLists.tsx b/src/components/AccountLists/AccountLists.tsx index 3e8575dc1f..1471dc674a 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -99,6 +99,7 @@ const AccountLists = ({ data }: Props): ReactElement => { goal, goalSource, preferencesGoalUpdatedAt, + preferencesGoalLow, preferencesGoalOld, } = getHealthIndicatorInfo(accountList, healthIndicatorData); @@ -108,22 +109,26 @@ const AccountLists = ({ data }: Props): ReactElement => { : NaN; const totalPercentage = hasValidGoal ? totalPledges / goal : NaN; - const annotation: Annotation | null = - goalSource === GoalSource.MachineCalculated - ? { - label: t('machine-calculated'), - color: 'statusWarning.main', - } - : preferencesGoalUpdatedAt - ? { - label: t('Last updated {{date}}', { - date: dateFormat(preferencesGoalUpdatedAt, locale), - }), - color: preferencesGoalOld - ? 'statusWarning.main' - : undefined, - } - : null; + const annotation: Annotation | null = preferencesGoalLow + ? { + label: t('Below machine-calculated goal'), + color: 'statusWarning.main', + } + : goalSource === GoalSource.MachineCalculated + ? { + label: t('machine-calculated'), + color: 'statusWarning.main', + } + : preferencesGoalUpdatedAt + ? { + label: t('Last updated {{date}}', { + date: dateFormat(preferencesGoalUpdatedAt, locale), + }), + color: preferencesGoalOld + ? 'statusWarning.main' + : undefined, + } + : null; const annotationId = `annotation-${id}`; return ( From b0fee782c470b61bbe5a053bb02158de30436b7a Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 26 Mar 2025 11:38:52 -0500 Subject: [PATCH 087/101] Adjust annotations based on new stakeholder mockups --- .../Dashboard/MonthlyGoal/MonthlyGoal.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx index c1f724a130..e196545142 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -10,7 +10,6 @@ import { Theme, Tooltip, Typography, - TypographyProps, } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; @@ -56,7 +55,7 @@ const useStyles = makeStyles()((_theme: Theme) => ({ interface Annotation { label: string; - color: TypographyProps['color']; + warning: boolean; } export interface MonthlyGoalProps { @@ -144,19 +143,19 @@ const MonthlyGoal = ({ const annotation: Annotation | null = preferencesGoalLow ? { label: t('Below machine-calculated goal'), - color: 'statusWarning.main', + warning: true, } : goalSource === GoalSource.MachineCalculated ? { label: t('Machine-calculated goal'), - color: 'statusWarning.main', + warning: true, } : preferencesGoalUpdatedAt ? { label: t('Last updated {{date}}', { date: dateFormat(preferencesGoalUpdatedAt, locale), }), - color: preferencesGoalOld ? 'statusWarning.main' : 'textSecondary', + warning: preferencesGoalOld, } : null; const annotationId = useId(); @@ -238,7 +237,11 @@ const MonthlyGoal = ({ {annotation && ( * @@ -247,22 +250,12 @@ const MonthlyGoal = ({ )} - {annotation && ( - - * - {annotation.label} - - )} - {(goalSource === GoalSource.MachineCalculated || - preferencesGoalLow) && ( + {annotation?.warning && (