@@ -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 })}
+ >
+
+ {statusOptions.map((s) => (
+
+ ))}
+
+ update({ formCode: e.target.value || undefined })}
+ >
+
+ {formOptions.map((f) => (
+
+ ))}
+
+ {/* 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={
+ <>
+
+ } variant="outlined" onClick={print}>
+ {T.translate("sponsor_reports_page.print")}
+
+ }
+ variant="outlined"
+ onClick={() =>
+ view === "orders"
+ ? exportOrdersCsv(filters, order, orderDir)
+ : exportLinesCsv(filters)
+ }
+ >
+ {T.translate("sponsor_reports_page.export_csv")}
+
+ >
+ }
+ 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}
+
+ }
+ variant="outlined"
+ onClick={() =>
+ exportSponsorAssetSectionCsv(sponsorId, section.page.id)
+ }
+ >
+ {T.translate("sponsor_reports_page.download_csv")}
+
+
+
+ {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={
+ <>
+ } variant="outlined" onClick={print}>
+ {T.translate("sponsor_reports_page.print")}
+
+ }
+ variant="outlined"
+ onClick={() =>
+ exportSponsorAssetCsv({ ...filters, moduleType: "Media" })
+ }
+ >
+ {T.translate("sponsor_reports_page.export_csv")}
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {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 1bef8e23f..12c1e6d77 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();
+};