diff --git a/.env.example b/.env.example index 6d0c13929..7a5aff8a1 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,10 @@ 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" +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" 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..d7f667962 --- /dev/null +++ b/src/actions/__tests__/sponsor-reports-actions.test.js @@ -0,0 +1,623 @@ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +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 "../sponsor-reports-errors"; + +import { + getPurchaseDetailsReport, + getPurchaseDetailsFilters, + getSponsorAssetReport, + getSponsorAssetFilters, + getSponsorAssetSponsor, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv, + exportSponsorAssetCsv, + exportSponsorAssetSectionCsv, + REQUEST_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS, + RECEIVE_PURCHASE_DETAILS_FILTERS, + PURCHASE_DETAILS_READ_ERROR, + PURCHASE_DETAILS_VALIDATION_ERROR, + 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 "../sponsor-reports-actions"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn(), + getCSV: jest.fn(() => ({ type: "GET_CSV_MOCK" })) +})); + +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(); + getCSV.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 built query params (page, per_page) in outgoing request", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getPurchaseDetailsReport({}, { page: 2, perPage: 25 })); + await flushPromises(); + + expect(capturedParams.access_token).toBe("TOKEN"); + 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 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 + }); + }); + }); + + // ─── 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({}, { groupBy: "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 built group_by param in outgoing request", async () => { + const store = mockStore(MOCK_STATE); + store.dispatch(getSponsorAssetReport({}, { groupBy: "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/"); + }); + + 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({}, { groupBy: "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 ────────────────────────────────────────────────── + + 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"); + }); + + 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); + }); + }); + + // ─── getPurchaseDetailsLinesReport ────────────────────────────────────────── + + describe("getPurchaseDetailsLinesReport", () => { + beforeEach(() => { + jest + .spyOn(methods, "getAccessTokenSafely") + .mockResolvedValue("test-token"); + }); + + 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( + { 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, + 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"); + }); + }); + + // ─── 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"); + }); + }); + + // ─── 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 → 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" + ); + // 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"); + }); + + 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) ──────────────────────────────── + + 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' 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_READ_ERROR, + 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_READ_ERROR }) + ); + }); + }); +}); diff --git a/src/actions/__tests__/sponsor-reports-query.test.js b/src/actions/__tests__/sponsor-reports-query.test.js new file mode 100644 index 000000000..09c975a53 --- /dev/null +++ b/src/actions/__tests__/sponsor-reports-query.test.js @@ -0,0 +1,123 @@ +import { + buildReportQuery, + buildPurchaseQuery, + buildPurchaseLinesQuery, + toOrderParam +} from "../sponsor-reports-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" + }); + }); +}); + +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"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 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 new file mode 100644 index 000000000..72d4105fe --- /dev/null +++ b/src/actions/sponsor-reports-actions.js @@ -0,0 +1,284 @@ +import { + createAction, + getRequest, + getCSV, + startLoading, + stopLoading +} from "openstack-uicore-foundation/lib/utils/actions"; +import { getAccessTokenSafely, isPositiveIntId } from "../utils/methods"; +import { makeReadErrorHandler } from "./sponsor-reports-errors"; +import { + buildReportQuery, + buildPurchaseQuery, + buildPurchaseLinesQuery +} from "./sponsor-reports-query"; + +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 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 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) => + `${window.SPONSOR_REPORTS_API_URL}/api/v1/summits/${summitId}/reports`; + +export const getPurchaseDetailsReport = + (filters = {}, pagination = {}) => + 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 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), + 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_READ_ERROR) + }) + )(params)(dispatch) + .catch(() => {}) + .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 = + (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), + 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; + if (!currentSummit?.id) return Promise.resolve(); + 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 = + (filters = {}, options = {}) => + 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 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), + 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_READ_ERROR) + }) + )(params)(dispatch) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + }; + +export const getSponsorAssetFilters = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + if (!currentSummit?.id) return Promise.resolve(); + 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())); +}; + +// 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 Promise.resolve(); + 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 Promise.resolve(); + 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(); + 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 Promise.resolve(); + 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), + // 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(() => {}) + .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 Promise.resolve(); + 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. 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 = [ + `sponsor_id==${sponsorId}`, + `page_id==${pageId}`, + "module_type==Media" + ]; + return dispatch( + getCSV( + `${base(currentSummit.id)}/sponsor-assets/csv`, + { access_token: accessToken, "filter[]": filter }, + `sponsor-${sponsorId}-page-${pageId}.csv` + ) + ); + }; diff --git a/src/actions/sponsor-reports-errors.js b/src/actions/sponsor-reports-errors.js new file mode 100644 index 000000000..9e5095a0f --- /dev/null +++ b/src/actions/sponsor-reports-errors.js @@ -0,0 +1,82 @@ +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 "../utils/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": + // 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 })); + 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/actions/sponsor-reports-query.js b/src/actions/sponsor-reports-query.js new file mode 100644 index 000000000..dc1f31a0b --- /dev/null +++ b/src/actions/sponsor-reports-query.js @@ -0,0 +1,114 @@ +// src/actions/sponsor-reports-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). + +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). +export const toOrderParam = (columnKey, dir) => { + if (!columnKey) return undefined; + return dir === -1 ? `-${columnKey}` : columnKey; +}; + +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. + // 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(",")); + } + + // 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; +}; + +// 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 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 = {}) => { + 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/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/components/sponsors/reports/FilterBar.js b/src/components/sponsors/reports/FilterBar.js new file mode 100644 index 000000000..2cf5d040c --- /dev/null +++ b/src/components/sponsors/reports/FilterBar.js @@ -0,0 +1,105 @@ +import React, { useEffect, 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); + // 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 ( + + + + + {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/GroupByComponentView.js b/src/components/sponsors/reports/GroupByComponentView.js new file mode 100644 index 000000000..3f270e2ec --- /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 { + 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 SponsorAvatar from "./SponsorAvatar"; +import { htmlToPlainText } from "../../../utils/methods"; + +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 drill-down route: +// /app/summits/:summitId/sponsors/reports/sponsor-assets/sponsors/:id. +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} + + + {/* 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") + : htmlToPlainText( + 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..f53bc33f0 --- /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 { + 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"; +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, +// 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/GroupByToggle.js b/src/components/sponsors/reports/GroupByToggle.js new file mode 100644 index 000000000..e022c07c9 --- /dev/null +++ b/src/components/sponsors/reports/GroupByToggle.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 grouping. +const GroupByToggle = ({ value, onChange }) => ( + { + if (next) onChange(next); + }} + aria-label={T.translate("sponsor_reports_page.group_by")} + // 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")} + + + {T.translate("sponsor_reports_page.group_by_component")} + + +); + +export default GroupByToggle; diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js new file mode 100644 index 000000000..52f95b362 --- /dev/null +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -0,0 +1,178 @@ +/** + * 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 { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +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, + TWENTY_PER_PAGE +} from "../../../utils/constants"; + +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 +// 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 = DEFAULT_CURRENT_PAGE, + perPage = FIFTY_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} + + + + + {line.line_total == null + ? "—" + : currencyAmountFromCents(line.line_total)} + + + ))} + +
+
+
+
+ ))} + onPageChange(zeroBased + 1)} + onRowsPerPageChange={(e) => onPerPageChange(Number(e.target.value))} + /> +
+ ); +}; + +export default LinesManifestView; diff --git a/src/components/sponsors/reports/OrdersTable.js b/src/components/sponsors/reports/OrdersTable.js new file mode 100644 index 000000000..5aeca37fa --- /dev/null +++ b/src/components/sponsors/reports/OrdersTable.js @@ -0,0 +1,126 @@ +/** + * 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 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_CURRENT_PAGE, + DEFAULT_PER_PAGE +} from "../../../utils/constants"; +import StatusPill from "./StatusPill"; + +const ISO_DATE_LENGTH = 10; // "YYYY-MM-DD" + +// 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; + 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"); +}; + +// MuiTable column definitions. +// 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 = [ + { + columnKey: "number", + header: T.translate("sponsor_reports_page.col_order"), + sortable: true, + render: (row) => row.purchase_number + }, + { + columnKey: "sponsor", + header: T.translate("sponsor_reports_page.col_sponsor"), + sortable: true, + render: (row) => row.sponsor?.name ?? "" + }, + { + columnKey: "order_date", + 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: T.translate("sponsor_reports_page.col_type"), + sortable: false, // not a backend ordering field + render: (row) => row.form?.display ?? "" + }, + { + columnKey: "status", + header: T.translate("sponsor_reports_page.col_status"), + sortable: true, + render: (row) => + }, + { + columnKey: "invoice_total", + header: T.translate("sponsor_reports_page.col_invoice_total"), + sortable: true, + align: "right", + render: (row) => + row.invoice_total == null + ? "—" + : currencyAmountFromCents(row.invoice_total) + }, + { + columnKey: "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. + } +]; + +// 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 = DEFAULT_CURRENT_PAGE, + 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/ReportShell.js b/src/components/sponsors/reports/ReportShell.js new file mode 100644 index 000000000..f82cbf251 --- /dev/null +++ b/src/components/sponsors/reports/ReportShell.js @@ -0,0 +1,63 @@ +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/ReportViewToggle.js b/src/components/sponsors/reports/ReportViewToggle.js new file mode 100644 index 000000000..68985f394 --- /dev/null +++ b/src/components/sponsors/reports/ReportViewToggle.js @@ -0,0 +1,31 @@ +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")} + // 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")} + + + {T.translate("sponsor_reports_page.view_line_items")} + + +); + +export default ReportViewToggle; 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/StatusPill.js b/src/components/sponsors/reports/StatusPill.js new file mode 100644 index 000000000..9353de00d --- /dev/null +++ b/src/components/sponsors/reports/StatusPill.js @@ -0,0 +1,31 @@ +import React from "react"; +import { Chip } from "@mui/material"; + +// 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 +// the raw `status` token via the shared tone map. +const StatusPill = ({ status, label, size = "small" }) => ( + +); + +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..5027f69c1 --- /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 "./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. +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..15bed8e96 --- /dev/null +++ b/src/components/sponsors/reports/SummaryPanel.js @@ -0,0 +1,43 @@ +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__/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__/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/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__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js new file mode 100644 index 000000000..010b0cd66 --- /dev/null +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -0,0 +1,93 @@ +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: 50000, + line_total: 100000, + 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(); + // 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)", () => { + 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/components/sponsors/reports/__tests__/OrdersTable.test.js b/src/components/sponsors/reports/__tests__/OrdersTable.test.js new file mode 100644 index 000000000..3e383998f --- /dev/null +++ b/src/components/sponsors/reports/__tests__/OrdersTable.test.js @@ -0,0 +1,207 @@ +// 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 } 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(""); + }); + + // ── 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"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 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: 25000, + 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 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(); + }); + + 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("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("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("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("sponsor_reports_page.col_type")); + fireEvent.click(screen.getByText("sponsor_reports_page.col_sponsor_note")); + expect(handleSort).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__/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(); + }); +}); 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/components/sponsors/reports/__tests__/StatusPill.test.js b/src/components/sponsors/reports/__tests__/StatusPill.test.js new file mode 100644 index 000000000..a8cd2ca6c --- /dev/null +++ b/src/components/sponsors/reports/__tests__/StatusPill.test.js @@ -0,0 +1,35 @@ +// 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", () => { + 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", () => { + 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__/manifest-grouping.test.js b/src/components/sponsors/reports/__tests__/manifest-grouping.test.js new file mode 100644 index 000000000..53d8111f1 --- /dev/null +++ b/src/components/sponsors/reports/__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/components/sponsors/reports/manifest-grouping.js b/src/components/sponsors/reports/manifest-grouping.js new file mode 100644 index 000000000..0195a75c7 --- /dev/null +++ b/src/components/sponsors/reports/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; 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/hooks/__tests__/usePrint.test.js b/src/hooks/__tests__/usePrint.test.js new file mode 100644 index 000000000..ab0ed1521 --- /dev/null +++ b/src/hooks/__tests__/usePrint.test.js @@ -0,0 +1,26 @@ +// 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"; +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/hooks/usePrint.js b/src/hooks/usePrint.js new file mode 100644 index 000000000..569ba24bc --- /dev/null +++ b/src/hooks/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; diff --git a/src/i18n/en.json b/src/i18n/en.json index 480855994..1b51789a5 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", @@ -4266,5 +4267,73 @@ "title": "Image Preview", "file_name": "File name", "uploaded": "Uploaded" + }, + "sponsor_reports_page": { + "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.", + "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", + "report_filters": "Report Filters", + "pending_upload": "Pending Upload", + "pages_active": "{count} pages active", + "purchase_details_desc": "Orders & revenue", + "sponsor_assets_desc": "Sponsor portal assets", + "landing_title": "Reports", + "view_toggle": "View", + "view_orders": "Orders", + "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", + "col_item_name": "Item Name", + "col_destination": "Destination", + "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/layouts/__tests__/sponsor-reports-layout.test.js b/src/layouts/__tests__/sponsor-reports-layout.test.js new file mode 100644 index 000000000..2943e3f69 --- /dev/null +++ b/src/layouts/__tests__/sponsor-reports-layout.test.js @@ -0,0 +1,191 @@ +/** + * 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 +})); + +// react-breadcrumbs: render a stub so the landing page Breadcrumb doesn't error +jest.mock("react-breadcrumbs", () => ({ + Breadcrumb: ({ data }) => ( +
+ ) +})); + +// 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", () => ({ + "admin-sponsors": [ + "super-admins", + "administrators", + "summit-front-end-administrators" + ] +})); + +// 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", + 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"; + +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 landing page (two cards) for an administrator", () => { + renderLayout([{ code: "administrators" }]); + expect( + screen.getByTestId("report-card-purchase-details") + ).toBeInTheDocument(); + expect( + screen.getByTestId("report-card-sponsor-assets") + ).toBeInTheDocument(); + }); + + it("renders UnAuthorizedPage for a sponsors-only member", () => { + renderLayout([{ code: "sponsors" }]); + // UnAuthorizedPage renders:

Sorry...

+ expect(screen.getByText("Sorry...")).toBeInTheDocument(); + expect( + 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 + } + } + } + ); + + // 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(); + }); + + 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 + }, + sponsorReportsPurchaseDetailsLinesState: { + data: [], + summary: null, + total: 0, + currentPage: 1, + lastPage: 1, + perPage: 50, + loading: false, + readError: 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-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} /> + (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). The drill-down + shows the Sponsor Assets parent crumb (links back to the list). */} + + + + +
+); + +export default Restrict(withRouter(SponsorReportsLayout), "admin-sponsors"); 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 new file mode 100644 index 000000000..4a8dca173 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/__tests__/index.test.js @@ -0,0 +1,473 @@ +// src/pages/sponsors/sponsor-reports/purchase-details-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 PurchaseDetailsReportPage 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 +})); + +// ── 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 +// 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" + })), + getPurchaseDetailsFilters: jest.fn(() => ({ + type: "REQUEST_PURCHASE_DETAILS_FILTERS" + })), + getPurchaseDetailsLinesReport: jest.fn(() => ({ + type: "REQUEST_PURCHASE_DETAILS_LINES" + })), + 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 { + getPurchaseDetailsReport, + getPurchaseDetailsFilters, + getPurchaseDetailsLinesReport, + clearPurchaseDetailsValidation, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv +} = require("../../../../../actions/sponsor-reports-actions"); + +// ──────────────────────────────────────────────────────────────────────────── +// 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: 10000, + 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: 50000, + line_total: 100000, + 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"; + +function buildState(summaryOverrides = {}, { total = 1 } = {}) { + return { + sponsorReportsPurchaseDetailsState: { + data: [SAMPLE_ROW], + summary: { + total_orders: 1, + total_items: 1, + total_paid: 10000, + total_pending: 0, + total_refunded: null, + ...summaryOverrides + }, + filterOptions: { sponsors: [], statuses: [], forms: [] }, + total, + loading: false, + readError: null, + validationError: null + }, + currentSummitState: { + currentSummit: { id: 42 } + }, + sponsorReportsPurchaseDetailsLinesState: { + data: [SAMPLE_LINE], + summary: { + total_orders: 1, + total_items: 2, + total_paid: 100000, + total_pending: 0, + total_refunded: null + }, + total: 1, + currentPage: 1, + lastPage: 1, + perPage: 50, + loading: false, + readError: null + } + }; +} + +function renderPage(summaryOverrides = {}, stateOptions = {}) { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + return { + history, + ...renderWithRedux( + + + , + { initialState: buildState(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(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 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 perPage=10 on initial load", async () => { + renderPage(); + await act(async () => {}); + expect(getPurchaseDetailsReport).toHaveBeenCalledWith( + {}, + expect.objectContaining({ page: 1, perPage: 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: 5000 }); + 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 export button", async () => { + renderPage(); + await act(async () => {}); + // 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") + ).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 → useEffect re-fires → re-fetch with new primitives + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + 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 () => { + renderPage(); + await act(async () => {}); + + const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); + await act(async () => { + fireEvent.click(exportBtn); + }); + + // 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 () => { + // 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 [[, 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 () => { + 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("sponsor_reports_page.col_order"); + await act(async () => { + fireEvent.click(orderHeader); + }); + + expect(getPurchaseDetailsReport).toHaveBeenCalled(); + 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 () => { + 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 [[, 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(); + }); + + 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.getByText("sponsor_reports_page.export_csv") + ).toBeInTheDocument(); + }); + + it("CSV export in the Line Items view calls exportPurchaseDetailsLinesCsv with filters", 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. + expect(exportPurchaseDetailsCsv).not.toHaveBeenCalled(); + expect(exportPurchaseDetailsLinesCsv).not.toHaveBeenCalled(); + + const exportBtn = screen.getByText("sponsor_reports_page.export_csv"); + await act(async () => { + fireEvent.click(exportBtn); + }); + + expect(exportPurchaseDetailsLinesCsv).toHaveBeenCalledWith({}); + }); + + it("Line Items CSV export passes applied filters to exportPurchaseDetailsLinesCsv", 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(exportPurchaseDetailsLinesCsv).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.click(screen.getByText("sponsor_reports_page.export_csv")); + }); + + // 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" + }); + }); + + 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 new file mode 100644 index 000000000..dda65aed1 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/purchase-details-report-page/index.js @@ -0,0 +1,338 @@ +/** + * 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, 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, 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 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 usePrint from "../../../../hooks/usePrint"; +import { + getPurchaseDetailsReport, + getPurchaseDetailsLinesReport, + getPurchaseDetailsFilters, + clearPurchaseDetailsValidation, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv +} from "../../../../actions/sponsor-reports-actions"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_PER_PAGE, + FIFTY_PER_PAGE +} from "../../../../utils/constants"; + +const PurchaseDetailsReportPage = ({ + // From mapStateToProps + data, + summary, + filterOptions, + total, + readError, + validationError, + // Lines slice (per-line manifest view) + linesData, + linesSummary, + linesTotal, + linesReadError, + // From mapDispatchToProps (object form — bound action creators) + getPurchaseDetailsReport: fetchReport, + getPurchaseDetailsLinesReport: fetchLinesReport, + getPurchaseDetailsFilters: fetchFilters, + clearPurchaseDetailsValidation: clearValidation, + exportPurchaseDetailsCsv: exportOrdersCsv, + 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({}); + 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(DEFAULT_CURRENT_PAGE); + const [linesPerPage, setLinesPerPage] = useState(FIFTY_PER_PAGE); + + // 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 + + // 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(filters, { page: currentPage, perPage, order, orderDir }); + }, [view, filters, currentPage, perPage, order, orderDir]); + + // 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(filters, { page: linesPage, perPage: linesPerPage }); + }, [view, filters, linesPage, linesPerPage]); + + // ── Summary tiles ─────────────────────────────────────────────────────────── + // 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) => + cents == null ? "—" : currencyAmountFromCents(cents); + const tiles = activeSummary + ? [ + { + key: "total_orders", + label: T.translate("sponsor_reports_page.total_orders"), + value: activeSummary.total_orders + }, + { + key: "total_items", + label: T.translate("sponsor_reports_page.total_items"), + value: activeSummary.total_items + }, + { + key: "total_paid", + label: T.translate("sponsor_reports_page.total_paid"), + value: money(activeSummary.total_paid), + tone: "success" + }, + { + key: "total_pending", + label: T.translate("sponsor_reports_page.total_pending"), + value: money(activeSummary.total_pending), + tone: "warning" + }, + ...(activeSummary.total_refunded != null + ? [ + { + key: "total_refunded", + label: T.translate("sponsor_reports_page.total_refunded"), + value: money(activeSummary.total_refunded) + } + ] + : []) + ] + : []; + + // ── FilterBar handlers ────────────────────────────────────────────────────── + // Applying/clearing a filter changes the result set → snap back to page 1. + const handleApply = (next) => { + setFilters(next); + setCurrentPage(DEFAULT_CURRENT_PAGE); + setLinesPage(DEFAULT_CURRENT_PAGE); + }; + const handleClear = () => { + setFilters({}); + setCurrentPage(DEFAULT_CURRENT_PAGE); + setLinesPage(DEFAULT_CURRENT_PAGE); + }; + + // ── Sort/pagination handlers ───────────────────────────────────────────────── + const handleSort = (columnKey, dir) => { + setOrder(columnKey); + setOrderDir(dir); + setCurrentPage(DEFAULT_CURRENT_PAGE); + }; + const handlePageChange = (page) => { + setCurrentPage(page); + }; + const handlePerPageChange = (newPerPage) => { + setPerPage(newPerPage); + setCurrentPage(DEFAULT_CURRENT_PAGE); + }; + const handleLinesPageChange = (page) => setLinesPage(page); + const handleLinesPerPageChange = (newPerPage) => { + setLinesPerPage(newPerPage); + setLinesPage(DEFAULT_CURRENT_PAGE); + }; + + // ── 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={ + + + + } + > + + {(view === "orders" ? readError : linesReadError) ? ( + + {(view === "orders" ? readError : linesReadError)?.message || + T.translate("sponsor_reports_page.read_error")} + + ) : view === "orders" ? ( + + ) : ( + + )} + + ); +}; + +const mapStateToProps = ({ + sponsorReportsPurchaseDetailsState, + sponsorReportsPurchaseDetailsLinesState +}) => ({ + ...sponsorReportsPurchaseDetailsState, + linesData: sponsorReportsPurchaseDetailsLinesState.data, + linesSummary: sponsorReportsPurchaseDetailsLinesState.summary, + linesTotal: sponsorReportsPurchaseDetailsLinesState.total, + linesReadError: sponsorReportsPurchaseDetailsLinesState.readError +}); + +const mapDispatchToProps = { + getPurchaseDetailsReport, + getPurchaseDetailsLinesReport, + getPurchaseDetailsFilters, + clearPurchaseDetailsValidation, + exportPurchaseDetailsCsv, + exportPurchaseDetailsLinesCsv +}; + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(PurchaseDetailsReportPage) +); 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..0b14398b0 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/__tests__/index.test.js @@ -0,0 +1,72 @@ +// 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 +})); + +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 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..9c1a83030 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/reports-landing-page/index.js @@ -0,0 +1,66 @@ +/** + * 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 { + 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); 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..f217cbef9 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/__tests__/index.test.js @@ -0,0 +1,326 @@ +/** + * 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, fireEvent } 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 +})); + +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, + exportSponsorAssetSectionCsv +} = 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, + ...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 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" }, + // 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" + } + ] + } + ] + } + }); + 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: { + 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: 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 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 }, + pages: [ + { + page: { id: 9, title: "Booth", type: "page" }, + modules: [ + { + module: { id: 1, title: "Tagline", type: "Media" }, + status: "completed", + content: { value: "

