Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ export interface AutosaveTextFieldProps
> {
fieldName: Exclude<keyof SalaryRequestUpdateInput, 'manuallySplitCap'>;
schema: yup.Schema;
/** Additional error flag from the parent; OR'd with the field's own validation error */
error?: boolean;

/**
* Save on every keystroke instead of on blur. Defaults to true for select
* boxes; opt-in for text inputs where immediate feedback is required.
*/
saveOnChange?: boolean;
}

export const AutosaveTextField: React.FC<AutosaveTextFieldProps> = ({
fieldName,
schema,
error: externalError,
saveOnChange,
...props
}) => {
const saveField = useSaveField();
Expand All @@ -28,10 +38,17 @@ export const AutosaveTextField: React.FC<AutosaveTextFieldProps> = ({
saveValue: (value) => saveField({ [fieldName]: value }),
fieldName,
schema,
// Select boxes should save on change instead of on blur
saveOnChange: props.select,
saveOnChange: saveOnChange ?? !!props.select,
disabled: !calculation,
});

return <TextField size="small" fullWidth {...fieldProps} {...props} />;
return (
<TextField
size="small"
fullWidth
{...fieldProps}
{...props}
error={!!externalError || !!fieldProps.error}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, waitFor } from '@testing-library/react';

Check warning on line 1 in src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.test.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Code Duplication

The module contains 4 functions with similar structure: 'renders married status message','renders single status message and textfield','renders status message and textfield','renders status message and textfield'. Avoid duplicated, aka copy-pasted, code inside the module. More duplication lowers the code health.
import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated';
import { ProgressiveApprovalTierEnum } from 'src/graphql/types.generated';
import {
SalaryCalculatorTestWrapper,
Expand Down Expand Up @@ -71,6 +72,40 @@
});
});

describe('SOSA blockOnCap', () => {
const sosaUser = {
staffInfo: {
userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse,
},
};
const overCapMock = {
calculations: { requestedGross: 40000, effectiveCap: 30000 },
progressiveApprovalTier: {
tier: ProgressiveApprovalTierEnum.VicePresident,
},
};

it('renders nothing when the SOSA user is over their effective cap', async () => {
const { queryByRole } = render(
<TestComponent hcmUser={sosaUser} salaryRequestMock={overCapMock} />,
);

await waitFor(() =>
expect(queryByRole('textbox')).not.toBeInTheDocument(),
);
});

it('renders the card when the user is not SOSA, even if over cap', async () => {
const { findByRole } = render(
<TestComponent salaryRequestMock={overCapMock} />,
);

expect(
await findByRole('textbox', { name: 'Additional info' }),
).toBeInTheDocument();
});
});

