diff --git a/pkg/handlers/internalapi/moves.go b/pkg/handlers/internalapi/moves.go
index 5deb85a9d97..6188b30153f 100644
--- a/pkg/handlers/internalapi/moves.go
+++ b/pkg/handlers/internalapi/moves.go
@@ -368,6 +368,18 @@ func (h GetAllMovesHandler) Handle(params moveop.GetAllMovesParams) middleware.R
}
/** End of Feature Flag Block **/
+ /** Feature Flag - Mobile Home Shipment **/
+ featureFlagNameMH := "mobileHome"
+ isMobileHomeFeatureOn := false
+ flagMH, err := h.FeatureFlagFetcher().GetBooleanFlagForUser(params.HTTPRequest.Context(), appCtx, featureFlagNameMH, map[string]string{})
+ if err != nil {
+ appCtx.Logger().Error("Error fetching feature flag", zap.String("featureFlagKey", featureFlagName), zap.Error(err))
+ isMobileHomeFeatureOn = false
+ } else {
+ isMobileHomeFeatureOn = flagMH.Match
+ }
+ /** End of Feature Flag Block **/
+
for _, move := range movesList {
/** Feature Flag - Boat Shipment **/
@@ -448,6 +460,23 @@ func (h GetAllMovesHandler) Handle(params moveop.GetAllMovesParams) middleware.R
}
/** End of Feature Flag Block **/
+ /** Feature Flag - Mobile Home Shipment **/
+ if !isMobileHomeFeatureOn {
+ var filteredShipments models.MTOShipments
+ if move.MTOShipments != nil {
+ filteredShipments = models.MTOShipments{}
+ }
+ for i, shipment := range move.MTOShipments {
+ if shipment.ShipmentType == models.MTOShipmentTypeMobileHome {
+ continue
+ }
+
+ filteredShipments = append(filteredShipments, move.MTOShipments[i])
+ }
+ move.MTOShipments = filteredShipments
+ }
+ /** End of Feature Flag Block **/
+
previousMovesList = append(previousMovesList, move)
}
}
diff --git a/pkg/services/orchestrators/shipment/shipment_updater.go b/pkg/services/orchestrators/shipment/shipment_updater.go
index b8f3d9d30d2..574bad183f0 100644
--- a/pkg/services/orchestrators/shipment/shipment_updater.go
+++ b/pkg/services/orchestrators/shipment/shipment_updater.go
@@ -80,7 +80,7 @@ func (s *shipmentUpdater) UpdateShipment(appCtx appcontext.AppContext, shipment
mtoShipment.BoatShipment = boatShipment
return nil
- } else if shipment.ShipmentType == models.MTOShipmentTypeMobileHome {
+ } else if shipment.ShipmentType == models.MTOShipmentTypeMobileHome && shipment.MobileHome != nil {
shipment.MobileHome.ShipmentID = mtoShipment.ID
shipment.MobileHome.Shipment = *mtoShipment
diff --git a/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx
new file mode 100644
index 00000000000..ed539caa480
--- /dev/null
+++ b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.jsx
@@ -0,0 +1,330 @@
+import React from 'react';
+import { func } from 'prop-types';
+import * as Yup from 'yup';
+import { Formik, Field } from 'formik';
+import { Button, Form, Label, Textarea } from '@trussworks/react-uswds';
+import classnames from 'classnames';
+
+import styles from './MobileHomeShipmentForm.module.scss';
+
+import SectionWrapper from 'components/Customer/SectionWrapper';
+import Hint from 'components/Hint';
+import Fieldset from 'shared/Fieldset';
+import formStyles from 'styles/form.module.scss';
+import { ShipmentShape } from 'types/shipment';
+import TextField from 'components/form/fields/TextField/TextField';
+import MaskedTextField from 'components/form/fields/MaskedTextField/MaskedTextField';
+import Callout from 'components/Callout';
+import { ErrorMessage } from 'components/form/index';
+import { convertInchesToFeetAndInches } from 'utils/formatMtoShipment';
+
+const currentYear = new Date().getFullYear();
+const maxYear = currentYear + 2;
+
+const validationShape = {
+ 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(),
+ 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),
+ 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),
+ customerRemarks: Yup.string(),
+};
+
+const MobileHomeShipmentForm = ({ mtoShipment, onBack, onSubmit }) => {
+ const { year, make, model, lengthInInches, widthInInches, heightInInches } = mtoShipment?.mobileHomeShipment || {};
+
+ const length = convertInchesToFeetAndInches(lengthInInches);
+ const width = convertInchesToFeetAndInches(widthInInches);
+ const height = convertInchesToFeetAndInches(heightInInches);
+
+ const initialValues = {
+ 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,
+ customerRemarks: mtoShipment?.customerRemarks,
+ };
+
+ return (
+
+ {({ isValid, handleSubmit, values, errors, touched, setFieldTouched, setFieldError, validateForm }) => {
+ const lengthHasError = !!(
+ (touched.lengthFeet && errors.lengthFeet) ||
+ (touched.lengthInches && errors.lengthFeet)
+ );
+ const widthHasError = !!((touched.widthFeet && errors.widthFeet) || (touched.widthInches && errors.widthFeet));
+ const heightHasError = !!(
+ (touched.heightFeet && errors.heightFeet) ||
+ (touched.heightInches && errors.heightFeet)
+ );
+ if (touched.lengthInches && !touched.lengthFeet) {
+ setFieldTouched('lengthFeet', true);
+ }
+ if (touched.widthInches && !touched.widthFeet) {
+ setFieldTouched('widthFeet', true);
+ }
+ if (touched.heightInches && !touched.heightFeet) {
+ setFieldTouched('heightFeet', true);
+ }
+ // manually turn off 'required' error when page loads if field is empty.
+ if (values.year === null && !touched.year && errors.year === 'Required') {
+ setFieldError('year', null);
+ }
+ return (
+
+ }
+ >
+
+
+
+ Examples
+
+ -
+ Dimensions of the mobile home on the trailer are signigicantly different than one would expect
+ given their individual dimensions
+
+
+ - Access info for your origin or destination address/marina
+
+
+
+
+
+ 250 characters
+
+
+
+
+
+
+
+
+
+ );
+ }}
+
+ );
+};
+
+MobileHomeShipmentForm.propTypes = {
+ mtoShipment: ShipmentShape,
+ onBack: func.isRequired,
+ onSubmit: func.isRequired,
+};
+
+MobileHomeShipmentForm.defaultProps = {
+ mtoShipment: undefined,
+};
+
+export default MobileHomeShipmentForm;
diff --git a/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.module.scss b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.module.scss
new file mode 100644
index 00000000000..09d5efe98f8
--- /dev/null
+++ b/src/components/Customer/MobileHomeShipment/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/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.stories.jsx b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.stories.jsx
new file mode 100644
index 00000000000..9cbee05fd9a
--- /dev/null
+++ b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.stories.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { expect } from '@storybook/jest';
+import { action } from '@storybook/addon-actions';
+import { Grid, GridContainer } from '@trussworks/react-uswds';
+import { within, userEvent } from '@storybook/testing-library';
+
+import MobileHomeShipmentForm from './MobileHomeShipmentForm';
+
+export default {
+ title: 'Customer Components / Mobile Home Shipment / Mobile Home Shipment Form',
+ component: MobileHomeShipmentForm,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+
+
+ ),
+ ],
+};
+
+const Template = (args) => ;
+
+export const BlankMobileHomeShipmentForm = Template.bind({});
+BlankMobileHomeShipmentForm.args = {
+ onSubmit: action('submit button clicked'),
+ onBack: action('back button clicked'),
+ mtoShipment: {
+ mobileHomeShipment: {
+ year: '',
+ make: '',
+ model: '',
+ lengthInInches: null,
+ widthInInches: null,
+ heightInInches: null,
+ },
+ },
+};
+
+export const FilledMobileHomeShipmentForm = Template.bind({});
+FilledMobileHomeShipmentForm.args = {
+ onSubmit: action('submit button clicked'),
+ onBack: action('back button clicked'),
+ mtoShipment: {
+ mobileHomeShipment: {
+ year: 2022,
+ make: 'Yamaha',
+ model: '242X',
+ lengthInInches: 288, // 24 feet
+ widthInInches: 102, // 8 feet 6 inches
+ heightInInches: 84, // 7 feet
+ },
+ },
+};
+
+export const ErrorMobileHomeShipmentForm = Template.bind({});
+ErrorMobileHomeShipmentForm.args = {
+ onSubmit: action('submit button clicked'),
+ onBack: action('back button clicked'),
+ mtoShipment: {
+ mobileHomeShipment: {
+ year: '',
+ make: '',
+ model: '',
+ lengthInInches: null,
+ widthInInches: null,
+ heightInInches: null,
+ },
+ },
+};
+ErrorMobileHomeShipmentForm.play = async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await expect(canvas.getByRole('button', { name: 'Continue' })).toBeEnabled();
+
+ await userEvent.click(canvas.getByRole('button', { name: 'Continue' }));
+};
+ErrorMobileHomeShipmentForm.parameters = {
+ happo: false,
+};
diff --git a/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.test.jsx b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.test.jsx
new file mode 100644
index 00000000000..eb65ff30eb4
--- /dev/null
+++ b/src/components/Customer/MobileHomeShipment/MobileHomeShipmentForm/MobileHomeShipmentForm.test.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import { render, screen, act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MobileHomeShipmentForm from './MobileHomeShipmentForm';
+
+const mtoShipment = {
+ mobileHomeShipment: {
+ year: '2022',
+ make: 'Skyline Homes',
+ model: 'Crown',
+ lengthInInches: 288, // 24 feet
+ widthInInches: 102, // 8 feet 6 inches
+ heightInInches: 84, // 7 feet
+ },
+};
+const emptyMobileHomeInfo = {
+ mobileHomeShipment: {
+ year: '',
+ make: '',
+ model: '',
+ lengthInInches: 0, // 24 feet
+ widthInInches: 0, // 8 feet 6 inches
+ heightInInches: 0, // 7 feet
+ },
+};
+
+const defaultProps = {
+ onSubmit: jest.fn(),
+ onBack: jest.fn(),
+ mtoShipment,
+};
+
+const emptyInfoProps = {
+ onSubmit: jest.fn(),
+ onBack: jest.fn(),
+ emptyMobileHomeInfo,
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('MobileHomeShipmentForm component', () => {
+ describe('displays form', () => {
+ it('renders filled form on load', async () => {
+ render();
+ expect(screen.getByTestId('year')).toHaveValue(mtoShipment.mobileHomeShipment.year);
+ expect(screen.getByTestId('make')).toHaveValue(mtoShipment.mobileHomeShipment.make);
+ expect(screen.getByTestId('model')).toHaveValue(mtoShipment.mobileHomeShipment.model);
+ expect(screen.getByTestId('lengthFeet')).toHaveValue('24');
+ expect(screen.getByTestId('lengthInches')).toHaveValue('0');
+ expect(screen.getByTestId('widthFeet')).toHaveValue('8');
+ expect(screen.getByTestId('widthInches')).toHaveValue('6');
+ expect(screen.getByTestId('heightFeet')).toHaveValue('7');
+ expect(screen.getByTestId('heightInches')).toHaveValue('0');
+ expect(
+ screen.getByLabelText(
+ 'Are there things about this mobile home shipment that your counselor or movers should know or discuss with you?',
+ ),
+ ).toBeVisible();
+ });
+ });
+
+ describe('validates form fields and displays error messages', () => {
+ it('marks required inputs when left empty', async () => {
+ render();
+
+ const requiredFields = [
+ 'year',
+ 'make',
+ 'model',
+ 'lengthFeet',
+ 'lengthInches',
+ 'widthFeet',
+ 'widthInches',
+ 'heightFeet',
+ 'heightInches',
+ ];
+
+ await act(async () => {
+ requiredFields.forEach(async (field) => {
+ const input = screen.getByTestId(field);
+ await userEvent.clear(input);
+ // await userEvent.click(input);
+ fireEvent.blur(input);
+ });
+ });
+
+ expect(screen.getAllByTestId('errorMessage').length).toBe(requiredFields.length);
+ });
+ });
+
+ describe('form submission', () => {
+ it('submits the form with valid data', async () => {
+ render();
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
+ });
+
+ expect(defaultProps.onSubmit).toHaveBeenCalled();
+ });
+
+ it('does not submit the form with invalid data', async () => {
+ render();
+
+ await act(async () => {
+ await userEvent.clear(screen.getByTestId('year'));
+ await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
+ });
+
+ expect(defaultProps.onSubmit).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/Customer/MtoShipmentForm/MtoShipmentForm.jsx b/src/components/Customer/MtoShipmentForm/MtoShipmentForm.jsx
index 3cf57673224..354407ad2a6 100644
--- a/src/components/Customer/MtoShipmentForm/MtoShipmentForm.jsx
+++ b/src/components/Customer/MtoShipmentForm/MtoShipmentForm.jsx
@@ -189,7 +189,9 @@ class MtoShipmentForm extends Component {
const isNTS = shipmentType === SHIPMENT_OPTIONS.NTS;
const isNTSR = shipmentType === SHIPMENT_OPTIONS.NTSR;
const isBoat = shipmentType === SHIPMENT_TYPES.BOAT_HAUL_AWAY || shipmentType === SHIPMENT_TYPES.BOAT_TOW_AWAY;
- const shipmentNumber = shipmentType === SHIPMENT_OPTIONS.HHG || isBoat ? this.getShipmentNumber() : null;
+ const isMobileHome = shipmentType === SHIPMENT_TYPES.MOBILE_HOME;
+ const shipmentNumber =
+ shipmentType === SHIPMENT_OPTIONS.HHG || isBoat || isMobileHome ? this.getShipmentNumber() : null;
const isRetireeSeparatee =
orders.orders_type === ORDERS_TYPE.RETIREMENT || orders.orders_type === ORDERS_TYPE.SEPARATION;
@@ -662,7 +664,7 @@ class MtoShipmentForm extends Component {
You can change details about your move by talking with your counselor or your movers
- {isBoat ? (
+ {isBoat || isMobileHome ? (