diff --git a/src/components/AccountLists/AccountLists.test.tsx b/src/components/AccountLists/AccountLists.test.tsx index 8e6e9ce63a..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', ); }); @@ -167,7 +167,7 @@ describe('AccountLists', () => { , ); expect(getByRole('link')).toHaveTextContent( - 'AccountGoal€2,000*Gifts Started-Committed-*machine-calculated', + 'AccountGifts Started-Committed-', ); }); @@ -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 d34ffc7b4f..1471dc674a 100644 --- a/src/components/AccountLists/AccountLists.tsx +++ b/src/components/AccountLists/AccountLists.tsx @@ -13,11 +13,11 @@ import { TypographyProps, } 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 { GoalSource, getHealthIndicatorInfo } from 'src/lib/healthIndicator'; import { currencyFormat, dateFormat, @@ -85,173 +85,154 @@ const AccountLists = ({ data }: Props): ReactElement => { > - {data.accountLists.nodes.map( - ({ + {data.accountLists.nodes.map((accountList) => { + const { id, name, - monthlyGoal: preferencesGoal, - monthlyGoalUpdatedAt: preferencesGoalUpdatedAt, receivedPledges, totalPledges, - currency: preferencesCurrency, + currency, healthIndicatorData, - }) => { - const hasPreferencesGoal = typeof preferencesGoal === 'number'; - const monthlyGoal = hasPreferencesGoal - ? preferencesGoal - : healthIndicatorData?.machineCalculatedGoal; - const currency = hasPreferencesGoal - ? preferencesCurrency - : healthIndicatorData?.machineCalculatedGoalCurrency; - const hasMachineCalculatedGoal = - !hasPreferencesGoal && typeof monthlyGoal === 'number'; - const preferencesGoalDate = - typeof preferencesGoal === 'number' && - preferencesGoalUpdatedAt && - DateTime.fromISO(preferencesGoalUpdatedAt); + } = accountList; - // 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 hasValidGoal = - currency === preferencesCurrency && !!monthlyGoal; - const receivedPercentage = hasValidGoal - ? receivedPledges / monthlyGoal - : NaN; - const totalPercentage = hasValidGoal - ? totalPledges / monthlyGoal - : NaN; + const { + goal, + goalSource, + preferencesGoalUpdatedAt, + preferencesGoalLow, + preferencesGoalOld, + } = getHealthIndicatorInfo(accountList, healthIndicatorData); - const annotation: Annotation | null = hasMachineCalculatedGoal - ? { - label: t('machine-calculated'), - color: 'statusWarning.main', - } - : preferencesGoalDate - ? { - label: t('Last updated {{date}}', { - date: dateFormat(preferencesGoalDate, locale), - }), - variant: 'body2', - } - : null; - const annotationId = `annotation-${id}`; + const hasValidGoal = goal !== null; + const receivedPercentage = hasValidGoal + ? receivedPledges / goal + : NaN; + const totalPercentage = hasValidGoal ? totalPledges / goal : NaN; - return ( - - + + - - - - - - {name} - - - - {monthlyGoal && ( - - - - {t('Goal')} - - - {currencyFormat( - monthlyGoal, + + + + + {name} + + + + {goal && ( + - * - - )} - - - - )} - - - {t('Gifts Started')} - - - {Number.isFinite(receivedPercentage) - ? percentageFormat( - receivedPercentage, - locale, - ) - : '-'} - - - - - {t('Committed')} - - - {Number.isFinite(totalPercentage) - ? percentageFormat(totalPercentage, locale) - : '-'} - - - - {annotation && ( - - * - {annotation.label} - + + + {t('Goal')} + + + {currencyFormat(goal, currency, locale)} + {annotation && ( + + * + + )} + + + )} - - - - - - ); - }, - )} + + + {t('Gifts Started')} + + + {Number.isFinite(receivedPercentage) + ? percentageFormat(receivedPercentage, locale) + : '-'} + + + + + {t('Committed')} + + + {Number.isFinite(totalPercentage) + ? percentageFormat(totalPercentage, locale) + : '-'} + + + + {annotation && ( + + * + {annotation.label} + + )} + + + + + + ); + })} diff --git a/src/components/Announcements/AnnouncementAction/AnnouncementAction.test.tsx b/src/components/Announcements/AnnouncementAction/AnnouncementAction.test.tsx index 0d36620b52..11451ce5b1 100644 --- a/src/components/Announcements/AnnouncementAction/AnnouncementAction.test.tsx +++ b/src/components/Announcements/AnnouncementAction/AnnouncementAction.test.tsx @@ -120,7 +120,7 @@ describe('AnnouncementAction', () => { const button = getByText('Contacts'); expect(button).toHaveStyle({ - 'background-color': '#ED6C02', + 'background-color': '#D34400', color: '#FFFFFF', }); }); @@ -131,7 +131,7 @@ describe('AnnouncementAction', () => { const button = getByText('Contacts'); expect(button).toHaveStyle({ - 'background-color': '#ED6C02', + 'background-color': '#D34400', color: '#FFFFFF', }); }); diff --git a/src/components/Contacts/ContactDetails/ContactTasksTab/ContactTaskRow/TaskDate/TaskDate.test.tsx b/src/components/Contacts/ContactDetails/ContactTasksTab/ContactTaskRow/TaskDate/TaskDate.test.tsx index d574a7270f..9fd935e32c 100644 --- a/src/components/Contacts/ContactDetails/ContactTasksTab/ContactTaskRow/TaskDate/TaskDate.test.tsx +++ b/src/components/Contacts/ContactDetails/ContactTasksTab/ContactTaskRow/TaskDate/TaskDate.test.tsx @@ -17,13 +17,7 @@ describe('TaskCommentsButton', () => { , ); - const dateText = getByText('Oct 12, 21'); - - expect(dateText).toBeInTheDocument(); - - const style = dateText && window.getComputedStyle(dateText); - - expect(style?.color).toMatchInlineSnapshot(`"rgb(56, 63, 67)"`); + expect(getByText('Oct 12, 21')).toHaveStyle('color: #383F43'); }); it('should render complete', () => { @@ -33,13 +27,7 @@ describe('TaskCommentsButton', () => { , ); - const dateText = getByText('Oct 12, 21'); - - expect(dateText).toBeInTheDocument(); - - const style = dateText && window.getComputedStyle(dateText); - - expect(style?.color).toMatchInlineSnapshot(`"rgb(156, 159, 161)"`); + expect(getByText('Oct 12, 21')).toHaveStyle('color: #9C9FA1'); }); it('should render late', () => { @@ -49,13 +37,7 @@ describe('TaskCommentsButton', () => { , ); - const dateText = getByText('Oct 12, 19'); - - expect(dateText).toBeInTheDocument(); - - const style = dateText && window.getComputedStyle(dateText); - - expect(style?.color).toMatchInlineSnapshot(`"rgb(211, 47, 47)"`); + expect(getByText('Oct 12, 19')).toHaveStyle('color: #991313'); }); it('should not render year', () => { @@ -65,7 +47,6 @@ describe('TaskCommentsButton', () => { , ); - const dateText = getByText('Oct 12'); - expect(dateText).toBeInTheDocument(); + expect(getByText('Oct 12')).toBeInTheDocument(); }); }); diff --git a/src/components/Dashboard/DonationHistories/graphData.ts b/src/components/Dashboard/DonationHistories/graphData.ts index bec2801c7e..370d123575 100644 --- a/src/components/Dashboard/DonationHistories/graphData.ts +++ b/src/components/Dashboard/DonationHistories/graphData.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import { getHealthIndicatorInfo } from 'src/lib/healthIndicator'; import { DonationHistoriesData } from './DonationHistories'; export interface CalculateGraphDataOptions { @@ -32,11 +33,7 @@ export const calculateGraphData = ({ data, currencyColors, }: CalculateGraphDataOptions): CalculateGraphDataResult => { - const { - currency, - monthlyGoal: goal, - totalPledges: pledged, - } = data?.accountList ?? {}; + const pledged = data?.accountList?.totalPledges; const { healthIndicatorData, reportsDonationHistories } = data ?? {}; const currentMonth = DateTime.now().startOf('month').toISODate(); @@ -49,15 +46,15 @@ export const calculateGraphData = ({ 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 = - currency && currency === hiPeriod?.machineCalculatedGoalCurrency - ? hiPeriod.machineCalculatedGoal - : null; + + const { machineCalculatedGoal, preferencesGoal } = getHealthIndicatorInfo( + data?.accountList, + hiPeriod, + ); const periodGoal = // In the current month, give the goal from preferences the highest precedence - (period.startDate === currentMonth ? goal : null) ?? + (period.startDate === currentMonth ? preferencesGoal : 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 diff --git a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx index beeddb3cd0..3570fa2fac 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.test.tsx @@ -26,7 +26,13 @@ const Components = ({ mocks={{ HealthIndicator: { accountList: { - healthIndicatorData, + healthIndicatorData: + healthIndicatorData === null + ? null + : { + machineCalculatedGoalCurrency: 'USD', + ...healthIndicatorData, + }, }, }, }} @@ -301,29 +307,44 @@ describe('MonthlyGoal', () => { }); }); - it('should set the monthly goal to the machine calculated goal', async () => { - const { - findByRole, - findByLabelText, - getByRole, - queryByRole, - queryByText, - } = render( + describe('below machine-calculated warning', () => { + it('is shown if goal is less than the machine-calculated goal', async () => { + const { findByText } = render( + , + ); + + expect( + await findByText('Below machine-calculated goal'), + ).toBeInTheDocument(); + }); + + it('is hidden if goal is greater than or equal to the machine-calculated goal', async () => { + const { queryByText } = render( + , + ); + + await waitFor(() => + expect( + queryByText('Below machine-calculated goal'), + ).not.toBeInTheDocument(), + ); + }); + }); + + it('should set the monthly goal to the machine-calculated goal', async () => { + 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 071e82c1a0..08f6a3580d 100644 --- a/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx +++ b/src/components/Dashboard/MonthlyGoal/MonthlyGoal.tsx @@ -11,7 +11,6 @@ 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'; @@ -22,6 +21,7 @@ import { StatusEnum, } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; +import { GoalSource, getHealthIndicatorInfo } from 'src/lib/healthIndicator'; import { currencyFormat, dateFormat, @@ -53,6 +53,11 @@ const useStyles = makeStyles()((_theme: Theme) => ({ }, })); +interface Annotation { + label: string; + warning: boolean; +} + export interface MonthlyGoalProps { accountListId: string; accountList: Pick< @@ -79,8 +84,6 @@ const MonthlyGoal = ({ const loading = accountList === null; const { - monthlyGoal: preferencesGoal, - monthlyGoalUpdatedAt: preferencesGoalUpdatedAt, receivedPledges: received = 0, totalPledges: pledged = 0, currency, @@ -94,17 +97,21 @@ const MonthlyGoal = ({ const latestHealthIndicatorData = data?.accountList.healthIndicatorData; const showHealthIndicator = !!latestHealthIndicatorData; - const machineCalculatedGoal = - latestHealthIndicatorData?.machineCalculatedGoal ?? null; - 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 { + 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) { @@ -133,7 +140,35 @@ const MonthlyGoal = ({ hIGrid: showHealthIndicator ? { xs: 12, md: 6, lg: 5 } : { xs: 0 }, }; - const lastUpdatedId = useId(); + const annotation: Annotation | null = preferencesGoalLow + ? { + label: t('Below machine-calculated goal'), + warning: true, + } + : goalSource === GoalSource.MachineCalculated + ? { + label: t('Machine-calculated goal'), + warning: true, + } + : preferencesGoalUpdatedAt + ? { + label: t('Last updated {{date}}', { + date: dateFormat(preferencesGoalUpdatedAt, locale), + }), + warning: preferencesGoalOld, + } + : null; + const annotationId = useId(); + const annotationNode = annotation && ( + + * + {annotation.label} + + ); return ( <> @@ -159,7 +194,7 @@ const MonthlyGoal = ({ - {!loading && currencyFormat(goal, currency, locale)} + {!loading && currencyFormat(goalOrZero, currency, locale)} @@ -181,7 +216,7 @@ const MonthlyGoal = ({
{loading ? ( ) : ( - currencyFormat(goal, currency, locale) + <> + {currencyFormat(goalOrZero, currency, locale)} + {annotation && ( + + * + + )} + )} - {preferencesGoalDate && ( - - {t('Last updated {{date}}', { - date: dateFormat(preferencesGoalDate, locale), - })} - - )} - {preferencesGoal === null && ( + {/* Without the HI card there is enough space for the annotation so display it here */} + {annotation && !showHealthIndicator && annotationNode} + {annotation?.warning && ( @@ -339,13 +391,19 @@ const MonthlyGoal = ({ )} + {/* With the HI card there isn't enough space for the annotation next to the monthly goal so display it here */} + {annotation && showHealthIndicator && ( + + {annotationNode} + + )} - {showHealthIndicator && latestHealthIndicatorData && ( + {latestHealthIndicatorData && ( { mutationSpy.mockClear(); }); - it('should render accordion closed', () => { - const { getByTestId, getByText, queryByRole } = render( - , - ); + describe('closed', () => { + it('renders label and hides the textbox', () => { + const { getByText, queryByRole } = render( + , + ); - expect(getByTestId('AccordionSummaryValue')).toHaveTextContent( - '$100 (last updated Jan 1, 2024)', - ); - expect(getByText(label)).toBeInTheDocument(); - expect(queryByRole('textbox')).not.toBeInTheDocument(); - }); + expect(getByText(label)).toBeInTheDocument(); + expect(queryByRole('textbox')).not.toBeInTheDocument(); + }); - it('should render accordion closed with calculated goal', async () => { - const { findByText, queryByRole } = render( - , - ); + it('renders goal without updated date', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent('$100'); + }); - expect(await findByText('€1,000 (estimated)')).toBeInTheDocument(); - expect(queryByRole('textbox')).not.toBeInTheDocument(); + it('renders goal and updated date', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent( + '$100 (last updated Jan 1, 2024)', + ); + }); + + it('renders too low warning', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent( + '$100 (below machine-calculated support goal)', + ), + ); + }); + + it('hides too low warning when currencies do not match', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('AccordionSummaryValue')).not.toHaveTextContent( + /below machine-calculated support goal/, + ), + ); + }); + + it('renders calculated goal', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent( + '€1,000 (estimated)', + ), + ); + }); + + it('renders calculated goal without currency', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent( + '1,000 (estimated)', + ), + ); + }); + + it('renders only goal when calculated goal is missing', async () => { + const { getByTestId } = render( + , + ); + + await waitFor(() => + expect(getByTestId('AccordionSummaryValue')).toHaveTextContent('$100'), + ); + }); + + it('renders nothing when goal and calculated goal are missing', async () => { + const { queryByTestId } = render( + , + ); + + await waitFor(() => + expect(queryByTestId('AccordionSummaryValue')).not.toBeInTheDocument(), + ); + }); }); it('should render accordion open and textfield should have a value', () => { diff --git a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx index bb5335f796..509d5dfa05 100644 --- a/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx +++ b/src/components/Settings/preferences/accordions/MonthlyGoalAccordion/MonthlyGoalAccordion.tsx @@ -1,7 +1,7 @@ import React, { ReactElement, useMemo } from 'react'; -import { Box, Button, TextField, Tooltip } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import { Box, Button, TextField, Tooltip, Typography } 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'; @@ -10,7 +10,8 @@ import { AccordionItem } from 'src/components/Shared/Forms/Accordions/AccordionI import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; import { AccountListSettingsInput } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat, dateFormat } from 'src/lib/intlFormat'; +import { GoalSource, getHealthIndicatorInfo } from 'src/lib/healthIndicator'; +import { currencyFormat, dateFormat, numberFormat } from 'src/lib/intlFormat'; import { AccordionProps } from '../../../accordionHelper'; import { useUpdateAccountPreferencesMutation } from '../UpdateAccountPreferences.generated'; import { useMachineCalculatedGoalQuery } from './MachineCalculatedGoal.generated'; @@ -25,15 +26,15 @@ const formatMonthlyGoal = ( goal: number | null, currency: string | null, locale: string, -): string => { +): string | null => { if (goal === null) { - return ''; + return null; } if (currency) { return currencyFormat(goal, currency, locale); } - return goal.toString(); + return numberFormat(goal, locale); }; interface MonthlyGoalAccordionProps @@ -67,32 +68,57 @@ export const MonthlyGoalAccordion: React.FC = ({ accountListId, }, }); + + const accountList = currency + ? { currency, monthlyGoal: initialMonthlyGoal, monthlyGoalUpdatedAt } + : null; + const healthIndicatorData = data?.healthIndicatorData.at(-1); const { - machineCalculatedGoal: calculatedGoal, - machineCalculatedGoalCurrency: calculatedCurrency, - } = data?.healthIndicatorData.at(-1) ?? {}; + goalSource, + machineCalculatedGoalCurrency, + unsafeMachineCalculatedGoal, + preferencesGoalLow, + preferencesGoalUpdatedAt, + } = getHealthIndicatorInfo(accountList, healthIndicatorData); + const formattedCalculatedGoal = useMemo( () => formatMonthlyGoal( - calculatedGoal ?? null, - calculatedCurrency ?? null, + unsafeMachineCalculatedGoal, + machineCalculatedGoalCurrency, locale, ), - [calculatedGoal, calculatedCurrency, locale], + [unsafeMachineCalculatedGoal, machineCalculatedGoalCurrency, locale], ); - const formattedMonthlyGoal = useMemo(() => { + const accordionValue = useMemo(() => { const goal = formatMonthlyGoal(initialMonthlyGoal, currency, locale); - if (!goal || !monthlyGoalUpdatedAt) { - return goal; - } - const date = DateTime.fromISO(monthlyGoalUpdatedAt); - return t('{{goal}} (last updated {{updated}})', { - goal, - updated: dateFormat(date, locale), - }); - }, [initialMonthlyGoal, monthlyGoalUpdatedAt, currency, locale]); + if (goalSource === GoalSource.Preferences) { + if (preferencesGoalLow) { + return ( + + {t('{{goal}} (below machine-calculated support goal)', { goal })} + + ); + } else if (preferencesGoalUpdatedAt) { + return t('{{goal}} (last updated {{updated}})', { + goal, + updated: dateFormat(preferencesGoalUpdatedAt, locale), + }); + } else { + return goal; + } + } else if (formattedCalculatedGoal !== null) { + return t('{{goal}} (estimated)', { goal: formattedCalculatedGoal }); + } + }, [ + initialMonthlyGoal, + formattedCalculatedGoal, + preferencesGoalUpdatedAt, + currency, + locale, + ]); const onSubmit = async ( attributes: Pick, @@ -123,13 +149,13 @@ export const MonthlyGoalAccordion: React.FC = ({ }; const getInstructions = () => { - if (typeof calculatedGoal !== 'number') { + if (unsafeMachineCalculatedGoal === null) { 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) { + if (goalSource === GoalSource.MachineCalculated) { 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 }, @@ -142,15 +168,39 @@ export const MonthlyGoalAccordion: React.FC = ({ } }; + const getWarning = (currentGoal: number | null) => { + if ( + currentGoal && + accountList && + getHealthIndicatorInfo( + { ...accountList, monthlyGoal: currentGoal }, + healthIndicatorData, + ).preferencesGoalLow + ) { + return ( + + + {t( + 'Your current monthly goal is less than the amount NetSuite estimates that you need. Please review your goal and adjust it if needed.', + )} + + ); + } + }; + return ( @@ -172,7 +222,14 @@ export const MonthlyGoalAccordion: React.FC = ({ handleChange, }): ReactElement => (
- + + {getInstructions()} + {getWarning(monthlyGoal)} + + } + > = ({ > {t('Save')} - {calculatedGoal && initialMonthlyGoal !== null && ( - - - - )} + + + )} )} diff --git a/src/components/Shared/Forms/Accordions/AccordionItem.tsx b/src/components/Shared/Forms/Accordions/AccordionItem.tsx index 7cf597c910..18bda49004 100644 --- a/src/components/Shared/Forms/Accordions/AccordionItem.tsx +++ b/src/components/Shared/Forms/Accordions/AccordionItem.tsx @@ -104,7 +104,7 @@ interface AccordionItemProps { onAccordionChange: (accordion: AccordionEnum | null) => void; expandedAccordion: AccordionEnum | null; label: string; - value: string; + value: React.ReactNode; children?: React.ReactNode; fullWidth?: boolean; image?: React.ReactNode; diff --git a/src/components/Shared/Forms/FieldWrapper.tsx b/src/components/Shared/Forms/FieldWrapper.tsx index 9ddb0d82fb..83b94e990b 100644 --- a/src/components/Shared/Forms/FieldWrapper.tsx +++ b/src/components/Shared/Forms/FieldWrapper.tsx @@ -9,7 +9,7 @@ import { interface FieldWrapperProps { labelText?: string; - helperText?: string; + helperText?: React.ReactNode; helperPosition?: HelperPositionEnum; formControlDisabled?: FormControlProps['disabled']; formControlError?: FormControlProps['error']; @@ -22,7 +22,7 @@ interface FieldWrapperProps { export const FieldWrapper: React.FC = ({ labelText = '', - helperText = '', + helperText = null, helperPosition = HelperPositionEnum.Top, formControlDisabled = false, formControlError = false, @@ -47,12 +47,10 @@ export const FieldWrapper: React.FC = ({ '' ); - const helperTextOutput = helperText ? ( + const helperTextOutput = helperText && ( - {t(helperText)} + {helperText} - ) : ( - '' ); return ( diff --git a/src/lib/healthIndicator.test.ts b/src/lib/healthIndicator.test.ts new file mode 100644 index 0000000000..f615df93c3 --- /dev/null +++ b/src/lib/healthIndicator.test.ts @@ -0,0 +1,153 @@ +import { DateTime } from 'luxon'; +import { GoalSource, getHealthIndicatorInfo } from './healthIndicator'; + +describe('getHealthIndicatorInfo', () => { + it('attributes are null when the account list and HI data is loading', () => { + expect(getHealthIndicatorInfo(null, null)).toEqual({ + goal: null, + goalSource: null, + machineCalculatedGoal: null, + machineCalculatedGoalCurrency: null, + unsafeMachineCalculatedGoal: null, + preferencesGoal: null, + preferencesGoalUpdatedAt: null, + preferencesGoalLow: false, + preferencesGoalOld: false, + }); + }); + + it('uses the preferences goal if it is set', () => { + expect( + getHealthIndicatorInfo( + { + currency: 'USD', + monthlyGoal: 2000, + monthlyGoalUpdatedAt: '2019-06-01T00:00:00Z', + }, + { machineCalculatedGoal: 1000, machineCalculatedGoalCurrency: 'USD' }, + ), + ).toEqual({ + goal: 2000, + goalSource: GoalSource.Preferences, + machineCalculatedGoal: 1000, + machineCalculatedGoalCurrency: 'USD', + unsafeMachineCalculatedGoal: 1000, + preferencesGoal: 2000, + preferencesGoalUpdatedAt: DateTime.local(2019, 6, 1), + preferencesGoalLow: false, + preferencesGoalOld: false, + }); + }); + + it('uses the preferences goal if machine-calculated goal is not loaded', () => { + expect( + getHealthIndicatorInfo( + { + currency: 'USD', + monthlyGoal: 2000, + monthlyGoalUpdatedAt: '2019-06-01T00:00:00Z', + }, + null, + ), + ).toEqual({ + goal: 2000, + goalSource: GoalSource.Preferences, + machineCalculatedGoal: null, + machineCalculatedGoalCurrency: null, + unsafeMachineCalculatedGoal: null, + preferencesGoal: 2000, + preferencesGoalUpdatedAt: DateTime.local(2019, 6, 1), + preferencesGoalLow: false, + preferencesGoalOld: false, + }); + }); + + it('uses the machine-calculated goal if a preferences goal is not set', () => { + expect( + getHealthIndicatorInfo( + { currency: 'USD' }, + { machineCalculatedGoal: 1000, machineCalculatedGoalCurrency: 'USD' }, + ), + ).toEqual({ + goal: 1000, + goalSource: GoalSource.MachineCalculated, + machineCalculatedGoal: 1000, + machineCalculatedGoalCurrency: 'USD', + unsafeMachineCalculatedGoal: 1000, + preferencesGoal: null, + preferencesGoalUpdatedAt: null, + preferencesGoalLow: false, + preferencesGoalOld: false, + }); + }); + + it('ignores the machine-calculated goal if its currency does the preferences currency', () => { + expect( + getHealthIndicatorInfo( + { currency: 'USD' }, + { machineCalculatedGoal: 1000, machineCalculatedGoalCurrency: 'EUR' }, + ), + ).toMatchObject({ + goal: null, + goalSource: null, + machineCalculatedGoal: null, + machineCalculatedGoalCurrency: 'EUR', + unsafeMachineCalculatedGoal: 1000, + }); + }); + + it('ignores the machine-calculated goal if its currency is not set', () => { + expect( + getHealthIndicatorInfo( + { currency: 'USD' }, + { machineCalculatedGoal: 1000 }, + ), + ).toMatchObject({ + goal: null, + goalSource: null, + machineCalculatedGoal: null, + machineCalculatedGoalCurrency: null, + unsafeMachineCalculatedGoal: 1000, + }); + }); + + describe('preferencesGoalUpdatedAt', () => { + it('is null when the preferences goal is not set', () => { + expect( + getHealthIndicatorInfo( + { currency: 'USD', monthlyGoalUpdatedAt: '2019-06-01T00:00:00Z' }, + { machineCalculatedGoal: 1000, machineCalculatedGoalCurrency: 'EUR' }, + ), + ).toMatchObject({ + preferencesGoal: null, + preferencesGoalUpdatedAt: null, + }); + }); + }); + + describe('preferencesGoalLow', () => { + it('is true when the preferences goal is less than the machine-calculated goal', () => { + expect( + getHealthIndicatorInfo( + { currency: 'USD', monthlyGoal: 500 }, + { machineCalculatedGoal: 1000, machineCalculatedGoalCurrency: 'USD' }, + ).preferencesGoalLow, + ).toBe(true); + }); + }); + + describe('preferencesGoalOld', () => { + it('is true when the preferences goal is older than a year', () => { + expect( + getHealthIndicatorInfo( + { + currency: 'USD', + monthlyGoal: 2000, + monthlyGoalUpdatedAt: '2018-01-01T00:00:00Z', + }, + {}, + ).preferencesGoalOld, + ).toBe(true); + }); + }); +}); diff --git a/src/lib/healthIndicator.ts b/src/lib/healthIndicator.ts new file mode 100644 index 0000000000..ad2c524b9c --- /dev/null +++ b/src/lib/healthIndicator.ts @@ -0,0 +1,107 @@ +import { DateTime } from 'luxon'; +import { AccountList, HealthIndicatorData } from 'src/graphql/types.generated'; + +export enum GoalSource { + Preferences = 'Preferences', + MachineCalculated = 'MachineCalculated', +} + +interface HealthIndicatorInfo { + /** + * The overall goal, `null` if it is not loaded or is unavailable. It is the preferences goal if + * it is set, and defaults to the machine-calculated goal otherwise. + **/ + goal: number | null; + + /** Whether the goal came from preferences is is machine-calculated */ + goalSource: GoalSource | null; + + /** The machine-calculated goal, `null` if it is not loaded or is unavailable */ + machineCalculatedGoal: number | null; + + /** The machine-calculated goal's currency, `null` if it is not loaded or is unavailable */ + machineCalculatedGoalCurrency: string | null; + + /** + * The machine-calculated goal, `null` if it is not loaded or is unavailable. + * + * WARNING: Unlike `machineCalculatedGoal`, it will be set even if its currency doesn't match the + * user's currency. It may be displayed to the user, but care must be taken not to use it in + * any numerical calculations because all other amounts will be in the user's currency. + **/ + unsafeMachineCalculatedGoal: number | null; + + /** The goal set in preferences, `null` if it is not loaded or is unavailable */ + preferencesGoal: number | null; + + /** The date that the preferences goal was last updated */ + preferencesGoalUpdatedAt: DateTime | null; + + /** `true` if the preferences goal is less than the machine-calculated goal */ + preferencesGoalLow: boolean; + + /** `true` if the preferences goal has not been updated in the last year */ + preferencesGoalOld: boolean; +} + +export const getHealthIndicatorInfo = ( + accountList: + | Pick + | null + | undefined, + healthIndicatorData: + | Pick< + HealthIndicatorData, + 'machineCalculatedGoal' | 'machineCalculatedGoalCurrency' + > + | null + | undefined, +): HealthIndicatorInfo => { + const mismatchedCurrencies = + !!accountList && + !!healthIndicatorData && + accountList.currency !== healthIndicatorData.machineCalculatedGoalCurrency; + // The machine-calculated goal cannot be used if its currency does not match the account list's currency + const machineCalculatedGoal = + !mismatchedCurrencies && healthIndicatorData?.machineCalculatedGoal + ? healthIndicatorData.machineCalculatedGoal + : null; + const machineCalculatedGoalCurrency = + healthIndicatorData?.machineCalculatedGoalCurrency ?? null; + const unsafeMachineCalculatedGoal = + healthIndicatorData?.machineCalculatedGoal ?? null; + + const preferencesGoal = accountList?.monthlyGoal ?? null; + const preferencesGoalUpdatedAt = + preferencesGoal !== null && + typeof accountList?.monthlyGoalUpdatedAt === 'string' + ? DateTime.fromISO(accountList.monthlyGoalUpdatedAt) + : null; + const preferencesGoalLow = + preferencesGoal !== null && + machineCalculatedGoal !== null && + preferencesGoal < machineCalculatedGoal; + const preferencesGoalOld = + preferencesGoalUpdatedAt !== null && + preferencesGoalUpdatedAt <= DateTime.now().minus({ year: 1 }); + + const goal = preferencesGoal ?? machineCalculatedGoal; + const goalSource = + preferencesGoal !== null + ? GoalSource.Preferences + : machineCalculatedGoal !== null + ? GoalSource.MachineCalculated + : null; + + return { + goal, + goalSource, + machineCalculatedGoal, + machineCalculatedGoalCurrency, + unsafeMachineCalculatedGoal, + preferencesGoal, + preferencesGoalUpdatedAt, + preferencesGoalLow, + preferencesGoalOld, + }; +}; diff --git a/src/theme.ts b/src/theme.ts index 598fc98e35..0825d355a1 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -136,6 +136,15 @@ const theme = createTheme({ progressBarGray: { main: progressBarColors.gray, }, + success: { + main: statusColors.success, + }, + warning: { + main: statusColors.warning, + }, + error: { + main: statusColors.danger, + }, statusSuccess: { main: statusColors.success, },