describe('combined over cap', () => {
it('renders single status message and textfield', async () => {
const { getByRole, getByTestId } = render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalcula
import { StepCard } from '../../Shared/StepCard';
import { StyledCardHeader } from '../StyledCardHeader';
import { useCaps } from '../useCaps';
import { useSosaBlockOverCap } from '../useSosaBlockOverCap';

export const ApprovalProcessCard: React.FC = () => {
const { t } = useTranslation();
const { calculation, hcmSpouse } = useSalaryCalculator();
const { overCapPerson } = useCaps();
const { blockOnCap } = useSosaBlockOverCap();

const schema = useMemo(
() =>
Expand All @@ -26,7 +28,7 @@ export const ApprovalProcessCard: React.FC = () => {
const spouseName = hcmSpouse?.staffInfo.preferredName;

const tier = calculation?.progressiveApprovalTier?.tier;
if (!tier) {
if (!tier || blockOnCap) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { DeepPartial } from 'ts-essentials';
import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated';
import { AutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm';
import { SalaryCalculationQuery } from '../../SalaryCalculatorContext/SalaryCalculation.generated';
import {
SalaryCalculatorTestWrapper,
Expand Down Expand Up @@ -33,7 +35,9 @@ const TestComponent: React.FC<SalaryCalculatorTestWrapperProps> = (props) => (
}}
{...props}
>
<RequestedSalaryCard />
<AutosaveForm>
<RequestedSalaryCard />
</AutosaveForm>
</SalaryCalculatorTestWrapper>
);

Expand Down Expand Up @@ -164,4 +168,56 @@ As you set your salary level, the amount you receive should reflect the amount o
);
});
});

describe('SOSA over-cap Alert', () => {
Comment thread
zweatshirt marked this conversation as resolved.
const sosaUser = {
staffInfo: {
userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse,
},
};
const overCapMock: DeepPartial<SalaryCalculationQuery['salaryRequest']> = {
calculations: {
minimumRequiredSalary: 10002,
minimumRequestedSalary: 10003,
effectiveCap: 10004,
requestedGross: 15000,
},
};

it('renders when the SOSA user is over their effective cap', async () => {
const { findByRole } = render(
<TestComponent
hasSpouse={false}
hcmUser={{ ...sosaUser, currentSalary: { grossSalaryAmount: 10001 } }}
salaryRequestMock={overCapMock}
/>,
);

expect(await findByRole('alert')).toHaveTextContent(
'Your request requires additional approvals and cannot be submitted online. SOSA staff can have requests exceeding the $10,004.00 cap approved for certain geographic locations with the appropriate levels of approval.Please contact payroll@cru.org for further assistance.',
);
expect(
await findByRole('link', { name: 'payroll@cru.org' }),
).toHaveAttribute('href', 'mailto:payroll@cru.org');
});

it('does not render when the user is SOSA but under their cap', async () => {
const { queryByRole } = render(
<TestComponent
hasSpouse={false}
hcmUser={{ ...sosaUser, currentSalary: { grossSalaryAmount: 10001 } }}
/>,
);

await waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument());
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] **Negative-Alert tests assert absence without anchoring to a positive render first.** The `waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument())` will pass on the first tick before the salary-calculation query resolves. A regression that delays the Alert past the initial render would slip through. Awaiting some other rendered element (e.g., a rowheader cell) before the absence assertion would catch that.

Flagged by Standards.

});

it('does not render when the user is not SOSA, even if over cap', async () => {
const { queryByRole } = render(
<TestComponent hasSpouse={false} salaryRequestMock={overCapMock} />,
);

await waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument());
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import {
Alert,
CardContent,
CardHeader,
Link,
Table,
TableBody,
TableCell,
Expand All @@ -10,13 +12,16 @@
} from '@mui/material';
import { Trans, useTranslation } from 'react-i18next';
import * as yup from 'yup';
import { useAutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm';
import { amount } from 'src/lib/yupHelpers';
import { AutosaveTextField } from '../../Autosave/AutosaveTextField';
import { CalculationFieldsFragment } from '../../SalaryCalculatorContext/SalaryCalculation.generated';
import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalculatorContext';
import { EffectiveDateNote } from '../../Shared/EffectiveDateNote';
import { StepCard, StepTableHead } from '../../Shared/StepCard';
import { useFormatters } from '../../Shared/useFormatters';
import { useCaps } from '../useCaps';
import { useSosaBlockOverCap } from '../useSosaBlockOverCap';

export const RequestedSalaryCard: React.FC = () => {
const { t } = useTranslation();
Expand All @@ -26,155 +31,186 @@
hcmSpouse,
} = useSalaryCalculator();
const { formatCurrency } = useFormatters();
const { overCapPerson } = useCaps();
const { isUserSosa, blockOnCap } = useSosaBlockOverCap();
const { markValid, markInvalid } = useAutosaveForm();

// Disable the Continue button while the saved gross exceeds the SOSA cap.
useEffect(() => {
if (blockOnCap) {
markInvalid('over-cap');
} else {
markValid('over-cap');
}
return () => markValid('over-cap');
}, [blockOnCap, markValid, markInvalid]);

const minimumSalaryValue =
salaryCalculation?.calculations.minimumRequestedSalary;
const spouseMinimumSalaryValue =
salaryCalculation?.spouseCalculations?.minimumRequestedSalary;

const minimumSalary = formatCurrency(minimumSalaryValue);
const spouseMinimumSalary = formatCurrency(spouseMinimumSalaryValue);

const renderFormula = (
calculations: CalculationFieldsFragment | null | undefined,
) =>
t('MHA + {{ min }} - SECA', {
min: formatCurrency(calculations?.minimumRequiredSalary),
});
const formula = renderFormula(salaryCalculation?.calculations);
const spouseFormula = renderFormula(salaryCalculation?.spouseCalculations);

const schema = useMemo(
() =>
yup.object({
salary: amount(t('Requested salary'), t, {
required: true,
min: minimumSalaryValue,
minMessage: t('Requested salary must be at least {{min}}', {
min: minimumSalary,
}),
}),
spouseSalary: amount(t('Spouse requested salary'), t, {
required: true,
min: spouseMinimumSalaryValue,
minMessage: t('Spouse requested salary must be at least {{min}}', {
min: spouseMinimumSalary,
}),
}),
}),
[
t,
minimumSalaryValue,
minimumSalary,
spouseMinimumSalaryValue,
spouseMinimumSalary,
],
);

return (
<StepCard>
<CardHeader
title={t('Requested Salary')}
subheader={<EffectiveDateNote />}
/>
<CardContent>
<Typography variant="body1" data-testid="RequestedSalaryCard-message">
<Trans t={t}>
Below, enter the annual salary amount you would like to request.
This salary level includes taxes (local, state, and federal) and
Minister&apos;s Housing Allowance. It does not include either Social
Security (SECA) or 403b. They will be added in later.{' '}
</Trans>
{hcmSpouse ? (
<Trans t={t}>
Because of IRS and Cru requirements, the lowest salary you can
request is {{ minimumSalary }} ({{ formula }}) for{' '}
{{ name: hcmUser?.staffInfo.preferredName }} and{' '}
{{ spouseMinimumSalary }} ({{ spouseFormula }}) for{' '}
{{ spouseName: hcmSpouse?.staffInfo.preferredName }}.
</Trans>
) : (
<Trans t={t}>
Because of IRS and Cru requirements, the lowest salary you can
request is {{ minimumSalary }} ({{ formula }}).
</Trans>
)}{' '}
<Trans t={t}>
As you set your salary level, the amount you receive should reflect
the amount of time you work in ministry.
</Trans>
</Typography>

<Table>
<StepTableHead />
<TableBody>
<TableRow>
<TableCell component="th" scope="row">
{t('Current Salary')}
</TableCell>
<TableCell>
{formatCurrency(hcmUser?.currentSalary.grossSalaryAmount)}
</TableCell>
{hcmSpouse && (
<TableCell>
{formatCurrency(hcmSpouse.currentSalary.grossSalaryAmount)}
</TableCell>
)}
</TableRow>

<TableRow>
<TableCell component="th" scope="row">
{t('Minimum Salary')}
</TableCell>
<TableCell>{formatCurrency(minimumSalaryValue)}</TableCell>
{hcmSpouse && (
<TableCell>
{formatCurrency(spouseMinimumSalaryValue)}
</TableCell>
)}
</TableRow>

<TableRow>
<TableCell component="th" scope="row">
{t('Maximum Allowable Salary (CAP)')}
</TableCell>
<TableCell>
{formatCurrency(salaryCalculation?.calculations.effectiveCap)}
</TableCell>
{hcmSpouse && (
<TableCell>
{formatCurrency(
salaryCalculation?.spouseCalculations?.effectiveCap,
)}
</TableCell>
)}
</TableRow>

<TableRow>
<TableCell component="th" scope="row">
{t('Requested Salary')}
</TableCell>
<TableCell>
<AutosaveTextField
fieldName="salary"
schema={schema}
label={t('Requested salary')}
required
error={blockOnCap}
saveOnChange={isUserSosa}
/>
</TableCell>
{hcmSpouse && (
<TableCell>
<AutosaveTextField
fieldName="spouseSalary"
schema={schema}
label={t('Spouse requested salary')}
required
/>
</TableCell>
)}
</TableRow>
</TableBody>
</Table>

{blockOnCap && (
<Alert severity="error" sx={{ mt: 2 }}>
<Trans t={t}>
Your request requires additional approvals and cannot be submitted
online. SOSA staff can have requests exceeding the{' '}
{{ cap: overCapPerson?.effectiveCap }} cap approved for certain
geographic locations with the appropriate levels of approval.
<br />
<br />
Please contact{' '}
<Link href="mailto:payroll@cru.org">payroll@cru.org</Link> for
further assistance.
</Trans>
</Alert>
)}

Check warning on line 213 in src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

RequestedSalaryCard:React.FC increases in cyclomatic complexity from 18 to 21, threshold = 15. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
</CardContent>
</StepCard>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext';
import { useFormatters } from '../Shared/useFormatters';

interface OverCapPerson {
export interface OverCapPerson {
/** The name of the person whose salary is over their effective cap */
name: string | null;

Expand All @@ -13,6 +13,9 @@ interface UseCapsResult {
/** The sum of the users' requested gross salaries */
combinedGross: number;

/** Whether the user is over their effective cap */
overUserCap: boolean;

/** The person whose salary is over their effective cap */
overCapPerson: OverCapPerson | null;
}
Expand Down Expand Up @@ -44,6 +47,7 @@ export const useCaps = (): UseCapsResult => {

return {
combinedGross,
overUserCap,
overCapPerson,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { DeepPartial } from 'ts-essentials';
import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated';
import { SalaryCalculationQuery } from '../SalaryCalculatorContext/SalaryCalculation.generated';
import { SalaryCalculatorTestWrapper } from '../SalaryCalculatorTestWrapper';
import { useSosaBlockOverCap } from './useSosaBlockOverCap';

const sosaUser = {
staffInfo: { userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse },
};

const overCapMock: DeepPartial<SalaryCalculationQuery['salaryRequest']> = {
calculations: { effectiveCap: 10004, requestedGross: 15000 },
};

const renderUseSosaBlockOverCap = (
props: Parameters<typeof SalaryCalculatorTestWrapper>[0],
) =>
renderHook(() => useSosaBlockOverCap(), {
wrapper: ({ children }) => (
<SalaryCalculatorTestWrapper {...props}>
{children}
</SalaryCalculatorTestWrapper>
),
});

describe('useSosaBlockOverCap', () => {
it('blocks when a SOSA user is over their cap', async () => {
const { result } = renderUseSosaBlockOverCap({
hasSpouse: false,
hcmUser: sosaUser,
salaryRequestMock: overCapMock,
});

await waitFor(() => expect(result.current.blockOnCap).toBe(true));
});

it('does not block when a SOSA user is under their cap', async () => {
const { result } = renderUseSosaBlockOverCap({
hasSpouse: false,
hcmUser: sosaUser,
salaryRequestMock: {
calculations: { effectiveCap: 10004, requestedGross: 10003 },
},
});

await waitFor(() => expect(result.current.isUserSosa).toBe(true));
expect(result.current.blockOnCap).toBe(false);
});

it('does not block when a SOSA user is exactly at their cap', async () => {
const { result } = renderUseSosaBlockOverCap({
hasSpouse: false,
hcmUser: sosaUser,
salaryRequestMock: {
calculations: { effectiveCap: 10004, requestedGross: 10004 },
},
});

await waitFor(() => expect(result.current.isUserSosa).toBe(true));
expect(result.current.blockOnCap).toBe(false);
});

it('does not block when the user is not SOSA, even if over cap', async () => {
const { result } = renderUseSosaBlockOverCap({
hasSpouse: false,
salaryRequestMock: overCapMock,
});

await waitFor(() => expect(result.current.isUserSosa).toBe(false));
expect(result.current.blockOnCap).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated';
import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext';
import { useCaps } from './useCaps';

interface UseSosaBlockOverCapResult {
/** Whether the user is a SOSA employee (Employee Staff Non-RMO Spouse) */
isUserSosa: boolean;

/** Whether the user is a SOSA employee whose saved gross exceeds their effective cap */
blockOnCap: boolean;
}

/**
* Determine whether a SOSA user's requested gross salary exceeds their
* effective cap. Requests over their cap may not be submitted online.
*/
export const useSosaBlockOverCap = (): UseSosaBlockOverCapResult => {
const { hcmUser } = useSalaryCalculator();
const { overUserCap } = useCaps();

const isUserSosa =
hcmUser?.staffInfo.userPersonType ===
UserPersonTypeEnum.EmployeeStaffNonRmoSpouse;
const blockOnCap = isUserSosa && overUserCap;

return { isUserSosa, blockOnCap };
};
Loading