@@ -600,11 +619,13 @@ export class Home extends Component {
{` ${shipmentNumber} `}
{shipment?.ppmShipment?.advanceStatus === ADVANCE_STATUSES.APPROVED.apiValue && (
- // TODO: B-18060 will add link to method that will create the AOA packet and return for download
-
- Download AOA Paperwork (PDF)
-
+
)}
{shipment?.ppmShipment?.advanceStatus === ADVANCE_STATUSES.REJECTED.apiValue && (
diff --git a/src/pages/MyMove/Home/index.test.jsx b/src/pages/MyMove/Home/index.test.jsx
index 02fc0cb49c2..798b4cf7a02 100644
--- a/src/pages/MyMove/Home/index.test.jsx
+++ b/src/pages/MyMove/Home/index.test.jsx
@@ -1,10 +1,10 @@
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
-import { render, screen, within } from '@testing-library/react';
+import { render, screen, within, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mount } from 'enzyme';
import moment from 'moment';
-import { generatePath } from 'react-router-dom';
+import { generatePath, MemoryRouter } from 'react-router-dom';
import { v4 } from 'uuid';
import { Home } from './index';
@@ -23,6 +23,7 @@ import {
createPPMShipmentWithFinalIncentive,
createSubmittedPPMShipment,
} from 'utils/test/factories/ppmShipment';
+import { downloadPPMAOAPacket } from 'services/internalApi';
jest.mock('containers/FlashMessage/FlashMessage', () => {
const MockFlash = () =>
Flash message
;
@@ -36,6 +37,11 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
+jest.mock('services/internalApi', () => ({
+ ...jest.requireActual('services/internalApi'),
+ downloadPPMAOAPacket: jest.fn(),
+}));
+
const defaultProps = {
serviceMember: {
id: v4(),
@@ -563,7 +569,11 @@ describe('Home component', () => {
it('renders advance request submitted for PPM', () => {
const mtoShipments = [approvedAdvancePPMShipment];
const props = { ...defaultProps, ...propUpdates, mtoShipments };
- render(
);
+ render(
+
+
+ ,
+ );
expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
});
@@ -574,7 +584,11 @@ describe('Home component', () => {
expect(advanceStep.prop('completedHeaderText')).toEqual('Advance request reviewed');
const props = { ...defaultProps, ...propUpdates, mtoShipments };
- render(
);
+ render(
+
+
+ ,
+ );
expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
});
@@ -585,7 +599,11 @@ describe('Home component', () => {
expect(advanceStep.prop('completedHeaderText')).toEqual('Advance request reviewed');
const props = { ...defaultProps, ...propUpdates, mtoShipments };
- render(
);
+ render(
+
+
+ ,
+ );
expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
});
@@ -597,7 +615,11 @@ describe('Home component', () => {
expect(advanceStep.prop('completedHeaderText')).toEqual('Advance request reviewed');
const props = { ...defaultProps, ...propUpdates, mtoShipments };
- render(
);
+ render(
+
+
+ ,
+ );
expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
expect(screen.getByText('Advance request denied')).toBeInTheDocument();
});
@@ -609,6 +631,64 @@ describe('Home component', () => {
expect(advanceStep.prop('completedHeaderText')).toEqual('Advance request denied');
});
+
+ it('Download AOA Packet PPM - Error', async () => {
+ downloadPPMAOAPacket.mockRejectedValue({
+ response: { body: { title: 'Error title', detail: 'Error detail' } },
+ });
+
+ const mtoShipments = [approvedAdvancePPMShipment];
+ const props = { ...defaultProps, ...propUpdates, mtoShipments };
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
+
+ const downloadAOAButton = screen.getByText('Download AOA Paperwork (PDF)');
+ expect(downloadAOAButton).toBeInTheDocument();
+ await userEvent.click(downloadAOAButton);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/Something went wrong downloading PPM AOA paperwork./, { exact: false }),
+ ).toBeInTheDocument();
+ expect(downloadPPMAOAPacket).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('Download AOA Packet PPM - Success', async () => {
+ const mockResponse = {
+ ok: true,
+ headers: {
+ 'content-disposition': 'filename="test.pdf"',
+ },
+ status: 200,
+ data: null,
+ };
+ downloadPPMAOAPacket.mockImplementation(() => Promise.resolve(mockResponse));
+
+ const mtoShipments = [approvedAdvancePPMShipment];
+ const props = { ...defaultProps, ...propUpdates, mtoShipments };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Download AOA Paperwork (PDF)')).toBeInTheDocument();
+
+ const downloadAOAButton = screen.getByText('Download AOA Paperwork (PDF)');
+ expect(downloadAOAButton).toBeInTheDocument();
+
+ await userEvent.click(downloadAOAButton);
+
+ await waitFor(() => {
+ expect(downloadPPMAOAPacket).toHaveBeenCalledTimes(1);
+ });
+ });
});
describe('for HHG moves (no PPM)', () => {
diff --git a/src/services/ghcApi.js b/src/services/ghcApi.js
index 319f353489c..7a3b7c3790b 100644
--- a/src/services/ghcApi.js
+++ b/src/services/ghcApi.js
@@ -1,6 +1,6 @@
import Swagger from 'swagger-client';
-import { makeSwaggerRequest, requestInterceptor, responseInterceptor } from './swaggerRequest';
+import { makeSwaggerRequest, requestInterceptor, responseInterceptor, makeSwaggerRequestRaw } from './swaggerRequest';
let ghcClient = null;
@@ -21,6 +21,11 @@ export async function makeGHCRequest(operationPath, params = {}, options = {}) {
return makeSwaggerRequest(client, operationPath, params, options);
}
+export async function makeGHCRequestRaw(operationPath, params = {}) {
+ const client = await getGHCClient();
+ return makeSwaggerRequestRaw(client, operationPath, params);
+}
+
export async function getPaymentRequest(key, paymentRequestID) {
return makeGHCRequest('paymentRequests.getPaymentRequest', { paymentRequestID });
}
@@ -662,3 +667,7 @@ export const reviewShipmentAddressUpdate = async ({ shipmentID, ifMatchETag, bod
{ schemaKey, normalize },
);
};
+
+export async function downloadPPMAOAPacket(ppmShipmentId) {
+ return makeGHCRequestRaw('ppm.showAOAPacket', { ppmShipmentId });
+}
diff --git a/src/services/internalApi.js b/src/services/internalApi.js
index 1e024712296..5cfa4ebeaba 100644
--- a/src/services/internalApi.js
+++ b/src/services/internalApi.js
@@ -1,6 +1,6 @@
import Swagger from 'swagger-client';
-import { makeSwaggerRequest, requestInterceptor, responseInterceptor } from './swaggerRequest';
+import { makeSwaggerRequest, requestInterceptor, responseInterceptor, makeSwaggerRequestRaw } from './swaggerRequest';
let internalClient = null;
@@ -28,6 +28,11 @@ export async function makeInternalRequest(operationPath, params = {}, options =
return makeSwaggerRequest(client, operationPath, params, options);
}
+export async function makeInternalRequestRaw(operationPath, params = {}) {
+ const client = await getInternalClient();
+ return makeSwaggerRequestRaw(client, operationPath, params);
+}
+
export async function getLoggedInUser(normalize = true) {
return makeInternalRequest('users.showLoggedInUser', {}, { normalize });
}
@@ -526,3 +531,7 @@ export async function submitPPMShipmentSignedCertification(ppmShipmentId, payloa
export async function searchTransportationOffices(search) {
return makeInternalRequest('transportation_offices.getTransportationOffices', { search }, { normalize: false });
}
+
+export async function downloadPPMAOAPacket(ppmShipmentId) {
+ return makeInternalRequestRaw('ppm.showAOAPacket', { ppmShipmentId });
+}
diff --git a/src/services/swaggerRequest.test.js b/src/services/swaggerRequest.test.js
index 6727db9c47c..477561ce113 100644
--- a/src/services/swaggerRequest.test.js
+++ b/src/services/swaggerRequest.test.js
@@ -1,4 +1,4 @@
-import { makeSwaggerRequest, normalizeResponse } from './swaggerRequest';
+import { makeSwaggerRequest, normalizeResponse, makeSwaggerRequestRaw } from './swaggerRequest';
function mockGetClient(operationMock) {
return Promise.resolve({
@@ -47,6 +47,48 @@ function mockGetClient(operationMock) {
});
}
+function mockGetClientRaw(operationMock) {
+ return Promise.resolve({
+ apis: {
+ ppm: {
+ showAOAPacket: operationMock,
+ },
+ },
+ spec: {
+ paths: {
+ 'ppm-shipments/{shipmentID}/aoa-packet': {
+ get: {
+ consumes: ['application/pdf'],
+ description: 'Returns a Shipment tied to the move ID',
+ operationId: 'showAOAPacket',
+ parameters: [
+ {
+ description: 'UUID of the Shipment being updated',
+ format: 'uuid',
+ in: 'path',
+ name: 'ppmShipmentId',
+ required: true,
+ type: 'string',
+ },
+ ],
+ produces: ['application/pdf'],
+ responses: {
+ 200: {
+ description: 'Returns AOA PDF',
+ },
+ 400: {
+ description: 'Bad request',
+ },
+ },
+ summary: 'Returns an AOA PDF',
+ tags: ['ppm'],
+ },
+ },
+ },
+ },
+ });
+}
+
describe('normalizeResponse', () => {
it('normalizes data with a valid schemaKey', () => {
const rawData = {
@@ -155,3 +197,42 @@ describe('makeSwaggerRequest', () => {
expect(request).toEqual(mockResponse.body);
});
});
+
+describe('makeSwaggerRequestRaw', () => {
+ it('makes a failed request', async () => {
+ const mockResponse = {
+ ok: false,
+ status: 400,
+ body: {
+ error: {
+ message: 'Test error message',
+ },
+ },
+ };
+
+ const errorResponse = () => Promise.reject(mockResponse);
+ const opMock = jest.fn(() => errorResponse());
+
+ const mockClient = await mockGetClientRaw(opMock);
+
+ await makeSwaggerRequestRaw(mockClient, 'ppm.showAOAPacket', { ppmShipmentId: 'abcd-1234' }).catch(
+ async (error) => {
+ expect(error).toEqual(mockResponse);
+ },
+ );
+ });
+
+ it('success returns the raw response', async () => {
+ const mockResponse = {
+ ok: true,
+ status: 200,
+ data: 'MOCK_SOMETHING_IN_RESPONSE_DATA',
+ };
+
+ const opMock = jest.fn(() => Promise.resolve(mockResponse));
+ const mockClient = await mockGetClientRaw(opMock);
+ const request = await makeSwaggerRequestRaw(mockClient, 'ppm.showAOAPacket', { ppmShipmentId: 'abcd-1234' });
+
+ expect(request).toEqual(mockResponse);
+ });
+});
diff --git a/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.jsx b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.jsx
new file mode 100644
index 00000000000..61d9e6f34c2
--- /dev/null
+++ b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { Button } from '@trussworks/react-uswds';
+import PropTypes from 'prop-types';
+
+import styles from './AsyncPacketDownloadLink.module.scss';
+
+export const onPacketDownloadSuccessHandler = (response) => {
+ // dynamically update DOM to trigger browser to display SAVE AS download file modal
+ const contentType = response.headers['content-type'];
+ const url = window.URL.createObjectURL(
+ new Blob([response.data], {
+ type: contentType,
+ }),
+ );
+
+ const link = document.createElement('a');
+ link.href = url;
+ const disposition = response.headers['content-disposition'];
+ const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
+ let strtime = new Date().toISOString();
+ // we are expecting PDF
+ let filename = `packet-${strtime}.pdf`;
+ const matches = filenameRegex.exec(disposition);
+ if (matches != null && matches[1]) {
+ filename = matches[1].replace(/['"]/g, '');
+ }
+ link.setAttribute('download', filename);
+
+ document.body.appendChild(link);
+
+ // Start download
+ link.click();
+
+ // Clean up and remove the link
+ link.parentNode.removeChild(link);
+};
+
+/**
+ * Shared component to render download links for AOA/Payment Packet packets.
+ * @param {string} id uuid to download
+ * @param {string} label link text
+ * @param {Promise} asyncRetrieval asynch document retrieval
+ * @param {func} onSucccess on success response handler
+ * @param {func} onFailure on failure response handler
+ */
+const AsyncPacketDownloadLink = ({ id, label, asyncRetrieval, onSucccess, onFailure }) => {
+ const dataTestId = `asyncPacketDownloadLink${id}`;
+ return (
+
+ asyncRetrieval(id)
+ .then((response) => {
+ onSucccess(response);
+ })
+ .catch(() => {
+ onFailure();
+ })
+ }
+ >
+ {label}
+
+ );
+};
+
+AsyncPacketDownloadLink.propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ asyncRetrieval: PropTypes.func.isRequired,
+ onSucccess: PropTypes.func.isRequired,
+ onFailure: PropTypes.func.isRequired,
+};
+
+AsyncPacketDownloadLink.defaultProps = {
+ onSucccess: onPacketDownloadSuccessHandler,
+ onFailure: () => {},
+};
+
+export default AsyncPacketDownloadLink;
diff --git a/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.module.scss b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.module.scss
new file mode 100644
index 00000000000..b2b2f8658d2
--- /dev/null
+++ b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.module.scss
@@ -0,0 +1,18 @@
+@import 'shared/styles/colors.scss';
+@import 'shared/styles/basics';
+
+
+:global(.usa-button).downloadButtonToLink {
+ background-color: transparent;
+ color: $primary;
+ font-weight: normal;
+ margin: 0;
+ @include u-padding(0);
+ border-width: 0px;
+ outline: none !important;
+ height: 30px !important;
+}
+
+:global(.usa-button).downloadButtonToLink:hover {
+ text-decoration: underline !important;
+}
diff --git a/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.test.jsx b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.test.jsx
new file mode 100644
index 00000000000..c9d04e210d1
--- /dev/null
+++ b/src/shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink.test.jsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import AsyncPacketDownloadLink, { onPacketDownloadSuccessHandler } from './AsyncPacketDownloadLink';
+
+afterEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('AsyncPacketDownloadLink success', () => {
+ it('success', async () => {
+ const asyncRetrieval = jest.fn().mockImplementation(() => Promise.resolve());
+ const onSuccessHandler = jest.fn();
+ const onErrorHandler = jest.fn();
+ const expectedId = 'testID';
+ const expectedLabel = 'test';
+ render(
+
,
+ );
+ expect(screen.getByText(expectedLabel, { exact: false })).toBeInTheDocument();
+
+ const downloadButton = screen.getByText(expectedLabel);
+ expect(downloadButton).toBeInTheDocument();
+ await userEvent.click(downloadButton);
+
+ await waitFor(() => {
+ expect(asyncRetrieval).toHaveBeenCalledTimes(1);
+ expect(onSuccessHandler).toHaveBeenCalledTimes(1);
+ expect(onErrorHandler).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ it('AsyncPacketDownloadLink failure', async () => {
+ const asyncRetrieval = jest.fn().mockImplementation(() => Promise.reject());
+ const onSuccessHandler = jest.fn();
+ const onErrorHandler = jest.fn();
+ const expectedId = 'testID';
+ const expectedLabel = 'test';
+ render(
+
,
+ );
+ expect(screen.getByText(expectedLabel, { exact: false })).toBeInTheDocument();
+
+ const downloadButton = screen.getByText(expectedLabel);
+ expect(downloadButton).toBeInTheDocument();
+ await userEvent.click(downloadButton);
+
+ await waitFor(() => {
+ expect(asyncRetrieval).toHaveBeenCalledTimes(1);
+ expect(asyncRetrieval).toHaveBeenCalledWith(expectedId);
+ expect(onSuccessHandler).toHaveBeenCalledTimes(0);
+ expect(onErrorHandler).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('success - downloadPacketOnSuccessHandler', () => {
+ const expectedResponseData = 'MOCK_PDF_DATA';
+ const expectedFileName = 'test.pdf';
+ const expectedContentType = 'application/pdf';
+
+ global.URL.createObjectURL = jest.fn();
+
+ const mockResponse = {
+ ok: true,
+ headers: {
+ 'content-disposition': `filename="${expectedFileName}"`,
+ 'content-type': expectedContentType,
+ },
+ status: 200,
+ data: expectedResponseData,
+ };
+
+ function makeAnchor(target) {
+ const setAttributeMock = jest.fn((key, value) => (target[key] = value));
+ return {
+ target,
+ setAttribute: setAttributeMock,
+ click: jest.fn(),
+ remove: jest.fn(),
+ parentNode: {
+ removeChild: jest.fn(),
+ },
+ };
+ }
+
+ jest.spyOn(document.body, 'appendChild').mockReturnValue(null);
+ const anchor = makeAnchor({ href: '#', download: '' });
+ jest.spyOn(document, 'createElement').mockReturnValue(anchor);
+ const clickSpy = jest.spyOn(anchor, 'click');
+
+ const mBlob = { size: 1024, type: 'application/pdf' };
+ const blobSpy = jest.spyOn(global, 'Blob').mockImplementationOnce(() => mBlob);
+
+ onPacketDownloadSuccessHandler(mockResponse);
+
+ // verify response.data is used for blob
+ expect(blobSpy).toBeCalledWith([expectedResponseData], {
+ type: expectedContentType,
+ });
+
+ // verify hyperlink was created
+ expect(document.createElement).toBeCalledWith('a');
+
+ // verify download attribute is from content-disposition
+ expect(document.body.appendChild).toBeCalledWith(
+ expect.objectContaining({
+ target: { download: expectedFileName, href: '#' },
+ }),
+ );
+
+ // verify click event is invoked to download file
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+
+ // verify link is removed
+ expect(anchor.parentNode.removeChild).toBeCalledWith(anchor);
+ });
+});
diff --git a/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.jsx b/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.jsx
new file mode 100644
index 00000000000..16709f78673
--- /dev/null
+++ b/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button } from '@trussworks/react-uswds';
+
+import styles from './DownloadAOAErrorModal.module.scss';
+import Modal, { ModalClose, ModalActions, connectModal } from 'components/Modal/Modal';
+import SystemError from 'components/SystemError';
+
+export const DownloadAOAErrorModal = ({ closeModal }) => (
+
+
+
+ Something went wrong downloading PPM AOA paperwork. Please try again later. If that doesn't fix it, contact
+ the Technical Help Desk .
+
+
+
+ ok
+
+
+
+);
+
+DownloadAOAErrorModal.propTypes = {
+ closeModal: PropTypes.func.isRequired,
+};
+
+DownloadAOAErrorModal.displayName = 'DownloadAOAErrorModal';
+
+export default connectModal(DownloadAOAErrorModal);
diff --git a/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.module.scss b/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.module.scss
new file mode 100644
index 00000000000..5015bb76c45
--- /dev/null
+++ b/src/shared/DownloadAOAErrorModal/DownloadAOAErrorModal.module.scss
@@ -0,0 +1,19 @@
+@import 'shared/styles/_basics';
+@import 'shared/styles/_mixins';
+@import 'shared/styles/_variables';
+
+.Modal {
+ h3 {
+ font-size: 1.45rem;
+ }
+
+ li {
+ @include u-margin-bottom('105');
+ }
+}
+
+.Button {
+ @media (max-width: $tablet) {
+ width: 100%;
+ }
+}