diff --git a/package.json b/package.json index 8243f06a5..b6afdcd12 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", - "@react-pdf/renderer": "4.3.0", + "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.32.0", "@sentry/webpack-plugin": "^2.22.4", "@stripe/react-stripe-js": "^5.4.1", @@ -93,7 +93,7 @@ "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.33", "mui-color-input": "^9.0.0", - "openstack-uicore-foundation": "5.0.43", + "openstack-uicore-foundation": "5.0.44", "p-limit": "^6.1.0", "path-browserify": "^1.0.1", "postcss-loader": "^6.2.1", @@ -180,7 +180,7 @@ "src/**/*.{js,jsx,mjs}" ], "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/__mocks__/fileMock.js", "\\.(css)$": "identity-obj-proxy" }, "transformIgnorePatterns": [ diff --git a/src/actions/__tests__/sponsor-purchases-actions.test.js b/src/actions/__tests__/sponsor-purchases-actions.test.js new file mode 100644 index 000000000..bce6afdef --- /dev/null +++ b/src/actions/__tests__/sponsor-purchases-actions.test.js @@ -0,0 +1,115 @@ +/** + * Copyright 2024 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + getRequest, + snackbarErrorMsg +} from "openstack-uicore-foundation/lib/utils/actions"; +import { generateInvoicePDF } from "openstack-uicore-foundation/lib/components/order-invoice-pdf"; +import { downloadSponsorInvoice } from "../sponsor-purchases-actions"; +import * as methods from "../../utils/methods"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn(), + snackbarErrorMsg: jest.fn((payload) => ({ + type: "SNACKBAR_ERROR_MSG_MOCK", + payload + })) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/order-invoice-pdf", + () => ({ + generateInvoicePDF: jest.fn(() => Promise.resolve()) + }) +); + +describe("downloadSponsorInvoice", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let capturedUrl; + + beforeEach(() => { + jest.clearAllMocks(); + capturedUrl = null; + window.PURCHASES_API_URL = "https://purchases.example.com"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.PURCHASES_API_URL; + }); + + const buildStore = () => + mockStore({ + currentSummitState: { currentSummit: { id: 1 } }, + currentSponsorState: { entity: { id: 123 } } + }); + + it("fetches the order for the explicit sponsorId, generates the PDF with the raw order and currentSummit, and never touches the shared order-detail state", async () => { + const fetchedOrder = { id: 7, forms: [] }; + getRequest.mockImplementation((reqAC, recAC, url) => { + capturedUrl = url; + return () => () => Promise.resolve({ response: fetchedOrder }); + }); + + const store = buildStore(); + await store.dispatch(downloadSponsorInvoice(7, 456)); + await flushPromises(); + + expect(capturedUrl).toBe( + `${window.PURCHASES_API_URL}/api/v2/summits/1/sponsors/456/purchases/7` + ); + expect(generateInvoicePDF).toHaveBeenCalledWith( + fetchedOrder, + { id: 1 }, + expect.objectContaining({ logoSrc: expect.anything() }) + ); + expect(snackbarErrorMsg).not.toHaveBeenCalled(); + }); + + it("swallows the order-fetch rejection silently since authErrorHandler already surfaced it", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("Network error")) + ); + + const store = buildStore(); + await store.dispatch(downloadSponsorInvoice(7, 456)); + await flushPromises(); + + expect(generateInvoicePDF).not.toHaveBeenCalled(); + // No second, stacked error UI on top of authErrorHandler's own message. + expect(snackbarErrorMsg).not.toHaveBeenCalled(); + }); + + it("shows an error message when PDF generation rejects", async () => { + getRequest.mockImplementation( + () => () => () => Promise.resolve({ response: { id: 7, forms: [] } }) + ); + generateInvoicePDF.mockRejectedValueOnce(new Error("PDF error")); + + const store = buildStore(); + await store.dispatch(downloadSponsorInvoice(7, 456)); + await flushPromises(); + + expect(snackbarErrorMsg).toHaveBeenCalledWith( + expect.objectContaining({ html: "errors.invoice_generation" }) + ); + }); +}); diff --git a/src/actions/sponsor-purchases-actions.js b/src/actions/sponsor-purchases-actions.js index 868acbebc..5f93da1ab 100644 --- a/src/actions/sponsor-purchases-actions.js +++ b/src/actions/sponsor-purchases-actions.js @@ -22,8 +22,10 @@ import { stopLoading, getCSV, snackbarErrorHandler, + snackbarErrorMsg, snackbarSuccessHandler } from "openstack-uicore-foundation/lib/utils/actions"; +import { generateInvoicePDF } from "openstack-uicore-foundation/lib/components/order-invoice-pdf"; import T from "i18n-react/dist/i18n-react"; import { escapeFilterValue, getAccessTokenSafely } from "../utils/methods"; import { @@ -32,6 +34,7 @@ import { DUMMY_ACTION, PURCHASE_STATUS } from "../utils/constants"; +import logoInvoice from "../assets/fn-invoice-header.png"; export const REQUEST_ALL_SPONSOR_PURCHASES = "REQUEST_ALL_SPONSOR_PURCHASES"; export const RECEIVE_ALL_SPONSOR_PURCHASES = "RECEIVE_ALL_SPONSOR_PURCHASES"; @@ -44,6 +47,9 @@ export const CLEAR_SPONSOR_ORDER = "CLEAR_SPONSOR_ORDER"; export const SPONSOR_CLIENT_ADDRESS_UPDATED = "SPONSOR_CLIENT_ADDRESS_UPDATED"; export const SPONSOR_CLIENT_UPDATED = "SPONSOR_CLIENT_UPDATED"; +const ORDER_DETAIL_EXPAND = + "forms,forms.items,forms.items.meta_fields,forms.items.type,refunds,payments,notes,fees"; + export const getAllSponsorPurchases = ( term = "", @@ -307,8 +313,7 @@ export const getSponsorOrder = (orderId) => async (dispatch, getState) => { const params = { access_token: accessToken, - expand: - "forms,forms.items,forms.items.meta_fields,forms.items.type,refunds,payments,notes,fees" + expand: ORDER_DETAIL_EXPAND }; return getRequest( @@ -325,6 +330,47 @@ export const clearSponsorOrder = () => async (dispatch) => { dispatch(createAction(CLEAR_SPONSOR_ORDER)({})); }; +export const downloadSponsorInvoice = + (orderId, sponsorId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken, + expand: ORDER_DETAIL_EXPAND + }; + + // Uses a DUMMY_ACTION (not RECEIVE_SPONSOR_ORDER) on purpose: this fetch + // is only ever used to build a PDF and must never write into + // sponsorPagePurchaseListState.currentOrder, which SponsorOrderDetails + // owns and mutates by order id. + return getRequest( + null, + createAction(DUMMY_ACTION), + `${window.PURCHASES_API_URL}/api/v2/summits/${currentSummit.id}/sponsors/${sponsorId}/purchases/${orderId}`, + authErrorHandler + )(params)(dispatch) + .then(({ response: fetchedOrder }) => + generateInvoicePDF(fetchedOrder, currentSummit, { + logoSrc: logoInvoice + }).catch(() => + dispatch( + snackbarErrorMsg({ + title: T.translate("general.error"), + html: T.translate("errors.invoice_generation") + }) + ) + ) + ) + .catch(() => {}) // authErrorHandler already surfaced the fetch failure + .finally(() => { + dispatch(stopLoading()); + }); + }; + export const updateClientAddress = (orderId, address) => async (dispatch, getState) => { const { currentSummitState, currentSponsorState } = getState(); diff --git a/src/assets/fn-invoice-header.png b/src/assets/fn-invoice-header.png new file mode 100644 index 000000000..952fefeba Binary files /dev/null and b/src/assets/fn-invoice-header.png differ diff --git a/src/i18n/en.json b/src/i18n/en.json index 90d959ede..d32d50d48 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -11,7 +11,8 @@ "entity_not_found": "The entity you are looking for was not found.", "maximum_files": "Maximum number of files has been reached", "payment_profile_not_found_title": "Payment Profile not found", - "payment_profile_not_found": "Missing payment profile for summit {{summitName}}. Please contact support." + "payment_profile_not_found": "Missing payment profile for summit {{summitName}}. Please contact support.", + "invoice_generation": "Invoice could not be generated. Please contact support." }, "general": { "summit": "Event", @@ -61,6 +62,7 @@ "save_and_publish": "Save & Publish", "save_and_add_next": "Save & Add Next", "export": "Export", + "download_invoice": "Download Invoice", "are_you_sure": "Are you sure?", "yes_delete": "Yes, delete.", "yes_remove": "Yes, remove.", diff --git a/src/pages/sponsors/show-purchase-list-page/__tests__/index.test.js b/src/pages/sponsors/show-purchase-list-page/__tests__/index.test.js new file mode 100644 index 000000000..8891ed38b --- /dev/null +++ b/src/pages/sponsors/show-purchase-list-page/__tests__/index.test.js @@ -0,0 +1,194 @@ +/** + * Copyright 2024 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +// ---- Mocks (must come before imports) ---- + +import React from "react"; +import { act, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; +import { renderWithRedux } from "../../../../utils/test-utils"; +import ShowPurchaseListPage from "../index"; +import { + getAllSponsorPurchases, + downloadSponsorInvoice +} from "../../../../actions/sponsor-purchases-actions"; +import { PURCHASE_METHODS, PURCHASE_STATUS } from "../../../../utils/constants"; + +jest.mock("react-breadcrumbs", () => ({ + Breadcrumb: () => null +})); + +jest.mock("../../../../actions/sponsor-purchases-actions", () => ({ + ...jest.requireActual("../../../../actions/sponsor-purchases-actions"), + getAllSponsorPurchases: jest.fn(() => () => Promise.resolve()), + exportAllSponsorPurchases: jest.fn(() => () => Promise.resolve()), + approveSponsorPurchase: jest.fn(() => () => Promise.resolve()), + rejectSponsorPurchase: jest.fn(() => () => Promise.resolve()), + downloadSponsorInvoice: jest.fn(() => () => Promise.resolve()) +})); + +/** + * SearchInput mock: plain that fires onSearch on Enter key, + * matching the real component behaviour without TextField overhead. + */ +jest.mock("openstack-uicore-foundation/lib/components/mui/search-input", () => { + const ReactLib = require("react"); + return { + __esModule: true, + default: ({ onSearch, term }) => { + const handleKeyDown = (e) => { + if (e.key === "Enter") onSearch(e.target.value); + }; + return ReactLib.createElement("input", { + "data-testid": "search-input", + defaultValue: term || "", + onKeyDown: handleKeyDown + }); + } + }; +}); + +// ---- Helpers ---- + +const DEFAULT_LIST_STATE = { + purchases: [], + order: "created", + orderDir: -1, + currentPage: 1, + lastPage: 1, + perPage: 10, + totalCount: 0, + term: "" +}; + +const createInitialState = (overrides = {}) => ({ + showPurchaseListState: { ...DEFAULT_LIST_STATE, ...overrides } +}); + +const createPurchase = (overrides = {}) => ({ + id: 1, + payment_id: 101, + number: "ORD-001", + purchased: "2024/01/01 10:00 am", + sponsor_id: 456, + sponsor_name: "Acme Co", + payment_method: PURCHASE_METHODS.INVOICE, + status: PURCHASE_STATUS.PENDING, + amount: "$100.00", + ...overrides +}); + +/** + * Returns a within()-scoped helper targeting the table body rows. + * TablePagination also renders a combobox (rows-per-page Select) outside + * the , so scoping to tbody isolates status-column assertions from + * pagination controls. + */ +const withinTableBody = () => { + const [, tbody] = screen.getAllByRole("rowgroup"); + return within(tbody); +}; + +const renderPage = (overrides = {}) => + renderWithRedux(, { + initialState: createInitialState(overrides) + }); + +// ---- Tests ---- + +describe("ShowPurchaseListPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Grid refresh behavior", () => { + it("calls getAllSponsorPurchases once on initial mount", () => { + renderPage(); + + expect(getAllSponsorPurchases).toHaveBeenCalledTimes(1); + }); + }); + + // ----------------------------------------------------------------------- + // Invoice download + // ----------------------------------------------------------------------- + + describe("Invoice download", () => { + const DOWNLOAD_LABEL = "general.download_invoice"; + const getDownloadButtons = () => + withinTableBody().getAllByRole("button", { name: DOWNLOAD_LABEL }); + const queryDownloadButtons = () => + withinTableBody().queryAllByRole("button", { name: DOWNLOAD_LABEL }); + + it("dispatches downloadSponsorInvoice with the row's order and sponsor ids", async () => { + const purchase = createPurchase({ id: 7, sponsor_id: 456 }); + + renderPage({ purchases: [purchase], totalCount: 1 }); + + await act(async () => { + await userEvent.click(getDownloadButtons()[0]); + }); + + expect(downloadSponsorInvoice).toHaveBeenCalledWith( + purchase.id, + purchase.sponsor_id + ); + }); + + it("only replaces the downloading row's icon with a spinner and disables the other rows, instead of swapping every row", async () => { + const purchaseA = createPurchase({ + id: 7, + sponsor_id: 456, + number: "ORD-007" + }); + const purchaseB = createPurchase({ + id: 8, + sponsor_id: 789, + number: "ORD-008" + }); + let resolveDownload; + downloadSponsorInvoice.mockImplementationOnce( + () => () => + new Promise((resolve) => { + resolveDownload = resolve; + }) + ); + + renderPage({ purchases: [purchaseA, purchaseB], totalCount: 2 }); + + const [firstRowButton] = getDownloadButtons(); + + await act(async () => { + await userEvent.click(firstRowButton); + }); + + // Only one row's button remains — the other row's icon became a spinner. + const remainingButtons = queryDownloadButtons(); + expect(remainingButtons).toHaveLength(1); + // The remaining row is disabled while a download is in flight elsewhere. + expect(remainingButtons[0]).toBeDisabled(); + expect(downloadSponsorInvoice).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDownload(); + await flushPromises(); + }); + + // Both rows are interactive again once the download settles. + expect(getDownloadButtons()).toHaveLength(2); + expect(getDownloadButtons()[0]).not.toBeDisabled(); + expect(getDownloadButtons()[1]).not.toBeDisabled(); + }); + }); +}); diff --git a/src/pages/sponsors/show-purchase-list-page/index.js b/src/pages/sponsors/show-purchase-list-page/index.js index 070e03f9f..c20075a68 100644 --- a/src/pages/sponsors/show-purchase-list-page/index.js +++ b/src/pages/sponsors/show-purchase-list-page/index.js @@ -11,13 +11,14 @@ * limitations under the License. * */ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import { Breadcrumb } from "react-breadcrumbs"; import { Box, Button, + CircularProgress, Grid2, IconButton, MenuItem, @@ -29,6 +30,7 @@ import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-i import history from "../../../history"; import { approveSponsorPurchase, + downloadSponsorInvoice, exportAllSponsorPurchases, getAllSponsorPurchases, rejectSponsorPurchase @@ -49,6 +51,7 @@ const ShowPurchaseListPage = ({ perPage, totalCount, getAllSponsorPurchases, + downloadSponsorInvoice, exportAllSponsorPurchases, approveSponsorPurchase, rejectSponsorPurchase @@ -57,6 +60,8 @@ const ShowPurchaseListPage = ({ getAllSponsorPurchases(); }, []); + const [downloadingOrderId, setDownloadingOrderId] = useState(null); + const handlePageChange = (page) => { getAllSponsorPurchases(term, page, perPage, order, orderDir); }; @@ -87,8 +92,12 @@ const ShowPurchaseListPage = ({ history.push(`${item.sponsor_id}/purchases/${item.id}`); }; - const handleMenu = (item) => { - console.log("MENU : ", item); + const handleInvoiceDownload = (purchaseOrder) => { + if (downloadingOrderId !== null) return; + setDownloadingOrderId(purchaseOrder.id); + downloadSponsorInvoice(purchaseOrder.id, purchaseOrder.sponsor_id).finally( + () => setDownloadingOrderId(null) + ); }; const handleStatusChange = (sponsorId, purchaseId, newStatus) => { @@ -180,15 +189,20 @@ const ShowPurchaseListPage = ({ header: "", width: 100, align: "center", - render: (row) => ( - handleMenu(row)} - > - - - ) + render: (row) => + downloadingOrderId === row.id ? ( + + ) : ( + handleInvoiceDownload(row)} + aria-label={T.translate("general.download_invoice")} + disabled={downloadingOrderId !== null} + > + + + ) } ]; @@ -254,6 +268,7 @@ const mapStateToProps = ({ showPurchaseListState }) => ({ export default connect(mapStateToProps, { getAllSponsorPurchases, + downloadSponsorInvoice, exportAllSponsorPurchases, approveSponsorPurchase, rejectSponsorPurchase diff --git a/src/pages/sponsors/sponsor-page/__tests__/utils.test.js b/src/pages/sponsors/sponsor-page/__tests__/utils.test.js new file mode 100644 index 000000000..2168527b3 --- /dev/null +++ b/src/pages/sponsors/sponsor-page/__tests__/utils.test.js @@ -0,0 +1,44 @@ +/** + * Copyright 2024 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import { normalizeOrder } from "../utils"; + +describe("normalizeOrder", () => { + it("normalizes each form and derives the order total", () => { + const result = normalizeOrder({ + net_amount: 500, + forms: [ + { + add_on: { name: "Extra badge" }, + discount_amount: 10, + discount_type: "percentage", + discount_in_cents: 100, + net_amount: 400 + } + ] + }); + + expect(result.total).toBe(500); + expect(result.forms[0]).toEqual( + expect.objectContaining({ + addon_name: "Extra badge", + discount_total: 100 + }) + ); + }); + + it("does not throw when the response omits the forms key", () => { + expect(() => normalizeOrder({ net_amount: 500 })).not.toThrow(); + expect(normalizeOrder({ net_amount: 500 }).forms).toEqual([]); + }); +}); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/__tests__/sponsor-purchases-list.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/__tests__/sponsor-purchases-list.test.js index 564ba84ec..31c4e8381 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/__tests__/sponsor-purchases-list.test.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/__tests__/sponsor-purchases-list.test.js @@ -18,12 +18,14 @@ import React from "react"; import { act, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; import { renderWithRedux } from "../../../../../../utils/test-utils"; import SponsorPurchasesTab from "../index"; import { getSponsorPurchases, approveSponsorPurchase, - rejectSponsorPurchase + rejectSponsorPurchase, + downloadSponsorInvoice } from "../../../../../../actions/sponsor-purchases-actions"; import { PURCHASE_METHODS, @@ -34,9 +36,17 @@ jest.mock("../../../../../../actions/sponsor-purchases-actions", () => ({ ...jest.requireActual("../../../../../../actions/sponsor-purchases-actions"), getSponsorPurchases: jest.fn(() => () => Promise.resolve()), approveSponsorPurchase: jest.fn(() => () => Promise.resolve()), - rejectSponsorPurchase: jest.fn(() => () => Promise.resolve()) + rejectSponsorPurchase: jest.fn(() => () => Promise.resolve()), + downloadSponsorInvoice: jest.fn(() => () => Promise.resolve()) })); +jest.mock( + "openstack-uicore-foundation/lib/components/mui/snackbar-notification", + () => ({ + useSnackbarMessage: () => ({ errorMessage: jest.fn() }) + }) +); + /** * SearchInput mock: plain that fires onSearch on Enter key, * matching the real component behaviour without TextField overhead. @@ -87,6 +97,9 @@ const createInitialState = (overrides = {}) => { sponsorPagePurchaseListState: state, currentSponsorState: { entity: { id: 123 } + }, + currentSummitState: { + currentSummit: { id: 1 } } }; }; @@ -471,4 +484,70 @@ describe("SponsorPurchasesTab", () => { ); }); }); + + // ----------------------------------------------------------------------- + // Invoice download + // ----------------------------------------------------------------------- + + describe("Invoice download", () => { + const DOWNLOAD_LABEL = "general.download_invoice"; + const getDownloadButton = () => + withinTableBody().getByRole("button", { name: DOWNLOAD_LABEL }); + const queryDownloadButton = () => + withinTableBody().queryByRole("button", { name: DOWNLOAD_LABEL }); + + it("dispatches downloadSponsorInvoice with the row's order id and the current sponsor id", async () => { + const purchase = createPurchase({ id: 7 }); + + renderWithRedux(, { + initialState: createInitialState({ + purchases: [purchase], + totalCount: 1 + }) + }); + + await act(async () => { + await userEvent.click(getDownloadButton()); + }); + + // currentSponsorState.entity.id from createInitialState, not a row field + expect(downloadSponsorInvoice).toHaveBeenCalledWith(purchase.id, 123); + }); + + it("does not start a second download while one is already pending", async () => { + const purchase = createPurchase({ id: 7 }); + let resolveDownload; + downloadSponsorInvoice.mockImplementationOnce( + () => () => + new Promise((resolve) => { + resolveDownload = resolve; + }) + ); + + renderWithRedux(, { + initialState: createInitialState({ + purchases: [purchase], + totalCount: 1 + }) + }); + + await act(async () => { + await userEvent.click(getDownloadButton()); + }); + + // While the download is pending, the icon is swapped for a progress + // spinner — there is no button left to click, so a second click can't + // happen. + expect(downloadSponsorInvoice).toHaveBeenCalledTimes(1); + expect(queryDownloadButton()).not.toBeInTheDocument(); + + await act(async () => { + resolveDownload(); + await flushPromises(); + }); + + expect(downloadSponsorInvoice).toHaveBeenCalledTimes(1); + expect(getDownloadButton()).toBeInTheDocument(); + }); + }); }); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/index.js index cd16c2bd6..4fa032a75 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/index.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/index.js @@ -11,12 +11,13 @@ * limitations under the License. * */ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import { Box, Button, + CircularProgress, Grid2, IconButton, MenuItem, @@ -28,6 +29,7 @@ import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-i import history from "../../../../../history"; import { approveSponsorPurchase, + downloadSponsorInvoice, getSponsorPurchases, rejectSponsorPurchase } from "../../../../../actions/sponsor-purchases-actions"; @@ -47,6 +49,7 @@ const SponsorPurchasesTab = ({ perPage, totalCount, getSponsorPurchases, + downloadSponsorInvoice, approveSponsorPurchase, rejectSponsorPurchase }) => { @@ -54,6 +57,8 @@ const SponsorPurchasesTab = ({ getSponsorPurchases(); }, [sponsor?.id]); + const [downloadingOrderId, setDownloadingOrderId] = useState(null); + const handlePageChange = (page) => { getSponsorPurchases(term, page, perPage, order, orderDir); }; @@ -80,8 +85,12 @@ const SponsorPurchasesTab = ({ history.push(`purchases/${item.id}`); }; - const handleMenu = (item) => { - console.log("MENU : ", item); + const handleInvoiceDownload = (item) => { + if (downloadingOrderId !== null) return; + setDownloadingOrderId(item.id); + downloadSponsorInvoice(item.id, sponsor.id).finally(() => + setDownloadingOrderId(null) + ); }; const handleStatusChange = (purchaseId, newStatus) => { @@ -163,15 +172,20 @@ const SponsorPurchasesTab = ({ header: "", width: 100, align: "center", - render: (row) => ( - handleMenu(row)} - > - - - ) + render: (row) => + downloadingOrderId === row.id ? ( + + ) : ( + handleInvoiceDownload(row)} + aria-label={T.translate("general.download_invoice")} + disabled={downloadingOrderId !== null} + > + + + ) } ]; @@ -226,6 +240,7 @@ const mapStateToProps = ({ export default connect(mapStateToProps, { getSponsorPurchases, + downloadSponsorInvoice, approveSponsorPurchase, rejectSponsorPurchase })(SponsorPurchasesTab); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/sponsor-order-details.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/sponsor-order-details.js index 5fa87a332..d0dcab0c2 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/sponsor-order-details.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-purchases-tab/sponsor-order-details.js @@ -56,7 +56,7 @@ const SponsorOrderDetails = ({ return () => clearSponsorOrder(); }, [orderId]); - if (!currentOrder) return null; + if (!currentOrder || currentOrder.id !== Number(orderId)) return null; const { client, address } = currentOrder; diff --git a/src/pages/sponsors/sponsor-page/utils.js b/src/pages/sponsors/sponsor-page/utils.js index da2f265ba..4de46b00f 100644 --- a/src/pages/sponsors/sponsor-page/utils.js +++ b/src/pages/sponsors/sponsor-page/utils.js @@ -6,7 +6,7 @@ import { export const normalizeOrder = (data) => ({ ...data, total: data.net_amount || data.amount_due || 0, - forms: data.forms.map((form) => ({ + forms: (data.forms || []).map((form) => ({ ...form, addon_name: form.add_on?.name || "", discount: formatDiscount(form.discount_amount, form.discount_type), diff --git a/yarn.lock b/yarn.lock index db00b7c94..3065f215e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2290,17 +2290,12 @@ resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4" integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA== -"@react-pdf/fns@3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-3.1.2.tgz#9ce7351d9fdf1cdb6e9c6ffd6801bc65f29f991c" - integrity sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g== - "@react-pdf/fns@3.1.3": version "3.1.3" resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-3.1.3.tgz#e0437d60ac10746bfbdf080e6809ba6f20d01556" integrity sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w== -"@react-pdf/font@^4.0.2", "@react-pdf/font@^4.0.8": +"@react-pdf/font@^4.0.8": version "4.0.8" resolved "https://registry.yarnpkg.com/@react-pdf/font/-/font-4.0.8.tgz#2279fb487f8a532e8b82e11732703a904ad05bb3" integrity sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA== @@ -2319,7 +2314,7 @@ jay-peg "^1.1.1" png-js "^2.0.0" -"@react-pdf/layout@^4.4.0": +"@react-pdf/layout@^4.6.1": version "4.6.1" resolved "https://registry.yarnpkg.com/@react-pdf/layout/-/layout-4.6.1.tgz#6777fafa2a47996d4b42de37ddd324ea1aff4557" integrity sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA== @@ -2334,20 +2329,6 @@ queue "^6.0.1" yoga-layout "^3.2.1" -"@react-pdf/pdfkit@^4.0.3": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz#2a32cb4bfa36e887747395d8c13ac425459eda0a" - integrity sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ== - dependencies: - "@babel/runtime" "^7.20.13" - "@react-pdf/png-js" "^3.0.0" - browserify-zlib "^0.2.0" - crypto-js "^4.2.0" - fontkit "^2.0.2" - jay-peg "^1.1.1" - linebreak "^1.1.0" - vite-compatible-readable-stream "^3.6.1" - "@react-pdf/pdfkit@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz#b3af968f94555a3d7c4cc1d5b81493bdbfbd76e0" @@ -2364,27 +2345,20 @@ png-js "^2.0.0" vite-compatible-readable-stream "^3.6.1" -"@react-pdf/png-js@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@react-pdf/png-js/-/png-js-3.0.0.tgz#c0b7dc7c77e36f0830e9b7bccca7ddd64ada1c5e" - integrity sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA== - dependencies: - browserify-zlib "^0.2.0" - -"@react-pdf/primitives@^4.1.1", "@react-pdf/primitives@^4.3.0": +"@react-pdf/primitives@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@react-pdf/primitives/-/primitives-4.3.0.tgz#3bb5f74294bea923392499dd46bc5196d47b918c" integrity sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A== -"@react-pdf/reconciler@^1.1.4": - version "1.1.4" - resolved "https://registry.yarnpkg.com/@react-pdf/reconciler/-/reconciler-1.1.4.tgz#62395cf5c8786a1c3465e2cf6315562543b663c5" - integrity sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg== +"@react-pdf/reconciler@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@react-pdf/reconciler/-/reconciler-2.0.0.tgz#d53ba53d5418c275c1fe1b150f0e9822243b799a" + integrity sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw== dependencies: object-assign "^4.1.1" scheduler "0.25.0-rc-603e6108-20241029" -"@react-pdf/render@^4.3.0": +"@react-pdf/render@^4.5.1": version "4.5.1" resolved "https://registry.yarnpkg.com/@react-pdf/render/-/render-4.5.1.tgz#7d94bfb96f4abe0cac28f769e296db909c8c456f" integrity sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA== @@ -2400,20 +2374,20 @@ parse-svg-path "^0.1.2" svg-arc-to-cubic-bezier "^3.2.0" -"@react-pdf/renderer@4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@react-pdf/renderer/-/renderer-4.3.0.tgz#21a41e0cf0db703e3cc54f6eb7d6cd78b460de06" - integrity sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw== +"@react-pdf/renderer@^4.4.1": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@react-pdf/renderer/-/renderer-4.5.1.tgz#3daa9caa572ea8c42beece01b6faa348b460d304" + integrity sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q== dependencies: "@babel/runtime" "^7.20.13" - "@react-pdf/fns" "3.1.2" - "@react-pdf/font" "^4.0.2" - "@react-pdf/layout" "^4.4.0" - "@react-pdf/pdfkit" "^4.0.3" - "@react-pdf/primitives" "^4.1.1" - "@react-pdf/reconciler" "^1.1.4" - "@react-pdf/render" "^4.3.0" - "@react-pdf/types" "^2.9.0" + "@react-pdf/fns" "3.1.3" + "@react-pdf/font" "^4.0.8" + "@react-pdf/layout" "^4.6.1" + "@react-pdf/pdfkit" "^5.1.1" + "@react-pdf/primitives" "^4.3.0" + "@react-pdf/reconciler" "^2.0.0" + "@react-pdf/render" "^4.5.1" + "@react-pdf/types" "^2.11.1" events "^3.3.0" object-assign "^4.1.1" prop-types "^15.6.2" @@ -2448,7 +2422,7 @@ hyphen "^1.6.4" unicode-properties "^1.4.1" -"@react-pdf/types@^2.11.1", "@react-pdf/types@^2.9.0": +"@react-pdf/types@^2.11.1": version "2.11.1" resolved "https://registry.yarnpkg.com/@react-pdf/types/-/types-2.11.1.tgz#ae37a12a883ae2d54a2b98b9b9ba32fbf4241478" integrity sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw== @@ -9068,10 +9042,10 @@ open@^10.0.3: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -openstack-uicore-foundation@5.0.43: - version "5.0.43" - resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.43.tgz#a0f870bd2d1dd7a97a1b6e78009b5248ca27baab" - integrity sha512-UBIENc7nEhhKEsmaGRKdEIziNhnGypy5bf9ydssEgFB/Fe6Q6gH07x1U8m5s1nXYV9OCneMvLTBH98tprUXlOg== +openstack-uicore-foundation@5.0.44: + version "5.0.44" + resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.44.tgz#d40d42d083de98bfb8d5e5baba7ff9b14267902c" + integrity sha512-wThO8s5IIZbpjlzhZKJ3KMNBYhTjt/q2TZyazfiDKOR2YMV51A8oudy6/uMe5UkQPMfuApLg4abPXZcNCdaNBA== dependencies: use-sync-external-store "^1.6.0"