From 9520b71c373112634c71726fb58c041cbb14227b Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Mon, 9 Mar 2026 17:47:50 -0300 Subject: [PATCH] fix: fix bulk inline edit mode for event activities --- .../__tests__/event-actions-bulk.test.js | 30 ++ src/actions/event-actions.js | 9 +- .../tables/editable-table/EditableTableRow.js | 2 +- .../__tests__/EditableTable.test.js | 96 ++++++ .../edit-event-material-page.test.js | 123 ++++++++ .../__tests__/summit-event-list-page.test.js | 276 ++++++++++++++++++ src/pages/events/summit-event-list-page.js | 52 +--- 7 files changed, 538 insertions(+), 50 deletions(-) create mode 100644 src/actions/__tests__/event-actions-bulk.test.js create mode 100644 src/components/tables/editable-table/__tests__/EditableTable.test.js create mode 100644 src/pages/events/__tests__/edit-event-material-page.test.js create mode 100644 src/pages/events/__tests__/summit-event-list-page.test.js 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);