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
16 changes: 16 additions & 0 deletions src/common/dates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export function getYearMonthDate(date: Date) {
};
}

/**
* Parses a date-only string (YYYY-MM-DD) as local time, avoiding timezone issues.
*
* Using `new Date('2025-01-15')` parses as UTC midnight, which becomes the previous
* day in UTC-negative timezones (Americas). This function parses the date components
* directly to create a Date at local midnight, ensuring the same calendar day
* regardless of timezone.
*
* Use this for date-only strings from forms, API responses, or anywhere you need
* calendar dates without time/timezone considerations.
*
* @example
* // In New York (UTC-5):
* new Date('2025-01-15') // → 2025-01-14T19:00:00 (previous day!)
* parseLocalDate('2025-01-15') // → 2025-01-15T00:00:00 (correct day)
*/
export const parseLocalDate = (dateString: string) => {
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
Expand Down
6 changes: 5 additions & 1 deletion src/components/form/fields/FieldSetField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ export function FieldSetField({
const fieldNames = fields.map(
({ name: fieldName }) => `${name}.${fieldName}`,
);
const fieldsetNeedsAllFormValues = fields.some(
(field: $TSFixMe) => field.calculateDynamicProperties,
);
const watchedValues = watch(fieldNames);
const allFormValues = fieldsetNeedsAllFormValues ? watch() : watchedValues;
const prevValuesRef = useRef<string[]>(watchedValues);
const triggerTimeoutRef = useRef<NodeJS.Timeout | null>(null);

Expand Down Expand Up @@ -223,7 +227,7 @@ export function FieldSetField({
if (field.calculateDynamicProperties) {
field = {
...field,
...(field.calculateDynamicProperties(watchedValues, field) ||
...(field.calculateDynamicProperties(allFormValues, field) ||
Comment thread
gabrielseco marked this conversation as resolved.
{}),
};
}
Expand Down
22 changes: 21 additions & 1 deletion src/flows/ContractorOnboarding/jsfModify.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { format } from 'date-fns';
import { addYears, format } from 'date-fns';
import { FieldValues } from 'react-hook-form';
import { ChangeEvent } from 'react';

import { parseLocalDate } from '@/src/common/dates';
import { createStatementProperty } from '@/src/components/form/jsf-utils/createFields';
import { zendeskArticles } from '@/src/components/shared/zendesk-drawer/utils';
import { ZendeskTriggerButton } from '@/src/components/shared/zendesk-drawer/ZendeskTriggerButton';
Expand Down Expand Up @@ -126,6 +127,25 @@ export const buildContractDetailsJsfModify = (
...statement,
},
},
'service_duration.expiration_date': {
'x-jsf-presentation': {
calculateDynamicProperties: (formValues: FieldValues) => {
const maxDate =
isContractorOfRecord &&
formValues.service_duration?.provisional_start_date
? addYears(
parseLocalDate(
formValues.service_duration.provisional_start_date,
),
1,
)
Comment thread
cursor[bot] marked this conversation as resolved.
: undefined;
return {
maxDate: maxDate ? format(maxDate, 'yyyy-MM-dd') : undefined,
};
},
},
},
services_and_deliverables: {
onChange: onServicesAndDeliverablesChange,
'x-jsf-presentation': {
Expand Down
145 changes: 145 additions & 0 deletions src/flows/ContractorOnboarding/tests/ContractorOnboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3309,4 +3309,149 @@ describe('ContractorOnboardingFlow', () => {
expect(mockOnError).toHaveBeenCalled();
});
});

describe('COR Contract Duration MaxDate', () => {
it('should set maxDate to 12 months after provisional_start_date for COR', async () => {
const employmentId = generateUniqueEmploymentId();

server.use(
http.get(`*/v1/employments/${employmentId}`, () => {
return HttpResponse.json({
...mockContractorEmploymentResponse,
data: {
...mockContractorEmploymentResponse.data,
employment: {
...mockContractorEmploymentResponse.data.employment,
id: employmentId,
contractor_type: 'cor',
},
},
});
}),
);

mockRender.mockImplementation(
createMockRenderImplementation(MultiStepFormWithoutCountry),
);

render(
<ContractorOnboardingFlow
employmentId={employmentId}
countryCode='PRT'
skipSteps={['select_country']}
{...defaultProps}
/>,
{ wrapper: TestProviders },
);

await screen.findByText(/Step: Basic Information/i);
await waitForElementToBeRemoved(() => screen.getByTestId('spinner'));

await fillBasicInformation();

let nextButton = screen.getByText(/Next Step/i);
nextButton.click();

await screen.findByText(/Step: Pricing Plan/i);
await fillContractorSubscription('Contractor of Record');

nextButton = screen.getByText(/Next Step/i);
nextButton.click();

await screen.findByText(/Step: Eligibility Questionnaire/i);
await fillEligibilityQuestionnaire();

nextButton = screen.getByText(/Next Step/i);
nextButton.click();

await screen.findByText(/Step: Contract Details/i);

// Fill the provisional start date
const startDate = '2025-01-15';
await fillDatePickerByTestId(
startDate,
'service_duration.provisional_start_date',
);

// Get the expiration date field
const expirationDateField = screen.getByTestId(
'service_duration.expiration_date',
);
expect(expirationDateField).toBeInTheDocument();

// Verify maxDate is set (12 months after start date = 2026-01-15)
await waitFor(() => {
expect(expirationDateField).toHaveAttribute('max', '2026-01-15');
});
});

it('should not set maxDate for non-COR contractor types', async () => {
const employmentId = generateUniqueEmploymentId();

server.use(
http.get(`*/v1/employments/${employmentId}`, () => {
return HttpResponse.json({
...mockContractorEmploymentResponse,
data: {
...mockContractorEmploymentResponse.data,
employment: {
...mockContractorEmploymentResponse.data.employment,
id: employmentId,
contractor_type: 'cm',
},
},
});
}),
);

mockRender.mockImplementation(
createMockRenderImplementation(MultiStepFormWithoutCountry),
);

render(
<ContractorOnboardingFlow
employmentId={employmentId}
countryCode='PRT'
skipSteps={['select_country']}
{...defaultProps}
/>,
{ wrapper: TestProviders },
);

// Navigate to contract details step
await screen.findByText(/Step: Basic Information/i);
await waitForElementToBeRemoved(() => screen.getByTestId('spinner'));

await fillBasicInformation();

let nextButton = screen.getByText(/Next Step/i);
nextButton.click();

await screen.findByText(/Step: Pricing Plan/i);
await fillContractorSubscription('Contractor Management Plus');

nextButton = screen.getByText(/Next Step/i);
nextButton.click();

await screen.findByText(/Step: Contract Details/i);

// Fill the provisional start date
const startDate = '2025-01-15';
await fillDatePickerByTestId(
startDate,
'service_duration.provisional_start_date',
);

// Get the expiration date field
const expirationDateField = screen.getByTestId(
'service_duration.expiration_date',
);
expect(expirationDateField).toBeInTheDocument();

// Verify maxDate is NOT set for non-COR types
await waitFor(() => {
expect(expirationDateField).not.toHaveAttribute('max');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5975,7 +5975,7 @@ export const basicInformationSchemaV3Germany = {
blockedDates: [],
inputType: 'date',
meta: {
mot: 20,
mot: 0,
Comment thread
gabrielseco marked this conversation as resolved.
},
minDate: '2025-05-12',
softBlockedDates: [],
Expand Down
8 changes: 8 additions & 0 deletions src/tests/defaultComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { defaultComponents as defaultComponentsFromPackage } from '@/src/default
export const defaultComponents = {
...defaultComponentsFromPackage,
date: ({ field, fieldData, fieldState }: FieldComponentProps) => {
const maxDate = fieldData.maxDate
? new Date(fieldData.maxDate).toISOString().split('T')[0]
: undefined;
const minDate = fieldData.minDate
? new Date(fieldData.minDate).toISOString().split('T')[0]
: undefined;
return (
<div className='input-container'>
<label htmlFor={field.name}>{fieldData.label}</label>
Expand All @@ -16,6 +22,8 @@ export const defaultComponents = {
onChange={(e) => {
field?.onChange?.(e.target.value);
}}
{...(maxDate && { max: maxDate })}
{...(minDate && { min: minDate })}
/>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
Expand Down
Loading