diff --git a/src/components/Office/DefinitionLists/PPMShipmentInfoList.jsx b/src/components/Office/DefinitionLists/PPMShipmentInfoList.jsx index 7e0159d900c..4acf5414716 100644 --- a/src/components/Office/DefinitionLists/PPMShipmentInfoList.jsx +++ b/src/components/Office/DefinitionLists/PPMShipmentInfoList.jsx @@ -6,6 +6,7 @@ import shipmentDefinitionListsStyles from './ShipmentDefinitionLists.module.scss import styles from 'styles/descriptionList.module.scss'; import { formatDate } from 'shared/dates'; +import AsyncPacketDownloadLink from 'shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink'; import { ShipmentShape } from 'types/shipment'; import { formatCentsTruncateWhole, formatWeight } from 'utils/formatters'; import { setFlagStyles, setDisplayFlags, getDisplayFlags, fieldValidationShape } from 'utils/displayFlags'; @@ -13,6 +14,7 @@ import { ADVANCE_STATUSES } from 'constants/ppms'; import affiliation from 'content/serviceMemberAgencies'; import { permissionTypes } from 'constants/permissions'; import Restricted from 'components/Restricted/Restricted'; +import { downloadPPMAOAPacket } from 'services/ghcApi'; const PPMShipmentInfoList = ({ className, @@ -22,6 +24,7 @@ const PPMShipmentInfoList = ({ showWhenCollapsed, isExpanded, isForEvaluationReport, + onErrorModalToggle, }) => { const { hasRequestedAdvance, @@ -187,9 +190,12 @@ const PPMShipmentInfoList = ({
AOA Packet

- - Download AOA Paperwork (PDF) - +

