Skip to content
3 changes: 3 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,7 @@
"Goal Name": "Goal Name",
"Goal Name is a required field": "Goal Name is a required field",
"Goal Status": "Goal Status",
"Goals were last run and sent on {{date}} at {{time}}.": "Goals were last run and sent on {{date}} at {{time}}.",
"Good Afternoon,": "Good Afternoon,",
"Good Evening,": "Good Evening,",
"Good Morning,": "Good Morning,",
Expand Down Expand Up @@ -2367,6 +2368,7 @@
"Program Based": "Program Based",
"Progress": "Progress",
"Prompt": "Prompt",
"Provide Training Cost": "Provide Training Cost",
"Puerto Rico": "Puerto Rico",
"Purchase": "Purchase",
"Qatar": "Qatar",
Expand Down Expand Up @@ -3073,6 +3075,7 @@
"Traditional 403(b) Deduction": "Traditional 403(b) Deduction",
"Training": "Training",
"Training Cost": "Training Cost",
"Training costs are required to run & send goals.": "Training costs are required to run & send goals.",
"Training Size": "Training Size",
"Training, conferences, supplies, evangelism & discipleship materials, communication with ministry partners, ministry travel expenses, etc.": "Training, conferences, supplies, evangelism & discipleship materials, communication with ministry partners, ministry travel expenses, etc.",
"Transactions": "Transactions",
Expand Down
85 changes: 82 additions & 3 deletions src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { MpdGoalAdminProvider } from '../MpdGoalAdminContext';
import {
NewStaffCohortAttendeesQuery,
NewStaffCohortsQuery,
UpdateNewStaffCohortMutation,
} from '../NewStaffCohorts.generated';
import {
attendeesMock,
cohortsMock,
cohortsWithoutCostsMock,
updatedCohortMock,
} from '../mpdGoalAdminMocks';
import { CohortBar } from './CohortBar';

