From d07a78a8d2ff559b4982b1d81a5d89401f698d66 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 16:36:55 -0500 Subject: [PATCH 01/63] feat(sponsor-reports): scaffold admin-gated Reports section under Sponsors Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 1 + src/app.js | 1 + src/components/menu/menu-definition.js | 5 ++ src/i18n/en.json | 1 + .../__tests__/sponsor-reports-layout.test.js | 71 +++++++++++++++++++ src/layouts/sponsor-layout.js | 4 ++ src/layouts/sponsor-reports-layout.js | 34 +++++++++ 7 files changed, 117 insertions(+) create mode 100644 src/layouts/__tests__/sponsor-reports-layout.test.js create mode 100644 src/layouts/sponsor-reports-layout.js diff --git a/.env.example b/.env.example index 6d0c13929..2af5ca5d2 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ EMAIL_SCOPES="clients/read templates/read templates/write emails/read" FILE_UPLOAD_SCOPES="files/upload" FILE_UPLOAD_ALLOWED_EXTENSIONS="pdf,jpg,jpeg,png,ppt,key,pptx" SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com +SPONSOR_REPORTS_API_URL=https://sponsor-reports-api.dev.fnopen.com SPONSOR_PAGES_SCOPES="page-template/read page-template/write show-page/read show-page/write media-upload/read" SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read ${SCOPES_BASE_REALM}/summits/badge-scans/write config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" GOOGLE_API_KEY= diff --git a/src/app.js b/src/app.js index b649b2985..55520153b 100644 --- a/src/app.js +++ b/src/app.js @@ -86,6 +86,7 @@ window.EMAIL_API_BASE_URL = process.env.EMAIL_API_BASE_URL; window.PURCHASES_API_URL = process.env.PURCHASES_API_URL; window.SPONSOR_USERS_API_URL = process.env.SPONSOR_USERS_API_URL; window.SPONSOR_PAGES_API_URL = process.env.SPONSOR_PAGES_API_URL; +window.SPONSOR_REPORTS_API_URL = process.env.SPONSOR_REPORTS_API_URL; window.FILE_UPLOAD_API_BASE_URL = process.env.FILE_UPLOAD_API_BASE_URL; window.SIGNAGE_BASE_URL = process.env.SIGNAGE_BASE_URL; window.INVENTORY_API_BASE_URL = process.env.INVENTORY_API_BASE_URL; diff --git a/src/components/menu/menu-definition.js b/src/components/menu/menu-definition.js index 0baaa4985..1d4743442 100644 --- a/src/components/menu/menu-definition.js +++ b/src/components/menu/menu-definition.js @@ -223,6 +223,11 @@ export const getSummitItems = (summitId) => [ linkUrl: `summits/${summitId}/sponsors/purchases`, accessRoute: "admin-sponsors" }, + { + name: "sponsor_reports", + linkUrl: `summits/${summitId}/sponsors/reports`, + accessRoute: "admin-sponsors" + }, { name: "sponsorship_list", linkUrl: `summits/${summitId}/sponsorships`, diff --git a/src/i18n/en.json b/src/i18n/en.json index 9f016f7f8..ffced9334 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -189,6 +189,7 @@ "sponsor_forms": "Forms", "sponsor_pages": "Pages", "sponsor_purchases": "Purchases", + "sponsor_reports": "Reports", "sponsorship_list": "Tiers", "sponsor_users": "Users", "sponsors_promocodes": "Promo Codes", diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js new file mode 100644 index 000000000..d013a7d17 --- /dev/null +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -0,0 +1,71 @@ +/** + * Copyright 2026 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 React from "react"; +import { screen } from "@testing-library/react"; +import { Router, Route } from "react-router-dom"; +import { createMemoryHistory } from "history"; +import { renderWithRedux } from "../../utils/test-utils"; +import SponsorReportsLayout from "../sponsor-reports-layout"; + +// Echo translation keys so UnAuthorizedPage's T.translate("errors.not_allowed") → "errors.not_allowed" +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k) => k +})); + +// Provide real access-routes data so Restrict/Member gates correctly. +// Without this the YAML transform stub returns "" and hasAccess() always returns true. +jest.mock("../../access-routes.yml", () => ({ + "admin-sponsors": [ + "super-admins", + "administrators", + "summit-front-end-administrators" + ] +})); + +const REPORTS_ROUTE = "/app/summits/:summit_id/sponsors/reports"; +const REPORTS_URL = "/app/summits/1/sponsors/reports"; + +const buildState = (groups) => ({ + loggedUserState: { + member: { groups } + } +}); + +const renderLayout = (groups) => { + const history = createMemoryHistory({ initialEntries: [REPORTS_URL] }); + return renderWithRedux( + + + , + { initialState: buildState(groups) } + ); +}; + +describe("SponsorReportsLayout", () => { + it("renders the reports placeholder for an administrator", () => { + renderLayout([{ code: "administrators" }]); + expect( + screen.getByTestId("sponsor-reports-placeholder") + ).toBeInTheDocument(); + }); + + it("renders UnAuthorizedPage for a sponsors-only member", () => { + renderLayout([{ code: "sponsors" }]); + // UnAuthorizedPage renders:

Sorry...

+ expect(screen.getByText("Sorry...")).toBeInTheDocument(); + expect( + screen.queryByTestId("sponsor-reports-placeholder") + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/layouts/sponsor-layout.js b/src/layouts/sponsor-layout.js index 68dd29cd6..5f893b41b 100644 --- a/src/layouts/sponsor-layout.js +++ b/src/layouts/sponsor-layout.js @@ -47,6 +47,9 @@ const ShowPagesListPage = React.lazy(() => const SponsorOrdersListPage = React.lazy(() => import("../pages/sponsors/show-purchase-list-page") ); +const SponsorReportsLayout = React.lazy(() => + import("./sponsor-reports-layout") +); const SponsorLayout = ({ match }) => (
@@ -98,6 +101,7 @@ const SponsorLayout = ({ match }) => ( exact component={SponsorOrdersListPage} /> + ( +
+ + ( +
+

Sponsor Reports

+
+ )} + /> +
+
+); + +export default Restrict(withRouter(SponsorReportsLayout), "admin-sponsors"); From f7d0281ec8a3a6f766aa780f6979f45c4acd49fa Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 16:54:05 -0500 Subject: [PATCH 02/63] feat(sponsor-reports): port actions/reducers/utils + store wiring Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 330 +++++++++++++++++ src/actions/sponsor-reports-actions.js | 135 +++++++ src/i18n/en.json | 53 +++ .../sponsor-reports-reducers.test.js | 341 ++++++++++++++++++ .../sponsor-reports-drilldown-reducer.js | 56 +++ ...ponsor-reports-purchase-details-reducer.js | 89 +++++ .../sponsor-reports-sponsor-asset-reducer.js | 74 ++++ src/store.js | 15 +- src/utils/constants.js | 2 + src/utils/report-errors.js | 80 ++++ src/utils/report-query.js | 67 ++++ src/utils/reports-api.js | 7 + src/utils/reports-money.js | 16 + src/utils/reports-text.js | 24 ++ src/utils/section-csv-query.js | 38 ++ 15 files changed, 1325 insertions(+), 2 deletions(-) create mode 100644 src/actions/__tests__/sponsor-reports-actions.test.js create mode 100644 src/actions/sponsor-reports-actions.js create mode 100644 src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js create mode 100644 src/reducers/sponsors/sponsor-reports-drilldown-reducer.js create mode 100644 src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js create mode 100644 src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js create mode 100644 src/utils/report-errors.js create mode 100644 src/utils/report-query.js create mode 100644 src/utils/reports-api.js create mode 100644 src/utils/reports-money.js create mode 100644 src/utils/reports-text.js create mode 100644 src/utils/section-csv-query.js diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js new file mode 100644 index 000000000..3046a6096 --- /dev/null +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -0,0 +1,330 @@ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; +import * as methods from "../../utils/methods"; +import { makeReadErrorHandler } from "../../utils/report-errors"; + + +import { + getPurchaseDetailsReport, + getPurchaseDetailsFilters, + getSponsorAssetReport, + getSponsorAssetFilters, + getSponsorAssetSponsor, + REQUEST_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS_FILTERS, + PURCHASE_DETAILS_READ_ERROR, + PURCHASE_DETAILS_VALIDATION_ERROR, + PURCHASE_DETAILS_EXPORT_DISABLED, + REQUEST_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET_FILTERS, + REQUEST_SPONSOR_DRILLDOWN, + RECEIVE_SPONSOR_DRILLDOWN +} from "../sponsor-reports-actions"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn() +})); + +jest.mock("openstack-uicore-foundation/lib/security/methods", () => ({ + doLogin: jest.fn() +})); + +jest.mock("openstack-uicore-foundation/lib/utils/methods", () => ({ + ...jest.requireActual("openstack-uicore-foundation/lib/utils/methods"), + getBackURL: jest.fn(() => "/back") +})); + +const MOCK_STATE = { + currentSummitState: { currentSummit: { id: 42 } } +}; + +describe("sponsor-reports-actions", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + let capturedUrl = null; + let capturedParams = null; + + function makeHappyGetRequest() { + return getRequest.mockImplementation( + (requestActionCreator, receiveActionCreator, url) => + (params = {}) => + (dispatch) => { + capturedUrl = url; + capturedParams = params; + + if ( + requestActionCreator && + typeof requestActionCreator === "function" + ) { + dispatch(requestActionCreator({})); + } + + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch( + receiveActionCreator({ + response: { + data: [], + total: 0, + current_page: 1, + last_page: 1, + per_page: 10, + summary: null + } + }) + ); + } + resolve({ response: {} }); + }); + } + ); + } + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + getRequest.mockClear(); + doLogin.mockClear(); + capturedUrl = null; + capturedParams = null; + makeHappyGetRequest(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + capturedUrl = null; + capturedParams = null; + }); + + // ─── getPurchaseDetailsReport ──────────────────────────────────────────────── + + describe("getPurchaseDetailsReport", () => { + it("dispatches REQUEST_PURCHASE_DETAILS then RECEIVE_PURCHASE_DETAILS", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsReport({ page: 1 })); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_PURCHASE_DETAILS); + expect(types).toContain(RECEIVE_PURCHASE_DETAILS); + }); + + it("uses summit id from currentSummitState (not a passed param)", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsReport()); + await flushPromises(); + + expect(capturedUrl).toContain("/summits/42/"); + }); + + it("passes access_token and spread query in params", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsReport({ page: 2, per_page: 25 })); + await flushPromises(); + + expect(capturedParams.access_token).toBe("TOKEN"); + expect(capturedParams.page).toBe(2); + expect(capturedParams.per_page).toBe(25); + }); + }); + + // ─── getPurchaseDetailsFilters ─────────────────────────────────────────────── + + describe("getPurchaseDetailsFilters", () => { + it("dispatches RECEIVE_PURCHASE_DETAILS_FILTERS", async () => { + getRequest.mockImplementation( + (_requestAC, receiveActionCreator, url) => + (params = {}) => + (dispatch) => { + capturedUrl = url; + capturedParams = params; + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + } + resolve({ response: {} }); + }); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsFilters()); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(RECEIVE_PURCHASE_DETAILS_FILTERS); + expect(capturedParams.access_token).toBe("TOKEN"); + }); + }); + + // ─── getSponsorAssetReport ─────────────────────────────────────────────────── + + describe("getSponsorAssetReport", () => { + it("dispatches REQUEST_SPONSOR_ASSET then RECEIVE_SPONSOR_ASSET", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_SPONSOR_ASSET); + expect(types).toContain(RECEIVE_SPONSOR_ASSET); + }); + + it("passes access_token and query params", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + await flushPromises(); + + expect(capturedParams.access_token).toBe("TOKEN"); + expect(capturedParams.group_by).toBe("sponsor"); + }); + + it("uses summit id from state in URL", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetReport()); + await flushPromises(); + + expect(capturedUrl).toContain("/summits/42/"); + }); + }); + + // ─── getSponsorAssetFilters ────────────────────────────────────────────────── + + describe("getSponsorAssetFilters", () => { + it("dispatches RECEIVE_SPONSOR_ASSET_FILTERS with access_token", async () => { + getRequest.mockImplementation( + (_requestAC, receiveActionCreator) => + (params = {}) => + (dispatch) => { + capturedParams = params; + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + } + resolve({ response: {} }); + }); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetFilters()); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(RECEIVE_SPONSOR_ASSET_FILTERS); + expect(capturedParams.access_token).toBe("TOKEN"); + }); + }); + + // ─── getSponsorAssetSponsor ────────────────────────────────────────────────── + + describe("getSponsorAssetSponsor", () => { + it("dispatches REQUEST_SPONSOR_DRILLDOWN then RECEIVE_SPONSOR_DRILLDOWN", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetSponsor(7)); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_SPONSOR_DRILLDOWN); + expect(types).toContain(RECEIVE_SPONSOR_DRILLDOWN); + }); + + it("uses summit id from state and sponsorId in URL", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetSponsor(7)); + await flushPromises(); + + expect(capturedUrl).toContain("/summits/42/"); + expect(capturedUrl).toContain("/sponsors/7"); + }); + }); + + // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── + + describe("makeReadErrorHandler", () => { + let mockDispatch; + + beforeEach(() => { + mockDispatch = jest.fn(); + }); + + it("401 calls doLogin and does not dispatch", () => { + const onReadError = jest.fn((p) => ({ + type: PURCHASE_DETAILS_READ_ERROR, + payload: p + })); + const handler = makeReadErrorHandler({ onReadError }); + handler({ status: 401 }, {})(mockDispatch); + + expect(doLogin).toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("403 dispatches onReadError", () => { + const onReadError = jest.fn((p) => ({ + type: PURCHASE_DETAILS_READ_ERROR, + payload: p + })); + const handler = makeReadErrorHandler({ onReadError }); + handler({ status: 403 }, {})(mockDispatch); + + expect(onReadError).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: PURCHASE_DETAILS_READ_ERROR }) + ); + }); + + it("412 dispatches onValidationError and leaves body intact", () => { + const onReadError = jest.fn((p) => ({ + type: PURCHASE_DETAILS_READ_ERROR, + payload: p + })); + const onValidationError = jest.fn((p) => ({ + type: PURCHASE_DETAILS_VALIDATION_ERROR, + payload: p + })); + const handler = makeReadErrorHandler({ onReadError, onValidationError }); + handler({ status: 412 }, {})(mockDispatch); + + expect(onValidationError).toHaveBeenCalled(); + expect(onReadError).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: PURCHASE_DETAILS_VALIDATION_ERROR }) + ); + }); + + it("503 with 'CSV export is not enabled' dispatches onExportDisabled", () => { + const onReadError = jest.fn((p) => ({ + type: PURCHASE_DETAILS_READ_ERROR, + payload: p + })); + const onExportDisabled = jest.fn((p) => ({ + type: PURCHASE_DETAILS_EXPORT_DISABLED, + payload: p + })); + const handler = makeReadErrorHandler({ onReadError, onExportDisabled }); + handler( + { + status: 503, + response: { + body: { message: "CSV export is not enabled for this summit" } + } + }, + {} + )(mockDispatch); + + expect(onExportDisabled).toHaveBeenCalled(); + expect(onReadError).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: PURCHASE_DETAILS_EXPORT_DISABLED }) + ); + }); + }); +}); diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js new file mode 100644 index 000000000..79626b3fc --- /dev/null +++ b/src/actions/sponsor-reports-actions.js @@ -0,0 +1,135 @@ +import { + createAction, + getRequest, + startLoading, + stopLoading +} from "openstack-uicore-foundation/lib/utils/actions"; +import { getAccessTokenSafely } from "../utils/methods"; +import { getReportsApiBaseUrl } from "../utils/reports-api"; +import { makeReadErrorHandler } from "../utils/report-errors"; + +export const REQUEST_PURCHASE_DETAILS = "REQUEST_PURCHASE_DETAILS"; +export const RECEIVE_PURCHASE_DETAILS = "RECEIVE_PURCHASE_DETAILS"; +export const RECEIVE_PURCHASE_DETAILS_FILTERS = + "RECEIVE_PURCHASE_DETAILS_FILTERS"; +export const PURCHASE_DETAILS_READ_ERROR = "PURCHASE_DETAILS_READ_ERROR"; +export const PURCHASE_DETAILS_VALIDATION_ERROR = + "PURCHASE_DETAILS_VALIDATION_ERROR"; +export const PURCHASE_DETAILS_VALIDATION_CLEAR = + "PURCHASE_DETAILS_VALIDATION_CLEAR"; +export const PURCHASE_DETAILS_EXPORT_DISABLED = + "PURCHASE_DETAILS_EXPORT_DISABLED"; + +export const REQUEST_SPONSOR_ASSET = "REQUEST_SPONSOR_ASSET"; +export const RECEIVE_SPONSOR_ASSET = "RECEIVE_SPONSOR_ASSET"; +export const RECEIVE_SPONSOR_ASSET_FILTERS = "RECEIVE_SPONSOR_ASSET_FILTERS"; +export const SPONSOR_ASSET_READ_ERROR = "SPONSOR_ASSET_READ_ERROR"; +export const SPONSOR_ASSET_EXPORT_DISABLED = "SPONSOR_ASSET_EXPORT_DISABLED"; + +export const REQUEST_SPONSOR_DRILLDOWN = "REQUEST_SPONSOR_DRILLDOWN"; +export const RECEIVE_SPONSOR_DRILLDOWN = "RECEIVE_SPONSOR_DRILLDOWN"; +export const SPONSOR_DRILLDOWN_READ_ERROR = "SPONSOR_DRILLDOWN_READ_ERROR"; +export const SPONSOR_DRILLDOWN_EXPORT_DISABLED = + "SPONSOR_DRILLDOWN_EXPORT_DISABLED"; + +// Base URL helper — scoped to a specific summit's reports endpoint. +const base = (summitId) => + `${getReportsApiBaseUrl()}/api/v1/summits/${summitId}/reports`; + +export const getPurchaseDetailsReport = + (query = {}) => + async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + const params = { access_token: accessToken, ...query }; + return getRequest( + createAction(REQUEST_PURCHASE_DETAILS), + createAction(RECEIVE_PURCHASE_DETAILS), + `${base(currentSummit.id)}/purchase-details`, + makeReadErrorHandler({ + onReadError: createAction(PURCHASE_DETAILS_READ_ERROR), + onValidationError: createAction(PURCHASE_DETAILS_VALIDATION_ERROR), + onExportDisabled: createAction(PURCHASE_DETAILS_EXPORT_DISABLED) + }) + )(params)(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + }; + +export const getPurchaseDetailsFilters = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + return getRequest( + null, + createAction(RECEIVE_PURCHASE_DETAILS_FILTERS), + `${base(currentSummit.id)}/purchase-details/filters`, + makeReadErrorHandler({ + onReadError: createAction(PURCHASE_DETAILS_READ_ERROR) + }) + )({ access_token: accessToken })(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); +}; + +export const getSponsorAssetReport = + (query = {}) => + async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + const params = { access_token: accessToken, ...query }; + return getRequest( + createAction(REQUEST_SPONSOR_ASSET), + createAction(RECEIVE_SPONSOR_ASSET), + `${base(currentSummit.id)}/sponsor-assets`, + makeReadErrorHandler({ + onReadError: createAction(SPONSOR_ASSET_READ_ERROR), + // FE never sends an invalid group_by/order, but a 412 must not be swallowed: + // route it to the read-error body rather than a silent no-op. + onValidationError: createAction(SPONSOR_ASSET_READ_ERROR), + onExportDisabled: createAction(SPONSOR_ASSET_EXPORT_DISABLED) + }) + )(params)(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + }; + +export const getSponsorAssetFilters = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + return getRequest( + null, // loading is owned by getSponsorAssetReport; filters must not toggle it + createAction(RECEIVE_SPONSOR_ASSET_FILTERS), + `${base(currentSummit.id)}/sponsor-assets/filters`, + makeReadErrorHandler({ + onReadError: createAction(SPONSOR_ASSET_READ_ERROR) + }) + )({ access_token: accessToken })(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); +}; + +export const getSponsorAssetSponsor = + (sponsorId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + return getRequest( + createAction(REQUEST_SPONSOR_DRILLDOWN), + createAction(RECEIVE_SPONSOR_DRILLDOWN), + `${base(currentSummit.id)}/sponsor-assets/sponsors/${sponsorId}`, + makeReadErrorHandler({ + onReadError: createAction(SPONSOR_DRILLDOWN_READ_ERROR) + }) + )({ access_token: accessToken })(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + }; diff --git a/src/i18n/en.json b/src/i18n/en.json index ffced9334..be983132c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4257,5 +4257,58 @@ "title": "Image Preview", "file_name": "File name", "uploaded": "Uploaded" + }, + "sponsor_reports_page": { + "unauthorized_title": "Access restricted", + "unauthorized_body": "You don't have permission to view sponsor reports. Contact an administrator if you believe this is a mistake.", + "search": "Search", + "filter_sponsor": "Sponsor", + "apply": "Apply", + "clear": "Clear", + "export_csv": "Export CSV", + "download_csv": "Download CSV", + "total_orders": "Total Sales", + "total_items": "Total Items", + "total_paid": "Total Paid", + "total_pending": "Total Pending", + "total_refunded": "Total Refunded", + "filter_status": "Purchase Status", + "filter_form": "Type", + "any": "Any", + "filter_date_from": "From date", + "filter_date_to": "To date", + "purchase_details_title": "Purchase Details", + "purchase_details_subtitle": "Orders, items, and revenue across sponsor purchases.", + "print": "Print", + "validation_error": "Invalid filter. Please adjust and try again.", + "read_error": "This report is currently unavailable.", + "sponsor_assets_title": "Sponsor Assets", + "sponsor_assets_subtitle": "Manage and export digital assets and text from sponsor portal pages.", + "sponsor_not_found": "Sponsor not found.", + "loading": "Loading…", + "sponsor_no_submissions": "This sponsor has no submissions yet.", + "no_results": "No results.", + "summit_not_found": "Summit not found.", + "summit_reports_title": "Summit Reports", + "summit": "Summit", + "status_completed": "Completed", + "status_in_progress": "In Progress", + "status_pending": "Pending", + "status_not_applicable": "N/A", + "group_by": "Group by", + "group_by_sponsor": "Sponsor", + "group_by_component": "Component", + "components_count": "{count} components", + "sponsors_count": "{count} sponsors", + "unnamed_component": "(Unnamed)", + "not_present_yet": "Not present yet", + "picker_title": "Reports", + "select_summit": "Select a summit to view its reports.", + "no_summits": "No summits with report data.", + "report_filters": "Report Filters", + "pending_upload": "Pending Upload", + "pages_active": "{count} pages active", + "purchase_details_desc": "Orders & revenue", + "sponsor_assets_desc": "Sponsor portal assets" } } diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js new file mode 100644 index 000000000..0442d61a7 --- /dev/null +++ b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js @@ -0,0 +1,341 @@ +import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { SET_CURRENT_SUMMIT } from "../../../actions/summit-actions"; +import { + REQUEST_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS, + PURCHASE_DETAILS_READ_ERROR, + PURCHASE_DETAILS_VALIDATION_ERROR, + PURCHASE_DETAILS_EXPORT_DISABLED, + REQUEST_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET_FILTERS, + SPONSOR_ASSET_READ_ERROR, + REQUEST_SPONSOR_DRILLDOWN, + RECEIVE_SPONSOR_DRILLDOWN, + SPONSOR_DRILLDOWN_READ_ERROR +} from "../../../actions/sponsor-reports-actions"; + +import purchaseDetailsReducer, { + DEFAULT_STATE as PD_DEFAULT_STATE +} from "../sponsor-reports-purchase-details-reducer"; + +import sponsorAssetReducer, { + DEFAULT_STATE as SA_DEFAULT_STATE +} from "../sponsor-reports-sponsor-asset-reducer"; + +import drilldownReducer, { + DEFAULT_STATE as DD_DEFAULT_STATE +} from "../sponsor-reports-drilldown-reducer"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// purchase-details reducer +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("sponsorReportsPurchaseDetailsReducer", () => { + describe("initial state", () => { + it("matches DEFAULT_STATE", () => { + const result = purchaseDetailsReducer(undefined, { type: "@@INIT" }); + expect(result).toStrictEqual(PD_DEFAULT_STATE); + }); + }); + + describe("REQUEST_PURCHASE_DETAILS", () => { + it("sets loading=true and readError=null", () => { + const state = { + ...PD_DEFAULT_STATE, + loading: false, + readError: { kind: "unknown" } + }; + const result = purchaseDetailsReducer(state, { + type: REQUEST_PURCHASE_DETAILS, + payload: {} + }); + expect(result.loading).toBe(true); + expect(result.readError).toBeNull(); + }); + }); + + describe("RECEIVE_PURCHASE_DETAILS", () => { + const payload = { + response: { + data: [{ id: 1 }], + total: 50, + current_page: 2, + last_page: 5, + per_page: 10, + summary: { total_paid: "100.00" } + } + }; + + it("maps data, total, pagination, summary; sets loading=false", () => { + const state = { ...PD_DEFAULT_STATE, loading: true }; + const result = purchaseDetailsReducer(state, { + type: RECEIVE_PURCHASE_DETAILS, + payload + }); + expect(result.loading).toBe(false); + expect(result.data).toStrictEqual([{ id: 1 }]); + expect(result.total).toBe(50); + expect(result.currentPage).toBe(2); + expect(result.lastPage).toBe(5); + expect(result.perPage).toBe(10); + expect(result.summary).toStrictEqual({ total_paid: "100.00" }); + expect(result.readError).toBeNull(); + expect(result.validationError).toBeNull(); + }); + + it("preserves existing summary when response summary is null", () => { + const prevSummary = { total_paid: "200.00" }; + const state = { ...PD_DEFAULT_STATE, summary: prevSummary }; + const result = purchaseDetailsReducer(state, { + type: RECEIVE_PURCHASE_DETAILS, + payload: { response: { ...payload.response, summary: null } } + }); + expect(result.summary).toStrictEqual(prevSummary); + }); + }); + + describe("PURCHASE_DETAILS_READ_ERROR", () => { + it("sets loading=false and readError=payload", () => { + const state = { ...PD_DEFAULT_STATE, loading: true }; + const errorPayload = { kind: "unauthorized", status: 403, message: "" }; + const result = purchaseDetailsReducer(state, { + type: PURCHASE_DETAILS_READ_ERROR, + payload: errorPayload + }); + expect(result.loading).toBe(false); + expect(result.readError).toStrictEqual(errorPayload); + }); + }); + + describe("PURCHASE_DETAILS_VALIDATION_ERROR", () => { + it("sets loading=false and validationError=payload without replacing body", () => { + const existingData = [{ id: 1 }, { id: 2 }]; + const state = { ...PD_DEFAULT_STATE, loading: true, data: existingData }; + const errPayload = { status: 412, message: "invalid filter" }; + const result = purchaseDetailsReducer(state, { + type: PURCHASE_DETAILS_VALIDATION_ERROR, + payload: errPayload + }); + expect(result.loading).toBe(false); + expect(result.validationError).toStrictEqual(errPayload); + // body must NOT be replaced + expect(result.data).toStrictEqual(existingData); + }); + }); + + describe("PURCHASE_DETAILS_EXPORT_DISABLED", () => { + it("sets exportDisabled=true", () => { + const result = purchaseDetailsReducer(PD_DEFAULT_STATE, { + type: PURCHASE_DETAILS_EXPORT_DISABLED, + payload: { message: "CSV export is not enabled" } + }); + expect(result.exportDisabled).toBe(true); + }); + }); + + describe("SET_CURRENT_SUMMIT", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { ...PD_DEFAULT_STATE, data: [{ id: 99 }], loading: true }; + const result = purchaseDetailsReducer(dirty, { + type: SET_CURRENT_SUMMIT + }); + expect(result).toStrictEqual(PD_DEFAULT_STATE); + }); + }); + + describe("LOGOUT_USER", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { + ...PD_DEFAULT_STATE, + data: [{ id: 1 }], + readError: { kind: "unknown" } + }; + const result = purchaseDetailsReducer(dirty, { type: LOGOUT_USER }); + expect(result).toStrictEqual(PD_DEFAULT_STATE); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// sponsor-asset reducer +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("sponsorReportsSponsorAssetReducer", () => { + describe("initial state", () => { + it("matches DEFAULT_STATE", () => { + const result = sponsorAssetReducer(undefined, { type: "@@INIT" }); + expect(result).toStrictEqual(SA_DEFAULT_STATE); + }); + }); + + describe("REQUEST_SPONSOR_ASSET", () => { + it("sets loading=true and readError=null", () => { + const state = { + ...SA_DEFAULT_STATE, + loading: false, + readError: { kind: "unknown" } + }; + const result = sponsorAssetReducer(state, { + type: REQUEST_SPONSOR_ASSET, + payload: {} + }); + expect(result.loading).toBe(true); + expect(result.readError).toBeNull(); + }); + }); + + describe("RECEIVE_SPONSOR_ASSET", () => { + const payload = { + response: { + data: [{ id: 10 }], + total: 5, + per_page: 20, + current_page: 1, + last_page: 1, + summary: { total: 100 } + } + }; + + it("maps env fields to state", () => { + const state = { ...SA_DEFAULT_STATE, loading: true }; + const result = sponsorAssetReducer(state, { + type: RECEIVE_SPONSOR_ASSET, + payload + }); + expect(result.loading).toBe(false); + expect(result.data).toStrictEqual([{ id: 10 }]); + expect(result.total).toBe(5); + expect(result.perPage).toBe(20); + expect(result.currentPage).toBe(1); + expect(result.lastPage).toBe(1); + expect(result.summary).toStrictEqual({ total: 100 }); + expect(result.readError).toBeNull(); + }); + }); + + describe("SPONSOR_ASSET_READ_ERROR", () => { + it("sets loading=false and readError=payload", () => { + const state = { ...SA_DEFAULT_STATE, loading: true }; + const err = { kind: "not-found", status: 404, message: "" }; + const result = sponsorAssetReducer(state, { + type: SPONSOR_ASSET_READ_ERROR, + payload: err + }); + expect(result.loading).toBe(false); + expect(result.readError).toStrictEqual(err); + }); + }); + + describe("RECEIVE_SPONSOR_ASSET_FILTERS", () => { + it("sets filterOptions to payload.response without changing loading", () => { + const state = { ...SA_DEFAULT_STATE, loading: true }; + const filters = { sponsors: [{ id: 1, name: "ACME" }] }; + const result = sponsorAssetReducer(state, { + type: RECEIVE_SPONSOR_ASSET_FILTERS, + payload: { response: filters } + }); + expect(result.filterOptions).toStrictEqual(filters); + // loading must NOT change + expect(result.loading).toBe(true); + }); + }); + + describe("SET_CURRENT_SUMMIT", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { ...SA_DEFAULT_STATE, data: [{ id: 5 }], loading: true }; + const result = sponsorAssetReducer(dirty, { type: SET_CURRENT_SUMMIT }); + expect(result).toStrictEqual(SA_DEFAULT_STATE); + }); + }); + + describe("LOGOUT_USER", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { + ...SA_DEFAULT_STATE, + data: [{ id: 5 }], + filterOptions: {} + }; + const result = sponsorAssetReducer(dirty, { type: LOGOUT_USER }); + expect(result).toStrictEqual(SA_DEFAULT_STATE); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// drilldown reducer +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("sponsorReportsDrilldownReducer", () => { + describe("initial state", () => { + it("matches DEFAULT_STATE", () => { + const result = drilldownReducer(undefined, { type: "@@INIT" }); + expect(result).toStrictEqual(DD_DEFAULT_STATE); + }); + }); + + describe("REQUEST_SPONSOR_DRILLDOWN", () => { + it("sets loading=true, readError=null, detail=null", () => { + const state = { + ...DD_DEFAULT_STATE, + loading: false, + readError: { kind: "unknown" }, + detail: { sponsor: { id: 1 } } + }; + const result = drilldownReducer(state, { + type: REQUEST_SPONSOR_DRILLDOWN, + payload: {} + }); + expect(result.loading).toBe(true); + expect(result.readError).toBeNull(); + expect(result.detail).toBeNull(); + }); + }); + + describe("RECEIVE_SPONSOR_DRILLDOWN", () => { + it("sets detail=payload.response and loading=false", () => { + const state = { ...DD_DEFAULT_STATE, loading: true }; + const responseData = { sponsor: { id: 7, name: "ACME" }, pages: [] }; + const result = drilldownReducer(state, { + type: RECEIVE_SPONSOR_DRILLDOWN, + payload: { response: responseData } + }); + expect(result.detail).toStrictEqual(responseData); + expect(result.loading).toBe(false); + expect(result.readError).toBeNull(); + }); + }); + + describe("SPONSOR_DRILLDOWN_READ_ERROR", () => { + it("sets loading=false and readError=payload", () => { + const state = { ...DD_DEFAULT_STATE, loading: true }; + const err = { kind: "not-found", status: 404, message: "" }; + const result = drilldownReducer(state, { + type: SPONSOR_DRILLDOWN_READ_ERROR, + payload: err + }); + expect(result.loading).toBe(false); + expect(result.readError).toStrictEqual(err); + }); + }); + + describe("SET_CURRENT_SUMMIT", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { + ...DD_DEFAULT_STATE, + detail: { sponsor: { id: 1 } }, + loading: true + }; + const result = drilldownReducer(dirty, { type: SET_CURRENT_SUMMIT }); + expect(result).toStrictEqual(DD_DEFAULT_STATE); + }); + }); + + describe("LOGOUT_USER", () => { + it("resets to DEFAULT_STATE", () => { + const dirty = { ...DD_DEFAULT_STATE, detail: { sponsor: { id: 2 } } }; + const result = drilldownReducer(dirty, { type: LOGOUT_USER }); + expect(result).toStrictEqual(DD_DEFAULT_STATE); + }); + }); +}); diff --git a/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js b/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js new file mode 100644 index 000000000..3adf9f7bf --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js @@ -0,0 +1,56 @@ +/** + * Copyright 2017 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; +import { + REQUEST_SPONSOR_DRILLDOWN, + RECEIVE_SPONSOR_DRILLDOWN, + SPONSOR_DRILLDOWN_READ_ERROR, + SPONSOR_DRILLDOWN_EXPORT_DISABLED +} from "../../actions/sponsor-reports-actions"; + +export const DEFAULT_STATE = { + // The whole retrieve response: { sponsor: {id,name,tier,pages_active}, pages: [...] }. + detail: null, + loading: false, + readError: null, // includes { kind: "not-found" } for unknown sponsor (404) + exportDisabled: false, + exportError: null +}; + +const reducer = (state = DEFAULT_STATE, action) => { + const { type, payload } = action; + switch (type) { + case LOGOUT_USER: + case SET_CURRENT_SUMMIT: + return DEFAULT_STATE; + case REQUEST_SPONSOR_DRILLDOWN: + return { ...state, loading: true, readError: null, detail: null }; + case RECEIVE_SPONSOR_DRILLDOWN: + return { + ...state, + detail: payload.response, + loading: false, + readError: null + }; + case SPONSOR_DRILLDOWN_READ_ERROR: + return { ...state, loading: false, readError: payload }; + case SPONSOR_DRILLDOWN_EXPORT_DISABLED: + return { ...state, exportDisabled: true, exportError: payload }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js new file mode 100644 index 000000000..18913fdfd --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js @@ -0,0 +1,89 @@ +/** + * Copyright 2017 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { DEFAULT_PER_PAGE } from "../../utils/constants"; +import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; +import { + REQUEST_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS_FILTERS, + PURCHASE_DETAILS_READ_ERROR, + PURCHASE_DETAILS_VALIDATION_ERROR, + PURCHASE_DETAILS_VALIDATION_CLEAR, + PURCHASE_DETAILS_EXPORT_DISABLED +} from "../../actions/sponsor-reports-actions"; + +export const DEFAULT_STATE = { + data: [], + summary: null, + filterOptions: null, + total: 0, + currentPage: 1, + lastPage: 1, + perPage: DEFAULT_PER_PAGE, + query: {}, + loading: false, + readError: null, // replaces the body (read-disabled / not-found / unauthorized / unknown) + validationError: null, // 412 — inline/toast, body stays + exportError: null, + exportDisabled: false +}; + +const reducer = (state = DEFAULT_STATE, action) => { + const { type, payload } = action; + switch (type) { + case LOGOUT_USER: + case SET_CURRENT_SUMMIT: + return DEFAULT_STATE; + case REQUEST_PURCHASE_DETAILS: + return { ...state, loading: true, readError: null }; + case RECEIVE_PURCHASE_DETAILS: { + const { + data, + total, + last_page: lastPage, + per_page: perPage, + current_page: currentPage, + summary + } = payload.response; + return { + ...state, + data, + total, + lastPage, + perPage, + currentPage, + summary: summary ?? state.summary, + loading: false, + readError: null, + validationError: null + }; + } + case RECEIVE_PURCHASE_DETAILS_FILTERS: + return { ...state, filterOptions: payload.response, loading: false }; + case PURCHASE_DETAILS_READ_ERROR: + return { ...state, loading: false, readError: payload }; + case PURCHASE_DETAILS_VALIDATION_ERROR: + // Do NOT replace the body — surface inline/toast; keep the last good rows. + return { ...state, loading: false, validationError: payload }; + case PURCHASE_DETAILS_VALIDATION_CLEAR: + return { ...state, validationError: null }; + case PURCHASE_DETAILS_EXPORT_DISABLED: + return { ...state, exportDisabled: true, exportError: payload }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js b/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js new file mode 100644 index 000000000..3deada8b1 --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js @@ -0,0 +1,74 @@ +/** + * Copyright 2017 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; +import { + REQUEST_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET, + RECEIVE_SPONSOR_ASSET_FILTERS, + SPONSOR_ASSET_READ_ERROR, + SPONSOR_ASSET_EXPORT_DISABLED +} from "../../actions/sponsor-reports-actions"; + +export const DEFAULT_STATE = { + filterOptions: null, // { sponsors, pages, tiers, components } + data: [], // grouped cards (sponsor or component) for the current page + total: 0, // number of GROUPS (not rows) + perPage: 0, + currentPage: 0, // 0 until the first report load — used to gate the empty state + lastPage: 0, + summary: null, // { total, by_status, by_page } + loading: false, + readError: null, + exportError: null, + exportDisabled: false +}; + +const reducer = (state = DEFAULT_STATE, action) => { + const { type, payload } = action; + switch (type) { + case LOGOUT_USER: + case SET_CURRENT_SUMMIT: + return DEFAULT_STATE; + case REQUEST_SPONSOR_ASSET: + return { ...state, loading: true, readError: null }; + case RECEIVE_SPONSOR_ASSET: { + const env = payload.response; + return { + ...state, + data: env.data, + total: env.total, + perPage: env.per_page, + currentPage: env.current_page, + lastPage: env.last_page, + summary: env.summary, + loading: false, + readError: null + // NOTE: exportDisabled/exportError are intentionally NOT cleared — CSV-disabled + // is a service-wide backend flag, not invalidated by a successful read. + }; + } + case RECEIVE_SPONSOR_ASSET_FILTERS: + // loading is report-owned now (filters use a null request action), so leave it alone. + return { ...state, filterOptions: payload.response, readError: null }; + case SPONSOR_ASSET_READ_ERROR: + return { ...state, loading: false, readError: payload }; + case SPONSOR_ASSET_EXPORT_DISABLED: + return { ...state, exportDisabled: true, exportError: payload }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/store.js b/src/store.js index 326f84399..04851c68c 100644 --- a/src/store.js +++ b/src/store.js @@ -174,13 +174,21 @@ import sponsorPagePurchaseListReducer from "./reducers/sponsors/sponsor-page-pur import sponsorPagePagesListReducer from "./reducers/sponsors/sponsor-page-pages-list-reducer.js"; import sponsorPageMUListReducer from "./reducers/sponsors/sponsor-page-mu-list-reducer.js"; import dropboxSyncReducer from "./reducers/locations/dropbox-sync-reducer"; +import sponsorReportsPurchaseDetailsReducer from "./reducers/sponsors/sponsor-reports-purchase-details-reducer"; +import sponsorReportsSponsorAssetReducer from "./reducers/sponsors/sponsor-reports-sponsor-asset-reducer"; +import sponsorReportsDrilldownReducer from "./reducers/sponsors/sponsor-reports-drilldown-reducer"; // default: localStorage if web, AsyncStorage if react-native const config = { key: "root", storage, - blacklist: ["dropboxSyncState"] + blacklist: [ + "dropboxSyncState", + "sponsorReportsPurchaseDetailsState", + "sponsorReportsSponsorAssetState", + "sponsorReportsDrilldownState" + ] }; const reducers = persistCombineReducers(config, { @@ -343,7 +351,10 @@ const reducers = persistCombineReducers(config, { sponsorSettingsState: sponsorSettingsReducer, pageTemplateListState: pageTemplateListReducer, pageTemplateState: pageTemplateReducer, - dropboxSyncState: dropboxSyncReducer + dropboxSyncState: dropboxSyncReducer, + sponsorReportsPurchaseDetailsState: sponsorReportsPurchaseDetailsReducer, + sponsorReportsSponsorAssetState: sponsorReportsSponsorAssetReducer, + sponsorReportsDrilldownState: sponsorReportsDrilldownReducer }); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; diff --git a/src/utils/constants.js b/src/utils/constants.js index 4b217d060..fc270a562 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -132,6 +132,8 @@ export const ERROR_CODE_404 = 404; export const ERROR_CODE_500 = 500; +export const ERROR_CODE_503 = 503; + export const HEX_RADIX = 16; export const DEBOUNCE_WAIT = 500; diff --git a/src/utils/report-errors.js b/src/utils/report-errors.js new file mode 100644 index 000000000..9f2176c9a --- /dev/null +++ b/src/utils/report-errors.js @@ -0,0 +1,80 @@ +import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; +import { getBackURL } from "openstack-uicore-foundation/lib/utils/methods"; +import { + ERROR_CODE_401, + ERROR_CODE_403, + ERROR_CODE_404, + ERROR_CODE_412, + ERROR_CODE_503 +} from "./constants"; + +export const extractErrorMessage = (err = {}, res = {}) => { + const candidates = [ + err?.response?.body?.message, + err?.response?.body?.detail, + err?.body?.message, + err?.body?.detail, + err?.message, + res?.body?.message, + res?.body?.detail, + typeof res === "string" ? res : undefined + ]; + return candidates.find((c) => typeof c === "string" && c.length > 0) || ""; +}; + +// Split the two 503s by message (case-insensitive substring match); an unknown +// 503 is generic (do not assume reads-disabled). Map auth statuses to non-logout kinds. +export const classifyReportError = (status, message = "") => { + const msg = String(message || ""); + switch (status) { + case ERROR_CODE_503: + if (/CSV export is not enabled/i.test(msg)) { + return { kind: "export-disabled", message: msg }; + } + if (/Reports are not enabled/i.test(msg)) { + return { kind: "read-disabled", message: msg }; + } + return { kind: "unknown", message: msg }; + case ERROR_CODE_412: + return { kind: "validation", message: msg }; + case ERROR_CODE_404: + return { kind: "not-found", message: msg }; + case ERROR_CODE_401: + return { kind: "reauth", message: msg }; + case ERROR_CODE_403: + return { kind: "unauthorized", message: msg }; + default: + return { kind: "unknown", message: msg }; + } +}; + +// Read error handler factory — shaped for uicore getRequest: +// getRequest(req, recv, url, makeReadErrorHandler({ onReadError, onValidationError, onExportDisabled })) +// The action creators come from the owning reducer. 403 is surfaced as a +// non-logout unauthorized read error; 401 triggers explicit reauth (doLogin); +// 412 routes to onValidationError (inline/toast), NOT a body replacement. +export const makeReadErrorHandler = + ({ onReadError, onValidationError, onExportDisabled }) => + (err, res) => + (dispatch) => { + const status = err?.status ?? res?.status; + const { kind, message } = classifyReportError( + status, + extractErrorMessage(err, res) + ); + switch (kind) { + case "export-disabled": + if (onExportDisabled) dispatch(onExportDisabled({ message })); + return; + case "validation": + if (onValidationError) dispatch(onValidationError({ status, message })); + return; + case "reauth": + // 401 -> reauthenticate explicitly, preserving full back URL (path + query + hash). + doLogin(getBackURL()); + return; + default: + // read-disabled / not-found / unauthorized / unknown -> replace the body. + if (onReadError) dispatch(onReadError({ kind, status, message })); + } + }; diff --git a/src/utils/report-query.js b/src/utils/report-query.js new file mode 100644 index 000000000..e296c62f3 --- /dev/null +++ b/src/utils/report-query.js @@ -0,0 +1,67 @@ +// src/utils/report-query.js +// +// Translates report UI filter state into a base-api-utils query object. +// +// Filter limitation (base-api-utils, do not modify): a filter[] value with a +// comma is an OR group; separate filter[] entries AND; apply_or_filters merges +// EVERY comma-bracket into one global OR. So multi-select works on at most ONE +// dimension. v1 designates SPONSOR as that dimension; all others are single-value. +// Every emitted value uses valid `field==value` / `field>=value` operator syntax +// (a no-operator value triggers a server IndexError → 500). + +export const buildReportQuery = (filters = {}) => { + const { + sponsorIds = [], + status, + formCode, + pageId, + moduleType, + mediaRequestType, + dateFrom, + dateTo, + search, + order, + page, + perPage, + groupBy + } = filters; + + const filter = []; + + // Sponsor — the one multi-select dimension → comma-OR in a SINGLE bracket. + if (sponsorIds.length > 0) { + // Number() coercion prevents stray-comma strings from injecting extra OR terms. + filter.push(sponsorIds.map((id) => `sponsor_id==${Number(id)}`).join(",")); + } + + // Single-value dimensions — each its own comma-free bracket (AND). + if (status) filter.push(`status==${status}`); + if (formCode) filter.push(`form_code==${formCode}`); + if (pageId) filter.push(`page_id==${pageId}`); + if (moduleType) filter.push(`module_type==${moduleType}`); + if (mediaRequestType) filter.push(`media_request_type==${mediaRequestType}`); + + // Date range — two comma-free brackets (AND). order_date is an IsoDateTimeFilter + // server-side, so dateFrom/dateTo MUST be ISO-8601 strings (never epochs). dateFrom is + // an INCLUSIVE lower bound (>= → __gte); dateTo is an EXCLUSIVE upper bound (< → __lt, + // verified in base-api-utils operator_map). The caller passes the START of the day AFTER + // the range as dateTo, so same-day rows with fractional seconds are included (a <= + // end-of-day bound would drop sub-second-later timestamps). + if (dateFrom != null) filter.push(`order_date>=${dateFrom}`); + if (dateTo != null) filter.push(`order_date<${dateTo}`); + + const query = {}; + if (filter.length > 0) query["filter[]"] = filter; + if (search) query.search = search; + if (order) query.order = order; + if (page != null) query.page = page; + if (perPage != null) query.per_page = perPage; + // Canceled is excluded server-side by default. + if (status === "Canceled") query.include_cancelled = "true"; + + // Grouped mode: filters/search above still apply (server groups the filtered set). + // Only `sponsor`/`component` are valid; an empty/falsy value stays flat (omit). + if (groupBy) query.group_by = groupBy; + + return query; +}; diff --git a/src/utils/reports-api.js b/src/utils/reports-api.js new file mode 100644 index 000000000..662457096 --- /dev/null +++ b/src/utils/reports-api.js @@ -0,0 +1,7 @@ +export const getReportsApiBaseUrl = () => window.SPONSOR_REPORTS_API_URL; + +// Strict positive-integer route-id validator. summit_id / sponsor_id arrive as +// strings from route params; accept only positive integers so a malformed or +// tampered id can't be interpolated into a filter clause, the CSV URL path, or a +// download filename. Invalid ids should render a not-found state, not fetch. +export const isPositiveIntId = (v) => /^[1-9]\d*$/.test(String(v)); diff --git a/src/utils/reports-money.js b/src/utils/reports-money.js new file mode 100644 index 000000000..dc500d7ad --- /dev/null +++ b/src/utils/reports-money.js @@ -0,0 +1,16 @@ +const USD = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2 +}); + +// Formats a DOLLAR amount (number or numeric string) as "$1,234.56". +// Non-numeric / null → em dash. +export const formatUsd = (value) => { + const n = typeof value === "string" ? Number(value) : value; + if (typeof n !== "number" || Number.isNaN(n)) return "—"; + return USD.format(n); +}; + +export default formatUsd; diff --git a/src/utils/reports-text.js b/src/utils/reports-text.js new file mode 100644 index 000000000..4994b65e8 --- /dev/null +++ b/src/utils/reports-text.js @@ -0,0 +1,24 @@ +// Sponsor-portal text fields (content.value / content.summary) may carry HTML +// markup (e.g. "

...

"). The reports render them as plain text, so strip +// tags and decode the common entities rather than show raw markup. We do NOT +// render the HTML (would be an XSS surface); we flatten it to readable text. +const ENTITIES = { + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'", + "'": "'", + " ": " " +}; + +export const toPlainText = (html) => { + if (html == null) return ""; + return String(html) + .replace(/<[^>]*>/g, " ") // tags become whitespace so boundaries don't fuse words + .replace(/&[a-z]+;|&#\d+;/gi, (m) => (m in ENTITIES ? ENTITIES[m] : m)) + .replace(/\s+/g, " ") + .trim(); +}; + +export default toPlainText; diff --git a/src/utils/section-csv-query.js b/src/utils/section-csv-query.js new file mode 100644 index 000000000..f09224d92 --- /dev/null +++ b/src/utils/section-csv-query.js @@ -0,0 +1,38 @@ +// Build a CSV query scoped to one sponsor+page section. Replaces any existing +// sponsor_id / page_id clauses (a second same-field filter[] ANDs to empty), +// preserving every unrelated filter and non-filter param. Parses each comma-OR +// bracket STRUCTURALLY (per clause) so a co-located unrelated clause survives. +// +// Caveat: if an active bracket were a true mixed OR like `status==Paid,sponsor_id==17`, +// stripping the sponsor clause and re-emitting `status==Paid` as its own bracket turns +// OR into AND. The v1 query builder never emits mixed brackets (sponsor is always its +// own bracket), so this is a defensive edge, not a live path. +export const buildSectionCsvQuery = ( + activeQuery = {}, + { sponsorId, pageId } +) => { + const { + "filter[]": existing = [], + page: _page, + per_page: _perPage, + ...rest + } = activeQuery; + const brackets = Array.isArray(existing) ? existing : [existing]; + const kept = []; + for (const bracket of brackets) { + const clauses = String(bracket) + .split(",") + .filter( + (c) => c && !/^sponsor_id[<>=!]/.test(c) && !/^page_id[<>=!]/.test(c) + ); + if (clauses.length) kept.push(clauses.join(",")); + } + const sid = Number(sponsorId); + const pid = Number(pageId); + // Defense-in-depth: callers pass route/backend integer ids (the drill-down page + // validates :sponsorId/:summitId before rendering). Never interpolate a + // non-integer value into a filter clause sent to the CSV endpoint. + if (Number.isInteger(sid)) kept.push(`sponsor_id==${sid}`); + if (Number.isInteger(pid)) kept.push(`page_id==${pid}`); + return { ...rest, "filter[]": kept }; +}; From cdfebcc84d1ec279e60eb82afbca3fd559f2673d Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 17:10:39 -0500 Subject: [PATCH 03/63] feat(sponsor-reports): port shared report components (T.translate, getCSV) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/ExportCsvButton.js | 61 ++++++++++++ src/components/sponsors/reports/FilterBar.js | 99 +++++++++++++++++++ .../sponsors/reports/GroupByToggle.js | 26 +++++ .../sponsors/reports/ReportShell.js | 62 ++++++++++++ src/components/sponsors/reports/StatusPill.js | 18 ++++ .../sponsors/reports/StatusRollupChips.js | 29 ++++++ .../sponsors/reports/SummaryPanel.js | 38 +++++++ src/components/sponsors/reports/TierBadge.js | 45 +++++++++ .../reports/__tests__/ExportCsvButton.test.js | 90 +++++++++++++++++ .../reports/__tests__/FilterBar.test.js | 65 ++++++++++++ .../reports/__tests__/GroupByToggle.test.js | 26 +++++ .../reports/__tests__/ReportShell.test.js | 33 +++++++ .../reports/__tests__/StatusPill.test.js | 25 +++++ .../__tests__/StatusRollupChips.test.js | 35 +++++++ .../reports/__tests__/SummaryPanel.test.js | 28 ++++++ .../reports/__tests__/TierBadge.test.js | 21 ++++ .../reports/__tests__/statusTone.test.js | 20 ++++ .../reports/__tests__/usePrint.test.js | 26 +++++ .../sponsors/reports/report-print.css | 16 +++ src/components/sponsors/reports/statusTone.js | 16 +++ src/components/sponsors/reports/usePrint.js | 8 ++ 21 files changed, 787 insertions(+) create mode 100644 src/components/sponsors/reports/ExportCsvButton.js create mode 100644 src/components/sponsors/reports/FilterBar.js create mode 100644 src/components/sponsors/reports/GroupByToggle.js create mode 100644 src/components/sponsors/reports/ReportShell.js create mode 100644 src/components/sponsors/reports/StatusPill.js create mode 100644 src/components/sponsors/reports/StatusRollupChips.js create mode 100644 src/components/sponsors/reports/SummaryPanel.js create mode 100644 src/components/sponsors/reports/TierBadge.js create mode 100644 src/components/sponsors/reports/__tests__/ExportCsvButton.test.js create mode 100644 src/components/sponsors/reports/__tests__/FilterBar.test.js create mode 100644 src/components/sponsors/reports/__tests__/GroupByToggle.test.js create mode 100644 src/components/sponsors/reports/__tests__/ReportShell.test.js create mode 100644 src/components/sponsors/reports/__tests__/StatusPill.test.js create mode 100644 src/components/sponsors/reports/__tests__/StatusRollupChips.test.js create mode 100644 src/components/sponsors/reports/__tests__/SummaryPanel.test.js create mode 100644 src/components/sponsors/reports/__tests__/TierBadge.test.js create mode 100644 src/components/sponsors/reports/__tests__/statusTone.test.js create mode 100644 src/components/sponsors/reports/__tests__/usePrint.test.js create mode 100644 src/components/sponsors/reports/report-print.css create mode 100644 src/components/sponsors/reports/statusTone.js create mode 100644 src/components/sponsors/reports/usePrint.js diff --git a/src/components/sponsors/reports/ExportCsvButton.js b/src/components/sponsors/reports/ExportCsvButton.js new file mode 100644 index 000000000..a92d73d1d --- /dev/null +++ b/src/components/sponsors/reports/ExportCsvButton.js @@ -0,0 +1,61 @@ +import React, { useState, useRef } from "react"; +import { connect } from "react-redux"; +import { Button } from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import T from "i18n-react/dist/i18n-react"; +import { getCSV } from "openstack-uicore-foundation/lib/utils/actions"; +import { getAccessTokenSafely } from "../../../utils/methods"; + +// D8: fire-and-forget via uicore getCSV (fetchErrorHandler → sweetalert for +// 401/403/412/500). Bespoke CSV error classification from sponsor-services is +// intentionally dropped — generic error handling is convention-aligned and +// sufficient for admin-only access. +// +// Props: { url, query, filename, disabled, label } +// query — already in uicore params shape (e.g. { "filter[]": [...] }); +// access_token is appended here and serialized by uicore's URIjs. +// label — overrides the default i18n label when provided. +// +// Synchronous in-flight ref guard: setBusy(true) only disables the button +// after a re-render; two rapid clicks in the same tick can both enter +// handleClick before the re-render fires. The ref flips synchronously and +// blocks the second click before the first await completes. +const ExportCsvButton = ({ + url, + query = {}, + filename, + disabled = false, + label, + dispatch +}) => { + const [busy, setBusy] = useState(false); + const inFlight = useRef(false); + + const handleClick = async () => { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + try { + const token = await getAccessTokenSafely(); + dispatch(getCSV(url, { ...query, access_token: token }, filename)); + } finally { + inFlight.current = false; + setBusy(false); + } + }; + + return ( + + ); +}; + +// connect() with no args injects raw `dispatch` as a prop — cleanest form for +// a component that needs dispatch but reads nothing from state. +export default connect()(ExportCsvButton); diff --git a/src/components/sponsors/reports/FilterBar.js b/src/components/sponsors/reports/FilterBar.js new file mode 100644 index 000000000..d19c19d62 --- /dev/null +++ b/src/components/sponsors/reports/FilterBar.js @@ -0,0 +1,99 @@ +import React, { useState } from "react"; +import { + Autocomplete, + Box, + Button, + InputAdornment, + Paper, + Stack, + TextField, + Typography +} from "@mui/material"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import SearchIcon from "@mui/icons-material/Search"; +import T from "i18n-react/dist/i18n-react"; + +// Sponsor is the ONLY multi-select (base-api-utils limitation). All other +// dimensions are passed as single-select controls via `extraControls`. +// `showSearch` is OFF by default: only the Sponsor Asset report supports `search` +// server-side; Purchase Details does NOT. +const FilterBar = ({ + sponsors = [], + value = {}, + onApply, + onClear, + extraControls, + showSearch = false +}) => { + const [draft, setDraft] = useState(value); + const update = (patch) => setDraft((d) => ({ ...d, ...patch })); + + return ( + + + + + {T.translate("sponsor_reports_page.report_filters")} + + + + {showSearch && ( + update({ search: e.target.value })} + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + )} + o.name} + isOptionEqualToValue={(o, v) => o.id === v.id} + value={sponsors.filter((s) => + (draft.sponsorIds || []).includes(s.id) + )} + onChange={(_e, selected) => + update({ sponsorIds: selected.map((s) => s.id) }) + } + renderInput={(params) => ( + + )} + /> + {extraControls && extraControls(draft, update)} + + + + + + ); +}; + +export default FilterBar; diff --git a/src/components/sponsors/reports/GroupByToggle.js b/src/components/sponsors/reports/GroupByToggle.js new file mode 100644 index 000000000..5a4eb113e --- /dev/null +++ b/src/components/sponsors/reports/GroupByToggle.js @@ -0,0 +1,26 @@ +import React from "react"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; + +// MUI ToggleButtonGroup passes `null` when the active button is re-clicked +// (exclusive mode); ignore it so the view never ends up with no grouping. +const GroupByToggle = ({ value, onChange }) => ( + { + if (next) onChange(next); + }} + aria-label={T.translate("sponsor_reports_page.group_by")} + > + + {T.translate("sponsor_reports_page.group_by_sponsor")} + + + {T.translate("sponsor_reports_page.group_by_component")} + + +); + +export default GroupByToggle; diff --git a/src/components/sponsors/reports/ReportShell.js b/src/components/sponsors/reports/ReportShell.js new file mode 100644 index 000000000..22c0c3545 --- /dev/null +++ b/src/components/sponsors/reports/ReportShell.js @@ -0,0 +1,62 @@ +import React from "react"; +import { Box, Paper, Stack, Typography } from "@mui/material"; +import "./report-print.css"; + +// Header card (tinted icon square + title/subtitle + action slot) / filter slot / body slot. +const ReportShell = ({ + title, + subtitle, + actions, + filterBar, + icon, + iconTone = "primary", + children +}) => ( + + + + + {icon && ( + + {icon} + + )} + + {title} + {subtitle && ( + + {subtitle} + + )} + + + {actions && ( + + {actions} + + )} + + + {filterBar && {filterBar}} + {children} + +); + +export default ReportShell; diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js new file mode 100644 index 000000000..bcd54caef --- /dev/null +++ b/src/components/sponsors/reports/StatusPill.js @@ -0,0 +1,18 @@ +import React from "react"; +import { Chip } from "@mui/material"; +import { statusTone } from "./statusTone"; + +// A status token rendered as a colored, filled chip. `label` overrides the +// displayed text (e.g. a T.translate'd label); the color always derives from +// the raw `status` token via the shared tone map. +const StatusPill = ({ status, label, size = "small" }) => ( + +); + +export { statusTone }; +export default StatusPill; diff --git a/src/components/sponsors/reports/StatusRollupChips.js b/src/components/sponsors/reports/StatusRollupChips.js new file mode 100644 index 000000000..b97b3e769 --- /dev/null +++ b/src/components/sponsors/reports/StatusRollupChips.js @@ -0,0 +1,29 @@ +import React from "react"; +import { Chip, Stack } from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; +import { statusTone } from "./statusTone"; + +// The backend status_rollup always carries all four lowercase keys; render them +// in a fixed order so cards line up. A missing rollup degrades to all-zero. +const STATUS_KEYS = ["completed", "in_progress", "pending", "not_applicable"]; + +const StatusRollupChips = ({ rollup }) => { + const r = rollup || {}; + return ( + + {STATUS_KEYS.map((key) => ( + + ))} + + ); +}; + +export default StatusRollupChips; diff --git a/src/components/sponsors/reports/SummaryPanel.js b/src/components/sponsors/reports/SummaryPanel.js new file mode 100644 index 000000000..d3cb0976f --- /dev/null +++ b/src/components/sponsors/reports/SummaryPanel.js @@ -0,0 +1,38 @@ +import React from "react"; +import { Box, Paper, Stack, Typography } from "@mui/material"; + +// tone -> theme color for the value text. "neutral"/undefined keeps default. +const TONE_COLOR = { + success: "success.main", + warning: "warning.main", + info: "info.main" +}; + +const SummaryPanel = ({ tiles = [] }) => { + if (!tiles.length) return null; + return ( + + {tiles.map((tile) => ( + + + {tile.label} + + + + {tile.value} + + + + ))} + + ); +}; + +export default SummaryPanel; diff --git a/src/components/sponsors/reports/TierBadge.js b/src/components/sponsors/reports/TierBadge.js new file mode 100644 index 000000000..a5e96760a --- /dev/null +++ b/src/components/sponsors/reports/TierBadge.js @@ -0,0 +1,45 @@ +import React from "react"; +import { Chip } from "@mui/material"; + +// Tier colors aren't in the MUI palette, so map the known tiers to explicit +// sx colors; an unknown-but-present tier renders as a neutral outlined chip. +const TIER_SX = { + gold: { bgcolor: "#F6C944", color: "#5A4500" }, + silver: { bgcolor: "#C9CDD3", color: "#33373D" }, + bronze: { bgcolor: "#CD7F4B", color: "#3A1E0A" } +}; + +// `onDark` makes the neutral (unknown-tier) outlined chip legible on a dark +// surface (the drill-down navy header) — default dark text on navy is +// unreadable. Known tiers use explicit fills that read on any background, so +// onDark only affects the neutral case. +const TierBadge = ({ tier, onDark = false }) => { + if (!tier) return null; + const key = String(tier).toLowerCase(); + const sx = TIER_SX[key]; + if (sx) { + return ( + + ); + } + return ( + + ); +}; + +export default TierBadge; diff --git a/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js b/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js new file mode 100644 index 000000000..5c34584de --- /dev/null +++ b/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js @@ -0,0 +1,90 @@ +// src/components/sponsors/reports/__tests__/ExportCsvButton.test.js +// D8: ExportCsvButton uses uicore getCSV (fire-and-forget, generic error +// handling). Tests assert dispatch args; bespoke error-classification tests +// from the source are intentionally absent. +import "@testing-library/jest-dom"; +import React from "react"; +import { screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import { getCSV } from "openstack-uicore-foundation/lib/utils/actions"; +import ExportCsvButton from "../ExportCsvButton"; +import { getAccessTokenSafely } from "../../../../utils/methods"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) +})); + +jest.mock("../../../../utils/methods", () => ({ + getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) +})); + +describe("ExportCsvButton", () => { + afterEach(() => jest.clearAllMocks()); + + it("dispatches getCSV with url, query+access_token, and filename on click", async () => { + const { store } = renderWithRedux( + + ); + fireEvent.click(screen.getByRole("button", { name: /export/i })); + await waitFor(() => { + expect(getCSV).toHaveBeenCalledWith( + "https://reports-api.test/api/v1/summits/1/reports/purchase-details/csv", + { "filter[]": ["sponsor_id==17"], access_token: "test-token" }, + "purchase-details.csv" + ); + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + + it("is disabled when disabled prop is true", () => { + renderWithRedux( + + ); + expect(screen.getByRole("button", { name: /export/i })).toBeDisabled(); + }); + + it("ignores a second click while the first is in flight (synchronous ref guard)", async () => { + let resolveToken; + getAccessTokenSafely.mockImplementationOnce( + () => + new Promise((res) => { + resolveToken = res; + }) + ); + renderWithRedux(); + const btn = screen.getByRole("button", { name: /export/i }); + // Two native click events in the same tick — the synchronous useRef guard + // must block the second before the first await resolves. + await act(async () => { + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + resolveToken("token"); + await waitFor(() => { + expect(getCSV).toHaveBeenCalledTimes(1); + }); + }); + + it("uses the label prop when provided, otherwise falls back to i18n key", () => { + const { rerender } = renderWithRedux( + + ); + expect( + screen.getByRole("button", { name: "Download" }) + ).toBeInTheDocument(); + + rerender(); + // With the echo mock, T.translate("sponsor_reports_page.export_csv") → key + expect( + screen.getByRole("button", { + name: "sponsor_reports_page.export_csv" + }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/FilterBar.test.js b/src/components/sponsors/reports/__tests__/FilterBar.test.js new file mode 100644 index 000000000..76a6f6693 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/FilterBar.test.js @@ -0,0 +1,65 @@ +// src/components/sponsors/reports/__tests__/FilterBar.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import FilterBar from "../FilterBar"; + +// Hoist mock above component import so T.translate returns the key. +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +const sponsors = [ + { id: 17, name: "Acme" }, + { id: 23, name: "Globex" } +]; + +describe("FilterBar", () => { + it("emits a sponsorIds array (multi-select) on Apply", () => { + const onApply = jest.fn(); + renderWithRedux( + + ); + fireEvent.click(screen.getByRole("button", { name: /apply/i })); + expect(onApply).toHaveBeenCalledWith( + expect.objectContaining({ sponsorIds: [17, 23] }) + ); + }); + + it("renders the Report Filters card title", () => { + renderWithRedux( {}} />); + expect( + screen.getByText("sponsor_reports_page.report_filters") + ).toBeInTheDocument(); + }); + + it("renders the search box only when showSearch is set, and emits the search string", () => { + const onApply = jest.fn(); + const { rerender } = renderWithRedux( + + ); + // default: no search box + expect(screen.queryByLabelText(/search/i)).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByLabelText(/search/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /apply/i })); + expect(onApply).toHaveBeenCalledWith( + expect.objectContaining({ search: "acme" }) + ); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/GroupByToggle.test.js b/src/components/sponsors/reports/__tests__/GroupByToggle.test.js new file mode 100644 index 000000000..4a442c99e --- /dev/null +++ b/src/components/sponsors/reports/__tests__/GroupByToggle.test.js @@ -0,0 +1,26 @@ +// src/components/sponsors/reports/__tests__/GroupByToggle.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import GroupByToggle from "../GroupByToggle"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +describe("GroupByToggle", () => { + it("shows the active value and calls onChange with the other value", () => { + const onChange = jest.fn(); + renderWithRedux(); + fireEvent.click( + screen.getByText("sponsor_reports_page.group_by_component") + ); + expect(onChange).toHaveBeenCalledWith("component"); + }); + + it("ignores a null toggle (clicking the already-active button) — never clears", () => { + const onChange = jest.fn(); + renderWithRedux(); + fireEvent.click(screen.getByText("sponsor_reports_page.group_by_sponsor")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/ReportShell.test.js b/src/components/sponsors/reports/__tests__/ReportShell.test.js new file mode 100644 index 000000000..d3c31e40c --- /dev/null +++ b/src/components/sponsors/reports/__tests__/ReportShell.test.js @@ -0,0 +1,33 @@ +// src/components/sponsors/reports/__tests__/ReportShell.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import ReportShell from "../ReportShell"; + +describe("ReportShell", () => { + it("renders title, subtitle, actions, filterBar and children", () => { + renderWithRedux( + Act} + filterBar={
FilterSlot
} + > +
Body
+
+ ); + expect(screen.getByText("My Title")).toBeInTheDocument(); + expect(screen.getByText("My Subtitle")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Act" })).toBeInTheDocument(); + expect(screen.getByText("FilterSlot")).toBeInTheDocument(); + expect(screen.getByText("Body")).toBeInTheDocument(); + }); + + it("renders an icon node when provided", () => { + renderWithRedux( + i} /> + ); + expect(screen.getByTestId("hdr-icon")).toBeInTheDocument(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js new file mode 100644 index 000000000..e7e8062d6 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -0,0 +1,25 @@ +// src/components/sponsors/reports/__tests__/StatusPill.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import StatusPill, { statusTone } from "../StatusPill"; + +describe("statusTone (re-export)", () => { + it("maps completed/paid/confirmed to success", () => { + expect(statusTone("completed")).toBe("success"); + expect(statusTone("paid")).toBe("success"); + expect(statusTone("Confirmed")).toBe("success"); + }); +}); + +describe("StatusPill", () => { + it("renders the given label, defaulting to the status text", () => { + renderWithRedux(); + expect(screen.getByText("pending")).toBeInTheDocument(); + }); + it("uses an explicit label when provided", () => { + renderWithRedux(); + expect(screen.getByText("Paid")).toBeInTheDocument(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/StatusRollupChips.test.js b/src/components/sponsors/reports/__tests__/StatusRollupChips.test.js new file mode 100644 index 000000000..d9ee6b959 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/StatusRollupChips.test.js @@ -0,0 +1,35 @@ +// src/components/sponsors/reports/__tests__/StatusRollupChips.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import StatusRollupChips from "../StatusRollupChips"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +describe("StatusRollupChips", () => { + it("renders all four status keys with their counts in a stable order", () => { + renderWithRedux( + + ); + expect( + screen.getByText("sponsor_reports_page.status_completed: 8") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_in_progress: 2") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_pending: 4") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_not_applicable: 0") + ).toBeInTheDocument(); + }); + + it("treats a missing rollup as all-zero (no crash)", () => { + renderWithRedux(); + expect(screen.getAllByText(/: 0$/)).toHaveLength(4); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/SummaryPanel.test.js b/src/components/sponsors/reports/__tests__/SummaryPanel.test.js new file mode 100644 index 000000000..9b7635410 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/SummaryPanel.test.js @@ -0,0 +1,28 @@ +// src/components/sponsors/reports/__tests__/SummaryPanel.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import SummaryPanel from "../SummaryPanel"; + +describe("SummaryPanel", () => { + it("renders tiles with label and value", () => { + renderWithRedux( + + ); + expect(screen.getByText("Paid")).toBeInTheDocument(); + expect(screen.getByText("$10.00")).toBeInTheDocument(); + expect(screen.getByText("Orders")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("renders nothing for an empty tile list", () => { + const { container } = renderWithRedux(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/TierBadge.test.js b/src/components/sponsors/reports/__tests__/TierBadge.test.js new file mode 100644 index 000000000..1d3346e35 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/TierBadge.test.js @@ -0,0 +1,21 @@ +// src/components/sponsors/reports/__tests__/TierBadge.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import TierBadge from "../TierBadge"; + +describe("TierBadge", () => { + it("renders the tier label uppercased", () => { + renderWithRedux(); + expect(screen.getByText("GOLD")).toBeInTheDocument(); + }); + it("renders a neutral badge for an unknown tier", () => { + renderWithRedux(); + expect(screen.getByText("PLATINUM")).toBeInTheDocument(); + }); + it("renders nothing when tier is null/empty", () => { + const { container } = renderWithRedux(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/statusTone.test.js b/src/components/sponsors/reports/__tests__/statusTone.test.js new file mode 100644 index 000000000..30916a324 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/statusTone.test.js @@ -0,0 +1,20 @@ +// src/components/sponsors/reports/__tests__/statusTone.test.js +import { statusTone } from "../statusTone"; + +describe("statusTone", () => { + it("maps completed/paid/confirmed to success", () => { + expect(statusTone("completed")).toBe("success"); + expect(statusTone("paid")).toBe("success"); + expect(statusTone("Confirmed")).toBe("success"); + }); + it("maps pending to warning, in_progress to info", () => { + expect(statusTone("pending")).toBe("warning"); + expect(statusTone("in_progress")).toBe("info"); + }); + it("maps not_applicable/canceled and unknown to default", () => { + expect(statusTone("not_applicable")).toBe("default"); + expect(statusTone("Canceled")).toBe("default"); + expect(statusTone("whatever")).toBe("default"); + expect(statusTone(null)).toBe("default"); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/usePrint.test.js b/src/components/sponsors/reports/__tests__/usePrint.test.js new file mode 100644 index 000000000..d6154c3c8 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/usePrint.test.js @@ -0,0 +1,26 @@ +// src/components/sponsors/reports/__tests__/usePrint.test.js +// @testing-library/react 12 (React 16) does not export renderHook; use a +// lightweight component wrapper instead. +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import usePrint from "../usePrint"; + +const PrintTrigger = () => { + const print = usePrint(); + return ( + + ); +}; + +describe("usePrint", () => { + it("invokes window.print", () => { + const printSpy = jest.spyOn(window, "print").mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByRole("button", { name: "print" })); + expect(printSpy).toHaveBeenCalled(); + printSpy.mockRestore(); + }); +}); diff --git a/src/components/sponsors/reports/report-print.css b/src/components/sponsors/reports/report-print.css new file mode 100644 index 000000000..75fcad8c1 --- /dev/null +++ b/src/components/sponsors/reports/report-print.css @@ -0,0 +1,16 @@ +/* src/components/sponsors/reports/report-print.css */ +@media print { + body * { + visibility: hidden; + } + .report-body, + .report-body * { + visibility: visible; + } + .report-body { + position: absolute; + left: 0; + top: 0; + width: 100%; + } +} diff --git a/src/components/sponsors/reports/statusTone.js b/src/components/sponsors/reports/statusTone.js new file mode 100644 index 000000000..5230417f4 --- /dev/null +++ b/src/components/sponsors/reports/statusTone.js @@ -0,0 +1,16 @@ +// Single source of truth: status token -> MUI Chip color. Case-insensitive. +const TONE_BY_STATUS = { + completed: "success", + paid: "success", + confirmed: "success", + pending: "warning", + in_progress: "info", + not_applicable: "default", + canceled: "default", + cancelled: "default" +}; + +export const statusTone = (status) => + TONE_BY_STATUS[String(status || "").toLowerCase()] || "default"; + +export default statusTone; diff --git a/src/components/sponsors/reports/usePrint.js b/src/components/sponsors/reports/usePrint.js new file mode 100644 index 000000000..569ba24bc --- /dev/null +++ b/src/components/sponsors/reports/usePrint.js @@ -0,0 +1,8 @@ +import { useCallback } from "react"; + +// Prints the currently loaded report body only (server-paginated page). +// v1 caveat: captures only the currently loaded page, not the full filtered view. +// A true full-report print would need a print-mode fetch of all pages — out of scope. +const usePrint = () => useCallback(() => window.print(), []); + +export default usePrint; From c2096a384d2fd140908f426fdc78c5537cceb350 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 17:37:18 -0500 Subject: [PATCH 04/63] feat(sponsor-reports): Purchase Details report (MuiTable, conditional refund tile) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/OrdersTable.js | 150 ++++++++ .../reports/__tests__/OrdersTable.test.js | 201 +++++++++++ src/layouts/sponsor-reports-layout.js | 6 + .../__tests__/index.test.js | 246 +++++++++++++ .../purchase-details-report-page/index.js | 332 ++++++++++++++++++ 5 files changed, 935 insertions(+) create mode 100644 src/components/sponsors/reports/OrdersTable.js create mode 100644 src/components/sponsors/reports/__tests__/OrdersTable.test.js create mode 100644 src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js create mode 100644 src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js new file mode 100644 index 000000000..5b726be8a --- /dev/null +++ b/src/components/sponsors/reports/OrdersTable.js @@ -0,0 +1,150 @@ +/** + * Copyright 2026 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 React from "react"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import StatusPill from "./StatusPill"; +import { formatUsd } from "../../../utils/reports-money"; + +// Backend `order=` key map. Keys are the MuiTable columnKey values (= backend +// order keys) for sortable columns. Non-sortable columns (form_display, +// sponsor_note) are intentionally absent — toOrderParam ignores them. +export const SORT_FIELD_MAP = { + number: "number", + order_date: "order_date", + sponsor: "sponsor", + status: "status", + invoice_total: "invoice_total" +}; + +const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" +const MS_PER_SECOND = 1000; +const NOON = 12; + +// Port of OrdersGrid.js formatCheckoutTime — handles BOTH the current ISO +// checkout_at (DRF DateTimeField on backend main) AND a future epoch int +// (pending ClickUp 86bagnfmn). Parses date/time directly off the ISO string +// parts so the displayed time always matches the stored UTC value and tests +// stay timezone-stable regardless of the machine's local TZ offset. +export const formatCheckoutTime = (value) => { + if (value == null || value === "") return ""; + const iso = + typeof value === "number" || /^\d+$/.test(value) + ? new Date(Number(value) * MS_PER_SECOND).toISOString() + : String(value); + const m = iso.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/); + if (!m) return iso.slice(0, ISO_DATE_LENGTH); + const [, date, hh, mm] = m; + const hour24 = Number(hh); + const ampm = hour24 >= NOON ? "PM" : "AM"; + const hour12 = hour24 % NOON || NOON; + return `${date} ${hour12}:${mm} ${ampm}`; +}; + +// MuiTable keys rows on row.id; the API exposes purchase_id, not id. +// The page must call rows.map(r => ({ ...r, id: r.purchase_id })) before +// passing data, or use this helper for explicit mapping. +export const getOrderRowId = (row) => row.purchase_id; + +// Converts MuiTable sort state to the `order` query param expected by the API. +// MuiTable calls onSort(columnKey, dir) where dir = 1 (asc) | -1 (desc). +// Since columnKey IS the backend key for sortable columns, no extra translation +// is needed — the page passes (key, dir) directly to buildReportQuery's `order`. +export const toOrderParam = (columnKey, dir) => { + if (!columnKey) return undefined; + return dir === -1 ? `-${columnKey}` : columnKey; +}; + +// MuiTable column definitions. +// columnKey for sortable columns equals the backend `order=` parameter so +// onSort(columnKey, dir) → toOrderParam(columnKey, dir) yields the correct string +// without any additional translation in the page handler. +// Non-sortable columns (Type, Sponsor Note) use arbitrary unique keys. +const columns = [ + { + columnKey: "number", + header: "Order #", + sortable: true, + render: (row) => row.purchase_number + }, + { + columnKey: "sponsor", + header: "Sponsor", + sortable: true, + render: (row) => row.sponsor?.name ?? "" + }, + { + columnKey: "order_date", + header: "Checkout Time", + sortable: true, + // render reads checkout_at (ISO or epoch) via the shared helper. + render: (row) => formatCheckoutTime(row.checkout_at) + }, + { + columnKey: "form_display", + header: "Type", + sortable: false, // not a backend ordering field + render: (row) => row.form?.display ?? "" + }, + { + columnKey: "status", + header: "Status", + sortable: true, + render: (row) => + }, + { + columnKey: "invoice_total", + header: "Invoice Total", + sortable: true, + align: "right", + render: (row) => formatUsd(row.invoice_total) + }, + { + columnKey: "sponsor_note", + header: "Sponsor Note", + sortable: false // not a backend ordering field + // No render — MuiTable fallback reads row["sponsor_note"] directly. + } +]; + +// eslint-disable-next-line no-magic-numbers +const DEFAULT_PER_PAGE = 10; + +// Props mirror the MuiTable contract used by show-purchase-list-page. +// rows must be raw API rows (purchase_id present); id mapping is done here. +const OrdersTable = ({ + rows = [], + totalRows = 0, + currentPage = 1, + perPage = DEFAULT_PER_PAGE, + order = null, + orderDir = 1, + onPageChange, + onPerPageChange, + onSort +}) => ( + ({ ...row, id: row.purchase_id }))} + options={{ sortCol: order, sortDir: orderDir }} + totalRows={totalRows} + currentPage={currentPage} + perPage={perPage} + onPageChange={onPageChange} + onPerPageChange={onPerPageChange} + onSort={onSort} + /> +); + +export default OrdersTable; diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js new file mode 100644 index 000000000..634343024 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -0,0 +1,201 @@ +// src/components/sponsors/reports/__tests__/OrdersTable.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import OrdersTable, { + formatCheckoutTime, + getOrderRowId, + toOrderParam, + SORT_FIELD_MAP +} from "../OrdersTable"; + +// MuiTable uses i18n-react internally (no-items message, pagination labels). +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k) => k +})); + +// ──────────────────────────────────────────────────────────────────────────── +// formatCheckoutTime — port of OrdersGrid.js helper (timezone-stable UTC parsing) +// ──────────────────────────────────────────────────────────────────────────── +describe("formatCheckoutTime", () => { + it("formats an ISO datetime as 'YYYY-MM-DD h:mm AM/PM' (12-hour, timezone-stable)", () => { + expect(formatCheckoutTime("2026-06-05T15:41:13.13489Z")).toBe( + "2026-06-05 3:41 PM" + ); + expect(formatCheckoutTime("2026-06-05T09:05:00Z")).toBe( + "2026-06-05 9:05 AM" + ); + expect(formatCheckoutTime("2026-06-05T00:00:00Z")).toBe( + "2026-06-05 12:00 AM" + ); + expect(formatCheckoutTime("2026-06-05T12:00:00Z")).toBe( + "2026-06-05 12:00 PM" + ); + }); + + it("formats an epoch (number or all-digit string) as UTC date+time", () => { + // 2026-06-05T15:41:13Z = 1780674073 s + expect(formatCheckoutTime(1780674073)).toBe("2026-06-05 3:41 PM"); + expect(formatCheckoutTime("1780674073")).toBe("2026-06-05 3:41 PM"); + }); + + it("falls back to the date part when there is no time component", () => { + expect(formatCheckoutTime("2026-01-01")).toBe("2026-01-01"); + }); + + it("returns an empty string for null/empty/undefined", () => { + expect(formatCheckoutTime(null)).toBe(""); + expect(formatCheckoutTime(undefined)).toBe(""); + expect(formatCheckoutTime("")).toBe(""); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// sort-key helpers +// ──────────────────────────────────────────────────────────────────────────── +describe("OrdersTable sort helpers", () => { + it("SORT_FIELD_MAP contains backend keys for sortable columns only", () => { + expect(SORT_FIELD_MAP.number).toBe("number"); + expect(SORT_FIELD_MAP.order_date).toBe("order_date"); + expect(SORT_FIELD_MAP.sponsor).toBe("sponsor"); + expect(SORT_FIELD_MAP.status).toBe("status"); + expect(SORT_FIELD_MAP.invoice_total).toBe("invoice_total"); + // Non-sortable columns (Type, Sponsor Note) are NOT in the map + expect(SORT_FIELD_MAP.form_display).toBeUndefined(); + expect(SORT_FIELD_MAP.sponsor_note).toBeUndefined(); + }); + + it("toOrderParam encodes asc (dir=1) and desc (dir=-1)", () => { + expect(toOrderParam("number", 1)).toBe("number"); + expect(toOrderParam("number", -1)).toBe("-number"); + expect(toOrderParam("order_date", -1)).toBe("-order_date"); + expect(toOrderParam("invoice_total", 1)).toBe("invoice_total"); + }); + + it("toOrderParam returns undefined when columnKey is falsy", () => { + expect(toOrderParam(null, 1)).toBeUndefined(); + expect(toOrderParam(undefined, 1)).toBeUndefined(); + expect(toOrderParam("", 1)).toBeUndefined(); + }); + + it("getOrderRowId extracts purchase_id (MuiTable requires row.id)", () => { + expect(getOrderRowId({ purchase_id: 99, id: 1 })).toBe(99); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// OrdersTable rendering +// ──────────────────────────────────────────────────────────────────────────── + +const sampleRow = { + purchase_id: 7, + purchase_number: "ORD-007", + sponsor: { name: "Acme Corp" }, + checkout_at: "2026-06-05T15:41:13Z", + form: { display: "Booth" }, + status: "Paid", + invoice_total: "250.00", + sponsor_note: "VIP note" +}; + +function renderTable(rows = [sampleRow], extraProps = {}) { + return render( + {}} + onPerPageChange={() => {}} + onSort={() => {}} + {...extraProps} + /> + ); +} + +describe("OrdersTable rendering", () => { + it("maps purchase_id → id so MuiTable can key rows without crashing", () => { + const { container } = renderTable(); + // If id mapping works, MuiTable renders at least one data row + expect(container.querySelector("tbody tr")).toBeTruthy(); + }); + + it("renders purchase_number in the Order # column", () => { + renderTable(); + expect(screen.getByText("ORD-007")).toBeInTheDocument(); + }); + + it("renders sponsor.name in the Sponsor column", () => { + renderTable(); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + }); + + it("renders formatCheckoutTime(checkout_at) in the Checkout Time column", () => { + renderTable(); + // "2026-06-05T15:41:13Z" → "2026-06-05 3:41 PM" + expect(screen.getByText("2026-06-05 3:41 PM")).toBeInTheDocument(); + }); + + it("renders form.display in the Type column", () => { + renderTable(); + expect(screen.getByText("Booth")).toBeInTheDocument(); + }); + + it("renders a StatusPill chip for the status column", () => { + renderTable(); + // StatusPill renders a MUI Chip; the label is the status value + expect(screen.getByText("Paid")).toBeInTheDocument(); + }); + + it("renders formatUsd(invoice_total) in the Invoice Total column", () => { + renderTable(); + expect(screen.getByText("$250.00")).toBeInTheDocument(); + }); + + it("renders the sponsor_note column", () => { + renderTable(); + expect(screen.getByText("VIP note")).toBeInTheDocument(); + }); + + it("renders epoch checkout_at correctly (timezone-stable)", () => { + const epochRow = { ...sampleRow, checkout_at: 1780674073 }; + renderTable([epochRow]); + expect(screen.getByText("2026-06-05 3:41 PM")).toBeInTheDocument(); + }); + + it("sortable columns (Order #, Sponsor, etc.) render MuiTableSortLabel; Type and Sponsor Note do not", () => { + renderTable(); + // MuiTable renders a .MuiTableSortLabel-root span for each sortable column + const sortLabels = Array.from( + document.querySelectorAll(".MuiTableSortLabel-root") + ); + const sortLabelTexts = sortLabels.map((el) => el.textContent.trim()); + + // Sortable columns are wrapped in sort labels + expect(sortLabelTexts.some((t) => t.includes("Order #"))).toBe(true); + expect(sortLabelTexts.some((t) => t.includes("Sponsor"))).toBe(true); + expect(sortLabelTexts.some((t) => t.includes("Checkout Time"))).toBe(true); + expect(sortLabelTexts.some((t) => t.includes("Invoice Total"))).toBe(true); + // Non-sortable columns are NOT in sort labels + expect(sortLabelTexts.some((t) => t.includes("Type"))).toBe(false); + expect(sortLabelTexts.some((t) => t.includes("Sponsor Note"))).toBe(false); + }); + + it("clicking a sortable column header calls onSort with (columnKey, dir)", () => { + const handleSort = jest.fn(); + renderTable([sampleRow], { onSort: handleSort }); + // TableSortLabel for "Order #" has onClick → calls onSort("number", dir) + fireEvent.click(screen.getByText("Order #")); + expect(handleSort).toHaveBeenCalledWith("number", expect.any(Number)); + }); + + it("clicking non-sortable Type or Sponsor Note header does NOT call onSort", () => { + const handleSort = jest.fn(); + renderTable([sampleRow], { onSort: handleSort }); + fireEvent.click(screen.getByText("Type")); + fireEvent.click(screen.getByText("Sponsor Note")); + expect(handleSort).not.toHaveBeenCalled(); + }); +}); diff --git a/src/layouts/sponsor-reports-layout.js b/src/layouts/sponsor-reports-layout.js index a312dba11..33290909e 100644 --- a/src/layouts/sponsor-reports-layout.js +++ b/src/layouts/sponsor-reports-layout.js @@ -14,10 +14,16 @@ import React from "react"; import { Route, Switch, withRouter } from "react-router-dom"; import Restrict from "../routes/restrict"; +import PurchaseDetailsReportPage from "../pages/sponsors/sponsor-reports/purchase-details-report-page"; const SponsorReportsLayout = ({ match }) => (
+ ({ + translate: (k) => k +})); + +// Action creators: jest.fn() inside the factory to avoid hoisting issues. +// Import the mocked functions below to assert on .mock.calls. +jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ + getPurchaseDetailsReport: jest.fn(() => ({ + type: "REQUEST_PURCHASE_DETAILS" + })), + getPurchaseDetailsFilters: jest.fn(() => ({ + type: "REQUEST_PURCHASE_DETAILS_FILTERS" + })), + PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", + PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR", + PURCHASE_DETAILS_EXPORT_DISABLED: "PURCHASE_DETAILS_EXPORT_DISABLED" +})); + +// Access the jest.fn() references from the mock (standard jest pattern). +const { + getPurchaseDetailsReport, + getPurchaseDetailsFilters +} = require("../../../../../actions/sponsor-reports-actions"); + +// Mock the API base-url helper so the CSV URL can be constructed in tests. +jest.mock("../../../../../utils/reports-api", () => ({ + getReportsApiBaseUrl: () => "http://test-api" +})); + +// Mock getCSV used by ExportCsvButton (via connect dispatch). +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) +})); + +// Mock getAccessTokenSafely so ExportCsvButton clicks don't fail in tests. +jest.mock("../../../../../utils/methods", () => ({ + getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) +})); + +// ──────────────────────────────────────────────────────────────────────────── +// Test fixtures +// ──────────────────────────────────────────────────────────────────────────── + +const SAMPLE_ROW = { + purchase_id: 1, + purchase_number: "ORD-001", + sponsor: { name: "Acme Corp" }, + checkout_at: "2026-06-05T15:41:13Z", + form: { display: "Booth" }, + status: "Paid", + invoice_total: "100.00", + sponsor_note: "" +}; + +const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports/purchase-details"; +const PAGE_URL = "/app/summits/42/sponsors/reports/purchase-details"; + +function buildState(summaryOverrides = {}) { + return { + sponsorReportsPurchaseDetailsState: { + data: [SAMPLE_ROW], + summary: { + total_orders: 1, + total_items: 1, + total_paid: "100.00", + total_pending: "0.00", + total_refunded: null, + ...summaryOverrides + }, + filterOptions: { sponsors: [], statuses: [], forms: [] }, + total: 1, + loading: false, + readError: null, + validationError: null, + exportDisabled: false + }, + currentSummitState: { + currentSummit: { id: 42 } + } + }; +} + +function renderPage(summaryOverrides = {}) { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + return { + history, + ...renderWithRedux( + + + , + { initialState: buildState(summaryOverrides) } + ) + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Tests +// ──────────────────────────────────────────────────────────────────────────── + +describe("PurchaseDetailsReportPage", () => { + it("dispatches getPurchaseDetailsReport and getPurchaseDetailsFilters on mount", async () => { + renderPage(); + await act(async () => {}); + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + expect(getPurchaseDetailsFilters).toHaveBeenCalled(); + }); + + it("dispatches getPurchaseDetailsReport with page=1 and per_page=10 on initial load", async () => { + renderPage(); + await act(async () => {}); + expect(getPurchaseDetailsReport).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, per_page: 10 }) + ); + }); + + it("renders data rows via OrdersTable (MuiTable)", async () => { + renderPage(); + await act(async () => {}); + // purchase_number rendered by OrdersTable's "Order #" column + expect(screen.getByText("ORD-001")).toBeInTheDocument(); + // sponsor.name rendered by Sponsor column + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + }); + + it("renders summary tiles for total_orders, total_items, total_paid, total_pending", async () => { + renderPage(); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.total_orders") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.total_items") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.total_paid") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.total_pending") + ).toBeInTheDocument(); + }); + + describe("D9 — conditional Total Refunded tile", () => { + it("hides the Total Refunded tile when summary.total_refunded is null", async () => { + renderPage({ total_refunded: null }); + await act(async () => {}); + expect( + screen.queryByText("sponsor_reports_page.total_refunded") + ).not.toBeInTheDocument(); + }); + + it("hides the Total Refunded tile when summary.total_refunded is undefined (key absent)", async () => { + // Build a summary with no total_refunded key at all + const { total_refunded: _r, ...noRefund } = + buildState().sponsorReportsPurchaseDetailsState.summary; + renderPage(noRefund); + await act(async () => {}); + expect( + screen.queryByText("sponsor_reports_page.total_refunded") + ).not.toBeInTheDocument(); + }); + + it("shows the Total Refunded tile when summary.total_refunded is a non-null value", async () => { + renderPage({ total_refunded: "50.00" }); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.total_refunded") + ).toBeInTheDocument(); + }); + + it("shows the Total Refunded tile when summary.total_refunded is 0 (presence check, not truthiness)", async () => { + renderPage({ total_refunded: 0 }); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.total_refunded") + ).toBeInTheDocument(); + }); + }); + + it("renders the page title from i18n", async () => { + renderPage(); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.purchase_details_title") + ).toBeInTheDocument(); + }); + + it("renders the ExportCsvButton", async () => { + renderPage(); + await act(async () => {}); + // ExportCsvButton renders text from T.translate("sponsor_reports_page.export_csv") + // With the echo mock this becomes the key string + expect( + screen.getByText("sponsor_reports_page.export_csv") + ).toBeInTheDocument(); + }); + + it("renders the Print button", async () => { + renderPage(); + await act(async () => {}); + expect(screen.getByText("sponsor_reports_page.print")).toBeInTheDocument(); + }); + + it("dispatches getPurchaseDetailsReport again when a filter changes and Apply is clicked", async () => { + renderPage(); + await act(async () => {}); + getPurchaseDetailsReport.mockClear(); + + // Set the "From date" filter to a non-empty value so the query memo changes. + // FilterBar renders date inputs with type="date"; the first one is "From date". + const dateInputs = document.querySelectorAll("input[type=\"date\"]"); + await act(async () => { + // Trigger the onChange handler which calls update({ dateFrom: "2026-01-01" }) + fireEvent.change(dateInputs[0], { target: { value: "2026-01-01" } }); + }); + + // Click Apply to commit the draft filter to page state + const applyBtn = screen.getByText("sponsor_reports_page.apply"); + await act(async () => { + fireEvent.click(applyBtn); + }); + + // Filter change → query memo invalidated → useEffect re-fires → re-fetch + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; + // Date filter is expanded to ISO and placed in filter[] + expect(calledQuery["filter[]"]).toEqual( + expect.arrayContaining([expect.stringContaining("order_date>=")]) + ); + expect(calledQuery).toMatchObject({ page: 1 }); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js new file mode 100644 index 000000000..c8f0f7f9c --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -0,0 +1,332 @@ +/** + * Copyright 2026 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 React, { useEffect, useMemo, useState } from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import T from "i18n-react/dist/i18n-react"; +import { + Alert, + Box, + Button, + MenuItem, + Snackbar, + TextField +} from "@mui/material"; +import PrintIcon from "@mui/icons-material/Print"; +import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; +import { formatUsd } from "../../../../utils/reports-money"; +import { buildReportQuery } from "../../../../utils/report-query"; +import { getReportsApiBaseUrl } from "../../../../utils/reports-api"; +import ReportShell from "../../../../components/sponsors/reports/ReportShell"; +import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; +import FilterBar from "../../../../components/sponsors/reports/FilterBar"; +import OrdersTable, { + toOrderParam +} from "../../../../components/sponsors/reports/OrdersTable"; +import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; +import usePrint from "../../../../components/sponsors/reports/usePrint"; +import { + getPurchaseDetailsReport, + getPurchaseDetailsFilters, + PURCHASE_DETAILS_VALIDATION_CLEAR +} from "../../../../actions/sponsor-reports-actions"; + +const DEFAULT_PAGE_SIZE = 10; +const TOAST_AUTO_HIDE_MS = 6000; +const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" + +const PurchaseDetailsReportPage = ({ + // From mapStateToProps + currentSummit, + data, + summary, + filterOptions, + total, + loading, + readError, + validationError, + exportDisabled, + // From mapDispatchToProps (function form — includes raw dispatch) + dispatch, + getPurchaseDetailsReport: fetchReport, + getPurchaseDetailsFilters: fetchFilters +}) => { + const print = usePrint(); + + // Local pagination/sort state. MuiTable dir = 1 (asc) | -1 (desc). + const [filters, setFilters] = useState({}); + const [currentPage, setCurrentPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PAGE_SIZE); + const [order, setOrder] = useState(null); + const [orderDir, setOrderDir] = useState(1); + + // Build the API query from all local state. Memoized so useEffect only re-runs + // when the query actually changes (referential stability). + const query = useMemo(() => { + const { dateFrom, dateTo, ...rest } = filters; + // Expand YYYY-MM-DD dates to ISO datetimes for the IsoDateTimeFilter backend field. + // dateTo → start of the NEXT day (exclusive <) so same-day fractional-second rows + // are included rather than dropped by a <= end-of-day bound. + const nextDayStartIso = (ymd) => { + const d = new Date(`${ymd}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; + }; + return buildReportQuery({ + ...rest, + dateFrom: dateFrom ? `${dateFrom}T00:00:00Z` : undefined, + dateTo: dateTo ? nextDayStartIso(dateTo) : undefined, + page: currentPage, + perPage, + order: toOrderParam(order, orderDir) + }); + }, [filters, currentPage, perPage, order, orderDir]); + + // Fetch filters once on mount. Summit is read from store inside the action. + // Empty deps is intentional: fetchFilters is stable from connect() and reads + // summit from Redux store inside the thunk. + useEffect(() => { + fetchFilters(); + }, []); // mount-only + + // Fetch report data whenever the derived query object changes. + // fetchReport reads summit from the store — only query changes drive re-fetches. + useEffect(() => { + fetchReport(query); + }, [query]); // query is memoized; updates only when filters/pagination/sort change + + // ── Summary tiles ─────────────────────────────────────────────────────────── + // D9: Total Refunded tile renders ONLY when summary.total_refunded != null. + // Backend main does not yet expose it (ships in PR #24); the presence check + // keeps the tile hidden on current main and auto-appears after PR #24 deploys. + const tiles = summary + ? [ + { + key: "total_orders", + label: T.translate("sponsor_reports_page.total_orders"), + value: summary.total_orders + }, + { + key: "total_items", + label: T.translate("sponsor_reports_page.total_items"), + value: summary.total_items + }, + { + key: "total_paid", + label: T.translate("sponsor_reports_page.total_paid"), + value: formatUsd(summary.total_paid), + tone: "success" + }, + { + key: "total_pending", + label: T.translate("sponsor_reports_page.total_pending"), + value: formatUsd(summary.total_pending), + tone: "warning" + }, + ...(summary.total_refunded != null + ? [ + { + key: "total_refunded", + label: T.translate("sponsor_reports_page.total_refunded"), + value: formatUsd(summary.total_refunded) + } + ] + : []) + ] + : []; + + // ── CSV export ────────────────────────────────────────────────────────────── + const csvUrl = currentSummit + ? `${getReportsApiBaseUrl()}/api/v1/summits/${ + currentSummit.id + }/reports/purchase-details/csv` + : ""; + const csvQuery = useMemo(() => { + // Drop pagination params from the CSV query — exports the full filtered set. + const { page: _page, per_page: _perPage, ...rest } = query; + return rest; + }, [query]); + + // ── FilterBar handlers ────────────────────────────────────────────────────── + // Applying/clearing a filter changes the result set → snap back to page 1. + const handleApply = (next) => { + setFilters(next); + setCurrentPage(1); + }; + const handleClear = () => { + setFilters({}); + setCurrentPage(1); + }; + + // ── Sort/pagination handlers ───────────────────────────────────────────────── + const handleSort = (columnKey, dir) => { + setOrder(columnKey); + setOrderDir(dir); + setCurrentPage(1); + }; + const handlePageChange = (page) => { + setCurrentPage(page); + }; + const handlePerPageChange = (newPerPage) => { + setPerPage(newPerPage); + setCurrentPage(1); + }; + + // ── Extra filter controls (status / type / date range) ────────────────────── + const statusOptions = filterOptions?.statuses || []; + // Drop forms with no display name — they render as unpickable blank rows. + const formOptions = (filterOptions?.forms || []).filter((f) => + f.name?.trim() + ); + + const extraControls = (draft, update) => ( + <> + update({ status: e.target.value || undefined })} + > + {T.translate("sponsor_reports_page.any")} + {statusOptions.map((s) => ( + + {s} + + ))} + + update({ formCode: e.target.value || undefined })} + > + {T.translate("sponsor_reports_page.any")} + {formOptions.map((f) => ( + + {f.name} + + ))} + + {/* Date inputs emit ISO YYYY-MM-DD — expanded to ISO datetimes in buildQuery */} + update({ dateFrom: e.target.value || undefined })} + /> + update({ dateTo: e.target.value || undefined })} + /> + + ); + + return ( + } + iconTone="primary" + subtitle={T.translate("sponsor_reports_page.purchase_details_subtitle")} + actions={ + <> + + + + } + filterBar={ + + + + } + > + + {/* 412 → inline toast; body preserved (rows stay visible) */} + dispatch({ type: PURCHASE_DETAILS_VALIDATION_CLEAR })} + > + + {validationError?.message || + T.translate("sponsor_reports_page.validation_error")} + + + {readError ? ( + + {readError.message || T.translate("sponsor_reports_page.read_error")} + + ) : ( + + )} + + ); +}; + +const mapStateToProps = ({ + sponsorReportsPurchaseDetailsState, + currentSummitState +}) => ({ + currentSummit: currentSummitState.currentSummit, + ...sponsorReportsPurchaseDetailsState +}); + +// Function form of mapDispatchToProps: injects raw dispatch (needed for the +// PURCHASE_DETAILS_VALIDATION_CLEAR action in the Snackbar handler) alongside +// the bound action creators. +const mapDispatchToProps = (dispatch) => ({ + dispatch, + getPurchaseDetailsReport: (query) => + dispatch(getPurchaseDetailsReport(query)), + getPurchaseDetailsFilters: () => dispatch(getPurchaseDetailsFilters()) +}); + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(PurchaseDetailsReportPage) +); From 71cfdc144fbc66a9aa98bc47019bd9244dbe7378 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 17:45:04 -0500 Subject: [PATCH 05/63] test(sponsor-reports): cover CSV+pagination/sort dispatch; drop vestigial SORT_FIELD_MAP Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/OrdersTable.js | 11 --- .../reports/__tests__/OrdersTable.test.js | 14 +--- .../__tests__/index.test.js | 67 +++++++++++++++++-- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 5b726be8a..60b9c4215 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -16,17 +16,6 @@ import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import StatusPill from "./StatusPill"; import { formatUsd } from "../../../utils/reports-money"; -// Backend `order=` key map. Keys are the MuiTable columnKey values (= backend -// order keys) for sortable columns. Non-sortable columns (form_display, -// sponsor_note) are intentionally absent — toOrderParam ignores them. -export const SORT_FIELD_MAP = { - number: "number", - order_date: "order_date", - sponsor: "sponsor", - status: "status", - invoice_total: "invoice_total" -}; - const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" const MS_PER_SECOND = 1000; const NOON = 12; diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index 634343024..15e1616ad 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -5,8 +5,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import OrdersTable, { formatCheckoutTime, getOrderRowId, - toOrderParam, - SORT_FIELD_MAP + toOrderParam } from "../OrdersTable"; // MuiTable uses i18n-react internally (no-items message, pagination labels). @@ -54,17 +53,6 @@ describe("formatCheckoutTime", () => { // sort-key helpers // ──────────────────────────────────────────────────────────────────────────── describe("OrdersTable sort helpers", () => { - it("SORT_FIELD_MAP contains backend keys for sortable columns only", () => { - expect(SORT_FIELD_MAP.number).toBe("number"); - expect(SORT_FIELD_MAP.order_date).toBe("order_date"); - expect(SORT_FIELD_MAP.sponsor).toBe("sponsor"); - expect(SORT_FIELD_MAP.status).toBe("status"); - expect(SORT_FIELD_MAP.invoice_total).toBe("invoice_total"); - // Non-sortable columns (Type, Sponsor Note) are NOT in the map - expect(SORT_FIELD_MAP.form_display).toBeUndefined(); - expect(SORT_FIELD_MAP.sponsor_note).toBeUndefined(); - }); - it("toOrderParam encodes asc (dir=1) and desc (dir=-1)", () => { expect(toOrderParam("number", 1)).toBe("number"); expect(toOrderParam("number", -1)).toBe("-number"); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 46fa150c1..a26e1fcf9 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -27,6 +27,7 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ })); // Access the jest.fn() references from the mock (standard jest pattern). +const { getCSV } = require("openstack-uicore-foundation/lib/utils/actions"); const { getPurchaseDetailsReport, getPurchaseDetailsFilters @@ -43,6 +44,7 @@ jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) })); + // Mock getAccessTokenSafely so ExportCsvButton clicks don't fail in tests. jest.mock("../../../../../utils/methods", () => ({ getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) @@ -66,7 +68,7 @@ const SAMPLE_ROW = { const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports/purchase-details"; const PAGE_URL = "/app/summits/42/sponsors/reports/purchase-details"; -function buildState(summaryOverrides = {}) { +function buildState(summaryOverrides = {}, { total = 1 } = {}) { return { sponsorReportsPurchaseDetailsState: { data: [SAMPLE_ROW], @@ -79,7 +81,7 @@ function buildState(summaryOverrides = {}) { ...summaryOverrides }, filterOptions: { sponsors: [], statuses: [], forms: [] }, - total: 1, + total, loading: false, readError: null, validationError: null, @@ -91,7 +93,7 @@ function buildState(summaryOverrides = {}) { }; } -function renderPage(summaryOverrides = {}) { +function renderPage(summaryOverrides = {}, stateOptions = {}) { const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); return { history, @@ -99,7 +101,7 @@ function renderPage(summaryOverrides = {}) { , - { initialState: buildState(summaryOverrides) } + { initialState: buildState(summaryOverrides, stateOptions) } ) }; } @@ -243,4 +245,61 @@ describe("PurchaseDetailsReportPage", () => { ); expect(calledQuery).toMatchObject({ page: 1 }); }); + + it("CSV export button dispatches getCSV with the summit-scoped URL, filtered query+access_token, and filename", async () => { + renderPage(); + await act(async () => {}); + + // ExportCsvButton renders text from T.translate("sponsor_reports_page.export_csv") + const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); + await act(async () => { + fireEvent.click(exportBtn); + }); + + // getCSV is dispatched with (url, { ...csvQuery, access_token }, filename). + // csvQuery drops page/per_page; with no filters it is empty → only access_token. + expect(getCSV).toHaveBeenCalledTimes(1); + expect(getCSV).toHaveBeenCalledWith( + "http://test-api/api/v1/summits/42/reports/purchase-details/csv", + { access_token: "test-token" }, + "purchase-details-summit-42.csv" + ); + }); + + it("re-dispatches getPurchaseDetailsReport with the new page when MuiTable pagination changes (1-based)", async () => { + // total > perPage so the TablePagination "next page" button is enabled. + renderPage({}, { total: 25 }); + await act(async () => {}); + getPurchaseDetailsReport.mockClear(); + + // MUI TablePagination renders a next-page button. MuiTable converts the + // 0-based MUI page to a 1-based page before calling the page's onPageChange, + // so page 2 (not 1, not 0) must reach the query. + const nextBtn = screen.getByRole("button", { name: /next page/i }); + await act(async () => { + fireEvent.click(nextBtn); + }); + + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; + expect(calledQuery).toMatchObject({ page: 2, per_page: 10 }); + }); + + it("re-dispatches getPurchaseDetailsReport with the backend order param when a sortable column header is clicked", async () => { + renderPage(); + await act(async () => {}); + getPurchaseDetailsReport.mockClear(); + + // Clicking the "Order #" sort label toggles direction. Initial sortDir is 1 (asc), + // so MuiTable calls onSort("number", -1) → order param "-number". + const orderHeader = screen.getByText("Order #"); + await act(async () => { + fireEvent.click(orderHeader); + }); + + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; + // Sort change snaps back to page 1; order is the backend key with desc prefix. + expect(calledQuery).toMatchObject({ page: 1, order: "-number" }); + }); }); From c3b4a0254e0f5cd29966685f7dd669fc173d1809 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 18:02:37 -0500 Subject: [PATCH 06/63] feat(sponsor-reports): Sponsor Assets report + drill-down (rewired routes) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/GroupByComponentView.js | 114 ++++++ .../sponsors/reports/GroupBySponsorView.js | 79 ++++ .../__tests__/GroupByComponentView.test.js | 129 +++++++ .../__tests__/GroupBySponsorView.test.js | 117 ++++++ src/layouts/sponsor-reports-layout.js | 15 + .../sponsor-reports/__tests__/routing.test.js | 65 ++++ .../__tests__/index.test.js | 289 +++++++++++++++ .../sponsor-asset-drilldown-page/index.js | 342 ++++++++++++++++++ .../__tests__/index.test.js | 260 +++++++++++++ .../sponsor-asset-report-page/index.js | 265 ++++++++++++++ 10 files changed, 1675 insertions(+) create mode 100644 src/components/sponsors/reports/GroupByComponentView.js create mode 100644 src/components/sponsors/reports/GroupBySponsorView.js create mode 100644 src/components/sponsors/reports/__tests__/GroupByComponentView.test.js create mode 100644 src/components/sponsors/reports/__tests__/GroupBySponsorView.test.js create mode 100644 src/pages/sponsors/sponsor-reports/__tests__/routing.test.js create mode 100644 src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js create mode 100644 src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js create mode 100644 src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js create mode 100644 src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js diff --git a/src/components/sponsors/reports/GroupByComponentView.js b/src/components/sponsors/reports/GroupByComponentView.js new file mode 100644 index 000000000..b8282402b --- /dev/null +++ b/src/components/sponsors/reports/GroupByComponentView.js @@ -0,0 +1,114 @@ +/** + * Copyright 2026 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 React from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { + Avatar, + Card, + CardContent, + Chip, + Divider, + Link as MuiLink, + Stack, + Typography +} from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; +import StatusRollupChips from "./StatusRollupChips"; +import StatusPill from "./StatusPill"; +import { toPlainText } from "../../../utils/reports-text"; + +const NOT_PRESENT_STATUSES = ["pending", "not_applicable"]; + +const hasContent = (content) => + !!(content && (content.summary || content.value || content.filename)); + +// "Sponsor not present yet" is FE-derived from status + absence of content, +// NOT from submitted_at being null (an Info/Document row can be completed with +// content yet have submitted_at === null). +const isNotPresent = (entry) => + NOT_PRESENT_STATUSES.includes(entry.status) && !hasContent(entry.content); + +// Each sponsor link inside a component card goes to the summit-admin drill-down. +// NOTE: path is /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id, +// NOT the old /app/reports/summits/:summitId/... path from the sponsor-services source. +const GroupByComponentView = ({ summitId, cards = [] }) => ( + + {cards.map((card, idx) => ( + + + + + {card.component.is_unnamed + ? T.translate("sponsor_reports_page.unnamed_component") + : card.component.name} + + + + + + + {card.sponsors.map((entry) => ( + + + + {entry.name} + + + + {isNotPresent(entry) + ? T.translate("sponsor_reports_page.not_present_yet") + : toPlainText( + entry.content?.summary || + entry.content?.value || + entry.content?.filename || + "" + )} + + + ))} + + + + ))} + +); + +export default GroupByComponentView; diff --git a/src/components/sponsors/reports/GroupBySponsorView.js b/src/components/sponsors/reports/GroupBySponsorView.js new file mode 100644 index 000000000..b5c8b6e29 --- /dev/null +++ b/src/components/sponsors/reports/GroupBySponsorView.js @@ -0,0 +1,79 @@ +/** + * Copyright 2026 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 React from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { + Avatar, + Card, + CardActionArea, + CardContent, + Chip, + Stack, + Typography +} from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; +import StatusRollupChips from "./StatusRollupChips"; +import TierBadge from "./TierBadge"; + +// Each sponsor card links to the summit-admin per-sponsor drill-down. +// NOTE: the drill-down path is /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id, +// NOT the old /app/reports/summits/:summitId/... path from the sponsor-services source. +const GroupBySponsorView = ({ summitId, cards = [] }) => ( + + {cards.map((card) => { + const s = card.sponsor; + // company_name often equals name — only show it when it adds information. + const showCompany = s.company_name && s.company_name !== s.name; + return ( + + + + + + {s.name} + {showCompany && ( + + {s.company_name} + + )} + + + + + + + + ); + })} + +); + +export default GroupBySponsorView; diff --git a/src/components/sponsors/reports/__tests__/GroupByComponentView.test.js b/src/components/sponsors/reports/__tests__/GroupByComponentView.test.js new file mode 100644 index 000000000..d90e7425e --- /dev/null +++ b/src/components/sponsors/reports/__tests__/GroupByComponentView.test.js @@ -0,0 +1,129 @@ +/** + * Copyright 2026 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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import GroupByComponentView from "../GroupByComponentView"; + +// Echo i18n keys; interpolate count for Chip labels. +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k, opts) => + opts && opts.count != null ? `${k}:${opts.count}` : k +})); + +const cards = [ + { + component: { name: "Company Logo", is_unnamed: false }, + sponsor_count: 2, + status_rollup: { + completed: 1, + in_progress: 0, + pending: 1, + not_applicable: 0 + }, + sponsors: [ + { + id: 17, + name: "Acme", + logo_url: null, + status: "completed", + submitted_at: "2026-06-09T16:00:19Z", + content: { summary: "Acme bio", value: null, filename: "logo.png" } + }, + { + id: 23, + name: "Beta", + logo_url: null, + status: "pending", + submitted_at: null, + content: { summary: null, value: null, filename: null } + } + ] + }, + { + component: { name: "", is_unnamed: true }, + sponsor_count: 1, + status_rollup: { + completed: 0, + in_progress: 0, + pending: 1, + not_applicable: 0 + }, + sponsors: [ + { + id: 31, + name: "Cee", + logo_url: null, + status: "pending", + submitted_at: null, + content: { summary: null, value: null, filename: null } + } + ] + } +]; + +const renderView = () => + render( + + + + ); + +describe("GroupByComponentView", () => { + it("renders a component card with its name and a sponsor-count pill", () => { + renderView(); + expect(screen.getByText("Company Logo")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.sponsors_count:2") + ).toBeInTheDocument(); + }); + + it("renders the (Unnamed) label for an is_unnamed card", () => { + renderView(); + expect( + screen.getByText("sponsor_reports_page.unnamed_component") + ).toBeInTheDocument(); + }); + + it("shows a present sponsor's content and a not-present hint for an empty one", () => { + renderView(); + expect(screen.getByText("Acme bio")).toBeInTheDocument(); + // Beta (pending, no content) and Cee (pending, no content) + expect( + screen.getAllByText("sponsor_reports_page.not_present_yet") + ).toHaveLength(2); + }); + + it("links each sponsor entry to its SUMMIT-ADMIN drill-down route (not the old /app/reports path)", () => { + renderView(); + const acme = screen.getByRole("link", { name: /Acme/ }); + expect(acme).toHaveAttribute( + "href", + "/app/summits/42/sponsors/reports/sponsor-assets/sponsors/17" + ); + const beta = screen.getByRole("link", { name: /Beta/ }); + expect(beta).toHaveAttribute( + "href", + "/app/summits/42/sponsors/reports/sponsor-assets/sponsors/23" + ); + }); + + it("renders the status rollup chips for the component card", () => { + renderView(); + expect( + screen.getAllByText(/sponsor_reports_page\.status_completed/).length + ).toBeGreaterThan(0); + }); +}); diff --git a/src/components/sponsors/reports/__tests__/GroupBySponsorView.test.js b/src/components/sponsors/reports/__tests__/GroupBySponsorView.test.js new file mode 100644 index 000000000..6a3db73a6 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/GroupBySponsorView.test.js @@ -0,0 +1,117 @@ +/** + * Copyright 2026 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 "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import GroupBySponsorView from "../GroupBySponsorView"; + +// Echo i18n keys; interpolate count for Chip labels. +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k, opts) => + opts && opts.count != null ? `${k}:${opts.count}` : k +})); + +const cards = [ + { + sponsor: { + id: 17, + name: "Acme", + company_name: "Acme Inc", + tier: "Gold", + logo_url: null + }, + component_count: 3, + status_rollup: { + completed: 2, + in_progress: 0, + pending: 1, + not_applicable: 0 + } + } +]; + +const renderView = () => + render( + + + + ); + +describe("GroupBySponsorView", () => { + it("renders a sponsor card with name and a components-count pill", () => { + renderView(); + expect(screen.getByText("Acme")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.components_count:3") + ).toBeInTheDocument(); + }); + + it("links the card to the SUMMIT-ADMIN sponsor drill-down route (not the old /app/reports path)", () => { + renderView(); + // CardActionArea renders as when component=RouterLink — getByRole("link") finds it. + const link = screen.getByRole("link", { name: /Acme/ }); + expect(link).toHaveAttribute( + "href", + "/app/summits/42/sponsors/reports/sponsor-assets/sponsors/17" + ); + }); + + it("renders the status rollup chips", () => { + renderView(); + // StatusRollupChips renders "sponsor_reports_page.status_completed: 2" etc. + expect( + screen.getByText(/sponsor_reports_page\.status_completed/) + ).toBeInTheDocument(); + }); + + it("renders the tier badge", () => { + renderView(); + expect(screen.getByText("GOLD")).toBeInTheDocument(); + }); + + it("shows company_name when it differs from sponsor name", () => { + renderView(); + expect(screen.getByText("Acme Inc")).toBeInTheDocument(); + }); + + it("hides company_name when it equals the sponsor name", () => { + render( + + + + ); + // name appears once (heading), NOT duplicated as company line + expect(screen.getAllByText("AcBel Polytech")).toHaveLength(1); + }); +}); diff --git a/src/layouts/sponsor-reports-layout.js b/src/layouts/sponsor-reports-layout.js index 33290909e..e411927c6 100644 --- a/src/layouts/sponsor-reports-layout.js +++ b/src/layouts/sponsor-reports-layout.js @@ -15,6 +15,8 @@ import React from "react"; import { Route, Switch, withRouter } from "react-router-dom"; import Restrict from "../routes/restrict"; import PurchaseDetailsReportPage from "../pages/sponsors/sponsor-reports/purchase-details-report-page"; +import SponsorAssetDrilldownPage from "../pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page"; +import SponsorAssetReportPage from "../pages/sponsors/sponsor-reports/sponsor-asset-report-page"; const SponsorReportsLayout = ({ match }) => (
@@ -24,6 +26,19 @@ const SponsorReportsLayout = ({ match }) => ( path={`${match.url}/purchase-details`} component={PurchaseDetailsReportPage} /> + {/* Drill-down (more specific) FIRST so the base /sponsor-assets route + cannot shadow it even with exact on both. Belt-and-suspenders ordering + per React Router v4 Switch semantics (first match wins). */} + + in sponsor-reports-layout correctly routes +// /sponsor-assets/sponsors/:sponsorId to the drilldown page and NOT the list +// (i.e., the drill-down route is matched before the base route can shadow it). +// +// This test uses standalone stub components to avoid full Redux setup — it is +// purely about route-matching correctness. + +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Router, Route, Switch } from "react-router-dom"; +import { createMemoryHistory } from "history"; + +const BASE = "/app/summits/1/sponsors/reports"; + +// Minimal stubs (same shape as the real pages in terms of rendering a testid). +const StubList = () =>
; +const StubDrilldown = () =>
; + +// Replicate the route-ordering declared in sponsor-reports-layout.js so this +// test verifies the ACTUAL ordering (drill-down first, exact on both). +const renderSwitch = (url) => { + const history = createMemoryHistory({ initialEntries: [url] }); + return render( + + + + + + + ); +}; + +describe("SponsorReportsLayout routing — sponsor-assets", () => { + it("navigating to /sponsor-assets/sponsors/:sponsorId renders the DRILLDOWN page, not the list", () => { + renderSwitch(`${BASE}/sponsor-assets/sponsors/17`); + expect(screen.getByTestId("asset-drilldown-page")).toBeInTheDocument(); + expect(screen.queryByTestId("asset-list-page")).not.toBeInTheDocument(); + }); + + it("navigating to /sponsor-assets renders the LIST page, not the drilldown", () => { + renderSwitch(`${BASE}/sponsor-assets`); + expect(screen.getByTestId("asset-list-page")).toBeInTheDocument(); + expect( + screen.queryByTestId("asset-drilldown-page") + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js new file mode 100644 index 000000000..66c8c9e92 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -0,0 +1,289 @@ +/** + * Copyright 2026 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. + * */ + +// src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js + +import "@testing-library/jest-dom"; +import React from "react"; +import { act, screen } from "@testing-library/react"; +import { Router, Route } from "react-router-dom"; +import { createMemoryHistory } from "history"; +import { renderWithRedux } from "utils/test-utils"; +import SponsorAssetDrilldownPage from "../index"; + +// Echo i18n keys verbatim. +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k) => k +})); + +// Stub ExportCsvButton to avoid real CSV logic in tests. +jest.mock("../../../../../components/sponsors/reports/ExportCsvButton", () => ({ + __esModule: true, + default: ({ label, disabled }) => ( + + ) +})); + +jest.mock("../../../../../utils/reports-api", () => ({ + getReportsApiBaseUrl: () => "http://test-api", + isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") + .isPositiveIntId +})); + +jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ + getSponsorAssetSponsor: jest.fn(() => ({ type: "GET_DRILLDOWN" })), + SPONSOR_DRILLDOWN_EXPORT_DISABLED: "SPONSOR_DRILLDOWN_EXPORT_DISABLED", + SPONSOR_DRILLDOWN_READ_ERROR: "SPONSOR_DRILLDOWN_READ_ERROR" +})); + +const { + getSponsorAssetSponsor +} = require("../../../../../actions/sponsor-reports-actions"); + +const PAGE_ROUTE = + "/app/summits/:summit_id/sponsors/reports/sponsor-assets/sponsors/:sponsorId"; + +function buildState(drilldownOverrides = {}) { + return { + sponsorReportsDrilldownState: { + detail: null, + loading: false, + readError: null, + exportDisabled: false, + ...drilldownOverrides + }, + currentSummitState: { + currentSummit: { id: 1 } + } + }; +} + +function renderAt(url, drilldownOverrides = {}) { + const history = createMemoryHistory({ initialEntries: [url] }); + return { + history, + ...renderWithRedux( + + + , + { initialState: buildState(drilldownOverrides) } + ) + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("SponsorAssetDrilldownPage", () => { + it("dispatches getSponsorAssetSponsor(sponsorId) on mount — no summitId arg (summit from state)", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + loading: true + }); + await act(async () => {}); + // Task-2 thunk: getSponsorAssetSponsor(sponsorId) only; summit comes from getState inside thunk. + expect(getSponsorAssetSponsor).toHaveBeenCalledWith("17"); + expect(getSponsorAssetSponsor).toHaveBeenCalledTimes(1); + }); + + it("renders not-found and skips the fetch for a malformed sponsorId (sponsorId=0)", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/0"); + await act(async () => {}); + expect(screen.getByTestId("sponsor-not-found")).toBeInTheDocument(); + expect(getSponsorAssetSponsor).not.toHaveBeenCalled(); + }); + + it("renders not-found state on a 404 readError", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + readError: { kind: "not-found", message: "Sponsor not found" } + }); + await act(async () => {}); + expect(screen.getByTestId("sponsor-not-found")).toBeInTheDocument(); + }); + + it("renders the sponsor header, page sections, and module rows from the real detail shape", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 3 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed" + } + ] + } + ] + } + }); + await act(async () => {}); + // Sponsor name appears in both the ReportShell title (h5) and the navy header (h6) + expect(screen.getAllByText("Acme").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Booth")).toBeInTheDocument(); + expect(screen.getByText("Logo")).toBeInTheDocument(); + }); + + it("renders the navy header with tier badge", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { + id: 17, + name: "AcBel Polytech", + tier: "Gold", + pages_active: 3 + }, + pages: [] + } + }); + await act(async () => {}); + expect(screen.getAllByText("AcBel Polytech").length).toBeGreaterThanOrEqual( + 1 + ); + // TierBadge renders tier.toUpperCase() + expect(screen.getByText("GOLD")).toBeInTheDocument(); + }); + + it("renders the pages_active count in the sponsor header", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme Corp", tier: "Silver", pages_active: 5 }, + pages: [] + } + }); + await act(async () => {}); + // With echo mock, T.translate("sponsor_reports_page.pages_active") → the key + expect( + screen.getByText("sponsor_reports_page.pages_active") + ).toBeInTheDocument(); + }); + + it("shows the sponsor-no-submissions state when the sponsor has no pages", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold" }, + pages: [] + } + }); + await act(async () => {}); + expect(screen.getByTestId("sponsor-no-submissions")).toBeInTheDocument(); + }); + + it("ContentCell: image row renders with preview_url", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 2 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed", + content: { + filename: "logo.png", + preview_url: "https://x/logo.png" + } + } + ] + } + ] + } + }); + await act(async () => {}); + expect(screen.getByRole("img", { name: /logo/i })).toHaveAttribute( + "src", + "https://x/logo.png" + ); + }); + + it("ContentCell: document row renders a download link, NOT an ", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 2 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 2, title: "Deck", type: "Document" }, + status: "completed", + content: { + filename: "deck.pdf", + preview_url: "https://x/deck.pdf" + }, + actions: { single_download_url: "https://x/deck.pdf" } + } + ] + } + ] + } + }); + await act(async () => {}); + const pdfLink = screen.getByRole("link", { name: /deck\.pdf/i }); + expect(pdfLink).toHaveAttribute("href", "https://x/deck.pdf"); + expect(pdfLink).toHaveAttribute("rel", "noopener noreferrer"); + expect( + screen.queryByRole("img", { name: /deck/i }) + ).not.toBeInTheDocument(); + }); + + it("ContentCell: shows pending_upload placeholder when there is no url or text", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Bronze", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 3, title: "Empty", type: "Media" }, + status: "pending" + } + ] + } + ] + } + }); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.pending_upload") + ).toBeInTheDocument(); + }); + + it("ContentCell: flattens HTML in a text value to plain text", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 4, title: "Blurb", type: "Info" }, + status: "completed", + content: { value: "

cespinTEST3

" } + } + ] + } + ] + } + }); + await act(async () => {}); + expect(screen.getByText("cespinTEST3")).toBeInTheDocument(); + expect(screen.queryByText("

cespinTEST3

")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js new file mode 100644 index 000000000..16c71b0a3 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -0,0 +1,342 @@ +/** + * Copyright 2026 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. + * */ + +// src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +// +// Per-sponsor asset drill-down page. Reads summitId from Redux state +// (currentSummitState.currentSummit) and sponsorId from the URL via withRouter +// (match.params.sponsorId). Only sponsorId is validated with isPositiveIntId; +// summitId comes from authenticated state and is always a valid integer. +// +// The drill-down shows the sponsor header + per-page cards with module rows. +// Each module row can hold a media image, a document download link, or a text +// value; the ContentCell component gates on filename extension (not MIME type) +// because the backend returns the same minted URL for both (sponsor_asset_serializers.py:72,76). + +import React, { useEffect } from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import T from "i18n-react/dist/i18n-react"; +import { + Avatar, + Box, + Button, + Card, + CardContent, + Grid, + Link as MuiLink, + Stack, + Typography +} from "@mui/material"; +import PrintIcon from "@mui/icons-material/Print"; +import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; +import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; +import DownloadIcon from "@mui/icons-material/Download"; +import { buildSectionCsvQuery } from "../../../../utils/section-csv-query"; +import { toPlainText } from "../../../../utils/reports-text"; +import { + getReportsApiBaseUrl, + isPositiveIntId +} from "../../../../utils/reports-api"; +import ReportShell from "../../../../components/sponsors/reports/ReportShell"; +import usePrint from "../../../../components/sponsors/reports/usePrint"; +import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; +import TierBadge from "../../../../components/sponsors/reports/TierBadge"; +import StatusPill from "../../../../components/sponsors/reports/StatusPill"; +import { getSponsorAssetSponsor } from "../../../../actions/sponsor-reports-actions"; + +// Gate the on an image file extension; render every other file as a +// download link (a PDF url in an would show a broken image). +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp)$/i; + +// ContentCell uses T.translate directly (no `t` prop) — this component is +// co-located with the page and uses the same i18n module as everything else. +const ContentCell = ({ row }) => { + const url = + row.content?.preview_url || row.actions?.single_download_url || null; + const filename = row.content?.filename || ""; + // value/summary may carry HTML markup — flatten to plain text (don't render markup). + const text = toPlainText( + row.content?.value || row.content?.summary || filename + ); + const isImage = !!url && IMAGE_EXT.test(filename || url); + + if (url && isImage) { + return ( + + ); + } + if (url) { + return ( + + + + {filename || row.module.title} + + + + ); + } + if (text) { + return ( + + {text} + + ); + } + return ( + + + + {T.translate("sponsor_reports_page.pending_upload")} + + + ); +}; + +const SponsorAssetDrilldownPage = ({ + // From mapStateToProps + currentSummit, + detail, + loading, + readError, + exportDisabled, + // From mapDispatchToProps + getSponsorAssetSponsor: fetchSponsor, + // From withRouter + match +}) => { + const print = usePrint(); + + // sponsorId from URL; summitId from Redux state (not URL params per summit-admin pattern). + const {sponsorId} = match.params; + // Accept only strict positive integers so a malformed :sponsorId cannot be + // interpolated into filter clauses or the CSV URL path. + const validParams = isPositiveIntId(sponsorId); + + // Fetch sponsor detail on mount / sponsorId change; summit is read from + // getState inside the action — only sponsorId is passed. + useEffect(() => { + if (validParams) fetchSponsor(sponsorId); + }, [sponsorId, validParams]); // fetchSponsor is stable from connect — no dep needed + + const csvBase = currentSummit + ? `${getReportsApiBaseUrl()}/api/v1/summits/${ + currentSummit.id + }/reports/sponsor-assets/csv` + : ""; + + if (!validParams || readError?.kind === "not-found") { + return ( + + + + {T.translate("sponsor_reports_page.sponsor_not_found")} + + + + ); + } + + if (readError) { + return ( + + + + {readError.message || + T.translate("sponsor_reports_page.read_error")} + + + + ); + } + + const sponsor = detail?.sponsor; + const pages = detail?.pages || []; + + return ( + } variant="outlined" onClick={print}> + {T.translate("sponsor_reports_page.print")} + + } + > + {loading && ( + {T.translate("sponsor_reports_page.loading")} + )} + {/* A valid sponsor with no submissions returns pages: [] (NOT a 404). */} + {!loading && detail && pages.length === 0 && ( + + + {T.translate("sponsor_reports_page.sponsor_no_submissions")} + + + )} + {sponsor && ( + + + {(sponsor.name || "?").charAt(0)} + + + {sponsor.name} + + + {typeof sponsor.pages_active === "number" && ( + + {T.translate("sponsor_reports_page.pages_active", { + count: sponsor.pages_active + })} + + )} + + + + )} + + {pages.map((section) => ( + + + + + {section.page.title} + + + + + {section.modules?.map((row) => ( + + + + + {row.module.title} + + + + + + + ))} + + + + ))} + + ); +}; + +const mapStateToProps = ({ + sponsorReportsDrilldownState, + currentSummitState +}) => ({ + currentSummit: currentSummitState.currentSummit, + ...sponsorReportsDrilldownState +}); + +const mapDispatchToProps = (dispatch) => ({ + getSponsorAssetSponsor: (sponsorId) => + dispatch(getSponsorAssetSponsor(sponsorId)) +}); + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(SponsorAssetDrilldownPage) +); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js new file mode 100644 index 000000000..3761cf65e --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -0,0 +1,260 @@ +/** + * Copyright 2026 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. + * */ + +// src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js + +import "@testing-library/jest-dom"; +import React from "react"; +import { act, screen, fireEvent } from "@testing-library/react"; +import { Router, Route } from "react-router-dom"; +import { createMemoryHistory } from "history"; +import { renderWithRedux } from "utils/test-utils"; +import SponsorAssetReportPage from "../index"; + +// Echo i18n keys so T.translate("sponsor_reports_page.foo") → "sponsor_reports_page.foo" +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k) => k +})); + +// Stub action creators — bare redux-mock-store (thunk middleware included via test-utils) +// only needs plain-object return values from these mocked thunks. +jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ + getSponsorAssetFilters: jest.fn(() => ({ type: "GET_SA_FILTERS" })), + getSponsorAssetReport: jest.fn(() => ({ type: "GET_SA_REPORT" })), + SPONSOR_ASSET_EXPORT_DISABLED: "SPONSOR_ASSET_EXPORT_DISABLED", + SPONSOR_ASSET_READ_ERROR: "SPONSOR_ASSET_READ_ERROR" +})); + +// Stub ExportCsvButton so tests can inspect the `query` prop without triggering +// a real CSV fetch. +jest.mock("../../../../../components/sponsors/reports/ExportCsvButton", () => ({ + __esModule: true, + default: ({ query, disabled, label }) => ( + + ) +})); + +jest.mock("../../../../../utils/reports-api", () => ({ + getReportsApiBaseUrl: () => "http://test-api", + isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") + .isPositiveIntId +})); + +// Require after mocks so the jest.fn() references are the mocked ones. +const { + getSponsorAssetFilters, + getSponsorAssetReport +} = require("../../../../../actions/sponsor-reports-actions"); + +const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports/sponsor-assets"; +const PAGE_URL = "/app/summits/42/sponsors/reports/sponsor-assets"; + +const sponsorCards = [ + { + sponsor: { + id: 17, + name: "Acme", + company_name: "Acme Inc", + tier: "Gold", + logo_url: null + }, + component_count: 3, + status_rollup: { + completed: 1, + in_progress: 1, + pending: 1, + not_applicable: 0 + } + } +]; + +function buildState(assetOverrides = {}) { + return { + sponsorReportsSponsorAssetState: { + filterOptions: { sponsors: [{ id: 17, name: "Acme" }] }, + data: sponsorCards, + currentPage: 1, + lastPage: 1, + summary: { + total: 3, + by_status: { + completed: 1, + in_progress: 1, + pending: 1, + not_applicable: 0 + } + }, + loading: false, + readError: null, + exportDisabled: false, + ...assetOverrides + }, + currentSummitState: { + currentSummit: { id: 42 } + } + }; +} + +function renderPage(assetOverrides = {}) { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + return { + history, + ...renderWithRedux( + + + , + { initialState: buildState(assetOverrides) } + ) + }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("SponsorAssetReportPage", () => { + it("dispatches getSponsorAssetFilters (no args) and getSponsorAssetReport on mount", async () => { + renderPage(); + await act(async () => {}); + expect(getSponsorAssetFilters).toHaveBeenCalledWith(); + expect(getSponsorAssetReport).toHaveBeenCalledWith( + expect.objectContaining({ group_by: "sponsor" }) + ); + }); + + it("dispatches getSponsorAssetReport with group_by=component when the Component toggle is clicked", async () => { + renderPage({ data: [], currentPage: 1, lastPage: 1 }); + await act(async () => {}); + getSponsorAssetReport.mockClear(); + + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_reports_page.group_by_component" + }) + ); + await act(async () => {}); + + expect(getSponsorAssetReport).toHaveBeenCalled(); + const lastCall = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ]; + expect(lastCall[0]).toEqual( + expect.objectContaining({ group_by: "component" }) + ); + }); + + it("renders the by_status summary tiles from the summary object", async () => { + renderPage(); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.status_completed") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_in_progress") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_pending") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.status_not_applicable") + ).toBeInTheDocument(); + }); + + it("renders the sponsor cards when data holds sponsor-shaped cards", async () => { + renderPage(); + await act(async () => {}); + expect(screen.getByText("Acme")).toBeInTheDocument(); + }); + + it("renders pagination and dispatches getSponsorAssetReport with new page on a page change", async () => { + renderPage({ lastPage: 3, currentPage: 1 }); + await act(async () => {}); + getSponsorAssetReport.mockClear(); + + // Clicking page 2 button in MUI Pagination + const nav = screen.getByRole("navigation"); + const page2 = Array.from(nav.querySelectorAll("button")).find((b) => + b.textContent.includes("2") + ); + fireEvent.click(page2); + await act(async () => {}); + + expect(getSponsorAssetReport).toHaveBeenCalled(); + const query = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ][0]; + expect(query).toMatchObject({ page: 2 }); + }); + + it("renders the summit-not-found guard when currentSummit is null", async () => { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + renderWithRedux( + + + , + { + initialState: { + sponsorReportsSponsorAssetState: { + filterOptions: null, + data: [], + currentPage: 0, + lastPage: 0, + summary: null, + loading: false, + readError: null, + exportDisabled: false + }, + currentSummitState: { currentSummit: null } + } + } + ); + await act(async () => {}); + expect(screen.getByTestId("reports-summit-not-found")).toBeInTheDocument(); + expect(getSponsorAssetFilters).not.toHaveBeenCalled(); + expect(getSponsorAssetReport).not.toHaveBeenCalled(); + }); + + it("renders the ExportCsvButton (enabled by default)", async () => { + renderPage(); + await act(async () => {}); + expect(screen.getByTestId("export-csv")).not.toBeDisabled(); + }); + + it("disables the ExportCsvButton when exportDisabled is true", async () => { + renderPage({ exportDisabled: true }); + await act(async () => {}); + expect(screen.getByTestId("export-csv")).toBeDisabled(); + }); + + it("hides the no-groups empty state until currentPage >= 1", async () => { + renderPage({ data: [], currentPage: 0, lastPage: 0 }); + await act(async () => {}); + expect(screen.queryByTestId("reports-no-groups")).not.toBeInTheDocument(); + + jest.clearAllMocks(); + renderPage({ data: [], currentPage: 1, lastPage: 1 }); + await act(async () => {}); + expect(screen.getAllByTestId("reports-no-groups").length).toBeGreaterThan( + 0 + ); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js new file mode 100644 index 000000000..d0b04f469 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -0,0 +1,265 @@ +/** + * Copyright 2026 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 React, { useEffect, useMemo, useState } from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, Pagination, Stack, Typography } from "@mui/material"; +import PrintIcon from "@mui/icons-material/Print"; +import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; +import { buildReportQuery } from "../../../../utils/report-query"; +import { + getReportsApiBaseUrl, + isPositiveIntId +} from "../../../../utils/reports-api"; +import ReportShell from "../../../../components/sponsors/reports/ReportShell"; +import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; +import FilterBar from "../../../../components/sponsors/reports/FilterBar"; +import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle"; +import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; +import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; +import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; +import usePrint from "../../../../components/sponsors/reports/usePrint"; +import { + getSponsorAssetFilters, + getSponsorAssetReport +} from "../../../../actions/sponsor-reports-actions"; + +const STATUS_TILE_KEYS = [ + "completed", + "in_progress", + "pending", + "not_applicable" +]; +const TILE_TONE = { + completed: "success", + in_progress: "info", + pending: "warning", + not_applicable: "neutral" +}; +const GROUP_PER_PAGE = 25; +const FIRST_PAGE = 1; + +const SponsorAssetReportPage = ({ + // From mapStateToProps + currentSummit, + filterOptions, + data, + summary, + lastPage, + currentPage, + loading, + readError, + exportDisabled, + // From mapDispatchToProps + getSponsorAssetReport: fetchReport, + getSponsorAssetFilters: fetchFilters +}) => { + const print = usePrint(); + + // Summit comes from Redux state (not URL params) — page is inside the summit + // route context and always has a valid currentSummit when rendered normally. + const validSummit = !!(currentSummit && isPositiveIntId(currentSummit.id)); + + const [groupBy, setGroupBy] = useState("sponsor"); + const [filters, setFilters] = useState({}); + const [page, setPage] = useState(FIRST_PAGE); + + // Fetch sponsor filter options once on mount; summit is read from store inside + // the action. Guard on validSummit so no network call fires when currentSummit + // is temporarily null (race on initial load or in test scaffolding). + useEffect(() => { + if (validSummit) fetchFilters(); + }, []); // mount-only — validSummit is stable once the summit context is set + + // Build the API query from all local state. Memoized so useEffect only re-runs + // when the query actually changes (referential stability). + const query = useMemo( + () => + buildReportQuery({ + ...filters, + groupBy, + page, + perPage: GROUP_PER_PAGE + }), + [filters, groupBy, page] + ); + + // Fetch the grouped report whenever the derived query changes; skips if + // currentSummit is not yet available (rare — summit always loads before nav). + useEffect(() => { + if (validSummit) fetchReport(query); + }, [query]); // query is memoized; re-fetches only on real changes + + // CSV export uses the flat row export path: strip group_by/page/per_page/order + // so the export matches the active filters but ignores grouping & pagination. + const csvQuery = useMemo(() => { + const { + group_by: _groupBy, + page: _page, + per_page: _perPage, + order: _order, + ...rest + } = query; + return rest; + }, [query]); + + const csvUrl = currentSummit + ? `${getReportsApiBaseUrl()}/api/v1/summits/${ + currentSummit.id + }/reports/sponsor-assets/csv` + : ""; + + const onApply = (next) => { + setPage(FIRST_PAGE); + setFilters(next); + }; + const onClear = () => { + setPage(FIRST_PAGE); + setFilters({}); + }; + const onGroupBy = (next) => { + setPage(FIRST_PAGE); + setGroupBy(next); + }; + + const tiles = STATUS_TILE_KEYS.map((key) => ({ + key, + label: T.translate(`sponsor_reports_page.status_${key}`), + value: summary?.by_status?.[key] ?? 0, + tone: TILE_TONE[key] + })); + + if (!validSummit) { + return ( + + + + {T.translate("sponsor_reports_page.summit_not_found")} + + + + ); + } + + return ( + } + iconTone="primary" + actions={ + <> + + + + } + > + + + + + {summary && } + + {loading && ( + {T.translate("sponsor_reports_page.loading")} + )} + {!loading && readError && ( + + + {readError.message || + T.translate("sponsor_reports_page.read_error")} + + + )} + {/* currentPage is 0 until the first report load → no empty-state flash before the + fetch resolves, and no flicker if /filters lands before the report (Task 3 decouple). */} + {!loading && + !readError && + currentPage >= FIRST_PAGE && + data.length === 0 && ( + + + {T.translate("sponsor_reports_page.no_results")} + + + )} + {/* Render the view that matches the data we actually hold, not the live toggle — + a stale/out-of-order grouped response could otherwise feed the wrong view component + a mismatched card shape and crash (sponsor card has .sponsor, component card .component). */} + {!loading && !readError && data.length > 0 && !!data[0].sponsor && ( + + )} + {!loading && !readError && data.length > 0 && !!data[0].component && ( + + )} + + {!loading && !readError && lastPage > FIRST_PAGE && ( + + setPage(p)} + /> + + )} + + ); +}; + +const mapStateToProps = ({ + sponsorReportsSponsorAssetState, + currentSummitState +}) => ({ + currentSummit: currentSummitState.currentSummit, + ...sponsorReportsSponsorAssetState +}); + +const mapDispatchToProps = (dispatch) => ({ + getSponsorAssetReport: (query) => dispatch(getSponsorAssetReport(query)), + getSponsorAssetFilters: () => dispatch(getSponsorAssetFilters()) +}); + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(SponsorAssetReportPage) +); From 084589cc2a6b11f950bfe06c12508e408b66b4b0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 18:26:32 -0500 Subject: [PATCH 07/63] feat(sponsor-reports): Reports landing + integration Replace placeholder with two MUI card links (Purchase Details, Sponsor Assets); add breadcrumb; add landing_title i18n key; update layout test for card assertions; 6 new tests, 912 total. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/i18n/en.json | 3 +- .../__tests__/sponsor-reports-layout.test.js | 16 +++- src/layouts/sponsor-reports-layout.js | 11 +-- .../__tests__/index.test.js | 88 +++++++++++++++++++ .../reports-landing-page/index.js | 73 +++++++++++++++ 5 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js create mode 100644 src/pages/sponsors/sponsor-reports/reports-landing-page/index.js diff --git a/src/i18n/en.json b/src/i18n/en.json index be983132c..c955ccdf4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4309,6 +4309,7 @@ "pending_upload": "Pending Upload", "pages_active": "{count} pages active", "purchase_details_desc": "Orders & revenue", - "sponsor_assets_desc": "Sponsor portal assets" + "sponsor_assets_desc": "Sponsor portal assets", + "landing_title": "Reports" } } diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index d013a7d17..2df8c4d65 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -23,6 +23,13 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); +// react-breadcrumbs: render a stub so the landing page Breadcrumb doesn't error +jest.mock("react-breadcrumbs", () => ({ + Breadcrumb: ({ data }) => ( +
+ ) +})); + // Provide real access-routes data so Restrict/Member gates correctly. // Without this the YAML transform stub returns "" and hasAccess() always returns true. jest.mock("../../access-routes.yml", () => ({ @@ -53,10 +60,13 @@ const renderLayout = (groups) => { }; describe("SponsorReportsLayout", () => { - it("renders the reports placeholder for an administrator", () => { + it("renders the reports landing page (two cards) for an administrator", () => { renderLayout([{ code: "administrators" }]); expect( - screen.getByTestId("sponsor-reports-placeholder") + screen.getByTestId("report-card-purchase-details") + ).toBeInTheDocument(); + expect( + screen.getByTestId("report-card-sponsor-assets") ).toBeInTheDocument(); }); @@ -65,7 +75,7 @@ describe("SponsorReportsLayout", () => { // UnAuthorizedPage renders:

Sorry...

expect(screen.getByText("Sorry...")).toBeInTheDocument(); expect( - screen.queryByTestId("sponsor-reports-placeholder") + screen.queryByTestId("report-card-purchase-details") ).not.toBeInTheDocument(); }); }); diff --git a/src/layouts/sponsor-reports-layout.js b/src/layouts/sponsor-reports-layout.js index e411927c6..069688629 100644 --- a/src/layouts/sponsor-reports-layout.js +++ b/src/layouts/sponsor-reports-layout.js @@ -17,6 +17,7 @@ import Restrict from "../routes/restrict"; import PurchaseDetailsReportPage from "../pages/sponsors/sponsor-reports/purchase-details-report-page"; import SponsorAssetDrilldownPage from "../pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page"; import SponsorAssetReportPage from "../pages/sponsors/sponsor-reports/sponsor-asset-report-page"; +import ReportsLandingPage from "../pages/sponsors/sponsor-reports/reports-landing-page"; const SponsorReportsLayout = ({ match }) => (
@@ -39,15 +40,7 @@ const SponsorReportsLayout = ({ match }) => ( path={`${match.url}/sponsor-assets`} component={SponsorAssetReportPage} /> - ( -
-

Sponsor Reports

-
- )} - /> +
); diff --git a/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js new file mode 100644 index 000000000..74659a596 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js @@ -0,0 +1,88 @@ +// src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js +/** + * Copyright 2026 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 + */ + +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Router, Route } from "react-router-dom"; +import { createMemoryHistory } from "history"; +import ReportsLandingPage from "../index"; + +// Echo i18n keys so T.translate("sponsor_reports_page.foo") → "sponsor_reports_page.foo" +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k) => k +})); + +// react-breadcrumbs: render a simple stub so we can assert the breadcrumb title +jest.mock("react-breadcrumbs", () => ({ + Breadcrumb: ({ data }) => ( +
+ ) +})); + +const BASE = "/app/summits/1/sponsors/reports"; +const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports"; + +function renderLanding(url = BASE) { + const history = createMemoryHistory({ initialEntries: [url] }); + return render( + + + + ); +} + +describe("ReportsLandingPage", () => { + it("renders a card for Purchase Details", () => { + renderLanding(); + expect( + screen.getByText("sponsor_reports_page.purchase_details_title") + ).toBeInTheDocument(); + }); + + it("renders a card for Sponsor Assets", () => { + renderLanding(); + expect( + screen.getByText("sponsor_reports_page.sponsor_assets_title") + ).toBeInTheDocument(); + }); + + it("Purchase Details card links to .../purchase-details", () => { + renderLanding(); + const link = screen + .getByText("sponsor_reports_page.purchase_details_title") + .closest("a"); + expect(link).not.toBeNull(); + expect(link.getAttribute("href")).toBe(`${BASE}/purchase-details`); + }); + + it("Sponsor Assets card links to .../sponsor-assets", () => { + renderLanding(); + const link = screen + .getByText("sponsor_reports_page.sponsor_assets_title") + .closest("a"); + expect(link).not.toBeNull(); + expect(link.getAttribute("href")).toBe(`${BASE}/sponsor-assets`); + }); + + it("renders a breadcrumb with the landing_title i18n key", () => { + renderLanding(); + const bc = screen.getByTestId("breadcrumb"); + expect(bc).toBeInTheDocument(); + expect(bc.getAttribute("data-title")).toBe( + "sponsor_reports_page.landing_title" + ); + }); + + it("renders exactly two report cards", () => { + renderLanding(); + // Each card has a data-testid + expect(screen.getAllByTestId(/^report-card-/).length).toBe(2); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js b/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js new file mode 100644 index 000000000..d53e5e730 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js @@ -0,0 +1,73 @@ +/** + * Copyright 2026 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 React from "react"; +import { Link, withRouter } from "react-router-dom"; +import T from "i18n-react/dist/i18n-react"; +import { Breadcrumb } from "react-breadcrumbs"; +import { + Card, + CardActionArea, + CardContent, + Grid2, + Typography +} from "@mui/material"; + +const CARDS = [ + { + id: "purchase-details", + titleKey: "sponsor_reports_page.purchase_details_title", + descKey: "sponsor_reports_page.purchase_details_desc" + }, + { + id: "sponsor-assets", + titleKey: "sponsor_reports_page.sponsor_assets_title", + descKey: "sponsor_reports_page.sponsor_assets_desc" + } +]; + +const ReportsLandingPage = ({ match }) => ( +
+ +

{T.translate("sponsor_reports_page.landing_title")}

+ + {CARDS.map((card) => ( + + + + + + {T.translate(card.titleKey)} + + + {T.translate(card.descKey)} + + + + + + ))} + +
+); + +export default withRouter(ReportsLandingPage); From db5680f20d5882686e7f2c31565b2697b0d70d0c Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 18:46:04 -0500 Subject: [PATCH 08/63] =?UTF-8?q?chore(sponsor-reports):=20final-review=20?= =?UTF-8?q?cleanup=20=E2=80=94=20drilldown=20errors,=20i18n,=20prop,=20rou?= =?UTF-8?q?te=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 52 +++++++++++++- src/actions/sponsor-reports-actions.js | 7 +- src/i18n/en.json | 7 -- .../__tests__/sponsor-reports-layout.test.js | 68 +++++++++++++++++++ .../purchase-details-report-page/index.js | 2 - 5 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 3046a6096..d143c0219 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -6,7 +6,6 @@ import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; import * as methods from "../../utils/methods"; import { makeReadErrorHandler } from "../../utils/report-errors"; - import { getPurchaseDetailsReport, getPurchaseDetailsFilters, @@ -23,7 +22,8 @@ import { RECEIVE_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET_FILTERS, REQUEST_SPONSOR_DRILLDOWN, - RECEIVE_SPONSOR_DRILLDOWN + RECEIVE_SPONSOR_DRILLDOWN, + SPONSOR_DRILLDOWN_READ_ERROR } from "../sponsor-reports-actions"; jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ @@ -244,6 +244,54 @@ describe("sponsor-reports-actions", () => { expect(capturedUrl).toContain("/summits/42/"); expect(capturedUrl).toContain("/sponsors/7"); }); + + it("412 on drilldown read dispatches SPONSOR_DRILLDOWN_READ_ERROR (clears loading)", async () => { + // Simulate getRequest invoking the error handler with a 412 response. + getRequest.mockImplementation( + (requestAC, _receiveAC, _url, errorHandler) => () => (dispatch) => { + if (requestAC) dispatch(requestAC({})); + errorHandler({ status: 412 }, {})(dispatch); + return Promise.resolve(); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetSponsor(17)); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_SPONSOR_DRILLDOWN); + // 412 must dispatch a loading-clearing error action, not silently no-op. + expect(types).toContain(SPONSOR_DRILLDOWN_READ_ERROR); + }); + + it("503 export-disabled on drilldown read dispatches SPONSOR_DRILLDOWN_READ_ERROR (clears loading)", async () => { + // Simulate getRequest invoking the error handler with a 503 export-disabled response. + getRequest.mockImplementation( + (requestAC, _receiveAC, _url, errorHandler) => () => (dispatch) => { + if (requestAC) dispatch(requestAC({})); + errorHandler( + { + status: 503, + response: { + body: { message: "CSV export is not enabled for this summit" } + } + }, + {} + )(dispatch); + return Promise.resolve(); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetSponsor(17)); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_SPONSOR_DRILLDOWN); + // export-disabled 503 must also clear loading via an error action. + expect(types).toContain(SPONSOR_DRILLDOWN_READ_ERROR); + }); }); // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 79626b3fc..ab657de33 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -127,7 +127,12 @@ export const getSponsorAssetSponsor = createAction(RECEIVE_SPONSOR_DRILLDOWN), `${base(currentSummit.id)}/sponsor-assets/sponsors/${sponsorId}`, makeReadErrorHandler({ - onReadError: createAction(SPONSOR_DRILLDOWN_READ_ERROR) + onReadError: createAction(SPONSOR_DRILLDOWN_READ_ERROR), + // A 412 or export-disabled 503 on a read endpoint must still clear + // loading; route both to the same READ_ERROR action so the page does + // not spin forever. + onValidationError: createAction(SPONSOR_DRILLDOWN_READ_ERROR), + onExportDisabled: createAction(SPONSOR_DRILLDOWN_READ_ERROR) }) )({ access_token: accessToken })(dispatch) .catch(() => {}) diff --git a/src/i18n/en.json b/src/i18n/en.json index c955ccdf4..439e2a7c6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4259,8 +4259,6 @@ "uploaded": "Uploaded" }, "sponsor_reports_page": { - "unauthorized_title": "Access restricted", - "unauthorized_body": "You don't have permission to view sponsor reports. Contact an administrator if you believe this is a mistake.", "search": "Search", "filter_sponsor": "Sponsor", "apply": "Apply", @@ -4289,8 +4287,6 @@ "sponsor_no_submissions": "This sponsor has no submissions yet.", "no_results": "No results.", "summit_not_found": "Summit not found.", - "summit_reports_title": "Summit Reports", - "summit": "Summit", "status_completed": "Completed", "status_in_progress": "In Progress", "status_pending": "Pending", @@ -4302,9 +4298,6 @@ "sponsors_count": "{count} sponsors", "unnamed_component": "(Unnamed)", "not_present_yet": "Not present yet", - "picker_title": "Reports", - "select_summit": "Select a summit to view its reports.", - "no_summits": "No summits with report data.", "report_filters": "Report Filters", "pending_upload": "Pending Upload", "pages_active": "{count} pages active", diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index 2df8c4d65..162f4bdbb 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -40,6 +40,33 @@ jest.mock("../../access-routes.yml", () => ({ ] })); +// Mock reports-api so child pages can build URLs without a real API host. +jest.mock("../../utils/reports-api", () => ({ + getReportsApiBaseUrl: () => "http://test-api", + isPositiveIntId: (v) => /^[1-9]\d*$/.test(String(v)) +})); + +// Mock action creators used by the connected child pages. +// Returns plain objects so the mock store can record them without real thunk logic. +jest.mock("../../actions/sponsor-reports-actions", () => ({ + getSponsorAssetSponsor: jest.fn(() => ({ type: "MOCK_GET_DRILLDOWN" })), + getSponsorAssetReport: jest.fn(() => ({ type: "MOCK_GET_SPONSOR_ASSET" })), + getSponsorAssetFilters: jest.fn(() => ({ + type: "MOCK_GET_SPONSOR_ASSET_FILTERS" + })), + getPurchaseDetailsReport: jest.fn(() => ({ + type: "MOCK_GET_PURCHASE_DETAILS" + })), + getPurchaseDetailsFilters: jest.fn(() => ({ + type: "MOCK_GET_PURCHASE_DETAILS_FILTERS" + })), + SPONSOR_DRILLDOWN_READ_ERROR: "SPONSOR_DRILLDOWN_READ_ERROR", + SPONSOR_DRILLDOWN_EXPORT_DISABLED: "SPONSOR_DRILLDOWN_EXPORT_DISABLED", + PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", + REQUEST_SPONSOR_DRILLDOWN: "REQUEST_SPONSOR_DRILLDOWN", + RECEIVE_SPONSOR_DRILLDOWN: "RECEIVE_SPONSOR_DRILLDOWN" +})); + const REPORTS_ROUTE = "/app/summits/:summit_id/sponsors/reports"; const REPORTS_URL = "/app/summits/1/sponsors/reports"; @@ -78,4 +105,45 @@ describe("SponsorReportsLayout", () => { screen.queryByTestId("report-card-purchase-details") ).not.toBeInTheDocument(); }); + + it("renders the drilldown page (not the landing) when navigating to the deep sponsor-assets/sponsors/:sponsorId path as admin", () => { + // Integration test: mounts the REAL Restrict-wrapped SponsorReportsLayout and + // navigates to the drilldown sub-route so the Switch routes to SponsorAssetDrilldownPage + // rather than the landing. Proves the route table resolves the deep path end-to-end + // through the admin gate, not just the list/landing. + const DRILLDOWN_URL = + "/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17"; + const history = createMemoryHistory({ initialEntries: [DRILLDOWN_URL] }); + + renderWithRedux( + + + , + { + initialState: { + loggedUserState: { + member: { groups: [{ code: "administrators" }] } + }, + currentSummitState: { currentSummit: { id: 1 } }, + sponsorReportsDrilldownState: { + detail: null, + loading: true, + readError: null, + exportDisabled: false + } + } + } + ); + + // The drilldown page renders its loading indicator — the landing cards are absent. + expect( + screen.getByText("sponsor_reports_page.loading") + ).toBeInTheDocument(); + expect( + screen.queryByTestId("report-card-purchase-details") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("report-card-sponsor-assets") + ).not.toBeInTheDocument(); + }); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index c8f0f7f9c..6641f7fe2 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -53,7 +53,6 @@ const PurchaseDetailsReportPage = ({ summary, filterOptions, total, - loading, readError, validationError, exportDisabled, @@ -295,7 +294,6 @@ const PurchaseDetailsReportPage = ({ Date: Wed, 24 Jun 2026 20:13:37 -0500 Subject: [PATCH 09/63] refactor(sponsor-reports): drop vestigial export-disabled; route to READ_ERROR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove *_EXPORT_DISABLED action constants (purchase-details, sponsor-asset, drilldown) and their dead reducer arms. Route onExportDisabled → READ_ERROR in getPurchaseDetailsReport and getSponsorAssetReport thunks (mirrors the already-fixed drilldown), so a 503 export-disabled on a read path clears loading via the same arm as not-found/unauthorized. Remove exportDisabled and exportError from all three reducer DEFAULT_STATEs, drop disabled={exportDisabled} from all three ExportCsvButton usages, and clean up all test references to the deleted constants and unreachable state. Add 503 export-disabled coverage for getPurchaseDetailsReport and getSponsorAssetReport matching the existing drilldown tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 67 +++++++++++++++++-- src/actions/sponsor-reports-actions.js | 9 +-- .../__tests__/sponsor-reports-layout.test.js | 4 +- .../__tests__/index.test.js | 7 +- .../purchase-details-report-page/index.js | 2 - .../__tests__/index.test.js | 2 - .../sponsor-asset-drilldown-page/index.js | 4 +- .../__tests__/index.test.js | 11 +-- .../sponsor-asset-report-page/index.js | 2 - .../sponsor-reports-reducers.test.js | 11 --- .../sponsor-reports-drilldown-reducer.js | 9 +-- ...ponsor-reports-purchase-details-reducer.js | 9 +-- .../sponsor-reports-sponsor-asset-reducer.js | 11 +-- 13 files changed, 76 insertions(+), 72 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index d143c0219..91f0e3f7c 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -17,10 +17,10 @@ import { RECEIVE_PURCHASE_DETAILS_FILTERS, PURCHASE_DETAILS_READ_ERROR, PURCHASE_DETAILS_VALIDATION_ERROR, - PURCHASE_DETAILS_EXPORT_DISABLED, REQUEST_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET_FILTERS, + SPONSOR_ASSET_READ_ERROR, REQUEST_SPONSOR_DRILLDOWN, RECEIVE_SPONSOR_DRILLDOWN, SPONSOR_DRILLDOWN_READ_ERROR @@ -133,6 +133,34 @@ describe("sponsor-reports-actions", () => { expect(capturedParams.page).toBe(2); expect(capturedParams.per_page).toBe(25); }); + + it("503 export-disabled on read dispatches PURCHASE_DETAILS_READ_ERROR (clears loading)", async () => { + // Simulate getRequest invoking the error handler with a 503 export-disabled response. + getRequest.mockImplementation( + (requestAC, _receiveAC, _url, errorHandler) => () => (dispatch) => { + if (requestAC) dispatch(requestAC({})); + errorHandler( + { + status: 503, + response: { + body: { message: "CSV export is not enabled for this summit" } + } + }, + {} + )(dispatch); + return Promise.resolve(); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsReport({ page: 1 })); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_PURCHASE_DETAILS); + // export-disabled must dispatch the loading-clearing READ_ERROR action. + expect(types).toContain(PURCHASE_DETAILS_READ_ERROR); + }); }); // ─── getPurchaseDetailsFilters ─────────────────────────────────────────────── @@ -193,6 +221,34 @@ describe("sponsor-reports-actions", () => { expect(capturedUrl).toContain("/summits/42/"); }); + + it("503 export-disabled on read dispatches SPONSOR_ASSET_READ_ERROR (clears loading)", async () => { + // Simulate getRequest invoking the error handler with a 503 export-disabled response. + getRequest.mockImplementation( + (requestAC, _receiveAC, _url, errorHandler) => () => (dispatch) => { + if (requestAC) dispatch(requestAC({})); + errorHandler( + { + status: 503, + response: { + body: { message: "CSV export is not enabled for this summit" } + } + }, + {} + )(dispatch); + return Promise.resolve(); + } + ); + + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + await flushPromises(); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain(REQUEST_SPONSOR_ASSET); + // export-disabled must dispatch the loading-clearing READ_ERROR action. + expect(types).toContain(SPONSOR_ASSET_READ_ERROR); + }); }); // ─── getSponsorAssetFilters ────────────────────────────────────────────────── @@ -348,13 +404,16 @@ describe("sponsor-reports-actions", () => { ); }); - it("503 with 'CSV export is not enabled' dispatches onExportDisabled", () => { + it("503 with 'CSV export is not enabled' calls onExportDisabled (thunks wire this to READ_ERROR)", () => { + // makeReadErrorHandler routes export-disabled to onExportDisabled regardless of what + // the caller wires it to. Thunks now wire onExportDisabled → READ_ERROR; this test + // verifies the routing layer with a local stub action type. const onReadError = jest.fn((p) => ({ type: PURCHASE_DETAILS_READ_ERROR, payload: p })); const onExportDisabled = jest.fn((p) => ({ - type: PURCHASE_DETAILS_EXPORT_DISABLED, + type: PURCHASE_DETAILS_READ_ERROR, payload: p })); const handler = makeReadErrorHandler({ onReadError, onExportDisabled }); @@ -371,7 +430,7 @@ describe("sponsor-reports-actions", () => { expect(onExportDisabled).toHaveBeenCalled(); expect(onReadError).not.toHaveBeenCalled(); expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: PURCHASE_DETAILS_EXPORT_DISABLED }) + expect.objectContaining({ type: PURCHASE_DETAILS_READ_ERROR }) ); }); }); diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index ab657de33..a8091eed3 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -17,20 +17,15 @@ export const PURCHASE_DETAILS_VALIDATION_ERROR = "PURCHASE_DETAILS_VALIDATION_ERROR"; export const PURCHASE_DETAILS_VALIDATION_CLEAR = "PURCHASE_DETAILS_VALIDATION_CLEAR"; -export const PURCHASE_DETAILS_EXPORT_DISABLED = - "PURCHASE_DETAILS_EXPORT_DISABLED"; export const REQUEST_SPONSOR_ASSET = "REQUEST_SPONSOR_ASSET"; export const RECEIVE_SPONSOR_ASSET = "RECEIVE_SPONSOR_ASSET"; export const RECEIVE_SPONSOR_ASSET_FILTERS = "RECEIVE_SPONSOR_ASSET_FILTERS"; export const SPONSOR_ASSET_READ_ERROR = "SPONSOR_ASSET_READ_ERROR"; -export const SPONSOR_ASSET_EXPORT_DISABLED = "SPONSOR_ASSET_EXPORT_DISABLED"; export const REQUEST_SPONSOR_DRILLDOWN = "REQUEST_SPONSOR_DRILLDOWN"; export const RECEIVE_SPONSOR_DRILLDOWN = "RECEIVE_SPONSOR_DRILLDOWN"; export const SPONSOR_DRILLDOWN_READ_ERROR = "SPONSOR_DRILLDOWN_READ_ERROR"; -export const SPONSOR_DRILLDOWN_EXPORT_DISABLED = - "SPONSOR_DRILLDOWN_EXPORT_DISABLED"; // Base URL helper — scoped to a specific summit's reports endpoint. const base = (summitId) => @@ -51,7 +46,7 @@ export const getPurchaseDetailsReport = makeReadErrorHandler({ onReadError: createAction(PURCHASE_DETAILS_READ_ERROR), onValidationError: createAction(PURCHASE_DETAILS_VALIDATION_ERROR), - onExportDisabled: createAction(PURCHASE_DETAILS_EXPORT_DISABLED) + onExportDisabled: createAction(PURCHASE_DETAILS_READ_ERROR) }) )(params)(dispatch) .catch(() => {}) @@ -92,7 +87,7 @@ export const getSponsorAssetReport = // FE never sends an invalid group_by/order, but a 412 must not be swallowed: // route it to the read-error body rather than a silent no-op. onValidationError: createAction(SPONSOR_ASSET_READ_ERROR), - onExportDisabled: createAction(SPONSOR_ASSET_EXPORT_DISABLED) + onExportDisabled: createAction(SPONSOR_ASSET_READ_ERROR) }) )(params)(dispatch) .catch(() => {}) diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index 162f4bdbb..60ddf4dfc 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -61,7 +61,6 @@ jest.mock("../../actions/sponsor-reports-actions", () => ({ type: "MOCK_GET_PURCHASE_DETAILS_FILTERS" })), SPONSOR_DRILLDOWN_READ_ERROR: "SPONSOR_DRILLDOWN_READ_ERROR", - SPONSOR_DRILLDOWN_EXPORT_DISABLED: "SPONSOR_DRILLDOWN_EXPORT_DISABLED", PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", REQUEST_SPONSOR_DRILLDOWN: "REQUEST_SPONSOR_DRILLDOWN", RECEIVE_SPONSOR_DRILLDOWN: "RECEIVE_SPONSOR_DRILLDOWN" @@ -128,8 +127,7 @@ describe("SponsorReportsLayout", () => { sponsorReportsDrilldownState: { detail: null, loading: true, - readError: null, - exportDisabled: false + readError: null } } } diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index a26e1fcf9..0c30605ee 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -22,8 +22,7 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ type: "REQUEST_PURCHASE_DETAILS_FILTERS" })), PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", - PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR", - PURCHASE_DETAILS_EXPORT_DISABLED: "PURCHASE_DETAILS_EXPORT_DISABLED" + PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); // Access the jest.fn() references from the mock (standard jest pattern). @@ -44,7 +43,6 @@ jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) })); - // Mock getAccessTokenSafely so ExportCsvButton clicks don't fail in tests. jest.mock("../../../../../utils/methods", () => ({ getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) @@ -84,8 +82,7 @@ function buildState(summaryOverrides = {}, { total = 1 } = {}) { total, loading: false, readError: null, - validationError: null, - exportDisabled: false + validationError: null }, currentSummitState: { currentSummit: { id: 42 } diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 6641f7fe2..e04140a9c 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -55,7 +55,6 @@ const PurchaseDetailsReportPage = ({ total, readError, validationError, - exportDisabled, // From mapDispatchToProps (function form — includes raw dispatch) dispatch, getPurchaseDetailsReport: fetchReport, @@ -258,7 +257,6 @@ const PurchaseDetailsReportPage = ({ filename={`purchase-details-summit-${ currentSummit?.id ?? "unknown" }.csv`} - disabled={exportDisabled} /> } diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index 66c8c9e92..1a81df3a0 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -44,7 +44,6 @@ jest.mock("../../../../../utils/reports-api", () => ({ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getSponsorAssetSponsor: jest.fn(() => ({ type: "GET_DRILLDOWN" })), - SPONSOR_DRILLDOWN_EXPORT_DISABLED: "SPONSOR_DRILLDOWN_EXPORT_DISABLED", SPONSOR_DRILLDOWN_READ_ERROR: "SPONSOR_DRILLDOWN_READ_ERROR" })); @@ -61,7 +60,6 @@ function buildState(drilldownOverrides = {}) { detail: null, loading: false, readError: null, - exportDisabled: false, ...drilldownOverrides }, currentSummitState: { diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 16c71b0a3..a65ca21e1 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -130,7 +130,6 @@ const SponsorAssetDrilldownPage = ({ detail, loading, readError, - exportDisabled, // From mapDispatchToProps getSponsorAssetSponsor: fetchSponsor, // From withRouter @@ -139,7 +138,7 @@ const SponsorAssetDrilldownPage = ({ const print = usePrint(); // sponsorId from URL; summitId from Redux state (not URL params per summit-admin pattern). - const {sponsorId} = match.params; + const { sponsorId } = match.params; // Accept only strict positive integers so a malformed :sponsorId cannot be // interpolated into filter clauses or the CSV URL path. const validParams = isPositiveIntId(sponsorId); @@ -278,7 +277,6 @@ const SponsorAssetDrilldownPage = ({ { sponsorId, pageId: section.page.id } )} filename={`sponsor-${sponsorId}-page-${section.page.id}.csv`} - disabled={exportDisabled} label={T.translate("sponsor_reports_page.download_csv")} /> diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index 3761cf65e..79e061c9f 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -31,7 +31,6 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getSponsorAssetFilters: jest.fn(() => ({ type: "GET_SA_FILTERS" })), getSponsorAssetReport: jest.fn(() => ({ type: "GET_SA_REPORT" })), - SPONSOR_ASSET_EXPORT_DISABLED: "SPONSOR_ASSET_EXPORT_DISABLED", SPONSOR_ASSET_READ_ERROR: "SPONSOR_ASSET_READ_ERROR" })); @@ -103,7 +102,6 @@ function buildState(assetOverrides = {}) { }, loading: false, readError: null, - exportDisabled: false, ...assetOverrides }, currentSummitState: { @@ -220,8 +218,7 @@ describe("SponsorAssetReportPage", () => { lastPage: 0, summary: null, loading: false, - readError: null, - exportDisabled: false + readError: null }, currentSummitState: { currentSummit: null } } @@ -239,12 +236,6 @@ describe("SponsorAssetReportPage", () => { expect(screen.getByTestId("export-csv")).not.toBeDisabled(); }); - it("disables the ExportCsvButton when exportDisabled is true", async () => { - renderPage({ exportDisabled: true }); - await act(async () => {}); - expect(screen.getByTestId("export-csv")).toBeDisabled(); - }); - it("hides the no-groups empty state until currentPage >= 1", async () => { renderPage({ data: [], currentPage: 0, lastPage: 0 }); await act(async () => {}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index d0b04f469..c4b91d420 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -61,7 +61,6 @@ const SponsorAssetReportPage = ({ currentPage, loading, readError, - exportDisabled, // From mapDispatchToProps getSponsorAssetReport: fetchReport, getSponsorAssetFilters: fetchFilters @@ -173,7 +172,6 @@ const SponsorAssetReportPage = ({ url={csvUrl} query={csvQuery} filename={`sponsor-assets-summit-${currentSummit.id}.csv`} - disabled={exportDisabled} /> } diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js index 0442d61a7..1d02dc896 100644 --- a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js @@ -5,7 +5,6 @@ import { RECEIVE_PURCHASE_DETAILS, PURCHASE_DETAILS_READ_ERROR, PURCHASE_DETAILS_VALIDATION_ERROR, - PURCHASE_DETAILS_EXPORT_DISABLED, REQUEST_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET_FILTERS, @@ -124,16 +123,6 @@ describe("sponsorReportsPurchaseDetailsReducer", () => { }); }); - describe("PURCHASE_DETAILS_EXPORT_DISABLED", () => { - it("sets exportDisabled=true", () => { - const result = purchaseDetailsReducer(PD_DEFAULT_STATE, { - type: PURCHASE_DETAILS_EXPORT_DISABLED, - payload: { message: "CSV export is not enabled" } - }); - expect(result.exportDisabled).toBe(true); - }); - }); - describe("SET_CURRENT_SUMMIT", () => { it("resets to DEFAULT_STATE", () => { const dirty = { ...PD_DEFAULT_STATE, data: [{ id: 99 }], loading: true }; diff --git a/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js b/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js index 3adf9f7bf..8a39f7e2e 100644 --- a/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js @@ -16,17 +16,14 @@ import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_SPONSOR_DRILLDOWN, RECEIVE_SPONSOR_DRILLDOWN, - SPONSOR_DRILLDOWN_READ_ERROR, - SPONSOR_DRILLDOWN_EXPORT_DISABLED + SPONSOR_DRILLDOWN_READ_ERROR } from "../../actions/sponsor-reports-actions"; export const DEFAULT_STATE = { // The whole retrieve response: { sponsor: {id,name,tier,pages_active}, pages: [...] }. detail: null, loading: false, - readError: null, // includes { kind: "not-found" } for unknown sponsor (404) - exportDisabled: false, - exportError: null + readError: null // includes { kind: "not-found" } for unknown sponsor (404) }; const reducer = (state = DEFAULT_STATE, action) => { @@ -46,8 +43,6 @@ const reducer = (state = DEFAULT_STATE, action) => { }; case SPONSOR_DRILLDOWN_READ_ERROR: return { ...state, loading: false, readError: payload }; - case SPONSOR_DRILLDOWN_EXPORT_DISABLED: - return { ...state, exportDisabled: true, exportError: payload }; default: return state; } diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js index 18913fdfd..48d66f90e 100644 --- a/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js @@ -20,8 +20,7 @@ import { RECEIVE_PURCHASE_DETAILS_FILTERS, PURCHASE_DETAILS_READ_ERROR, PURCHASE_DETAILS_VALIDATION_ERROR, - PURCHASE_DETAILS_VALIDATION_CLEAR, - PURCHASE_DETAILS_EXPORT_DISABLED + PURCHASE_DETAILS_VALIDATION_CLEAR } from "../../actions/sponsor-reports-actions"; export const DEFAULT_STATE = { @@ -35,9 +34,7 @@ export const DEFAULT_STATE = { query: {}, loading: false, readError: null, // replaces the body (read-disabled / not-found / unauthorized / unknown) - validationError: null, // 412 — inline/toast, body stays - exportError: null, - exportDisabled: false + validationError: null // 412 — inline/toast, body stays }; const reducer = (state = DEFAULT_STATE, action) => { @@ -79,8 +76,6 @@ const reducer = (state = DEFAULT_STATE, action) => { return { ...state, loading: false, validationError: payload }; case PURCHASE_DETAILS_VALIDATION_CLEAR: return { ...state, validationError: null }; - case PURCHASE_DETAILS_EXPORT_DISABLED: - return { ...state, exportDisabled: true, exportError: payload }; default: return state; } diff --git a/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js b/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js index 3deada8b1..622093357 100644 --- a/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js @@ -17,8 +17,7 @@ import { REQUEST_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET_FILTERS, - SPONSOR_ASSET_READ_ERROR, - SPONSOR_ASSET_EXPORT_DISABLED + SPONSOR_ASSET_READ_ERROR } from "../../actions/sponsor-reports-actions"; export const DEFAULT_STATE = { @@ -30,9 +29,7 @@ export const DEFAULT_STATE = { lastPage: 0, summary: null, // { total, by_status, by_page } loading: false, - readError: null, - exportError: null, - exportDisabled: false + readError: null }; const reducer = (state = DEFAULT_STATE, action) => { @@ -55,8 +52,6 @@ const reducer = (state = DEFAULT_STATE, action) => { summary: env.summary, loading: false, readError: null - // NOTE: exportDisabled/exportError are intentionally NOT cleared — CSV-disabled - // is a service-wide backend flag, not invalidated by a successful read. }; } case RECEIVE_SPONSOR_ASSET_FILTERS: @@ -64,8 +59,6 @@ const reducer = (state = DEFAULT_STATE, action) => { return { ...state, filterOptions: payload.response, readError: null }; case SPONSOR_ASSET_READ_ERROR: return { ...state, loading: false, readError: payload }; - case SPONSOR_ASSET_EXPORT_DISABLED: - return { ...state, exportDisabled: true, exportError: payload }; default: return state; } From 07c3261cfa3231d8a8ab53682ebd43d29b219fd8 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 21:51:08 -0500 Subject: [PATCH 10/63] fix(sponsor-reports): UI polish (fonts, breadcrumb trail, asset spacing) - SummaryPanel tile labels overline -> subtitle1 (bigger; both reports) - GroupByToggle small -> medium + larger button text - SponsorReportsLayout renders a persistent Reports crumb + per-route page crumb so the trail reads .../Sponsors/Reports/ (was dropping to .../Sponsors on sub-pages); landing's own crumb removed (no dup) - Sponsor Assets: gap between Report Filters and summary cards - tests updated (landing breadcrumb moved to layout; layout trail test added) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/GroupByToggle.js | 3 +- .../sponsors/reports/SummaryPanel.js | 6 ++- .../__tests__/sponsor-reports-layout.test.js | 32 ++++++++++++++ src/layouts/sponsor-reports-layout.js | 42 +++++++++++++++++-- .../__tests__/index.test.js | 16 ------- .../reports-landing-page/index.js | 7 ---- .../sponsor-asset-report-page/index.js | 16 +++---- 7 files changed, 86 insertions(+), 36 deletions(-) diff --git a/src/components/sponsors/reports/GroupByToggle.js b/src/components/sponsors/reports/GroupByToggle.js index 5a4eb113e..27374e73c 100644 --- a/src/components/sponsors/reports/GroupByToggle.js +++ b/src/components/sponsors/reports/GroupByToggle.js @@ -7,12 +7,13 @@ import T from "i18n-react/dist/i18n-react"; const GroupByToggle = ({ value, onChange }) => ( { if (next) onChange(next); }} aria-label={T.translate("sponsor_reports_page.group_by")} + sx={{ "& .MuiToggleButton-root": { px: 2.5, fontSize: "0.95rem" } }} > {T.translate("sponsor_reports_page.group_by_sponsor")} diff --git a/src/components/sponsors/reports/SummaryPanel.js b/src/components/sponsors/reports/SummaryPanel.js index d3cb0976f..97ce7cc50 100644 --- a/src/components/sponsors/reports/SummaryPanel.js +++ b/src/components/sponsors/reports/SummaryPanel.js @@ -18,7 +18,11 @@ const SummaryPanel = ({ tiles = [] }) => { variant="outlined" sx={{ p: 2, flex: 1, minWidth: 140, borderRadius: 2 }} > - + {tile.label} diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index 60ddf4dfc..2fb38e76d 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -144,4 +144,36 @@ describe("SponsorReportsLayout", () => { screen.queryByTestId("report-card-sponsor-assets") ).not.toBeInTheDocument(); }); + + it("renders the Reports → Purchase Details breadcrumb trail on the sub-route", () => { + const PD_URL = "/app/summits/1/sponsors/reports/purchase-details"; + const history = createMemoryHistory({ initialEntries: [PD_URL] }); + renderWithRedux( + + + , + { + initialState: { + loggedUserState: { + member: { groups: [{ code: "administrators" }] } + }, + currentSummitState: { currentSummit: { id: 1 } }, + sponsorReportsPurchaseDetailsState: { + data: [], + summary: null, + filterOptions: null, + total: 0, + readError: null, + validationError: null + } + } + } + ); + // The persistent "Reports" crumb + the route's "Purchase Details" crumb both render. + const titles = screen + .getAllByTestId("breadcrumb") + .map((el) => el.getAttribute("data-title")); + expect(titles).toContain("sponsor_reports_page.landing_title"); + expect(titles).toContain("sponsor_reports_page.purchase_details_title"); + }); }); diff --git a/src/layouts/sponsor-reports-layout.js b/src/layouts/sponsor-reports-layout.js index 069688629..a234fe28d 100644 --- a/src/layouts/sponsor-reports-layout.js +++ b/src/layouts/sponsor-reports-layout.js @@ -13,32 +13,66 @@ import React from "react"; import { Route, Switch, withRouter } from "react-router-dom"; +import { Breadcrumb } from "react-breadcrumbs"; +import T from "i18n-react/dist/i18n-react"; import Restrict from "../routes/restrict"; import PurchaseDetailsReportPage from "../pages/sponsors/sponsor-reports/purchase-details-report-page"; import SponsorAssetDrilldownPage from "../pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page"; import SponsorAssetReportPage from "../pages/sponsors/sponsor-reports/sponsor-asset-report-page"; import ReportsLandingPage from "../pages/sponsors/sponsor-reports/reports-landing-page"; +// Each sub-route adds its own crumb under the persistent "Reports" crumb, so the +// trail reads .../Sponsors/Reports/ (mirrors sponsor-layout's convention). +const withCrumb = (Page, titleKey, pathname) => (props) => + ( + <> + + + + ); + const SponsorReportsLayout = ({ match }) => (
+ {/* Drill-down (more specific) FIRST so the base /sponsor-assets route cannot shadow it even with exact on both. Belt-and-suspenders ordering - per React Router v4 Switch semantics (first match wins). */} + per React Router v4 Switch semantics (first match wins). The drill-down + shows the Sponsor Assets parent crumb (links back to the list). */} diff --git a/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js index 74659a596..0b14398b0 100644 --- a/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js @@ -19,13 +19,6 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); -// react-breadcrumbs: render a simple stub so we can assert the breadcrumb title -jest.mock("react-breadcrumbs", () => ({ - Breadcrumb: ({ data }) => ( -
- ) -})); - const BASE = "/app/summits/1/sponsors/reports"; const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports"; @@ -71,15 +64,6 @@ describe("ReportsLandingPage", () => { expect(link.getAttribute("href")).toBe(`${BASE}/sponsor-assets`); }); - it("renders a breadcrumb with the landing_title i18n key", () => { - renderLanding(); - const bc = screen.getByTestId("breadcrumb"); - expect(bc).toBeInTheDocument(); - expect(bc.getAttribute("data-title")).toBe( - "sponsor_reports_page.landing_title" - ); - }); - it("renders exactly two report cards", () => { renderLanding(); // Each card has a data-testid diff --git a/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js b/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js index d53e5e730..9c1a83030 100644 --- a/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js @@ -14,7 +14,6 @@ import React from "react"; import { Link, withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; -import { Breadcrumb } from "react-breadcrumbs"; import { Card, CardActionArea, @@ -38,12 +37,6 @@ const CARDS = [ const ReportsLandingPage = ({ match }) => (
-

{T.translate("sponsor_reports_page.landing_title")}

{CARDS.map((card) => ( diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index c4b91d420..7ecee5618 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -184,13 +184,15 @@ const SponsorAssetReportPage = ({ > - + + + {summary && } {loading && ( From e7357b5f13f762f6f9e6153c688a91479fdab489 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 22:35:19 -0500 Subject: [PATCH 11/63] fix(sponsor-reports): address CodeRabbit review (PR #997) - guard all report thunks against a missing currentSummit (prevents a stuck spinner from base(currentSummit.id) throwing after startLoading) - formatUsd: treat blank/whitespace strings + non-finite numbers as missing (em dash) instead of $0.00; add reports-money tests - FilterBar: re-sync draft when the committed value prop changes externally - drop redundant routing.test.js (real-layout integration test in sponsor-reports-layout.test.js already covers route ordering) - add PURCHASE_DETAILS_VALIDATION_CLEAR reducer test Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/sponsor-reports-actions.js | 11 ++++ src/components/sponsors/reports/FilterBar.js | 8 ++- .../sponsor-reports/__tests__/routing.test.js | 65 ------------------- .../sponsor-reports-reducers.test.js | 18 +++++ src/utils/__tests__/reports-money.test.js | 29 +++++++++ src/utils/reports-money.js | 6 +- 6 files changed, 70 insertions(+), 67 deletions(-) delete mode 100644 src/pages/sponsors/sponsor-reports/__tests__/routing.test.js create mode 100644 src/utils/__tests__/reports-money.test.js diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index a8091eed3..7707b3552 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -36,6 +36,9 @@ export const getPurchaseDetailsReport = async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + // No summit in context → skip. Otherwise base(currentSummit.id) throws + // synchronously after startLoading() and the spinner is never cleared. + if (!currentSummit?.id) return undefined; const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); const params = { access_token: accessToken, ...query }; @@ -56,6 +59,7 @@ export const getPurchaseDetailsReport = export const getPurchaseDetailsFilters = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + if (!currentSummit?.id) return undefined; const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( @@ -75,6 +79,9 @@ export const getSponsorAssetReport = async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + // No summit in context → skip. Otherwise base(currentSummit.id) throws + // synchronously after startLoading() and the spinner is never cleared. + if (!currentSummit?.id) return undefined; const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); const params = { access_token: accessToken, ...query }; @@ -97,6 +104,7 @@ export const getSponsorAssetReport = export const getSponsorAssetFilters = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + if (!currentSummit?.id) return undefined; const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( @@ -115,6 +123,9 @@ export const getSponsorAssetSponsor = (sponsorId) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + // No summit in context → skip. Otherwise base(currentSummit.id) throws + // synchronously after startLoading() and the spinner is never cleared. + if (!currentSummit?.id) return undefined; const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( diff --git a/src/components/sponsors/reports/FilterBar.js b/src/components/sponsors/reports/FilterBar.js index d19c19d62..2cf5d040c 100644 --- a/src/components/sponsors/reports/FilterBar.js +++ b/src/components/sponsors/reports/FilterBar.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Autocomplete, Box, @@ -26,6 +26,12 @@ const FilterBar = ({ showSearch = false }) => { const [draft, setDraft] = useState(value); + // Re-sync the draft when the committed `value` prop changes externally + // (e.g. a parent-driven reset). Typing only mutates `draft`, so this fires + // on Apply/Clear/external changes, not on every keystroke. + useEffect(() => { + setDraft(value); + }, [value]); const update = (patch) => setDraft((d) => ({ ...d, ...patch })); return ( diff --git a/src/pages/sponsors/sponsor-reports/__tests__/routing.test.js b/src/pages/sponsors/sponsor-reports/__tests__/routing.test.js deleted file mode 100644 index 323c30b54..000000000 --- a/src/pages/sponsors/sponsor-reports/__tests__/routing.test.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2026 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. - * */ - -// Routing tests: verify the in sponsor-reports-layout correctly routes -// /sponsor-assets/sponsors/:sponsorId to the drilldown page and NOT the list -// (i.e., the drill-down route is matched before the base route can shadow it). -// -// This test uses standalone stub components to avoid full Redux setup — it is -// purely about route-matching correctness. - -import "@testing-library/jest-dom"; -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { Router, Route, Switch } from "react-router-dom"; -import { createMemoryHistory } from "history"; - -const BASE = "/app/summits/1/sponsors/reports"; - -// Minimal stubs (same shape as the real pages in terms of rendering a testid). -const StubList = () =>
; -const StubDrilldown = () =>
; - -// Replicate the route-ordering declared in sponsor-reports-layout.js so this -// test verifies the ACTUAL ordering (drill-down first, exact on both). -const renderSwitch = (url) => { - const history = createMemoryHistory({ initialEntries: [url] }); - return render( - - - - - - - ); -}; - -describe("SponsorReportsLayout routing — sponsor-assets", () => { - it("navigating to /sponsor-assets/sponsors/:sponsorId renders the DRILLDOWN page, not the list", () => { - renderSwitch(`${BASE}/sponsor-assets/sponsors/17`); - expect(screen.getByTestId("asset-drilldown-page")).toBeInTheDocument(); - expect(screen.queryByTestId("asset-list-page")).not.toBeInTheDocument(); - }); - - it("navigating to /sponsor-assets renders the LIST page, not the drilldown", () => { - renderSwitch(`${BASE}/sponsor-assets`); - expect(screen.getByTestId("asset-list-page")).toBeInTheDocument(); - expect( - screen.queryByTestId("asset-drilldown-page") - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js index 1d02dc896..b70684227 100644 --- a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js @@ -5,6 +5,7 @@ import { RECEIVE_PURCHASE_DETAILS, PURCHASE_DETAILS_READ_ERROR, PURCHASE_DETAILS_VALIDATION_ERROR, + PURCHASE_DETAILS_VALIDATION_CLEAR, REQUEST_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET, RECEIVE_SPONSOR_ASSET_FILTERS, @@ -123,6 +124,23 @@ describe("sponsorReportsPurchaseDetailsReducer", () => { }); }); + describe("PURCHASE_DETAILS_VALIDATION_CLEAR", () => { + it("clears validationError without replacing the body", () => { + const existingData = [{ id: 1 }, { id: 2 }]; + const state = { + ...PD_DEFAULT_STATE, + data: existingData, + validationError: { status: 412, message: "invalid filter" } + }; + const result = purchaseDetailsReducer(state, { + type: PURCHASE_DETAILS_VALIDATION_CLEAR + }); + expect(result.validationError).toBeNull(); + // body must NOT be replaced + expect(result.data).toStrictEqual(existingData); + }); + }); + describe("SET_CURRENT_SUMMIT", () => { it("resets to DEFAULT_STATE", () => { const dirty = { ...PD_DEFAULT_STATE, data: [{ id: 99 }], loading: true }; diff --git a/src/utils/__tests__/reports-money.test.js b/src/utils/__tests__/reports-money.test.js new file mode 100644 index 000000000..cbe19a465 --- /dev/null +++ b/src/utils/__tests__/reports-money.test.js @@ -0,0 +1,29 @@ +import { formatUsd } from "../reports-money"; + +describe("formatUsd", () => { + it("formats numbers as USD", () => { + expect(formatUsd(1234.5)).toBe("$1,234.50"); + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(5)).toBe("$5.00"); + }); + + it("formats numeric strings", () => { + expect(formatUsd("4754.15")).toBe("$4,754.15"); + }); + + it("renders an em dash for missing / non-numeric values", () => { + expect(formatUsd(null)).toBe("—"); + expect(formatUsd(undefined)).toBe("—"); + expect(formatUsd("abc")).toBe("—"); + }); + + it("treats blank / whitespace-only strings as missing, not zero", () => { + expect(formatUsd("")).toBe("—"); + expect(formatUsd(" ")).toBe("—"); + }); + + it("renders an em dash for non-finite numbers", () => { + expect(formatUsd(Infinity)).toBe("—"); + expect(formatUsd(-Infinity)).toBe("—"); + }); +}); diff --git a/src/utils/reports-money.js b/src/utils/reports-money.js index dc500d7ad..d7e5b284c 100644 --- a/src/utils/reports-money.js +++ b/src/utils/reports-money.js @@ -8,8 +8,12 @@ const USD = new Intl.NumberFormat("en-US", { // Formats a DOLLAR amount (number or numeric string) as "$1,234.56". // Non-numeric / null → em dash. export const formatUsd = (value) => { + // Blank / whitespace-only strings are missing values, not zero + // (Number("") === 0 would otherwise render "$0.00"). + if (typeof value === "string" && value.trim() === "") return "—"; const n = typeof value === "string" ? Number(value) : value; - if (typeof n !== "number" || Number.isNaN(n)) return "—"; + if (typeof n !== "number" || Number.isNaN(n) || !Number.isFinite(n)) + return "—"; return USD.format(n); }; From 7c4eaa917ce6e24ba7b8650598f2f01bb4ccec60 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 24 Jun 2026 22:56:20 -0500 Subject: [PATCH 12/63] fix(sponsor-reports): address Copilot review (PR #997) - reports-text: decode uppercase/mixed-case HTML entities (the case-insensitive match was missing the lowercase ENTITIES map); add toPlainText tests - report-errors: export-disabled now dispatches { kind, status, message } to match the other error branches - report-query: coerce sponsorIds to positive integers and drop the rest so the filter never emits sponsor_id==NaN/==0; add buildReportQuery tests Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 12 +++- src/utils/__tests__/report-query.test.js | 64 +++++++++++++++++++ src/utils/__tests__/reports-text.test.js | 27 ++++++++ src/utils/report-errors.js | 4 +- src/utils/report-query.js | 11 +++- src/utils/reports-text.js | 7 +- 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/report-query.test.js create mode 100644 src/utils/__tests__/reports-text.test.js diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 91f0e3f7c..7256a59de 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -156,10 +156,20 @@ describe("sponsor-reports-actions", () => { store.dispatch(getPurchaseDetailsReport({ page: 1 })); await flushPromises(); - const types = store.getActions().map((a) => a.type); + const actions = store.getActions(); + const types = actions.map((a) => a.type); expect(types).toContain(REQUEST_PURCHASE_DETAILS); // export-disabled must dispatch the loading-clearing READ_ERROR action. expect(types).toContain(PURCHASE_DETAILS_READ_ERROR); + // payload carries the full { kind, status, message } shape (consistent + // with the other error branches). + const readErr = actions.find( + (a) => a.type === PURCHASE_DETAILS_READ_ERROR + ); + expect(readErr.payload).toMatchObject({ + kind: "export-disabled", + status: 503 + }); }); }); diff --git a/src/utils/__tests__/report-query.test.js b/src/utils/__tests__/report-query.test.js new file mode 100644 index 000000000..d51f97fee --- /dev/null +++ b/src/utils/__tests__/report-query.test.js @@ -0,0 +1,64 @@ +import { buildReportQuery } from "../report-query"; + +describe("buildReportQuery", () => { + it("returns an empty object for no filters", () => { + expect(buildReportQuery()).toStrictEqual({}); + }); + + it("emits a single comma-OR bracket for multi-select sponsorIds", () => { + expect(buildReportQuery({ sponsorIds: [1, 2, 3] })).toStrictEqual({ + "filter[]": ["sponsor_id==1,sponsor_id==2,sponsor_id==3"] + }); + }); + + it("coerces numeric-string sponsorIds to integers", () => { + expect(buildReportQuery({ sponsorIds: ["10", "20"] })).toStrictEqual({ + "filter[]": ["sponsor_id==10,sponsor_id==20"] + }); + }); + + it("drops non-numeric sponsorIds so it never emits sponsor_id==NaN", () => { + expect( + buildReportQuery({ sponsorIds: [1, "abc", undefined, null, 2] }) + ).toStrictEqual({ + "filter[]": ["sponsor_id==1,sponsor_id==2"] + }); + }); + + it("omits the sponsor filter entirely when all ids are non-numeric", () => { + expect(buildReportQuery({ sponsorIds: ["abc", null] })).toStrictEqual({}); + }); + + it("adds single-value dimensions as separate AND brackets", () => { + expect( + buildReportQuery({ sponsorIds: [1], status: "Paid", formCode: "AS" }) + ).toStrictEqual({ + "filter[]": ["sponsor_id==1", "status==Paid", "form_code==AS"] + }); + }); + + it("sets include_cancelled when status is Canceled", () => { + expect(buildReportQuery({ status: "Canceled" })).toStrictEqual({ + "filter[]": ["status==Canceled"], + include_cancelled: "true" + }); + }); + + it("passes through search/order/pagination/group_by", () => { + expect( + buildReportQuery({ + search: "acme", + order: "-number", + page: 2, + perPage: 25, + groupBy: "sponsor" + }) + ).toStrictEqual({ + search: "acme", + order: "-number", + page: 2, + per_page: 25, + group_by: "sponsor" + }); + }); +}); diff --git a/src/utils/__tests__/reports-text.test.js b/src/utils/__tests__/reports-text.test.js new file mode 100644 index 000000000..7a5dfe131 --- /dev/null +++ b/src/utils/__tests__/reports-text.test.js @@ -0,0 +1,27 @@ +import { toPlainText } from "../reports-text"; + +describe("toPlainText", () => { + it("returns empty string for null/undefined", () => { + expect(toPlainText(null)).toBe(""); + expect(toPlainText(undefined)).toBe(""); + }); + + it("strips tags and collapses whitespace", () => { + expect(toPlainText("

Hello

world")).toBe("Hello world"); + }); + + it("decodes common lowercase entities", () => { + expect(toPlainText("a & b <tag>  x")).toBe("a & b x"); + expect(toPlainText("it's")).toBe("it's"); + }); + + it("decodes uppercase / mixed-case entities (case-insensitive)", () => { + expect(toPlainText("a & b")).toBe("a & b"); + expect(toPlainText("x&NBSP;y")).toBe("x y"); + expect(toPlainText("<tag>")).toBe(""); + }); + + it("leaves unknown entities untouched", () => { + expect(toPlainText("5 ° &Copy;")).toBe("5 ° &Copy;"); + }); +}); diff --git a/src/utils/report-errors.js b/src/utils/report-errors.js index 9f2176c9a..5ef86c400 100644 --- a/src/utils/report-errors.js +++ b/src/utils/report-errors.js @@ -64,7 +64,9 @@ export const makeReadErrorHandler = ); switch (kind) { case "export-disabled": - if (onExportDisabled) dispatch(onExportDisabled({ message })); + // Same payload shape as onReadError so consumers can switch on `kind`. + if (onExportDisabled) + dispatch(onExportDisabled({ kind, status, message })); return; case "validation": if (onValidationError) dispatch(onValidationError({ status, message })); diff --git a/src/utils/report-query.js b/src/utils/report-query.js index e296c62f3..b23559ac3 100644 --- a/src/utils/report-query.js +++ b/src/utils/report-query.js @@ -29,9 +29,14 @@ export const buildReportQuery = (filters = {}) => { const filter = []; // Sponsor — the one multi-select dimension → comma-OR in a SINGLE bracket. - if (sponsorIds.length > 0) { - // Number() coercion prevents stray-comma strings from injecting extra OR terms. - filter.push(sponsorIds.map((id) => `sponsor_id==${Number(id)}`).join(",")); + // Coerce to positive integers and drop everything else, so a stray entry can't + // emit `sponsor_id==NaN`/`==0` (rejected by the backend; can hit the bad-filter + // 500 path). Note Number(null) === 0, so the `> 0` check is load-bearing. + const sponsorFilterIds = sponsorIds + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id) && id > 0); + if (sponsorFilterIds.length > 0) { + filter.push(sponsorFilterIds.map((id) => `sponsor_id==${id}`).join(",")); } // Single-value dimensions — each its own comma-free bracket (AND). diff --git a/src/utils/reports-text.js b/src/utils/reports-text.js index 4994b65e8..3ae74b5a4 100644 --- a/src/utils/reports-text.js +++ b/src/utils/reports-text.js @@ -16,7 +16,12 @@ export const toPlainText = (html) => { if (html == null) return ""; return String(html) .replace(/<[^>]*>/g, " ") // tags become whitespace so boundaries don't fuse words - .replace(/&[a-z]+;|&#\d+;/gi, (m) => (m in ENTITIES ? ENTITIES[m] : m)) + .replace(/&[a-z]+;|&#\d+;/gi, (m) => { + // Match is case-insensitive (e.g. "&") but the map keys are lowercase; + // normalize for lookup, and return the original token on a miss. + const key = m.toLowerCase(); + return key in ENTITIES ? ENTITIES[key] : m; + }) .replace(/\s+/g, " ") .trim(); }; From d342f261f56c639c6462154bbbd7d5362e01a9bf Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 09:09:38 -0500 Subject: [PATCH 13/63] chore(sponsor-reports): request dedicated sponsor-reports scopes (review) Per Sebastian's PR review: summit-admin now requests the new microservice's own scopes (sponsor-reports/read, sponsor-reports/export) instead of relying on the broad reports/all. Adds SPONSOR_REPORTS_SCOPES and wires it into SCOPES (reports/all kept for the legacy summit-reports-api). Requires the IDP to grant these two scopes to the summit-admin client. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2af5ca5d2..7a5aff8a1 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,8 @@ FILE_UPLOAD_ALLOWED_EXTENSIONS="pdf,jpg,jpeg,png,ppt,key,pptx" SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com SPONSOR_REPORTS_API_URL=https://sponsor-reports-api.dev.fnopen.com SPONSOR_PAGES_SCOPES="page-template/read page-template/write show-page/read show-page/write media-upload/read" -SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read ${SCOPES_BASE_REALM}/summits/badge-scans/write config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" +SPONSOR_REPORTS_SCOPES="sponsor-reports/read sponsor-reports/export" +SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${SPONSOR_REPORTS_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read ${SCOPES_BASE_REALM}/summits/badge-scans/write config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" GOOGLE_API_KEY= ALLOWED_USER_GROUPS="super-admins administrators summit-front-end-administrators summit-room-administrators track-chairs-admins sponsors" APP_CLIENT_NAME="openstack" From 18851e78d231f584f2010d23014bc791ce72ee5c Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 12:53:10 -0500 Subject: [PATCH 14/63] feat(sponsor-reports): add purchase-details lines report action Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 41 +++++++++++++++++++ src/actions/sponsor-reports-actions.js | 30 ++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 7256a59de..ae3f154f7 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -360,6 +360,47 @@ describe("sponsor-reports-actions", () => { }); }); + // ─── getPurchaseDetailsLinesReport ────────────────────────────────────────── + + describe("getPurchaseDetailsLinesReport", () => { + beforeEach(() => { + jest + .spyOn(methods, "getAccessTokenSafely") + .mockResolvedValue("test-token"); + }); + + it("GETs the /purchase-details/lines endpoint with query + access_token and NO order", async () => { + makeHappyGetRequest(); + const store = mockStore(MOCK_STATE); + const { + getPurchaseDetailsLinesReport + } = require("../sponsor-reports-actions"); + await store.dispatch( + getPurchaseDetailsLinesReport({ + page: 1, + per_page: 50, + "filter[]": ["sponsor_id==17"] + }) + ); + await flushPromises(); + + expect(capturedUrl).toMatch( + /\/api\/v1\/summits\/42\/reports\/purchase-details\/lines$/ + ); + expect(capturedParams).toMatchObject({ + access_token: "test-token", + page: 1, + per_page: 50, + "filter[]": ["sponsor_id==17"] + }); + expect(capturedParams).not.toHaveProperty("order"); + + const types = store.getActions().map((a) => a.type); + expect(types).toContain("REQUEST_PURCHASE_DETAILS_LINES"); + expect(types).toContain("RECEIVE_PURCHASE_DETAILS_LINES"); + }); + }); + // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── describe("makeReadErrorHandler", () => { diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 7707b3552..c5a21a880 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -27,6 +27,11 @@ export const REQUEST_SPONSOR_DRILLDOWN = "REQUEST_SPONSOR_DRILLDOWN"; export const RECEIVE_SPONSOR_DRILLDOWN = "RECEIVE_SPONSOR_DRILLDOWN"; export const SPONSOR_DRILLDOWN_READ_ERROR = "SPONSOR_DRILLDOWN_READ_ERROR"; +export const REQUEST_PURCHASE_DETAILS_LINES = "REQUEST_PURCHASE_DETAILS_LINES"; +export const RECEIVE_PURCHASE_DETAILS_LINES = "RECEIVE_PURCHASE_DETAILS_LINES"; +export const PURCHASE_DETAILS_LINES_READ_ERROR = + "PURCHASE_DETAILS_LINES_READ_ERROR"; + // Base URL helper — scoped to a specific summit's reports endpoint. const base = (summitId) => `${getReportsApiBaseUrl()}/api/v1/summits/${summitId}/reports`; @@ -56,6 +61,31 @@ export const getPurchaseDetailsReport = .finally(() => dispatch(stopLoading())); }; +export const getPurchaseDetailsLinesReport = + (query = {}) => + async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + if (!currentSummit?.id) return undefined; + const accessToken = await getAccessTokenSafely(); + dispatch(startLoading()); + const params = { access_token: accessToken, ...query }; + return getRequest( + createAction(REQUEST_PURCHASE_DETAILS_LINES), + createAction(RECEIVE_PURCHASE_DETAILS_LINES), + `${base(currentSummit.id)}/purchase-details/lines`, + makeReadErrorHandler({ + onReadError: createAction(PURCHASE_DETAILS_LINES_READ_ERROR), + // This view sends no client-invalid input, but a 412 must still clear + // loading rather than silently no-op → route it to the read-error body. + onValidationError: createAction(PURCHASE_DETAILS_LINES_READ_ERROR), + onExportDisabled: createAction(PURCHASE_DETAILS_LINES_READ_ERROR) + }) + )(params)(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + }; + export const getPurchaseDetailsFilters = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; From 0e37fe69d2653e10e6c43c4cb3fa3f1fc41b61e5 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 12:57:36 -0500 Subject: [PATCH 15/63] feat(sponsor-reports): add purchase-details lines reducer + store registration Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rts-purchase-details-lines-reducer.test.js | 67 ++++++++++++++++++ ...-reports-purchase-details-lines-reducer.js | 70 +++++++++++++++++++ src/store.js | 4 ++ 3 files changed, 141 insertions(+) create mode 100644 src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-lines-reducer.test.js create mode 100644 src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-lines-reducer.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-lines-reducer.test.js new file mode 100644 index 000000000..ad88fb532 --- /dev/null +++ b/src/reducers/sponsors/__tests__/sponsor-reports-purchase-details-lines-reducer.test.js @@ -0,0 +1,67 @@ +import reducer, { + DEFAULT_STATE +} from "../sponsor-reports-purchase-details-lines-reducer"; +import { + REQUEST_PURCHASE_DETAILS_LINES, + RECEIVE_PURCHASE_DETAILS_LINES, + PURCHASE_DETAILS_LINES_READ_ERROR +} from "../../../actions/sponsor-reports-actions"; +import { SET_CURRENT_SUMMIT } from "../../../actions/summit-actions"; + +describe("sponsor-reports-purchase-details-lines-reducer", () => { + it("returns DEFAULT_STATE for an unknown action", () => { + expect(reducer(undefined, { type: "X" })).toEqual(DEFAULT_STATE); + }); + + it("REQUEST sets loading and clears readError", () => { + const s = reducer( + { ...DEFAULT_STATE, readError: { message: "old" } }, + { type: REQUEST_PURCHASE_DETAILS_LINES } + ); + expect(s.loading).toBe(true); + expect(s.readError).toBeNull(); + }); + + it("RECEIVE maps the snake_case envelope to camelCase state", () => { + const s = reducer(DEFAULT_STATE, { + type: RECEIVE_PURCHASE_DETAILS_LINES, + payload: { + response: { + data: [{ item_code: "AV1" }], + total: 7, + current_page: 2, + last_page: 3, + per_page: 50, + summary: { total_orders: 1 } + } + } + }); + expect(s).toMatchObject({ + data: [{ item_code: "AV1" }], + total: 7, + currentPage: 2, + lastPage: 3, + perPage: 50, + summary: { total_orders: 1 }, + loading: false, + readError: null + }); + }); + + it("READ_ERROR stores the error payload and clears loading", () => { + const s = reducer( + { ...DEFAULT_STATE, loading: true }, + { type: PURCHASE_DETAILS_LINES_READ_ERROR, payload: { message: "boom" } } + ); + expect(s.readError).toEqual({ message: "boom" }); + expect(s.loading).toBe(false); + }); + + it("resets to DEFAULT_STATE when the summit changes", () => { + const s = reducer( + { ...DEFAULT_STATE, data: [{ item_code: "AV1" }] }, + { type: SET_CURRENT_SUMMIT } + ); + expect(s).toEqual(DEFAULT_STATE); + }); +}); diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js new file mode 100644 index 000000000..152e95771 --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js @@ -0,0 +1,70 @@ +/** + * Copyright 2026 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { DEFAULT_PER_PAGE } from "../../utils/constants"; +import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; +import { + REQUEST_PURCHASE_DETAILS_LINES, + RECEIVE_PURCHASE_DETAILS_LINES, + PURCHASE_DETAILS_LINES_READ_ERROR +} from "../../actions/sponsor-reports-actions"; + +export const DEFAULT_STATE = { + data: [], + summary: null, + total: 0, + currentPage: 1, + lastPage: 1, + perPage: DEFAULT_PER_PAGE, + loading: false, + readError: null +}; + +const reducer = (state = DEFAULT_STATE, action) => { + const { type, payload } = action; + switch (type) { + case LOGOUT_USER: + case SET_CURRENT_SUMMIT: + return DEFAULT_STATE; + case REQUEST_PURCHASE_DETAILS_LINES: + return { ...state, loading: true, readError: null }; + case RECEIVE_PURCHASE_DETAILS_LINES: { + const { + data, + total, + last_page: lastPage, + per_page: perPage, + current_page: currentPage, + summary + } = payload.response; + return { + ...state, + data, + total, + lastPage, + perPage, + currentPage, + summary: summary ?? state.summary, + loading: false, + readError: null + }; + } + case PURCHASE_DETAILS_LINES_READ_ERROR: + return { ...state, loading: false, readError: payload }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/store.js b/src/store.js index 04851c68c..6b0e445f5 100644 --- a/src/store.js +++ b/src/store.js @@ -175,6 +175,7 @@ import sponsorPagePagesListReducer from "./reducers/sponsors/sponsor-page-pages- import sponsorPageMUListReducer from "./reducers/sponsors/sponsor-page-mu-list-reducer.js"; import dropboxSyncReducer from "./reducers/locations/dropbox-sync-reducer"; import sponsorReportsPurchaseDetailsReducer from "./reducers/sponsors/sponsor-reports-purchase-details-reducer"; +import sponsorReportsPurchaseDetailsLinesReducer from "./reducers/sponsors/sponsor-reports-purchase-details-lines-reducer"; import sponsorReportsSponsorAssetReducer from "./reducers/sponsors/sponsor-reports-sponsor-asset-reducer"; import sponsorReportsDrilldownReducer from "./reducers/sponsors/sponsor-reports-drilldown-reducer"; @@ -186,6 +187,7 @@ const config = { blacklist: [ "dropboxSyncState", "sponsorReportsPurchaseDetailsState", + "sponsorReportsPurchaseDetailsLinesState", "sponsorReportsSponsorAssetState", "sponsorReportsDrilldownState" ] @@ -353,6 +355,8 @@ const reducers = persistCombineReducers(config, { pageTemplateState: pageTemplateReducer, dropboxSyncState: dropboxSyncReducer, sponsorReportsPurchaseDetailsState: sponsorReportsPurchaseDetailsReducer, + sponsorReportsPurchaseDetailsLinesState: + sponsorReportsPurchaseDetailsLinesReducer, sponsorReportsSponsorAssetState: sponsorReportsSponsorAssetReducer, sponsorReportsDrilldownState: sponsorReportsDrilldownReducer }); From dd5e8a2dc714ab76f2e595c7e7c40a18c527584b Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 13:01:21 -0500 Subject: [PATCH 16/63] feat(sponsor-reports): add bucketLinesBySponsor grouping util Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/__tests__/manifest-grouping.test.js | 45 +++++++++++++++++++ src/utils/manifest-grouping.js | 26 +++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/utils/__tests__/manifest-grouping.test.js create mode 100644 src/utils/manifest-grouping.js diff --git a/src/utils/__tests__/manifest-grouping.test.js b/src/utils/__tests__/manifest-grouping.test.js new file mode 100644 index 000000000..53d8111f1 --- /dev/null +++ b/src/utils/__tests__/manifest-grouping.test.js @@ -0,0 +1,45 @@ +import { bucketLinesBySponsor } from "../manifest-grouping"; + +const line = (sponsorId, name, itemCode) => ({ + sponsor: { id: sponsorId, name }, + item_code: itemCode +}); + +describe("bucketLinesBySponsor", () => { + it("returns [] for no rows", () => { + expect(bucketLinesBySponsor([])).toEqual([]); + }); + + it("groups by sponsor.id preserving first-seen order", () => { + const groups = bucketLinesBySponsor([ + line(17, "Acme", "A1"), + line(9, "Globex", "G1"), + line(17, "Acme", "A2") + ]); + expect(groups.map((g) => g.sponsorId)).toEqual([17, 9]); + expect(groups[0].lines.map((l) => l.item_code)).toEqual(["A1", "A2"]); + expect(groups[1].sponsorName).toBe("Globex"); + }); + + it("keeps a sponsor in ONE group even when its rows are non-adjacent (same name, interleaved)", () => { + // Two distinct ids sharing a name, interleaved by date as the backend would order them. + const groups = bucketLinesBySponsor([ + line(17, "Dup Name", "X1"), + line(42, "Dup Name", "Y1"), + line(17, "Dup Name", "X2") + ]); + expect(groups).toHaveLength(2); + const acme = groups.find((g) => g.sponsorId === 17); + expect(acme.lines.map((l) => l.item_code)).toEqual(["X1", "X2"]); + }); + + it("buckets rows with a missing sponsor id under a single null group", () => { + const groups = bucketLinesBySponsor([ + { item_code: "Z1" }, + { sponsor: {}, item_code: "Z2" } + ]); + expect(groups).toHaveLength(1); + expect(groups[0].sponsorId).toBeNull(); + expect(groups[0].lines).toHaveLength(2); + }); +}); diff --git a/src/utils/manifest-grouping.js b/src/utils/manifest-grouping.js new file mode 100644 index 000000000..0195a75c7 --- /dev/null +++ b/src/utils/manifest-grouping.js @@ -0,0 +1,26 @@ +// Buckets flat per-line rows into sponsor groups, preserving first-seen order. +// +// Do NOT rely on row adjacency: the backend orders lines by sponsor NAME +// (purchase__sponsor__name) and dim_sponsor.name is not unique, so two distinct +// sponsor ids sharing a name can interleave by date. Bucketing by sponsor.id +// keeps each sponsor's lines in a single group regardless of row order. +export const bucketLinesBySponsor = (rows = []) => { + const groups = []; + const indexByKey = new Map(); + rows.forEach((row) => { + const id = row.sponsor?.id ?? null; + const key = id === null ? "__null__" : id; + if (!indexByKey.has(key)) { + indexByKey.set(key, groups.length); + groups.push({ + sponsorId: id, + sponsorName: row.sponsor?.name ?? "", + lines: [] + }); + } + groups[indexByKey.get(key)].lines.push(row); + }); + return groups; +}; + +export default bucketLinesBySponsor; From 2666e27f8791952cbb92ee5fa6bccf397aeaa92e Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 13:08:55 -0500 Subject: [PATCH 17/63] feat(sponsor-reports): add LinesManifestView collapsible per-line manifest Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/LinesManifestView.js | 169 ++++++++++++++++++ .../__tests__/LinesManifestView.test.js | 92 ++++++++++ src/i18n/en.json | 18 +- 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/components/sponsors/reports/LinesManifestView.js create mode 100644 src/components/sponsors/reports/__tests__/LinesManifestView.test.js diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js new file mode 100644 index 000000000..1fb609994 --- /dev/null +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -0,0 +1,169 @@ +/** + * Copyright 2026 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 React from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Chip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + Typography +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import T from "i18n-react/dist/i18n-react"; +import StatusPill from "./StatusPill"; +import { formatCheckoutTime } from "./OrdersTable"; +import { formatUsd } from "../../../utils/reports-money"; +import { bucketLinesBySponsor } from "../../../utils/manifest-grouping"; + +// eslint-disable-next-line no-magic-numbers +const PER_PAGE_OPTIONS = [10, 25, 50, 100]; +// eslint-disable-next-line no-magic-numbers +const DEFAULT_PER_PAGE = 50; + +// Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the +// logistics convention is the sponsor's booth. The booth NUMBER ships with +// slice #1 — until then show a muted "Booth" placeholder. +const Destination = ({ name }) => + name ? ( + <>{name} + ) : ( + + {T.translate("sponsor_reports_page.destination_booth_fallback")} + + ); + +const HEADERS = [ + { key: "col_order" }, + { key: "col_form_code" }, + { key: "col_item_code" }, + { key: "col_item_name" }, + { key: "col_destination" }, + { key: "col_checkout_at" }, + { key: "col_notes" }, + { key: "col_quantity", align: "right" }, + { key: "col_used_rate" }, + { key: "col_status" }, + { key: "col_line_total", align: "right" } +]; + +const LinesManifestView = ({ + rows = [], + total = 0, + currentPage = 1, + perPage = DEFAULT_PER_PAGE, + onPageChange, + onPerPageChange +}) => { + const groups = bucketLinesBySponsor(rows); + return ( + + {groups.map((group) => ( + + }> + + {group.sponsorName} + + + + + + + + + {HEADERS.map((h) => ( + + {T.translate(`sponsor_reports_page.${h.key}`)} + + ))} + + + + {group.lines.map((line, idx) => + // No backend line id; purchase.id repeats per line, so a + // composite key (with the in-group index) is needed. + ( + + {line.purchase?.number} + {line.form?.code} + {line.item_code} + {line.description} + + + + + {formatCheckoutTime(line.purchase?.checkout_at)} + + {line.notes} + {line.quantity} + {line.rate_name} + + + + + {formatUsd(line.line_total)} + + + ) + )} + +
+
+
+
+ ))} + onPageChange(zeroBased + 1)} + onRowsPerPageChange={(e) => onPerPageChange(Number(e.target.value))} + /> +
+ ); +}; + +export default LinesManifestView; diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js new file mode 100644 index 000000000..90ed8fc5b --- /dev/null +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -0,0 +1,92 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import LinesManifestView from "../LinesManifestView"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: (k, opts) => + opts && opts.count != null ? `${k}:${opts.count}` : k +})); + +const line = (over = {}) => ({ + sponsor: { id: 17, name: "Acme" }, + purchase: { + id: 5001, + number: "OCP-1", + status: "Paid", + checkout_at: 1735000000 + }, + form: { code: "AV", name: "Audio Visual" }, + item_code: "AV1", + description: "Audio mixer", + rate_name: "Early", + quantity: 2, + unit_price: "500.00", + line_total: "1000.00", + add_on_id: 3, + add_on_name: "Meeting Room T", + notes: "dock B", + is_canceled: false, + canceled_at: null, + ...over +}); + +const renderView = (props = {}) => + render( + + ); + +describe("LinesManifestView", () => { + it("renders a sponsor section header with a lines count", () => { + renderView(); + expect(screen.getByText("Acme")).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.lines_count:1") + ).toBeInTheDocument(); + }); + + it("renders the line's destination from add_on_name", () => { + renderView(); + expect(screen.getByText("Meeting Room T")).toBeInTheDocument(); + }); + + it("falls back to a muted 'Booth' when add_on_name is null", () => { + renderView({ rows: [line({ add_on_name: null })] }); + expect( + screen.getByText("sponsor_reports_page.destination_booth_fallback") + ).toBeInTheDocument(); + }); + + it("renders the status pill and money/qty cells", () => { + renderView(); + expect(screen.getByText("Paid")).toBeInTheDocument(); + expect(screen.getByText("AV1")).toBeInTheDocument(); + expect(screen.getByText("$1,000.00")).toBeInTheDocument(); + }); + + it("KEEPS a canceled line in the rendered set (visual treatment, not filtered)", () => { + renderView({ + rows: [ + line({ item_code: "AV2", is_canceled: true, canceled_at: 1735100000 }) + ] + }); + expect(screen.getByText("AV2")).toBeInTheDocument(); + const row = screen.getByText("AV2").closest("tr"); + expect(row).toHaveAttribute("data-canceled", "true"); + }); + + it("calls onPageChange with a 1-indexed page when the pager advances", () => { + const onPageChange = jest.fn(); + renderView({ total: 120, onPageChange }); + fireEvent.click(screen.getByRole("button", { name: /next page/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 439e2a7c6..4de298de5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4303,6 +4303,22 @@ "pages_active": "{count} pages active", "purchase_details_desc": "Orders & revenue", "sponsor_assets_desc": "Sponsor portal assets", - "landing_title": "Reports" + "landing_title": "Reports", + "view_toggle": "View", + "view_orders": "Orders", + "view_line_items": "Line Items", + "lines_count": "{count} lines", + "destination_booth_fallback": "Booth", + "col_order": "Order #", + "col_form_code": "Form Code", + "col_item_code": "Item Code", + "col_item_name": "Item Name", + "col_destination": "Destination", + "col_checkout_at": "Checked Out At", + "col_notes": "Notes", + "col_quantity": "Qty", + "col_used_rate": "Used Rate", + "col_status": "Status", + "col_line_total": "Line Total" } } From 4667f8b46e7c89c158cfae578da3d9baa7c02bd9 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 13:13:39 -0500 Subject: [PATCH 18/63] feat(sponsor-reports): add Orders/Line-Items report view toggle Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/ReportViewToggle.js | 27 ++++++++++++++++ .../__tests__/ReportViewToggle.test.js | 32 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/components/sponsors/reports/ReportViewToggle.js create mode 100644 src/components/sponsors/reports/__tests__/ReportViewToggle.test.js diff --git a/src/components/sponsors/reports/ReportViewToggle.js b/src/components/sponsors/reports/ReportViewToggle.js new file mode 100644 index 000000000..0a726286b --- /dev/null +++ b/src/components/sponsors/reports/ReportViewToggle.js @@ -0,0 +1,27 @@ +import React from "react"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; + +// Exclusive toggle between the order-grain "Orders" view and the per-line +// "Line Items" (manifest) view. MUI passes null when the active button is +// re-clicked in exclusive mode; ignore it so a view is always selected. +const ReportViewToggle = ({ value, onChange }) => ( + { + if (next) onChange(next); + }} + aria-label={T.translate("sponsor_reports_page.view_toggle")} + > + + {T.translate("sponsor_reports_page.view_orders")} + + + {T.translate("sponsor_reports_page.view_line_items")} + + +); + +export default ReportViewToggle; diff --git a/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js b/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js new file mode 100644 index 000000000..f7d9e158e --- /dev/null +++ b/src/components/sponsors/reports/__tests__/ReportViewToggle.test.js @@ -0,0 +1,32 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ReportViewToggle from "../ReportViewToggle"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +describe("ReportViewToggle", () => { + it("renders both view options", () => { + render(); + expect( + screen.getByText("sponsor_reports_page.view_orders") + ).toBeInTheDocument(); + expect( + screen.getByText("sponsor_reports_page.view_line_items") + ).toBeInTheDocument(); + }); + + it("calls onChange with the clicked view", () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + expect(onChange).toHaveBeenCalledWith("lines"); + }); + + it("ignores a re-click of the active button (MUI passes null)", () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByText("sponsor_reports_page.view_orders")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); From 17d4ff632eeb78665554eb6514eb93b940c47e94 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 13:21:31 -0500 Subject: [PATCH 19/63] feat(sponsor-reports): wire Line Items manifest view into Purchase Details page Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.js | 80 ++++++++++++- .../purchase-details-report-page/index.js | 110 ++++++++++++++---- 2 files changed, 166 insertions(+), 24 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 0c30605ee..41be121ec 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -21,6 +21,9 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getPurchaseDetailsFilters: jest.fn(() => ({ type: "REQUEST_PURCHASE_DETAILS_FILTERS" })), + getPurchaseDetailsLinesReport: jest.fn(() => ({ + type: "REQUEST_PURCHASE_DETAILS_LINES" + })), PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); @@ -29,7 +32,8 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ const { getCSV } = require("openstack-uicore-foundation/lib/utils/actions"); const { getPurchaseDetailsReport, - getPurchaseDetailsFilters + getPurchaseDetailsFilters, + getPurchaseDetailsLinesReport } = require("../../../../../actions/sponsor-reports-actions"); // Mock the API base-url helper so the CSV URL can be constructed in tests. @@ -63,6 +67,28 @@ const SAMPLE_ROW = { sponsor_note: "" }; +const SAMPLE_LINE = { + sponsor: { id: 17, name: "Acme Corp" }, + purchase: { + id: 5001, + number: "OCP-1", + status: "Paid", + checkout_at: 1735000000 + }, + form: { code: "AV", name: "Audio Visual" }, + item_code: "AV1", + description: "Audio mixer", + rate_name: "Early", + quantity: 2, + unit_price: "500.00", + line_total: "1000.00", + add_on_id: 3, + add_on_name: "Meeting Room T", + notes: "dock B", + is_canceled: false, + canceled_at: null +}; + const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports/purchase-details"; const PAGE_URL = "/app/summits/42/sponsors/reports/purchase-details"; @@ -86,6 +112,22 @@ function buildState(summaryOverrides = {}, { total = 1 } = {}) { }, currentSummitState: { currentSummit: { id: 42 } + }, + sponsorReportsPurchaseDetailsLinesState: { + data: [SAMPLE_LINE], + summary: { + total_orders: 1, + total_items: 2, + total_paid: "1000.00", + total_pending: "0.00", + total_refunded: null + }, + total: 1, + currentPage: 1, + lastPage: 1, + perPage: 50, + loading: false, + readError: null } }; } @@ -299,4 +341,40 @@ describe("PurchaseDetailsReportPage", () => { // Sort change snaps back to page 1; order is the backend key with desc prefix. expect(calledQuery).toMatchObject({ page: 1, order: "-number" }); }); + + it("renders the Orders/Line-Items view toggle", async () => { + renderPage(); + await act(async () => {}); + expect( + screen.getByText("sponsor_reports_page.view_line_items") + ).toBeInTheDocument(); + }); + + it("dispatches getPurchaseDetailsLinesReport and renders the manifest when Line Items is selected", async () => { + renderPage(); + await act(async () => {}); + getPurchaseDetailsLinesReport.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + }); + + expect(getPurchaseDetailsLinesReport).toHaveBeenCalled(); + const [[calledQuery]] = getPurchaseDetailsLinesReport.mock.calls; + expect(calledQuery).toMatchObject({ page: 1, per_page: 50 }); + expect(calledQuery).not.toHaveProperty("order"); + // Manifest renders the line's destination + expect(screen.getByText("Meeting Room T")).toBeInTheDocument(); + }); + + it("hides the CSV export button in the Line Items view", async () => { + renderPage(); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + }); + expect( + screen.queryByText("sponsor_reports_page.export_csv") + ).not.toBeInTheDocument(); + }); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index e04140a9c..0ac7d5586 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -34,15 +34,19 @@ import FilterBar from "../../../../components/sponsors/reports/FilterBar"; import OrdersTable, { toOrderParam } from "../../../../components/sponsors/reports/OrdersTable"; +import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView"; +import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import usePrint from "../../../../components/sponsors/reports/usePrint"; import { getPurchaseDetailsReport, + getPurchaseDetailsLinesReport, getPurchaseDetailsFilters, PURCHASE_DETAILS_VALIDATION_CLEAR } from "../../../../actions/sponsor-reports-actions"; const DEFAULT_PAGE_SIZE = 10; +const LINES_DEFAULT_PAGE_SIZE = 50; const TOAST_AUTO_HIDE_MS = 6000; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" @@ -55,9 +59,15 @@ const PurchaseDetailsReportPage = ({ total, readError, validationError, + // Lines slice (per-line manifest view) + linesData, + linesSummary, + linesTotal, + linesReadError, // From mapDispatchToProps (function form — includes raw dispatch) dispatch, getPurchaseDetailsReport: fetchReport, + getPurchaseDetailsLinesReport: fetchLinesReport, getPurchaseDetailsFilters: fetchFilters }) => { const print = usePrint(); @@ -68,6 +78,9 @@ const PurchaseDetailsReportPage = ({ const [perPage, setPerPage] = useState(DEFAULT_PAGE_SIZE); const [order, setOrder] = useState(null); const [orderDir, setOrderDir] = useState(1); + const [view, setView] = useState("orders"); + const [linesPage, setLinesPage] = useState(1); + const [linesPerPage, setLinesPerPage] = useState(LINES_DEFAULT_PAGE_SIZE); // Build the API query from all local state. Memoized so useEffect only re-runs // when the query actually changes (referential stability). @@ -91,6 +104,25 @@ const PurchaseDetailsReportPage = ({ }); }, [filters, currentPage, perPage, order, orderDir]); + // Lines query: same filters as Orders, but NO order param. CustomOrderingFilter + // would replace the default sponsor-name ordering and scatter the sponsor groups, + // so the manifest relies on the backend default ordering. + const linesQuery = useMemo(() => { + const { dateFrom, dateTo, ...rest } = filters; + const nextDayStartIso = (ymd) => { + const d = new Date(`${ymd}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; + }; + return buildReportQuery({ + ...rest, + dateFrom: dateFrom ? `${dateFrom}T00:00:00Z` : undefined, + dateTo: dateTo ? nextDayStartIso(dateTo) : undefined, + page: linesPage, + perPage: linesPerPage + }); + }, [filters, linesPage, linesPerPage]); + // Fetch filters once on mount. Summit is read from store inside the action. // Empty deps is intentional: fetchFilters is stable from connect() and reads // summit from Redux store inside the thunk. @@ -98,46 +130,51 @@ const PurchaseDetailsReportPage = ({ fetchFilters(); }, []); // mount-only - // Fetch report data whenever the derived query object changes. - // fetchReport reads summit from the store — only query changes drive re-fetches. + // Orders view: fetch the order-grain report when its query changes. useEffect(() => { - fetchReport(query); - }, [query]); // query is memoized; updates only when filters/pagination/sort change + if (view === "orders") fetchReport(query); + }, [view, query]); + + // Line Items view: fetch the per-line feed when its query changes. + useEffect(() => { + if (view === "lines") fetchLinesReport(linesQuery); + }, [view, linesQuery]); // ── Summary tiles ─────────────────────────────────────────────────────────── - // D9: Total Refunded tile renders ONLY when summary.total_refunded != null. + // D9: Total Refunded tile renders ONLY when activeSummary.total_refunded != null. // Backend main does not yet expose it (ships in PR #24); the presence check // keeps the tile hidden on current main and auto-appears after PR #24 deploys. - const tiles = summary + const activeSummary = view === "orders" ? summary : linesSummary; + const tiles = activeSummary ? [ { key: "total_orders", label: T.translate("sponsor_reports_page.total_orders"), - value: summary.total_orders + value: activeSummary.total_orders }, { key: "total_items", label: T.translate("sponsor_reports_page.total_items"), - value: summary.total_items + value: activeSummary.total_items }, { key: "total_paid", label: T.translate("sponsor_reports_page.total_paid"), - value: formatUsd(summary.total_paid), + value: formatUsd(activeSummary.total_paid), tone: "success" }, { key: "total_pending", label: T.translate("sponsor_reports_page.total_pending"), - value: formatUsd(summary.total_pending), + value: formatUsd(activeSummary.total_pending), tone: "warning" }, - ...(summary.total_refunded != null + ...(activeSummary.total_refunded != null ? [ { key: "total_refunded", label: T.translate("sponsor_reports_page.total_refunded"), - value: formatUsd(summary.total_refunded) + value: formatUsd(activeSummary.total_refunded) } ] : []) @@ -161,10 +198,12 @@ const PurchaseDetailsReportPage = ({ const handleApply = (next) => { setFilters(next); setCurrentPage(1); + setLinesPage(1); }; const handleClear = () => { setFilters({}); setCurrentPage(1); + setLinesPage(1); }; // ── Sort/pagination handlers ───────────────────────────────────────────────── @@ -180,6 +219,11 @@ const PurchaseDetailsReportPage = ({ setPerPage(newPerPage); setCurrentPage(1); }; + const handleLinesPageChange = (page) => setLinesPage(page); + const handleLinesPerPageChange = (newPerPage) => { + setLinesPerPage(newPerPage); + setLinesPage(1); + }; // ── Extra filter controls (status / type / date range) ────────────────────── const statusOptions = filterOptions?.statuses || []; @@ -248,16 +292,19 @@ const PurchaseDetailsReportPage = ({ subtitle={T.translate("sponsor_reports_page.purchase_details_subtitle")} actions={ <> + - + {view === "orders" && ( + + )} } filterBar={ @@ -284,11 +331,12 @@ const PurchaseDetailsReportPage = ({ T.translate("sponsor_reports_page.validation_error")} - {readError ? ( + {(view === "orders" ? readError : linesReadError) ? ( - {readError.message || T.translate("sponsor_reports_page.read_error")} + {(view === "orders" ? readError : linesReadError)?.message || + T.translate("sponsor_reports_page.read_error")} - ) : ( + ) : view === "orders" ? ( + ) : ( + )} ); @@ -307,10 +364,15 @@ const PurchaseDetailsReportPage = ({ const mapStateToProps = ({ sponsorReportsPurchaseDetailsState, + sponsorReportsPurchaseDetailsLinesState, currentSummitState }) => ({ currentSummit: currentSummitState.currentSummit, - ...sponsorReportsPurchaseDetailsState + ...sponsorReportsPurchaseDetailsState, + linesData: sponsorReportsPurchaseDetailsLinesState.data, + linesSummary: sponsorReportsPurchaseDetailsLinesState.summary, + linesTotal: sponsorReportsPurchaseDetailsLinesState.total, + linesReadError: sponsorReportsPurchaseDetailsLinesState.readError }); // Function form of mapDispatchToProps: injects raw dispatch (needed for the @@ -320,6 +382,8 @@ const mapDispatchToProps = (dispatch) => ({ dispatch, getPurchaseDetailsReport: (query) => dispatch(getPurchaseDetailsReport(query)), + getPurchaseDetailsLinesReport: (query) => + dispatch(getPurchaseDetailsLinesReport(query)), getPurchaseDetailsFilters: () => dispatch(getPurchaseDetailsFilters()) }); From c1cc057bb62ca814f13e2425e04b2454d340c8be Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 13:55:56 -0500 Subject: [PATCH 20/63] style(sponsor-reports): strip trailing whitespace in LinesManifestView Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/LinesManifestView.js | 82 +++++++++---------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 1fb609994..be251fb19 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -103,50 +103,48 @@ const LinesManifestView = ({ - {group.lines.map((line, idx) => + {group.lines.map((line, idx) => ( // No backend line id; purchase.id repeats per line, so a // composite key (with the in-group index) is needed. - ( - - {line.purchase?.number} - {line.form?.code} - {line.item_code} - {line.description} - - - - - {formatCheckoutTime(line.purchase?.checkout_at)} - - {line.notes} - {line.quantity} - {line.rate_name} - - - - - {formatUsd(line.line_total)} - - - ) - )} + + {line.purchase?.number} + {line.form?.code} + {line.item_code} + {line.description} + + + + + {formatCheckoutTime(line.purchase?.checkout_at)} + + {line.notes} + {line.quantity} + {line.rate_name} + + + + + {formatUsd(line.line_total)} + + + ))} From e06b1e9ebccf103068ca62940db03f90d9edaba5 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 14:50:13 -0500 Subject: [PATCH 21/63] refactor(sponsor-reports): address PR #997 convention review (smarcet) - index.js: import DEFAULT_PER_PAGE from constants (drop local DEFAULT_PAGE_SIZE dup) - index.js: mapDispatchToProps to object shorthand via new clearPurchaseDetailsValidation action creator (removes the raw dispatch prop) - OrdersTable.js: remove dead getOrderRowId export + its test (page maps id inline) - usePrint: move to src/hooks/ per the codebase custom-hook convention - section-csv-query.js: align defense-in-depth comment with the sponsor_id/page_id guard Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/sponsor-reports-actions.js | 7 ++++ .../sponsors/reports/OrdersTable.js | 5 --- .../reports/__tests__/OrdersTable.test.js | 10 +----- .../__tests__/usePrint.test.js | 2 +- .../sponsors/reports => hooks}/usePrint.js | 0 .../__tests__/index.test.js | 3 ++ .../purchase-details-report-page/index.js | 33 ++++++++----------- .../sponsor-asset-drilldown-page/index.js | 2 +- .../sponsor-asset-report-page/index.js | 2 +- src/utils/section-csv-query.js | 4 +-- 10 files changed, 30 insertions(+), 38 deletions(-) rename src/{components/sponsors/reports => hooks}/__tests__/usePrint.test.js (92%) rename src/{components/sponsors/reports => hooks}/usePrint.js (100%) diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index c5a21a880..d6ff84505 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -61,6 +61,13 @@ export const getPurchaseDetailsReport = .finally(() => dispatch(stopLoading())); }; +// Clears the Purchase Details validation toast (dispatched from the Snackbar +// onClose). A plain action creator lets the page bind it via the object form of +// mapDispatchToProps instead of receiving raw dispatch. +export const clearPurchaseDetailsValidation = () => ({ + type: PURCHASE_DETAILS_VALIDATION_CLEAR +}); + export const getPurchaseDetailsLinesReport = (query = {}) => async (dispatch, getState) => { diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 60b9c4215..684b23850 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -40,11 +40,6 @@ export const formatCheckoutTime = (value) => { return `${date} ${hour12}:${mm} ${ampm}`; }; -// MuiTable keys rows on row.id; the API exposes purchase_id, not id. -// The page must call rows.map(r => ({ ...r, id: r.purchase_id })) before -// passing data, or use this helper for explicit mapping. -export const getOrderRowId = (row) => row.purchase_id; - // Converts MuiTable sort state to the `order` query param expected by the API. // MuiTable calls onSort(columnKey, dir) where dir = 1 (asc) | -1 (desc). // Since columnKey IS the backend key for sortable columns, no extra translation diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index 15e1616ad..c6434a1ed 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -2,11 +2,7 @@ import "@testing-library/jest-dom"; import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; -import OrdersTable, { - formatCheckoutTime, - getOrderRowId, - toOrderParam -} from "../OrdersTable"; +import OrdersTable, { formatCheckoutTime, toOrderParam } from "../OrdersTable"; // MuiTable uses i18n-react internally (no-items message, pagination labels). jest.mock("i18n-react/dist/i18n-react", () => ({ @@ -65,10 +61,6 @@ describe("OrdersTable sort helpers", () => { expect(toOrderParam(undefined, 1)).toBeUndefined(); expect(toOrderParam("", 1)).toBeUndefined(); }); - - it("getOrderRowId extracts purchase_id (MuiTable requires row.id)", () => { - expect(getOrderRowId({ purchase_id: 99, id: 1 })).toBe(99); - }); }); // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/components/sponsors/reports/__tests__/usePrint.test.js b/src/hooks/__tests__/usePrint.test.js similarity index 92% rename from src/components/sponsors/reports/__tests__/usePrint.test.js rename to src/hooks/__tests__/usePrint.test.js index d6154c3c8..ab0ed1521 100644 --- a/src/components/sponsors/reports/__tests__/usePrint.test.js +++ b/src/hooks/__tests__/usePrint.test.js @@ -1,4 +1,4 @@ -// src/components/sponsors/reports/__tests__/usePrint.test.js +// src/hooks/__tests__/usePrint.test.js // @testing-library/react 12 (React 16) does not export renderHook; use a // lightweight component wrapper instead. import "@testing-library/jest-dom"; diff --git a/src/components/sponsors/reports/usePrint.js b/src/hooks/usePrint.js similarity index 100% rename from src/components/sponsors/reports/usePrint.js rename to src/hooks/usePrint.js diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 41be121ec..33e79250f 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -24,6 +24,9 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getPurchaseDetailsLinesReport: jest.fn(() => ({ type: "REQUEST_PURCHASE_DETAILS_LINES" })), + clearPurchaseDetailsValidation: jest.fn(() => ({ + type: "PURCHASE_DETAILS_VALIDATION_CLEAR" + })), PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 0ac7d5586..69121c10d 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -37,15 +37,15 @@ import OrdersTable, { import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView"; import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; -import usePrint from "../../../../components/sponsors/reports/usePrint"; +import usePrint from "../../../../hooks/usePrint"; import { getPurchaseDetailsReport, getPurchaseDetailsLinesReport, getPurchaseDetailsFilters, - PURCHASE_DETAILS_VALIDATION_CLEAR + clearPurchaseDetailsValidation } from "../../../../actions/sponsor-reports-actions"; +import { DEFAULT_PER_PAGE } from "../../../../utils/constants"; -const DEFAULT_PAGE_SIZE = 10; const LINES_DEFAULT_PAGE_SIZE = 50; const TOAST_AUTO_HIDE_MS = 6000; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" @@ -64,18 +64,18 @@ const PurchaseDetailsReportPage = ({ linesSummary, linesTotal, linesReadError, - // From mapDispatchToProps (function form — includes raw dispatch) - dispatch, + // From mapDispatchToProps (object form — bound action creators) getPurchaseDetailsReport: fetchReport, getPurchaseDetailsLinesReport: fetchLinesReport, - getPurchaseDetailsFilters: fetchFilters + getPurchaseDetailsFilters: fetchFilters, + clearPurchaseDetailsValidation: clearValidation }) => { const print = usePrint(); // Local pagination/sort state. MuiTable dir = 1 (asc) | -1 (desc). const [filters, setFilters] = useState({}); const [currentPage, setCurrentPage] = useState(1); - const [perPage, setPerPage] = useState(DEFAULT_PAGE_SIZE); + const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); const [order, setOrder] = useState(null); const [orderDir, setOrderDir] = useState(1); const [view, setView] = useState("orders"); @@ -324,7 +324,7 @@ const PurchaseDetailsReportPage = ({ dispatch({ type: PURCHASE_DETAILS_VALIDATION_CLEAR })} + onClose={() => clearValidation()} > {validationError?.message || @@ -375,17 +375,12 @@ const mapStateToProps = ({ linesReadError: sponsorReportsPurchaseDetailsLinesState.readError }); -// Function form of mapDispatchToProps: injects raw dispatch (needed for the -// PURCHASE_DETAILS_VALIDATION_CLEAR action in the Snackbar handler) alongside -// the bound action creators. -const mapDispatchToProps = (dispatch) => ({ - dispatch, - getPurchaseDetailsReport: (query) => - dispatch(getPurchaseDetailsReport(query)), - getPurchaseDetailsLinesReport: (query) => - dispatch(getPurchaseDetailsLinesReport(query)), - getPurchaseDetailsFilters: () => dispatch(getPurchaseDetailsFilters()) -}); +const mapDispatchToProps = { + getPurchaseDetailsReport, + getPurchaseDetailsLinesReport, + getPurchaseDetailsFilters, + clearPurchaseDetailsValidation +}; export default withRouter( connect(mapStateToProps, mapDispatchToProps)(PurchaseDetailsReportPage) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index a65ca21e1..3bb89dcde 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -49,7 +49,7 @@ import { isPositiveIntId } from "../../../../utils/reports-api"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; -import usePrint from "../../../../components/sponsors/reports/usePrint"; +import usePrint from "../../../../hooks/usePrint"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; import StatusPill from "../../../../components/sponsors/reports/StatusPill"; diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 7ecee5618..01798a57d 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -30,7 +30,7 @@ import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; -import usePrint from "../../../../components/sponsors/reports/usePrint"; +import usePrint from "../../../../hooks/usePrint"; import { getSponsorAssetFilters, getSponsorAssetReport diff --git a/src/utils/section-csv-query.js b/src/utils/section-csv-query.js index f09224d92..965cee5f8 100644 --- a/src/utils/section-csv-query.js +++ b/src/utils/section-csv-query.js @@ -30,8 +30,8 @@ export const buildSectionCsvQuery = ( const sid = Number(sponsorId); const pid = Number(pageId); // Defense-in-depth: callers pass route/backend integer ids (the drill-down page - // validates :sponsorId/:summitId before rendering). Never interpolate a - // non-integer value into a filter clause sent to the CSV endpoint. + // validates :sponsorId before rendering). Only interpolate sponsor_id/page_id + // into a filter clause when each coerces to an integer; non-integers are dropped. if (Number.isInteger(sid)) kept.push(`sponsor_id==${sid}`); if (Number.isInteger(pid)) kept.push(`page_id==${pid}`); return { ...rest, "filter[]": kept }; From 3315801e538e91ba4503c1fd095eb5b367b3846d Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 15:21:53 -0500 Subject: [PATCH 22/63] test(sponsor-reports): add lines slice to layout test store (fix CI) The Purchase Details page mapStateToProps reads sponsorReportsPurchaseDetailsLinesState.data; the SponsorReportsLayout test mounts the real page, so its mock store must include that slice. Missing it threw 'Cannot read properties of undefined (reading data)' under the full yarn test run (CI), though the scoped report-suite runs passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/layouts/__tests__/sponsor-reports-layout.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index 2fb38e76d..cbd7d4afc 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -165,6 +165,16 @@ describe("SponsorReportsLayout", () => { total: 0, readError: null, validationError: null + }, + sponsorReportsPurchaseDetailsLinesState: { + data: [], + summary: null, + total: 0, + currentPage: 1, + lastPage: 1, + perPage: 50, + loading: false, + readError: null } } } From 562f87e6be977c9789cbb258eb3b0abe0e053413 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 15:29:10 -0500 Subject: [PATCH 23/63] fix(sponsor-reports): wrap overflowing asset filenames in drilldown links Hashed asset filenames (32-char hex prefix, no spaces) overflowed their card. The link Typography had noWrap (white-space:nowrap). Switch the link to a flex row with overflowWrap:anywhere + minWidth:0 on the filename so the unbroken hash wraps within the card; icons get flexShrink:0 so they don't squash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsor-asset-drilldown-page/index.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 3bb89dcde..42c842272 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -93,13 +93,23 @@ const ContentCell = ({ row }) => { href={url} target="_blank" rel="noopener noreferrer" - sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }} + sx={{ display: "flex", alignItems: "flex-start", gap: 0.5 }} > - - + + {/* Long hashed filenames have no spaces; overflowWrap:anywhere breaks + the unbroken hash so the link wraps inside its card instead of + overflowing. minWidth:0 lets the text shrink within the flex row. */} + {filename || row.module.title} - + ); } From f2cac7e8f0e0ac9acd5e759c7207b9a6b66891d7 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 16:32:42 -0500 Subject: [PATCH 24/63] fix(sponsor-reports): readable toggle + summary-tile typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app sets html root font-size to 62.5% (10px), so rem-based sizes render tiny: the toggle override read 8.75px and subtitle1 tile labels 10px. Pin px instead — toggles to 14px/500 to match the Print/Export action buttons, and the summary-tile labels to 14px. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/sponsors/reports/GroupByToggle.js | 7 ++++++- src/components/sponsors/reports/ReportViewToggle.js | 6 +++++- src/components/sponsors/reports/SummaryPanel.js | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/sponsors/reports/GroupByToggle.js b/src/components/sponsors/reports/GroupByToggle.js index 27374e73c..e022c07c9 100644 --- a/src/components/sponsors/reports/GroupByToggle.js +++ b/src/components/sponsors/reports/GroupByToggle.js @@ -13,7 +13,12 @@ const GroupByToggle = ({ value, onChange }) => ( if (next) onChange(next); }} aria-label={T.translate("sponsor_reports_page.group_by")} - sx={{ "& .MuiToggleButton-root": { px: 2.5, fontSize: "0.95rem" } }} + // Match the adjacent action buttons (Print / Export CSV) typography. + // px, not rem: html root font-size is 62.5% (10px) here, so "0.875rem" would + // render 8.75px; the MuiButton resolves to 14px. + sx={{ + "& .MuiToggleButton-root": { px: 2.5, fontSize: "14px", fontWeight: 500 } + }} > {T.translate("sponsor_reports_page.group_by_sponsor")} diff --git a/src/components/sponsors/reports/ReportViewToggle.js b/src/components/sponsors/reports/ReportViewToggle.js index 0a726286b..68985f394 100644 --- a/src/components/sponsors/reports/ReportViewToggle.js +++ b/src/components/sponsors/reports/ReportViewToggle.js @@ -8,12 +8,16 @@ import T from "i18n-react/dist/i18n-react"; const ReportViewToggle = ({ value, onChange }) => ( { if (next) onChange(next); }} aria-label={T.translate("sponsor_reports_page.view_toggle")} + // Match the adjacent action buttons (Print / Export CSV) typography. + // Use px, not rem: this app sets html root font-size to 62.5% (10px), so a + // hardcoded "0.875rem" would render 8.75px. The MuiButton resolves to 14px. + sx={{ "& .MuiToggleButton-root": { fontSize: "14px", fontWeight: 500 } }} > {T.translate("sponsor_reports_page.view_orders")} diff --git a/src/components/sponsors/reports/SummaryPanel.js b/src/components/sponsors/reports/SummaryPanel.js index 97ce7cc50..15bed8e96 100644 --- a/src/components/sponsors/reports/SummaryPanel.js +++ b/src/components/sponsors/reports/SummaryPanel.js @@ -21,7 +21,8 @@ const SummaryPanel = ({ tiles = [] }) => { {tile.label} From 645fe0c7eae5931733751bd139f429c5042633f0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 16:32:56 -0500 Subject: [PATCH 25/63] feat(sponsor-reports): visible sponsor avatars + white header-icon theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SponsorAvatar: logo-first with a colored-initials fallback so a no-logo (or white-on-white) sponsor is never an invisible blank. Styled to mimic the ReportShell title icon — single primary.light rounded square with white foreground — and used in both group-by views and the drill-down header. Switch the ReportShell title icon glyph to white to match. Also wrap the component-view asset filename (was truncated by noWrap+maxWidth) so the full name is readable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/GroupByComponentView.js | 9 ++-- .../sponsors/reports/GroupBySponsorView.js | 4 +- .../sponsors/reports/ReportShell.js | 3 +- .../sponsors/reports/SponsorAvatar.js | 53 +++++++++++++++++++ .../reports/__tests__/SponsorAvatar.test.js | 29 ++++++++++ .../sponsor-asset-drilldown-page/index.js | 4 +- 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/components/sponsors/reports/SponsorAvatar.js create mode 100644 src/components/sponsors/reports/__tests__/SponsorAvatar.test.js diff --git a/src/components/sponsors/reports/GroupByComponentView.js b/src/components/sponsors/reports/GroupByComponentView.js index b8282402b..ed63e8eac 100644 --- a/src/components/sponsors/reports/GroupByComponentView.js +++ b/src/components/sponsors/reports/GroupByComponentView.js @@ -14,7 +14,6 @@ import React from "react"; import { Link as RouterLink } from "react-router-dom"; import { - Avatar, Card, CardContent, Chip, @@ -26,6 +25,7 @@ import { import T from "i18n-react/dist/i18n-react"; import StatusRollupChips from "./StatusRollupChips"; import StatusPill from "./StatusPill"; +import SponsorAvatar from "./SponsorAvatar"; import { toPlainText } from "../../../utils/reports-text"; const NOT_PRESENT_STATUSES = ["pending", "not_applicable"]; @@ -79,7 +79,7 @@ const GroupByComponentView = ({ summitId, cards = [] }) => ( alignItems="center" sx={{ flexWrap: "wrap" }} > - + ( {entry.name} + {/* Asset filename/summary: wrap (don't truncate) so the full + name is readable; overflowWrap breaks long hashed filenames. */} {isNotPresent(entry) ? T.translate("sponsor_reports_page.not_present_yet") diff --git a/src/components/sponsors/reports/GroupBySponsorView.js b/src/components/sponsors/reports/GroupBySponsorView.js index b5c8b6e29..f53bc33f0 100644 --- a/src/components/sponsors/reports/GroupBySponsorView.js +++ b/src/components/sponsors/reports/GroupBySponsorView.js @@ -14,7 +14,6 @@ import React from "react"; import { Link as RouterLink } from "react-router-dom"; import { - Avatar, Card, CardActionArea, CardContent, @@ -25,6 +24,7 @@ import { import T from "i18n-react/dist/i18n-react"; import StatusRollupChips from "./StatusRollupChips"; import TierBadge from "./TierBadge"; +import SponsorAvatar from "./SponsorAvatar"; // Each sponsor card links to the summit-admin per-sponsor drill-down. // NOTE: the drill-down path is /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id, @@ -52,7 +52,7 @@ const GroupBySponsorView = ({ summitId, cards = [] }) => ( alignItems="center" sx={{ mb: 1, flexWrap: "wrap" }} > - + {s.name} {showCompany && ( diff --git a/src/components/sponsors/reports/ReportShell.js b/src/components/sponsors/reports/ReportShell.js index 22c0c3545..f82cbf251 100644 --- a/src/components/sponsors/reports/ReportShell.js +++ b/src/components/sponsors/reports/ReportShell.js @@ -32,7 +32,8 @@ const ReportShell = ({ alignItems: "center", justifyContent: "center", bgcolor: `${iconTone}.light`, - color: `${iconTone}.dark` + // White glyph to match the sponsor avatars on the same tint. + color: "common.white" }} > {icon} diff --git a/src/components/sponsors/reports/SponsorAvatar.js b/src/components/sponsors/reports/SponsorAvatar.js new file mode 100644 index 000000000..c17d982e4 --- /dev/null +++ b/src/components/sponsors/reports/SponsorAvatar.js @@ -0,0 +1,53 @@ +/** + * Copyright 2026 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 React from "react"; +import { Avatar } from "@mui/material"; + +const MAX_INITIALS = 2; + +const initialsOf = (name = "") => + name + .trim() + .split(/\s+/) + .slice(0, MAX_INITIALS) + .map((w) => w[0] || "") + .join("") + .toUpperCase() || "?"; + +// Sponsor avatar with a logo-first, initials-fallback strategy. Container mimics +// the ReportShell title icon: a single tinted rounded square (primary.light bg / +// primary.dark foreground), so initials and logos share one consistent look and +// a no-logo (or white-logo) sponsor is never an invisible blank. +const SponsorAvatar = ({ name, logoUrl, sx, ...props }) => ( + + {initialsOf(name)} + +); + +export default SponsorAvatar; diff --git a/src/components/sponsors/reports/__tests__/SponsorAvatar.test.js b/src/components/sponsors/reports/__tests__/SponsorAvatar.test.js new file mode 100644 index 000000000..1c4745dba --- /dev/null +++ b/src/components/sponsors/reports/__tests__/SponsorAvatar.test.js @@ -0,0 +1,29 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import SponsorAvatar from "../SponsorAvatar"; + +describe("SponsorAvatar", () => { + it("renders the logo image when logoUrl is provided", () => { + render(); + const img = screen.getByRole("img"); + expect(img).toHaveAttribute("src", "http://x/logo.png"); + expect(img).toHaveAttribute("alt", "Acme Corp"); + }); + + it("falls back to up-to-two uppercase initials when there is no logo", () => { + render(); + expect(screen.getByText("AE")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("uses a single initial for a one-word name", () => { + render(); + expect(screen.getByText("A")).toBeInTheDocument(); + }); + + it("renders '?' for an empty/whitespace name with no logo", () => { + render(); + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 42c842272..3ece6aeff 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -28,7 +28,6 @@ import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; import { - Avatar, Box, Button, Card, @@ -53,6 +52,7 @@ import usePrint from "../../../../hooks/usePrint"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; import StatusPill from "../../../../components/sponsors/reports/StatusPill"; +import SponsorAvatar from "../../../../components/sponsors/reports/SponsorAvatar"; import { getSponsorAssetSponsor } from "../../../../actions/sponsor-reports-actions"; // Gate the on an image file extension; render every other file as a @@ -242,7 +242,7 @@ const SponsorAssetDrilldownPage = ({ }} > - {(sponsor.name || "?").charAt(0)} + {sponsor.name} From 809356f8bc9fe2ad30180146b255df2f2c98b364 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 25 Jun 2026 18:58:34 -0500 Subject: [PATCH 26/63] feat(sponsor-reports): enable CSV export on the Line Items view Render the Export CSV button on the Line Items (per-line) view of the Purchase Details report, wired to the new backend GET .../purchase-details/lines/csv endpoint. One ExportCsvButton switched by view; lines query strips only pagination (preserves filters incl. the derived include_cancelled). Orders export unchanged. Label stays 'Export CSV'; filename purchase-details-lines-summit-.csv. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.js | 68 ++++++++++++++++++- .../purchase-details-report-page/index.js | 35 +++++++--- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 33e79250f..71e8ba3e4 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -370,14 +370,76 @@ describe("PurchaseDetailsReportPage", () => { expect(screen.getByText("Meeting Room T")).toBeInTheDocument(); }); - it("hides the CSV export button in the Line Items view", async () => { + it("renders the CSV export button in the Line Items view", async () => { renderPage(); await act(async () => {}); await act(async () => { fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); }); expect( - screen.queryByText("sponsor_reports_page.export_csv") - ).not.toBeInTheDocument(); + screen.getByText("sponsor_reports_page.export_csv") + ).toBeInTheDocument(); + }); + + it("CSV export in the Line Items view dispatches getCSV with the lines URL, pagination-stripped query, and lines filename", async () => { + renderPage(); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + }); + + // Guard: switching to the lines view must not trigger an export on its own. + // (beforeEach jest.clearAllMocks() leaves getCSV at zero calls here.) + expect(getCSV).not.toHaveBeenCalled(); + const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); + await act(async () => { + fireEvent.click(exportBtn); + }); + + // getCSV is dispatched with (url, { ...linesCsvQuery, access_token }, filename). + // With no filters, linesCsvQuery drops page/per_page → empty → only access_token. + expect(getCSV).toHaveBeenCalledTimes(1); + expect(getCSV).toHaveBeenCalledWith( + "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv", + { access_token: "test-token" }, + "purchase-details-lines-summit-42.csv" + ); + }); + + it("Line Items CSV export preserves applied filters and strips only pagination", async () => { + renderPage(); + await act(async () => {}); + + // Apply a date filter (same mechanism as the orders filter test). + const dateInputs = document.querySelectorAll("input[type=\"date\"]"); + await act(async () => { + fireEvent.change(dateInputs[0], { target: { value: "2026-01-01" } }); + }); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.apply")); + }); + + // Switch to Line Items and export. + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); + }); + // Guard: neither Apply nor the view switch should have exported anything yet. + expect(getCSV).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.export_csv")); + }); + + expect(getCSV).toHaveBeenCalledTimes(1); + const [[exportedUrl, exportedQuery]] = getCSV.mock.calls; + expect(exportedUrl).toBe( + "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv" + ); + // Applied date filter survives; only pagination is stripped. + expect(exportedQuery["filter[]"]).toEqual( + expect.arrayContaining([expect.stringContaining("order_date>=")]) + ); + expect(exportedQuery).not.toHaveProperty("page"); + expect(exportedQuery).not.toHaveProperty("per_page"); + expect(exportedQuery.access_token).toBe("test-token"); }); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 69121c10d..5079cfa7b 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -193,6 +193,19 @@ const PurchaseDetailsReportPage = ({ return rest; }, [query]); + const linesCsvUrl = currentSummit + ? `${getReportsApiBaseUrl()}/api/v1/summits/${ + currentSummit.id + }/reports/purchase-details/lines/csv` + : ""; + const linesCsvQuery = useMemo(() => { + // Strip ONLY pagination — exports the full filtered set (backend caps at + // CSV_MAX_ROWS). Everything else buildReportQuery emitted is preserved, + // including the derived include_cancelled="true" when status is "Canceled". + const { page: _page, per_page: _perPage, ...rest } = linesQuery; + return rest; + }, [linesQuery]); + // ── FilterBar handlers ────────────────────────────────────────────────────── // Applying/clearing a filter changes the result set → snap back to page 1. const handleApply = (next) => { @@ -296,15 +309,19 @@ const PurchaseDetailsReportPage = ({ - {view === "orders" && ( - - )} + } filterBar={ From fd1296c0e5b84ff9199afa7f880800de72454479 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 14:24:59 -0500 Subject: [PATCH 27/63] refactor(sponsor-reports): add isPositiveIntId + htmlToPlainText to methods.js Generic helpers move to the shared methods.js home (convention). htmlToPlainText preserves tag-boundary whitespace + decodes entities (htmlToString fuses words). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/__tests__/methods.test.js | 39 +++++++++++++++++++++++++++++ src/utils/methods.js | 17 +++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/utils/__tests__/methods.test.js b/src/utils/__tests__/methods.test.js index 0200a1756..0dd147444 100644 --- a/src/utils/__tests__/methods.test.js +++ b/src/utils/__tests__/methods.test.js @@ -1,6 +1,8 @@ import { getMediaInputValue, + htmlToPlainText, isImageUrl, + isPositiveIntId, normalizeSelectAllField } from "../methods"; @@ -154,3 +156,40 @@ describe("getMediaInputValue", () => { }); }); }); + +describe("isPositiveIntId", () => { + it("accepts positive integers (number or string)", () => { + expect(isPositiveIntId(5)).toBe(true); + expect(isPositiveIntId("17")).toBe(true); + }); + it("rejects zero, negatives, non-integers, junk", () => { + expect(isPositiveIntId(0)).toBe(false); + expect(isPositiveIntId("0")).toBe(false); + expect(isPositiveIntId(-3)).toBe(false); + expect(isPositiveIntId("1.5")).toBe(false); + expect(isPositiveIntId("abc")).toBe(false); + expect(isPositiveIntId(null)).toBe(false); + expect(isPositiveIntId(undefined)).toBe(false); + }); +}); + +describe("htmlToPlainText", () => { + it("returns '' for null/undefined", () => { + expect(htmlToPlainText(null)).toBe(""); + expect(htmlToPlainText(undefined)).toBe(""); + }); + it("strips tags with a space at boundaries (no word fusing)", () => { + expect(htmlToPlainText("

a

b")).toBe("a b"); + expect(htmlToPlainText("

Hello

world")).toBe("Hello world"); + }); + it("decodes valid named + numeric entities", () => { + expect(htmlToPlainText("a & b")).toBe("a & b"); + expect(htmlToPlainText("5 °")).toBe("5 °"); + expect(htmlToPlainText("©")).toBe("©"); + expect(htmlToPlainText("©")).toBe("©"); + }); + it("leaves malformed-case entities literal (DOMParser is case-sensitive)", () => { + expect(htmlToPlainText("&Copy;")).toBe("&Copy;"); + expect(htmlToPlainText("x&NBSP;y")).toBe("x&NBSP;y"); + }); +}); diff --git a/src/utils/methods.js b/src/utils/methods.js index a2bc7edab..fb1e13cff 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -644,3 +644,20 @@ export const getFileUploadAllowedExtensions = () => { export const isImageUrl = (url) => /\.(jpe?g|png|gif|webp|svg|bmp)(\?|$)/i.test(url); + +// Strict positive-integer route-id validator. Route params arrive as strings; +// accept only positive integers so a malformed/tampered id can't be interpolated +// into a filter clause, a URL path, or a download filename. +export const isPositiveIntId = (v) => /^[1-9]\d*$/.test(String(v)); + +// Flatten HTML-ish text to plain text: tag boundaries become spaces (so adjacent +// tags don't fuse words), entities are decoded via the browser parser, whitespace +// is collapsed and trimmed. Use this instead of the existing htmlToString, which +// is documentElement.textContent and fuses adjacent-tag text. +export const htmlToPlainText = (html) => { + if (html == null) return ""; + const spaced = String(html).replace(/<[^>]*>/g, " "); + const decoded = new DOMParser().parseFromString(spaced, "text/html") + .documentElement.textContent; + return decoded.replace(/\s+/g, " ").trim(); +}; From b22dc41a9e17e7597d46f7866c5b254cba2d6214 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 14:31:40 -0500 Subject: [PATCH 28/63] refactor(sponsor-reports): co-locate report-query + add shared purchase query builders Move report-query under the feature folder (feature helper convention). Add buildPurchaseQuery/buildPurchaseLinesQuery so the on-screen fetch and the upcoming export thunks share one date/order normalization (no duplication, no regression). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/report-query.test.js | 38 +++++++++++- .../purchase-details-report-page/index.js | 59 +++++++------------ .../sponsors/sponsor-reports}/report-query.js | 41 ++++++++++++- .../sponsor-asset-report-page/index.js | 2 +- 4 files changed, 98 insertions(+), 42 deletions(-) rename src/{utils => pages/sponsors/sponsor-reports}/__tests__/report-query.test.js (57%) rename src/{utils => pages/sponsors/sponsor-reports}/report-query.js (67%) diff --git a/src/utils/__tests__/report-query.test.js b/src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js similarity index 57% rename from src/utils/__tests__/report-query.test.js rename to src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js index d51f97fee..e5c1f618f 100644 --- a/src/utils/__tests__/report-query.test.js +++ b/src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js @@ -1,4 +1,4 @@ -import { buildReportQuery } from "../report-query"; +import { buildReportQuery , buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; describe("buildReportQuery", () => { it("returns an empty object for no filters", () => { @@ -62,3 +62,39 @@ describe("buildReportQuery", () => { }); }); }); + +describe("buildPurchaseQuery (orders)", () => { + it("expands dates and includes a formatted order param", () => { + const q = buildPurchaseQuery( + { dateFrom: "2026-01-01", dateTo: "2026-01-31" }, + { page: 1, perPage: 10, order: "order_date", orderDir: -1 } + ); + expect(q["filter[]"]).toEqual( + expect.arrayContaining([ + "order_date>=2026-01-01T00:00:00Z", + "order_date<2026-02-01T00:00:00Z" + ]) + ); + expect(q.order).toBe("-order_date"); + expect(q).toMatchObject({ page: 1, per_page: 10 }); + }); + it("omits page/per_page/order when not provided (export shape)", () => { + const q = buildPurchaseQuery({ status: "Paid" }, {}); + expect(q).not.toHaveProperty("page"); + expect(q).not.toHaveProperty("per_page"); + expect(q).not.toHaveProperty("order"); + expect(q["filter[]"]).toEqual(["status==Paid"]); + }); +}); + +describe("buildPurchaseLinesQuery", () => { + it("expands dates, carries pagination, and never sets order", () => { + const q = buildPurchaseLinesQuery( + { dateFrom: "2026-01-01" }, + { page: 2, perPage: 50 } + ); + expect(q["filter[]"]).toEqual(["order_date>=2026-01-01T00:00:00Z"]); + expect(q).toMatchObject({ page: 2, per_page: 50 }); + expect(q).not.toHaveProperty("order"); + }); +}); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 5079cfa7b..5be16ed63 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -26,14 +26,12 @@ import { import PrintIcon from "@mui/icons-material/Print"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; import { formatUsd } from "../../../../utils/reports-money"; -import { buildReportQuery } from "../../../../utils/report-query"; +import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; import { getReportsApiBaseUrl } from "../../../../utils/reports-api"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; -import OrdersTable, { - toOrderParam -} from "../../../../components/sponsors/reports/OrdersTable"; +import OrdersTable from "../../../../components/sponsors/reports/OrdersTable"; import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView"; import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle"; import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; @@ -48,7 +46,6 @@ import { DEFAULT_PER_PAGE } from "../../../../utils/constants"; const LINES_DEFAULT_PAGE_SIZE = 50; const TOAST_AUTO_HIDE_MS = 6000; -const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" const PurchaseDetailsReportPage = ({ // From mapStateToProps @@ -84,44 +81,28 @@ const PurchaseDetailsReportPage = ({ // Build the API query from all local state. Memoized so useEffect only re-runs // when the query actually changes (referential stability). - const query = useMemo(() => { - const { dateFrom, dateTo, ...rest } = filters; - // Expand YYYY-MM-DD dates to ISO datetimes for the IsoDateTimeFilter backend field. - // dateTo → start of the NEXT day (exclusive <) so same-day fractional-second rows - // are included rather than dropped by a <= end-of-day bound. - const nextDayStartIso = (ymd) => { - const d = new Date(`${ymd}T00:00:00Z`); - d.setUTCDate(d.getUTCDate() + 1); - return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; - }; - return buildReportQuery({ - ...rest, - dateFrom: dateFrom ? `${dateFrom}T00:00:00Z` : undefined, - dateTo: dateTo ? nextDayStartIso(dateTo) : undefined, - page: currentPage, - perPage, - order: toOrderParam(order, orderDir) - }); - }, [filters, currentPage, perPage, order, orderDir]); + const query = useMemo( + () => + buildPurchaseQuery(filters, { + page: currentPage, + perPage, + order, + orderDir + }), + [filters, currentPage, perPage, order, orderDir] + ); // Lines query: same filters as Orders, but NO order param. CustomOrderingFilter // would replace the default sponsor-name ordering and scatter the sponsor groups, // so the manifest relies on the backend default ordering. - const linesQuery = useMemo(() => { - const { dateFrom, dateTo, ...rest } = filters; - const nextDayStartIso = (ymd) => { - const d = new Date(`${ymd}T00:00:00Z`); - d.setUTCDate(d.getUTCDate() + 1); - return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; - }; - return buildReportQuery({ - ...rest, - dateFrom: dateFrom ? `${dateFrom}T00:00:00Z` : undefined, - dateTo: dateTo ? nextDayStartIso(dateTo) : undefined, - page: linesPage, - perPage: linesPerPage - }); - }, [filters, linesPage, linesPerPage]); + const linesQuery = useMemo( + () => + buildPurchaseLinesQuery(filters, { + page: linesPage, + perPage: linesPerPage + }), + [filters, linesPage, linesPerPage] + ); // Fetch filters once on mount. Summit is read from store inside the action. // Empty deps is intentional: fetchFilters is stable from connect() and reads diff --git a/src/utils/report-query.js b/src/pages/sponsors/sponsor-reports/report-query.js similarity index 67% rename from src/utils/report-query.js rename to src/pages/sponsors/sponsor-reports/report-query.js index b23559ac3..431c52e46 100644 --- a/src/utils/report-query.js +++ b/src/pages/sponsors/sponsor-reports/report-query.js @@ -1,4 +1,4 @@ -// src/utils/report-query.js +// src/pages/sponsors/sponsor-reports/report-query.js // // Translates report UI filter state into a base-api-utils query object. // @@ -9,6 +9,8 @@ // Every emitted value uses valid `field==value` / `field>=value` operator syntax // (a no-operator value triggers a server IndexError → 500). +import { toOrderParam } from "../../../components/sponsors/reports/OrdersTable"; + export const buildReportQuery = (filters = {}) => { const { sponsorIds = [], @@ -70,3 +72,40 @@ export const buildReportQuery = (filters = {}) => { return query; }; + +const ISO_DATE_LENGTH = 10; + +// dateTo → start of the NEXT day (exclusive <) so same-day fractional-second rows +// are included rather than dropped by a <= end-of-day bound. +const nextDayStartIso = (ymd) => { + const d = new Date(`${ymd}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; +}; + +const expandDates = (filters = {}) => { + const { dateFrom, dateTo, ...rest } = filters; + return { + ...rest, + dateFrom: dateFrom ? `${dateFrom}T00:00:00Z` : undefined, + dateTo: dateTo ? nextDayStartIso(dateTo) : undefined + }; +}; + +// Orders grain: date expansion + pagination + formatted sort. Used by the on-screen +// fetch AND exportPurchaseDetailsCsv (export passes no page/perPage → none emitted). +export const buildPurchaseQuery = ( + filters = {}, + { page, perPage, order, orderDir } = {} +) => + buildReportQuery({ + ...expandDates(filters), + page, + perPage, + order: toOrderParam(order, orderDir) + }); + +// Lines grain: same date expansion, NO order (manifest relies on backend default +// ordering). Used by the on-screen lines fetch AND exportPurchaseDetailsLinesCsv. +export const buildPurchaseLinesQuery = (filters = {}, { page, perPage } = {}) => + buildReportQuery({ ...expandDates(filters), page, perPage }); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 01798a57d..a40bb1838 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -18,7 +18,7 @@ import T from "i18n-react/dist/i18n-react"; import { Box, Button, Pagination, Stack, Typography } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; -import { buildReportQuery } from "../../../../utils/report-query"; +import { buildReportQuery } from "../report-query"; import { getReportsApiBaseUrl, isPositiveIntId From 500cab979d505ec7b14d5e0cee5085cf65134245 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 14:36:07 -0500 Subject: [PATCH 29/63] refactor(sponsor-reports): use methods.htmlToPlainText, drop reports-text util Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/GroupByComponentView.js | 4 +-- .../sponsor-asset-drilldown-page/index.js | 4 +-- src/utils/__tests__/reports-text.test.js | 27 ----------------- src/utils/reports-text.js | 29 ------------------- 4 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 src/utils/__tests__/reports-text.test.js delete mode 100644 src/utils/reports-text.js diff --git a/src/components/sponsors/reports/GroupByComponentView.js b/src/components/sponsors/reports/GroupByComponentView.js index ed63e8eac..39a410534 100644 --- a/src/components/sponsors/reports/GroupByComponentView.js +++ b/src/components/sponsors/reports/GroupByComponentView.js @@ -26,7 +26,7 @@ import T from "i18n-react/dist/i18n-react"; import StatusRollupChips from "./StatusRollupChips"; import StatusPill from "./StatusPill"; import SponsorAvatar from "./SponsorAvatar"; -import { toPlainText } from "../../../utils/reports-text"; +import { htmlToPlainText } from "../../../utils/methods"; const NOT_PRESENT_STATUSES = ["pending", "not_applicable"]; @@ -96,7 +96,7 @@ const GroupByComponentView = ({ summitId, cards = [] }) => ( > {isNotPresent(entry) ? T.translate("sponsor_reports_page.not_present_yet") - : toPlainText( + : htmlToPlainText( entry.content?.summary || entry.content?.value || entry.content?.filename || diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 3ece6aeff..3d68f7a18 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -42,7 +42,7 @@ import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import DownloadIcon from "@mui/icons-material/Download"; import { buildSectionCsvQuery } from "../../../../utils/section-csv-query"; -import { toPlainText } from "../../../../utils/reports-text"; +import { htmlToPlainText } from "../../../../utils/methods"; import { getReportsApiBaseUrl, isPositiveIntId @@ -66,7 +66,7 @@ const ContentCell = ({ row }) => { row.content?.preview_url || row.actions?.single_download_url || null; const filename = row.content?.filename || ""; // value/summary may carry HTML markup — flatten to plain text (don't render markup). - const text = toPlainText( + const text = htmlToPlainText( row.content?.value || row.content?.summary || filename ); const isImage = !!url && IMAGE_EXT.test(filename || url); diff --git a/src/utils/__tests__/reports-text.test.js b/src/utils/__tests__/reports-text.test.js deleted file mode 100644 index 7a5dfe131..000000000 --- a/src/utils/__tests__/reports-text.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { toPlainText } from "../reports-text"; - -describe("toPlainText", () => { - it("returns empty string for null/undefined", () => { - expect(toPlainText(null)).toBe(""); - expect(toPlainText(undefined)).toBe(""); - }); - - it("strips tags and collapses whitespace", () => { - expect(toPlainText("

Hello

world")).toBe("Hello world"); - }); - - it("decodes common lowercase entities", () => { - expect(toPlainText("a & b <tag>  x")).toBe("a & b x"); - expect(toPlainText("it's")).toBe("it's"); - }); - - it("decodes uppercase / mixed-case entities (case-insensitive)", () => { - expect(toPlainText("a & b")).toBe("a & b"); - expect(toPlainText("x&NBSP;y")).toBe("x y"); - expect(toPlainText("<tag>")).toBe(""); - }); - - it("leaves unknown entities untouched", () => { - expect(toPlainText("5 ° &Copy;")).toBe("5 ° &Copy;"); - }); -}); diff --git a/src/utils/reports-text.js b/src/utils/reports-text.js deleted file mode 100644 index 3ae74b5a4..000000000 --- a/src/utils/reports-text.js +++ /dev/null @@ -1,29 +0,0 @@ -// Sponsor-portal text fields (content.value / content.summary) may carry HTML -// markup (e.g. "

...

"). The reports render them as plain text, so strip -// tags and decode the common entities rather than show raw markup. We do NOT -// render the HTML (would be an XSS surface); we flatten it to readable text. -const ENTITIES = { - "&": "&", - "<": "<", - ">": ">", - """: "\"", - "'": "'", - "'": "'", - " ": " " -}; - -export const toPlainText = (html) => { - if (html == null) return ""; - return String(html) - .replace(/<[^>]*>/g, " ") // tags become whitespace so boundaries don't fuse words - .replace(/&[a-z]+;|&#\d+;/gi, (m) => { - // Match is case-insensitive (e.g. "&") but the map keys are lowercase; - // normalize for lookup, and return the original token on a miss. - const key = m.toLowerCase(); - return key in ENTITIES ? ENTITIES[key] : m; - }) - .replace(/\s+/g, " ") - .trim(); -}; - -export default toPlainText; From a0310a18563a5befddab15b765edb14a17c4dc22 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 14:47:18 -0500 Subject: [PATCH 30/63] refactor(sponsor-reports): purchase-details CSV export via action thunks Move orders/lines CSV URL+params+filename building into exportPurchaseDetailsCsv/ exportPurchaseDetailsLinesCsv (cf. exportEventRsvpsCSV); page dispatches them from a plain MUI Button. Orders CSV keeps the on-screen sort. base() now reads window.SPONSOR_REPORTS_API_URL directly (drops getReportsApiBaseUrl import). currentSummit removed from component props/mapStateToProps (no longer needed after CSV URL building moved to the thunks). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 71 ++++++++++++++++- src/actions/sponsor-reports-actions.js | 52 ++++++++++++- .../__tests__/index.test.js | 78 ++++++------------- .../purchase-details-report-page/index.js | 65 +++++----------- 4 files changed, 164 insertions(+), 102 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index ae3f154f7..9ae2ea98b 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -1,7 +1,10 @@ import configureStore from "redux-mock-store"; import thunk from "redux-thunk"; import flushPromises from "flush-promises"; -import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { + getRequest, + getCSV +} from "openstack-uicore-foundation/lib/utils/actions"; import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; import * as methods from "../../utils/methods"; import { makeReadErrorHandler } from "../../utils/report-errors"; @@ -12,6 +15,8 @@ import { getSponsorAssetReport, getSponsorAssetFilters, getSponsorAssetSponsor, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv, REQUEST_PURCHASE_DETAILS, RECEIVE_PURCHASE_DETAILS, RECEIVE_PURCHASE_DETAILS_FILTERS, @@ -29,7 +34,8 @@ import { jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ __esModule: true, ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), - getRequest: jest.fn() + getRequest: jest.fn(), + getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) })); jest.mock("openstack-uicore-foundation/lib/security/methods", () => ({ @@ -91,6 +97,7 @@ describe("sponsor-reports-actions", () => { beforeEach(() => { jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); getRequest.mockClear(); + getCSV.mockClear(); doLogin.mockClear(); capturedUrl = null; capturedParams = null; @@ -401,6 +408,66 @@ describe("sponsor-reports-actions", () => { }); }); + // ─── exportPurchaseDetailsCsv / exportPurchaseDetailsLinesCsv ─────────────── + + describe("exportPurchaseDetailsCsv / exportPurchaseDetailsLinesCsv", () => { + let dispatch; + let getState; + + beforeEach(() => { + jest + .spyOn(methods, "getAccessTokenSafely") + .mockResolvedValue("test-token"); + getCSV.mockClear(); + dispatch = jest.fn(); + getState = () => ({ currentSummitState: { currentSummit: { id: 42 } } }); + window.SPONSOR_REPORTS_API_URL = "http://test-api"; + }); + + it("exportPurchaseDetailsCsv → getCSV with orders URL, sort, expanded dates, no pagination", async () => { + await exportPurchaseDetailsCsv( + { dateFrom: "2026-01-01", dateTo: "2026-01-31" }, + "order_date", + -1 + )(dispatch, getState); + const [url, params, filename] = getCSV.mock.calls[0]; + expect(url).toBe( + "http://test-api/api/v1/summits/42/reports/purchase-details/csv" + ); + expect(params).toMatchObject({ + access_token: "test-token", + order: "-order_date" + }); + expect(params["filter[]"]).toEqual( + expect.arrayContaining([ + "order_date>=2026-01-01T00:00:00Z", + "order_date<2026-02-01T00:00:00Z" + ]) + ); + expect(params).not.toHaveProperty("page"); + expect(params).not.toHaveProperty("per_page"); + expect(filename).toBe("purchase-details-summit-42.csv"); + }); + + it("exportPurchaseDetailsCsv encodes ascending sort too", async () => { + await exportPurchaseDetailsCsv({}, "number", 1)(dispatch, getState); + expect(getCSV.mock.calls[0][1].order).toBe("number"); + }); + + it("exportPurchaseDetailsLinesCsv → lines URL, no order, lines filename", async () => { + await exportPurchaseDetailsLinesCsv({ status: "Paid" })( + dispatch, + getState + ); + const [url, params, filename] = getCSV.mock.calls[0]; + expect(url).toBe( + "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv" + ); + expect(params).not.toHaveProperty("order"); + expect(filename).toBe("purchase-details-lines-summit-42.csv"); + }); + }); + // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── describe("makeReadErrorHandler", () => { diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index d6ff84505..4fd618003 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -1,12 +1,16 @@ import { createAction, getRequest, + getCSV, startLoading, stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; import { getAccessTokenSafely } from "../utils/methods"; -import { getReportsApiBaseUrl } from "../utils/reports-api"; import { makeReadErrorHandler } from "../utils/report-errors"; +import { + buildPurchaseQuery, + buildPurchaseLinesQuery +} from "../pages/sponsors/sponsor-reports/report-query"; export const REQUEST_PURCHASE_DETAILS = "REQUEST_PURCHASE_DETAILS"; export const RECEIVE_PURCHASE_DETAILS = "RECEIVE_PURCHASE_DETAILS"; @@ -34,7 +38,7 @@ export const PURCHASE_DETAILS_LINES_READ_ERROR = // Base URL helper — scoped to a specific summit's reports endpoint. const base = (summitId) => - `${getReportsApiBaseUrl()}/api/v1/summits/${summitId}/reports`; + `${window.SPONSOR_REPORTS_API_URL}/api/v1/summits/${summitId}/reports`; export const getPurchaseDetailsReport = (query = {}) => @@ -156,6 +160,50 @@ export const getSponsorAssetFilters = () => async (dispatch, getState) => { .finally(() => dispatch(stopLoading())); }; +// Orders CSV export — owns URL + params + filename (cf. exportEventRsvpsCSV). +// Keeps the on-screen sort so the exported rows match what the user sees. +// No page/perPage → buildPurchaseQuery emits neither; backend exports the full +// filtered set. +export const exportPurchaseDetailsCsv = + (filters = {}, order, orderDir) => + async (dispatch, getState) => { + const { currentSummit } = getState().currentSummitState; + if (!currentSummit?.id) return undefined; + const accessToken = await getAccessTokenSafely(); + const params = { + access_token: accessToken, + ...buildPurchaseQuery(filters, { order, orderDir }) + }; + return dispatch( + getCSV( + `${base(currentSummit.id)}/purchase-details/csv`, + params, + `purchase-details-summit-${currentSummit.id}.csv` + ) + ); + }; + +// Per-line CSV export — no order param (backend default ordering keeps sponsor +// groups intact; see lines query comment in the page). +export const exportPurchaseDetailsLinesCsv = + (filters = {}) => + async (dispatch, getState) => { + const { currentSummit } = getState().currentSummitState; + if (!currentSummit?.id) return undefined; + const accessToken = await getAccessTokenSafely(); + const params = { + access_token: accessToken, + ...buildPurchaseLinesQuery(filters, {}) + }; + return dispatch( + getCSV( + `${base(currentSummit.id)}/purchase-details/lines/csv`, + params, + `purchase-details-lines-summit-${currentSummit.id}.csv` + ) + ); + }; + export const getSponsorAssetSponsor = (sponsorId) => async (dispatch, getState) => { const { currentSummitState } = getState(); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index 71e8ba3e4..ce092e239 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -14,6 +14,8 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ // Action creators: jest.fn() inside the factory to avoid hoisting issues. // Import the mocked functions below to assert on .mock.calls. +// Export thunks return a plain object so redux-mock-store does not reject the +// dispatched value (a bare jest.fn() returns undefined which the store rejects). jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getPurchaseDetailsReport: jest.fn(() => ({ type: "REQUEST_PURCHASE_DETAILS" @@ -27,34 +29,23 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ clearPurchaseDetailsValidation: jest.fn(() => ({ type: "PURCHASE_DETAILS_VALIDATION_CLEAR" })), + exportPurchaseDetailsCsv: jest.fn(() => ({ type: "EXPORT_PD_CSV" })), + exportPurchaseDetailsLinesCsv: jest.fn(() => ({ + type: "EXPORT_PD_LINES_CSV" + })), PURCHASE_DETAILS_VALIDATION_CLEAR: "PURCHASE_DETAILS_VALIDATION_CLEAR", PURCHASE_DETAILS_READ_ERROR: "PURCHASE_DETAILS_READ_ERROR" })); // Access the jest.fn() references from the mock (standard jest pattern). -const { getCSV } = require("openstack-uicore-foundation/lib/utils/actions"); const { getPurchaseDetailsReport, getPurchaseDetailsFilters, - getPurchaseDetailsLinesReport + getPurchaseDetailsLinesReport, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv } = require("../../../../../actions/sponsor-reports-actions"); -// Mock the API base-url helper so the CSV URL can be constructed in tests. -jest.mock("../../../../../utils/reports-api", () => ({ - getReportsApiBaseUrl: () => "http://test-api" -})); - -// Mock getCSV used by ExportCsvButton (via connect dispatch). -jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ - ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), - getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) -})); - -// Mock getAccessTokenSafely so ExportCsvButton clicks don't fail in tests. -jest.mock("../../../../../utils/methods", () => ({ - getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) -})); - // ──────────────────────────────────────────────────────────────────────────── // Test fixtures // ──────────────────────────────────────────────────────────────────────────── @@ -288,24 +279,18 @@ describe("PurchaseDetailsReportPage", () => { expect(calledQuery).toMatchObject({ page: 1 }); }); - it("CSV export button dispatches getCSV with the summit-scoped URL, filtered query+access_token, and filename", async () => { + it("CSV export button calls exportPurchaseDetailsCsv with current filters and sort", async () => { renderPage(); await act(async () => {}); - // ExportCsvButton renders text from T.translate("sponsor_reports_page.export_csv") const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); await act(async () => { fireEvent.click(exportBtn); }); - // getCSV is dispatched with (url, { ...csvQuery, access_token }, filename). - // csvQuery drops page/per_page; with no filters it is empty → only access_token. - expect(getCSV).toHaveBeenCalledTimes(1); - expect(getCSV).toHaveBeenCalledWith( - "http://test-api/api/v1/summits/42/reports/purchase-details/csv", - { access_token: "test-token" }, - "purchase-details-summit-42.csv" - ); + // URL/params/filename correctness lives in the action tests. + // Here we assert the page dispatches the right thunk with the right args. + expect(exportPurchaseDetailsCsv).toHaveBeenCalledWith({}, null, 1); }); it("re-dispatches getPurchaseDetailsReport with the new page when MuiTable pagination changes (1-based)", async () => { @@ -381,7 +366,7 @@ describe("PurchaseDetailsReportPage", () => { ).toBeInTheDocument(); }); - it("CSV export in the Line Items view dispatches getCSV with the lines URL, pagination-stripped query, and lines filename", async () => { + it("CSV export in the Line Items view calls exportPurchaseDetailsLinesCsv with filters", async () => { renderPage(); await act(async () => {}); await act(async () => { @@ -389,24 +374,18 @@ describe("PurchaseDetailsReportPage", () => { }); // Guard: switching to the lines view must not trigger an export on its own. - // (beforeEach jest.clearAllMocks() leaves getCSV at zero calls here.) - expect(getCSV).not.toHaveBeenCalled(); + expect(exportPurchaseDetailsCsv).not.toHaveBeenCalled(); + expect(exportPurchaseDetailsLinesCsv).not.toHaveBeenCalled(); + const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); await act(async () => { fireEvent.click(exportBtn); }); - // getCSV is dispatched with (url, { ...linesCsvQuery, access_token }, filename). - // With no filters, linesCsvQuery drops page/per_page → empty → only access_token. - expect(getCSV).toHaveBeenCalledTimes(1); - expect(getCSV).toHaveBeenCalledWith( - "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv", - { access_token: "test-token" }, - "purchase-details-lines-summit-42.csv" - ); + expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith({}); }); - it("Line Items CSV export preserves applied filters and strips only pagination", async () => { + it("Line Items CSV export passes applied filters to exportPurchaseDetailsLinesCsv", async () => { renderPage(); await act(async () => {}); @@ -424,22 +403,15 @@ describe("PurchaseDetailsReportPage", () => { fireEvent.click(screen.getByText("sponsor_reports_page.view_line_items")); }); // Guard: neither Apply nor the view switch should have exported anything yet. - expect(getCSV).not.toHaveBeenCalled(); + expect(exportPurchaseDetailsLinesCsv).not.toHaveBeenCalled(); await act(async () => { fireEvent.click(screen.getByText("sponsor_reports_page.export_csv")); }); - expect(getCSV).toHaveBeenCalledTimes(1); - const [[exportedUrl, exportedQuery]] = getCSV.mock.calls; - expect(exportedUrl).toBe( - "http://test-api/api/v1/summits/42/reports/purchase-details/lines/csv" - ); - // Applied date filter survives; only pagination is stripped. - expect(exportedQuery["filter[]"]).toEqual( - expect.arrayContaining([expect.stringContaining("order_date>=")]) - ); - expect(exportedQuery).not.toHaveProperty("page"); - expect(exportedQuery).not.toHaveProperty("per_page"); - expect(exportedQuery.access_token).toBe("test-token"); + // The thunk receives the live filters object; URL/params correctness lives in + // the action tests (expandDates, filter[] assembly, etc.). + expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith({ + dateFrom: "2026-01-01" + }); }); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 5be16ed63..6a43d6101 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -24,23 +24,24 @@ import { TextField } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; +import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; import { formatUsd } from "../../../../utils/reports-money"; import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; -import { getReportsApiBaseUrl } from "../../../../utils/reports-api"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; import OrdersTable from "../../../../components/sponsors/reports/OrdersTable"; import LinesManifestView from "../../../../components/sponsors/reports/LinesManifestView"; import ReportViewToggle from "../../../../components/sponsors/reports/ReportViewToggle"; -import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import usePrint from "../../../../hooks/usePrint"; import { getPurchaseDetailsReport, getPurchaseDetailsLinesReport, getPurchaseDetailsFilters, - clearPurchaseDetailsValidation + clearPurchaseDetailsValidation, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv } from "../../../../actions/sponsor-reports-actions"; import { DEFAULT_PER_PAGE } from "../../../../utils/constants"; @@ -49,7 +50,6 @@ const TOAST_AUTO_HIDE_MS = 6000; const PurchaseDetailsReportPage = ({ // From mapStateToProps - currentSummit, data, summary, filterOptions, @@ -65,7 +65,9 @@ const PurchaseDetailsReportPage = ({ getPurchaseDetailsReport: fetchReport, getPurchaseDetailsLinesReport: fetchLinesReport, getPurchaseDetailsFilters: fetchFilters, - clearPurchaseDetailsValidation: clearValidation + clearPurchaseDetailsValidation: clearValidation, + exportPurchaseDetailsCsv: exportOrdersCsv, + exportPurchaseDetailsLinesCsv: exportLinesCsv }) => { const print = usePrint(); @@ -162,31 +164,6 @@ const PurchaseDetailsReportPage = ({ ] : []; - // ── CSV export ────────────────────────────────────────────────────────────── - const csvUrl = currentSummit - ? `${getReportsApiBaseUrl()}/api/v1/summits/${ - currentSummit.id - }/reports/purchase-details/csv` - : ""; - const csvQuery = useMemo(() => { - // Drop pagination params from the CSV query — exports the full filtered set. - const { page: _page, per_page: _perPage, ...rest } = query; - return rest; - }, [query]); - - const linesCsvUrl = currentSummit - ? `${getReportsApiBaseUrl()}/api/v1/summits/${ - currentSummit.id - }/reports/purchase-details/lines/csv` - : ""; - const linesCsvQuery = useMemo(() => { - // Strip ONLY pagination — exports the full filtered set (backend caps at - // CSV_MAX_ROWS). Everything else buildReportQuery emitted is preserved, - // including the derived include_cancelled="true" when status is "Canceled". - const { page: _page, per_page: _perPage, ...rest } = linesQuery; - return rest; - }, [linesQuery]); - // ── FilterBar handlers ────────────────────────────────────────────────────── // Applying/clearing a filter changes the result set → snap back to page 1. const handleApply = (next) => { @@ -290,19 +267,17 @@ const PurchaseDetailsReportPage = ({ - } + variant="outlined" + onClick={() => view === "orders" - ? `purchase-details-summit-${ - currentSummit?.id ?? "unknown" - }.csv` - : `purchase-details-lines-summit-${ - currentSummit?.id ?? "unknown" - }.csv` + ? exportOrdersCsv(filters, order, orderDir) + : exportLinesCsv(filters) } - /> + > + {T.translate("sponsor_reports_page.export_csv")} + } filterBar={ @@ -362,10 +337,8 @@ const PurchaseDetailsReportPage = ({ const mapStateToProps = ({ sponsorReportsPurchaseDetailsState, - sponsorReportsPurchaseDetailsLinesState, - currentSummitState + sponsorReportsPurchaseDetailsLinesState }) => ({ - currentSummit: currentSummitState.currentSummit, ...sponsorReportsPurchaseDetailsState, linesData: sponsorReportsPurchaseDetailsLinesState.data, linesSummary: sponsorReportsPurchaseDetailsLinesState.summary, @@ -377,7 +350,9 @@ const mapDispatchToProps = { getPurchaseDetailsReport, getPurchaseDetailsLinesReport, getPurchaseDetailsFilters, - clearPurchaseDetailsValidation + clearPurchaseDetailsValidation, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv }; export default withRouter( From 306ce261c81eb4e3fc0fc88674c309c2936d04e2 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 14:59:42 -0500 Subject: [PATCH 31/63] refactor(sponsor-reports): sponsor-asset CSV export via action thunks; drop ExportCsvButton Add exportSponsorAssetCsv/exportSponsorAssetSectionCsv (section logic folded in, simplified); pages dispatch from plain buttons. Remove the ported ExportCsvButton and section-csv-query util. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sponsor-reports-actions.test.js | 54 +++++++++++ src/actions/sponsor-reports-actions.js | 47 ++++++++++ .../sponsors/reports/ExportCsvButton.js | 61 ------------- .../reports/__tests__/ExportCsvButton.test.js | 90 ------------------- .../__tests__/index.test.js | 45 +++++++--- .../sponsor-asset-drilldown-page/index.js | 48 ++++------ .../__tests__/index.test.js | 45 +++++----- .../sponsor-asset-report-page/index.js | 45 +++------- src/utils/section-csv-query.js | 38 -------- 9 files changed, 191 insertions(+), 282 deletions(-) delete mode 100644 src/components/sponsors/reports/ExportCsvButton.js delete mode 100644 src/components/sponsors/reports/__tests__/ExportCsvButton.test.js delete mode 100644 src/utils/section-csv-query.js diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 9ae2ea98b..1409f6ee8 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -17,6 +17,8 @@ import { getSponsorAssetSponsor, exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv, + exportSponsorAssetCsv, + exportSponsorAssetSectionCsv, REQUEST_PURCHASE_DETAILS, RECEIVE_PURCHASE_DETAILS, RECEIVE_PURCHASE_DETAILS_FILTERS, @@ -468,6 +470,58 @@ describe("sponsor-reports-actions", () => { }); }); + // ─── exportSponsorAssetCsv / exportSponsorAssetSectionCsv ─────────────────── + + describe("exportSponsorAssetCsv / exportSponsorAssetSectionCsv", () => { + let dispatch; + let getState; + + beforeEach(() => { + jest + .spyOn(methods, "getAccessTokenSafely") + .mockResolvedValue("test-token"); + getCSV.mockClear(); + dispatch = jest.fn(); + getState = () => ({ + currentSummitState: { currentSummit: { id: 42 } } + }); + window.SPONSOR_REPORTS_API_URL = "http://test-api"; + }); + + it("exportSponsorAssetCsv → assets URL, keeps filters, strips group_by/order/pagination", async () => { + // Pass an input that buildReportQuery WOULD emit grouping/pagination/order for, + // to actually exercise the strip (the page only ever passes flat filters, but the + // thunk's contract is a flat export regardless). + await exportSponsorAssetCsv({ + sponsorIds: [17], + groupBy: "component", + page: 2, + perPage: 25, + order: "status" + })(dispatch, getState); + const [url, params, filename] = getCSV.mock.calls[0]; + expect(url).toBe( + "http://test-api/api/v1/summits/42/reports/sponsor-assets/csv" + ); + expect(params["filter[]"]).toEqual(["sponsor_id==17"]); // filter survives + expect(params).not.toHaveProperty("group_by"); + expect(params).not.toHaveProperty("order"); + expect(params).not.toHaveProperty("page"); + expect(params).not.toHaveProperty("per_page"); + expect(filename).toBe("sponsor-assets-summit-42.csv"); + }); + + it("exportSponsorAssetSectionCsv → only sponsor_id/page_id filters + filename", async () => { + await exportSponsorAssetSectionCsv("17", "3")(dispatch, getState); + const [url, params, filename] = getCSV.mock.calls[0]; + expect(url).toBe( + "http://test-api/api/v1/summits/42/reports/sponsor-assets/csv" + ); + expect(params["filter[]"]).toEqual(["sponsor_id==17", "page_id==3"]); + expect(filename).toBe("sponsor-17-page-3.csv"); + }); + }); + // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── describe("makeReadErrorHandler", () => { diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 4fd618003..314ee740f 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -8,6 +8,7 @@ import { import { getAccessTokenSafely } from "../utils/methods"; import { makeReadErrorHandler } from "../utils/report-errors"; import { + buildReportQuery, buildPurchaseQuery, buildPurchaseLinesQuery } from "../pages/sponsors/sponsor-reports/report-query"; @@ -229,3 +230,49 @@ export const getSponsorAssetSponsor = .catch(() => {}) .finally(() => dispatch(stopLoading())); }; + +// Sponsor-asset CSV — flat export: drop grouping/order/pagination so the export +// matches the active filters but not the grouped/paged view. +export const exportSponsorAssetCsv = + (filters = {}) => + async (dispatch, getState) => { + const { currentSummit } = getState().currentSummitState; + if (!currentSummit?.id) return undefined; + const accessToken = await getAccessTokenSafely(); + const { + group_by: _g, + order: _o, + page: _p, + per_page: _pp, + ...rest + } = buildReportQuery(filters); + return dispatch( + getCSV( + `${base(currentSummit.id)}/sponsor-assets/csv`, + { access_token: accessToken, ...rest }, + `sponsor-assets-summit-${currentSummit.id}.csv` + ) + ); + }; + +// Single sponsor+page section export. Integer-guard both ids (defense-in-depth; +// the drilldown route validates :sponsorId before render). No base query — the +// drilldown always exported a section with no other active filters. +export const exportSponsorAssetSectionCsv = + (sponsorId, pageId) => async (dispatch, getState) => { + const { currentSummit } = getState().currentSummitState; + if (!currentSummit?.id) return undefined; + const accessToken = await getAccessTokenSafely(); + const filter = []; + const sid = Number(sponsorId); + const pid = Number(pageId); + if (Number.isInteger(sid)) filter.push(`sponsor_id==${sid}`); + if (Number.isInteger(pid)) filter.push(`page_id==${pid}`); + return dispatch( + getCSV( + `${base(currentSummit.id)}/sponsor-assets/csv`, + { access_token: accessToken, "filter[]": filter }, + `sponsor-${sponsorId}-page-${pageId}.csv` + ) + ); + }; diff --git a/src/components/sponsors/reports/ExportCsvButton.js b/src/components/sponsors/reports/ExportCsvButton.js deleted file mode 100644 index a92d73d1d..000000000 --- a/src/components/sponsors/reports/ExportCsvButton.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useState, useRef } from "react"; -import { connect } from "react-redux"; -import { Button } from "@mui/material"; -import DownloadIcon from "@mui/icons-material/Download"; -import T from "i18n-react/dist/i18n-react"; -import { getCSV } from "openstack-uicore-foundation/lib/utils/actions"; -import { getAccessTokenSafely } from "../../../utils/methods"; - -// D8: fire-and-forget via uicore getCSV (fetchErrorHandler → sweetalert for -// 401/403/412/500). Bespoke CSV error classification from sponsor-services is -// intentionally dropped — generic error handling is convention-aligned and -// sufficient for admin-only access. -// -// Props: { url, query, filename, disabled, label } -// query — already in uicore params shape (e.g. { "filter[]": [...] }); -// access_token is appended here and serialized by uicore's URIjs. -// label — overrides the default i18n label when provided. -// -// Synchronous in-flight ref guard: setBusy(true) only disables the button -// after a re-render; two rapid clicks in the same tick can both enter -// handleClick before the re-render fires. The ref flips synchronously and -// blocks the second click before the first await completes. -const ExportCsvButton = ({ - url, - query = {}, - filename, - disabled = false, - label, - dispatch -}) => { - const [busy, setBusy] = useState(false); - const inFlight = useRef(false); - - const handleClick = async () => { - if (inFlight.current) return; - inFlight.current = true; - setBusy(true); - try { - const token = await getAccessTokenSafely(); - dispatch(getCSV(url, { ...query, access_token: token }, filename)); - } finally { - inFlight.current = false; - setBusy(false); - } - }; - - return ( - - ); -}; - -// connect() with no args injects raw `dispatch` as a prop — cleanest form for -// a component that needs dispatch but reads nothing from state. -export default connect()(ExportCsvButton); diff --git a/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js b/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js deleted file mode 100644 index 5c34584de..000000000 --- a/src/components/sponsors/reports/__tests__/ExportCsvButton.test.js +++ /dev/null @@ -1,90 +0,0 @@ -// src/components/sponsors/reports/__tests__/ExportCsvButton.test.js -// D8: ExportCsvButton uses uicore getCSV (fire-and-forget, generic error -// handling). Tests assert dispatch args; bespoke error-classification tests -// from the source are intentionally absent. -import "@testing-library/jest-dom"; -import React from "react"; -import { screen, fireEvent, waitFor, act } from "@testing-library/react"; -import { renderWithRedux } from "utils/test-utils"; -import { getCSV } from "openstack-uicore-foundation/lib/utils/actions"; -import ExportCsvButton from "../ExportCsvButton"; -import { getAccessTokenSafely } from "../../../../utils/methods"; - -jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); - -jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ - getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) -})); - -jest.mock("../../../../utils/methods", () => ({ - getAccessTokenSafely: jest.fn(() => Promise.resolve("test-token")) -})); - -describe("ExportCsvButton", () => { - afterEach(() => jest.clearAllMocks()); - - it("dispatches getCSV with url, query+access_token, and filename on click", async () => { - const { store } = renderWithRedux( - - ); - fireEvent.click(screen.getByRole("button", { name: /export/i })); - await waitFor(() => { - expect(getCSV).toHaveBeenCalledWith( - "https://reports-api.test/api/v1/summits/1/reports/purchase-details/csv", - { "filter[]": ["sponsor_id==17"], access_token: "test-token" }, - "purchase-details.csv" - ); - expect(store.dispatch).toHaveBeenCalled(); - }); - }); - - it("is disabled when disabled prop is true", () => { - renderWithRedux( - - ); - expect(screen.getByRole("button", { name: /export/i })).toBeDisabled(); - }); - - it("ignores a second click while the first is in flight (synchronous ref guard)", async () => { - let resolveToken; - getAccessTokenSafely.mockImplementationOnce( - () => - new Promise((res) => { - resolveToken = res; - }) - ); - renderWithRedux(); - const btn = screen.getByRole("button", { name: /export/i }); - // Two native click events in the same tick — the synchronous useRef guard - // must block the second before the first await resolves. - await act(async () => { - btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); - btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - resolveToken("token"); - await waitFor(() => { - expect(getCSV).toHaveBeenCalledTimes(1); - }); - }); - - it("uses the label prop when provided, otherwise falls back to i18n key", () => { - const { rerender } = renderWithRedux( - - ); - expect( - screen.getByRole("button", { name: "Download" }) - ).toBeInTheDocument(); - - rerender(); - // With the echo mock, T.translate("sponsor_reports_page.export_csv") → key - expect( - screen.getByRole("button", { - name: "sponsor_reports_page.export_csv" - }) - ).toBeInTheDocument(); - }); -}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index 1a81df3a0..c406c7125 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -15,7 +15,7 @@ import "@testing-library/jest-dom"; import React from "react"; -import { act, screen } from "@testing-library/react"; +import { act, screen, fireEvent } from "@testing-library/react"; import { Router, Route } from "react-router-dom"; import { createMemoryHistory } from "history"; import { renderWithRedux } from "utils/test-utils"; @@ -26,29 +26,22 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); -// Stub ExportCsvButton to avoid real CSV logic in tests. -jest.mock("../../../../../components/sponsors/reports/ExportCsvButton", () => ({ - __esModule: true, - default: ({ label, disabled }) => ( - - ) -})); - jest.mock("../../../../../utils/reports-api", () => ({ - getReportsApiBaseUrl: () => "http://test-api", isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") .isPositiveIntId })); jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getSponsorAssetSponsor: jest.fn(() => ({ type: "GET_DRILLDOWN" })), + exportSponsorAssetSectionCsv: jest.fn(() => ({ + type: "EXPORT_SA_SECTION_CSV" + })), SPONSOR_DRILLDOWN_READ_ERROR: "SPONSOR_DRILLDOWN_READ_ERROR" })); const { - getSponsorAssetSponsor + getSponsorAssetSponsor, + exportSponsorAssetSectionCsv } = require("../../../../../actions/sponsor-reports-actions"); const PAGE_ROUTE = @@ -135,6 +128,32 @@ describe("SponsorAssetDrilldownPage", () => { expect(screen.getByText("Logo")).toBeInTheDocument(); }); + it("renders the section download button and dispatches exportSponsorAssetSectionCsv on click", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [] + } + ] + } + }); + await act(async () => {}); + exportSponsorAssetSectionCsv.mockClear(); + + const downloadBtn = screen.getByRole("button", { + name: /sponsor_reports_page\.download_csv/ + }); + expect(downloadBtn).not.toBeDisabled(); + fireEvent.click(downloadBtn); + await act(async () => {}); + + // sponsorId from URL ("17"), pageId from section.page.id (9) + expect(exportSponsorAssetSectionCsv).toHaveBeenCalledWith("17", 9); + }); + it("renders the navy header with tier badge", async () => { renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { detail: { diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 3d68f7a18..0d4a245ea 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -41,19 +41,17 @@ import PrintIcon from "@mui/icons-material/Print"; import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import DownloadIcon from "@mui/icons-material/Download"; -import { buildSectionCsvQuery } from "../../../../utils/section-csv-query"; import { htmlToPlainText } from "../../../../utils/methods"; -import { - getReportsApiBaseUrl, - isPositiveIntId -} from "../../../../utils/reports-api"; +import { isPositiveIntId } from "../../../../utils/reports-api"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import usePrint from "../../../../hooks/usePrint"; -import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; import StatusPill from "../../../../components/sponsors/reports/StatusPill"; import SponsorAvatar from "../../../../components/sponsors/reports/SponsorAvatar"; -import { getSponsorAssetSponsor } from "../../../../actions/sponsor-reports-actions"; +import { + exportSponsorAssetSectionCsv, + getSponsorAssetSponsor +} from "../../../../actions/sponsor-reports-actions"; // Gate the on an image file extension; render every other file as a // download link (a PDF url in an would show a broken image). @@ -136,12 +134,12 @@ const ContentCell = ({ row }) => { const SponsorAssetDrilldownPage = ({ // From mapStateToProps - currentSummit, detail, loading, readError, // From mapDispatchToProps getSponsorAssetSponsor: fetchSponsor, + exportSponsorAssetSectionCsv, // From withRouter match }) => { @@ -159,12 +157,6 @@ const SponsorAssetDrilldownPage = ({ if (validParams) fetchSponsor(sponsorId); }, [sponsorId, validParams]); // fetchSponsor is stable from connect — no dep needed - const csvBase = currentSummit - ? `${getReportsApiBaseUrl()}/api/v1/summits/${ - currentSummit.id - }/reports/sponsor-assets/csv` - : ""; - if (!validParams || readError?.kind === "not-found") { return ( {section.page.title}
- + {section.modules?.map((row) => ( @@ -332,17 +324,15 @@ const SponsorAssetDrilldownPage = ({ ); }; -const mapStateToProps = ({ - sponsorReportsDrilldownState, - currentSummitState -}) => ({ - currentSummit: currentSummitState.currentSummit, +const mapStateToProps = ({ sponsorReportsDrilldownState }) => ({ ...sponsorReportsDrilldownState }); const mapDispatchToProps = (dispatch) => ({ getSponsorAssetSponsor: (sponsorId) => - dispatch(getSponsorAssetSponsor(sponsorId)) + dispatch(getSponsorAssetSponsor(sponsorId)), + exportSponsorAssetSectionCsv: (sponsorId, pageId) => + dispatch(exportSponsorAssetSectionCsv(sponsorId, pageId)) }); export default withRouter( diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index 79e061c9f..f8e64b034 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -31,27 +31,11 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getSponsorAssetFilters: jest.fn(() => ({ type: "GET_SA_FILTERS" })), getSponsorAssetReport: jest.fn(() => ({ type: "GET_SA_REPORT" })), + exportSponsorAssetCsv: jest.fn(() => ({ type: "EXPORT_SA_CSV" })), SPONSOR_ASSET_READ_ERROR: "SPONSOR_ASSET_READ_ERROR" })); -// Stub ExportCsvButton so tests can inspect the `query` prop without triggering -// a real CSV fetch. -jest.mock("../../../../../components/sponsors/reports/ExportCsvButton", () => ({ - __esModule: true, - default: ({ query, disabled, label }) => ( - - ) -})); - jest.mock("../../../../../utils/reports-api", () => ({ - getReportsApiBaseUrl: () => "http://test-api", isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") .isPositiveIntId })); @@ -59,7 +43,8 @@ jest.mock("../../../../../utils/reports-api", () => ({ // Require after mocks so the jest.fn() references are the mocked ones. const { getSponsorAssetFilters, - getSponsorAssetReport + getSponsorAssetReport, + exportSponsorAssetCsv } = require("../../../../../actions/sponsor-reports-actions"); const PAGE_ROUTE = "/app/summits/:summit_id/sponsors/reports/sponsor-assets"; @@ -230,10 +215,30 @@ describe("SponsorAssetReportPage", () => { expect(getSponsorAssetReport).not.toHaveBeenCalled(); }); - it("renders the ExportCsvButton (enabled by default)", async () => { + it("renders the export button (enabled by default)", async () => { + renderPage(); + await act(async () => {}); + expect( + screen.getByRole("button", { + name: /sponsor_reports_page\.export_csv/ + }) + ).not.toBeDisabled(); + }); + + it("dispatches exportSponsorAssetCsv with current filters on export button click", async () => { renderPage(); await act(async () => {}); - expect(screen.getByTestId("export-csv")).not.toBeDisabled(); + exportSponsorAssetCsv.mockClear(); + + fireEvent.click( + screen.getByRole("button", { + name: /sponsor_reports_page\.export_csv/ + }) + ); + await act(async () => {}); + + // Initial filters state is {} — the thunk is called with those filters. + expect(exportSponsorAssetCsv).toHaveBeenCalledWith({}); }); it("hides the no-groups empty state until currentPage >= 1", async () => { diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index a40bb1838..0d946cf71 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -17,21 +17,19 @@ import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; import { Box, Button, Pagination, Stack, Typography } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; +import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; import { buildReportQuery } from "../report-query"; -import { - getReportsApiBaseUrl, - isPositiveIntId -} from "../../../../utils/reports-api"; +import { isPositiveIntId } from "../../../../utils/reports-api"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle"; import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; -import ExportCsvButton from "../../../../components/sponsors/reports/ExportCsvButton"; import usePrint from "../../../../hooks/usePrint"; import { + exportSponsorAssetCsv, getSponsorAssetFilters, getSponsorAssetReport } from "../../../../actions/sponsor-reports-actions"; @@ -63,7 +61,8 @@ const SponsorAssetReportPage = ({ readError, // From mapDispatchToProps getSponsorAssetReport: fetchReport, - getSponsorAssetFilters: fetchFilters + getSponsorAssetFilters: fetchFilters, + exportSponsorAssetCsv }) => { const print = usePrint(); @@ -101,25 +100,6 @@ const SponsorAssetReportPage = ({ if (validSummit) fetchReport(query); }, [query]); // query is memoized; re-fetches only on real changes - // CSV export uses the flat row export path: strip group_by/page/per_page/order - // so the export matches the active filters but ignores grouping & pagination. - const csvQuery = useMemo(() => { - const { - group_by: _groupBy, - page: _page, - per_page: _perPage, - order: _order, - ...rest - } = query; - return rest; - }, [query]); - - const csvUrl = currentSummit - ? `${getReportsApiBaseUrl()}/api/v1/summits/${ - currentSummit.id - }/reports/sponsor-assets/csv` - : ""; - const onApply = (next) => { setPage(FIRST_PAGE); setFilters(next); @@ -168,11 +148,13 @@ const SponsorAssetReportPage = ({ - + } > @@ -257,7 +239,8 @@ const mapStateToProps = ({ const mapDispatchToProps = (dispatch) => ({ getSponsorAssetReport: (query) => dispatch(getSponsorAssetReport(query)), - getSponsorAssetFilters: () => dispatch(getSponsorAssetFilters()) + getSponsorAssetFilters: () => dispatch(getSponsorAssetFilters()), + exportSponsorAssetCsv: (filters) => dispatch(exportSponsorAssetCsv(filters)) }); export default withRouter( diff --git a/src/utils/section-csv-query.js b/src/utils/section-csv-query.js deleted file mode 100644 index 965cee5f8..000000000 --- a/src/utils/section-csv-query.js +++ /dev/null @@ -1,38 +0,0 @@ -// Build a CSV query scoped to one sponsor+page section. Replaces any existing -// sponsor_id / page_id clauses (a second same-field filter[] ANDs to empty), -// preserving every unrelated filter and non-filter param. Parses each comma-OR -// bracket STRUCTURALLY (per clause) so a co-located unrelated clause survives. -// -// Caveat: if an active bracket were a true mixed OR like `status==Paid,sponsor_id==17`, -// stripping the sponsor clause and re-emitting `status==Paid` as its own bracket turns -// OR into AND. The v1 query builder never emits mixed brackets (sponsor is always its -// own bracket), so this is a defensive edge, not a live path. -export const buildSectionCsvQuery = ( - activeQuery = {}, - { sponsorId, pageId } -) => { - const { - "filter[]": existing = [], - page: _page, - per_page: _perPage, - ...rest - } = activeQuery; - const brackets = Array.isArray(existing) ? existing : [existing]; - const kept = []; - for (const bracket of brackets) { - const clauses = String(bracket) - .split(",") - .filter( - (c) => c && !/^sponsor_id[<>=!]/.test(c) && !/^page_id[<>=!]/.test(c) - ); - if (clauses.length) kept.push(clauses.join(",")); - } - const sid = Number(sponsorId); - const pid = Number(pageId); - // Defense-in-depth: callers pass route/backend integer ids (the drill-down page - // validates :sponsorId before rendering). Only interpolate sponsor_id/page_id - // into a filter clause when each coerces to an integer; non-integers are dropped. - if (Number.isInteger(sid)) kept.push(`sponsor_id==${sid}`); - if (Number.isInteger(pid)) kept.push(`page_id==${pid}`); - return { ...rest, "filter[]": kept }; -}; From 6757a71d53748e2950906aa358f3046fd30802b4 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 15:09:57 -0500 Subject: [PATCH 32/63] refactor(sponsor-reports): delete reports-api util; wire isPositiveIntId from methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sponsor-asset-report-page/index.js: repoint isPositiveIntId import to utils/methods - sponsor-asset-drilldown-page/index.js: merge htmlToPlainText + isPositiveIntId into one import from utils/methods (fixes import/no-duplicates) - sponsor-asset-report-page/__tests__: remove jest.mock("reports-api") — pure fn, no mock needed - sponsor-asset-drilldown-page/__tests__: same - layouts/__tests__/sponsor-reports-layout.test.js: remove obsolete reports-api mock + comment - purchase-details-report-page/__tests__: rename stale "ExportCsvButton" test label to "export button" (authorized micro-cleanup; component deleted in Task 5) - git rm src/utils/reports-api.js — no longer consumed by any source or test - 115 suites / 966 tests passing; 0 new lint errors Co-Authored-By: Claude Opus 4.8 (1M context) --- src/layouts/__tests__/sponsor-reports-layout.test.js | 6 ------ .../purchase-details-report-page/__tests__/index.test.js | 4 ++-- .../sponsor-asset-drilldown-page/__tests__/index.test.js | 5 ----- .../sponsor-reports/sponsor-asset-drilldown-page/index.js | 3 +-- .../sponsor-asset-report-page/__tests__/index.test.js | 5 ----- .../sponsor-reports/sponsor-asset-report-page/index.js | 2 +- src/utils/reports-api.js | 7 ------- 7 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 src/utils/reports-api.js diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index cbd7d4afc..e8d0ebf4e 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -40,12 +40,6 @@ jest.mock("../../access-routes.yml", () => ({ ] })); -// Mock reports-api so child pages can build URLs without a real API host. -jest.mock("../../utils/reports-api", () => ({ - getReportsApiBaseUrl: () => "http://test-api", - isPositiveIntId: (v) => /^[1-9]\d*$/.test(String(v)) -})); - // Mock action creators used by the connected child pages. // Returns plain objects so the mock store can record them without real thunk logic. jest.mock("../../actions/sponsor-reports-actions", () => ({ diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index ce092e239..ba06e3b30 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -234,10 +234,10 @@ describe("PurchaseDetailsReportPage", () => { ).toBeInTheDocument(); }); - it("renders the ExportCsvButton", async () => { + it("renders the export button", async () => { renderPage(); await act(async () => {}); - // ExportCsvButton renders text from T.translate("sponsor_reports_page.export_csv") + // The export button renders text from T.translate("sponsor_reports_page.export_csv") // With the echo mock this becomes the key string expect( screen.getByText("sponsor_reports_page.export_csv") diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index c406c7125..35aa497f9 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -26,11 +26,6 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); -jest.mock("../../../../../utils/reports-api", () => ({ - isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") - .isPositiveIntId -})); - jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ getSponsorAssetSponsor: jest.fn(() => ({ type: "GET_DRILLDOWN" })), exportSponsorAssetSectionCsv: jest.fn(() => ({ diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 0d4a245ea..e508769ab 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -41,8 +41,7 @@ import PrintIcon from "@mui/icons-material/Print"; import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import DownloadIcon from "@mui/icons-material/Download"; -import { htmlToPlainText } from "../../../../utils/methods"; -import { isPositiveIntId } from "../../../../utils/reports-api"; +import { htmlToPlainText, isPositiveIntId } from "../../../../utils/methods"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import usePrint from "../../../../hooks/usePrint"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index f8e64b034..33a61456c 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -35,11 +35,6 @@ jest.mock("../../../../../actions/sponsor-reports-actions", () => ({ SPONSOR_ASSET_READ_ERROR: "SPONSOR_ASSET_READ_ERROR" })); -jest.mock("../../../../../utils/reports-api", () => ({ - isPositiveIntId: jest.requireActual("../../../../../utils/reports-api") - .isPositiveIntId -})); - // Require after mocks so the jest.fn() references are the mocked ones. const { getSponsorAssetFilters, diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 0d946cf71..140aaec2a 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -20,7 +20,7 @@ import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; import { buildReportQuery } from "../report-query"; -import { isPositiveIntId } from "../../../../utils/reports-api"; +import { isPositiveIntId } from "../../../../utils/methods"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; diff --git a/src/utils/reports-api.js b/src/utils/reports-api.js deleted file mode 100644 index 662457096..000000000 --- a/src/utils/reports-api.js +++ /dev/null @@ -1,7 +0,0 @@ -export const getReportsApiBaseUrl = () => window.SPONSOR_REPORTS_API_URL; - -// Strict positive-integer route-id validator. summit_id / sponsor_id arrive as -// strings from route params; accept only positive integers so a malformed or -// tampered id can't be interpolated into a filter clause, the CSV URL path, or a -// download filename. Invalid ids should render a not-found state, not fetch. -export const isPositiveIntId = (v) => /^[1-9]\d*$/.test(String(v)); From f728156db9fc2b41fd5c461cd11c5ef8b7fe8063 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 15:25:57 -0500 Subject: [PATCH 33/63] refactor(sponsor-reports): co-locate report-errors + reports-money out of shared utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files moved into src/pages/sponsors/sponsor-reports/ per §2 (feature helpers never in shared utils/); test for reports-money co-located alongside. Internal ./constants import in report-errors updated to ../../../utils/constants. formatUsd logic unchanged — uicore/cents contract deferred to PR-B. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/__tests__/sponsor-reports-actions.test.js | 2 +- src/actions/sponsor-reports-actions.js | 2 +- src/components/sponsors/reports/LinesManifestView.js | 2 +- src/components/sponsors/reports/OrdersTable.js | 2 +- .../sponsors/sponsor-reports}/__tests__/reports-money.test.js | 0 .../sponsor-reports/purchase-details-report-page/index.js | 2 +- src/{utils => pages/sponsors/sponsor-reports}/report-errors.js | 0 src/{utils => pages/sponsors/sponsor-reports}/reports-money.js | 0 8 files changed, 5 insertions(+), 5 deletions(-) rename src/{utils => pages/sponsors/sponsor-reports}/__tests__/reports-money.test.js (100%) rename src/{utils => pages/sponsors/sponsor-reports}/report-errors.js (100%) rename src/{utils => pages/sponsors/sponsor-reports}/reports-money.js (100%) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 1409f6ee8..3d12a241d 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -7,7 +7,7 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; import * as methods from "../../utils/methods"; -import { makeReadErrorHandler } from "../../utils/report-errors"; +import { makeReadErrorHandler } from "../../pages/sponsors/sponsor-reports/report-errors"; import { getPurchaseDetailsReport, diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 314ee740f..9fa61f3d7 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -6,7 +6,7 @@ import { stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; import { getAccessTokenSafely } from "../utils/methods"; -import { makeReadErrorHandler } from "../utils/report-errors"; +import { makeReadErrorHandler } from "../pages/sponsors/sponsor-reports/report-errors"; import { buildReportQuery, buildPurchaseQuery, diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index be251fb19..77537c952 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -31,7 +31,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import T from "i18n-react/dist/i18n-react"; import StatusPill from "./StatusPill"; import { formatCheckoutTime } from "./OrdersTable"; -import { formatUsd } from "../../../utils/reports-money"; +import { formatUsd } from "../../../pages/sponsors/sponsor-reports/reports-money"; import { bucketLinesBySponsor } from "../../../utils/manifest-grouping"; // eslint-disable-next-line no-magic-numbers diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 684b23850..aad63388b 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -14,7 +14,7 @@ import React from "react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import StatusPill from "./StatusPill"; -import { formatUsd } from "../../../utils/reports-money"; +import { formatUsd } from "../../../pages/sponsors/sponsor-reports/reports-money"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" const MS_PER_SECOND = 1000; diff --git a/src/utils/__tests__/reports-money.test.js b/src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js similarity index 100% rename from src/utils/__tests__/reports-money.test.js rename to src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 6a43d6101..4743efb99 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -26,7 +26,7 @@ import { import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; -import { formatUsd } from "../../../../utils/reports-money"; +import { formatUsd } from "../reports-money"; import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; diff --git a/src/utils/report-errors.js b/src/pages/sponsors/sponsor-reports/report-errors.js similarity index 100% rename from src/utils/report-errors.js rename to src/pages/sponsors/sponsor-reports/report-errors.js diff --git a/src/utils/reports-money.js b/src/pages/sponsors/sponsor-reports/reports-money.js similarity index 100% rename from src/utils/reports-money.js rename to src/pages/sponsors/sponsor-reports/reports-money.js From a798b4e8b79675b52ba3e2ccffd43589b9be145c Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 15:26:51 -0500 Subject: [PATCH 34/63] fix(sponsor-reports): update constants import path in moved report-errors.js ./constants was not re-staged after the path update; this corrects it to ../../../utils/constants (resolves from the new feature folder location). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/sponsors/sponsor-reports/report-errors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/sponsors/sponsor-reports/report-errors.js b/src/pages/sponsors/sponsor-reports/report-errors.js index 5ef86c400..078dc46a9 100644 --- a/src/pages/sponsors/sponsor-reports/report-errors.js +++ b/src/pages/sponsors/sponsor-reports/report-errors.js @@ -6,7 +6,7 @@ import { ERROR_CODE_404, ERROR_CODE_412, ERROR_CODE_503 -} from "./constants"; +} from "../../../utils/constants"; export const extractErrorMessage = (err = {}, res = {}) => { const candidates = [ From a18a6d5cb87a27e1dfebaaab27faea6ac718ca22 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sat, 27 Jun 2026 21:12:17 -0500 Subject: [PATCH 35/63] refactor(reports): replace formatUsd with uicore currencyAmountFromCents Backend now emits money fields as integer cents. Drop the bespoke reports-money.js formatter (per santi PR #997 review) and use the platform-wide currencyAmountFromCents from openstack-uicore-foundation. Null guard via inline/local const rather than a new shared helper file. Test fixtures updated to cents input; assertions to no-comma "$X.XX" output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/reports/LinesManifestView.js | 6 ++-- .../sponsors/reports/OrdersTable.js | 7 +++-- .../__tests__/LinesManifestView.test.js | 7 +++-- .../reports/__tests__/OrdersTable.test.js | 5 ++-- .../__tests__/reports-money.test.js | 29 ------------------- .../__tests__/index.test.js | 16 +++++----- .../purchase-details-report-page/index.js | 11 ++++--- .../sponsors/sponsor-reports/reports-money.js | 20 ------------- 8 files changed, 31 insertions(+), 70 deletions(-) delete mode 100644 src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js delete mode 100644 src/pages/sponsors/sponsor-reports/reports-money.js diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 77537c952..c795fe09e 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -29,9 +29,9 @@ import { } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import T from "i18n-react/dist/i18n-react"; +import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import StatusPill from "./StatusPill"; import { formatCheckoutTime } from "./OrdersTable"; -import { formatUsd } from "../../../pages/sponsors/sponsor-reports/reports-money"; import { bucketLinesBySponsor } from "../../../utils/manifest-grouping"; // eslint-disable-next-line no-magic-numbers @@ -141,7 +141,9 @@ const LinesManifestView = ({ /> - {formatUsd(line.line_total)} + {line.line_total == null + ? "—" + : currencyAmountFromCents(line.line_total)} ))} diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index aad63388b..f12203252 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -13,8 +13,8 @@ import React from "react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import StatusPill from "./StatusPill"; -import { formatUsd } from "../../../pages/sponsors/sponsor-reports/reports-money"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" const MS_PER_SECOND = 1000; @@ -91,7 +91,10 @@ const columns = [ header: "Invoice Total", sortable: true, align: "right", - render: (row) => formatUsd(row.invoice_total) + render: (row) => + row.invoice_total == null + ? "—" + : currencyAmountFromCents(row.invoice_total) }, { columnKey: "sponsor_note", diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 90ed8fc5b..010b0cd66 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -21,8 +21,8 @@ const line = (over = {}) => ({ description: "Audio mixer", rate_name: "Early", quantity: 2, - unit_price: "500.00", - line_total: "1000.00", + unit_price: 50000, + line_total: 100000, add_on_id: 3, add_on_name: "Meeting Room T", notes: "dock B", @@ -69,7 +69,8 @@ describe("LinesManifestView", () => { renderView(); expect(screen.getByText("Paid")).toBeInTheDocument(); expect(screen.getByText("AV1")).toBeInTheDocument(); - expect(screen.getByText("$1,000.00")).toBeInTheDocument(); + // 100000 cents → "$1000.00" (no thousands separator — platform-wide uicore behavior) + expect(screen.getByText("$1000.00")).toBeInTheDocument(); }); it("KEEPS a canceled line in the rendered set (visual treatment, not filtered)", () => { diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index c6434a1ed..dbf51eafa 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -74,7 +74,7 @@ const sampleRow = { checkout_at: "2026-06-05T15:41:13Z", form: { display: "Booth" }, status: "Paid", - invoice_total: "250.00", + invoice_total: 25000, sponsor_note: "VIP note" }; @@ -129,8 +129,9 @@ describe("OrdersTable rendering", () => { expect(screen.getByText("Paid")).toBeInTheDocument(); }); - it("renders formatUsd(invoice_total) in the Invoice Total column", () => { + it("renders currencyAmountFromCents(invoice_total) in the Invoice Total column", () => { renderTable(); + // 25000 cents → "$250.00" (no thousands separator — platform-wide uicore behavior) expect(screen.getByText("$250.00")).toBeInTheDocument(); }); diff --git a/src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js b/src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js deleted file mode 100644 index cbe19a465..000000000 --- a/src/pages/sponsors/sponsor-reports/__tests__/reports-money.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { formatUsd } from "../reports-money"; - -describe("formatUsd", () => { - it("formats numbers as USD", () => { - expect(formatUsd(1234.5)).toBe("$1,234.50"); - expect(formatUsd(0)).toBe("$0.00"); - expect(formatUsd(5)).toBe("$5.00"); - }); - - it("formats numeric strings", () => { - expect(formatUsd("4754.15")).toBe("$4,754.15"); - }); - - it("renders an em dash for missing / non-numeric values", () => { - expect(formatUsd(null)).toBe("—"); - expect(formatUsd(undefined)).toBe("—"); - expect(formatUsd("abc")).toBe("—"); - }); - - it("treats blank / whitespace-only strings as missing, not zero", () => { - expect(formatUsd("")).toBe("—"); - expect(formatUsd(" ")).toBe("—"); - }); - - it("renders an em dash for non-finite numbers", () => { - expect(formatUsd(Infinity)).toBe("—"); - expect(formatUsd(-Infinity)).toBe("—"); - }); -}); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index ba06e3b30..c85e476a3 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -57,7 +57,7 @@ const SAMPLE_ROW = { checkout_at: "2026-06-05T15:41:13Z", form: { display: "Booth" }, status: "Paid", - invoice_total: "100.00", + invoice_total: 10000, sponsor_note: "" }; @@ -74,8 +74,8 @@ const SAMPLE_LINE = { description: "Audio mixer", rate_name: "Early", quantity: 2, - unit_price: "500.00", - line_total: "1000.00", + unit_price: 50000, + line_total: 100000, add_on_id: 3, add_on_name: "Meeting Room T", notes: "dock B", @@ -93,8 +93,8 @@ function buildState(summaryOverrides = {}, { total = 1 } = {}) { summary: { total_orders: 1, total_items: 1, - total_paid: "100.00", - total_pending: "0.00", + total_paid: 10000, + total_pending: 0, total_refunded: null, ...summaryOverrides }, @@ -112,8 +112,8 @@ function buildState(summaryOverrides = {}, { total = 1 } = {}) { summary: { total_orders: 1, total_items: 2, - total_paid: "1000.00", - total_pending: "0.00", + total_paid: 100000, + total_pending: 0, total_refunded: null }, total: 1, @@ -210,7 +210,7 @@ describe("PurchaseDetailsReportPage", () => { }); it("shows the Total Refunded tile when summary.total_refunded is a non-null value", async () => { - renderPage({ total_refunded: "50.00" }); + renderPage({ total_refunded: 5000 }); await act(async () => {}); expect( screen.getByText("sponsor_reports_page.total_refunded") diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 4743efb99..2d8fb59b9 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -26,7 +26,7 @@ import { import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; -import { formatUsd } from "../reports-money"; +import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; @@ -128,6 +128,9 @@ const PurchaseDetailsReportPage = ({ // Backend main does not yet expose it (ships in PR #24); the presence check // keeps the tile hidden on current main and auto-appears after PR #24 deploys. const activeSummary = view === "orders" ? summary : linesSummary; + // money: format integer CENTS via uicore; guard unexpected nulls with em dash. + const money = (cents) => + cents == null ? "—" : currencyAmountFromCents(cents); const tiles = activeSummary ? [ { @@ -143,13 +146,13 @@ const PurchaseDetailsReportPage = ({ { key: "total_paid", label: T.translate("sponsor_reports_page.total_paid"), - value: formatUsd(activeSummary.total_paid), + value: money(activeSummary.total_paid), tone: "success" }, { key: "total_pending", label: T.translate("sponsor_reports_page.total_pending"), - value: formatUsd(activeSummary.total_pending), + value: money(activeSummary.total_pending), tone: "warning" }, ...(activeSummary.total_refunded != null @@ -157,7 +160,7 @@ const PurchaseDetailsReportPage = ({ { key: "total_refunded", label: T.translate("sponsor_reports_page.total_refunded"), - value: formatUsd(activeSummary.total_refunded) + value: money(activeSummary.total_refunded) } ] : []) diff --git a/src/pages/sponsors/sponsor-reports/reports-money.js b/src/pages/sponsors/sponsor-reports/reports-money.js deleted file mode 100644 index d7e5b284c..000000000 --- a/src/pages/sponsors/sponsor-reports/reports-money.js +++ /dev/null @@ -1,20 +0,0 @@ -const USD = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2 -}); - -// Formats a DOLLAR amount (number or numeric string) as "$1,234.56". -// Non-numeric / null → em dash. -export const formatUsd = (value) => { - // Blank / whitespace-only strings are missing values, not zero - // (Number("") === 0 would otherwise render "$0.00"). - if (typeof value === "string" && value.trim() === "") return "—"; - const n = typeof value === "string" ? Number(value) : value; - if (typeof n !== "number" || Number.isNaN(n) || !Number.isFinite(n)) - return "—"; - return USD.format(n); -}; - -export default formatUsd; From 9a614a11811e01871d7df910542a6d1a07ce2f79 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sun, 28 Jun 2026 16:41:12 -0500 Subject: [PATCH 36/63] test(reports): use cents fixtures in purchase-details reducer test Codex follow-up: the reducer passes summary payload through unchanged, so the dollar-string fixtures were not a live bug, but they're stale under the cents contract. Switch total_paid fixtures to integer cents to stay representative. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sponsors/__tests__/sponsor-reports-reducers.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js index b70684227..df65e16b0 100644 --- a/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js @@ -63,7 +63,7 @@ describe("sponsorReportsPurchaseDetailsReducer", () => { current_page: 2, last_page: 5, per_page: 10, - summary: { total_paid: "100.00" } + summary: { total_paid: 10000 } } }; @@ -79,13 +79,13 @@ describe("sponsorReportsPurchaseDetailsReducer", () => { expect(result.currentPage).toBe(2); expect(result.lastPage).toBe(5); expect(result.perPage).toBe(10); - expect(result.summary).toStrictEqual({ total_paid: "100.00" }); + expect(result.summary).toStrictEqual({ total_paid: 10000 }); expect(result.readError).toBeNull(); expect(result.validationError).toBeNull(); }); it("preserves existing summary when response summary is null", () => { - const prevSummary = { total_paid: "200.00" }; + const prevSummary = { total_paid: 20000 }; const state = { ...PD_DEFAULT_STATE, summary: prevSummary }; const result = purchaseDetailsReducer(state, { type: RECEIVE_PURCHASE_DETAILS, From 473c62574372c98f645c829ce650106f3a5f8bb2 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:18:38 -0500 Subject: [PATCH 37/63] refactor(reports): dedup per-page/ms constants via src/utils/constants - OrdersTable: replace local MS_PER_SECOND (1000) with MILLISECONDS_IN_SECOND and local DEFAULT_PER_PAGE (10) with the shared constant; remove eslint-disable - LinesManifestView: replace magic-number PER_PAGE_OPTIONS [10,25,50,100] and DEFAULT_PER_PAGE (50) with named constants [DEFAULT_PER_PAGE, TWENTY_PER_PAGE, FIFTY_PER_PAGE, MAX_PER_PAGE]; default perPage prop is now FIFTY_PER_PAGE Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsors/reports/LinesManifestView.js | 18 +++++++++++++----- src/components/sponsors/reports/OrdersTable.js | 10 +++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index c795fe09e..ceddbb4b0 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -33,11 +33,19 @@ import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/m import StatusPill from "./StatusPill"; import { formatCheckoutTime } from "./OrdersTable"; import { bucketLinesBySponsor } from "../../../utils/manifest-grouping"; +import { + DEFAULT_PER_PAGE, + FIFTY_PER_PAGE, + MAX_PER_PAGE, + TWENTY_PER_PAGE +} from "../../../utils/constants"; -// eslint-disable-next-line no-magic-numbers -const PER_PAGE_OPTIONS = [10, 25, 50, 100]; -// eslint-disable-next-line no-magic-numbers -const DEFAULT_PER_PAGE = 50; +const PER_PAGE_OPTIONS = [ + DEFAULT_PER_PAGE, + TWENTY_PER_PAGE, + FIFTY_PER_PAGE, + MAX_PER_PAGE +]; // Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the // logistics convention is the sponsor's booth. The booth NUMBER ships with @@ -69,7 +77,7 @@ const LinesManifestView = ({ rows = [], total = 0, currentPage = 1, - perPage = DEFAULT_PER_PAGE, + perPage = FIFTY_PER_PAGE, onPageChange, onPerPageChange }) => { diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index f12203252..1f2ae08c2 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -14,10 +14,13 @@ import React from "react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { + DEFAULT_PER_PAGE, + MILLISECONDS_IN_SECOND +} from "../../../utils/constants"; import StatusPill from "./StatusPill"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" -const MS_PER_SECOND = 1000; const NOON = 12; // Port of OrdersGrid.js formatCheckoutTime — handles BOTH the current ISO @@ -29,7 +32,7 @@ export const formatCheckoutTime = (value) => { if (value == null || value === "") return ""; const iso = typeof value === "number" || /^\d+$/.test(value) - ? new Date(Number(value) * MS_PER_SECOND).toISOString() + ? new Date(Number(value) * MILLISECONDS_IN_SECOND).toISOString() : String(value); const m = iso.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/); if (!m) return iso.slice(0, ISO_DATE_LENGTH); @@ -104,9 +107,6 @@ const columns = [ } ]; -// eslint-disable-next-line no-magic-numbers -const DEFAULT_PER_PAGE = 10; - // Props mirror the MuiTable contract used by show-purchase-list-page. // rows must be raw API rows (purchase_id present); id mapping is done here. const OrdersTable = ({ From 35974eebac2db0fe47cf3c2b514460ddc42659b3 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:20:13 -0500 Subject: [PATCH 38/63] fix(sponsor-reports): return Promise.resolve() on all thunk guard branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every thunk in sponsor-reports-actions.js that guards against a missing summit returned `undefined` synchronously while the normal code-path returned a Promise — inconsistent for callers using `.then()`/`await`. Changed all ten guard-branch returns to `return Promise.resolve()` so every thunk is uniformly thenable regardless of which branch is taken. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/actions/sponsor-reports-actions.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 9fa61f3d7..5c1dd7508 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -48,7 +48,7 @@ export const getPurchaseDetailsReport = const { currentSummit } = currentSummitState; // No summit in context → skip. Otherwise base(currentSummit.id) throws // synchronously after startLoading() and the spinner is never cleared. - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); const params = { access_token: accessToken, ...query }; @@ -78,7 +78,7 @@ export const getPurchaseDetailsLinesReport = async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); const params = { access_token: accessToken, ...query }; @@ -101,7 +101,7 @@ export const getPurchaseDetailsLinesReport = export const getPurchaseDetailsFilters = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( @@ -123,7 +123,7 @@ export const getSponsorAssetReport = const { currentSummit } = currentSummitState; // No summit in context → skip. Otherwise base(currentSummit.id) throws // synchronously after startLoading() and the spinner is never cleared. - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); const params = { access_token: accessToken, ...query }; @@ -146,7 +146,7 @@ export const getSponsorAssetReport = export const getSponsorAssetFilters = () => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( @@ -169,7 +169,7 @@ export const exportPurchaseDetailsCsv = (filters = {}, order, orderDir) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); const params = { access_token: accessToken, @@ -190,7 +190,7 @@ export const exportPurchaseDetailsLinesCsv = (filters = {}) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); const params = { access_token: accessToken, @@ -211,7 +211,7 @@ export const getSponsorAssetSponsor = const { currentSummit } = currentSummitState; // No summit in context → skip. Otherwise base(currentSummit.id) throws // synchronously after startLoading() and the spinner is never cleared. - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); return getRequest( @@ -237,7 +237,7 @@ export const exportSponsorAssetCsv = (filters = {}) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); const { group_by: _g, @@ -261,7 +261,7 @@ export const exportSponsorAssetCsv = export const exportSponsorAssetSectionCsv = (sponsorId, pageId) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; - if (!currentSummit?.id) return undefined; + if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); const filter = []; const sid = Number(sponsorId); From 6dcd25732e95f5f9fd8c6cbc8feefeeb25440f81 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:22:28 -0500 Subject: [PATCH 39/63] refactor(sponsor-reports): fold statusTone into StatusPill Moves TONE_BY_STATUS + statusTone() from the standalone statusTone.js into StatusPill.js as a named export. Updates StatusRollupChips.js to import from StatusPill. Deletes statusTone.js and its test file; all branch-coverage assertions migrated into StatusPill.test.js. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/components/sponsors/reports/StatusPill.js | 17 ++++++++++++++-- .../sponsors/reports/StatusRollupChips.js | 2 +- .../reports/__tests__/StatusPill.test.js | 12 ++++++++++- .../reports/__tests__/statusTone.test.js | 20 ------------------- src/components/sponsors/reports/statusTone.js | 16 --------------- 5 files changed, 27 insertions(+), 40 deletions(-) delete mode 100644 src/components/sponsors/reports/__tests__/statusTone.test.js delete mode 100644 src/components/sponsors/reports/statusTone.js diff --git a/src/components/sponsors/reports/StatusPill.js b/src/components/sponsors/reports/StatusPill.js index bcd54caef..9353de00d 100644 --- a/src/components/sponsors/reports/StatusPill.js +++ b/src/components/sponsors/reports/StatusPill.js @@ -1,6 +1,20 @@ import React from "react"; import { Chip } from "@mui/material"; -import { statusTone } from "./statusTone"; + +// Single source of truth: status token -> MUI Chip color. Case-insensitive. +const TONE_BY_STATUS = { + completed: "success", + paid: "success", + confirmed: "success", + pending: "warning", + in_progress: "info", + not_applicable: "default", + canceled: "default", + cancelled: "default" +}; + +export const statusTone = (status) => + TONE_BY_STATUS[String(status || "").toLowerCase()] || "default"; // A status token rendered as a colored, filled chip. `label` overrides the // displayed text (e.g. a T.translate'd label); the color always derives from @@ -14,5 +28,4 @@ const StatusPill = ({ status, label, size = "small" }) => ( /> ); -export { statusTone }; export default StatusPill; diff --git a/src/components/sponsors/reports/StatusRollupChips.js b/src/components/sponsors/reports/StatusRollupChips.js index b97b3e769..5027f69c1 100644 --- a/src/components/sponsors/reports/StatusRollupChips.js +++ b/src/components/sponsors/reports/StatusRollupChips.js @@ -1,7 +1,7 @@ import React from "react"; import { Chip, Stack } from "@mui/material"; import T from "i18n-react/dist/i18n-react"; -import { statusTone } from "./statusTone"; +import { statusTone } from "./StatusPill"; // The backend status_rollup always carries all four lowercase keys; render them // in a fixed order so cards line up. A missing rollup degrades to all-zero. diff --git a/src/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js index e7e8062d6..a8cd2ca6c 100644 --- a/src/components/sponsors/reports/__tests__/StatusPill.test.js +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -5,12 +5,22 @@ import { screen } from "@testing-library/react"; import { renderWithRedux } from "utils/test-utils"; import StatusPill, { statusTone } from "../StatusPill"; -describe("statusTone (re-export)", () => { +describe("statusTone", () => { it("maps completed/paid/confirmed to success", () => { expect(statusTone("completed")).toBe("success"); expect(statusTone("paid")).toBe("success"); expect(statusTone("Confirmed")).toBe("success"); }); + it("maps pending to warning, in_progress to info", () => { + expect(statusTone("pending")).toBe("warning"); + expect(statusTone("in_progress")).toBe("info"); + }); + it("maps not_applicable/canceled and unknown to default", () => { + expect(statusTone("not_applicable")).toBe("default"); + expect(statusTone("Canceled")).toBe("default"); + expect(statusTone("whatever")).toBe("default"); + expect(statusTone(null)).toBe("default"); + }); }); describe("StatusPill", () => { diff --git a/src/components/sponsors/reports/__tests__/statusTone.test.js b/src/components/sponsors/reports/__tests__/statusTone.test.js deleted file mode 100644 index 30916a324..000000000 --- a/src/components/sponsors/reports/__tests__/statusTone.test.js +++ /dev/null @@ -1,20 +0,0 @@ -// src/components/sponsors/reports/__tests__/statusTone.test.js -import { statusTone } from "../statusTone"; - -describe("statusTone", () => { - it("maps completed/paid/confirmed to success", () => { - expect(statusTone("completed")).toBe("success"); - expect(statusTone("paid")).toBe("success"); - expect(statusTone("Confirmed")).toBe("success"); - }); - it("maps pending to warning, in_progress to info", () => { - expect(statusTone("pending")).toBe("warning"); - expect(statusTone("in_progress")).toBe("info"); - }); - it("maps not_applicable/canceled and unknown to default", () => { - expect(statusTone("not_applicable")).toBe("default"); - expect(statusTone("Canceled")).toBe("default"); - expect(statusTone("whatever")).toBe("default"); - expect(statusTone(null)).toBe("default"); - }); -}); diff --git a/src/components/sponsors/reports/statusTone.js b/src/components/sponsors/reports/statusTone.js deleted file mode 100644 index 5230417f4..000000000 --- a/src/components/sponsors/reports/statusTone.js +++ /dev/null @@ -1,16 +0,0 @@ -// Single source of truth: status token -> MUI Chip color. Case-insensitive. -const TONE_BY_STATUS = { - completed: "success", - paid: "success", - confirmed: "success", - pending: "warning", - in_progress: "info", - not_applicable: "default", - canceled: "default", - cancelled: "default" -}; - -export const statusTone = (status) => - TONE_BY_STATUS[String(status || "").toLowerCase()] || "default"; - -export default statusTone; From 78bc8c640c245470f2eeb6893bdfcc2571c1c0a9 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:25:47 -0500 Subject: [PATCH 40/63] refactor(reports): replace hand-rolled formatCheckoutTime with moment Swaps the manual regex + AM/PM math in formatCheckoutTime for moment.unix().utc() / moment.utc() + .format("YYYY-MM-DD h:mm A"). Output is byte-identical for all tested inputs. Removes dead NOON constant and unused MILLISECONDS_IN_SECOND import; keeps ISO_DATE_LENGTH (still needed for the date-only fallback path, and the no-magic-numbers rule requires it named). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsors/reports/OrdersTable.js | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 1f2ae08c2..2c1eb11df 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -12,35 +12,30 @@ * */ import React from "react"; +import moment from "moment-timezone"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; -import { - DEFAULT_PER_PAGE, - MILLISECONDS_IN_SECOND -} from "../../../utils/constants"; +import { DEFAULT_PER_PAGE } from "../../../utils/constants"; import StatusPill from "./StatusPill"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" -const NOON = 12; // Port of OrdersGrid.js formatCheckoutTime — handles BOTH the current ISO // checkout_at (DRF DateTimeField on backend main) AND a future epoch int -// (pending ClickUp 86bagnfmn). Parses date/time directly off the ISO string -// parts so the displayed time always matches the stored UTC value and tests -// stay timezone-stable regardless of the machine's local TZ offset. +// (pending ClickUp 86bagnfmn). Parses in UTC so the displayed time always +// matches the stored UTC value and tests stay timezone-stable. export const formatCheckoutTime = (value) => { if (value == null || value === "") return ""; - const iso = - typeof value === "number" || /^\d+$/.test(value) - ? new Date(Number(value) * MILLISECONDS_IN_SECOND).toISOString() - : String(value); - const m = iso.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/); - if (!m) return iso.slice(0, ISO_DATE_LENGTH); - const [, date, hh, mm] = m; - const hour24 = Number(hh); - const ampm = hour24 >= NOON ? "PM" : "AM"; - const hour12 = hour24 % NOON || NOON; - return `${date} ${hour12}:${mm} ${ampm}`; + let m; + if (typeof value === "number" || /^\d+$/.test(value)) { + m = moment.unix(Number(value)).utc(); + } else { + const s = String(value); + if (!s.includes("T")) return s.slice(0, ISO_DATE_LENGTH); + m = moment.utc(s); + } + if (!m.isValid()) return String(value).slice(0, ISO_DATE_LENGTH); + return m.format("YYYY-MM-DD h:mm A"); }; // Converts MuiTable sort state to the `order` query param expected by the API. From 7632cbefb51fccdbab5fe528493c38a44d53a97a Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:29:49 -0500 Subject: [PATCH 41/63] refactor(sponsor-reports): adopt useSnackbarMessage hook for 412 validation toast Replaces the hand-rolled inline MUI / with the existing useSnackbarMessage hook (openstack-uicore-foundation), consistent with payment-view.js and mui-qr-badge-popup.js. Removes TOAST_AUTO_HIDE_MS and the Snackbar import; Alert import kept (still used for readError). Redux clearPurchaseDetailsValidation lifecycle preserved via useEffect. Tests: mocks useSnackbarMessage, asserts errorMessage is called with the correct message (and i18n fallback), clearValidation is dispatched, and errorMessage is not called when validationError is null. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/index.test.js | 51 +++++++++++++++++++ .../purchase-details-report-page/index.js | 35 ++++++------- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index c85e476a3..f82e21c33 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -12,6 +12,15 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); +// ── Snackbar hook ───────────────────────────────────────────────────────────── +const mockErrorMessage = jest.fn(); +jest.mock( + "openstack-uicore-foundation/lib/components/mui/snackbar-notification", + () => ({ + useSnackbarMessage: () => ({ errorMessage: mockErrorMessage }) + }) +); + // Action creators: jest.fn() inside the factory to avoid hoisting issues. // Import the mocked functions below to assert on .mock.calls. // Export thunks return a plain object so redux-mock-store does not reject the @@ -42,6 +51,7 @@ const { getPurchaseDetailsReport, getPurchaseDetailsFilters, getPurchaseDetailsLinesReport, + clearPurchaseDetailsValidation, exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv } = require("../../../../../actions/sponsor-reports-actions"); @@ -139,6 +149,19 @@ function renderPage(summaryOverrides = {}, stateOptions = {}) { }; } +/** Render with an explicit validationError in the purchase-details slice. */ +function renderPageWithValidationError(validationError) { + const state = buildState(); + state.sponsorReportsPurchaseDetailsState.validationError = validationError; + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + return renderWithRedux( + + + , + { initialState: state } + ); +} + beforeEach(() => { jest.clearAllMocks(); }); @@ -414,4 +437,32 @@ describe("PurchaseDetailsReportPage", () => { dateFrom: "2026-01-01" }); }); + + describe("validation error — snackbar hook", () => { + it("calls errorMessage with the validationError message when validationError is set", async () => { + renderPageWithValidationError({ message: "Too many filters" }); + await act(async () => {}); + expect(mockErrorMessage).toHaveBeenCalledWith("Too many filters"); + }); + + it("calls errorMessage with the i18n fallback key when validationError has no message", async () => { + renderPageWithValidationError({}); + await act(async () => {}); + expect(mockErrorMessage).toHaveBeenCalledWith( + "sponsor_reports_page.validation_error" + ); + }); + + it("dispatches clearPurchaseDetailsValidation after showing the error message", async () => { + renderPageWithValidationError({ message: "Bad request" }); + await act(async () => {}); + expect(clearPurchaseDetailsValidation).toHaveBeenCalled(); + }); + + it("does not call errorMessage when validationError is null", async () => { + renderPage(); // default state has validationError: null + await act(async () => {}); + expect(mockErrorMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 2d8fb59b9..6b3ca74fb 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -15,18 +15,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; -import { - Alert, - Box, - Button, - MenuItem, - Snackbar, - TextField -} from "@mui/material"; +import { Alert, Box, Button, MenuItem, TextField } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification"; import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; @@ -46,7 +40,6 @@ import { import { DEFAULT_PER_PAGE } from "../../../../utils/constants"; const LINES_DEFAULT_PAGE_SIZE = 50; -const TOAST_AUTO_HIDE_MS = 6000; const PurchaseDetailsReportPage = ({ // From mapStateToProps @@ -70,6 +63,19 @@ const PurchaseDetailsReportPage = ({ exportPurchaseDetailsLinesCsv: exportLinesCsv }) => { const print = usePrint(); + const { errorMessage } = useSnackbarMessage(); + + // Show a global snackbar toast when the backend returns a 412 validation error, + // then clear the redux slice so the toast fires only once per error. + useEffect(() => { + if (validationError) { + errorMessage( + validationError.message || + T.translate("sponsor_reports_page.validation_error") + ); + clearValidation(); + } + }, [validationError]); // Local pagination/sort state. MuiTable dir = 1 (asc) | -1 (desc). const [filters, setFilters] = useState({}); @@ -296,17 +302,6 @@ const PurchaseDetailsReportPage = ({ } > - {/* 412 → inline toast; body preserved (rows stay visible) */} - clearValidation()} - > - - {validationError?.message || - T.translate("sponsor_reports_page.validation_error")} - - {(view === "orders" ? readError : linesReadError) ? ( {(view === "orders" ? readError : linesReadError)?.message || From 57a14008692cad0640465188dfe10553785897b7 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:33:09 -0500 Subject: [PATCH 42/63] test(sponsor-reports): mock useSnackbarMessage in layout test The Purchase Details page now calls useSnackbarMessage() (T5); the layout integration test renders that page without the global snackbar provider, so the hook destructured undefined and threw on render. Mock it like the page's own test does. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/layouts/__tests__/sponsor-reports-layout.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js index e8d0ebf4e..2943e3f69 100644 --- a/src/layouts/__tests__/sponsor-reports-layout.test.js +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -30,6 +30,14 @@ jest.mock("react-breadcrumbs", () => ({ ) })); +// The connected Purchase Details page calls useSnackbarMessage(); the global +// provider isn't in this layout render, so mock the hook (mirrors the page's +// own test) — otherwise it destructures undefined and throws on render. +jest.mock( + "openstack-uicore-foundation/lib/components/mui/snackbar-notification", + () => ({ useSnackbarMessage: () => ({ errorMessage: jest.fn() }) }) +); + // Provide real access-routes data so Restrict/Member gates correctly. // Without this the YAML transform stub returns "" and hasAccess() always returns true. jest.mock("../../access-routes.yml", () => ({ From 06e65f0c5c168af97e318183e4f4e93b3f0bd4f7 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:53:21 -0500 Subject: [PATCH 43/63] refactor(T6): move manifest-grouping into reports feature dir bucketLinesBySponsor had exactly one production caller (LinesManifestView). Moving the helper and its test out of src/utils/ and co-locating them with the feature removes the one-off-file-in-shared-utils anti-pattern flagged in PR review. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/components/sponsors/reports/LinesManifestView.js | 2 +- .../sponsors/reports}/__tests__/manifest-grouping.test.js | 0 src/{utils => components/sponsors/reports}/manifest-grouping.js | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/{utils => components/sponsors/reports}/__tests__/manifest-grouping.test.js (100%) rename src/{utils => components/sponsors/reports}/manifest-grouping.js (100%) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index ceddbb4b0..5fd67a8f0 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -32,7 +32,7 @@ import T from "i18n-react/dist/i18n-react"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import StatusPill from "./StatusPill"; import { formatCheckoutTime } from "./OrdersTable"; -import { bucketLinesBySponsor } from "../../../utils/manifest-grouping"; +import { bucketLinesBySponsor } from "./manifest-grouping"; import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, diff --git a/src/utils/__tests__/manifest-grouping.test.js b/src/components/sponsors/reports/__tests__/manifest-grouping.test.js similarity index 100% rename from src/utils/__tests__/manifest-grouping.test.js rename to src/components/sponsors/reports/__tests__/manifest-grouping.test.js diff --git a/src/utils/manifest-grouping.js b/src/components/sponsors/reports/manifest-grouping.js similarity index 100% rename from src/utils/manifest-grouping.js rename to src/components/sponsors/reports/manifest-grouping.js From b5339014f14caa4daeb4e0eee5e98b83fcd11c67 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:54:54 -0500 Subject: [PATCH 44/63] =?UTF-8?q?refactor(sponsor-reports):=20T7=20?= =?UTF-8?q?=E2=80=94=20dedup=20page-size=20constant=20+=20fix=20default-pa?= =?UTF-8?q?ram-last?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit A: replace local LINES_DEFAULT_PAGE_SIZE = 50 with imported FIFTY_PER_PAGE from utils/constants; Edit B: remove `filters = {}` default on exportPurchaseDetailsCsv — all callers pass an explicit object, buildPurchaseQuery already defaults its own first arg. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/actions/sponsor-reports-actions.js | 3 +-- .../sponsor-reports/purchase-details-report-page/index.js | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 5c1dd7508..e6bafe7dc 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -166,8 +166,7 @@ export const getSponsorAssetFilters = () => async (dispatch, getState) => { // No page/perPage → buildPurchaseQuery emits neither; backend exports the full // filtered set. export const exportPurchaseDetailsCsv = - (filters = {}, order, orderDir) => - async (dispatch, getState) => { + (filters, order, orderDir) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 6b3ca74fb..a7574852e 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -37,9 +37,7 @@ import { exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv } from "../../../../actions/sponsor-reports-actions"; -import { DEFAULT_PER_PAGE } from "../../../../utils/constants"; - -const LINES_DEFAULT_PAGE_SIZE = 50; +import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE } from "../../../../utils/constants"; const PurchaseDetailsReportPage = ({ // From mapStateToProps @@ -85,7 +83,7 @@ const PurchaseDetailsReportPage = ({ const [orderDir, setOrderDir] = useState(1); const [view, setView] = useState("orders"); const [linesPage, setLinesPage] = useState(1); - const [linesPerPage, setLinesPerPage] = useState(LINES_DEFAULT_PAGE_SIZE); + const [linesPerPage, setLinesPerPage] = useState(FIFTY_PER_PAGE); // Build the API query from all local state. Memoized so useEffect only re-runs // when the query actually changes (referential stability). From dc18ea4f3251634b77b6199d5b1bdb70076c1444 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:56:38 -0500 Subject: [PATCH 45/63] refactor(sponsor-reports): convert nextDayStartIso to moment-timezone Replace hand-rolled new Date / setUTCDate with moment.utc(ymd, "YYYY-MM-DD", true).add(1, "day") for consistency with the rest of the date-formatting in this feature. Output is byte-identical; ISO_DATE_LENGTH removed (now unused). Invalid-input guard returns ymd unchanged rather than throwing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/pages/sponsors/sponsor-reports/report-query.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/report-query.js b/src/pages/sponsors/sponsor-reports/report-query.js index 431c52e46..2f19b677e 100644 --- a/src/pages/sponsors/sponsor-reports/report-query.js +++ b/src/pages/sponsors/sponsor-reports/report-query.js @@ -9,6 +9,7 @@ // Every emitted value uses valid `field==value` / `field>=value` operator syntax // (a no-operator value triggers a server IndexError → 500). +import moment from "moment-timezone"; import { toOrderParam } from "../../../components/sponsors/reports/OrdersTable"; export const buildReportQuery = (filters = {}) => { @@ -73,14 +74,11 @@ export const buildReportQuery = (filters = {}) => { return query; }; -const ISO_DATE_LENGTH = 10; - // dateTo → start of the NEXT day (exclusive <) so same-day fractional-second rows // are included rather than dropped by a <= end-of-day bound. const nextDayStartIso = (ymd) => { - const d = new Date(`${ymd}T00:00:00Z`); - d.setUTCDate(d.getUTCDate() + 1); - return `${d.toISOString().slice(0, ISO_DATE_LENGTH)}T00:00:00Z`; + const m = moment.utc(ymd, "YYYY-MM-DD", true).add(1, "day"); + return m.isValid() ? m.format("YYYY-MM-DDT00:00:00[Z]") : ymd; }; const expandDates = (filters = {}) => { From aceafedb327495afc88a411645b63046018dabeb Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 10:58:40 -0500 Subject: [PATCH 46/63] test(OrdersTable): pin moment.utc offset+malformed contract (T9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for non-Z offset inputs (-05:00, +05:00) and malformed ISO- like strings to lock current moment.utc() behavior. Backend emits UTC Z only; offset path is inert in production — assertions are documentation. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../reports/__tests__/OrdersTable.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index dbf51eafa..a3938c88a 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -43,6 +43,31 @@ describe("formatCheckoutTime", () => { expect(formatCheckoutTime(undefined)).toBe(""); expect(formatCheckoutTime("")).toBe(""); }); + + // ── Offset & malformed contract pins ──────────────────────────────────────── + // The backend always emits UTC `Z` datetimes (sponsor-reports-api TIME_ZONE= + // "UTC", USE_TZ=True, DRF emits Z), so the offset path is inert in production. + // These assertions lock the moment.utc() contract so future refactors can't + // silently change the behavior on non-Z inputs or malformed strings. + it("converts ISO strings with explicit UTC offsets to UTC before formatting", () => { + // -05:00 → adds 5 h → 2026-06-30T04:59:59Z + expect(formatCheckoutTime("2026-06-29T23:59:59-05:00")).toBe( + "2026-06-30 4:59 AM" + ); + // +05:00 → subtracts 5 h → 2026-06-29T18:59:59Z + expect(formatCheckoutTime("2026-06-29T23:59:59+05:00")).toBe( + "2026-06-29 6:59 PM" + ); + // Z suffix (the real-data path) — baseline assertion alongside offset cases + expect(formatCheckoutTime("2026-06-29T23:59:59Z")).toBe( + "2026-06-29 11:59 PM" + ); + }); + + it("falls back to the 10-char date slice for malformed ISO-like strings", () => { + // month 13 / day 99 / hour 25 → moment marks invalid → date-only fallback + expect(formatCheckoutTime("2026-13-99T25:99:00Z")).toBe("2026-13-99"); + }); }); // ──────────────────────────────────────────────────────────────────────────── From 13ec334a0b01778cc2f973fa37066d246f2297d0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 11:20:33 -0500 Subject: [PATCH 47/63] refactor(sponsor-reports): use repo-native isImageUrl + DEFAULT_CURRENT_PAGE Replace local IMAGE_EXT regex in sponsor-asset-drilldown-page with the repo-canonical isImageUrl from utils/methods (same extensions, also handles query strings). Replace local FIRST_PAGE = 1 in sponsor-asset-report-page with DEFAULT_CURRENT_PAGE from utils/constants (7 references). Replace bare useState(1) / setPage(1) page-init/reset literals in purchase-details- report-page with DEFAULT_CURRENT_PAGE (9 references); orderDir useState(1) (sort direction) is intentionally unchanged. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../purchase-details-report-page/index.js | 24 +++++++++++-------- .../sponsor-asset-drilldown-page/index.js | 12 +++++----- .../sponsor-asset-report-page/index.js | 16 ++++++------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index a7574852e..eb63885f8 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -37,7 +37,11 @@ import { exportPurchaseDetailsCsv, exportPurchaseDetailsLinesCsv } from "../../../../actions/sponsor-reports-actions"; -import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE } from "../../../../utils/constants"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_PER_PAGE, + FIFTY_PER_PAGE +} from "../../../../utils/constants"; const PurchaseDetailsReportPage = ({ // From mapStateToProps @@ -77,12 +81,12 @@ const PurchaseDetailsReportPage = ({ // Local pagination/sort state. MuiTable dir = 1 (asc) | -1 (desc). const [filters, setFilters] = useState({}); - const [currentPage, setCurrentPage] = useState(1); + const [currentPage, setCurrentPage] = useState(DEFAULT_CURRENT_PAGE); const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE); const [order, setOrder] = useState(null); const [orderDir, setOrderDir] = useState(1); const [view, setView] = useState("orders"); - const [linesPage, setLinesPage] = useState(1); + const [linesPage, setLinesPage] = useState(DEFAULT_CURRENT_PAGE); const [linesPerPage, setLinesPerPage] = useState(FIFTY_PER_PAGE); // Build the API query from all local state. Memoized so useEffect only re-runs @@ -175,32 +179,32 @@ const PurchaseDetailsReportPage = ({ // Applying/clearing a filter changes the result set → snap back to page 1. const handleApply = (next) => { setFilters(next); - setCurrentPage(1); - setLinesPage(1); + setCurrentPage(DEFAULT_CURRENT_PAGE); + setLinesPage(DEFAULT_CURRENT_PAGE); }; const handleClear = () => { setFilters({}); - setCurrentPage(1); - setLinesPage(1); + setCurrentPage(DEFAULT_CURRENT_PAGE); + setLinesPage(DEFAULT_CURRENT_PAGE); }; // ── Sort/pagination handlers ───────────────────────────────────────────────── const handleSort = (columnKey, dir) => { setOrder(columnKey); setOrderDir(dir); - setCurrentPage(1); + setCurrentPage(DEFAULT_CURRENT_PAGE); }; const handlePageChange = (page) => { setCurrentPage(page); }; const handlePerPageChange = (newPerPage) => { setPerPage(newPerPage); - setCurrentPage(1); + setCurrentPage(DEFAULT_CURRENT_PAGE); }; const handleLinesPageChange = (page) => setLinesPage(page); const handleLinesPerPageChange = (newPerPage) => { setLinesPerPage(newPerPage); - setLinesPage(1); + setLinesPage(DEFAULT_CURRENT_PAGE); }; // ── Extra filter controls (status / type / date range) ────────────────────── diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index e508769ab..a9e9eaf68 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -41,7 +41,11 @@ import PrintIcon from "@mui/icons-material/Print"; import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import DownloadIcon from "@mui/icons-material/Download"; -import { htmlToPlainText, isPositiveIntId } from "../../../../utils/methods"; +import { + htmlToPlainText, + isImageUrl, + isPositiveIntId +} from "../../../../utils/methods"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import usePrint from "../../../../hooks/usePrint"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; @@ -52,10 +56,6 @@ import { getSponsorAssetSponsor } from "../../../../actions/sponsor-reports-actions"; -// Gate the on an image file extension; render every other file as a -// download link (a PDF url in an would show a broken image). -const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp)$/i; - // ContentCell uses T.translate directly (no `t` prop) — this component is // co-located with the page and uses the same i18n module as everything else. const ContentCell = ({ row }) => { @@ -66,7 +66,7 @@ const ContentCell = ({ row }) => { const text = htmlToPlainText( row.content?.value || row.content?.summary || filename ); - const isImage = !!url && IMAGE_EXT.test(filename || url); + const isImage = !!url && isImageUrl(filename || url); if (url && isImage) { return ( diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 140aaec2a..dc308444b 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -21,6 +21,7 @@ import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; import { buildReportQuery } from "../report-query"; import { isPositiveIntId } from "../../../../utils/methods"; +import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; @@ -47,7 +48,6 @@ const TILE_TONE = { not_applicable: "neutral" }; const GROUP_PER_PAGE = 25; -const FIRST_PAGE = 1; const SponsorAssetReportPage = ({ // From mapStateToProps @@ -72,7 +72,7 @@ const SponsorAssetReportPage = ({ const [groupBy, setGroupBy] = useState("sponsor"); const [filters, setFilters] = useState({}); - const [page, setPage] = useState(FIRST_PAGE); + const [page, setPage] = useState(DEFAULT_CURRENT_PAGE); // Fetch sponsor filter options once on mount; summit is read from store inside // the action. Guard on validSummit so no network call fires when currentSummit @@ -101,15 +101,15 @@ const SponsorAssetReportPage = ({ }, [query]); // query is memoized; re-fetches only on real changes const onApply = (next) => { - setPage(FIRST_PAGE); + setPage(DEFAULT_CURRENT_PAGE); setFilters(next); }; const onClear = () => { - setPage(FIRST_PAGE); + setPage(DEFAULT_CURRENT_PAGE); setFilters({}); }; const onGroupBy = (next) => { - setPage(FIRST_PAGE); + setPage(DEFAULT_CURRENT_PAGE); setGroupBy(next); }; @@ -195,7 +195,7 @@ const SponsorAssetReportPage = ({ fetch resolves, and no flicker if /filters lands before the report (Task 3 decouple). */} {!loading && !readError && - currentPage >= FIRST_PAGE && + currentPage >= DEFAULT_CURRENT_PAGE && data.length === 0 && ( )} - {!loading && !readError && lastPage > FIRST_PAGE && ( + {!loading && !readError && lastPage > DEFAULT_CURRENT_PAGE && ( setPage(p)} /> From fb388124070a1a2603b6b269400cad3aab8eee8f Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:07:01 -0500 Subject: [PATCH 48/63] refactor(sponsor-reports): translate OrdersTable column headers via i18n Replace 7 bare-string header values with T.translate calls; add 5 new col_* keys to en.json; update header assertions in both test files to match key output from the translate mock. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsors/reports/OrdersTable.js | 15 ++++---- .../reports/__tests__/OrdersTable.test.js | 36 ++++++++++++++----- src/i18n/en.json | 5 +++ .../__tests__/index.test.js | 2 +- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 2c1eb11df..470cd34ff 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -13,6 +13,7 @@ import React from "react"; import moment from "moment-timezone"; +import T from "i18n-react/dist/i18n-react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { DEFAULT_PER_PAGE } from "../../../utils/constants"; @@ -55,38 +56,38 @@ export const toOrderParam = (columnKey, dir) => { const columns = [ { columnKey: "number", - header: "Order #", + header: T.translate("sponsor_reports_page.col_order"), sortable: true, render: (row) => row.purchase_number }, { columnKey: "sponsor", - header: "Sponsor", + header: T.translate("sponsor_reports_page.col_sponsor"), sortable: true, render: (row) => row.sponsor?.name ?? "" }, { columnKey: "order_date", - header: "Checkout Time", + header: T.translate("sponsor_reports_page.col_checkout_time"), sortable: true, // render reads checkout_at (ISO or epoch) via the shared helper. render: (row) => formatCheckoutTime(row.checkout_at) }, { columnKey: "form_display", - header: "Type", + header: T.translate("sponsor_reports_page.col_type"), sortable: false, // not a backend ordering field render: (row) => row.form?.display ?? "" }, { columnKey: "status", - header: "Status", + header: T.translate("sponsor_reports_page.col_status"), sortable: true, render: (row) => }, { columnKey: "invoice_total", - header: "Invoice Total", + header: T.translate("sponsor_reports_page.col_invoice_total"), sortable: true, align: "right", render: (row) => @@ -96,7 +97,7 @@ const columns = [ }, { columnKey: "sponsor_note", - header: "Sponsor Note", + header: T.translate("sponsor_reports_page.col_sponsor_note"), sortable: false // not a backend ordering field // No render — MuiTable fallback reads row["sponsor_note"] directly. } diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index a3938c88a..e744a9110 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -180,28 +180,46 @@ describe("OrdersTable rendering", () => { const sortLabelTexts = sortLabels.map((el) => el.textContent.trim()); // Sortable columns are wrapped in sort labels - expect(sortLabelTexts.some((t) => t.includes("Order #"))).toBe(true); - expect(sortLabelTexts.some((t) => t.includes("Sponsor"))).toBe(true); - expect(sortLabelTexts.some((t) => t.includes("Checkout Time"))).toBe(true); - expect(sortLabelTexts.some((t) => t.includes("Invoice Total"))).toBe(true); + expect( + sortLabelTexts.some((t) => t.includes("sponsor_reports_page.col_order")) + ).toBe(true); + expect( + sortLabelTexts.some((t) => t.includes("sponsor_reports_page.col_sponsor")) + ).toBe(true); + expect( + sortLabelTexts.some((t) => + t.includes("sponsor_reports_page.col_checkout_time") + ) + ).toBe(true); + expect( + sortLabelTexts.some((t) => + t.includes("sponsor_reports_page.col_invoice_total") + ) + ).toBe(true); // Non-sortable columns are NOT in sort labels - expect(sortLabelTexts.some((t) => t.includes("Type"))).toBe(false); - expect(sortLabelTexts.some((t) => t.includes("Sponsor Note"))).toBe(false); + expect( + sortLabelTexts.some((t) => t.includes("sponsor_reports_page.col_type")) + ).toBe(false); + expect( + sortLabelTexts.some((t) => + t.includes("sponsor_reports_page.col_sponsor_note") + ) + ).toBe(false); }); it("clicking a sortable column header calls onSort with (columnKey, dir)", () => { const handleSort = jest.fn(); renderTable([sampleRow], { onSort: handleSort }); // TableSortLabel for "Order #" has onClick → calls onSort("number", dir) - fireEvent.click(screen.getByText("Order #")); + fireEvent.click(screen.getByText("sponsor_reports_page.col_order")); expect(handleSort).toHaveBeenCalledWith("number", expect.any(Number)); }); it("clicking non-sortable Type or Sponsor Note header does NOT call onSort", () => { const handleSort = jest.fn(); renderTable([sampleRow], { onSort: handleSort }); - fireEvent.click(screen.getByText("Type")); - fireEvent.click(screen.getByText("Sponsor Note")); + fireEvent.click(screen.getByText("sponsor_reports_page.col_type")); + fireEvent.click(screen.getByText("sponsor_reports_page.col_sponsor_note")); expect(handleSort).not.toHaveBeenCalled(); }); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index d94fa9db0..8ae52e979 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4307,6 +4307,8 @@ "view_line_items": "Line Items", "lines_count": "{count} lines", "destination_booth_fallback": "Booth", + "col_checkout_time": "Checkout Time", + "col_invoice_total": "Invoice Total", "col_order": "Order #", "col_form_code": "Form Code", "col_item_code": "Item Code", @@ -4315,6 +4317,9 @@ "col_checkout_at": "Checked Out At", "col_notes": "Notes", "col_quantity": "Qty", + "col_sponsor": "Sponsor", + "col_sponsor_note": "Sponsor Note", + "col_type": "Type", "col_used_rate": "Used Rate", "col_status": "Status", "col_line_total": "Line Total" diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index f82e21c33..fa2c83174 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -342,7 +342,7 @@ describe("PurchaseDetailsReportPage", () => { // Clicking the "Order #" sort label toggles direction. Initial sortDir is 1 (asc), // so MuiTable calls onSort("number", -1) → order param "-number". - const orderHeader = screen.getByText("Order #"); + const orderHeader = screen.getByText("sponsor_reports_page.col_order"); await act(async () => { fireEvent.click(orderHeader); }); From 108a0cb52c2ee1528379803e657c445cdf2c4cd0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:09:27 -0500 Subject: [PATCH 49/63] refactor(sponsor-reports): use DEFAULT_CURRENT_PAGE for page-number defaults Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/components/sponsors/reports/LinesManifestView.js | 3 ++- src/components/sponsors/reports/OrdersTable.js | 7 +++++-- .../sponsor-reports-purchase-details-lines-reducer.js | 6 +++--- .../sponsors/sponsor-reports-purchase-details-reducer.js | 6 +++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 5fd67a8f0..52f95b362 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -34,6 +34,7 @@ import StatusPill from "./StatusPill"; import { formatCheckoutTime } from "./OrdersTable"; import { bucketLinesBySponsor } from "./manifest-grouping"; import { + DEFAULT_CURRENT_PAGE, DEFAULT_PER_PAGE, FIFTY_PER_PAGE, MAX_PER_PAGE, @@ -76,7 +77,7 @@ const HEADERS = [ const LinesManifestView = ({ rows = [], total = 0, - currentPage = 1, + currentPage = DEFAULT_CURRENT_PAGE, perPage = FIFTY_PER_PAGE, onPageChange, onPerPageChange diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 470cd34ff..c7eb218b3 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -16,7 +16,10 @@ import moment from "moment-timezone"; import T from "i18n-react/dist/i18n-react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; -import { DEFAULT_PER_PAGE } from "../../../utils/constants"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_PER_PAGE +} from "../../../utils/constants"; import StatusPill from "./StatusPill"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" @@ -108,7 +111,7 @@ const columns = [ const OrdersTable = ({ rows = [], totalRows = 0, - currentPage = 1, + currentPage = DEFAULT_CURRENT_PAGE, perPage = DEFAULT_PER_PAGE, order = null, orderDir = 1, diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js index 152e95771..9a7bace64 100644 --- a/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-lines-reducer.js @@ -12,7 +12,7 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { DEFAULT_PER_PAGE } from "../../utils/constants"; +import { DEFAULT_CURRENT_PAGE, DEFAULT_PER_PAGE } from "../../utils/constants"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_PURCHASE_DETAILS_LINES, @@ -24,8 +24,8 @@ export const DEFAULT_STATE = { data: [], summary: null, total: 0, - currentPage: 1, - lastPage: 1, + currentPage: DEFAULT_CURRENT_PAGE, + lastPage: DEFAULT_CURRENT_PAGE, perPage: DEFAULT_PER_PAGE, loading: false, readError: null diff --git a/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js index 48d66f90e..b4fae6899 100644 --- a/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js @@ -12,7 +12,7 @@ * */ import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { DEFAULT_PER_PAGE } from "../../utils/constants"; +import { DEFAULT_CURRENT_PAGE, DEFAULT_PER_PAGE } from "../../utils/constants"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { REQUEST_PURCHASE_DETAILS, @@ -28,8 +28,8 @@ export const DEFAULT_STATE = { summary: null, filterOptions: null, total: 0, - currentPage: 1, - lastPage: 1, + currentPage: DEFAULT_CURRENT_PAGE, + lastPage: DEFAULT_CURRENT_PAGE, perPage: DEFAULT_PER_PAGE, query: {}, loading: false, From 57defe60a79101f2c8b1e9286dc91200408f6725 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:10:51 -0500 Subject: [PATCH 50/63] refactor(sponsor-reports): use Grid2 in asset drilldown page Replace deprecated MUI v4 Grid (item xs={}) with Grid2 size={{}} API, matching every other file in this PR. Co-Authored-By: Claude Sonnet 4.6 --- .../sponsor-asset-drilldown-page/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index a9e9eaf68..d3d42c6b4 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -32,7 +32,7 @@ import { Button, Card, CardContent, - Grid, + Grid2, Link as MuiLink, Stack, Typography @@ -281,9 +281,9 @@ const SponsorAssetDrilldownPage = ({ {T.translate("sponsor_reports_page.download_csv")} - + {section.modules?.map((row) => ( - + - + ))} - + ))} From 3ae7147f65c35648f64a44a90afdac08d6052af0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:13:22 -0500 Subject: [PATCH 51/63] refactor(sponsor-reports): use object-shorthand mapDispatchToProps in asset pages Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsor-asset-drilldown-page/index.js | 10 ++++------ .../sponsor-reports/sponsor-asset-report-page/index.js | 10 +++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index d3d42c6b4..070c69e1b 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -327,12 +327,10 @@ const mapStateToProps = ({ sponsorReportsDrilldownState }) => ({ ...sponsorReportsDrilldownState }); -const mapDispatchToProps = (dispatch) => ({ - getSponsorAssetSponsor: (sponsorId) => - dispatch(getSponsorAssetSponsor(sponsorId)), - exportSponsorAssetSectionCsv: (sponsorId, pageId) => - dispatch(exportSponsorAssetSectionCsv(sponsorId, pageId)) -}); +const mapDispatchToProps = { + getSponsorAssetSponsor, + exportSponsorAssetSectionCsv +}; export default withRouter( connect(mapStateToProps, mapDispatchToProps)(SponsorAssetDrilldownPage) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index dc308444b..05aae0f1b 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -237,11 +237,11 @@ const mapStateToProps = ({ ...sponsorReportsSponsorAssetState }); -const mapDispatchToProps = (dispatch) => ({ - getSponsorAssetReport: (query) => dispatch(getSponsorAssetReport(query)), - getSponsorAssetFilters: () => dispatch(getSponsorAssetFilters()), - exportSponsorAssetCsv: (filters) => dispatch(exportSponsorAssetCsv(filters)) -}); +const mapDispatchToProps = { + getSponsorAssetReport, + getSponsorAssetFilters, + exportSponsorAssetCsv +}; export default withRouter( connect(mapStateToProps, mapDispatchToProps)(SponsorAssetReportPage) From c8b71f4546e66d2086afab4f0c108356da861703 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:16:53 -0500 Subject: [PATCH 52/63] refactor(sponsor-reports): move query builders to actions layer, fold in toOrderParam Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/sponsor-reports-query.test.js} | 25 ++++++++++++++++++- src/actions/sponsor-reports-actions.js | 2 +- .../sponsor-reports-query.js} | 10 ++++++-- .../sponsors/reports/OrdersTable.js | 9 ------- .../reports/__tests__/OrdersTable.test.js | 20 +-------------- .../purchase-details-report-page/index.js | 5 +++- .../sponsor-asset-report-page/index.js | 2 +- 7 files changed, 39 insertions(+), 34 deletions(-) rename src/{pages/sponsors/sponsor-reports/__tests__/report-query.test.js => actions/__tests__/sponsor-reports-query.test.js} (71%) rename src/{pages/sponsors/sponsor-reports/report-query.js => actions/sponsor-reports-query.js} (92%) diff --git a/src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js b/src/actions/__tests__/sponsor-reports-query.test.js similarity index 71% rename from src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js rename to src/actions/__tests__/sponsor-reports-query.test.js index e5c1f618f..09c975a53 100644 --- a/src/pages/sponsors/sponsor-reports/__tests__/report-query.test.js +++ b/src/actions/__tests__/sponsor-reports-query.test.js @@ -1,4 +1,9 @@ -import { buildReportQuery , buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; +import { + buildReportQuery, + buildPurchaseQuery, + buildPurchaseLinesQuery, + toOrderParam +} from "../sponsor-reports-query"; describe("buildReportQuery", () => { it("returns an empty object for no filters", () => { @@ -98,3 +103,21 @@ describe("buildPurchaseLinesQuery", () => { expect(q).not.toHaveProperty("order"); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// toOrderParam — moved here from OrdersTable since it is query-layer logic +// ──────────────────────────────────────────────────────────────────────────── +describe("toOrderParam", () => { + it("encodes asc (dir=1) and desc (dir=-1)", () => { + expect(toOrderParam("number", 1)).toBe("number"); + expect(toOrderParam("number", -1)).toBe("-number"); + expect(toOrderParam("order_date", -1)).toBe("-order_date"); + expect(toOrderParam("invoice_total", 1)).toBe("invoice_total"); + }); + + it("returns undefined when columnKey is falsy", () => { + expect(toOrderParam(null, 1)).toBeUndefined(); + expect(toOrderParam(undefined, 1)).toBeUndefined(); + expect(toOrderParam("", 1)).toBeUndefined(); + }); +}); diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index e6bafe7dc..126b3a14c 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -11,7 +11,7 @@ import { buildReportQuery, buildPurchaseQuery, buildPurchaseLinesQuery -} from "../pages/sponsors/sponsor-reports/report-query"; +} from "./sponsor-reports-query"; export const REQUEST_PURCHASE_DETAILS = "REQUEST_PURCHASE_DETAILS"; export const RECEIVE_PURCHASE_DETAILS = "RECEIVE_PURCHASE_DETAILS"; diff --git a/src/pages/sponsors/sponsor-reports/report-query.js b/src/actions/sponsor-reports-query.js similarity index 92% rename from src/pages/sponsors/sponsor-reports/report-query.js rename to src/actions/sponsor-reports-query.js index 2f19b677e..bb52b7cbc 100644 --- a/src/pages/sponsors/sponsor-reports/report-query.js +++ b/src/actions/sponsor-reports-query.js @@ -1,4 +1,4 @@ -// src/pages/sponsors/sponsor-reports/report-query.js +// src/actions/sponsor-reports-query.js // // Translates report UI filter state into a base-api-utils query object. // @@ -10,7 +10,13 @@ // (a no-operator value triggers a server IndexError → 500). import moment from "moment-timezone"; -import { toOrderParam } from "../../../components/sponsors/reports/OrdersTable"; + +// Converts MuiTable sort state to the `order` query param expected by the API. +// MuiTable calls onSort(columnKey, dir) where dir = 1 (asc) | -1 (desc). +export const toOrderParam = (columnKey, dir) => { + if (!columnKey) return undefined; + return dir === -1 ? `-${columnKey}` : columnKey; +}; export const buildReportQuery = (filters = {}) => { const { diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index c7eb218b3..e33eb755b 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -42,15 +42,6 @@ export const formatCheckoutTime = (value) => { return m.format("YYYY-MM-DD h:mm A"); }; -// Converts MuiTable sort state to the `order` query param expected by the API. -// MuiTable calls onSort(columnKey, dir) where dir = 1 (asc) | -1 (desc). -// Since columnKey IS the backend key for sortable columns, no extra translation -// is needed — the page passes (key, dir) directly to buildReportQuery's `order`. -export const toOrderParam = (columnKey, dir) => { - if (!columnKey) return undefined; - return dir === -1 ? `-${columnKey}` : columnKey; -}; - // MuiTable column definitions. // columnKey for sortable columns equals the backend `order=` parameter so // onSort(columnKey, dir) → toOrderParam(columnKey, dir) yields the correct string diff --git a/src/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js index e744a9110..3e383998f 100644 --- a/src/components/sponsors/reports/__tests__/OrdersTable.test.js +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -2,7 +2,7 @@ import "@testing-library/jest-dom"; import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; -import OrdersTable, { formatCheckoutTime, toOrderParam } from "../OrdersTable"; +import OrdersTable, { formatCheckoutTime } from "../OrdersTable"; // MuiTable uses i18n-react internally (no-items message, pagination labels). jest.mock("i18n-react/dist/i18n-react", () => ({ @@ -70,24 +70,6 @@ describe("formatCheckoutTime", () => { }); }); -// ──────────────────────────────────────────────────────────────────────────── -// sort-key helpers -// ──────────────────────────────────────────────────────────────────────────── -describe("OrdersTable sort helpers", () => { - it("toOrderParam encodes asc (dir=1) and desc (dir=-1)", () => { - expect(toOrderParam("number", 1)).toBe("number"); - expect(toOrderParam("number", -1)).toBe("-number"); - expect(toOrderParam("order_date", -1)).toBe("-order_date"); - expect(toOrderParam("invoice_total", 1)).toBe("invoice_total"); - }); - - it("toOrderParam returns undefined when columnKey is falsy", () => { - expect(toOrderParam(null, 1)).toBeUndefined(); - expect(toOrderParam(undefined, 1)).toBeUndefined(); - expect(toOrderParam("", 1)).toBeUndefined(); - }); -}); - // ──────────────────────────────────────────────────────────────────────────── // OrdersTable rendering // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index eb63885f8..71afe5917 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -21,7 +21,10 @@ import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification"; -import { buildPurchaseQuery, buildPurchaseLinesQuery } from "../report-query"; +import { + buildPurchaseQuery, + buildPurchaseLinesQuery +} from "../../../../actions/sponsor-reports-query"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 05aae0f1b..1ed3be092 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -19,7 +19,7 @@ import { Box, Button, Pagination, Stack, Typography } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; -import { buildReportQuery } from "../report-query"; +import { buildReportQuery } from "../../../../actions/sponsor-reports-query"; import { isPositiveIntId } from "../../../../utils/methods"; import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; From c212e54867b998282789da807405422c88ca4680 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:27:46 -0500 Subject: [PATCH 53/63] refactor(sponsor-reports): build report queries inside thunks, not components Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/sponsor-reports-actions.test.js | 30 ++++++------ src/actions/sponsor-reports-actions.js | 9 ++-- .../__tests__/index.test.js | 39 ++++++++------- .../purchase-details-report-page/index.js | 48 +++++-------------- .../__tests__/index.test.js | 15 +++--- .../sponsor-asset-report-page/index.js | 24 +++------- 6 files changed, 71 insertions(+), 94 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 3d12a241d..3759c743c 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -117,7 +117,7 @@ describe("sponsor-reports-actions", () => { describe("getPurchaseDetailsReport", () => { it("dispatches REQUEST_PURCHASE_DETAILS then RECEIVE_PURCHASE_DETAILS", async () => { const store = mockStore(MOCK_STATE); - store.dispatch(getPurchaseDetailsReport({ page: 1 })); + store.dispatch(getPurchaseDetailsReport({}, { page: 1 })); await flushPromises(); const types = store.getActions().map((a) => a.type); @@ -133,9 +133,9 @@ describe("sponsor-reports-actions", () => { expect(capturedUrl).toContain("/summits/42/"); }); - it("passes access_token and spread query in params", async () => { + it("passes access_token and built query params (page, per_page) in outgoing request", async () => { const store = mockStore(MOCK_STATE); - store.dispatch(getPurchaseDetailsReport({ page: 2, per_page: 25 })); + store.dispatch(getPurchaseDetailsReport({}, { page: 2, perPage: 25 })); await flushPromises(); expect(capturedParams.access_token).toBe("TOKEN"); @@ -162,7 +162,7 @@ describe("sponsor-reports-actions", () => { ); const store = mockStore(MOCK_STATE); - store.dispatch(getPurchaseDetailsReport({ page: 1 })); + store.dispatch(getPurchaseDetailsReport({}, { page: 1 })); await flushPromises(); const actions = store.getActions(); @@ -216,7 +216,7 @@ describe("sponsor-reports-actions", () => { describe("getSponsorAssetReport", () => { it("dispatches REQUEST_SPONSOR_ASSET then RECEIVE_SPONSOR_ASSET", async () => { const store = mockStore(MOCK_STATE); - store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + store.dispatch(getSponsorAssetReport({}, { groupBy: "sponsor" })); await flushPromises(); const types = store.getActions().map((a) => a.type); @@ -224,9 +224,9 @@ describe("sponsor-reports-actions", () => { expect(types).toContain(RECEIVE_SPONSOR_ASSET); }); - it("passes access_token and query params", async () => { + it("passes access_token and built group_by param in outgoing request", async () => { const store = mockStore(MOCK_STATE); - store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + store.dispatch(getSponsorAssetReport({}, { groupBy: "sponsor" })); await flushPromises(); expect(capturedParams.access_token).toBe("TOKEN"); @@ -260,7 +260,7 @@ describe("sponsor-reports-actions", () => { ); const store = mockStore(MOCK_STATE); - store.dispatch(getSponsorAssetReport({ group_by: "sponsor" })); + store.dispatch(getSponsorAssetReport({}, { groupBy: "sponsor" })); await flushPromises(); const types = store.getActions().map((a) => a.type); @@ -378,24 +378,26 @@ describe("sponsor-reports-actions", () => { .mockResolvedValue("test-token"); }); - it("GETs the /purchase-details/lines endpoint with query + access_token and NO order", async () => { + it("GETs the /purchase-details/lines endpoint with built query + access_token and NO order", async () => { makeHappyGetRequest(); const store = mockStore(MOCK_STATE); const { getPurchaseDetailsLinesReport } = require("../sponsor-reports-actions"); + // Pass primitives (filters + pagination); thunk calls buildPurchaseLinesQuery internally. await store.dispatch( - getPurchaseDetailsLinesReport({ - page: 1, - per_page: 50, - "filter[]": ["sponsor_id==17"] - }) + getPurchaseDetailsLinesReport( + { sponsorIds: [17] }, + { page: 1, perPage: 50 } + ) ); await flushPromises(); expect(capturedUrl).toMatch( /\/api\/v1\/summits\/42\/reports\/purchase-details\/lines$/ ); + // buildPurchaseLinesQuery({ sponsorIds: [17] }, { page: 1, perPage: 50 }) → + // { "filter[]": ["sponsor_id==17"], page: 1, per_page: 50 } — no order emitted. expect(capturedParams).toMatchObject({ access_token: "test-token", page: 1, diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 126b3a14c..2e24ce415 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -42,7 +42,7 @@ const base = (summitId) => `${window.SPONSOR_REPORTS_API_URL}/api/v1/summits/${summitId}/reports`; export const getPurchaseDetailsReport = - (query = {}) => + (filters = {}, pagination = {}) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; @@ -51,6 +51,7 @@ export const getPurchaseDetailsReport = if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); + const query = buildPurchaseQuery(filters, pagination); const params = { access_token: accessToken, ...query }; return getRequest( createAction(REQUEST_PURCHASE_DETAILS), @@ -74,13 +75,14 @@ export const clearPurchaseDetailsValidation = () => ({ }); export const getPurchaseDetailsLinesReport = - (query = {}) => + (filters = {}, pagination = {}) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); + const query = buildPurchaseLinesQuery(filters, pagination); const params = { access_token: accessToken, ...query }; return getRequest( createAction(REQUEST_PURCHASE_DETAILS_LINES), @@ -117,7 +119,7 @@ export const getPurchaseDetailsFilters = () => async (dispatch, getState) => { }; export const getSponsorAssetReport = - (query = {}) => + (filters = {}, options = {}) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; @@ -126,6 +128,7 @@ export const getSponsorAssetReport = if (!currentSummit?.id) return Promise.resolve(); const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); + const query = buildReportQuery({ ...filters, ...options }); const params = { access_token: accessToken, ...query }; return getRequest( createAction(REQUEST_SPONSOR_ASSET), diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js index fa2c83174..4a8dca173 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -178,11 +178,12 @@ describe("PurchaseDetailsReportPage", () => { expect(getPurchaseDetailsFilters).toHaveBeenCalled(); }); - it("dispatches getPurchaseDetailsReport with page=1 and per_page=10 on initial load", async () => { + it("dispatches getPurchaseDetailsReport with page=1 and perPage=10 on initial load", async () => { renderPage(); await act(async () => {}); expect(getPurchaseDetailsReport).toHaveBeenCalledWith( - expect.objectContaining({ page: 1, per_page: 10 }) + {}, + expect.objectContaining({ page: 1, perPage: 10 }) ); }); @@ -292,14 +293,13 @@ describe("PurchaseDetailsReportPage", () => { fireEvent.click(applyBtn); }); - // Filter change → query memo invalidated → useEffect re-fires → re-fetch + // Filter change → useEffect re-fires → re-fetch with new primitives expect(getPurchaseDetailsReport).toHaveBeenCalled(); - const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; - // Date filter is expanded to ISO and placed in filter[] - expect(calledQuery["filter[]"]).toEqual( - expect.arrayContaining([expect.stringContaining("order_date>=")]) - ); - expect(calledQuery).toMatchObject({ page: 1 }); + const [[calledFilters, calledPagination]] = + getPurchaseDetailsReport.mock.calls; + // The page passes the raw filter object; date expansion happens inside the thunk. + expect(calledFilters).toMatchObject({ dateFrom: "2026-01-01" }); + expect(calledPagination).toMatchObject({ page: 1 }); }); it("CSV export button calls exportPurchaseDetailsCsv with current filters and sort", async () => { @@ -331,8 +331,8 @@ describe("PurchaseDetailsReportPage", () => { }); expect(getPurchaseDetailsReport).toHaveBeenCalled(); - const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; - expect(calledQuery).toMatchObject({ page: 2, per_page: 10 }); + const [[, calledPagination]] = getPurchaseDetailsReport.mock.calls; + expect(calledPagination).toMatchObject({ page: 2, perPage: 10 }); }); it("re-dispatches getPurchaseDetailsReport with the backend order param when a sortable column header is clicked", async () => { @@ -348,9 +348,14 @@ describe("PurchaseDetailsReportPage", () => { }); expect(getPurchaseDetailsReport).toHaveBeenCalled(); - const [[calledQuery]] = getPurchaseDetailsReport.mock.calls; - // Sort change snaps back to page 1; order is the backend key with desc prefix. - expect(calledQuery).toMatchObject({ page: 1, order: "-number" }); + const [[, calledPagination]] = getPurchaseDetailsReport.mock.calls; + // Sort change snaps back to page 1; raw primitives — thunk converts order/orderDir + // to the backend "-number" format internally via toOrderParam. + expect(calledPagination).toMatchObject({ + page: 1, + order: "number", + orderDir: -1 + }); }); it("renders the Orders/Line-Items view toggle", async () => { @@ -371,9 +376,9 @@ describe("PurchaseDetailsReportPage", () => { }); expect(getPurchaseDetailsLinesReport).toHaveBeenCalled(); - const [[calledQuery]] = getPurchaseDetailsLinesReport.mock.calls; - expect(calledQuery).toMatchObject({ page: 1, per_page: 50 }); - expect(calledQuery).not.toHaveProperty("order"); + const [[, calledPagination]] = getPurchaseDetailsLinesReport.mock.calls; + expect(calledPagination).toMatchObject({ page: 1, perPage: 50 }); + expect(calledPagination).not.toHaveProperty("order"); // Manifest renders the line's destination expect(screen.getByText("Meeting Room T")).toBeInTheDocument(); }); diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index 71afe5917..f8386a595 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; @@ -21,10 +21,6 @@ import DownloadIcon from "@mui/icons-material/Download"; import ShoppingCartOutlinedIcon from "@mui/icons-material/ShoppingCartOutlined"; import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification"; -import { - buildPurchaseQuery, - buildPurchaseLinesQuery -} from "../../../../actions/sponsor-reports-query"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; @@ -92,31 +88,6 @@ const PurchaseDetailsReportPage = ({ const [linesPage, setLinesPage] = useState(DEFAULT_CURRENT_PAGE); const [linesPerPage, setLinesPerPage] = useState(FIFTY_PER_PAGE); - // Build the API query from all local state. Memoized so useEffect only re-runs - // when the query actually changes (referential stability). - const query = useMemo( - () => - buildPurchaseQuery(filters, { - page: currentPage, - perPage, - order, - orderDir - }), - [filters, currentPage, perPage, order, orderDir] - ); - - // Lines query: same filters as Orders, but NO order param. CustomOrderingFilter - // would replace the default sponsor-name ordering and scatter the sponsor groups, - // so the manifest relies on the backend default ordering. - const linesQuery = useMemo( - () => - buildPurchaseLinesQuery(filters, { - page: linesPage, - perPage: linesPerPage - }), - [filters, linesPage, linesPerPage] - ); - // Fetch filters once on mount. Summit is read from store inside the action. // Empty deps is intentional: fetchFilters is stable from connect() and reads // summit from Redux store inside the thunk. @@ -124,15 +95,20 @@ const PurchaseDetailsReportPage = ({ fetchFilters(); }, []); // mount-only - // Orders view: fetch the order-grain report when its query changes. + // Orders view: fetch the order-grain report when any primitive input changes. + // The thunk builds the API query (date expansion, filter[] assembly, sort) internally. useEffect(() => { - if (view === "orders") fetchReport(query); - }, [view, query]); + if (view === "orders") + fetchReport(filters, { page: currentPage, perPage, order, orderDir }); + }, [view, filters, currentPage, perPage, order, orderDir]); - // Line Items view: fetch the per-line feed when its query changes. + // Line Items view: fetch the per-line feed when its inputs change. NO order param — + // CustomOrderingFilter would replace the default sponsor-name ordering and scatter + // the sponsor groups, so the manifest relies on the backend default ordering. useEffect(() => { - if (view === "lines") fetchLinesReport(linesQuery); - }, [view, linesQuery]); + if (view === "lines") + fetchLinesReport(filters, { page: linesPage, perPage: linesPerPage }); + }, [view, filters, linesPage, linesPerPage]); // ── Summary tiles ─────────────────────────────────────────────────────────── // D9: Total Refunded tile renders ONLY when activeSummary.total_refunded != null. diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index 33a61456c..5fbbf6057 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -113,7 +113,8 @@ describe("SponsorAssetReportPage", () => { await act(async () => {}); expect(getSponsorAssetFilters).toHaveBeenCalledWith(); expect(getSponsorAssetReport).toHaveBeenCalledWith( - expect.objectContaining({ group_by: "sponsor" }) + {}, + expect.objectContaining({ groupBy: "sponsor" }) ); }); @@ -134,8 +135,9 @@ describe("SponsorAssetReportPage", () => { getSponsorAssetReport.mock.calls[ getSponsorAssetReport.mock.calls.length - 1 ]; - expect(lastCall[0]).toEqual( - expect.objectContaining({ group_by: "component" }) + // Second arg is the options object — thunk converts groupBy → group_by internally. + expect(lastCall[1]).toEqual( + expect.objectContaining({ groupBy: "component" }) ); }); @@ -176,11 +178,12 @@ describe("SponsorAssetReportPage", () => { await act(async () => {}); expect(getSponsorAssetReport).toHaveBeenCalled(); - const query = + const calledOptions = getSponsorAssetReport.mock.calls[ getSponsorAssetReport.mock.calls.length - 1 - ][0]; - expect(query).toMatchObject({ page: 2 }); + ][1]; + // Second arg is the options object containing page, groupBy, perPage. + expect(calledOptions).toMatchObject({ page: 2 }); }); it("renders the summit-not-found guard when currentSummit is null", async () => { diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 1ed3be092..499b51927 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; @@ -19,7 +19,6 @@ import { Box, Button, Pagination, Stack, Typography } from "@mui/material"; import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; -import { buildReportQuery } from "../../../../actions/sponsor-reports-query"; import { isPositiveIntId } from "../../../../utils/methods"; import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; @@ -81,24 +80,13 @@ const SponsorAssetReportPage = ({ if (validSummit) fetchFilters(); }, []); // mount-only — validSummit is stable once the summit context is set - // Build the API query from all local state. Memoized so useEffect only re-runs - // when the query actually changes (referential stability). - const query = useMemo( - () => - buildReportQuery({ - ...filters, - groupBy, - page, - perPage: GROUP_PER_PAGE - }), - [filters, groupBy, page] - ); - - // Fetch the grouped report whenever the derived query changes; skips if + // Fetch the grouped report when any primitive input changes; skips if // currentSummit is not yet available (rare — summit always loads before nav). + // The thunk builds the API query (group_by, per_page, filter[]) internally. useEffect(() => { - if (validSummit) fetchReport(query); - }, [query]); // query is memoized; re-fetches only on real changes + if (validSummit) + fetchReport(filters, { groupBy, page, perPage: GROUP_PER_PAGE }); + }, [filters, groupBy, page]); // validSummit omitted intentionally — stable once summit loads const onApply = (next) => { setPage(DEFAULT_CURRENT_PAGE); From c18013e996c206c03ffb312ed9b5ba4314e948ed Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:30:33 -0500 Subject: [PATCH 54/63] refactor(sponsor-reports): move report-errors helper to actions layer Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- src/actions/__tests__/sponsor-reports-actions.test.js | 2 +- src/actions/sponsor-reports-actions.js | 2 +- .../report-errors.js => actions/sponsor-reports-errors.js} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/{pages/sponsors/sponsor-reports/report-errors.js => actions/sponsor-reports-errors.js} (98%) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index 3759c743c..f0b9f83d2 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -7,7 +7,7 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import { doLogin } from "openstack-uicore-foundation/lib/security/methods"; import * as methods from "../../utils/methods"; -import { makeReadErrorHandler } from "../../pages/sponsors/sponsor-reports/report-errors"; +import { makeReadErrorHandler } from "../sponsor-reports-errors"; import { getPurchaseDetailsReport, diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 2e24ce415..203393383 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -6,7 +6,7 @@ import { stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; import { getAccessTokenSafely } from "../utils/methods"; -import { makeReadErrorHandler } from "../pages/sponsors/sponsor-reports/report-errors"; +import { makeReadErrorHandler } from "./sponsor-reports-errors"; import { buildReportQuery, buildPurchaseQuery, diff --git a/src/pages/sponsors/sponsor-reports/report-errors.js b/src/actions/sponsor-reports-errors.js similarity index 98% rename from src/pages/sponsors/sponsor-reports/report-errors.js rename to src/actions/sponsor-reports-errors.js index 078dc46a9..9e5095a0f 100644 --- a/src/pages/sponsors/sponsor-reports/report-errors.js +++ b/src/actions/sponsor-reports-errors.js @@ -6,7 +6,7 @@ import { ERROR_CODE_404, ERROR_CODE_412, ERROR_CODE_503 -} from "../../../utils/constants"; +} from "../utils/constants"; export const extractErrorMessage = (err = {}, res = {}) => { const candidates = [ From 9e357d37302d8338bc3879d8902ca686e647d480 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 14:45:19 -0500 Subject: [PATCH 55/63] docs(sponsor-reports): fix stale toOrderParam comment in OrdersTable toOrderParam moved to the query thunk in an earlier commit; the column comment no longer describes a page-handler conversion. --- src/components/sponsors/reports/OrdersTable.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index e33eb755b..35b5f852e 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -43,9 +43,8 @@ export const formatCheckoutTime = (value) => { }; // MuiTable column definitions. -// columnKey for sortable columns equals the backend `order=` parameter so -// onSort(columnKey, dir) → toOrderParam(columnKey, dir) yields the correct string -// without any additional translation in the page handler. +// columnKey for sortable columns equals the backend `order=` field, so the +// query thunk formats the sort direction from (columnKey, dir) directly. // Non-sortable columns (Type, Sponsor Note) use arbitrary unique keys. const columns = [ { From 52dba3101808eb341abbc02bb8ed524ff51b4266 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 15:22:14 -0500 Subject: [PATCH 56/63] feat(sponsor-reports): add ContentTypeToggle (Collected/All) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsors/reports/ContentTypeToggle.js | 32 +++++++++++++++++++ .../__tests__/ContentTypeToggle.test.js | 28 ++++++++++++++++ src/i18n/en.json | 3 ++ 3 files changed, 63 insertions(+) create mode 100644 src/components/sponsors/reports/ContentTypeToggle.js create mode 100644 src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js diff --git a/src/components/sponsors/reports/ContentTypeToggle.js b/src/components/sponsors/reports/ContentTypeToggle.js new file mode 100644 index 000000000..f40831e65 --- /dev/null +++ b/src/components/sponsors/reports/ContentTypeToggle.js @@ -0,0 +1,32 @@ +import React from "react"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import T from "i18n-react/dist/i18n-react"; + +// MUI ToggleButtonGroup passes `null` when the active button is re-clicked +// (exclusive mode); ignore it so the view never ends up with no content type. +const ContentTypeToggle = ({ value, onChange }) => ( + { + if (next) onChange(next); + }} + aria-label={T.translate("sponsor_reports_page.content_type")} + // Match the adjacent action buttons (Print / Export CSV) typography. + // px, not rem: html root font-size is 62.5% (10px) here, so "0.875rem" would + // render 8.75px; the MuiButton resolves to 14px. + sx={{ + "& .MuiToggleButton-root": { px: 2.5, fontSize: "14px", fontWeight: 500 } + }} + > + + {T.translate("sponsor_reports_page.content_collected")} + + + {T.translate("sponsor_reports_page.content_all")} + + +); + +export default ContentTypeToggle; diff --git a/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js b/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js new file mode 100644 index 000000000..2f90b88cd --- /dev/null +++ b/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js @@ -0,0 +1,28 @@ +// src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js +import "@testing-library/jest-dom"; +import React from "react"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithRedux } from "utils/test-utils"; +import ContentTypeToggle from "../ContentTypeToggle"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); + +describe("ContentTypeToggle", () => { + it("shows the active value and calls onChange with the other value", () => { + const onChange = jest.fn(); + renderWithRedux( + + ); + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + expect(onChange).toHaveBeenCalledWith("all"); + }); + + it("ignores a null toggle (clicking the already-active button) — never clears", () => { + const onChange = jest.fn(); + renderWithRedux( + + ); + fireEvent.click(screen.getByText("sponsor_reports_page.content_collected")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 8ae52e979..415b6f225 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4292,6 +4292,9 @@ "group_by": "Group by", "group_by_sponsor": "Sponsor", "group_by_component": "Component", + "content_type": "Content", + "content_collected": "Collected", + "content_all": "All", "components_count": "{count} components", "sponsors_count": "{count} sponsors", "unnamed_component": "(Unnamed)", From 9ec067b63708605cf43379d27bfaef5e6be557c2 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 15:24:41 -0500 Subject: [PATCH 57/63] feat(sponsor-reports): Collected/All content filter on asset report page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire ContentTypeToggle (default: collected) into SponsorAssetReportPage — injects moduleType:"Media" on fetch + CSV export when collected, omits it for all; resets page on toggle. Tests: 12/12 (+3 new), full suite 995/995. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/index.test.js | 70 ++++++++++++++++++- .../sponsor-asset-report-page/index.js | 24 ++++++- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index 5fbbf6057..7f46b8764 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -112,8 +112,9 @@ describe("SponsorAssetReportPage", () => { renderPage(); await act(async () => {}); expect(getSponsorAssetFilters).toHaveBeenCalledWith(); + // Default contentType is "collected" → moduleType: "Media" injected into first arg. expect(getSponsorAssetReport).toHaveBeenCalledWith( - {}, + expect.objectContaining({ moduleType: "Media" }), expect.objectContaining({ groupBy: "sponsor" }) ); }); @@ -235,8 +236,71 @@ describe("SponsorAssetReportPage", () => { ); await act(async () => {}); - // Initial filters state is {} — the thunk is called with those filters. - expect(exportSponsorAssetCsv).toHaveBeenCalledWith({}); + // Default contentType is "collected" → moduleType: "Media" injected into first arg. + expect(exportSponsorAssetCsv).toHaveBeenCalledWith( + expect.objectContaining({ moduleType: "Media" }) + ); + }); + + it("fetches with moduleType=Media by default (Collected mode)", async () => { + renderPage(); + await act(async () => {}); + const firstArg = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ][0]; + expect(firstArg).toEqual(expect.objectContaining({ moduleType: "Media" })); + }); + + it("fetches with moduleType undefined after toggling to All", async () => { + renderPage({ data: [], currentPage: 1, lastPage: 1 }); + await act(async () => {}); + getSponsorAssetReport.mockClear(); + + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_reports_page.content_all" + }) + ); + await act(async () => {}); + + expect(getSponsorAssetReport).toHaveBeenCalled(); + const firstArg = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ][0]; + // "All" → no moduleType filter (omit entirely / undefined) + expect(firstArg).not.toHaveProperty("moduleType", "Media"); + expect(firstArg.moduleType).toBeUndefined(); + }); + + it("resets page to DEFAULT_CURRENT_PAGE when content type is toggled", async () => { + renderPage({ data: [], lastPage: 3, currentPage: 1 }); + await act(async () => {}); + + // Navigate to page 2 first + const nav = screen.getByRole("navigation"); + const page2 = Array.from(nav.querySelectorAll("button")).find((b) => + b.textContent.includes("2") + ); + fireEvent.click(page2); + await act(async () => {}); + getSponsorAssetReport.mockClear(); + + // Toggle content type → page must reset to 1 + fireEvent.click( + screen.getByRole("button", { + name: "sponsor_reports_page.content_all" + }) + ); + await act(async () => {}); + + expect(getSponsorAssetReport).toHaveBeenCalled(); + const options = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ][1]; + expect(options).toMatchObject({ page: 1 }); }); it("hides the no-groups empty state until currentPage >= 1", async () => { diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 499b51927..859fab61c 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -25,6 +25,7 @@ import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle"; +import ContentTypeToggle from "../../../../components/sponsors/reports/ContentTypeToggle"; import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; import usePrint from "../../../../hooks/usePrint"; @@ -70,6 +71,7 @@ const SponsorAssetReportPage = ({ const validSummit = !!(currentSummit && isPositiveIntId(currentSummit.id)); const [groupBy, setGroupBy] = useState("sponsor"); + const [contentType, setContentType] = useState("collected"); const [filters, setFilters] = useState({}); const [page, setPage] = useState(DEFAULT_CURRENT_PAGE); @@ -85,8 +87,14 @@ const SponsorAssetReportPage = ({ // The thunk builds the API query (group_by, per_page, filter[]) internally. useEffect(() => { if (validSummit) - fetchReport(filters, { groupBy, page, perPage: GROUP_PER_PAGE }); - }, [filters, groupBy, page]); // validSummit omitted intentionally — stable once summit loads + fetchReport( + { + ...filters, + moduleType: contentType === "collected" ? "Media" : undefined + }, + { groupBy, page, perPage: GROUP_PER_PAGE } + ); + }, [filters, groupBy, page, contentType]); // validSummit omitted intentionally — stable once summit loads const onApply = (next) => { setPage(DEFAULT_CURRENT_PAGE); @@ -100,6 +108,10 @@ const SponsorAssetReportPage = ({ setPage(DEFAULT_CURRENT_PAGE); setGroupBy(next); }; + const onContentType = (next) => { + setPage(DEFAULT_CURRENT_PAGE); + setContentType(next); + }; const tiles = STATUS_TILE_KEYS.map((key) => ({ key, @@ -139,7 +151,12 @@ const SponsorAssetReportPage = ({ @@ -153,6 +170,7 @@ const SponsorAssetReportPage = ({ sx={{ mb: 2, flexWrap: "wrap" }} > + Date: Mon, 29 Jun 2026 15:31:03 -0500 Subject: [PATCH 58/63] feat(sponsor-reports): suppress download status + Collected/All filter in drilldown Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/index.test.js | 131 +++++++++++++++++- .../sponsor-asset-drilldown-page/index.js | 28 +++- 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index 35aa497f9..d7751f2ad 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -130,7 +130,13 @@ describe("SponsorAssetDrilldownPage", () => { pages: [ { page: { id: 9, title: "Booth", type: "page" }, - modules: [] + // At least one Media module so the section is not filtered out in collected mode. + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed" + } + ] } ] } @@ -245,6 +251,9 @@ describe("SponsorAssetDrilldownPage", () => { } }); await act(async () => {}); + // Toggle to "all" so the Document module card is visible. + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + await act(async () => {}); const pdfLink = screen.getByRole("link", { name: /deck\.pdf/i }); expect(pdfLink).toHaveAttribute("href", "https://x/deck.pdf"); expect(pdfLink).toHaveAttribute("rel", "noopener noreferrer"); @@ -295,7 +304,127 @@ describe("SponsorAssetDrilldownPage", () => { } }); await act(async () => {}); + // Toggle to "all" so the Info module card is visible. + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + await act(async () => {}); expect(screen.getByText("cespinTEST3")).toBeInTheDocument(); expect(screen.queryByText("

cespinTEST3

")).not.toBeInTheDocument(); }); + + it("S1a: Document row title is shown but StatusPill is suppressed; Media row StatusPill is present", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "submitted" + }, + { + module: { id: 2, title: "Deck", type: "Document" }, + status: "completed" + } + ] + } + ] + } + }); + await act(async () => {}); + + // Toggle to "all" so the Document row is visible alongside the Media row. + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + await act(async () => {}); + + // Document row title is rendered. + expect(screen.getByText("Deck")).toBeInTheDocument(); + // Document StatusPill is suppressed — the status label "completed" must not appear. + expect(screen.queryByText("completed")).not.toBeInTheDocument(); + // Media row StatusPill is present — the status label "submitted" appears. + expect(screen.getByText("submitted")).toBeInTheDocument(); + }); + + it("S1b default collected: only Media module cards render; a section with only non-Media rows is absent", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 2 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed" + }, + { + module: { id: 2, title: "Deck", type: "Document" }, + status: "pending" + }, + { + module: { id: 3, title: "Blurb", type: "Info" }, + status: "pending" + } + ] + }, + { + page: { id: 10, title: "Branding", type: "page" }, + modules: [ + { + module: { id: 4, title: "PDF Only", type: "Document" }, + status: "pending" + } + ] + } + ] + } + }); + await act(async () => {}); + + // Media card is visible in default "collected" mode. + expect(screen.getByText("Logo")).toBeInTheDocument(); + // Document and Info module cards are not rendered. + expect(screen.queryByText("Deck")).not.toBeInTheDocument(); + expect(screen.queryByText("Blurb")).not.toBeInTheDocument(); + // A section whose only modules are non-Media is not rendered. + expect(screen.queryByText("Branding")).not.toBeInTheDocument(); + }); + + it("S1b toggle to all: Document and Info module cards become visible", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed" + }, + { + module: { id: 2, title: "Deck", type: "Document" }, + status: "pending" + }, + { + module: { id: 3, title: "Blurb", type: "Info" }, + status: "pending" + } + ] + } + ] + } + }); + await act(async () => {}); + + // Toggle to "all". + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + await act(async () => {}); + + // All module cards are now visible. + expect(screen.getByText("Logo")).toBeInTheDocument(); + expect(screen.getByText("Deck")).toBeInTheDocument(); + expect(screen.getByText("Blurb")).toBeInTheDocument(); + }); }); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 070c69e1b..c82924124 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -23,7 +23,7 @@ // value; the ContentCell component gates on filename extension (not MIME type) // because the backend returns the same minted URL for both (sponsor_asset_serializers.py:72,76). -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; @@ -47,6 +47,7 @@ import { isPositiveIntId } from "../../../../utils/methods"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; +import ContentTypeToggle from "../../../../components/sponsors/reports/ContentTypeToggle"; import usePrint from "../../../../hooks/usePrint"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; import StatusPill from "../../../../components/sponsors/reports/StatusPill"; @@ -143,6 +144,7 @@ const SponsorAssetDrilldownPage = ({ match }) => { const print = usePrint(); + const [contentType, setContentType] = useState("collected"); // sponsorId from URL; summitId from Redux state (not URL params per summit-admin pattern). const { sponsorId } = match.params; @@ -191,6 +193,15 @@ const SponsorAssetDrilldownPage = ({ const sponsor = detail?.sponsor; const pages = detail?.pages || []; + const visiblePages = pages + .map((section) => ({ + ...section, + modules: (section.modules || []).filter( + (row) => contentType === "all" || row.module.type === "Media" + ) + })) + .filter((section) => section.modules.length > 0); + return ( } variant="outlined" onClick={print}> - {T.translate("sponsor_reports_page.print")} - + + + + } > {loading && ( @@ -251,7 +265,7 @@ const SponsorAssetDrilldownPage = ({
)} - {pages.map((section) => ( + {visiblePages.map((section) => ( {row.module.title}
- + {row.module.type !== "Document" && ( + + )} From 1b5f2e2ded1b366e4be1163fb807f0120e2fdfaa Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 15:37:26 -0500 Subject: [PATCH 59/63] fix(sponsor-reports): show empty page sections under All in drilldown Drop-empty-section now applies only in Collected mode; All renders every section as-is, including a page the sponsor never submitted to. --- .../__tests__/index.test.js | 33 +++++++++++++++++++ .../sponsor-asset-drilldown-page/index.js | 22 ++++++++----- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index d7751f2ad..9696acc8d 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -427,4 +427,37 @@ describe("SponsorAssetDrilldownPage", () => { expect(screen.getByText("Deck")).toBeInTheDocument(); expect(screen.getByText("Blurb")).toBeInTheDocument(); }); + + it("S1b: an empty page section (no modules) is hidden under Collected but still shown under All", async () => { + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Logo", type: "Media" }, + status: "completed" + } + ] + }, + { + page: { id: 11, title: "Empty Page", type: "page" }, + modules: [] + } + ] + } + }); + await act(async () => {}); + + // Default Collected: a section with no collected Media is dropped. + expect(screen.getByText("Booth")).toBeInTheDocument(); + expect(screen.queryByText("Empty Page")).not.toBeInTheDocument(); + + // "All" shows every section as-is — the empty page section still renders. + fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); + await act(async () => {}); + expect(screen.getByText("Empty Page")).toBeInTheDocument(); + }); }); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index c82924124..57155d7cc 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -193,14 +193,20 @@ const SponsorAssetDrilldownPage = ({ const sponsor = detail?.sponsor; const pages = detail?.pages || []; - const visiblePages = pages - .map((section) => ({ - ...section, - modules: (section.modules || []).filter( - (row) => contentType === "all" || row.module.type === "Media" - ) - })) - .filter((section) => section.modules.length > 0); + // "All" shows every section as-is, including a page the sponsor never + // submitted to (an empty section still renders). "Collected" hides non-Media + // rows and drops a section only once the filter has emptied it. + const visiblePages = + contentType === "all" + ? pages + : pages + .map((section) => ({ + ...section, + modules: (section.modules || []).filter( + (row) => row.module.type === "Media" + ) + })) + .filter((section) => section.modules.length > 0); return ( Date: Mon, 29 Jun 2026 17:26:30 -0500 Subject: [PATCH 60/63] refactor(sponsor-reports): hard-wire sponsor assets to collected (Media); remove toggle Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../sponsors/reports/ContentTypeToggle.js | 32 ---- .../__tests__/ContentTypeToggle.test.js | 28 --- src/i18n/en.json | 3 - .../__tests__/index.test.js | 169 +----------------- .../sponsor-asset-drilldown-page/index.js | 41 ++--- .../__tests__/index.test.js | 57 +----- .../sponsor-asset-report-page/index.js | 19 +- 7 files changed, 23 insertions(+), 326 deletions(-) delete mode 100644 src/components/sponsors/reports/ContentTypeToggle.js delete mode 100644 src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js diff --git a/src/components/sponsors/reports/ContentTypeToggle.js b/src/components/sponsors/reports/ContentTypeToggle.js deleted file mode 100644 index f40831e65..000000000 --- a/src/components/sponsors/reports/ContentTypeToggle.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import { ToggleButton, ToggleButtonGroup } from "@mui/material"; -import T from "i18n-react/dist/i18n-react"; - -// MUI ToggleButtonGroup passes `null` when the active button is re-clicked -// (exclusive mode); ignore it so the view never ends up with no content type. -const ContentTypeToggle = ({ value, onChange }) => ( - { - if (next) onChange(next); - }} - aria-label={T.translate("sponsor_reports_page.content_type")} - // Match the adjacent action buttons (Print / Export CSV) typography. - // px, not rem: html root font-size is 62.5% (10px) here, so "0.875rem" would - // render 8.75px; the MuiButton resolves to 14px. - sx={{ - "& .MuiToggleButton-root": { px: 2.5, fontSize: "14px", fontWeight: 500 } - }} - > - - {T.translate("sponsor_reports_page.content_collected")} - - - {T.translate("sponsor_reports_page.content_all")} - - -); - -export default ContentTypeToggle; diff --git a/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js b/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js deleted file mode 100644 index 2f90b88cd..000000000 --- a/src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js +++ /dev/null @@ -1,28 +0,0 @@ -// src/components/sponsors/reports/__tests__/ContentTypeToggle.test.js -import "@testing-library/jest-dom"; -import React from "react"; -import { screen, fireEvent } from "@testing-library/react"; -import { renderWithRedux } from "utils/test-utils"; -import ContentTypeToggle from "../ContentTypeToggle"; - -jest.mock("i18n-react/dist/i18n-react", () => ({ translate: (k) => k })); - -describe("ContentTypeToggle", () => { - it("shows the active value and calls onChange with the other value", () => { - const onChange = jest.fn(); - renderWithRedux( - - ); - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - expect(onChange).toHaveBeenCalledWith("all"); - }); - - it("ignores a null toggle (clicking the already-active button) — never clears", () => { - const onChange = jest.fn(); - renderWithRedux( - - ); - fireEvent.click(screen.getByText("sponsor_reports_page.content_collected")); - expect(onChange).not.toHaveBeenCalled(); - }); -}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 415b6f225..8ae52e979 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4292,9 +4292,6 @@ "group_by": "Group by", "group_by_sponsor": "Sponsor", "group_by_component": "Component", - "content_type": "Content", - "content_collected": "Collected", - "content_all": "All", "components_count": "{count} components", "sponsors_count": "{count} sponsors", "unnamed_component": "(Unnamed)", diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index 9696acc8d..c565b7ebf 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -228,40 +228,6 @@ describe("SponsorAssetDrilldownPage", () => { ); }); - it("ContentCell: document row renders a download link, NOT an ", async () => { - renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { - detail: { - sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 2 }, - pages: [ - { - page: { id: 9, title: "Booth", type: "page" }, - modules: [ - { - module: { id: 2, title: "Deck", type: "Document" }, - status: "completed", - content: { - filename: "deck.pdf", - preview_url: "https://x/deck.pdf" - }, - actions: { single_download_url: "https://x/deck.pdf" } - } - ] - } - ] - } - }); - await act(async () => {}); - // Toggle to "all" so the Document module card is visible. - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - await act(async () => {}); - const pdfLink = screen.getByRole("link", { name: /deck\.pdf/i }); - expect(pdfLink).toHaveAttribute("href", "https://x/deck.pdf"); - expect(pdfLink).toHaveAttribute("rel", "noopener noreferrer"); - expect( - screen.queryByRole("img", { name: /deck/i }) - ).not.toBeInTheDocument(); - }); - it("ContentCell: shows pending_upload placeholder when there is no url or text", async () => { renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { detail: { @@ -285,68 +251,7 @@ describe("SponsorAssetDrilldownPage", () => { ).toBeInTheDocument(); }); - it("ContentCell: flattens HTML in a text value to plain text", async () => { - renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { - detail: { - sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, - pages: [ - { - page: { id: 9, title: "Booth", type: "page" }, - modules: [ - { - module: { id: 4, title: "Blurb", type: "Info" }, - status: "completed", - content: { value: "

cespinTEST3

" } - } - ] - } - ] - } - }); - await act(async () => {}); - // Toggle to "all" so the Info module card is visible. - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - await act(async () => {}); - expect(screen.getByText("cespinTEST3")).toBeInTheDocument(); - expect(screen.queryByText("

cespinTEST3

")).not.toBeInTheDocument(); - }); - - it("S1a: Document row title is shown but StatusPill is suppressed; Media row StatusPill is present", async () => { - renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { - detail: { - sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, - pages: [ - { - page: { id: 9, title: "Booth", type: "page" }, - modules: [ - { - module: { id: 1, title: "Logo", type: "Media" }, - status: "submitted" - }, - { - module: { id: 2, title: "Deck", type: "Document" }, - status: "completed" - } - ] - } - ] - } - }); - await act(async () => {}); - - // Toggle to "all" so the Document row is visible alongside the Media row. - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - await act(async () => {}); - - // Document row title is rendered. - expect(screen.getByText("Deck")).toBeInTheDocument(); - // Document StatusPill is suppressed — the status label "completed" must not appear. - expect(screen.queryByText("completed")).not.toBeInTheDocument(); - // Media row StatusPill is present — the status label "submitted" appears. - expect(screen.getByText("submitted")).toBeInTheDocument(); - }); - - it("S1b default collected: only Media module cards render; a section with only non-Media rows is absent", async () => { + it("shows only collected Media content: only Media module cards render; a section with only non-Media rows is absent", async () => { renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { detail: { sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 2 }, @@ -382,7 +287,7 @@ describe("SponsorAssetDrilldownPage", () => { }); await act(async () => {}); - // Media card is visible in default "collected" mode. + // Media card is visible. expect(screen.getByText("Logo")).toBeInTheDocument(); // Document and Info module cards are not rendered. expect(screen.queryByText("Deck")).not.toBeInTheDocument(); @@ -390,74 +295,4 @@ describe("SponsorAssetDrilldownPage", () => { // A section whose only modules are non-Media is not rendered. expect(screen.queryByText("Branding")).not.toBeInTheDocument(); }); - - it("S1b toggle to all: Document and Info module cards become visible", async () => { - renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { - detail: { - sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, - pages: [ - { - page: { id: 9, title: "Booth", type: "page" }, - modules: [ - { - module: { id: 1, title: "Logo", type: "Media" }, - status: "completed" - }, - { - module: { id: 2, title: "Deck", type: "Document" }, - status: "pending" - }, - { - module: { id: 3, title: "Blurb", type: "Info" }, - status: "pending" - } - ] - } - ] - } - }); - await act(async () => {}); - - // Toggle to "all". - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - await act(async () => {}); - - // All module cards are now visible. - expect(screen.getByText("Logo")).toBeInTheDocument(); - expect(screen.getByText("Deck")).toBeInTheDocument(); - expect(screen.getByText("Blurb")).toBeInTheDocument(); - }); - - it("S1b: an empty page section (no modules) is hidden under Collected but still shown under All", async () => { - renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { - detail: { - sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, - pages: [ - { - page: { id: 9, title: "Booth", type: "page" }, - modules: [ - { - module: { id: 1, title: "Logo", type: "Media" }, - status: "completed" - } - ] - }, - { - page: { id: 11, title: "Empty Page", type: "page" }, - modules: [] - } - ] - } - }); - await act(async () => {}); - - // Default Collected: a section with no collected Media is dropped. - expect(screen.getByText("Booth")).toBeInTheDocument(); - expect(screen.queryByText("Empty Page")).not.toBeInTheDocument(); - - // "All" shows every section as-is — the empty page section still renders. - fireEvent.click(screen.getByText("sponsor_reports_page.content_all")); - await act(async () => {}); - expect(screen.getByText("Empty Page")).toBeInTheDocument(); - }); }); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js index 57155d7cc..ef73fc8a8 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -23,7 +23,7 @@ // value; the ContentCell component gates on filename extension (not MIME type) // because the backend returns the same minted URL for both (sponsor_asset_serializers.py:72,76). -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { connect } from "react-redux"; import { withRouter } from "react-router-dom"; import T from "i18n-react/dist/i18n-react"; @@ -47,7 +47,6 @@ import { isPositiveIntId } from "../../../../utils/methods"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; -import ContentTypeToggle from "../../../../components/sponsors/reports/ContentTypeToggle"; import usePrint from "../../../../hooks/usePrint"; import TierBadge from "../../../../components/sponsors/reports/TierBadge"; import StatusPill from "../../../../components/sponsors/reports/StatusPill"; @@ -144,7 +143,6 @@ const SponsorAssetDrilldownPage = ({ match }) => { const print = usePrint(); - const [contentType, setContentType] = useState("collected"); // sponsorId from URL; summitId from Redux state (not URL params per summit-admin pattern). const { sponsorId } = match.params; @@ -193,20 +191,16 @@ const SponsorAssetDrilldownPage = ({ const sponsor = detail?.sponsor; const pages = detail?.pages || []; - // "All" shows every section as-is, including a page the sponsor never - // submitted to (an empty section still renders). "Collected" hides non-Media - // rows and drops a section only once the filter has emptied it. - const visiblePages = - contentType === "all" - ? pages - : pages - .map((section) => ({ - ...section, - modules: (section.modules || []).filter( - (row) => row.module.type === "Media" - ) - })) - .filter((section) => section.modules.length > 0); + // Hard-wired to collected (Media) only — filter out non-Media rows and drop + // sections that become empty after filtering. + const visiblePages = pages + .map((section) => ({ + ...section, + modules: (section.modules || []).filter( + (row) => row.module.type === "Media" + ) + })) + .filter((section) => section.modules.length > 0); return ( - - - + } > {loading && ( @@ -329,9 +320,7 @@ const SponsorAssetDrilldownPage = ({ > {row.module.title}
- {row.module.type !== "Document" && ( - - )} + diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js index 7f46b8764..9a0b90215 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -112,7 +112,7 @@ describe("SponsorAssetReportPage", () => { renderPage(); await act(async () => {}); expect(getSponsorAssetFilters).toHaveBeenCalledWith(); - // Default contentType is "collected" → moduleType: "Media" injected into first arg. + // moduleType: "Media" is hard-wired (collected only). expect(getSponsorAssetReport).toHaveBeenCalledWith( expect.objectContaining({ moduleType: "Media" }), expect.objectContaining({ groupBy: "sponsor" }) @@ -236,13 +236,13 @@ describe("SponsorAssetReportPage", () => { ); await act(async () => {}); - // Default contentType is "collected" → moduleType: "Media" injected into first arg. + // moduleType: "Media" is hard-wired (collected only). expect(exportSponsorAssetCsv).toHaveBeenCalledWith( expect.objectContaining({ moduleType: "Media" }) ); }); - it("fetches with moduleType=Media by default (Collected mode)", async () => { + it("fetches with moduleType=Media (hard-wired collected mode)", async () => { renderPage(); await act(async () => {}); const firstArg = @@ -252,57 +252,6 @@ describe("SponsorAssetReportPage", () => { expect(firstArg).toEqual(expect.objectContaining({ moduleType: "Media" })); }); - it("fetches with moduleType undefined after toggling to All", async () => { - renderPage({ data: [], currentPage: 1, lastPage: 1 }); - await act(async () => {}); - getSponsorAssetReport.mockClear(); - - fireEvent.click( - screen.getByRole("button", { - name: "sponsor_reports_page.content_all" - }) - ); - await act(async () => {}); - - expect(getSponsorAssetReport).toHaveBeenCalled(); - const firstArg = - getSponsorAssetReport.mock.calls[ - getSponsorAssetReport.mock.calls.length - 1 - ][0]; - // "All" → no moduleType filter (omit entirely / undefined) - expect(firstArg).not.toHaveProperty("moduleType", "Media"); - expect(firstArg.moduleType).toBeUndefined(); - }); - - it("resets page to DEFAULT_CURRENT_PAGE when content type is toggled", async () => { - renderPage({ data: [], lastPage: 3, currentPage: 1 }); - await act(async () => {}); - - // Navigate to page 2 first - const nav = screen.getByRole("navigation"); - const page2 = Array.from(nav.querySelectorAll("button")).find((b) => - b.textContent.includes("2") - ); - fireEvent.click(page2); - await act(async () => {}); - getSponsorAssetReport.mockClear(); - - // Toggle content type → page must reset to 1 - fireEvent.click( - screen.getByRole("button", { - name: "sponsor_reports_page.content_all" - }) - ); - await act(async () => {}); - - expect(getSponsorAssetReport).toHaveBeenCalled(); - const options = - getSponsorAssetReport.mock.calls[ - getSponsorAssetReport.mock.calls.length - 1 - ][1]; - expect(options).toMatchObject({ page: 1 }); - }); - it("hides the no-groups empty state until currentPage >= 1", async () => { renderPage({ data: [], currentPage: 0, lastPage: 0 }); await act(async () => {}); diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 859fab61c..5a20f9286 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -25,7 +25,6 @@ import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle"; -import ContentTypeToggle from "../../../../components/sponsors/reports/ContentTypeToggle"; import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; import usePrint from "../../../../hooks/usePrint"; @@ -71,7 +70,6 @@ const SponsorAssetReportPage = ({ const validSummit = !!(currentSummit && isPositiveIntId(currentSummit.id)); const [groupBy, setGroupBy] = useState("sponsor"); - const [contentType, setContentType] = useState("collected"); const [filters, setFilters] = useState({}); const [page, setPage] = useState(DEFAULT_CURRENT_PAGE); @@ -88,13 +86,10 @@ const SponsorAssetReportPage = ({ useEffect(() => { if (validSummit) fetchReport( - { - ...filters, - moduleType: contentType === "collected" ? "Media" : undefined - }, + { ...filters, moduleType: "Media" }, { groupBy, page, perPage: GROUP_PER_PAGE } ); - }, [filters, groupBy, page, contentType]); // validSummit omitted intentionally — stable once summit loads + }, [filters, groupBy, page]); // validSummit omitted intentionally — stable once summit loads const onApply = (next) => { setPage(DEFAULT_CURRENT_PAGE); @@ -108,10 +103,6 @@ const SponsorAssetReportPage = ({ setPage(DEFAULT_CURRENT_PAGE); setGroupBy(next); }; - const onContentType = (next) => { - setPage(DEFAULT_CURRENT_PAGE); - setContentType(next); - }; const tiles = STATUS_TILE_KEYS.map((key) => ({ key, @@ -152,10 +143,7 @@ const SponsorAssetReportPage = ({ startIcon={} variant="outlined" onClick={() => - exportSponsorAssetCsv({ - ...filters, - moduleType: contentType === "collected" ? "Media" : undefined - }) + exportSponsorAssetCsv({ ...filters, moduleType: "Media" }) } > {T.translate("sponsor_reports_page.export_csv")} @@ -170,7 +158,6 @@ const SponsorAssetReportPage = ({ sx={{ mb: 2, flexWrap: "wrap" }} > - Date: Mon, 29 Jun 2026 17:44:30 -0500 Subject: [PATCH 61/63] fix(sponsor-reports): scope section CSV to collected (Media); restore HTML-flatten test - exportSponsorAssetSectionCsv now filters module_type==Media so the per-page Download CSV matches the collected-only view (was an escape path for Document/Info rows). - Restore ContentCell HTML-flatten coverage as a Media text/input row (the prior test used an Info row removed with the toggle teardown). --- .../__tests__/sponsor-reports-actions.test.js | 9 +++++-- src/actions/sponsor-reports-actions.js | 6 +++-- .../__tests__/index.test.js | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index f0b9f83d2..c4901081e 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -513,13 +513,18 @@ describe("sponsor-reports-actions", () => { expect(filename).toBe("sponsor-assets-summit-42.csv"); }); - it("exportSponsorAssetSectionCsv → only sponsor_id/page_id filters + filename", async () => { + it("exportSponsorAssetSectionCsv → sponsor_id/page_id + collected (Media) filter + filename", async () => { await exportSponsorAssetSectionCsv("17", "3")(dispatch, getState); const [url, params, filename] = getCSV.mock.calls[0]; expect(url).toBe( "http://test-api/api/v1/summits/42/reports/sponsor-assets/csv" ); - expect(params["filter[]"]).toEqual(["sponsor_id==17", "page_id==3"]); + // Collected-only: the per-page CSV is scoped to Media, matching the view. + expect(params["filter[]"]).toEqual([ + "sponsor_id==17", + "page_id==3", + "module_type==Media" + ]); expect(filename).toBe("sponsor-17-page-3.csv"); }); }); diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 203393383..cebeb0bcb 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -258,8 +258,9 @@ export const exportSponsorAssetCsv = }; // Single sponsor+page section export. Integer-guard both ids (defense-in-depth; -// the drilldown route validates :sponsorId before render). No base query — the -// drilldown always exported a section with no other active filters. +// the drilldown route validates :sponsorId before render). Scoped to collected +// (module_type==Media) so the per-page CSV matches the collected-only view — +// downloads/info are excluded from the report and from this export. export const exportSponsorAssetSectionCsv = (sponsorId, pageId) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; @@ -270,6 +271,7 @@ export const exportSponsorAssetSectionCsv = const pid = Number(pageId); if (Number.isInteger(sid)) filter.push(`sponsor_id==${sid}`); if (Number.isInteger(pid)) filter.push(`page_id==${pid}`); + filter.push("module_type==Media"); return dispatch( getCSV( `${base(currentSummit.id)}/sponsor-assets/csv`, diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index c565b7ebf..f297b748b 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -228,6 +228,31 @@ describe("SponsorAssetDrilldownPage", () => { ); }); + it("ContentCell: flattens HTML in a Media text/input value to plain text", async () => { + // A Media row whose media_request_type is Input carries entered text in + // content.value, which may contain HTML — ContentCell flattens it (no markup). + renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { + detail: { + sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Tagline", type: "Media" }, + status: "completed", + content: { value: "

cespinTEST3

" } + } + ] + } + ] + } + }); + await act(async () => {}); + expect(screen.getByText("cespinTEST3")).toBeInTheDocument(); + expect(screen.queryByText("

cespinTEST3

")).not.toBeInTheDocument(); + }); + it("ContentCell: shows pending_upload placeholder when there is no url or text", async () => { renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { detail: { From 5a31d0e0b601b78685d511a97b05961ff100df87 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 18:03:12 -0500 Subject: [PATCH 62/63] test(sponsor-reports): strengthen ContentCell flatten test to pin htmlToPlainText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use an input with entities + adjacent tags (

Booth A

B & C

→ "Booth A B & C") so the test exercises entity-decode + whitespace-collapse + tag-to-space — the behavior that distinguishes htmlToPlainText from stripTags. A regression swapping the helper would now fail. --- .../__tests__/index.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js index f297b748b..f217cbef9 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -230,7 +230,9 @@ describe("SponsorAssetDrilldownPage", () => { it("ContentCell: flattens HTML in a Media text/input value to plain text", async () => { // A Media row whose media_request_type is Input carries entered text in - // content.value, which may contain HTML — ContentCell flattens it (no markup). + // content.value, which may contain HTML — ContentCell uses htmlToPlainText. + // Input exercises the behavior that distinguishes htmlToPlainText from a bare + // stripTags: tags → space, entities decoded ( /&), whitespace collapsed. renderAt("/app/summits/1/sponsors/reports/sponsor-assets/sponsors/17", { detail: { sponsor: { id: 17, name: "Acme", tier: "Gold", pages_active: 1 }, @@ -241,7 +243,7 @@ describe("SponsorAssetDrilldownPage", () => { { module: { id: 1, title: "Tagline", type: "Media" }, status: "completed", - content: { value: "

cespinTEST3

" } + content: { value: "

Booth A

B & C

" } } ] } @@ -249,8 +251,9 @@ describe("SponsorAssetDrilldownPage", () => { } }); await act(async () => {}); - expect(screen.getByText("cespinTEST3")).toBeInTheDocument(); - expect(screen.queryByText("

cespinTEST3

")).not.toBeInTheDocument(); + expect(screen.getByText("Booth A B & C")).toBeInTheDocument(); + // Entities must be decoded — a bare stripTags would leave "&"/" ". + expect(screen.queryByText(/&| /)).not.toBeInTheDocument(); }); it("ContentCell: shows pending_upload placeholder when there is no url or text", async () => { From 737c3e1224cba7caacb37d4f31f2974c3551127f Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 29 Jun 2026 18:58:01 -0500 Subject: [PATCH 63/63] refactor(sponsor-reports): reuse isPositiveIntId + TWENTY_PER_PAGE; drop port-framing comments Convention-sweep fixes (Codex full-branch pass): - query + section-CSV thunks reuse isPositiveIntId instead of hand-rolled Number.isInteger ID checks; section CSV bails on an invalid id rather than emitting a broadened (whole-sponsor) export - replace local GROUP_PER_PAGE=25 (an arbitrary literal carried from the sponsor-services source, not spec'd) with the existing TWENTY_PER_PAGE - rewrite OrdersGrid/sponsor-services port-framing comments to describe summit-admin behavior only Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S1oFJHczgo13Z6MnUUHjwV --- .../__tests__/sponsor-reports-actions.test.js | 7 ++++++ src/actions/sponsor-reports-actions.js | 24 ++++++++++--------- src/actions/sponsor-reports-query.js | 11 ++++----- .../sponsors/reports/GroupByComponentView.js | 5 ++-- .../sponsors/reports/OrdersTable.js | 8 +++---- .../purchase-details-report-page/index.js | 5 ++-- .../sponsor-asset-report-page/index.js | 8 ++++--- 7 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/actions/__tests__/sponsor-reports-actions.test.js b/src/actions/__tests__/sponsor-reports-actions.test.js index c4901081e..d7f667962 100644 --- a/src/actions/__tests__/sponsor-reports-actions.test.js +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -527,6 +527,13 @@ describe("sponsor-reports-actions", () => { ]); expect(filename).toBe("sponsor-17-page-3.csv"); }); + + it("exportSponsorAssetSectionCsv → bails (no CSV) on a non-positive-int id rather than broadening the export", async () => { + // A missing/invalid page_id must NOT widen the CSV to the whole sponsor. + await exportSponsorAssetSectionCsv("17", "0")(dispatch, getState); + await exportSponsorAssetSectionCsv("abc", "3")(dispatch, getState); + expect(getCSV).not.toHaveBeenCalled(); + }); }); // ─── makeReadErrorHandler (direct unit tests) ──────────────────────────────── diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index cebeb0bcb..72d4105fe 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -5,7 +5,7 @@ import { startLoading, stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; -import { getAccessTokenSafely } from "../utils/methods"; +import { getAccessTokenSafely, isPositiveIntId } from "../utils/methods"; import { makeReadErrorHandler } from "./sponsor-reports-errors"; import { buildReportQuery, @@ -257,21 +257,23 @@ export const exportSponsorAssetCsv = ); }; -// Single sponsor+page section export. Integer-guard both ids (defense-in-depth; -// the drilldown route validates :sponsorId before render). Scoped to collected -// (module_type==Media) so the per-page CSV matches the collected-only view — -// downloads/info are excluded from the report and from this export. +// Single sponsor+page section export. Both ids must be positive ints (shared +// isPositiveIntId; the drilldown route validates :sponsorId before render) — bail +// rather than emit a broadened CSV, since dropping one id would widen the export +// to the whole sponsor/report. Scoped to collected (module_type==Media) so the +// per-page CSV matches the collected-only view — downloads/info are excluded. export const exportSponsorAssetSectionCsv = (sponsorId, pageId) => async (dispatch, getState) => { const { currentSummit } = getState().currentSummitState; if (!currentSummit?.id) return Promise.resolve(); + if (!isPositiveIntId(sponsorId) || !isPositiveIntId(pageId)) + return Promise.resolve(); const accessToken = await getAccessTokenSafely(); - const filter = []; - const sid = Number(sponsorId); - const pid = Number(pageId); - if (Number.isInteger(sid)) filter.push(`sponsor_id==${sid}`); - if (Number.isInteger(pid)) filter.push(`page_id==${pid}`); - filter.push("module_type==Media"); + const filter = [ + `sponsor_id==${sponsorId}`, + `page_id==${pageId}`, + "module_type==Media" + ]; return dispatch( getCSV( `${base(currentSummit.id)}/sponsor-assets/csv`, diff --git a/src/actions/sponsor-reports-query.js b/src/actions/sponsor-reports-query.js index bb52b7cbc..dc1f31a0b 100644 --- a/src/actions/sponsor-reports-query.js +++ b/src/actions/sponsor-reports-query.js @@ -10,6 +10,7 @@ // (a no-operator value triggers a server IndexError → 500). import moment from "moment-timezone"; +import { isPositiveIntId } from "../utils/methods"; // Converts MuiTable sort state to the `order` query param expected by the API. // MuiTable calls onSort(columnKey, dir) where dir = 1 (asc) | -1 (desc). @@ -38,12 +39,10 @@ export const buildReportQuery = (filters = {}) => { const filter = []; // Sponsor — the one multi-select dimension → comma-OR in a SINGLE bracket. - // Coerce to positive integers and drop everything else, so a stray entry can't - // emit `sponsor_id==NaN`/`==0` (rejected by the backend; can hit the bad-filter - // 500 path). Note Number(null) === 0, so the `> 0` check is load-bearing. - const sponsorFilterIds = sponsorIds - .map((id) => Number(id)) - .filter((id) => Number.isInteger(id) && id > 0); + // Keep only positive-integer ids (shared isPositiveIntId — string-aware), so a + // stray entry can't emit `sponsor_id==NaN`/`==0`/negative (rejected by the + // backend; can hit the bad-filter 500 path). + const sponsorFilterIds = sponsorIds.filter(isPositiveIntId).map(Number); if (sponsorFilterIds.length > 0) { filter.push(sponsorFilterIds.map((id) => `sponsor_id==${id}`).join(",")); } diff --git a/src/components/sponsors/reports/GroupByComponentView.js b/src/components/sponsors/reports/GroupByComponentView.js index 39a410534..3f270e2ec 100644 --- a/src/components/sponsors/reports/GroupByComponentView.js +++ b/src/components/sponsors/reports/GroupByComponentView.js @@ -39,9 +39,8 @@ const hasContent = (content) => const isNotPresent = (entry) => NOT_PRESENT_STATUSES.includes(entry.status) && !hasContent(entry.content); -// Each sponsor link inside a component card goes to the summit-admin drill-down. -// NOTE: path is /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id, -// NOT the old /app/reports/summits/:summitId/... path from the sponsor-services source. +// Each sponsor link inside a component card goes to the drill-down route: +// /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id. const GroupByComponentView = ({ summitId, cards = [] }) => ( {cards.map((card, idx) => ( diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js index 35b5f852e..5aeca37fa 100644 --- a/src/components/sponsors/reports/OrdersTable.js +++ b/src/components/sponsors/reports/OrdersTable.js @@ -24,10 +24,10 @@ import StatusPill from "./StatusPill"; const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" -// Port of OrdersGrid.js formatCheckoutTime — handles BOTH the current ISO -// checkout_at (DRF DateTimeField on backend main) AND a future epoch int -// (pending ClickUp 86bagnfmn). Parses in UTC so the displayed time always -// matches the stored UTC value and tests stay timezone-stable. +// Formats the checkout timestamp — handles BOTH the current ISO checkout_at +// (DRF DateTimeField on the backend) AND a future epoch int (pending ClickUp +// 86bagnfmn). Parses in UTC so the displayed time always matches the stored UTC +// value and tests stay timezone-stable. export const formatCheckoutTime = (value) => { if (value == null || value === "") return ""; let m; diff --git a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js index f8386a595..dda65aed1 100644 --- a/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -111,9 +111,8 @@ const PurchaseDetailsReportPage = ({ }, [view, filters, linesPage, linesPerPage]); // ── Summary tiles ─────────────────────────────────────────────────────────── - // D9: Total Refunded tile renders ONLY when activeSummary.total_refunded != null. - // Backend main does not yet expose it (ships in PR #24); the presence check - // keeps the tile hidden on current main and auto-appears after PR #24 deploys. + // D9: Total Refunded tile renders ONLY when total_refunded != null — a defensive + // presence check (the field is optional in the summary payload). const activeSummary = view === "orders" ? summary : linesSummary; // money: format integer CENTS via uicore; guard unexpected nulls with em dash. const money = (cents) => diff --git a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js index 5a20f9286..d3dd08819 100644 --- a/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -20,7 +20,10 @@ import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; import { isPositiveIntId } from "../../../../utils/methods"; -import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; +import { + DEFAULT_CURRENT_PAGE, + TWENTY_PER_PAGE +} from "../../../../utils/constants"; import ReportShell from "../../../../components/sponsors/reports/ReportShell"; import SummaryPanel from "../../../../components/sponsors/reports/SummaryPanel"; import FilterBar from "../../../../components/sponsors/reports/FilterBar"; @@ -46,7 +49,6 @@ const TILE_TONE = { pending: "warning", not_applicable: "neutral" }; -const GROUP_PER_PAGE = 25; const SponsorAssetReportPage = ({ // From mapStateToProps @@ -87,7 +89,7 @@ const SponsorAssetReportPage = ({ if (validSummit) fetchReport( { ...filters, moduleType: "Media" }, - { groupBy, page, perPage: GROUP_PER_PAGE } + { groupBy, page, perPage: TWENTY_PER_PAGE } ); }, [filters, groupBy, page]); // validSummit omitted intentionally — stable once summit loads