@@ -277,6 +283,7 @@ PPMShipmentInfoList.propTypes = { showWhenCollapsed: PropTypes.arrayOf(PropTypes.string), isExpanded: PropTypes.bool, isForEvaluationReport: PropTypes.bool, + onErrorModalToggle: PropTypes.func, }; PPMShipmentInfoList.defaultProps = { @@ -286,6 +293,7 @@ PPMShipmentInfoList.defaultProps = { showWhenCollapsed: [], isExpanded: false, isForEvaluationReport: false, + onErrorModalToggle: undefined, }; export default PPMShipmentInfoList; diff --git a/src/components/Office/DefinitionLists/PPMShipmentInfoList.test.jsx b/src/components/Office/DefinitionLists/PPMShipmentInfoList.test.jsx index acba089bead..86da49eb2ab 100644 --- a/src/components/Office/DefinitionLists/PPMShipmentInfoList.test.jsx +++ b/src/components/Office/DefinitionLists/PPMShipmentInfoList.test.jsx @@ -1,11 +1,23 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import PPMShipmentInfoList from './PPMShipmentInfoList'; import affiliation from 'content/serviceMemberAgencies'; import { MockProviders } from 'testUtils'; import { permissionTypes } from 'constants/permissions'; +import { ADVANCE_STATUSES } from 'constants/ppms'; +import { downloadPPMAOAPacket } from 'services/ghcApi'; + +jest.mock('services/ghcApi', () => ({ + ...jest.requireActual('services/ghcApi'), + downloadPPMAOAPacket: jest.fn(), +})); + +afterEach(() => { + jest.resetAllMocks(); +}); const renderWithPermissions = (shipment) => { render( @@ -50,4 +62,55 @@ describe('PPMShipmentInfoList', () => { expect(screen.getByTestId('closeout').textContent).toEqual('—'); expect(screen.getByText('Closeout office')).toBeInTheDocument(); }); + + it('PPM Download AOA Paperwork - success', async () => { + const mockResponse = { + ok: true, + headers: { + 'content-disposition': 'filename="test.pdf"', + }, + status: 200, + data: null, + }; + downloadPPMAOAPacket.mockImplementation(() => Promise.resolve(mockResponse)); + + renderWithPermissions({ ppmShipment: { advanceStatus: ADVANCE_STATUSES.APPROVED.apiValue } }); + + expect(screen.getByText('Download AOA Paperwork (PDF)', { exact: false })).toBeInTheDocument(); + + const downloadAOAButton = screen.getByText('Download AOA Paperwork (PDF)'); + expect(downloadAOAButton).toBeInTheDocument(); + + await userEvent.click(downloadAOAButton); + + await waitFor(() => { + expect(downloadPPMAOAPacket).toHaveBeenCalledTimes(1); + }); + }); + + it('PPM Download AOA Paperwork - failure', async () => { + downloadPPMAOAPacket.mockRejectedValue({ + response: { body: { title: 'Error title', detail: 'Error detail' } }, + }); + + const shipment = { ppmShipment: { advanceStatus: ADVANCE_STATUSES.APPROVED.apiValue } }; + const onErrorHandler = jest.fn(); + + 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); + expect(onErrorHandler).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx b/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx index 7dd7516bb1a..f6578d26ab5 100644 --- a/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx +++ b/src/components/Office/DefinitionLists/ShipmentInfoListSelector.jsx @@ -20,6 +20,7 @@ const ShipmentInfoListSelector = ({ shipmentType, isForEvaluationReport, destinationDutyLocationPostalCode, + onErrorModalToggle, }) => { switch (shipmentType) { case SHIPMENT_OPTIONS.PPM: @@ -33,6 +34,7 @@ const ShipmentInfoListSelector = ({ showWhenCollapsed={showWhenCollapsed} isExpanded={isExpanded} isForEvaluationReport={isForEvaluationReport} + onErrorModalToggle={onErrorModalToggle} /> ); case SHIPMENT_OPTIONS.HHG: @@ -105,6 +107,7 @@ ShipmentInfoListSelector.propTypes = { ]), isForEvaluationReport: PropTypes.bool, destinationDutyLocationPostalCode: PropTypes.string, + onErrorModalToggle: PropTypes.func, }; ShipmentInfoListSelector.defaultProps = { @@ -117,6 +120,7 @@ ShipmentInfoListSelector.defaultProps = { neverShow: [], isForEvaluationReport: false, destinationDutyLocationPostalCode: '', + onErrorModalToggle: undefined, }; export default ShipmentInfoListSelector; diff --git a/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx b/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx index d60480df607..e5c45d88fa8 100644 --- a/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx +++ b/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx @@ -5,6 +5,7 @@ import { Checkbox, Tag } from '@trussworks/react-uswds'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classnames from 'classnames'; +import DownloadAOAErrorModal from 'shared/DownloadAOAErrorModal/DownloadAOAErrorModal'; import { EditButton, ReviewButton } from 'components/form/IconButtons'; import ShipmentInfoListSelector from 'components/Office/DefinitionLists/ShipmentInfoListSelector'; import ShipmentContainer from 'components/Office/ShipmentContainer/ShipmentContainer'; @@ -41,6 +42,7 @@ const ShipmentDisplay = ({ const [isExpanded, setIsExpanded] = useState(false); const tac = retrieveTAC(displayInfo.tacType, ordersLOA); const sac = retrieveSAC(displayInfo.sacType, ordersLOA); + const [isDownloadAOAErrorModalVisible, setIsDownloadAOAErrorModalVisible] = useState(false); const disableApproval = errorIfMissing.some((requiredInfo) => objectIsMissingFieldWithCondition(displayInfo, requiredInfo), @@ -54,6 +56,10 @@ const ShipmentDisplay = ({ 'chevron-down': !isExpanded, }); + const toggleDownloadAOAErrorModal = () => { + setIsDownloadAOAErrorModalVisible((prev) => !prev); + }; + return (
@@ -104,7 +110,9 @@ const ShipmentDisplay = ({ errorIfMissing={errorIfMissing} showWhenCollapsed={showWhenCollapsed} neverShow={neverShow} + onErrorModalToggle={toggleDownloadAOAErrorModal} /> + {editURL && ( { + setShowDownloadPPMAOAPaperworkErrorAlert(!showDownloadPPMAOAPaperworkErrorAlert); + }; + // early return if loading user/service member if (!serviceMember) { return ( @@ -410,6 +417,7 @@ const MoveHome = ({ serviceMemberMoves, isProfileComplete, serviceMember, signed submitText="Yes, Delete" closeText="No, Keep It" /> +
@@ -585,9 +593,12 @@ const MoveHome = ({ serviceMemberMoves, isProfileComplete, serviceMember, signed {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/MoveHome.test.jsx b/src/pages/MyMove/Home/MoveHome.test.jsx index 67fdb864546..26150190344 100644 --- a/src/pages/MyMove/Home/MoveHome.test.jsx +++ b/src/pages/MyMove/Home/MoveHome.test.jsx @@ -2,11 +2,13 @@ import React from 'react'; import { v4 } from 'uuid'; import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; import MoveHome from './MoveHome'; import { customerRoutes } from 'constants/routes'; import { MockProviders } from 'testUtils'; +import { downloadPPMAOAPacket } from 'services/internalApi'; jest.mock('containers/FlashMessage/FlashMessage', () => { const MockFlash = () =>
Flash message
; @@ -29,6 +31,7 @@ jest.mock('services/internalApi', () => ({ deleteMTOShipment: jest.fn(), getMTOShipmentsForMove: jest.fn(), getAllMoves: jest.fn().mockImplementation(() => Promise.resolve()), + downloadPPMAOAPacket: jest.fn().mockImplementation(() => Promise.resolve()), })); const props = { @@ -794,6 +797,8 @@ const defaultPropsAmendedOrdersWithAdvanceRequested = { uploadedAmendedOrderDocuments: [], }; +const expectedPpmShipmentID = 'd18b865f-fd12-495d-91fa-65b53d72705a'; + const defaultPropsWithAdvanceAndPPMApproved = { ...props, serviceMemberMoves: { @@ -829,7 +834,7 @@ const defaultPropsWithAdvanceAndPPMApproved = { hasProGear: false, hasReceivedAdvance: null, hasRequestedAdvance: true, - id: 'd18b865f-fd12-495d-91fa-65b53d72705a', + id: expectedPpmShipmentID, movingExpenses: [], pickupPostalCode: '74133', proGearWeight: null, @@ -1248,5 +1253,37 @@ describe('Home component', () => { expect(confirmMoveRequest.prop('actionBtnDisabled')).toBeFalsy(); expect(confirmMoveRequest.prop('actionBtnLabel')).toBe('Review your request'); }); + + it('Download AOA Paperwork - success', async () => { + const buttonId = `button[data-testid="asyncPacketDownloadLink${expectedPpmShipmentID}"]`; + expect(wrapper.find(buttonId).length).toBe(1); + const mockResponse = { + ok: true, + headers: { + 'content-disposition': 'filename="test.pdf"', + }, + status: 200, + data: null, + }; + downloadPPMAOAPacket.mockImplementation(() => Promise.resolve(mockResponse)); + await wrapper.find(buttonId).simulate('click'); + await waitFor(() => { + expect(downloadPPMAOAPacket).toHaveBeenCalledTimes(1); + }); + }); + + it('Download AOA Paperwork - failure', async () => { + const buttonId = `button[data-testid="asyncPacketDownloadLink${expectedPpmShipmentID}"]`; + expect(wrapper.find(buttonId).length).toBe(1); + downloadPPMAOAPacket.mockRejectedValue({ + response: { body: { title: 'Error title', detail: 'Error detail' } }, + }); + await wrapper.find(buttonId).simulate('click'); + await waitFor(() => { + // scrape text from error modal + expect(wrapper.text()).toContain('Something went wrong downloading PPM AOA paperwork'); + expect(downloadPPMAOAPacket).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/src/pages/MyMove/Home/index.jsx b/src/pages/MyMove/Home/index.jsx index 739548e0892..76659d3bcb3 100644 --- a/src/pages/MyMove/Home/index.jsx +++ b/src/pages/MyMove/Home/index.jsx @@ -16,6 +16,7 @@ import { HelperPPMCloseoutSubmitted, } from './HomeHelpers'; +import AsyncPacketDownloadLink from 'shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink'; import ConnectedDestructiveShipmentConfirmationModal from 'components/ConfirmationModals/DestructiveShipmentConfirmationModal'; import Contact from 'components/Customer/Home/Contact'; import DocsUploaded from 'components/Customer/Home/DocsUploaded'; @@ -30,7 +31,7 @@ import MOVE_STATUSES from 'constants/moves'; import { customerRoutes } from 'constants/routes'; import { ppmShipmentStatuses, shipmentTypes } from 'constants/shipments'; import ConnectedFlashMessage from 'containers/FlashMessage/FlashMessage'; -import { deleteMTOShipment, getMTOShipmentsForMove } from 'services/internalApi'; +import { deleteMTOShipment, getMTOShipmentsForMove, downloadPPMAOAPacket } from 'services/internalApi'; import { withContext } from 'shared/AppContext'; import { SHIPMENT_OPTIONS } from 'shared/constants'; import { @@ -55,6 +56,7 @@ import { isPPMAboutInfoComplete, isPPMShipmentComplete, isWeightTicketComplete } import withRouter from 'utils/routing'; import { RouterShape } from 'types/router'; import { ADVANCE_STATUSES } from 'constants/ppms'; +import DownloadAOAErrorModal from 'shared/DownloadAOAErrorModal/DownloadAOAErrorModal'; const Description = ({ className, children, dataTestId }) => (

@@ -81,6 +83,7 @@ export class Home extends Component { targetShipmentId: null, showDeleteSuccessAlert: false, showDeleteErrorAlert: false, + showDownloadPPMAOAPaperworkErrorAlert: false, }; } @@ -354,6 +357,12 @@ export class Home extends Component { navigate(path); }; + toggleDownloadAOAErrorModal = () => { + this.setState((prevState) => ({ + showDownloadPPMAOAPaperworkErrorAlert: !prevState.showDownloadPPMAOAPaperworkErrorAlert, + })); + }; + // eslint-disable-next-line class-methods-use-this sortAllShipments = (mtoShipments) => { const allShipments = JSON.parse(JSON.stringify(mtoShipments)); @@ -379,7 +388,13 @@ export class Home extends Component { orders, } = this.props; - const { showDeleteModal, targetShipmentId, showDeleteSuccessAlert, showDeleteErrorAlert } = this.state; + const { + showDeleteModal, + targetShipmentId, + showDeleteSuccessAlert, + showDeleteErrorAlert, + showDownloadPPMAOAPaperworkErrorAlert, + } = this.state; // early return if loading user/service member if (!serviceMember) { @@ -425,6 +440,10 @@ export class Home extends Component { submitText="Yes, Delete" closeText="No, Keep It" /> +

@@ -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 ( + + ); +}; + +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. + + + + + +); + +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%; + } +}