Booth A

B & C

" } + } + ] + } + ] + } + }); + await act(async () => {}); + 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 () => { + 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("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 }, + 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. + 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(); + }); +}); 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..ef73fc8a8 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-drilldown-page/index.js @@ -0,0 +1,348 @@ +/** + * 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 { + Box, + Button, + Card, + CardContent, + Grid2, + 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 { + 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"; +import StatusPill from "../../../../components/sponsors/reports/StatusPill"; +import SponsorAvatar from "../../../../components/sponsors/reports/SponsorAvatar"; +import { + exportSponsorAssetSectionCsv, + getSponsorAssetSponsor +} from "../../../../actions/sponsor-reports-actions"; + +// 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 = htmlToPlainText( + row.content?.value || row.content?.summary || filename + ); + const isImage = !!url && isImageUrl(filename || url); + + if (url && isImage) { + return ( + + ); + } + if (url) { + return ( + + + {/* 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} + + + + ); + } + if (text) { + return ( + + {text} + + ); + } + return ( + + + + {T.translate("sponsor_reports_page.pending_upload")} + + + ); +}; + +const SponsorAssetDrilldownPage = ({ + // From mapStateToProps + detail, + loading, + readError, + // From mapDispatchToProps + getSponsorAssetSponsor: fetchSponsor, + exportSponsorAssetSectionCsv, + // 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 + + 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 || []; + + // 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 ( + } 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} + + + {typeof sponsor.pages_active === "number" && ( + + {T.translate("sponsor_reports_page.pages_active", { + count: sponsor.pages_active + })} + + )} + + + + )} + + {visiblePages.map((section) => ( + + + + + {section.page.title} + + + + + {section.modules?.map((row) => ( + + + + + {row.module.title} + + + + + + + ))} + + + + ))} + + ); +}; + +const mapStateToProps = ({ sponsorReportsDrilldownState }) => ({ + ...sponsorReportsDrilldownState +}); + +const mapDispatchToProps = { + getSponsorAssetSponsor, + exportSponsorAssetSectionCsv +}; + +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..9a0b90215 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/__tests__/index.test.js @@ -0,0 +1,267 @@ +/** + * 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" })), + exportSponsorAssetCsv: jest.fn(() => ({ type: "EXPORT_SA_CSV" })), + SPONSOR_ASSET_READ_ERROR: "SPONSOR_ASSET_READ_ERROR" +})); + +// Require after mocks so the jest.fn() references are the mocked ones. +const { + getSponsorAssetFilters, + getSponsorAssetReport, + exportSponsorAssetCsv +} = 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, + ...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(); + // moduleType: "Media" is hard-wired (collected only). + expect(getSponsorAssetReport).toHaveBeenCalledWith( + expect.objectContaining({ moduleType: "Media" }), + expect.objectContaining({ groupBy: "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 + ]; + // Second arg is the options object — thunk converts groupBy → group_by internally. + expect(lastCall[1]).toEqual( + expect.objectContaining({ groupBy: "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 calledOptions = + getSponsorAssetReport.mock.calls[ + getSponsorAssetReport.mock.calls.length - 1 + ][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 () => { + const history = createMemoryHistory({ initialEntries: [PAGE_URL] }); + renderWithRedux( + + + , + { + initialState: { + sponsorReportsSponsorAssetState: { + filterOptions: null, + data: [], + currentPage: 0, + lastPage: 0, + summary: null, + loading: false, + readError: null + }, + 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 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 () => {}); + exportSponsorAssetCsv.mockClear(); + + fireEvent.click( + screen.getByRole("button", { + name: /sponsor_reports_page\.export_csv/ + }) + ); + await act(async () => {}); + + // moduleType: "Media" is hard-wired (collected only). + expect(exportSponsorAssetCsv).toHaveBeenCalledWith( + expect.objectContaining({ moduleType: "Media" }) + ); + }); + + it("fetches with moduleType=Media (hard-wired 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("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..d3dd08819 --- /dev/null +++ b/src/pages/sponsors/sponsor-reports/sponsor-asset-report-page/index.js @@ -0,0 +1,243 @@ +/** + * 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, 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 DownloadIcon from "@mui/icons-material/Download"; +import CollectionsOutlinedIcon from "@mui/icons-material/CollectionsOutlined"; +import { isPositiveIntId } from "../../../../utils/methods"; +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"; +import GroupByToggle from "../../../../components/sponsors/reports/GroupByToggle"; +import GroupBySponsorView from "../../../../components/sponsors/reports/GroupBySponsorView"; +import GroupByComponentView from "../../../../components/sponsors/reports/GroupByComponentView"; +import usePrint from "../../../../hooks/usePrint"; +import { + exportSponsorAssetCsv, + 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 SponsorAssetReportPage = ({ + // From mapStateToProps + currentSummit, + filterOptions, + data, + summary, + lastPage, + currentPage, + loading, + readError, + // From mapDispatchToProps + getSponsorAssetReport: fetchReport, + getSponsorAssetFilters: fetchFilters, + exportSponsorAssetCsv +}) => { + 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(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 + // 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 + + // 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( + { ...filters, moduleType: "Media" }, + { groupBy, page, perPage: TWENTY_PER_PAGE } + ); + }, [filters, groupBy, page]); // validSummit omitted intentionally — stable once summit loads + + const onApply = (next) => { + setPage(DEFAULT_CURRENT_PAGE); + setFilters(next); + }; + const onClear = () => { + setPage(DEFAULT_CURRENT_PAGE); + setFilters({}); + }; + const onGroupBy = (next) => { + setPage(DEFAULT_CURRENT_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 >= DEFAULT_CURRENT_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 > DEFAULT_CURRENT_PAGE && ( + + setPage(p)} + /> + + )} + + ); +}; + +const mapStateToProps = ({ + sponsorReportsSponsorAssetState, + currentSummitState +}) => ({ + currentSummit: currentSummitState.currentSummit, + ...sponsorReportsSponsorAssetState +}); + +const mapDispatchToProps = { + getSponsorAssetReport, + getSponsorAssetFilters, + exportSponsorAssetCsv +}; + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(SponsorAssetReportPage) +); 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/__tests__/sponsor-reports-reducers.test.js b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js new file mode 100644 index 000000000..df65e16b0 --- /dev/null +++ b/src/reducers/sponsors/__tests__/sponsor-reports-reducers.test.js @@ -0,0 +1,348 @@ +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_VALIDATION_CLEAR, + 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: 10000 } + } + }; + + 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: 10000 }); + expect(result.readError).toBeNull(); + expect(result.validationError).toBeNull(); + }); + + it("preserves existing summary when response summary is null", () => { + const prevSummary = { total_paid: 20000 }; + 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_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 }; + 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..8a39f7e2e --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-drilldown-reducer.js @@ -0,0 +1,51 @@ +/** + * 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 +} 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) +}; + +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 }; + default: + return state; + } +}; + +export default reducer; 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..9a7bace64 --- /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_CURRENT_PAGE, 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: DEFAULT_CURRENT_PAGE, + lastPage: DEFAULT_CURRENT_PAGE, + 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/reducers/sponsors/sponsor-reports-purchase-details-reducer.js b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js new file mode 100644 index 000000000..b4fae6899 --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-purchase-details-reducer.js @@ -0,0 +1,84 @@ +/** + * 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_CURRENT_PAGE, 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 +} from "../../actions/sponsor-reports-actions"; + +export const DEFAULT_STATE = { + data: [], + summary: null, + filterOptions: null, + total: 0, + currentPage: DEFAULT_CURRENT_PAGE, + lastPage: DEFAULT_CURRENT_PAGE, + 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 +}; + +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 }; + 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..622093357 --- /dev/null +++ b/src/reducers/sponsors/sponsor-reports-sponsor-asset-reducer.js @@ -0,0 +1,67 @@ +/** + * 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 +} 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 +}; + +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 + }; + } + 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 }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/store.js b/src/store.js index 326f84399..6b0e445f5 100644 --- a/src/store.js +++ b/src/store.js @@ -174,13 +174,23 @@ 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 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"; // default: localStorage if web, AsyncStorage if react-native const config = { key: "root", storage, - blacklist: ["dropboxSyncState"] + blacklist: [ + "dropboxSyncState", + "sponsorReportsPurchaseDetailsState", + "sponsorReportsPurchaseDetailsLinesState", + "sponsorReportsSponsorAssetState", + "sponsorReportsDrilldownState" + ] }; const reducers = persistCombineReducers(config, { @@ -343,7 +353,12 @@ const reducers = persistCombineReducers(config, { sponsorSettingsState: sponsorSettingsReducer, pageTemplateListState: pageTemplateListReducer, pageTemplateState: pageTemplateReducer, - dropboxSyncState: dropboxSyncReducer + dropboxSyncState: dropboxSyncReducer, + sponsorReportsPurchaseDetailsState: sponsorReportsPurchaseDetailsReducer, + sponsorReportsPurchaseDetailsLinesState: + sponsorReportsPurchaseDetailsLinesReducer, + sponsorReportsSponsorAssetState: sponsorReportsSponsorAssetReducer, + sponsorReportsDrilldownState: sponsorReportsDrilldownReducer }); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 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/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/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(); +};