diff --git a/pkg/services/move/move_router.go b/pkg/services/move/move_router.go index c510023e5a7..9894a841df4 100644 --- a/pkg/services/move/move_router.go +++ b/pkg/services/move/move_router.go @@ -213,6 +213,16 @@ func (router moveRouter) sendToServiceCounselor(appCtx appcontext.AppContext, mo move.MTOShipments[i].ShipmentType == models.MTOShipmentTypeMobileHome { move.MTOShipments[i].Status = models.MTOShipmentStatusSubmitted + if verrs, err := appCtx.DB().ValidateAndUpdate(&move.MTOShipments[i]); verrs.HasAny() || err != nil { + msg := "failure saving shipment when routing move submission" + appCtx.Logger().Error(msg, zap.Error(err)) + return apperror.NewInvalidInputError(move.MTOShipments[i].ID, err, verrs, msg) + } + } + // update status for mobile home shipment + if move.MTOShipments[i].ShipmentType == models.MTOShipmentTypeMobileHome { + move.MTOShipments[i].Status = models.MTOShipmentStatusSubmitted + if verrs, err := appCtx.DB().ValidateAndUpdate(&move.MTOShipments[i]); verrs.HasAny() || err != nil { msg := "failure saving shipment when routing move submission" appCtx.Logger().Error(msg, zap.Error(err)) diff --git a/playwright/tests/office/servicescounseling/servicesCounselingMobileHome.spec.js b/playwright/tests/office/servicescounseling/servicesCounselingMobileHome.spec.js new file mode 100644 index 00000000000..0588fe6cd0d --- /dev/null +++ b/playwright/tests/office/servicescounseling/servicesCounselingMobileHome.spec.js @@ -0,0 +1,47 @@ +// @ts-check +import { test, expect } from './servicesCounselingTestFixture'; + +test.describe('Services counselor user', () => { + test.beforeEach(async ({ scPage }) => { + const move = await scPage.testHarness.buildHHGMoveWithNTSAndNeedsSC(); + await scPage.navigateToMove(move.locator); + }); + + test('Services Counselor can create a mobile home shipment and view shipment card info', async ({ page, scPage }) => { + const deliveryDate = new Date().toLocaleDateString('en-US'); + await page.getByTestId('dropdown').selectOption({ label: 'Mobile Home' }); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Add shipment details'); + await expect(page.getByTestId('tag')).toHaveText('Mobile Home'); + + await page.getByLabel('Year').fill('2022'); + await page.getByLabel('Make').fill('make'); + await page.getByLabel('Model').fill('model'); + await page.getByTestId('lengthFeet').fill('22'); + await page.getByTestId('widthFeet').fill('22'); + await page.getByTestId('heightFeet').fill('22'); + + await page.locator('#requestedPickupDate').fill(deliveryDate); + await page.locator('#requestedPickupDate').blur(); + await page.getByText('Use current address').click(); + await page.locator('#requestedDeliveryDate').fill('16 Mar 2022'); + await page.locator('#requestedDeliveryDate').blur(); + + await page.getByLabel('Counselor remarks').fill('Sample counselor remarks'); + + // Save the shipment + await page.getByRole('button', { name: 'Save' }).click(); + await scPage.waitForPage.moveDetails(); + + await expect(page.getByTestId('ShipmentContainer')).toHaveCount(2); + + await expect(page.getByText('Mobile home year')).toBeVisible(); + await expect(page.getByTestId('year')).toHaveText('2022'); + await expect(page.getByText('Mobile home make')).toBeVisible(); + await expect(page.getByTestId('make')).toHaveText('make'); + await expect(page.getByText('Mobile home model')).toBeVisible(); + await expect(page.getByTestId('model')).toHaveText('model'); + await expect(page.getByText('Dimensions')).toBeVisible(); + await expect(page.getByTestId('dimensions')).toHaveText("22' L x 22' W x 22' H"); + }); +}); diff --git a/playwright/tests/office/txo/tooFlowsMobileHome.spec.js b/playwright/tests/office/txo/tooFlowsMobileHome.spec.js new file mode 100644 index 00000000000..0d98f4c5ee7 --- /dev/null +++ b/playwright/tests/office/txo/tooFlowsMobileHome.spec.js @@ -0,0 +1,58 @@ +/** + * Semi-automated converted from a cypress test, and thus may contain + * non best-practices, in particular: heavy use of `page.locator` + * instead of `page.getBy*`. + */ + +// @ts-check +import { test, expect } from '../../utils/office/officeTest'; + +import { TooFlowPage } from './tooTestFixture'; + +test.describe('TOO user', () => { + /** @type {TooFlowPage} */ + let tooFlowPage; + + test.beforeEach(async ({ officePage }) => { + const move = await officePage.testHarness.buildHHGMoveWithServiceItemsAndPaymentRequestsAndFilesForTOO(); + await officePage.signInAsNewTOOUser(); + tooFlowPage = new TooFlowPage(officePage, move); + await tooFlowPage.waitForLoading(); + await officePage.tooNavigateToMove(tooFlowPage.moveLocator); + }); + + test('TOO can create a mobile home shipment and view shipment card info', async ({ page }) => { + const deliveryDate = new Date().toLocaleDateString('en-US'); + await page.getByTestId('dropdown').selectOption({ label: 'Mobile Home' }); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Add shipment details'); + await expect(page.getByTestId('tag')).toHaveText('Mobile Home'); + + await page.getByLabel('Year').fill('2022'); + await page.getByLabel('Make').fill('make'); + await page.getByLabel('Model').fill('model'); + await page.getByTestId('lengthFeet').fill('22'); + await page.getByTestId('widthFeet').fill('22'); + await page.getByTestId('heightFeet').fill('22'); + + await page.locator('#requestedPickupDate').fill(deliveryDate); + await page.locator('#requestedPickupDate').blur(); + await page.getByText('Use current address').click(); + await page.locator('#requestedDeliveryDate').fill('16 Mar 2022'); + await page.locator('#requestedDeliveryDate').blur(); + + // Save the shipment + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByTestId('ShipmentContainer')).toHaveCount(2); + + await expect(page.getByText('Mobile home year')).toBeVisible(); + await expect(page.getByTestId('year')).toHaveText('2022'); + await expect(page.getByText('Mobile home make')).toBeVisible(); + await expect(page.getByTestId('make')).toHaveText('make'); + await expect(page.getByText('Mobile home model')).toBeVisible(); + await expect(page.getByTestId('model')).toHaveText('model'); + await expect(page.getByText('Dimensions')).toBeVisible(); + await expect(page.getByTestId('dimensions')).toHaveText("22' L x 22' W x 22' H"); + }); +}); diff --git a/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.jsx b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.jsx new file mode 100644 index 00000000000..8854c983d75 --- /dev/null +++ b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.jsx @@ -0,0 +1,394 @@ +import React, { useEffect, useState } from 'react'; +import * as PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import shipmentDefinitionListsStyles from './ShipmentDefinitionLists.module.scss'; + +import styles from 'styles/descriptionList.module.scss'; +import { formatDateWithUTC } from 'shared/dates'; +import { ShipmentShape } from 'types/shipment'; +import { formatAddress, formatAgent } from 'utils/shipmentDisplay'; +import { convertInchesToFeetAndInches } from 'utils/formatMtoShipment'; +import { + setFlagStyles, + setDisplayFlags, + getDisplayFlags, + getMissingOrDash, + fieldValidationShape, +} from 'utils/displayFlags'; +import { ADDRESS_UPDATE_STATUS } from 'constants/shipments'; +import { isBooleanFlagEnabled } from 'utils/featureFlags'; + +const ShipmentInfoList = ({ + className, + shipment, + warnIfMissing, + errorIfMissing, + showWhenCollapsed, + isExpanded, + isForEvaluationReport, +}) => { + const { + actualPickupDate, + requestedPickupDate, + requiredDeliveryDate, + scheduledDeliveryDate, + requestedDeliveryDate, + scheduledPickupDate, + actualDeliveryDate, + pickupAddress, + secondaryPickupAddress, + tertiaryPickupAddress, + destinationAddress, + destinationType, + displayDestinationType, + secondaryDeliveryAddress, + tertiaryDeliveryAddress, + mtoAgents, + counselorRemarks, + customerRemarks, + deliveryAddressUpdate, + } = shipment; + + const { year, make, model, lengthInInches, widthInInches, heightInInches } = shipment?.mobileHomeShipment || {}; + + setFlagStyles({ + row: styles.row, + warning: shipmentDefinitionListsStyles.warning, + missingInfoError: shipmentDefinitionListsStyles.missingInfoError, + }); + setDisplayFlags(errorIfMissing, warnIfMissing, showWhenCollapsed, null, shipment); + + const [isTertiaryAddressEnabled, setIsTertiaryAddressEnabled] = useState(false); + useEffect(() => { + const fetchData = async () => { + setIsTertiaryAddressEnabled(await isBooleanFlagEnabled('third_address_available')); + }; + if (!isForEvaluationReport) fetchData(); + }, [isForEvaluationReport]); + + const showElement = (elementFlags) => { + return (isExpanded || elementFlags.alwaysShow) && !elementFlags.hideRow; + }; + + const length = convertInchesToFeetAndInches(lengthInInches); + const width = convertInchesToFeetAndInches(widthInInches); + const height = convertInchesToFeetAndInches(heightInInches); + + const formattedDimensions = `${length?.feet}'${length?.inches > 0 ? ` ${length.inches}"` : ''} L x ${width?.feet}'${ + width?.inches > 0 ? ` ${width.inches}"` : '' + } W x ${height?.feet}'${height?.inches > 0 ? ` ${height.inches}"` : ''} H`; + + const releasingAgent = mtoAgents ? mtoAgents.find((agent) => agent.agentType === 'RELEASING_AGENT') : false; + const receivingAgent = mtoAgents ? mtoAgents.find((agent) => agent.agentType === 'RECEIVING_AGENT') : false; + + const agentsElementFlags = getDisplayFlags('mtoAgents'); + const releasingAgentElement = !releasingAgent ? ( +
+
Releasing agent
+
+
+ ) : ( +
+
Releasing agent
+
{formatAgent(releasingAgent)}
+
+ ); + + const receivingAgentElement = !receivingAgent ? ( +
+
Receiving agent
+
+
+ ) : ( +
+
Receiving agent
+
{formatAgent(receivingAgent)}
+
+ ); + + const scheduledPickupDateElementFlags = getDisplayFlags('scheduledPickupDate'); + const scheduledPickupDateElement = ( +
+
Scheduled pickup date
+
+ {(scheduledPickupDate && formatDateWithUTC(scheduledPickupDate, 'DD MMM YYYY')) || + getMissingOrDash('scheduledPickupDate')} +
+
+ ); + + const requestedPickupDateElementFlags = getDisplayFlags('scheduledPickupDate'); + const requestedPickupDateElement = ( +
+
Requested pickup date
+
+ {(requestedPickupDate && formatDateWithUTC(requestedPickupDate, 'DD MMM YYYY')) || + getMissingOrDash('requestedPickupDate')} +
+
+ ); + + const actualPickupDateElementFlags = getDisplayFlags('actualPickupDate'); + const actualPickupDateElement = ( +
+
Actual pickup date
+
+ {(actualPickupDate && formatDateWithUTC(actualPickupDate, 'DD MMM YYYY')) || + getMissingOrDash('actualPickupDate')} +
+
+ ); + + const requestedDeliveryDateElementFlags = getDisplayFlags('requestedDeliveryDate'); + const requestedDeliveryDateElement = ( +
+
Requested delivery date
+
+ {(requestedDeliveryDate && formatDateWithUTC(requestedDeliveryDate, 'DD MMM YYYY')) || + getMissingOrDash('requestedDeliveryDate')} +
+
+ ); + + const scheduledDeliveryDateElementFlags = getDisplayFlags('scheduledDeliveryDate'); + const scheduledDeliveryDateElement = ( +
+
Scheduled delivery date
+
+ {(scheduledDeliveryDate && formatDateWithUTC(scheduledDeliveryDate, 'DD MMM YYYY')) || + getMissingOrDash('scheduledDeliveryDate')} +
+
+ ); + + const requiredDeliveryDateElementFlags = getDisplayFlags('requiredDeliveryDate'); + const requiredDeliveryDateElement = ( +
+
Required delivery date
+
+ {(requiredDeliveryDate && formatDateWithUTC(requiredDeliveryDate, 'DD MMM YYYY')) || + getMissingOrDash('requiredDeliveryDate')} +
+
+ ); + const actualDeliveryDateElementFlags = getDisplayFlags('actualDeliveryDate'); + const actualDeliveryDateElement = ( +
+
Actual delivery date
+
+ {(actualDeliveryDate && formatDateWithUTC(actualDeliveryDate, 'DD MMM YYYY')) || + getMissingOrDash('actualDeliverDate')} +
+
+ ); + + const pickupAddressElementFlags = getDisplayFlags('pickupAddress'); + const pickupAddressElement = ( +
+
Origin address
+
+ {(pickupAddress && formatAddress(pickupAddress)) || getMissingOrDash('pickupAddress')} +
+
+ ); + + const secondaryPickupAddressElementFlags = getDisplayFlags('secondaryPickupAddress'); + const secondaryPickupAddressElement = ( +
+
Second pickup address
+
+ {secondaryPickupAddress ? formatAddress(secondaryPickupAddress) : '—'} +
+
+ ); + + const tertiaryPickupAddressElementFlags = getDisplayFlags('tertiaryPickupAddress'); + const tertiaryPickupAddressElement = ( +
+
Third pickup address
+
{tertiaryPickupAddress ? formatAddress(tertiaryPickupAddress) : '—'}
+
+ ); + + const destinationTypeFlags = getDisplayFlags('destinationType'); + const destinationTypeElement = ( +
+
Destination type
+
{destinationType || getMissingOrDash('destinationType')}
+
+ ); + + const destinationAddressElementFlags = getDisplayFlags('destinationAddress'); + const destinationAddressElement = ( +
+
Destination address
+
+ {deliveryAddressUpdate?.status === ADDRESS_UPDATE_STATUS.REQUESTED + ? 'Review required' + : (destinationAddress && formatAddress(destinationAddress)) || '—'} +
+
+ ); + + const secondaryDeliveryAddressElementFlags = getDisplayFlags('secondaryDeliveryAddress'); + const secondaryDeliveryAddressElement = ( +
+
Second destination address
+
+ {secondaryDeliveryAddress ? formatAddress(secondaryDeliveryAddress) : '—'} +
+
+ ); + + const tertiaryDeliveryAddressElementFlags = getDisplayFlags('tertiaryDeliveryAddress'); + const tertiaryDeliveryAddressElement = ( +
+
Third destination address
+
+ {tertiaryDeliveryAddress ? formatAddress(tertiaryDeliveryAddress) : '—'} +
+
+ ); + + const yearElementFlags = getDisplayFlags('year'); + const yearElement = ( +
+
Mobile home year
+
{year}
+
+ ); + + const makeElementFlags = getDisplayFlags('make'); + const makeElement = ( +
+
Mobile home make
+
{make}
+
+ ); + + const modelElementFlags = getDisplayFlags('model'); + const modelElement = ( +
+
Mobile home model
+
{model}
+
+ ); + + const dimensionsElementFlags = getDisplayFlags('dimensions'); + const dimensionsElement = ( +
+
Dimensions
+
{formattedDimensions}
+
+ ); + + const counselorRemarksElementFlags = getDisplayFlags('counselorRemarks'); + const counselorRemarksElement = ( +
+
Counselor remarks
+
{counselorRemarks || '—'}
+
+ ); + + const customerRemarksElementFlags = getDisplayFlags('customerRemarks'); + const customerRemarksElement = ( +
+
Customer remarks
+
{customerRemarks || '—'}
+
+ ); + + const defaultDetails = ( +
+ {requestedPickupDateElement} + {pickupAddressElement} + {secondaryPickupAddressElement} + {isTertiaryAddressEnabled ? tertiaryPickupAddressElement : null} + {showElement(agentsElementFlags) && releasingAgentElement} + {showElement(requestedDeliveryDateElementFlags) && requestedDeliveryDateElement} + {requestedDeliveryDateElement} + {destinationAddressElement} + {showElement(destinationTypeFlags) && displayDestinationType && destinationTypeElement} + {secondaryDeliveryAddressElement} + {isTertiaryAddressEnabled ? tertiaryDeliveryAddressElement : null} + {showElement(agentsElementFlags) && receivingAgentElement} + {yearElement} + {makeElement} + {modelElement} + {dimensionsElement} + {counselorRemarksElement} + {customerRemarksElement} +
+ ); + + const evaluationReportDetails = ( +
+
+
+ {showElement(scheduledPickupDateElement) && scheduledPickupDateElement} + {showElement(actualPickupDateElementFlags) && actualPickupDateElement} + {showElement(requestedDeliveryDateElementFlags) && requestedDeliveryDateElement} + {showElement(agentsElementFlags) && releasingAgentElement} +
+
+
+
+ {showElement(scheduledDeliveryDateElementFlags) && scheduledDeliveryDateElement} + {showElement(requiredDeliveryDateElementFlags) && requiredDeliveryDateElement} + {showElement(actualDeliveryDateElementFlags) && actualDeliveryDateElement} + {showElement(agentsElementFlags) && receivingAgentElement} +
+
+
+ ); + + return
{isForEvaluationReport ? evaluationReportDetails : defaultDetails}
; +}; + +ShipmentInfoList.propTypes = { + className: PropTypes.string, + shipment: ShipmentShape.isRequired, + warnIfMissing: PropTypes.arrayOf(fieldValidationShape), + errorIfMissing: PropTypes.arrayOf(fieldValidationShape), + showWhenCollapsed: PropTypes.arrayOf(PropTypes.string), + isExpanded: PropTypes.bool, +}; + +ShipmentInfoList.defaultProps = { + className: '', + warnIfMissing: [], + errorIfMissing: [], + showWhenCollapsed: [], + isExpanded: false, +}; + +export default ShipmentInfoList; diff --git a/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.stories.jsx b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.stories.jsx new file mode 100644 index 00000000000..735c38d3ff4 --- /dev/null +++ b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.stories.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { object, text } from '@storybook/addon-knobs'; + +import MobileHomeShipmentInfoList from './MobileHomeShipmentInfoList'; + +export default { + title: 'Office Components/Mobile Home Shipment Info List', + component: MobileHomeShipmentInfoList, +}; + +const mobileHomeShipment = { + mobileHomeShipment: { + year: 2020, + make: 'Yamaha', + model: '242X E-Series', + lengthInInches: 276, + widthInInches: 102, + heightInInches: 120, + }, + pickupAddress: { + streetAddress1: '123 Harbor Dr', + city: 'Miami', + state: 'FL', + postalCode: '33101', + }, + destinationAddress: { + streetAddress1: '456 Marina Blvd', + city: 'Key West', + state: 'FL', + postalCode: '33040', + }, + secondaryPickupAddress: { + streetAddress1: '789 Seaport Ln', + city: 'Fort Lauderdale', + state: 'FL', + postalCode: '33316', + }, + tertiaryPickupAddress: { + streetAddress1: '101 Yacht Club Rd', + city: 'Naples', + state: 'FL', + postalCode: '34102', + }, + secondaryDeliveryAddress: { + streetAddress1: '111 Ocean Dr', + city: 'Palm Beach', + state: 'FL', + postalCode: '33480', + }, + tertiaryDeliveryAddress: { + streetAddress1: '222 Shoreline Dr', + city: 'Clearwater', + state: 'FL', + postalCode: '33767', + }, + mtoAgents: [ + { + agentType: 'RELEASING_AGENT', + firstName: 'John', + lastName: 'Doe', + phone: '123-456-7890', + email: 'john.doe@example.com', + }, + { + agentType: 'RECEIVING_AGENT', + firstName: 'Jane', + lastName: 'Doe', + phone: '987-654-3210', + email: 'jane.doe@example.com', + }, + ], + counselorRemarks: 'Please be cautious with the mobile home.', + customerRemarks: 'Handle with care.', +}; + +export const Basic = () => ( + +); + +export const DefaultView = () => ( + +); diff --git a/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.test.jsx b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.test.jsx new file mode 100644 index 00000000000..3d50715bc35 --- /dev/null +++ b/src/components/Office/DefinitionLists/MobileHomeShipmentInfoList.test.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { render, screen, within, act } from '@testing-library/react'; + +import MobileHomeShipmentInfoList from './MobileHomeShipmentInfoList'; + +import { isBooleanFlagEnabled } from 'utils/featureFlags'; + +jest.mock('utils/featureFlags', () => ({ + ...jest.requireActual('utils/featureFlags'), + isBooleanFlagEnabled: jest.fn().mockImplementation(() => Promise.resolve(false)), +})); + +const shipment = { + mobileHomeShipment: { + year: 2022, + make: 'Fleetwood', + model: 'Southwind', + lengthInInches: 3600, + widthInInches: 1020, + heightInInches: 1440, + }, + requestedPickupDate: '2020-03-26', + pickupAddress: { + streetAddress1: '123 Harbor Dr', + city: 'Miami', + state: 'FL', + postalCode: '33101', + }, + destinationAddress: { + streetAddress1: '456 Marina Blvd', + city: 'Key West', + state: 'FL', + postalCode: '33040', + }, + mtoAgents: [ + { + agentType: 'RELEASING_AGENT', + firstName: 'John', + lastName: 'Doe', + phone: '123-456-7890', + email: 'john.doe@example.com', + }, + { + agentType: 'RECEIVING_AGENT', + firstName: 'Jane', + lastName: 'Smith', + phone: '987-654-3210', + email: 'jane.smith@example.com', + }, + ], + counselorRemarks: 'Handle with care.', + customerRemarks: 'Please avoid scratches.', +}; + +const labels = { + requestedPickupDate: 'Requested pickup date', + pickupAddress: 'Origin address', + destinationAddress: 'Destination address', + mtoAgents: ['Releasing agent', 'Receiving agent'], + counselorRemarks: 'Counselor remarks', + customerRemarks: 'Customer remarks', + dimensions: 'Dimensions', +}; + +describe('Shipment Info List - Mobile Home Shipment', () => { + it('renders all mobile home shipment fields when provided and expanded', async () => { + isBooleanFlagEnabled.mockImplementation(() => Promise.resolve(true)); + + await act(async () => { + render(); + }); + + const requestedPickupDate = screen.getByText(labels.requestedPickupDate); + expect(within(requestedPickupDate.parentElement).getByText('26 Mar 2020')).toBeInTheDocument(); + + const pickupAddress = screen.getByText(labels.pickupAddress); + expect( + within(pickupAddress.parentElement).getByText(shipment.pickupAddress.streetAddress1, { exact: false }), + ).toBeInTheDocument(); + + const destinationAddress = screen.getByText(labels.destinationAddress); + expect( + within(destinationAddress.parentElement).getByText(shipment.destinationAddress.streetAddress1, { + exact: false, + }), + ).toBeInTheDocument(); + + const releasingAgent = screen.getByText(labels.mtoAgents[0]); + expect( + within(releasingAgent.parentElement).getByText(shipment.mtoAgents[0].email, { exact: false }), + ).toBeInTheDocument(); + + const receivingAgent = screen.getByText(labels.mtoAgents[1]); + expect( + within(receivingAgent.parentElement).getByText(shipment.mtoAgents[1].email, { exact: false }), + ).toBeInTheDocument(); + + const counselorRemarks = screen.getByText(labels.counselorRemarks); + expect(within(counselorRemarks.parentElement).getByText(shipment.counselorRemarks)).toBeInTheDocument(); + + const customerRemarks = screen.getByText(labels.customerRemarks); + expect(within(customerRemarks.parentElement).getByText(shipment.customerRemarks)).toBeInTheDocument(); + + const dimensions = screen.getByText(labels.dimensions); + expect(within(dimensions.parentElement).getByText("300' L x 85' W x 120' H", { exact: false })).toBeInTheDocument(); + }); + + it('does not render mtoAgents when not provided', async () => { + await act(async () => { + render( + , + ); + }); + + expect(screen.queryByText(labels.mtoAgents[0])).not.toBeInTheDocument(); + expect(screen.queryByText(labels.mtoAgents[1])).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx b/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx index ce2441f7d77..fa152b443d9 100644 --- a/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx +++ b/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx @@ -6,6 +6,7 @@ import ShipmentInfoList from 'components/Office/DefinitionLists/ShipmentInfoList import PPMShipmentInfoList from 'components/Office/DefinitionLists/PPMShipmentInfoList'; import NTSRShipmentInfoList from 'components/Office/DefinitionLists/NTSRShipmentInfoList'; import NTSShipmentInfoList from 'components/Office/DefinitionLists/NTSShipmentInfoList'; +import MobileHomeShipmentInfoList from 'components/Office/DefinitionLists/MobileHomeShipmentInfoList'; import BoatShipmentInfoList from 'components/Office/DefinitionLists/BoatShipmentInfoList'; import { SHIPMENT_OPTIONS, SHIPMENT_TYPES } from 'shared/constants'; import { fieldValidationShape } from 'utils/displayFlags'; @@ -79,6 +80,20 @@ const ShipmentInfoListSelector = ({ isForEvaluationReport={isForEvaluationReport} /> ); + case SHIPMENT_OPTIONS.MOBILE_HOME: + return ( + + ); case SHIPMENT_OPTIONS.BOAT: case SHIPMENT_TYPES.BOAT_HAUL_AWAY: case SHIPMENT_TYPES.BOAT_TOW_AWAY: diff --git a/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx new file mode 100644 index 00000000000..b5b7f2dd1c1 --- /dev/null +++ b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { Fieldset } from '@trussworks/react-uswds'; +import { func } from 'prop-types'; +import classnames from 'classnames'; + +import styles from './MobileHomeShipmentForm.module.scss'; + +import formStyles from 'styles/form.module.scss'; +import SectionWrapper from 'components/Customer/SectionWrapper'; +import MaskedTextField from 'components/form/fields/MaskedTextField/MaskedTextField'; +import TextField from 'components/form/fields/TextField/TextField'; +import { ErrorMessage } from 'components/form/index'; + +const MobileHomeShipmentForm = ({ + lengthHasError, + widthHasError, + heightHasError, + setFieldTouched, + setFieldError, + validateForm, + dimensionError, +}) => { + return ( +
+ +

Mobile Home Information

+
+
+ { + setFieldError('year', null); + }} + onBlur={() => { + setFieldTouched('year', true); + setFieldError('year', null); + validateForm(); + }} + required + /> +
+
+
+ + +
+
+ +

Mobile Home Dimensions

+

Enter the total outside dimensions of the mobile home.

+
+ +

+ The dimensions do not meet the requirements for a mobile home shipment. Please cancel and select a + different shipment type. +

+
+
+
+ Length + Required +
+
+
+ { + setFieldError('heightFeet', null); + setFieldError('widthFeet', null); + setFieldError('lengthFeet', null); + }} + /> +
+
+ +
+
+
+
+
+ Width + Required +
+
+
+ { + setFieldError('heightFeet', null); + setFieldError('widthFeet', null); + setFieldError('lengthFeet', null); + }} + /> +
+
+ +
+
+
+
+
+ Height + Required +
+
+
+ { + setFieldError('heightFeet', null); + setFieldError('widthFeet', null); + setFieldError('lengthFeet', null); + }} + /> +
+
+ +
+
+
+
+
+
+ ); +}; + +export default MobileHomeShipmentForm; + +MobileHomeShipmentForm.propTypes = { + lengthHasError: func.isRequired, + widthHasError: func.isRequired, + heightHasError: func.isRequired, + setFieldTouched: func.isRequired, + setFieldError: func.isRequired, + validateForm: func.isRequired, +}; diff --git a/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.module.scss b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.module.scss new file mode 100644 index 00000000000..093d03a6cd4 --- /dev/null +++ b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/MobileHomeShipmentForm.module.scss @@ -0,0 +1,121 @@ +@import 'shared/styles/_basics'; +@import 'shared/styles/_variables'; +@import 'shared/styles/colors'; + +.formContainer { + :global(.usa-legend) { + max-width: none; + } + .formTextFieldWrapper { + :global(.usa-form-group){ + margin-top: 0; + } + .hide { + display: none; + } + } + .formFieldContainer { + margin-top: 1.6rem; + margin-bottom: 0; + padding-bottom: 0; + border: none; + } + .form { + max-width: none; + + :global(.usa-input) { + @include u-display('inline-block'); + width: unset; + } + + :global(.usa-form-group--error), + :global(.usa-form-group.warning) { + margin-top: 1.6rem; + } + + :global(.usa-form-group:first-of-type .usa-label) { + margin-top: 0; + } + + // last section wrapper on mobile shouldn't render divider + @include at-media-max('tablet') { + .sectionWrapper:nth-last-child(2) { + border-bottom: none; + @include u-padding-bottom(0); + @include u-margin-bottom(3); + } + } + } + + h2 { + @include u-margin-bottom(2.5); + } + + // fixes collapsing margins cross-browser for Storage section + h2 + fieldset legend { + @include u-padding-top(1.5); + } + + .sectionWrapper { + border-bottom: 1px solid $base-lighter; + + @include at-media-max('tablet') { + @include u-padding-bottom(4); + @include u-margin-top(4); + } + } + + .sectionWrapper:last-of-type { + border-bottom: none; + } + + fieldset { + @include u-margin-top(2); + @include u-padding-top(0); + + legend:global(.usa-label) { + @include u-margin-top(0); + } + } + + :global(.usa-label), + :global(.usa-checkbox__label) { + @include u-margin-top(2); + } + + :global(.usa-label.usa-label--error) { + @include u-margin-top(0); + } + + :global(.usa-legend) { + @include u-margin-top(0); + } + + .innerHint { + @include u-margin-top(1); + } + + .hint { + @include u-margin-top(2); + } +} + +.buttonContainer { + @include u-display(flex); + flex-wrap: wrap; + + button:global(.usa-button) { + @include u-margin-top(2); + @include u-margin-bottom(0); + } + + @include at-media-max(mobile-lg) { + .backButton { + order: 2; + } + + .saveButton { + order: 1; + } + } +} diff --git a/src/components/Office/ShipmentForm/MobileHomeShipmentForm/mobileHomeShipmentSchema.js b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/mobileHomeShipmentSchema.js new file mode 100644 index 00000000000..bc64db9ea22 --- /dev/null +++ b/src/components/Office/ShipmentForm/MobileHomeShipmentForm/mobileHomeShipmentSchema.js @@ -0,0 +1,108 @@ +import * as Yup from 'yup'; + +import { + AdditionalAddressSchema, + RequiredPlaceSchema, + OptionalPlaceSchema, +} from 'components/Customer/MtoShipmentForm/validationSchemas'; +import { toTotalInches } from 'utils/formatMtoShipment'; + +const currentYear = new Date().getFullYear(); +const maxYear = currentYear + 2; + +const mobileHomeShipmentSchema = () => { + const formSchema = Yup.object() + .shape({ + year: Yup.number().required('Required').min(1700, 'Invalid year').max(maxYear, 'Invalid year'), + + make: Yup.string().required('Required'), + + model: Yup.string().required('Required'), + + lengthFeet: Yup.number() + .min(0) + .nullable() + .when('lengthInches', { + is: (lengthInches) => !lengthInches, + then: (schema) => schema.required('Required'), + otherwise: (schema) => schema.notRequired(), + }), + + lengthInches: Yup.number().min(0).nullable(), + + widthFeet: Yup.number() + .min(0) + .nullable() + .when('widthInches', { + is: (widthInches) => !widthInches, + then: (schema) => schema.required('Required'), + otherwise: (schema) => schema.notRequired(), + }), + + widthInches: Yup.number().min(0).nullable(), + + heightFeet: Yup.number() + .min(0) + .nullable() + .when('heightInches', { + is: (heightInches) => !heightInches, + then: (schema) => schema.required('Required'), + otherwise: (schema) => schema.notRequired(), + }), + + heightInches: Yup.number().min(0).nullable(), + + pickup: RequiredPlaceSchema, + delivery: OptionalPlaceSchema, + secondaryPickup: AdditionalAddressSchema, + secondaryDelivery: AdditionalAddressSchema, + tertiaryPickup: AdditionalAddressSchema, + tertiaryDelivery: AdditionalAddressSchema, + counselorRemarks: Yup.string(), + customerRemarks: Yup.string(), + }) + .test('dimension-check', 'Dimensions requirements.', function dimensionTest(values) { + const { lengthFeet, lengthInches, widthFeet, widthInches, heightFeet, heightInches } = values; + const hasLength = lengthFeet !== undefined || lengthInches !== undefined; + const hasWidth = widthFeet !== undefined || widthInches !== undefined; + const hasHeight = heightFeet !== undefined || heightInches !== undefined; + + if (hasLength && hasWidth && hasHeight) { + const lengthInInches = toTotalInches(lengthFeet, lengthInches); + const widthInInches = toTotalInches(widthFeet, widthInches); + const heightInInches = toTotalInches(heightFeet, heightInches); + + if (lengthInInches < 1 && widthInInches < 1 && heightInInches < 1) { + const errors = []; + errors.push( + this.createError({ + path: 'lengthFeet', + message: 'Dimensions do not meet the requirement.', + }), + ); + + errors.push( + this.createError({ + path: 'widthFeet', + message: 'Dimensions do not meet the requirement.', + }), + ); + + errors.push( + this.createError({ + path: 'heightFeet', + message: 'Dimensions do not meet the requirement.', + }), + ); + + if (errors.length) { + throw new Yup.ValidationError(errors); + } + } + } + return true; + }); + return formSchema; +}; + +export default mobileHomeShipmentSchema; diff --git a/src/components/Office/ShipmentForm/ShipmentForm.jsx b/src/components/Office/ShipmentForm/ShipmentForm.jsx index 72ae2a3806d..5a3b1d9e9aa 100644 --- a/src/components/Office/ShipmentForm/ShipmentForm.jsx +++ b/src/components/Office/ShipmentForm/ShipmentForm.jsx @@ -12,6 +12,8 @@ import { CloseoutOfficeInput } from '../../form/fields/CloseoutOfficeInput'; import ppmShipmentSchema from './ppmShipmentSchema'; import styles from './ShipmentForm.module.scss'; +import MobileHomeShipmentForm from './MobileHomeShipmentForm/MobileHomeShipmentForm'; +import mobileHomeShipmentSchema from './MobileHomeShipmentForm/mobileHomeShipmentSchema'; import BoatShipmentForm from './BoatShipmentForm/BoatShipmentForm'; import boatShipmentSchema from './BoatShipmentForm/boatShipmentSchema'; @@ -58,6 +60,8 @@ import { formatMtoShipmentForDisplay, formatPpmShipmentForAPI, formatPpmShipmentForDisplay, + formatMobileHomeShipmentForDisplay, + formatMobileHomeShipmentForAPI, formatBoatShipmentForDisplay, formatBoatShipmentForAPI, } from 'utils/formatMtoShipment'; @@ -249,6 +253,7 @@ const ShipmentForm = (props) => { const isNTS = shipmentType === SHIPMENT_OPTIONS.NTS; const isNTSR = shipmentType === SHIPMENT_OPTIONS.NTSR; const isPPM = shipmentType === SHIPMENT_OPTIONS.PPM; + const isMobileHome = shipmentType === SHIPMENT_OPTIONS.MOBILE_HOME; const isBoat = shipmentType === SHIPMENT_OPTIONS.BOAT || shipmentType === SHIPMENT_OPTIONS.BOAT_HAUL_AWAY || @@ -279,6 +284,11 @@ const ShipmentForm = (props) => { closeoutOffice: move.closeoutOffice, }, ); + } else if (isMobileHome) { + const hhgInitialValues = formatMtoShipmentForDisplay( + isCreatePage ? { userRole } : { userRole, shipmentType, agents: mtoShipment.mtoAgents, ...mtoShipment }, + ); + initialValues = formatMobileHomeShipmentForDisplay(mtoShipment?.mobileHomeShipment, hhgInitialValues); } else if (isBoat) { const hhgInitialValues = formatMtoShipmentForDisplay( isCreatePage ? { userRole } : { userRole, shipmentType, agents: mtoShipment.mtoAgents, ...mtoShipment }, @@ -306,6 +316,10 @@ const ShipmentForm = (props) => { showCloseoutOffice, sitEstimatedWeightMax: estimatedWeightValue || 0, }); + } else if (isMobileHome) { + schema = mobileHomeShipmentSchema(); + showDeliveryFields = true; + showPickupFields = true; } else if (isBoat) { schema = boatShipmentSchema(); showDeliveryFields = true; @@ -541,6 +555,15 @@ const ShipmentForm = (props) => { tertiaryDelivery: hasTertiaryDelivery === 'yes' ? tertiaryDelivery : {}, }); + // Mobile Home Shipment + if (isMobileHome) { + const mobileHomeShipmentBody = formatMobileHomeShipmentForAPI(formValues); + pendingMtoShipment = { + ...pendingMtoShipment, + ...mobileHomeShipmentBody, + }; + } + // Boat Shipment if (isBoat) { const boatShipmentBody = formatBoatShipmentForAPI(formValues); @@ -612,13 +635,13 @@ const ShipmentForm = (props) => { values, isValid, isSubmitting, - touched, setValues, handleSubmit, + errors, + touched, setFieldTouched, setFieldError, validateForm, - errors, }) => { const { hasSecondaryDestination, @@ -829,10 +852,23 @@ const ShipmentForm = (props) => {
- {isTOO && !isHHG && !isPPM && !isBoat && } + {isTOO && !isHHG && !isPPM && !isBoat && !isMobileHome && } {isNTSR && } + {isMobileHome && ( + + )} + {isBoat && ( { ).not.toBeInTheDocument(); }); }); + + describe('creating a new Mobile Home shipment', () => { + it('renders the Mobile Home shipment form correctly', async () => { + renderWithRouter(); + + expect(screen.getByLabelText('Year')).toBeInTheDocument(); + expect(screen.getByLabelText('Make')).toBeInTheDocument(); + expect(screen.getByLabelText('Model')).toBeInTheDocument(); + expect(await screen.findByText('Length')).toBeInTheDocument(); + expect(await screen.findByText('Width')).toBeInTheDocument(); + expect(await screen.findByText('Height')).toBeInTheDocument(); + expect(await screen.findByText('Remarks')).toBeInTheDocument(); + }); + + it('validates length and width input fields to ensure they accept only numeric values', async () => { + renderWithRouter(); + + const lengthInput = await screen.findByTestId('lengthFeet'); + const heightInput = await screen.findByTestId('heightFeet'); + const widthInput = await screen.findByTestId('widthFeet'); + + await act(async () => { + userEvent.type(lengthInput, 'abc'); + userEvent.type(heightInput, 'xyz'); + userEvent.type(widthInput, 'zyz'); + }); + + await waitFor(() => { + expect(lengthInput).toHaveValue(''); + expect(heightInput).toHaveValue(''); + expect(widthInput).toHaveValue(''); + }); + }); + + it('validates required fields for Mobile Home shipment', async () => { + renderWithRouter(); + + const submitButton = screen.getByRole('button', { name: 'Save' }); + + await act(async () => { + userEvent.click(submitButton); + }); + + expect(submitButton).toBeDisabled(); + }); + + it('validates the year field is within the valid range', async () => { + renderWithRouter(); + + await act(async () => { + await userEvent.click(screen.getByTestId('year')); + await userEvent.type(screen.getByTestId('year'), '1600'); + const submitButton = screen.getByRole('button', { name: 'Save' }); + userEvent.click(submitButton); + }); + + expect(await screen.findByText('Invalid year')).toBeInTheDocument(); + }); + + it('validates dimensions - pass', async () => { + renderWithRouter(); + + // Enter dimensions below the required minimums + await act(async () => { + await userEvent.click(screen.getByTestId('lengthFeet')); + await userEvent.type(screen.getByTestId('lengthFeet'), '15'); + await userEvent.click(screen.getByTestId('widthFeet')); + await userEvent.type(screen.getByTestId('widthFeet'), '5'); + await userEvent.click(screen.getByTestId('heightFeet')); + await userEvent.type(screen.getByTestId('heightFeet'), '6'); + const submitButton = screen.getByRole('button', { name: 'Save' }); + userEvent.click(submitButton); + }); + + expect(screen.queryByText('Where and when should the movers deliver your mobile home?')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Office/ShipmentFormRemarks/ShipmentFormRemarks.jsx b/src/components/Office/ShipmentFormRemarks/ShipmentFormRemarks.jsx index e437040769a..66a94a24a8b 100644 --- a/src/components/Office/ShipmentFormRemarks/ShipmentFormRemarks.jsx +++ b/src/components/Office/ShipmentFormRemarks/ShipmentFormRemarks.jsx @@ -68,6 +68,7 @@ ShipmentFormRemarks.propTypes = { SHIPMENT_OPTIONS.NTS, SHIPMENT_OPTIONS.NTSR, SHIPMENT_OPTIONS.PPM, + SHIPMENT_OPTIONS.MOBILE_HOME, SHIPMENT_OPTIONS.BOAT, SHIPMENT_OPTIONS.BOAT_HAUL_AWAY, SHIPMENT_OPTIONS.BOAT_TOW_AWAY, diff --git a/src/pages/Office/AddShipment/AddShipment.jsx b/src/pages/Office/AddShipment/AddShipment.jsx index e3721e0c363..7c545dc86cb 100644 --- a/src/pages/Office/AddShipment/AddShipment.jsx +++ b/src/pages/Office/AddShipment/AddShipment.jsx @@ -25,6 +25,8 @@ const AddShipment = () => { shipmentType = SHIPMENT_OPTIONS.NTSR; } else if (shipmentType === SHIPMENT_OPTIONS_URL.BOAT) { shipmentType = SHIPMENT_OPTIONS.BOAT; + } else if (shipmentType === SHIPMENT_OPTIONS_URL.MOBILE_HOME) { + shipmentType = SHIPMENT_OPTIONS.MOBILE_HOME; } else { shipmentType = SHIPMENT_OPTIONS[shipmentType]; } diff --git a/src/pages/Office/ServicesCounselingAddShipment/ServicesCounselingAddShipment.jsx b/src/pages/Office/ServicesCounselingAddShipment/ServicesCounselingAddShipment.jsx index 614ea8e9f27..e27dee8c4b7 100644 --- a/src/pages/Office/ServicesCounselingAddShipment/ServicesCounselingAddShipment.jsx +++ b/src/pages/Office/ServicesCounselingAddShipment/ServicesCounselingAddShipment.jsx @@ -25,6 +25,8 @@ const ServicesCounselingAddShipment = () => { shipmentType = SHIPMENT_OPTIONS.NTSR; } else if (shipmentType === SHIPMENT_OPTIONS_URL.BOAT) { shipmentType = SHIPMENT_OPTIONS.BOAT; + } else if (shipmentType === SHIPMENT_OPTIONS_URL.MOBILE_HOME) { + shipmentType = SHIPMENT_OPTIONS.MOBILE_HOME; } else { shipmentType = SHIPMENT_OPTIONS[shipmentType]; } diff --git a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx index 1a8497c417d..c5be3bf99f0 100644 --- a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx +++ b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx @@ -616,6 +616,9 @@ const ServicesCounselingMoveDetails = ({ + ) } diff --git a/src/utils/formatMtoShipment.js b/src/utils/formatMtoShipment.js index 9993787cb43..73b0902c448 100644 --- a/src/utils/formatMtoShipment.js +++ b/src/utils/formatMtoShipment.js @@ -519,6 +519,49 @@ export function convertInchesToFeetAndInches(totalInches) { return { feet, inches }; } +// Initial values for mobile home shipment +export function formatMobileHomeShipmentForDisplay(mobileHomeShipment, initialValues) { + const { year, make, model, lengthInInches, widthInInches, heightInInches } = mobileHomeShipment || {}; + + const length = convertInchesToFeetAndInches(lengthInInches); + const width = convertInchesToFeetAndInches(widthInInches); + const height = convertInchesToFeetAndInches(heightInInches); + + const displayValues = { + year: year?.toString() || null, + make: make || '', + model: model || '', + lengthFeet: length.feet, + lengthInches: length.inches, + widthFeet: width.feet, + widthInches: width.inches, + heightFeet: height.feet, + heightInches: height.inches, + ...initialValues, + }; + + return displayValues; +} + +export function formatMobileHomeShipmentForAPI(values) { + const totalLengthInInches = toTotalInches(values.lengthFeet, values.lengthInches); + const totalWidthInInches = toTotalInches(values.widthFeet, values.widthInches); + const totalHeightInInches = toTotalInches(values.heightFeet, values.heightInches); + + const mobileHomeShipment = { + year: Number(values.year), + make: values.make, + model: values.model, + lengthInInches: totalLengthInInches, + widthInInches: totalWidthInInches, + heightInInches: totalHeightInInches, + }; + + return { + mobileHomeShipment, + }; +} + // Initial values for boat shipment export function formatBoatShipmentForDisplay(boatShipment, initialValues) { const { year, make, model, lengthInInches, widthInInches, heightInInches, hasTrailer, isRoadworthy } = @@ -582,6 +625,8 @@ export default { getMtoShipmentLabel, toTotalInches, convertInchesToFeetAndInches, + formatMobileHomeShipmentForDisplay, + formatMobileHomeShipmentForAPI, formatBoatShipmentForDisplay, formatBoatShipmentForAPI, };