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 @@ -17,6 +17,7 @@ query LandingSalaryCalculations {
}
effectiveCalculation: effectiveSalaryRequest {
id
personNumber
salary
spouseSalary
calculations {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import { AccountBalanceQuery } from '../AccountBalance.generated';
import { StaffAccountIdQuery } from '../StaffAccountId.generated';
import { LandingSalaryCalculationsQuery } from './LandingSalaryCalculations.generated';

interface LandingTestWrapperProps {
export interface LandingTestWrapperProps {
onCall?: MockLinkCallHandler;
children?: React.ReactNode;
hasInProgressCalculation?: boolean;
hasApprovedCalculation?: boolean;
hasSpouseApprovedCalculation?: boolean;
hasLatestCalculation?: boolean;
salaryRequestEligible?: boolean;
}
Expand All @@ -32,6 +33,7 @@ export const LandingTestWrapper: React.FC<LandingTestWrapperProps> = ({
children,
hasInProgressCalculation = false,
hasApprovedCalculation = false,
hasSpouseApprovedCalculation = false,
hasLatestCalculation = false,
salaryRequestEligible = true,
}) => (
Expand All @@ -51,6 +53,7 @@ export const LandingTestWrapper: React.FC<LandingTestWrapperProps> = ({
staffInfo: {
preferredName: 'John',
lastName: 'Doe',
personNumber: '000123456',
secaStatus: SecaStatusEnum.Seca,
peopleGroupSupportType:
PeopleGroupSupportTypeEnum.SupportedRmo,
Expand All @@ -75,6 +78,7 @@ export const LandingTestWrapper: React.FC<LandingTestWrapperProps> = ({
staffInfo: {
preferredName: 'Jane',
lastName: 'Doe',
personNumber: '000123457',
secaStatus: SecaStatusEnum.Seca,
},
currentSalary: {
Expand All @@ -98,6 +102,9 @@ export const LandingTestWrapper: React.FC<LandingTestWrapperProps> = ({
: null,
effectiveCalculation: hasApprovedCalculation
? {
personNumber: hasSpouseApprovedCalculation
? '000123457'
: '000123456',
salary: 50000,
spouseSalary: 60000,
calculations: { effectiveCap: 60000 },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LandingTestWrapper } from './LandingTestWrapper';
import {
LandingTestWrapper,
LandingTestWrapperProps,
} from './LandingTestWrapper';
import { NewSalaryCalculatorLanding } from './NewSalaryCalculatorLanding';

const mutationSpy = jest.fn();

interface TestComponentProps {
hasInProgressCalculation?: boolean;
hasApprovedCalculation?: boolean;
salaryRequestEligible?: boolean;
}

const TestComponent: React.FC<TestComponentProps> = ({
hasInProgressCalculation = false,
hasApprovedCalculation = false,
salaryRequestEligible,
}) => (
<LandingTestWrapper
hasInProgressCalculation={hasInProgressCalculation}
hasApprovedCalculation={hasApprovedCalculation}
salaryRequestEligible={salaryRequestEligible}
onCall={mutationSpy}
>
const TestComponent: React.FC<LandingTestWrapperProps> = (props) => (
<LandingTestWrapper {...props} onCall={mutationSpy}>
<NewSalaryCalculatorLanding />
</LandingTestWrapper>
);
Expand All @@ -45,18 +33,54 @@
expect(await findByTestId('amount-two')).toHaveTextContent('$10,000.00');
});

it('renders SalaryInformationCard with correct cell data', async () => {
const { findByRole } = render(<TestComponent />);
it('renders table with correct cell data', async () => {
Comment thread
canac marked this conversation as resolved.
const { getAllByRole } = render(<TestComponent hasApprovedCalculation />);

const expectedCells = [
['Maximum Allowable Salary', '$60,000.00', '$70,000.00'],
['Requested Salary', '$50,000.00', '$60,000.00'],
['Tax-deferred 403(b) Contribution', '5%', '6%'],
['Roth 403(b) Contribution', '12%', '10%'],
['Security (SECA/FICA) Status', 'Subject to SECA', 'Subject to SECA'],
['Current Gross Salary', '$55,000.00', '$10,000.00'],
[
'Current MHA (Included in Current Gross Salary)',
'$10,000.00View MHA Form',
'$12,000.00',
],
].flat();

// Self
expect(
await findByRole('heading', { name: '$55,000.00' }),
).toBeInTheDocument();
await waitFor(() =>
expect(getAllByRole('cell').map((cell) => cell.textContent)).toEqual(
expectedCells,
),
);
});

Check warning on line 58 in src/components/HrTools/SalaryCalculator/Landing/NewSalaryCalculationLanding/NewSalaryCalculatorLanding.test.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Code Duplication

The module contains 2 functions with similar structure: 'renders table with correct cell data','swaps effective request fields when the spouse created the request'. Avoid duplicated, aka copy-pasted, code inside the module. More duplication lowers the code health.

// Spouse
expect(
await findByRole('heading', { name: '$10,000.00' }),
).toBeInTheDocument();
it('swaps effective request fields when the spouse created the request', async () => {
const { getAllByRole } = render(
<TestComponent hasApprovedCalculation hasSpouseApprovedCalculation />,
);

const expectedCells = [
['Maximum Allowable Salary', '$70,000.00', '$60,000.00'],
['Requested Salary', '$60,000.00', '$50,000.00'],
['Tax-deferred 403(b) Contribution', '5%', '6%'],
['Roth 403(b) Contribution', '12%', '10%'],
['Security (SECA/FICA) Status', 'Subject to SECA', 'Subject to SECA'],
['Current Gross Salary', '$55,000.00', '$10,000.00'],
[
'Current MHA (Included in Current Gross Salary)',
'$10,000.00View MHA Form',
'$12,000.00',
],
].flat();

await waitFor(() =>
expect(getAllByRole('cell').map((cell) => cell.textContent)).toEqual(
expectedCells,
),
);
});

it('renders action button', async () => {
Expand Down
16 changes: 10 additions & 6 deletions src/components/HrTools/SalaryCalculator/Landing/useLandingData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { currencyFormat, percentageFormat } from 'src/lib/intlFormat';
import { type HcmQuery, useHcmQuery } from '../../Shared/HcmData/Hcm.generated';
import { getLocalizedTaxStatus } from '../Shared/getLocalizedTaxStatus';
import { orientSalaryRequest } from '../Shared/orientSalaryRequest';
import { useAccountBalanceQuery } from './AccountBalance.generated';
import {
type LandingSalaryCalculationsQuery,
Expand Down Expand Up @@ -63,188 +64,191 @@
useAccountBalanceQuery();
const { data: staffAccountIdData, loading: staffAccountIdLoading } =
useStaffAccountIdQuery();

const effectiveCalculation = calculationData?.effectiveCalculation;
const inProgressCalculationId =
calculationData?.inProgressCalculation?.id ?? null;
const latestCalculation = calculationData?.latestCalculation;

const { requestedOn, processedOn } = useMemo(
() => formatCalculationDates(latestCalculation ?? null, locale),
[latestCalculation, locale],
);

const shouldShowPending = useMemo(
() =>
latestCalculation?.status === SalaryRequestStatusEnum.Pending ||
latestCalculation?.status === SalaryRequestStatusEnum.ActionRequired,
[latestCalculation],
);

const feedback = useMemo(
() => latestCalculation?.feedback ?? null,
[latestCalculation],
);

const { self, spouse } = useMemo(() => {
// Using [undefined, undefined] instead of [] ensures that we handle the case where HCM returns
// an array with fewer than two elements
const [selfData, spouseData] = hcmData?.hcm ?? [undefined, undefined];
return {
self: selfData ?? null,
spouse: spouseData?.salaryRequestEligible ? (spouseData ?? null) : null,
};
}, [hcmData]);

const staffAccountId = useMemo(
() => staffAccountIdData?.user?.staffAccountId ?? null,
[staffAccountIdData],
);

const names = useMemo(() => {
if (!self?.staffInfo?.preferredName || !self?.staffInfo?.lastName) {
return '';
}
const selfName = `${self.staffInfo.lastName}, ${self.staffInfo.preferredName}`;
if (!spouse || !spouse?.staffInfo?.preferredName) {
return selfName;
}
return `${selfName} and ${spouse.staffInfo.preferredName}`;
}, [self, spouse]);

const salaryData = useMemo((): SalaryData => {
const currentGrossSalary = self?.currentSalary.grossSalaryAmount ?? 0;
const lastUpdated = self?.currentSalary.lastUpdated ?? '';
const spouseCurrentGrossSalary =
spouse?.currentSalary.grossSalaryAmount ?? 0;

const rothContribution =
self?.fourOThreeB.currentRothContributionPercentage ?? 0;
const spouseRothContribution =
spouse?.fourOThreeB.currentRothContributionPercentage ?? 0;

const taxDeferredContribution =
self?.fourOThreeB.currentTaxDeferredContributionPercentage ?? 0;
const spouseTaxDeferredContribution =
spouse?.fourOThreeB.currentTaxDeferredContributionPercentage ?? 0;

const takenMha = self?.mhaRequest.currentTakenAmount ?? 0;
const spouseTakenMha = spouse?.mhaRequest.currentTakenAmount ?? 0;

return {
currentGrossSalary,
lastUpdated,
spouseCurrentGrossSalary,
rothContribution,
spouseRothContribution,
taxDeferredContribution,
spouseTaxDeferredContribution,
takenMha,
spouseTakenMha,
};
}, [self, spouse]);

const accountBalance = useMemo(
() =>
accountBalanceData?.reportsStaffExpenses?.funds?.reduce(
(sum, fund) => sum + (fund.total ?? 0),
0,
) ?? 0,
[accountBalanceData],
);

const salaryCategories = useMemo<SalaryCategory[]>(
() => [
const salaryCategories = useMemo<SalaryCategory[]>(() => {
const effectiveCalculation = orientSalaryRequest(
calculationData?.effectiveCalculation,
self?.staffInfo.personNumber,
);

return [
{
category: t('Maximum Allowable Salary'),
user: effectiveCalculation?.calculations.effectiveCap
? currencyFormat(
effectiveCalculation.calculations.effectiveCap,
'USD',
locale,
{ showTrailingZeros: true },
)
: 'TBD',
spouse: effectiveCalculation?.spouseCalculations?.effectiveCap
? currencyFormat(
effectiveCalculation.spouseCalculations?.effectiveCap,
'USD',
locale,
{ showTrailingZeros: true },
)
: 'TBD',
},
{
category: t('Requested Salary'),
user: effectiveCalculation?.salary
? currencyFormat(effectiveCalculation.salary, 'USD', locale, {
showTrailingZeros: true,
})
: 'TBD',
spouse: effectiveCalculation?.spouseSalary
? currencyFormat(effectiveCalculation.spouseSalary, 'USD', locale, {
showTrailingZeros: true,
})
: 'TBD',
tooltip: t(
'Requested Salary includes MHA and taxes if applicable. It does not include 403(b) Contributions and SECA.',
),
},
{
category: t('Tax-deferred 403(b) Contribution'),
user: salaryData.taxDeferredContribution
? percentageFormat(salaryData.taxDeferredContribution / 100, locale)
: '-',
spouse: salaryData.spouseTaxDeferredContribution
? percentageFormat(
salaryData.spouseTaxDeferredContribution / 100,
locale,
)
: '-',
},
{
category: t('Roth 403(b) Contribution'),
user: salaryData.rothContribution
? percentageFormat(salaryData.rothContribution / 100, locale)
: '-',
spouse: salaryData.spouseRothContribution
? percentageFormat(salaryData.spouseRothContribution / 100, locale)
: '-',
},
{
category: t('Security (SECA/FICA) Status'),
user: getLocalizedTaxStatus(self?.staffInfo.secaStatus, t),
spouse: getLocalizedTaxStatus(spouse?.staffInfo.secaStatus, t),
},
{
category: t('Current Gross Salary'),
user: salaryData.currentGrossSalary
? currencyFormat(salaryData.currentGrossSalary, 'USD', locale, {
showTrailingZeros: true,
})
: '-',
spouse: salaryData.spouseCurrentGrossSalary
? currencyFormat(salaryData.spouseCurrentGrossSalary, 'USD', locale, {
showTrailingZeros: true,
})
: '-',
tooltip: t(
'Current Gross Salary includes MHA, 403(b), SECA, and taxes if applicable.',
),
},
{
category: t('Current MHA (Included in Current Gross Salary)'),
user: currencyFormat(salaryData.takenMha, 'USD', locale, {
showTrailingZeros: true,
}),
spouse: currencyFormat(salaryData.spouseTakenMha, 'USD', locale, {
showTrailingZeros: true,
}),
link: '/hrTools/mhaCalculator',
},
],
[t, salaryData, self, spouse, effectiveCalculation, locale],
);
];
}, [t, salaryData, self, spouse, calculationData, locale]);

Check warning on line 251 in src/components/HrTools/SalaryCalculator/Landing/useLandingData.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

useLandingData increases in cyclomatic complexity from 57 to 58, threshold = 9. 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.

return {
staffAccountId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ query SalaryCalculation($id: ID!) {
query EffectiveSalaryCalculation {
salaryRequest: effectiveSalaryRequest {
id
personNumber
mhaAmount
spouseMhaAmount
salary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const hcmMock = gqlMock<HcmQuery, HcmQueryVariables>(HcmDocument, {
staffInfo: {
preferredName: 'John',
lastName: 'Doe',
personNumber: '000123456',
city: 'Miami',
country: 'US',
state: 'FL',
Expand Down Expand Up @@ -62,6 +63,7 @@ const hcmMock = gqlMock<HcmQuery, HcmQueryVariables>(HcmDocument, {
staffInfo: {
preferredName: 'Jane',
lastName: 'Doe',
personNumber: '000789123',
tenure: 1000,
primaryPhoneNumber: '555-0124',
emailAddress: 'jane.doe@example.com',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { orientSalaryRequest } from './orientSalaryRequest';

const baseRequest = {
personNumber: 'user-1',
salary: 10001,
spouseSalary: 20001,
mhaAmount: 10002,
spouseMhaAmount: 20003,
salaryCap: 10003,
spouseSalaryCap: 20003,
calculations: { effectiveCap: 10004, contributing403bFraction: 0.15 },
spouseCalculations: { effectiveCap: 20005, contributing403bFraction: 0.25 },
};

describe('orientSalaryRequest', () => {
it('returns the request unchanged when person numbers match', () => {
expect(orientSalaryRequest(baseRequest, 'user-1')).toEqual(baseRequest);
});

it('swaps user and spouse fields when the spouse created the request', () => {
const oriented = orientSalaryRequest(baseRequest, 'user-2');

expect(oriented).toEqual({
personNumber: 'user-2',
salary: 20001,
spouseSalary: 10001,
mhaAmount: 20003,
spouseMhaAmount: 10002,
salaryCap: 20003,
spouseSalaryCap: 10003,
calculations: {
effectiveCap: 20005,
contributing403bFraction: 0.25,
},
spouseCalculations: {
effectiveCap: 10004,
contributing403bFraction: 0.15,
},
});
});

it('passes through nullish requests', () => {
expect(orientSalaryRequest(null, 'user-1')).toBeNull();
expect(orientSalaryRequest(undefined, 'user-1')).toBeUndefined();
});

it('returns the request unchanged when the user person number is missing', () => {
expect(orientSalaryRequest(baseRequest, null)).toEqual(baseRequest);
expect(orientSalaryRequest(baseRequest, undefined)).toEqual(baseRequest);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type {
SalaryRequest,
SalaryRequestCalculations,
} from 'src/graphql/types.generated';

type SwappableSalaryRequest = Partial<
Pick<
SalaryRequest,
| 'personNumber'
| 'salary'
| 'spouseSalary'
| 'mhaAmount'
| 'spouseMhaAmount'
| 'salaryCap'
| 'spouseSalaryCap'
>
> & {
calculations?: Partial<SalaryRequestCalculations> | null;
spouseCalculations?: Partial<SalaryRequestCalculations> | null;
};

/**
* The database stores salary requests from the perspective of the person who created it. This
* helper detects when the request was created by the spouse and swaps the fields to be from the
* user's perspective.
*/
export const orientSalaryRequest = <Request extends SwappableSalaryRequest>(
request: Request | null | undefined,
userPersonNumber: string | null | undefined,
): Request | null | undefined => {
if (
!request?.personNumber ||
!userPersonNumber ||

Check warning on line 33 in src/components/HrTools/SalaryCalculator/Shared/orientSalaryRequest.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Complex Conditional

orientSalaryRequest has 1 complex conditionals with 2 branches, threshold = 2. A complex conditional is an expression inside a branch (e.g. if, for, while) which consists of multiple, logical operators such as AND/OR. The more logical operators in an expression, the more severe the code smell.
request.personNumber === userPersonNumber
) {
return request;
}

// The person number of the request doesn't match the user's person number, so swap the fields
return {
...request,
personNumber: userPersonNumber,
salary: request.spouseSalary,
spouseSalary: request.salary,
mhaAmount: request.spouseMhaAmount,
spouseMhaAmount: request.mhaAmount,
salaryCap: request.spouseSalaryCap,
spouseSalaryCap: request.salaryCap,
calculations: request.spouseCalculations,
spouseCalculations: request.calculations,
};
};
Loading
Loading