Expand All @@ -38,10 +40,13 @@ const TestComponent: React.FC<TestComponentProps> = ({
<GqlMockedProvider<{
NewStaffCohorts: NewStaffCohortsQuery;
NewStaffCohortAttendees: NewStaffCohortAttendeesQuery;
UpdateNewStaffCohort: UpdateNewStaffCohortMutation;
}>
mocks={{
NewStaffCohorts: withoutCosts ? cohortsWithoutCostsMock : cohortsMock,
NewStaffCohortAttendees: attendeesMock(),
// Normalizes over the selected cohort so a save clears the gate.
UpdateNewStaffCohort: updatedCohortMock('fall-nso-2026'),
}}
onCall={mutationSpy}
>
Expand All @@ -54,9 +59,13 @@ const TestComponent: React.FC<TestComponentProps> = ({
);

/** Waits for the cohort first; clicking early opens the modal with no cohort. */
const openModal = async (screen: ReturnType<typeof render>) => {
const openModal = async (
screen: ReturnType<typeof render>,
// A cohort missing its costs prompts to provide them instead.
name: string = 'View/Edit',
) => {
await screen.findByText('Fall NSO 2026');
userEvent.click(screen.getByRole('button', { name: 'View/Edit' }));
userEvent.click(screen.getByRole('button', { name }));
return screen.findByRole('heading', { name: /Training Costs for/ });
};

Expand All @@ -72,6 +81,56 @@ describe('CohortBar', () => {
expect(await findByText('8/10/2026')).toBeInTheDocument();
});

it('renders the disabled View/Edit link while the cohort is still loading', () => {
const { getByRole, queryByRole } = render(<TestComponent withoutCosts />);

// The prompt must not flash before the cohorts query has resolved.
expect(getByRole('button', { name: 'View/Edit' })).toBeDisabled();
expect(
queryByRole('button', { name: 'Provide Training Cost' }),
).not.toBeInTheDocument();
});

it('prompts to provide the costs when the cohort has none', async () => {
const { findByText, findByRole, queryByRole } = render(
<TestComponent withoutCosts />,
);

await findByText('Fall NSO 2026');
expect(
await findByRole('button', { name: 'Provide Training Cost' }),
).toBeInTheDocument();
expect(
queryByRole('button', { name: 'View/Edit' }),
).not.toBeInTheDocument();
});

it('explains why the costs are needed when the cohort has none', async () => {
const { findByText, findByRole } = render(<TestComponent withoutCosts />);

await findByText('Fall NSO 2026');
const prompt = await findByRole('button', {
name: 'Provide Training Cost',
});

userEvent.hover(prompt);
expect(
await findByText('Training costs are required to run & send goals.'),
).toBeInTheDocument();
});

it('opens the modal from the Provide Training Cost prompt', async () => {
Comment thread
wjames111 marked this conversation as resolved.
const screen = render(<TestComponent withoutCosts />);
const { findByText, findByRole, getByRole } = screen;

await findByText('Fall NSO 2026');
userEvent.click(getByRole('button', { name: 'Provide Training Cost' }));

expect(
await findByRole('heading', { name: /Training Costs for/ }),
).toHaveTextContent('Training Costs for Fall NSO 2026');
});

it('opens the Edit Training Costs modal for the selected cohort', async () => {
const screen = render(<TestComponent />);
const { queryByRole } = screen;
Expand Down Expand Up @@ -149,10 +208,30 @@ describe('CohortBar', () => {
);
});

it('replaces the prompt with View/Edit once the costs are saved', async () => {
const screen = render(<TestComponent withoutCosts />);
const { findByRole, getAllByRole, queryByRole } = screen;
await openModal(screen, 'Provide Training Cost');

// Apply stays disabled until all thirteen costs are entered.
getAllByRole('spinbutton').forEach((input, index) =>
userEvent.type(input, String((index + 1) * 100)),
);
const apply = await findByRole('button', { name: 'Apply' });
await waitFor(() => expect(apply).toBeEnabled());
userEvent.click(apply);

expect(await findByRole('button', { name: 'View/Edit' })).toBeEnabled();
expect(
queryByRole('button', { name: 'Provide Training Cost' }),
).not.toBeInTheDocument();
// Typing all thirteen fields exceeds the default 5s timeout under load.
}, 20000);

it('keeps APPLY disabled until every cost is entered', async () => {
const screen = render(<TestComponent withoutCosts />);
const { findByRole } = screen;
await openModal(screen);
await openModal(screen, 'Provide Training Cost');

// The cohort has no saved costs, so the form opens blank.
expect(await findByRole('button', { name: 'Apply' })).toBeDisabled();
Expand Down
61 changes: 51 additions & 10 deletions src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { ErrorOutline } from '@mui/icons-material';
import {
Box,
Link,
MenuItem,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { useSnackbar } from 'notistack';
Expand Down Expand Up @@ -46,6 +48,10 @@ export const CohortBar: React.FC = () => {
} = useMpdGoalAdmin();
const [trainingCostsOpen, setTrainingCostsOpen] = useState(false);

// Only a loaded cohort can be short its costs; an absent one is still loading.
const needsTrainingCosts =
!!selectedCohort && !selectedCohort.hasTrainingCosts;
Comment thread
wjames111 marked this conversation as resolved.

const handleSaveTrainingCosts = async (costs: TrainingCosts) => {
if (!selectedCohort) {
return;
Expand All @@ -63,6 +69,38 @@ export const CohortBar: React.FC = () => {
setTrainingCostsOpen(false);
};

const trainingCostLink = (
<Link
component="button"
type="button"
underline="hover"
disabled={!selectedCohort}
onClick={() => setTrainingCostsOpen(true)}
onMouseEnter={preloadEditTrainingCostsModal}
sx={
needsTrainingCosts
? (theme) => ({
// MUI's warning palette is only 3.79:1 on white; the Cru vermilion
// token clears WCAG AA for body2's 14px text.
color: theme.palette.statusWarning.main,
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
})
: undefined
}
>
{needsTrainingCosts ? (
<>
<ErrorOutline fontSize="small" />
{t('Provide Training Cost')}
</>
) : (
t('View/Edit')
)}
</Link>
);

return (
<Stack
direction={{ xs: 'column', md: 'row' }}
Expand Down Expand Up @@ -92,16 +130,19 @@ export const CohortBar: React.FC = () => {
</Stat>
<Stat label={t('NSO Date')}>{selectedCohort?.nsoDate ?? '—'}</Stat>
<Stat label={t('Training Cost')}>
<Link
component="button"
type="button"
underline="hover"
disabled={!selectedCohort}
onClick={() => setTrainingCostsOpen(true)}
onMouseEnter={preloadEditTrainingCostsModal}
>
{t('View/Edit')}
</Link>
{/* Only the costs-missing branch is tooltipped, and a disabled child
would need a wrapper element for the tooltip to fire. */}
{needsTrainingCosts ? (
<Tooltip
// Without this the tooltip becomes the button's aria-label and hides its text.
describeChild
title={t('Training costs are required to run & send goals.')}
>
{trainingCostLink}
</Tooltip>
) : (
trainingCostLink
)}
</Stat>
{trainingCostsOpen && (
<DynamicEditTrainingCostsModal
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { render, waitFor } from '@testing-library/react';
import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
import theme from 'src/theme';
import { MpdGoalAdminProvider } from '../MpdGoalAdminContext';
import {
NewStaffCohortAttendeesQuery,
NewStaffCohortsQuery,
} from '../NewStaffCohorts.generated';
import { attendeesMock, cohortsMock } from '../mpdGoalAdminMocks';
import { GoalsSentBanner } from './GoalsSentBanner';

const onCall = jest.fn();

/** The same cohort, but never run and sent. */
const neverSentMock: NewStaffCohortsQuery = {
newStaffCohorts: {
...cohortsMock.newStaffCohorts,
nodes: [{ ...cohortsMock.newStaffCohorts.nodes[0], goalsSentAt: null }],
},
};

interface TestComponentProps {
neverSent?: boolean;
}

const TestComponent: React.FC<TestComponentProps> = ({ neverSent = false }) => (
<ThemeProvider theme={theme}>
<GqlMockedProvider<{
NewStaffCohorts: NewStaffCohortsQuery;
NewStaffCohortAttendees: NewStaffCohortAttendeesQuery;
}>
mocks={{
NewStaffCohorts: neverSent ? neverSentMock : cohortsMock,
NewStaffCohortAttendees: attendeesMock(),
}}
onCall={onCall}
>
<MpdGoalAdminProvider>
<GoalsSentBanner />
</MpdGoalAdminProvider>
</GqlMockedProvider>
</ThemeProvider>
);

describe('GoalsSentBanner', () => {
it('reports when the cohort goals were last run and sent', async () => {
const { findByRole } = render(<TestComponent />);

expect(await findByRole('status')).toHaveTextContent(
'Goals were last run and sent on 8/10/2026 at 3:40 PM UTC.',
);
});

it('renders nothing until the cohort has been sent at least once', async () => {
const { queryByRole } = render(<TestComponent neverSent />);

// The banner renders nothing here, so settle on the cohort query instead;
// asserting immediately would pass on the pre-load render either way.
await waitFor(() =>
expect(onCall).toHaveGraphqlOperation('NewStaffCohortAttendees'),
);
expect(queryByRole('status')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { Alert } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useLocale } from 'src/hooks/useLocale';
import { dateFormatShort, timeFormat } from 'src/lib/intlFormat';
import { useMpdGoalAdmin } from '../MpdGoalAdminContext';

/**
* Confirms the cohort's most recent Run & Send batch. Absent until the first
* send, so its absence is itself meaningful — don't render a placeholder.
*/
export const GoalsSentBanner: React.FC = () => {
const { t } = useTranslation();
const locale = useLocale();
const { selectedCohort } = useMpdGoalAdmin();
const goalsSentAt = selectedCohort?.goalsSentAt;

if (!goalsSentAt) {
return null;
}

return (
<Alert severity="success" role="status" sx={{ mb: 2 }}>
{t('Goals were last run and sent on {{date}} at {{time}}.', {
date: dateFormatShort(goalsSentAt, locale),
time: timeFormat(goalsSentAt, locale),
})}
</Alert>
);
};
11 changes: 11 additions & 0 deletions src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ describe('MpdGoalAdmin', () => {
expect(await findByText('John & Jane Doe')).toBeInTheDocument();
});

it('reports when the cohort goals were last run and sent', async () => {
const { findByText } = renderMain();

// Query the sentence, not role="status", which the null state also uses.
expect(
await findByText(
'Goals were last run and sent on 8/10/2026 at 3:40 PM UTC.',
),
).toBeInTheDocument();
});

it('shows a loading indicator until the attendees arrive', () => {
const { getByRole, queryByRole } = renderMain();

Expand Down
2 changes: 2 additions & 0 deletions src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from 'src/components/Shared/MultiPageLayout/MultiPageHeader';
import { getHeaderTitleAccess } from 'src/components/Shared/MultiPageLayout/helpers';
import { CohortBar } from './CohortBar/CohortBar';
import { GoalsSentBanner } from './GoalsSentBanner/GoalsSentBanner';
import { GoalsTable } from './GoalsTable/GoalsTable';
import { GoalsTableToolbar } from './GoalsTableToolbar/GoalsTableToolbar';
import { useMpdGoalAdmin } from './MpdGoalAdminContext';
Expand Down Expand Up @@ -57,6 +58,7 @@ const ActiveGoalsContent: React.FC = () => {

return (
<>
<GoalsSentBanner />
Comment thread
wjames111 marked this conversation as resolved.
<GoalsTableToolbar />
{/* Surface query failures here rather than as an empty table. */}
{error ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ query NewStaffCohorts($after: String) {
name
trainingSize
date
goalsSentAt
hasTrainingCosts
canRunAndSend
runAndSendBlockers
Expand Down
Loading
Loading