diff --git a/src/actions/__tests__/event-actions-bulk.test.js b/src/actions/__tests__/event-actions-bulk.test.js
new file mode 100644
index 000000000..a2c16bc26
--- /dev/null
+++ b/src/actions/__tests__/event-actions-bulk.test.js
@@ -0,0 +1,30 @@
+import { normalizeBulkEvents } from "../event-actions";
+
+describe("event-actions bulk normalization", () => {
+ test("does not include speakers in bulk payload", () => {
+ const input = [
+ {
+ id: 10,
+ title: "My Event",
+ speakers: [{ id: 3 }, { id: 4 }],
+ selection_plan: { id: 20 },
+ type: { id: 30 },
+ track: { id: 40 },
+ streaming_url: "https://example.com/live"
+ }
+ ];
+
+ const result = normalizeBulkEvents(input);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ id: 10,
+ title: "My Event",
+ selection_plan_id: 20,
+ type_id: 30,
+ track_id: 40,
+ streaming_url: "https://example.com/live"
+ });
+ expect(result[0]).not.toHaveProperty("speakers");
+ });
+});
diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js
index d2f845a70..19b66aec5 100644
--- a/src/actions/event-actions.js
+++ b/src/actions/event-actions.js
@@ -430,10 +430,14 @@ export const normalizeEvent = (entity, eventTypeConfig, summit) => {
});
if (normalizedEntity.hasOwnProperty("sponsors"))
- normalizedEntity.sponsors = normalizedEntity.sponsors.map((s) => s.id);
+ normalizedEntity.sponsors = normalizedEntity.sponsors
+ .map((s) => (typeof s === "number" ? s : s?.id))
+ .filter((s) => !!s);
if (normalizedEntity.hasOwnProperty("speakers"))
- normalizedEntity.speakers = normalizedEntity.speakers.map((s) => s.id);
+ normalizedEntity.speakers = normalizedEntity.speakers
+ .map((s) => (typeof s === "number" ? s : s?.id))
+ .filter((s) => !!s);
if (
normalizedEntity.hasOwnProperty("moderator") &&
@@ -511,7 +515,6 @@ export const normalizeBulkEvents = (entity) => {
selection_plan_id: getIdValue(e.selection_plan) || e.selection_plan_id,
location_id: e.location?.id || e.location_id,
start_date: e.start_date,
- speakers: e.speakers,
end_date: e.end_date,
type_id: getIdValue(e.type) || e.type_id,
track_id: getIdValue(e.track) || e.track_id,
diff --git a/src/components/tables/editable-table/EditableTableRow.js b/src/components/tables/editable-table/EditableTableRow.js
index e382244ae..17783001c 100644
--- a/src/components/tables/editable-table/EditableTableRow.js
+++ b/src/components/tables/editable-table/EditableTableRow.js
@@ -135,7 +135,7 @@ function EditableTableRow(props) {
return (
{col.render
- ? col.render(row[col.columnKey])
+ ? col.render(row[col.columnKey], row)
: formattedData[col.columnKey]}
|
);
diff --git a/src/components/tables/editable-table/__tests__/EditableTable.test.js b/src/components/tables/editable-table/__tests__/EditableTable.test.js
new file mode 100644
index 000000000..189b36a09
--- /dev/null
+++ b/src/components/tables/editable-table/__tests__/EditableTable.test.js
@@ -0,0 +1,96 @@
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import EditableTable from "../EditableTable";
+
+describe("EditableTable", () => {
+ const baseProps = {
+ options: {
+ className: "test-table",
+ actions: {}
+ },
+ columns: [
+ { columnKey: "id", value: "id", sortable: true },
+ { columnKey: "title", value: "title", sortable: true }
+ ],
+ currentSummit: { id: 99 },
+ page: 1,
+ handleSort: jest.fn(),
+ handleDeleteRow: jest.fn(),
+ formattingFunction: (row) => row,
+ data: [
+ {
+ id: 1,
+ title: "Event 1",
+ media_uploads: [{ id: 11, event_id: 1 }]
+ },
+ {
+ id: 2,
+ title: "Event 2",
+ media_uploads: [{ id: 22, event_id: 2 }]
+ }
+ ]
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("applies bulk updates without executing afterUpdate by default", async () => {
+ const user = userEvent.setup();
+ const updateData = jest.fn(() => Promise.resolve());
+
+ render();
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ await user.click(checkboxes[1]);
+ await user.click(screen.getByText("event_list.edit_selected"));
+ await act(async () => {
+ await user.click(screen.getByText("bulk_actions_page.btn_apply_changes"));
+ });
+
+ await waitFor(() => {
+ expect(updateData).toHaveBeenCalledTimes(1);
+ expect(updateData).toHaveBeenCalledWith(
+ 99,
+ expect.arrayContaining([expect.objectContaining({ id: 1 })])
+ );
+ });
+ });
+
+ test("executes afterUpdate actions only when explicitly configured", async () => {
+ const user = userEvent.setup();
+ const updateData = jest.fn(() => Promise.resolve());
+ const afterUpdateAction = jest.fn(() => Promise.resolve());
+
+ render(
+
+ );
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ await user.click(checkboxes[1]);
+ await user.click(screen.getByText("event_list.edit_selected"));
+ await act(async () => {
+ await user.click(screen.getByText("bulk_actions_page.btn_apply_changes"));
+ });
+
+ await waitFor(() => {
+ expect(updateData).toHaveBeenCalledTimes(1);
+ expect(afterUpdateAction).toHaveBeenCalledTimes(1);
+ expect(afterUpdateAction).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 11, event_id: 1 })
+ );
+ });
+ });
+});
diff --git a/src/pages/events/__tests__/edit-event-material-page.test.js b/src/pages/events/__tests__/edit-event-material-page.test.js
new file mode 100644
index 000000000..3d23f24ea
--- /dev/null
+++ b/src/pages/events/__tests__/edit-event-material-page.test.js
@@ -0,0 +1,123 @@
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import { screen } from "@testing-library/react";
+import EditEventMaterialPage from "../edit-event-material-page";
+import { renderWithRedux } from "../../../utils/test-utils";
+
+jest.mock("react-breadcrumbs", () => ({
+ Breadcrumb: () => null
+}));
+
+jest.mock("../../../components/buttons/add-new-button", () => () => null);
+
+jest.mock("../../../components/forms/event-material-form", () => (props) => (
+
+
+
+
+));
+
+jest.mock("../../../actions/event-material-actions", () => ({
+ getEventMaterial: jest.fn(() => ({ type: "GET_EVENT_MATERIAL_MOCK" })),
+ resetEventMaterialForm: jest.fn(() => ({
+ type: "RESET_EVENT_MATERIAL_MOCK"
+ })),
+ saveEventMaterial: jest.fn(() => ({ type: "SAVE_EVENT_MATERIAL_MOCK" })),
+ saveEventMaterialWithFile: jest.fn(() => ({
+ type: "SAVE_EVENT_MATERIAL_WITH_FILE_MOCK"
+ }))
+}));
+
+const EventMaterialActions = jest.requireMock(
+ "../../../actions/event-material-actions"
+);
+
+describe("EditEventMaterialPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const baseState = {
+ currentSummitState: {
+ currentSummit: { id: 12 }
+ },
+ currentSummitEventState: {
+ entity: {
+ id: 321,
+ materials: []
+ }
+ },
+ currentEventMaterialState: {
+ entity: { id: 0, class_name: "PresentationSlide" },
+ errors: {}
+ }
+ };
+
+ test("uses saveEventMaterial for regular material submit (non-bulk)", async () => {
+ const user = userEvent.setup();
+
+ renderWithRedux(
+ ,
+ {
+ initialState: baseState
+ }
+ );
+
+ expect(EventMaterialActions.getEventMaterial).toHaveBeenCalledWith("15");
+
+ await user.click(screen.getByText("submit-material"));
+
+ expect(EventMaterialActions.saveEventMaterial).toHaveBeenCalledTimes(1);
+ expect(EventMaterialActions.saveEventMaterial).toHaveBeenCalledWith({
+ id: 77
+ });
+ });
+
+ test("uses saveEventMaterialWithFile with slides slug for file submit", async () => {
+ const user = userEvent.setup();
+
+ renderWithRedux(
+ ,
+ {
+ initialState: baseState
+ }
+ );
+
+ await user.click(screen.getByText("submit-material-with-file"));
+
+ expect(
+ EventMaterialActions.saveEventMaterialWithFile
+ ).toHaveBeenCalledTimes(1);
+
+ const [entityArg, fileArg, slugArg] =
+ EventMaterialActions.saveEventMaterialWithFile.mock.calls[0];
+
+ expect(entityArg).toEqual({ id: 88, class_name: "PresentationSlide" });
+ expect(fileArg).toBeInstanceOf(global.File);
+ expect(slugArg).toBe("slides");
+ });
+});
diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js
new file mode 100644
index 000000000..d7b0f1424
--- /dev/null
+++ b/src/pages/events/__tests__/summit-event-list-page.test.js
@@ -0,0 +1,276 @@
+import React from "react";
+import SummitEventListPage from "../summit-event-list-page";
+import { renderWithRedux } from "../../../utils/test-utils";
+
+const mockEditableTableSpy = jest.fn(() => null);
+
+jest.mock("openstack-uicore-foundation/lib/components", () => ({
+ CompanyInput: () => null,
+ DateTimePicker: () => null,
+ Dropdown: () => null,
+ FreeTextSearch: () => null,
+ Input: () => null,
+ MemberInput: () => null,
+ OperatorInput: () => null,
+ SpeakerInput: () => null,
+ TagInput: () => null,
+ UploadInput: () => null
+}));
+
+jest.mock(
+ "../../../components/tables/editable-table/EditableTable",
+ () =>
+ function EditableTableMock(props) {
+ mockEditableTableSpy(props);
+ return null;
+ }
+);
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ translate: (key) => key
+}));
+
+jest.mock("sweetalert2", () => ({
+ fire: jest.fn(() => Promise.resolve({ value: false }))
+}));
+
+jest.mock("react-breadcrumbs", () => ({
+ Breadcrumb: () => null
+}));
+
+jest.mock("react-bootstrap", () => {
+ const Modal = ({ children }) => {children}
;
+ Modal.Header = ({ children }) => {children}
;
+ Modal.Title = ({ children }) => {children}
;
+ Modal.Body = ({ children }) => {children}
;
+ Modal.Footer = ({ children }) => {children}
;
+
+ return {
+ Modal,
+ Pagination: () => null
+ };
+});
+
+jest.mock("../../../components/filters/media-type-filter", () => () => null);
+jest.mock("../../../components/filters/or-and-filter", () => () => null);
+jest.mock("../../../components/filters/save-filter-criteria", () => () => null);
+jest.mock(
+ "../../../components/filters/select-filter-criteria",
+ () => () => null
+);
+
+describe("SummitEventListPage", () => {
+ let windowOpenSpy;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ windowOpenSpy = jest.spyOn(window, "open").mockImplementation(() => null);
+ });
+
+ afterEach(() => {
+ windowOpenSpy.mockRestore();
+ });
+
+ test("does not pass afterUpdate prop to EditableTable in bulk mode", () => {
+ renderWithRedux(, {
+ initialState: {
+ currentSummitState: {
+ currentSummit: {
+ id: 12,
+ time_zone: { name: "UTC" },
+ time_zone_id: "UTC",
+ selection_plans: [],
+ tracks: [],
+ event_types: [],
+ locations: [],
+ presentation_action_types: []
+ }
+ },
+ currentEventListState: {
+ events: [
+ {
+ id: 101,
+ type: { id: 1, name: "Presentation", use_speakers: true },
+ title: "Sample event",
+ selection_status: "pending",
+ media_uploads: []
+ }
+ ],
+ lastPage: 1,
+ currentPage: 1,
+ order: "id",
+ orderDir: 1,
+ totalEvents: 1,
+ term: "",
+ filters: {},
+ extraColumns: ["media_uploads"],
+ perPage: 10,
+ enabledFilters: []
+ }
+ }
+ });
+
+ expect(mockEditableTableSpy).toHaveBeenCalled();
+
+ const editableTableProps =
+ mockEditableTableSpy.mock.calls[
+ mockEditableTableSpy.mock.calls.length - 1
+ ][0];
+ expect(editableTableProps.afterUpdate).toBeUndefined();
+ });
+
+ test("opens media upload material link using row event id", async () => {
+ renderWithRedux(, {
+ initialState: {
+ currentSummitState: {
+ currentSummit: {
+ id: 12,
+ time_zone: { name: "UTC" },
+ time_zone_id: "UTC",
+ selection_plans: [],
+ tracks: [],
+ event_types: [],
+ locations: [],
+ presentation_action_types: []
+ }
+ },
+ currentEventListState: {
+ events: [
+ {
+ id: 101,
+ type: { id: 1, name: "Presentation", use_speakers: true },
+ title: "Sample event",
+ selection_status: "pending",
+ media_uploads: [
+ {
+ id: 999,
+ summit_id: 12,
+ presentation_id: 101,
+ event_id: null,
+ created: "now",
+ media_upload_type: { name: "Slides" }
+ }
+ ]
+ }
+ ],
+ lastPage: 1,
+ currentPage: 1,
+ order: "id",
+ orderDir: 1,
+ totalEvents: 1,
+ term: "",
+ filters: {},
+ extraColumns: ["media_uploads"],
+ perPage: 10,
+ enabledFilters: []
+ }
+ }
+ });
+
+ const editableTableProps =
+ mockEditableTableSpy.mock.calls[
+ mockEditableTableSpy.mock.calls.length - 1
+ ][0];
+ const mediaUploadsColumn = editableTableProps.columns.find(
+ (col) => col.columnKey === "media_uploads"
+ );
+
+ const mediaUploadItem = {
+ id: 999,
+ summit_id: 12,
+ presentation_id: 101,
+ event_id: 101,
+ created: "now",
+ media_upload_type: { name: "Slides" }
+ };
+
+ const rendered = mediaUploadsColumn.render([mediaUploadItem], { id: 101 });
+ const firstRow = rendered.props.children[0];
+ const firstButton = Array.isArray(firstRow.props.children)
+ ? firstRow.props.children[0]
+ : firstRow.props.children;
+
+ firstButton.props.onClick({
+ preventDefault: jest.fn()
+ });
+
+ expect(windowOpenSpy).toHaveBeenCalledWith(
+ "/app/summits/12/events/101/materials/999",
+ "_blank"
+ );
+ });
+
+ test("does not open media upload material link when row event id is missing", async () => {
+ renderWithRedux(, {
+ initialState: {
+ currentSummitState: {
+ currentSummit: {
+ id: 12,
+ time_zone: { name: "UTC" },
+ time_zone_id: "UTC",
+ selection_plans: [],
+ tracks: [],
+ event_types: [],
+ locations: [],
+ presentation_action_types: []
+ }
+ },
+ currentEventListState: {
+ events: [
+ {
+ id: 101,
+ type: { id: 1, name: "Presentation", use_speakers: true },
+ title: "Sample event",
+ selection_status: "pending",
+ media_uploads: [
+ {
+ id: 999,
+ summit_id: 12,
+ created: "now",
+ media_upload_type: { name: "Slides" }
+ }
+ ]
+ }
+ ],
+ lastPage: 1,
+ currentPage: 1,
+ order: "id",
+ orderDir: 1,
+ totalEvents: 1,
+ term: "",
+ filters: {},
+ extraColumns: ["media_uploads"],
+ perPage: 10,
+ enabledFilters: []
+ }
+ }
+ });
+
+ const editableTableProps =
+ mockEditableTableSpy.mock.calls[
+ mockEditableTableSpy.mock.calls.length - 1
+ ][0];
+ const mediaUploadsColumn = editableTableProps.columns.find(
+ (col) => col.columnKey === "media_uploads"
+ );
+
+ const mediaUploadItem = {
+ id: 999,
+ summit_id: 12,
+ created: "now",
+ media_upload_type: { name: "Slides" }
+ };
+
+ const rendered = mediaUploadsColumn.render([mediaUploadItem], {});
+ const firstRow = rendered.props.children[0];
+ const firstButton = Array.isArray(firstRow.props.children)
+ ? firstRow.props.children[0]
+ : firstRow.props.children;
+
+ firstButton.props.onClick({
+ preventDefault: jest.fn()
+ });
+
+ expect(windowOpenSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/events/summit-event-list-page.js b/src/pages/events/summit-event-list-page.js
index b09c41cbf..bf67fe11c 100644
--- a/src/pages/events/summit-event-list-page.js
+++ b/src/pages/events/summit-event-list-page.js
@@ -1,5 +1,5 @@
/**
- * Copyright 2017 OpenStack Foundation
+ * 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
@@ -68,7 +68,6 @@ import {
} from "../../actions/filter-criteria-actions";
import { CONTEXT_ACTIVITIES } from "../../utils/filter-criteria-constants";
import EditableTable from "../../components/tables/editable-table/EditableTable";
-import { saveEventMaterial } from "../../actions/event-material-actions";
const fieldNames = (allSelectionPlans, allTracks, event_types) => [
{
@@ -253,7 +252,7 @@ const fieldNames = (allSelectionPlans, allTracks, event_types) => [
columnKey: "media_uploads",
value: "media_uploads",
sortable: false,
- render: (e) => {
+ render: (e, row) => {
if (!e?.length) return "N/A";
return (
<>
@@ -264,8 +263,9 @@ const fieldNames = (allSelectionPlans, allTracks, event_types) => [
className="text-link-button"
onClick={(ev) => {
ev.preventDefault();
+ if (!row?.id) return false;
window.open(
- `/app/summits/${m.summit_id}/events/${m.event_id}/materials/${m.id}`,
+ `/app/summits/${m.summit_id}/events/${row.id}/materials/${m.id}`,
"_blank"
);
return false;
@@ -298,38 +298,6 @@ const fieldNames = (allSelectionPlans, allTracks, event_types) => [
))}
>
);
- },
- editableField: (extraProps) => {
- const media_uploads = extraProps.row?.media_uploads || [];
- if (!media_uploads.length) return false;
- return (
- <>
- {media_uploads.map((m) => (
-
- {`"${m.media_upload_type.name}": `}
- ({ ...base, zIndex: 9999 }),
- control: (base, state) => ({
- ...base,
- zIndex: state.menuIsOpen ? HIGH_Z_INDEX : DEFAULT_Z_INDEX
- })
- }}
- />
-
- ))}
- >
- );
}
},
{
@@ -979,8 +947,7 @@ class SummitEventListPage extends React.Component {
orderDir,
totalEvents,
term,
- bulkUpdateEvents,
- saveEventMaterial
+ bulkUpdateEvents
} = this.props;
const {
enabledFilters,
@@ -1991,12 +1958,6 @@ class SummitEventListPage extends React.Component {
columns={columns}
handleSort={this.handleSort}
updateData={bulkUpdateEvents}
- afterUpdate={[
- {
- action: (data) => saveEventMaterial(data),
- propertyName: "media_uploads"
- }
- ]}
handleDeleteRow={this.handleDeleteEvent}
formattingFunction={formatEventData}
/>
@@ -2186,6 +2147,5 @@ export default connect(mapStateToProps, {
changeEventListSearchTerm,
saveFilterCriteria,
deleteFilterCriteria,
- bulkUpdateEvents,
- saveEventMaterial
+ bulkUpdateEvents
})(SummitEventListPage);