Skip to content

Commit

Permalink
PSP-8312 Start date not mandatory for non-active leases (#4123)
Browse files Browse the repository at this point in the history
* PSP-8312 Enforce start date as required only for ACTIVE leases; optional for other statuses

* Code cleanup

* Fixes to form validation logic

* Cleanup

* Test updates
  • Loading branch information
asanchezr committed Jun 19, 2024
1 parent 8ddd604 commit 63114e0
Show file tree
Hide file tree
Showing 12 changed files with 289 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Pims.Api.Helpers.Exceptions;
using Pims.Api.Models.Concepts.AcquisitionFile;
using Pims.Api.Models.Concepts.File;
using Pims.Api.Models.Concepts.Lease;
using Pims.Api.Policies;
Expand Down Expand Up @@ -174,9 +173,9 @@ public IActionResult GetLeaseChecklistItems([FromRoute] long id)
[ProducesResponseType(typeof(LeaseModel), 200)]
[SwaggerOperation(Tags = new[] { "lease" })]
[TypeFilter(typeof(NullJsonResultFilter))]
public IActionResult UpdateLeaseChecklist([FromRoute]long id, [FromBody] IList<FileChecklistItemModel> checklistItems)
public IActionResult UpdateLeaseChecklist([FromRoute] long id, [FromBody] IList<FileChecklistItemModel> checklistItems)
{
if(checklistItems.Any(x => x.FileId != id))
if (checklistItems.Any(x => x.FileId != id))
{
throw new BadRequestException("All checklist items file id must match the Lease's id");
}
Expand Down
12 changes: 5 additions & 7 deletions source/frontend/src/features/leases/add/AddLeaseForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,11 @@ const AddLeaseForm: React.FunctionComponent<React.PropsWithChildren<IAddLeaseFor
>
{formikProps => (
<>
<>
<LeaseDetailSubForm formikProps={formikProps}></LeaseDetailSubForm>
<LeasePropertySelector formikProps={formikProps} />
<AdministrationSubForm formikProps={formikProps}></AdministrationSubForm>
<ConsultationSubForm formikProps={formikProps}></ConsultationSubForm>
<DocumentationSubForm />
</>
<LeaseDetailSubForm formikProps={formikProps}></LeaseDetailSubForm>
<LeasePropertySelector formikProps={formikProps} />
<AdministrationSubForm formikProps={formikProps}></AdministrationSubForm>
<ConsultationSubForm formikProps={formikProps}></ConsultationSubForm>
<DocumentationSubForm />
</>
)}
</Formik>
Expand Down
14 changes: 12 additions & 2 deletions source/frontend/src/features/leases/add/AddLeaseYupSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@
import * as Yup from 'yup';

import { ApiGen_CodeTypes_LeaseStatusTypes } from '@/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes';
import { exists } from '@/utils';

import { isLeaseCategoryVisible } from './AdministrationSubForm';

export const AddLeaseYupSchema = Yup.object().shape({
statusTypeCode: Yup.string().required('Required'),
startDate: Yup.date().required('Required'),
expiryDate: Yup.date().min(Yup.ref('startDate'), 'Expiry Date must be after Start Date'),
startDate: Yup.date().when('statusTypeCode', {
is: (statusTypeCode: string) =>
statusTypeCode && statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.ACTIVE.toString(),
then: Yup.date().required('Required'),
otherwise: Yup.date().nullable(),
}),
expiryDate: Yup.date().when('startDate', {
is: (startDate: Date) => exists(startDate),
then: Yup.date().min(Yup.ref('startDate'), 'Expiry Date must be after Start Date'),
otherwise: Yup.date().nullable(),
}),
paymentReceivableTypeCode: Yup.string().required('Payment Receivable Type is required'),
regionId: Yup.string().required('MOTI Region Type is required'),
programTypeCode: Yup.string().required('Program Type is required'),
Expand Down
247 changes: 219 additions & 28 deletions source/frontend/src/features/leases/add/LeaseDetailSubForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Formik } from 'formik';
import { Formik, FormikProps } from 'formik';
import { createMemoryHistory } from 'history';
import noop from 'lodash/noop';

Expand All @@ -8,17 +8,18 @@ import { lookupCodesSlice } from '@/store/slices/lookupCodes';
import {
act,
fillInput,
fireEvent,
renderAsync,
render,
RenderOptions,
screen,
userEvent,
waitFor,
} from '@/utils/test-utils';

import { getDefaultFormLease } from '../models';
import { getDefaultFormLease, LeaseFormModel } from '../models';
import { AddLeaseYupSchema } from './AddLeaseYupSchema';
import LeaseDetailSubForm, { ILeaseDetailsSubFormProps } from './LeaseDetailSubForm';
import LeaseDetailSubForm from './LeaseDetailSubForm';
import { ApiGen_CodeTypes_LeaseStatusTypes } from '@/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes';
import React from 'react';

const history = createMemoryHistory();
const storeState = {
Expand All @@ -31,12 +32,14 @@ const mockUseProjectTypeahead = vi.mocked(useProjectTypeahead);
const handleTypeaheadSearch = vi.fn();

describe('LeaseDetailSubForm component', () => {
const setup = async (renderOptions: RenderOptions & Partial<ILeaseDetailsSubFormProps> = {}) => {
const setup = async (renderOptions: RenderOptions & { initialValues?: LeaseFormModel } = {}) => {
// render component under test
const component = await renderAsync(
<Formik
const formikRef = React.createRef<FormikProps<LeaseFormModel>>();
const utils = render(
<Formik<LeaseFormModel>
innerRef={formikRef}
onSubmit={noop}
initialValues={getDefaultFormLease()}
initialValues={renderOptions.initialValues ?? getDefaultFormLease()}
validationSchema={AddLeaseYupSchema}
>
{formikProps => <LeaseDetailSubForm formikProps={formikProps} />}
Expand All @@ -49,20 +52,22 @@ describe('LeaseDetailSubForm component', () => {
);

return {
...component,
...utils,
formikRef,
// Finding elements
getStatusDropDown: () =>
component.container.querySelector(`select[name="statusTypeCode"]`) as HTMLInputElement,
getProjectSelector: () => {
getStatusDropDown: (): HTMLSelectElement => {
return document.querySelector(`select[name="statusTypeCode"]`);
},
getProjectSelector: (): HTMLElement => {
return document.querySelector(`input[name="typeahead-project"]`);
},
findProjectSelectorItems: async () => {
return document.querySelectorAll(`a[id^="typeahead-project-item"]`);
},
getTerminationReason: () => {
getTerminationReason: (): HTMLElement => {
return document.querySelector(`textarea[name="terminationReason"]`);
},
getCancellationReason: () => {
getCancellationReason: (): HTMLElement => {
return document.querySelector(`textarea[name="cancellationReason"]`);
},
};
Expand Down Expand Up @@ -94,10 +99,61 @@ describe('LeaseDetailSubForm component', () => {
expect(asFragment()).toMatchSnapshot();
});

it.each([
[ApiGen_CodeTypes_LeaseStatusTypes.ACTIVE, true],
[ApiGen_CodeTypes_LeaseStatusTypes.ARCHIVED, false],
[ApiGen_CodeTypes_LeaseStatusTypes.DISCARD, false],
[ApiGen_CodeTypes_LeaseStatusTypes.DRAFT, false],
[ApiGen_CodeTypes_LeaseStatusTypes.DUPLICATE, false],
[ApiGen_CodeTypes_LeaseStatusTypes.INACTIVE, false],
[ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED, false],
])(
'checks that start date is required only for ACTIVE leases - %s',
async (leaseStatus: string, startDateRequired: boolean) => {
const { container, getStatusDropDown, findByText, queryByText } = await setup({});

await act(async () => userEvent.selectOptions(getStatusDropDown(), leaseStatus));
await act(async () => {
fillInput(container, 'startDate', '', 'datepicker');
});

if (startDateRequired) {
expect(await findByText('Required')).toBeVisible();
} else {
expect(queryByText('Required')).toBeNull();
}
},
);

it('checks that expiry date must be later than start date', async () => {
// start date is not mandatory for DRAFT leases
const { queryByText, container } = await setup({
initialValues: {
...getDefaultFormLease(),
statusTypeCode: ApiGen_CodeTypes_LeaseStatusTypes.DRAFT,
},
});

await act(async () => {
fillInput(container, 'startDate', '', 'datepicker');
});
await act(async () => {
fillInput(container, 'expiryDate', '01/01/2010', 'datepicker');
});

expect(queryByText('Expiry Date must be after Start Date')).toBeNull();
});

it(`doesn't enforce expiry date logic when start date is not mandatory`, async () => {
const { findByText, container } = await setup({});
await fillInput(container, 'startDate', '01/02/2020', 'datepicker');
await fillInput(container, 'expiryDate', '01/01/2020', 'datepicker');

await act(async () => {
fillInput(container, 'startDate', '01/02/2025', 'datepicker');
});
await act(async () => {
fillInput(container, 'expiryDate', '01/01/2010', 'datepicker');
});

expect(await findByText('Expiry Date must be after Start Date')).toBeVisible();
});

Expand All @@ -112,28 +168,163 @@ describe('LeaseDetailSubForm component', () => {
expect(items[1]).toHaveTextContent(/ANOTHER MOCK/i);
});

it('displays the cancellation reason textbox whe status is changed to "Cancelled"', async () => {
const { container, getCancellationReason } = await setup({});
it('displays the cancellation reason textbox when status is changed to "Discarded"', async () => {
const { getCancellationReason, getStatusDropDown } = await setup({});

await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.DISCARD),
);

expect(getCancellationReason()).toBeInTheDocument();
});

it('displays a confirmation modal when user changes the status from "Discarded" to a new status', async () => {
const { getByTestId, getCancellationReason, getStatusDropDown, formikRef } = await setup({
initialValues: {
...getDefaultFormLease(),
statusTypeCode: ApiGen_CodeTypes_LeaseStatusTypes.DISCARD,
},
});

expect(formikRef.current).not.toBeNull();
expect(getCancellationReason()).toBeInTheDocument();

await act(async () => {
fillInput(container, 'statusTypeCode', ApiGen_CodeTypes_LeaseStatusTypes.DISCARD, 'select');
userEvent.paste(getCancellationReason(), 'Lorem ipsum');
});

// cancellation reason is captured in the form values
expect(formikRef.current.values.cancellationReason).toBe('Lorem ipsum');

// changing from "Discarded" a new status should trigger a confirmation modal
await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.DRAFT),
);

const popup = await screen.findByText('The lease is no longer in', { exact: false });
expect(popup.textContent).toBe(
'The lease is no longer in Cancelled state. The reason for doing so will be cleared from the file details and can only be viewed in the notes tab.Do you want to proceed?',
);

const okButton = getByTestId('ok-modal-button');
await act(async () => userEvent.click(okButton));

// cancellation reason is cleared upon closing the modal
expect(formikRef.current.values.cancellationReason).toBe('');
});

it(`doesn't clear the cancellation reason textbox if the user cancels the confirmation modal`, async () => {
const { getByTestId, getCancellationReason, getStatusDropDown, formikRef } = await setup({
initialValues: {
...getDefaultFormLease(),
statusTypeCode: ApiGen_CodeTypes_LeaseStatusTypes.DISCARD,
},
});

expect(formikRef.current).not.toBeNull();
expect(getCancellationReason()).toBeInTheDocument();

await act(async () => {
userEvent.paste(getCancellationReason(), 'Lorem ipsum');
});

// cancellation reason is captured in the form values
expect(formikRef.current.values.cancellationReason).toBe('Lorem ipsum');

// changing from "Discarded" a new status should trigger a confirmation modal
await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.DRAFT),
);

const popup = await screen.findByText('The lease is no longer in', { exact: false });
expect(popup.textContent).toBe(
'The lease is no longer in Cancelled state. The reason for doing so will be cleared from the file details and can only be viewed in the notes tab.Do you want to proceed?',
);

const cancelButton = getByTestId('cancel-modal-button');
await act(async () => userEvent.click(cancelButton));

// cancellation reason should remain the same if modal was cancelled
expect(formikRef.current.values.cancellationReason).toBe('Lorem ipsum');
});

it('displays the termination reason textbox whe status is changed to "Terminated"', async () => {
const { container, getTerminationReason } = await setup({});
it('displays the termination reason textbox when status is changed to "Terminated"', async () => {
const { getTerminationReason, getStatusDropDown } = await setup({});

await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED),
);

expect(getTerminationReason()).toBeInTheDocument();
});

it('displays a confirmation modal when user changes the status from "Terminated" to a new status', async () => {
const { getByTestId, getTerminationReason, getStatusDropDown, formikRef } = await setup({
initialValues: {
...getDefaultFormLease(),
statusTypeCode: ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED,
},
});

expect(formikRef.current).not.toBeNull();
expect(getTerminationReason()).toBeInTheDocument();

await act(async () => {
fillInput(
container,
'statusTypeCode',
ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED,
'select',
);
userEvent.paste(getTerminationReason(), 'Lorem ipsum');
});

// cancellation reason is captured in the form values
expect(formikRef.current.values.terminationReason).toBe('Lorem ipsum');

// changing from "Discarded" a new status should trigger a confirmation modal
await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.DRAFT),
);

const popup = await screen.findByText('The lease is no longer in', { exact: false });
expect(popup.textContent).toBe(
'The lease is no longer in Terminated state. The reason for doing so will be cleared from the file details and can only be viewed in the notes tab.Do you want to proceed?',
);

const okButton = getByTestId('ok-modal-button');
await act(async () => userEvent.click(okButton));

// cancellation reason is cleared upon closing the modal
expect(formikRef.current.values.terminationReason).toBe('');
});

it(`doesn't clear the termination reason textbox if the user cancels the confirmation modal`, async () => {
const { getByTestId, getTerminationReason, getStatusDropDown, formikRef } = await setup({
initialValues: {
...getDefaultFormLease(),
statusTypeCode: ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED,
},
});

expect(formikRef.current).not.toBeNull();
expect(getTerminationReason()).toBeInTheDocument();

await act(async () => {
userEvent.paste(getTerminationReason(), 'Lorem ipsum');
});

// cancellation reason is captured in the form values
expect(formikRef.current.values.terminationReason).toBe('Lorem ipsum');

// changing from "Discarded" a new status should trigger a confirmation modal
await act(async () =>
userEvent.selectOptions(getStatusDropDown(), ApiGen_CodeTypes_LeaseStatusTypes.DRAFT),
);

const popup = await screen.findByText('The lease is no longer in', { exact: false });
expect(popup.textContent).toBe(
'The lease is no longer in Terminated state. The reason for doing so will be cleared from the file details and can only be viewed in the notes tab.Do you want to proceed?',
);

const cancelButton = getByTestId('cancel-modal-button');
await act(async () => userEvent.click(cancelButton));

// cancellation reason should remain the same if modal was cancelled
expect(formikRef.current.values.terminationReason).toBe('Lorem ipsum');
});
});
11 changes: 9 additions & 2 deletions source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,15 @@ export const LeaseDetailSubForm: React.FunctionComponent<ILeaseDetailsSubFormPro
</SectionField>
<Row>
<Col>
<SectionField label="Start date" required>
<FastDatePicker formikProps={formikProps} field="startDate" required />
<SectionField
label="Start date"
required={statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.ACTIVE}
>
<FastDatePicker
formikProps={formikProps}
field="startDate"
required={statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.ACTIVE}
/>
</SectionField>
</Col>
<Col>
Expand Down
Loading

0 comments on commit 63114e0

Please sign in to comment.