From 2e65d86cacb1774ad875732738329fe0ba489924 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 1 Oct 2026 21:48:50 -0500 Subject: [PATCH 01/39] refactor: change activity list grid from coreui to mui data grid --- package.json | 1 + .../mui/__tests__/bulk-edit-data-grid.test.js | 177 +++++++ src/components/mui/bulk-edit-data-grid.js | 454 ++++++++++++++++++ src/i18n/en.json | 3 +- .../__tests__/summit-event-list-page.test.js | 7 +- .../events/summit-event-list-page/index.js | 169 +++---- yarn.lock | 23 + 7 files changed, 720 insertions(+), 114 deletions(-) create mode 100644 src/components/mui/__tests__/bulk-edit-data-grid.test.js create mode 100644 src/components/mui/bulk-edit-data-grid.js diff --git a/package.json b/package.json index 5f100d02f..39b51ea74 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@fontsource/roboto": "^5.1.1", "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", + "@mui/x-data-grid": "^7.29.13", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.32.0", diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js new file mode 100644 index 000000000..7717015b2 --- /dev/null +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -0,0 +1,177 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import BulkEditDataGrid from "../bulk-edit-data-grid"; +import showConfirmDialog from "../showConfirmDialog"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../showConfirmDialog", () => jest.fn()); + +const columns = [ + { columnKey: "id", label: "Id", sortable: true }, + { columnKey: "title", label: "Title", sortable: true, editableField: true } +]; + +const data = [ + { id: 1, title: "First" }, + { id: 2, title: "Second" } +]; + +const renderGrid = (props = {}) => + render( + Promise.resolve())} + totalRows={30} + perPage={10} + currentPage={1} + onPageChange={jest.fn()} + onPerPageChange={jest.fn()} + getName={(row) => row.title} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); + +const selectRow = (rowName) => + userEvent.click( + within(screen.getByRole("row", { name: new RegExp(rowName) })).getByRole( + "checkbox" + ) + ); + +describe("BulkEditDataGrid", () => { + test("applies inline edits of the selected rows through onUpdate", async () => { + const onUpdate = jest.fn(() => Promise.resolve()); + renderGrid({ onUpdate }); + + await selectRow("Second"); + await userEvent.click( + screen.getByRole("button", { name: "general.edit_selected (1)" }) + ); + + const titleInput = screen.getByDisplayValue("Second"); + await userEvent.clear(titleInput); + await userEvent.type(titleInput, "Renamed"); + await userEvent.click( + screen.getByRole("button", { name: "general.apply_changes" }) + ); + + expect(onUpdate).toHaveBeenCalledWith([{ id: 2, title: "Renamed" }]); + await waitFor(() => + expect( + screen.getByRole("button", { name: "general.edit_selected" }) + ).toBeDisabled() + ); + }); + + test("leaves edit mode when the rows shown change", async () => { + const { rerender } = renderGrid(); + + await selectRow("First"); + await userEvent.click( + screen.getByRole("button", { name: "general.edit_selected (1)" }) + ); + expect( + screen.getByRole("button", { name: "general.apply_changes" }) + ).toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.queryByRole("button", { name: "general.apply_changes" }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.edit_selected" }) + ).toBeDisabled(); + }); + + test("reports 1-based pages to onPageChange", async () => { + const onPageChange = jest.fn(); + renderGrid({ onPageChange }); + + await userEvent.click( + screen.getByRole("button", { name: /go to next page/i }) + ); + + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + test("does not mark the default order as a user sort", () => { + renderGrid({ options: { sortCol: "id", sortDir: 1 } }); + + expect(screen.getByRole("columnheader", { name: "Id" })).toHaveAttribute( + "aria-sort", + "none" + ); + }); + + test("reports header sorting as column index, key and direction", async () => { + const onSort = jest.fn(); + renderGrid({ onSort }); + + await userEvent.click(screen.getByRole("columnheader", { name: "Title" })); + + expect(onSort).toHaveBeenCalledWith(1, "title", 1); + }); + + test("sends the quick filter text to onSearch", async () => { + const onSearch = jest.fn(); + renderGrid({ searchProps: { term: "", onSearch } }); + + await userEvent.type(screen.getByRole("searchbox"), "keynote"); + + await waitFor(() => expect(onSearch).toHaveBeenCalledWith("keynote")); + }); + + const clickDelete = (rowName) => + userEvent.click( + within(screen.getByRole("row", { name: new RegExp(rowName) })).getByRole( + "menuitem", + { name: "general.delete" } + ) + ); + + test("deletes a row after the confirm dialog is accepted", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(true); + renderGrid({ onDelete, deleteDialogBody: (name) => `delete ${name}?` }); + + await clickDelete("First"); + + expect(showConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ text: "delete First?" }) + ); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(1)); + }); + + test("keeps the row when the confirm dialog is cancelled", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(false); + renderGrid({ onDelete }); + + await clickDelete("First"); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled()); + expect(onDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js new file mode 100644 index 000000000..4a8dc698c --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid.js @@ -0,0 +1,454 @@ +/** + * 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 PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, TextField, Tooltip } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import FileDownloadIcon from "@mui/icons-material/FileDownload"; +import { + DataGrid, + GridActionsCellItem, + GridToolbarColumnsButton, + GridToolbarContainer, + GridToolbarQuickFilter +} from "@mui/x-data-grid"; +import showConfirmDialog from "./showConfirmDialog"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + DEFAULT_PER_PAGE, + FIFTY_PER_PAGE, + TWENTY_PER_PAGE +} from "../../utils/constants"; + +// MUI DataGrid version of uicore's BulkEditTable: same props and the same +// select -> "Edit Selected" -> Apply flow, so callers can swap one for the +// other. Kept self-contained so it can move to Core UI later. + +const BASE_PER_PAGE_OPTIONS = [ + DEFAULT_PER_PAGE, + TWENTY_PER_PAGE, + FIFTY_PER_PAGE +]; +const COLUMN_MIN_WIDTH = 150; +const EDIT_COLUMN_MIN_WIDTH = 250; +const DESC_ORDER_DIR = -1; + +// the grid uses arrows/space for cell navigation and row selection, which +// would otherwise swallow typing inside the bulk-edit inputs +const stopGridKeyDown = (ev) => ev.stopPropagation(); + +// toolbar buttons show only their icon; the label stays as tooltip/aria-label +const ICON_ONLY_BUTTON_SX = { + minWidth: 0, + "& .MuiButton-startIcon": { m: 0 } +}; + +const renderEditor = (col, editRow, onChange) => { + if (col.editableField === true) { + return ( + onChange(ev.target.value)} + value={editRow[col.columnKey] ?? ""} + /> + ); + } + + return ( + col.editableField({ + value: + editRow[col.columnKey]?.id ?? + editRow[col.columnKey]?.value ?? + editRow[col.columnKey], + onChange: (ev) => onChange(ev.target.value), + row: editRow, + rowData: editRow[col.columnKey] + }) ?? null + ); +}; + +const Toolbar = ({ + filter, + onExport, + searchProps, + editEnabled, + selectedCount, + onEditSelected, + onApply, + onCancel +}) => ( + + {searchProps && ( + (input ? [input] : [])} + quickFilterFormatter={(values) => values.join(" ")} + /> + )} + + + {filter} + {onExport && ( + + + + + ) : ( + + )} + +); + +Toolbar.propTypes = { + filter: PropTypes.node, + onExport: PropTypes.func, + searchProps: PropTypes.shape({ placeholder: PropTypes.string }), + editEnabled: PropTypes.bool.isRequired, + selectedCount: PropTypes.number.isRequired, + onEditSelected: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +Toolbar.defaultProps = { + filter: null, + onExport: null, + searchProps: null +}; + +const BulkEditDataGrid = ({ + options, + columns, + data, + onSort, + onUpdate, + idKey, + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onEdit, + onDelete, + getName, + deleteDialogBody, + columnVisibilityModel, + onColumnVisibilityModelChange, + filter, + onExport, + searchProps, + noRowsLabel +}) => { + const [selectedIds, setSelectedIds] = useState([]); + const [editRows, setEditRows] = useState({}); + const [editEnabled, setEditEnabled] = useState(false); + // the list always has a default order; only show the sort arrow once the + // user has sorted a column themselves + const [hasUserSorted, setHasUserSorted] = useState(false); + + const reset = () => { + setSelectedIds([]); + setEditRows({}); + setEditEnabled(false); + }; + + const dataIds = data.map((row) => row[idKey]).join(","); + + // reset selection/edit state whenever the set of rows shown changes + // (pagination, filtering, sorting, search, etc.) + useEffect(() => { + reset(); + }, [dataIds]); + + const enterEditMode = () => { + setEditRows( + Object.fromEntries( + data + .filter((row) => selectedIds.includes(row[idKey])) + .map((row) => [row[idKey], row]) + ) + ); + setEditEnabled(true); + }; + + const editField = (rowId, key, value) => { + setEditRows((current) => ({ + ...current, + [rowId]: { ...current[rowId], [key]: value } + })); + }; + + const handleApply = () => { + Promise.resolve(onUpdate(Object.values(editRows))) + .then(() => reset()) + // the action's error handler reports the failure; keep the edits so + // the user can retry + .catch(() => {}); + }; + + const handleDelete = async (row) => { + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: + typeof deleteDialogBody === "function" + ? deleteDialogBody(getName(row)) + : `${T.translate("general.row_remove_warning")} ${getName(row)}`, + iconType: "warning", + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (isConfirmed) onDelete(row[idKey]); + }; + + const gridColumns = columns.map((col) => { + const customMinWidth = parseFloat(col.customStyle?.minWidth); + const minWidth = Math.max( + Number.isNaN(customMinWidth) ? COLUMN_MIN_WIDTH : customMinWidth, + editEnabled && col.editableField ? EDIT_COLUMN_MIN_WIDTH : 0 + ); + + return { + field: col.columnKey, + headerName: col.header ?? col.label ?? col.value, + sortable: !!col.sortable, + hideable: col.hideable ?? true, + flex: 1, + minWidth, + renderCell: ({ row }) => { + const rowId = row[idKey]; + + if (editEnabled && col.editableField && editRows[rowId]) { + return ( + + {renderEditor(col, editRows[rowId], (value) => + editField(rowId, col.columnKey, value) + )} + + ); + } + + if (col.render) return col.render(row[col.columnKey], row) ?? null; + + return row[col.columnKey] ?? null; + } + }; + }); + + if (onEdit || onDelete) { + gridColumns.push({ + field: "actions", + type: "actions", + getActions: ({ row }) => + [ + onEdit && ( + } + label={T.translate("general.edit")} + onClick={() => onEdit(row)} + /> + ), + onDelete && ( + } + label={T.translate("general.delete")} + onClick={() => handleDelete(row)} + /> + ) + ].filter(Boolean) + }); + } + + const pageSizeOptions = BASE_PER_PAGE_OPTIONS.includes(perPage) + ? BASE_PER_PAGE_OPTIONS + : [...BASE_PER_PAGE_OPTIONS, perPage].sort((a, b) => a - b); + + const handlePaginationModelChange = ({ page, pageSize }) => { + if (pageSize !== perPage) { + onPerPageChange(pageSize); + } else { + onPageChange(page + 1); + } + }; + + const sortModel = + hasUserSorted && options.sortCol + ? [ + { + field: options.sortCol, + sort: options.sortDir === DESC_ORDER_DIR ? "desc" : "asc" + } + ] + : []; + + const handleSortModelChange = (model) => { + if (model.length === 0) return; + setHasUserSorted(true); + const { field, sort } = model[0]; + onSort( + columns.findIndex((col) => col.columnKey === field), + field, + sort === "desc" ? DESC_ORDER_DIR : DEFAULT_ORDER_DIR + ); + }; + + const filterModel = searchProps + ? { + items: [], + quickFilterValues: searchProps.term ? [searchProps.term] : [] + } + : undefined; + + const handleFilterModelChange = (model) => { + const newTerm = (model.quickFilterValues ?? []).join(" "); + if (newTerm !== (searchProps.term ?? "")) searchProps.onSearch(newTerm); + }; + + return ( + row[idKey]} + getRowHeight={() => "auto"} + checkboxSelection + disableRowSelectionOnClick + rowSelectionModel={selectedIds} + onRowSelectionModelChange={setSelectedIds} + isRowSelectable={() => !editEnabled} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={onColumnVisibilityModelChange} + disableColumnFilter + paginationMode="server" + rowCount={totalRows} + paginationModel={{ page: currentPage - 1, pageSize: perPage }} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={handlePaginationModelChange} + sortingMode="server" + sortingOrder={["asc", "desc"]} + sortModel={sortModel} + onSortModelChange={handleSortModelChange} + disableColumnSorting={editEnabled} + filterMode="server" + filterModel={filterModel} + onFilterModelChange={searchProps ? handleFilterModelChange : undefined} + localeText={{ toolbarColumns: "", ...(noRowsLabel && { noRowsLabel }) }} + slots={{ toolbar: Toolbar }} + slotProps={{ + toolbar: { + filter, + onExport, + searchProps, + editEnabled, + selectedCount: selectedIds.length, + onEditSelected: enterEditMode, + onApply: handleApply, + onCancel: reset + } + }} + sx={{ "& .MuiDataGrid-cell": { py: 1 } }} + /> + ); +}; + +BulkEditDataGrid.propTypes = { + options: PropTypes.shape({ + sortCol: PropTypes.string, + sortDir: PropTypes.number + }).isRequired, + columns: PropTypes.arrayOf( + PropTypes.shape({ columnKey: PropTypes.string.isRequired }) + ).isRequired, + data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + onSort: PropTypes.func.isRequired, + onUpdate: PropTypes.func.isRequired, + idKey: PropTypes.string, + totalRows: PropTypes.number, + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func, + onEdit: PropTypes.func, + onDelete: PropTypes.func, + getName: PropTypes.func, + deleteDialogBody: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + columnVisibilityModel: PropTypes.objectOf(PropTypes.bool), + onColumnVisibilityModelChange: PropTypes.func, + // rendered in the grid toolbar, e.g. a uicore + filter: PropTypes.node, + onExport: PropTypes.func, + // same shape as GridToolbar's searchProps + searchProps: PropTypes.shape({ + term: PropTypes.string, + onSearch: PropTypes.func.isRequired, + placeholder: PropTypes.string + }), + noRowsLabel: PropTypes.string +}; + +BulkEditDataGrid.defaultProps = { + idKey: "id", + totalRows: 0, + perPage: DEFAULT_PER_PAGE, + currentPage: DEFAULT_CURRENT_PAGE, + onPageChange: () => {}, + onPerPageChange: () => {}, + onEdit: null, + onDelete: null, + getName: (row) => row.name, + deleteDialogBody: null, + columnVisibilityModel: undefined, + onColumnVisibilityModelChange: undefined, + filter: null, + onExport: null, + searchProps: null, + noRowsLabel: null +}; + +export default BulkEditDataGrid; diff --git a/src/i18n/en.json b/src/i18n/en.json index ea9c9f400..4086cb912 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -49,7 +49,6 @@ "delete": "Delete", "clone": "Clone", "new": "New", - "all": "All", "new_summit": "New Event", "drop_files": "Drop images or click to select files to upload.", "search": "Search", @@ -79,6 +78,7 @@ "drag_and_drop": "Drag and Drop to sort items.", "remove_warning": "Are you sure you want to delete this?", "edit_selected": "Edit Selected", + "apply_changes": "Apply Changes", "copy_to_clipboard": "Copy to clipboard", "copied": "Copied!", "success": "Success!", @@ -1242,7 +1242,6 @@ "speaker_company": "Speakers Companies", "submitter_company": "Submitter Company", "sponsor": "Sponsors", - "all_companies": "All Companies", "track": "Activity Category", "start_date": "Start Date", "end_date": "End Date", diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js index 374b65979..497968229 100644 --- a/src/pages/events/__tests__/summit-event-list-page.test.js +++ b/src/pages/events/__tests__/summit-event-list-page.test.js @@ -19,13 +19,12 @@ jest.mock("../../../actions/filter-criteria-actions", () => ({ })); jest.mock( - "openstack-uicore-foundation/lib/components/mui/bulk-edit-table", + "../../../components/mui/bulk-edit-data-grid", () => - function BulkEditTableMock(props) { + function BulkEditDataGridMock(props) { mockEditableTableSpy(props); return null; - }, - { virtual: true } + } ); jest.mock("i18n-react/dist/i18n-react", () => ({ diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index 672d8945e..6d762738e 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -14,16 +14,15 @@ import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import { Box, Button } from "@mui/material"; +import { Button } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; -import MuiDropdown from "openstack-uicore-foundation/lib/components/mui/dropdown"; import { GridFilter, useGridFilter } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; -import BulkEditTable from "openstack-uicore-foundation/lib/components/mui/bulk-edit-table"; import GridToolbar from "../../../components/mui/grid-toolbar"; +import BulkEditDataGrid from "../../../components/mui/bulk-edit-data-grid"; import { bulkUpdateEvents, deleteEvent, @@ -33,7 +32,6 @@ import { importMP4AssetsFromMUX } from "../../../actions/event-actions"; import { getMediaUploads } from "../../../actions/media-upload-actions"; -import { handleDDLSortByLabel } from "../../../utils/methods"; import { DEFAULT_CURRENT_PAGE, DEFAULT_Z_INDEX, @@ -197,54 +195,21 @@ const SummitEventListPage = ({ currentSummit.id ); - const columnDDLOptions = [ - ...optionalColumns.map((oc) => ({ value: oc.columnKey, label: oc.label })), - { - value: "all_companies", - label: T.translate("event_list.all_companies") - }, - { - value: "all", - label: T.translate("general.all") - } - ]; - - const handleColumnsChange = (ev) => { - const { value: newColumns } = ev.target; - - if (newColumns.includes("all")) { - setSelectedColumns( - columnDDLOptions.map((opt) => opt.value).filter((v) => v !== "all") - ); - return; - } - - const allCompanies = ["submitter_company", "speaker_company", "sponsor"]; - const hadAllCompanies = selectedColumns.includes("all_companies"); - const hasAllCompanies = newColumns.includes("all_companies"); - - if (hadAllCompanies && !hasAllCompanies) { - setSelectedColumns(newColumns.filter((c) => !allCompanies.includes(c))); - return; - } - - if (hasAllCompanies) { - const selectedCompanies = allCompanies.filter((c) => - selectedColumns.includes(c) - ).length; - const newCompanies = allCompanies.filter((c) => - newColumns.includes(c) - ).length; - - setSelectedColumns( - newCompanies < selectedCompanies - ? newColumns.filter((c) => c !== "all_companies") - : [...new Set([...newColumns, ...allCompanies])] - ); - return; - } + const columnVisibilityModel = Object.fromEntries( + optionalColumns.map((oc) => [ + oc.columnKey, + selectedColumns.includes(oc.columnKey) + ]) + ); - setSelectedColumns(newColumns); + // the grid omits columns from the model when they're visible (e.g. after + // "Show all"), so only an explicit false hides one + const handleColumnVisibilityModelChange = (model) => { + setSelectedColumns( + optionalColumns + .map((oc) => oc.columnKey) + .filter((columnKey) => model[columnKey] !== false) + ); }; const handleFilterCriteriaChange = (filterCriteria) => { @@ -260,11 +225,17 @@ const SummitEventListPage = ({ const eventTypeOptions = buildNameIdDDL(currentSummit.event_types); const fixedColumns = [ - { columnKey: "id", label: T.translate("general.id"), sortable: true }, + { + columnKey: "id", + label: T.translate("general.id"), + sortable: true, + hideable: false + }, { columnKey: "type", label: T.translate("event_list.type"), sortable: true, + hideable: false, // eslint-disable-next-line react/no-unstable-nested-components editableField: (extraProps) => ( status === "unaccepted" && row.is_published === true ? "accepted" @@ -310,11 +283,7 @@ const SummitEventListPage = ({ sortDir: orderDir }; - const selectedOptionalColumns = optionalColumns.filter((c) => - selectedColumns.includes(c.columnKey) - ); - - const tableColumns = [...fixedColumns, ...selectedOptionalColumns]; + const tableColumns = [...fixedColumns, ...optionalColumns]; if (!currentSummit.id) return
; @@ -325,18 +294,7 @@ const SummitEventListPage = ({

{T.translate("event_list.event_list")} ({totalEvents})

- - + -
Date: Thu, 1 Oct 2026 21:52:47 -0500 Subject: [PATCH 02/39] fix: add original pagination back to the top and bottom of grid --- .../mui/__tests__/bulk-edit-data-grid.test.js | 11 +- src/components/mui/bulk-edit-data-grid.js | 150 ++++++++++-------- 2 files changed, 95 insertions(+), 66 deletions(-) diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js index 7717015b2..e0b440a9e 100644 --- a/src/components/mui/__tests__/bulk-edit-data-grid.test.js +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -109,9 +109,14 @@ describe("BulkEditDataGrid", () => { const onPageChange = jest.fn(); renderGrid({ onPageChange }); - await userEvent.click( - screen.getByRole("button", { name: /go to next page/i }) - ); + // top and bottom paginations both page the list + const [topNext, bottomNext] = screen.getAllByRole("button", { + name: "mui_table.next_page" + }); + await userEvent.click(topNext); + expect(onPageChange).toHaveBeenLastCalledWith(2); + + await userEvent.click(bottomNext); expect(onPageChange).toHaveBeenCalledWith(2); }); diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 4a8dc698c..e36b5ecb8 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -25,24 +25,18 @@ import { GridToolbarContainer, GridToolbarQuickFilter } from "@mui/x-data-grid"; +import CustomTablePagination from "openstack-uicore-foundation/lib/components/mui/table/custom-table-pagination"; import showConfirmDialog from "./showConfirmDialog"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, - DEFAULT_PER_PAGE, - FIFTY_PER_PAGE, - TWENTY_PER_PAGE + DEFAULT_PER_PAGE } from "../../utils/constants"; // MUI DataGrid version of uicore's BulkEditTable: same props and the same // select -> "Edit Selected" -> Apply flow, so callers can swap one for the // other. Kept self-contained so it can move to Core UI later. -const BASE_PER_PAGE_OPTIONS = [ - DEFAULT_PER_PAGE, - TWENTY_PER_PAGE, - FIFTY_PER_PAGE -]; const COLUMN_MIN_WIDTH = 150; const EDIT_COLUMN_MIN_WIDTH = 250; const DESC_ORDER_DIR = -1; @@ -143,6 +137,34 @@ const Toolbar = ({ ); +// replaces the grid's footer with the uicore pagination BulkEditTable uses +const Footer = ({ + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange +}) => ( + + + +); + +Footer.propTypes = { + totalRows: PropTypes.number.isRequired, + perPage: PropTypes.number.isRequired, + currentPage: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + onPerPageChange: PropTypes.func.isRequired +}; + Toolbar.propTypes = { filter: PropTypes.node, onExport: PropTypes.func, @@ -305,18 +327,6 @@ const BulkEditDataGrid = ({ }); } - const pageSizeOptions = BASE_PER_PAGE_OPTIONS.includes(perPage) - ? BASE_PER_PAGE_OPTIONS - : [...BASE_PER_PAGE_OPTIONS, perPage].sort((a, b) => a - b); - - const handlePaginationModelChange = ({ page, pageSize }) => { - if (pageSize !== perPage) { - onPerPageChange(pageSize); - } else { - onPageChange(page + 1); - } - }; - const sortModel = hasUserSorted && options.sortCol ? [ @@ -351,49 +361,63 @@ const BulkEditDataGrid = ({ }; return ( - row[idKey]} - getRowHeight={() => "auto"} - checkboxSelection - disableRowSelectionOnClick - rowSelectionModel={selectedIds} - onRowSelectionModelChange={setSelectedIds} - isRowSelectable={() => !editEnabled} - columnVisibilityModel={columnVisibilityModel} - onColumnVisibilityModelChange={onColumnVisibilityModelChange} - disableColumnFilter - paginationMode="server" - rowCount={totalRows} - paginationModel={{ page: currentPage - 1, pageSize: perPage }} - pageSizeOptions={pageSizeOptions} - onPaginationModelChange={handlePaginationModelChange} - sortingMode="server" - sortingOrder={["asc", "desc"]} - sortModel={sortModel} - onSortModelChange={handleSortModelChange} - disableColumnSorting={editEnabled} - filterMode="server" - filterModel={filterModel} - onFilterModelChange={searchProps ? handleFilterModelChange : undefined} - localeText={{ toolbarColumns: "", ...(noRowsLabel && { noRowsLabel }) }} - slots={{ toolbar: Toolbar }} - slotProps={{ - toolbar: { - filter, - onExport, - searchProps, - editEnabled, - selectedCount: selectedIds.length, - onEditSelected: enterEditMode, - onApply: handleApply, - onCancel: reset - } - }} - sx={{ "& .MuiDataGrid-cell": { py: 1 } }} - /> + + {/* top copy has no range text and is hidden on phones, as in BulkEditTable */} + + + + row[idKey]} + getRowHeight={() => "auto"} + checkboxSelection + disableRowSelectionOnClick + rowSelectionModel={selectedIds} + onRowSelectionModelChange={setSelectedIds} + isRowSelectable={() => !editEnabled} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={onColumnVisibilityModelChange} + disableColumnFilter + sortingMode="server" + sortingOrder={["asc", "desc"]} + sortModel={sortModel} + onSortModelChange={handleSortModelChange} + disableColumnSorting={editEnabled} + filterMode="server" + filterModel={filterModel} + onFilterModelChange={searchProps ? handleFilterModelChange : undefined} + localeText={{ toolbarColumns: "", ...(noRowsLabel && { noRowsLabel }) }} + slots={{ toolbar: Toolbar, footer: Footer }} + slotProps={{ + footer: { + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange + }, + toolbar: { + filter, + onExport, + searchProps, + editEnabled, + selectedCount: selectedIds.length, + onEditSelected: enterEditMode, + onApply: handleApply, + onCancel: reset + } + }} + sx={{ "& .MuiDataGrid-cell": { py: 1 } }} + /> + ); }; From 39d075b0ea8eb925f7399694dc2df52f2b15989b Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 1 Oct 2026 21:57:02 -0500 Subject: [PATCH 03/39] fix: add sticky headers to the grid --- src/components/mui/bulk-edit-data-grid.js | 101 ++++++++++++---------- 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index e36b5ecb8..fba7253a6 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -90,7 +90,9 @@ const Toolbar = ({ onApply, onCancel }) => ( - + {searchProps && ( - row[idKey]} - getRowHeight={() => "auto"} - checkboxSelection - disableRowSelectionOnClick - rowSelectionModel={selectedIds} - onRowSelectionModelChange={setSelectedIds} - isRowSelectable={() => !editEnabled} - columnVisibilityModel={columnVisibilityModel} - onColumnVisibilityModelChange={onColumnVisibilityModelChange} - disableColumnFilter - sortingMode="server" - sortingOrder={["asc", "desc"]} - sortModel={sortModel} - onSortModelChange={handleSortModelChange} - disableColumnSorting={editEnabled} - filterMode="server" - filterModel={filterModel} - onFilterModelChange={searchProps ? handleFilterModelChange : undefined} - localeText={{ toolbarColumns: "", ...(noRowsLabel && { noRowsLabel }) }} - slots={{ toolbar: Toolbar, footer: Footer }} - slotProps={{ - footer: { - totalRows, - perPage, - currentPage, - onPageChange, - onPerPageChange - }, - toolbar: { - filter, - onExport, - searchProps, - editEnabled, - selectedCount: selectedIds.length, - onEditSelected: enterEditMode, - onApply: handleApply, - onCancel: reset + {/* flex parent + maxHeight: the grid fits its rows, and once they + outgrow the viewport it scrolls internally, keeping the column + headers in view */} + + row[idKey]} + getRowHeight={() => "auto"} + checkboxSelection + disableRowSelectionOnClick + rowSelectionModel={selectedIds} + onRowSelectionModelChange={setSelectedIds} + isRowSelectable={() => !editEnabled} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={onColumnVisibilityModelChange} + disableColumnFilter + sortingMode="server" + sortingOrder={["asc", "desc"]} + sortModel={sortModel} + onSortModelChange={handleSortModelChange} + disableColumnSorting={editEnabled} + filterMode="server" + filterModel={filterModel} + onFilterModelChange={ + searchProps ? handleFilterModelChange : undefined } - }} - sx={{ "& .MuiDataGrid-cell": { py: 1 } }} - /> + localeText={{ + toolbarColumns: "", + ...(noRowsLabel && { noRowsLabel }) + }} + slots={{ toolbar: Toolbar, footer: Footer }} + slotProps={{ + footer: { + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange + }, + toolbar: { + filter, + onExport, + searchProps, + editEnabled, + selectedCount: selectedIds.length, + onEditSelected: enterEditMode, + onApply: handleApply, + onCancel: reset + } + }} + sx={{ "& .MuiDataGrid-cell": { py: 1 } }} + /> + ); }; From ddd8705df22b3310d8e65ba961f7302c9fd76661 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 1 Oct 2026 22:02:37 -0500 Subject: [PATCH 04/39] refactor: reorder grid header buttons --- .../mui/__tests__/bulk-edit-data-grid.test.js | 18 ++++ src/components/mui/bulk-edit-data-grid.js | 101 +++++++++++++----- src/i18n/en.json | 1 - .../events/summit-event-list-page/index.js | 2 +- 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js index e0b440a9e..87e57d7d7 100644 --- a/src/components/mui/__tests__/bulk-edit-data-grid.test.js +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -143,11 +143,29 @@ describe("BulkEditDataGrid", () => { const onSearch = jest.fn(); renderGrid({ searchProps: { term: "", onSearch } }); + await userEvent.click( + screen.getByRole("button", { name: "general.search" }) + ); await userEvent.type(screen.getByRole("searchbox"), "keynote"); await waitFor(() => expect(onSearch).toHaveBeenCalledWith("keynote")); }); + test("shows the search box open when a search term is applied, and collapses it when left empty", async () => { + renderGrid({ searchProps: { term: "keynote", onSearch: jest.fn() } }); + + const searchbox = screen.getByRole("searchbox"); + expect(searchbox).toHaveValue("keynote"); + + await userEvent.clear(searchbox); + await userEvent.tab(); + + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.search" }) + ).toBeInTheDocument(); + }); + const clickDelete = (rowName) => userEvent.click( within(screen.getByRole("row", { name: new RegExp(rowName) })).getByRole( diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index fba7253a6..3ae24422d 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -14,10 +14,11 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; -import { Box, Button, TextField, Tooltip } from "@mui/material"; +import { Box, Button, Divider, TextField, Tooltip } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import FileDownloadIcon from "@mui/icons-material/FileDownload"; +import SearchIcon from "@mui/icons-material/Search"; import { DataGrid, GridActionsCellItem, @@ -80,6 +81,48 @@ const renderEditor = (col, editRow, onChange) => { ); }; +// search icon that expands into the grid's quick filter; starts open when a +// search term is already applied, and collapses again when left empty +const ExpandableQuickFilter = ({ placeholder, term }) => { + const [expanded, setExpanded] = useState(!!term); + + if (!expanded) { + return ( + + )} + + + {filter} + {onExport && ( + <> + + + )} + {onAdd && ( + + - - -
} + onAdd={handleNewEvent} + addLabel={T.translate("event_list.add_event")} + importItems={[ + { + label: T.translate("event_list.mux_import"), + onClick: () => setShowImportFromMUXModal(true) + }, + { + label: T.translate("event_list.import"), + onClick: () => setShowImportModal(true) + } + ]} + importLabel={T.translate("event_list.import")} onExport={handleExport} searchProps={{ term, From 4ef2a1d6b647c0a02047d3a0be49303047aaf704 Mon Sep 17 00:00:00 2001 From: anna Date: Thu, 1 Oct 2026 22:29:00 -0500 Subject: [PATCH 07/39] refactor: move saved views into the grid toolbar --- .../saved-views/__tests__/saved-views.test.js | 224 +++++++++++++ src/components/filters/saved-views/index.js | 293 ++++++++++++++++++ src/components/mui/bulk-edit-data-grid.js | 9 + src/i18n/en.json | 7 + .../__tests__/summit-event-list-page.test.js | 15 +- .../events/summit-event-list-page/index.js | 47 +-- 6 files changed, 565 insertions(+), 30 deletions(-) create mode 100644 src/components/filters/saved-views/__tests__/saved-views.test.js create mode 100644 src/components/filters/saved-views/index.js diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js new file mode 100644 index 000000000..456fbe1d0 --- /dev/null +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -0,0 +1,224 @@ +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SavedViews from ".."; +import showConfirmDialog from "../../../mui/showConfirmDialog"; +import { queryFilterCriterias } from "../../../../actions/filter-criteria-actions"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../mui/showConfirmDialog", () => jest.fn()); + +jest.mock("../../../../actions/filter-criteria-actions", () => ({ + queryFilterCriterias: jest.fn() +})); + +const VIEWS = [ + { id: 1, name: "Open Activities", visibility: "Everyone", criteria: [] }, + { id: 2, name: "Recently Modified", visibility: "Me", criteria: [] } +]; + +const renderViews = (props = {}) => + render( + Promise.resolve())} + onDelete={jest.fn(() => Promise.resolve())} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); + +const openMenu = (label = "saved_views.title") => + userEvent.click(screen.getByRole("button", { name: label })); + +describe("SavedViews", () => { + beforeEach(() => { + jest.clearAllMocks(); + queryFilterCriterias.mockImplementation((summitId, context, input, cb) => + cb(VIEWS.filter((v) => v.name.includes(input))) + ); + }); + + test("lists the show's views for this context and applies the one picked", async () => { + const onChange = jest.fn(); + renderViews({ onChange }); + + await openMenu(); + + expect(queryFilterCriterias).toHaveBeenCalledWith( + 12, + "Activities", + "", + expect.any(Function) + ); + await userEvent.click( + screen.getByRole("menuitem", { name: /Recently Modified/ }) + ); + + expect(onChange).toHaveBeenCalledWith(VIEWS[1]); + }); + + test("searches views by name", async () => { + renderViews(); + + await openMenu(); + await userEvent.type( + screen.getByRole("textbox", { name: "general.search" }), + "Open" + ); + + expect(queryFilterCriterias).toHaveBeenLastCalledWith( + 12, + "Activities", + "Open", + expect.any(Function) + ); + expect( + screen.queryByRole("menuitem", { name: /Recently Modified/ }) + ).not.toBeInTheDocument(); + }); + + test("deletes a view after confirming, without applying it", async () => { + const onChange = jest.fn(); + const onDelete = jest.fn(() => Promise.resolve()); + showConfirmDialog.mockResolvedValueOnce(true); + renderViews({ onChange, onDelete }); + + await openMenu(); + await userEvent.click( + screen.getByRole("button", { name: "general.delete Open Activities" }) + ); + + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(1)); + await waitFor(() => + expect( + screen.queryByRole("menuitem", { name: /Open Activities/ }) + ).not.toBeInTheDocument() + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("keeps the view when the delete is cancelled", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(false); + renderViews({ onDelete }); + + await openMenu(); + await userEvent.click( + screen.getByRole("button", { name: "general.delete Open Activities" }) + ); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled()); + expect(onDelete).not.toHaveBeenCalled(); + }); + + test("offers clearing only while a view is active", async () => { + const onChange = jest.fn(); + const { rerender } = renderViews({ onChange }); + + await openMenu(); + expect( + screen.queryByRole("menuitem", { name: "saved_views.clear" }) + ).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + + rerender( + + ); + await openMenu("Open Activities"); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.clear" }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + test("requires a name and visibility before saving", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderViews({ onSave }); + + await openMenu(); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(await screen.findAllByText("validation.required")).toHaveLength(2); + expect(onSave).not.toHaveBeenCalled(); + }); + + test("saves over the active view and closes once the save resolves", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderViews({ onSave, selectedView: VIEWS[0] }); + + await openMenu("Open Activities"); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + + const name = screen.getByRole("textbox", { name: "saved_views.name" }); + expect(name).toHaveValue("Open Activities"); + await userEvent.clear(name); + await userEvent.type(name, "Open Keynotes"); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(onSave).toHaveBeenCalledWith({ + id: 1, + name: "Open Keynotes", + visibility: "Everyone" + }); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + ); + }); + + test("keeps the dialog open when the save fails", async () => { + let rejectSave; + const onSave = jest.fn( + () => + new Promise((resolve, reject) => { + rejectSave = reject; + }) + ); + renderViews({ onSave }); + + await openMenu(); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + await userEvent.type( + screen.getByRole("textbox", { name: "saved_views.name" }), + "Mine" + ); + await userEvent.click( + screen.getByRole("radio", { name: "save_filter_criteria.me" }) + ); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled() + ); + await act(async () => rejectSave(new Error("nope"))); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "general.save" })).toBeEnabled() + ); + }); +}); diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js new file mode 100644 index 000000000..228006158 --- /dev/null +++ b/src/components/filters/saved-views/index.js @@ -0,0 +1,293 @@ +/** + * 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, { useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + ListItemText, + Menu, + MenuItem, + TextField +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import DeleteIcon from "@mui/icons-material/Delete"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import MuiFormikRadioGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group"; +import showConfirmDialog from "../../mui/showConfirmDialog"; +import { queryFilterCriterias } from "../../../actions/filter-criteria-actions"; +import { + VISIBILITY_OPTION_EVERYONE, + VISIBILITY_OPTION_ME +} from "../../../utils/filter-criteria-constants"; + +const SaveViewDialog = ({ selectedView, onSave, onClose }) => { + const formik = useFormik({ + initialValues: { + name: selectedView?.name ?? "", + visibility: selectedView?.visibility ?? "" + }, + validationSchema: yup.object({ + name: yup.string().trim().required(T.translate("validation.required")), + visibility: yup.string().required(T.translate("validation.required")) + }), + // keeps the id of the active view so saving updates it instead of + // creating a new one + onSubmit: (values) => + onSave({ id: selectedView?.id, ...values }) + .then(() => onClose()) + // the action's error handler reports the failure; keep the dialog + // open so the user can retry + .catch(() => {}) + }); + + const handleClose = () => { + if (formik.isSubmitting) return; + onClose(); + }; + + return ( + + {T.translate("saved_views.save_current")} + + + + + + + + + + + + + + ); +}; + +SaveViewDialog.propTypes = { + selectedView: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + visibility: PropTypes.string + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +SaveViewDialog.defaultProps = { + selectedView: null +}; + +// Saved filter criteria as a toolbar dropdown: pick a view to apply it, +// delete one, clear the active one, or save the current filters as a view. +const SavedViews = ({ + summitId, + context, + selectedView, + onChange, + onSave, + onDelete +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const [views, setViews] = useState([]); + const [search, setSearch] = useState(""); + const [showSaveDialog, setShowSaveDialog] = useState(false); + + const loadViews = (input) => + queryFilterCriterias(summitId, context, input, setViews); + + const handleOpen = (ev) => { + setAnchorEl(ev.currentTarget); + setSearch(""); + loadViews(""); + }; + + const handleClose = () => setAnchorEl(null); + + const handleSearch = (ev) => { + setSearch(ev.target.value); + loadViews(ev.target.value); + }; + + const handleSelect = (view) => { + onChange(view); + handleClose(); + }; + + const handleDelete = async (ev, view) => { + ev.stopPropagation(); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "select_filter_criteria.remove_filter_criteria_warning" + )}"${view.name}"`, + iconType: "warning", + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (!isConfirmed) return; + + Promise.resolve(onDelete(view.id)) + .then(() => + setViews((current) => current.filter((v) => v.id !== view.id)) + ) + .catch(() => {}); + }; + + const handleOpenSaveDialog = () => { + handleClose(); + setShowSaveDialog(true); + }; + + return ( + <> + + + + ev.stopPropagation()} + inputProps={{ "aria-label": T.translate("general.search") }} + /> + + {views.length === 0 && ( + {T.translate("saved_views.no_views")} + )} + {views.map((view) => ( + handleSelect(view)} + > + + handleDelete(ev, view)} + > + + + + ))} + + {selectedView && ( + handleSelect(null)}> + {T.translate("saved_views.clear")} + + )} + + + {T.translate("saved_views.save_current")} + + + {showSaveDialog && ( + setShowSaveDialog(false)} + /> + )} + + ); +}; + +SavedViews.propTypes = { + summitId: PropTypes.number.isRequired, + context: PropTypes.string.isRequired, + selectedView: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + visibility: PropTypes.string + }), + onChange: PropTypes.func.isRequired, + // must return a promise; the save dialog closes once it resolves + onSave: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +SavedViews.defaultProps = { + selectedView: null +}; + +export default SavedViews; diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index e04c04e1b..25dabd1a9 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -127,6 +127,7 @@ ExpandableQuickFilter.defaultProps = { }; const Toolbar = ({ + views, onAdd, addLabel, importItems, @@ -163,6 +164,7 @@ const Toolbar = ({ {selectedCount > 0 ? ` (${selectedCount})` : ""} )} + {views} {onAdd && ( @@ -223,6 +225,7 @@ const Toolbar = ({ ); Toolbar.propTypes = { + views: PropTypes.node, onAdd: PropTypes.func, addLabel: PropTypes.string, importItems: PropTypes.arrayOf( @@ -243,6 +246,7 @@ Toolbar.propTypes = { }; Toolbar.defaultProps = { + views: null, onAdd: null, addLabel: "", importItems: [], @@ -270,6 +274,7 @@ const BulkEditDataGrid = ({ deleteDialogBody, columnVisibilityModel, onColumnVisibilityModelChange, + views, onAdd, addLabel, importItems, @@ -481,6 +486,7 @@ const BulkEditDataGrid = ({ slots={{ toolbar: Toolbar }} slotProps={{ toolbar: { + views, onAdd, addLabel, importItems, @@ -533,6 +539,8 @@ BulkEditDataGrid.propTypes = { deleteDialogBody: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), columnVisibilityModel: PropTypes.objectOf(PropTypes.bool), onColumnVisibilityModelChange: PropTypes.func, + // rendered next to "Edit Selected", e.g. a dropdown + views: PropTypes.node, onAdd: PropTypes.func, // tooltip / aria-label of the add (+) button addLabel: PropTypes.string, @@ -558,6 +566,7 @@ BulkEditDataGrid.propTypes = { }; BulkEditDataGrid.defaultProps = { + views: null, idKey: "id", totalRows: 0, perPage: DEFAULT_PER_PAGE, diff --git a/src/i18n/en.json b/src/i18n/en.json index bbd960a8a..651bd3c8b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4008,6 +4008,13 @@ "no_extra_questions": "No extra questions found.", "remove_warning": "Are you sure you want to remove question " }, + "saved_views": { + "title": "Saved Views", + "name": "Name", + "save_current": "Save current view", + "clear": "Clear view", + "no_views": "No saved views" + }, "save_filter_criteria": { "save_as": "Save As", "visible_to": "Visible to", diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js index 497968229..30203f82d 100644 --- a/src/pages/events/__tests__/summit-event-list-page.test.js +++ b/src/pages/events/__tests__/summit-event-list-page.test.js @@ -23,7 +23,8 @@ jest.mock( () => function BulkEditDataGridMock(props) { mockEditableTableSpy(props); - return null; + // the saved views dropdown is rendered inside the grid toolbar + return props.views; } ); @@ -65,12 +66,12 @@ jest.mock( }) ); -// Stubs the real popup: exposes a button that calls onSave with a fixed -// "save this filter" payload, mirroring how the real dialog invokes onSave. +// Stubs the saved views dropdown: exposes a button that calls onSave with a +// fixed "save this filter" payload, mirroring how its save dialog invokes it. jest.mock( - "../../../components/filters/save-filter-criteria", + "../../../components/filters/saved-views", () => - function SaveFilterCriteriaMock({ onSave }) { + function SavedViewsMock({ onSave }) { return ( )} {importItems.length > 0 && ( dropdown views: PropTypes.node, onAdd: PropTypes.func, - // tooltip / aria-label of the add (+) button + // text of the add button addLabel: PropTypes.string, // options listed by the import icon's menu importItems: PropTypes.arrayOf( From 25297f0bbbf9b1c915077c50a5f60d311c837aed Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 6 Oct 2026 12:27:51 -0500 Subject: [PATCH 21/39] fix: saved views no longer briefly shows incorrect list --- .../saved-views/__tests__/saved-views.test.js | 18 ++++++++++++++++++ src/components/filters/saved-views/index.js | 9 ++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js index 456fbe1d0..00e1fea50 100644 --- a/src/components/filters/saved-views/__tests__/saved-views.test.js +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -65,6 +65,24 @@ describe("SavedViews", () => { expect(onChange).toHaveBeenCalledWith(VIEWS[1]); }); + test("says there are no saved views only once they've loaded empty", async () => { + let resolveViews; + queryFilterCriterias.mockImplementation((summitId, context, input, cb) => { + resolveViews = cb; + }); + renderViews(); + + await openMenu(); + expect( + screen.queryByRole("menuitem", { name: "saved_views.no_views" }) + ).not.toBeInTheDocument(); + + act(() => resolveViews([])); + expect( + screen.getByRole("menuitem", { name: "saved_views.no_views" }) + ).toBeInTheDocument(); + }); + test("searches views by name", async () => { renderViews(); diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index 9d8af2869..c7e70446e 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -150,7 +150,8 @@ const SavedViews = ({ onDelete }) => { const [anchorEl, setAnchorEl] = useState(null); - const [views, setViews] = useState([]); + // null until the views load, so "No saved views" isn't shown while loading + const [views, setViews] = useState(null); const [search, setSearch] = useState(""); const [showSaveDialog, setShowSaveDialog] = useState(false); @@ -160,6 +161,8 @@ const SavedViews = ({ const handleOpen = (ev) => { setAnchorEl(ev.currentTarget); setSearch(""); + // don't show the list from the last time it was opened while reloading + setViews(null); loadViews(""); }; @@ -231,10 +234,10 @@ const SavedViews = ({ inputProps={{ "aria-label": T.translate("general.search") }} /> - {views.length === 0 && ( + {views?.length === 0 && ( {T.translate("saved_views.no_views")} )} - {views.map((view) => ( + {(views ?? []).map((view) => ( Date: Tue, 6 Oct 2026 12:28:07 -0500 Subject: [PATCH 22/39] fix: bring back place holder text fallback --- src/components/mui/bulk-edit-data-grid.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 4ee420c20..31e34e719 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -65,7 +65,7 @@ const renderEditor = (col, editRow, onChange) => { return ( Date: Tue, 6 Oct 2026 12:36:21 -0500 Subject: [PATCH 23/39] fix: test and comment clean up --- .../filters/saved-views/__tests__/saved-views.test.js | 3 --- src/components/mui/__tests__/bulk-edit-data-grid.test.js | 8 +++----- src/components/mui/bulk-edit-data-grid.js | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js index 00e1fea50..8decdb8e6 100644 --- a/src/components/filters/saved-views/__tests__/saved-views.test.js +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -98,9 +98,6 @@ describe("SavedViews", () => { "Open", expect.any(Function) ); - expect( - screen.queryByRole("menuitem", { name: /Recently Modified/ }) - ).not.toBeInTheDocument(); }); test("deletes a view after confirming, without applying it", async () => { diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js index 324f7fca8..c5098d2d8 100644 --- a/src/components/mui/__tests__/bulk-edit-data-grid.test.js +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -192,20 +192,18 @@ describe("BulkEditDataGrid", () => { expect(onSort).toHaveBeenLastCalledWith(-1, null, null); }); - test("reports 1-based pages to onPageChange", async () => { + test("both paginations page the list", async () => { const onPageChange = jest.fn(); renderGrid({ onPageChange }); - // top and bottom paginations both page the list const [topNext, bottomNext] = screen.getAllByRole("button", { name: "mui_table.next_page" }); await userEvent.click(topNext); - expect(onPageChange).toHaveBeenLastCalledWith(2); - await userEvent.click(bottomNext); - expect(onPageChange).toHaveBeenCalledWith(2); + expect(onPageChange).toHaveBeenCalledTimes(2); + expect(onPageChange).toHaveBeenNthCalledWith(2, 2); }); test("shows a remembered order on its column", () => { diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 31e34e719..96016c898 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -54,7 +54,7 @@ const SEARCH_WIDTH = 250; // would otherwise swallow typing inside the bulk-edit inputs const stopGridKeyDown = (ev) => ev.stopPropagation(); -// toolbar buttons show only their icon; the label stays as tooltip/aria-label +// icon-only toolbar buttons; the label stays as tooltip/aria-label const ICON_ONLY_BUTTON_SX = { minWidth: 0, "& .MuiButton-startIcon": { m: 0 } From bb3601cbde475d86f5e37451ef116c13e8635c9a Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 6 Oct 2026 12:48:06 -0500 Subject: [PATCH 24/39] fix: use the preexsting general name --- .../filters/saved-views/__tests__/saved-views.test.js | 4 ++-- src/components/filters/saved-views/index.js | 2 +- src/i18n/en.json | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js index 8decdb8e6..950aff88d 100644 --- a/src/components/filters/saved-views/__tests__/saved-views.test.js +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -185,7 +185,7 @@ describe("SavedViews", () => { screen.getByRole("menuitem", { name: "saved_views.save_current" }) ); - const name = screen.getByRole("textbox", { name: "saved_views.name" }); + const name = screen.getByRole("textbox", { name: "general.name" }); expect(name).toHaveValue("Open Activities"); await userEvent.clear(name); await userEvent.type(name, "Open Keynotes"); @@ -216,7 +216,7 @@ describe("SavedViews", () => { screen.getByRole("menuitem", { name: "saved_views.save_current" }) ); await userEvent.type( - screen.getByRole("textbox", { name: "saved_views.name" }), + screen.getByRole("textbox", { name: "general.name" }), "Mine" ); await userEvent.click( diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index c7e70446e..5f8ab0e7f 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -87,7 +87,7 @@ const SaveViewDialog = ({ selectedView, onSave, onClose }) => { diff --git a/src/i18n/en.json b/src/i18n/en.json index 251ac113e..5c01010f9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4010,7 +4010,6 @@ }, "saved_views": { "title": "Saved Views", - "name": "Name", "save_current": "Save current view", "clear": "Clear view", "no_views": "No saved views" From bdf606fc02bc839e708e8359e702672d619ffdd1 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 6 Oct 2026 13:14:30 -0500 Subject: [PATCH 25/39] fix: all columns are hideable, defaults reset and optional one are remembered --- .../__tests__/summit-event-list-page.test.js | 8 ++++++-- src/pages/events/summit-event-list-page/index.js | 15 ++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js index 50f914922..308d5c699 100644 --- a/src/pages/events/__tests__/summit-event-list-page.test.js +++ b/src/pages/events/__tests__/summit-event-list-page.test.js @@ -467,7 +467,9 @@ describe("SummitEventListPage", () => { mockEditableTableSpy.mock.calls.length - 1 ][0]; - expect(latestGridProps().columnVisibilityModel.selection_status).toBe(true); + expect(latestGridProps().columnVisibilityModel.selection_status).not.toBe( + false + ); act(() => { latestGridProps().onColumnVisibilityModelChange({ @@ -483,6 +485,8 @@ describe("SummitEventListPage", () => { act(() => { latestGridProps().onColumnVisibilityModelChange({}); }); - expect(latestGridProps().columnVisibilityModel.selection_status).toBe(true); + expect(latestGridProps().columnVisibilityModel.selection_status).not.toBe( + false + ); }); }); diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index 2338b7d5b..da42e4866 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -81,9 +81,9 @@ const SummitEventListPage = ({ const [showImportModal, setShowImportModal] = useState(false); const [showImportFromMUXModal, setShowImportFromMUXModal] = useState(false); const [selectedColumns, setSelectedColumns] = useState(extraColumns ?? []); - // selection status is the one fixed column that can be hidden - const [isSelectionStatusVisible, setIsSelectionStatusVisible] = - useState(true); + // the grid's last column visibility; only the optional columns are + // remembered (selectedColumns), the default ones reset each visit + const [gridColumnVisibility, setGridColumnVisibility] = useState({}); const [selectedFilterCriteria, setSelectedFilterCriteria] = useState(null); const { parsedFilter, resetFilters, filterValues, setFilters, joinOperator } = useGridFilter(FILTER_ID); @@ -198,7 +198,7 @@ const SummitEventListPage = ({ ); const columnVisibilityModel = { - selection_status: isSelectionStatusVisible, + ...gridColumnVisibility, ...Object.fromEntries( optionalColumns.map((oc) => [ oc.columnKey, @@ -210,7 +210,7 @@ const SummitEventListPage = ({ // the grid omits columns from the model when they're visible (e.g. after // "Show all"), so only an explicit false hides one const handleColumnVisibilityModelChange = (model) => { - setIsSelectionStatusVisible(model.selection_status !== false); + setGridColumnVisibility(model); setSelectedColumns( optionalColumns .map((oc) => oc.columnKey) @@ -237,14 +237,12 @@ const SummitEventListPage = ({ { columnKey: "id", label: T.translate("general.id"), - sortable: true, - hideable: false + sortable: true }, { columnKey: "type", label: T.translate("event_list.type"), sortable: true, - hideable: false, // eslint-disable-next-line react/no-unstable-nested-components editableField: (extraProps) => ( Date: Fri, 9 Oct 2026 11:54:10 -0500 Subject: [PATCH 26/39] fix: keep grid column widths stable when resizing and truncate cell text --- src/components/mui/bulk-edit-data-grid.js | 51 ++++++++++++++++--- .../__tests__/summit-event-list-page.test.js | 16 ++---- .../events/summit-event-list-page/helpers.js | 7 +++ .../events/summit-event-list-page/index.js | 13 ++--- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 96016c898..0e02c7c40 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -22,6 +22,7 @@ import FileUploadIcon from "@mui/icons-material/FileUpload"; import SearchIcon from "@mui/icons-material/Search"; import { DataGrid, + GRID_STRING_COL_DEF, GridActionsCellItem, GridColumnMenu, GridColumnMenuHideItem, @@ -30,6 +31,7 @@ import { GridToolbarContainer, gridPreferencePanelStateSelector, useGridApiContext, + useGridApiRef, useGridSelector } from "@mui/x-data-grid"; import CustomTablePagination from "openstack-uicore-foundation/lib/components/mui/table/custom-table-pagination"; @@ -46,7 +48,6 @@ import { // MUI DataGrid version of uicore's BulkEditTable: // same select -> "Edit Selected" -> Apply flow -const COLUMN_MIN_WIDTH = 150; const EDIT_COLUMN_MIN_WIDTH = 250; const SEARCH_WIDTH = 250; @@ -371,6 +372,25 @@ const BulkEditDataGrid = ({ // the columns panel anchors to the column headers by default; anchor it to // its toolbar button instead const [columnsButtonEl, setColumnsButtonEl] = useState(null); + const apiRef = useGridApiRef(); + // resized widths; the grid drops them whenever it gets new columns + const [columnWidths, setColumnWidths] = useState({}); + + // flex until a resize starts, then freeze widths so the others don't stretch + useEffect( + () => + apiRef.current.subscribeEvent("columnResizeStart", () => + setColumnWidths((current) => ({ + ...Object.fromEntries( + apiRef.current + .getVisibleColumns() + .map((col) => [col.field, col.computedWidth]) + ), + ...current + })) + ), + [] + ); const reset = () => { setSelectedIds([]); @@ -428,16 +448,21 @@ const BulkEditDataGrid = ({ const gridColumns = columns.map((col) => { const customMinWidth = parseFloat(col.customStyle?.minWidth); const minWidth = Math.max( - Number.isNaN(customMinWidth) ? COLUMN_MIN_WIDTH : customMinWidth, + Number.isNaN(customMinWidth) + ? GRID_STRING_COL_DEF.minWidth + : customMinWidth, editEnabled && col.editableField ? EDIT_COLUMN_MIN_WIDTH : 0 ); + // a saved or page-given width is fixed; otherwise flex by the page's weight + const width = columnWidths[col.columnKey] ?? col.width; return { field: col.columnKey, headerName: col.header ?? col.label ?? col.value, sortable: !!col.sortable, hideable: col.hideable ?? true, - flex: 1, + flex: width ? 0 : col.flex ?? 1, + width, minWidth, renderCell: ({ row }) => { const rowId = row[idKey]; @@ -538,10 +563,12 @@ const BulkEditDataGrid = ({ headers in view */} row[idKey]} - getRowHeight={() => "auto"} + // rows truncate at MUI's default height; only rows being edited grow + getRowHeight={({ id }) => (editRows[id] ? "auto" : null)} checkboxSelection disableRowSelectionOnClick rowSelectionModel={selectedIds} @@ -549,6 +576,12 @@ const BulkEditDataGrid = ({ isRowSelectable={() => !editEnabled} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={onColumnVisibilityModelChange} + onColumnWidthChange={({ colDef, width }) => + setColumnWidths((current) => ({ + ...current, + [colDef.field]: width + })) + } disableColumnFilter sortingMode="server" sortModel={sortModel} @@ -592,7 +625,9 @@ const BulkEditDataGrid = ({ setColumnsButtonEl } }} - sx={{ "& .MuiDataGrid-cell": { py: 1 } }} + sx={{ + "& .MuiDataGrid-row--dynamicHeight > .MuiDataGrid-cell": { py: 1 } + }} /> { ]); }); - test("keeps Selection Status hidden once hidden, and shows it again with Show all", async () => { + test("keeps Activity Type hidden once hidden, and shows it again with Show all", async () => { renderWithRedux(, { initialState: { currentSummitState: { @@ -467,26 +467,20 @@ describe("SummitEventListPage", () => { mockEditableTableSpy.mock.calls.length - 1 ][0]; - expect(latestGridProps().columnVisibilityModel.selection_status).not.toBe( - false - ); + expect(latestGridProps().columnVisibilityModel.type).not.toBe(false); act(() => { latestGridProps().onColumnVisibilityModelChange({ ...latestGridProps().columnVisibilityModel, - selection_status: false + type: false }); }); - expect(latestGridProps().columnVisibilityModel.selection_status).toBe( - false - ); + expect(latestGridProps().columnVisibilityModel.type).toBe(false); // "Show all" reports visible columns by leaving them out of the model act(() => { latestGridProps().onColumnVisibilityModelChange({}); }); - expect(latestGridProps().columnVisibilityModel.selection_status).not.toBe( - false - ); + expect(latestGridProps().columnVisibilityModel.type).not.toBe(false); }); }); diff --git a/src/pages/events/summit-event-list-page/helpers.js b/src/pages/events/summit-event-list-page/helpers.js index 138a7816a..05b430ed1 100644 --- a/src/pages/events/summit-event-list-page/helpers.js +++ b/src/pages/events/summit-event-list-page/helpers.js @@ -385,6 +385,13 @@ export const getOptionalColumns = ( label: T.translate("event_list.streaming_type"), render: (type) => type || "N/A" }, + { + columnKey: "selection_status", + label: T.translate("event_list.selection_status"), + sortable: true, + render: (status, row) => + status === "unaccepted" && row.is_published === true ? "accepted" : status + }, { columnKey: "review_status", sortable: true, diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index da42e4866..a6bdb540e 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -237,7 +237,8 @@ const SummitEventListPage = ({ { columnKey: "id", label: T.translate("general.id"), - sortable: true + sortable: true, + width: 90 }, { columnKey: "type", @@ -269,17 +270,9 @@ const SummitEventListPage = ({ columnKey: "title", label: T.translate("event_list.title"), sortable: true, + flex: 2, editableField: true, placeholder: T.translate("bulk_actions_page.placeholders.event_title") - }, - { - columnKey: "selection_status", - label: T.translate("event_list.selection_status"), - sortable: true, - render: (status, row) => - status === "unaccepted" && row.is_published === true - ? "accepted" - : status } ]; From 4c0ab320609f939d2437369037fe416e28c441e5 Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 19 Oct 2026 13:25:05 -0500 Subject: [PATCH 27/39] fix: make added columns persist when navigating away or refresh --- src/actions/event-actions.js | 5 ++ .../__tests__/summit-event-list-page.test.js | 52 +++++++++++++++++++ .../events/summit-event-list-page/index.js | 17 +++--- src/reducers/events/event-list-reducer.js | 7 ++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index e0ea6c8c8..321296cb3 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -81,6 +81,7 @@ export const FLAG_CHANGED = "FLAG_CHANGED"; export const REQUEST_EVENT_COMMENTS = "REQUEST_EVENT_COMMENTS"; export const RECEIVE_EVENT_COMMENTS = "RECEIVE_EVENT_COMMENTS"; export const CHANGE_SEARCH_TERM = "CHANGE_SEARCH_TERM"; +export const CHANGE_EXTRA_COLUMNS = "CHANGE_EXTRA_COLUMNS"; export const SUBMISSION_PERIOD_REOPENED = "SUBMISSION_PERIOD_REOPENED"; export const SUBMISSION_PERIOD_CLOSED = "SUBMISSION_PERIOD_CLOSED"; export const SUBMISSION_REOPEN_NOTIFIED = "SUBMISSION_REOPEN_NOTIFIED"; @@ -1529,3 +1530,7 @@ export const queryAllCompanies = debounce(async (input, callback) => { export const changeEventListSearchTerm = (term) => (dispatch) => { dispatch(createAction(CHANGE_SEARCH_TERM)({ term })); }; + +export const changeEventListExtraColumns = (extraColumns) => (dispatch) => { + dispatch(createAction(CHANGE_EXTRA_COLUMNS)({ extraColumns })); +}; diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js index 66754c014..0468b1a04 100644 --- a/src/pages/events/__tests__/summit-event-list-page.test.js +++ b/src/pages/events/__tests__/summit-event-list-page.test.js @@ -6,6 +6,7 @@ import flushPromises from "flush-promises"; import SummitEventListPage from "../summit-event-list-page"; import { renderWithRedux } from "../../../utils/test-utils"; import { unpackJoinOperatorFromCriteria } from "../summit-event-list-page/helpers"; +import { CHANGE_EXTRA_COLUMNS } from "../../../actions/event-actions"; import { saveFilterCriteria, deleteFilterCriteria @@ -483,4 +484,55 @@ describe("SummitEventListPage", () => { }); expect(latestGridProps().columnVisibilityModel.type).not.toBe(false); }); + + test("saves the chosen optional columns to the store so they survive leaving the page", () => { + const { store } = renderWithRedux(, { + initialState: { + currentSummitState: { + currentSummit: { + id: 12, + time_zone: { name: "UTC" }, + time_zone_id: "UTC", + selection_plans: [], + tracks: [], + event_types: [], + locations: [], + presentation_action_types: [] + } + }, + mediaUploadListState: { + media_uploads: [] + }, + currentEventListState: { + events: [], + lastPage: 1, + currentPage: 1, + order: "id", + orderDir: 1, + totalEvents: 0, + term: "", + filters: {}, + extraColumns: [], + perPage: 10 + } + } + }); + + const latestGridProps = + mockEditableTableSpy.mock.calls[ + mockEditableTableSpy.mock.calls.length - 1 + ][0]; + + act(() => { + latestGridProps.onColumnVisibilityModelChange({ + selection_status: false + }); + }); + + const { extraColumns } = store + .getActions() + .find((a) => a.type === CHANGE_EXTRA_COLUMNS).payload; + expect(extraColumns).toContain("track"); + expect(extraColumns).not.toContain("selection_status"); + }); }); diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index a6bdb540e..34c4d3001 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -22,6 +22,7 @@ import { import BulkEditDataGrid from "../../../components/mui/bulk-edit-data-grid"; import { bulkUpdateEvents, + changeEventListExtraColumns, deleteEvent, exportEvents, getEvents, @@ -76,7 +77,8 @@ const SummitEventListPage = ({ importMP4AssetsFromMUX, saveFilterCriteria, deleteFilterCriteria, - bulkUpdateEvents + bulkUpdateEvents, + changeEventListExtraColumns }) => { const [showImportModal, setShowImportModal] = useState(false); const [showImportFromMUXModal, setShowImportFromMUXModal] = useState(false); @@ -210,12 +212,12 @@ const SummitEventListPage = ({ // the grid omits columns from the model when they're visible (e.g. after // "Show all"), so only an explicit false hides one const handleColumnVisibilityModelChange = (model) => { + const newColumns = optionalColumns + .map((oc) => oc.columnKey) + .filter((columnKey) => model[columnKey] !== false); setGridColumnVisibility(model); - setSelectedColumns( - optionalColumns - .map((oc) => oc.columnKey) - .filter((columnKey) => model[columnKey] !== false) - ); + setSelectedColumns(newColumns); + changeEventListExtraColumns(newColumns); }; const handleFilterCriteriaChange = (filterCriteria) => { @@ -387,5 +389,6 @@ export default connect(mapStateToProps, { saveFilterCriteria, deleteFilterCriteria, bulkUpdateEvents, - getMediaUploads + getMediaUploads, + changeEventListExtraColumns })(SummitEventListPage); diff --git a/src/reducers/events/event-list-reducer.js b/src/reducers/events/event-list-reducer.js index 626813abc..fb2bdee61 100644 --- a/src/reducers/events/event-list-reducer.js +++ b/src/reducers/events/event-list-reducer.js @@ -19,7 +19,8 @@ import { RECEIVE_EVENTS, REQUEST_EVENTS, EVENT_DELETED, - CHANGE_SEARCH_TERM + CHANGE_SEARCH_TERM, + CHANGE_EXTRA_COLUMNS } from "../../actions/event-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; @@ -90,6 +91,10 @@ const eventListReducer = (state = DEFAULT_STATE, action) => { const { term } = payload; return { ...state, term }; } + case CHANGE_EXTRA_COLUMNS: { + const { extraColumns } = payload; + return { ...state, extraColumns }; + } default: return state; } From 929fd4e908ecc3d677eb788a1bf08f98009df9ba Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 19 Oct 2026 18:05:54 -0500 Subject: [PATCH 28/39] fix: speaker and selection plan persist during an activity type switch on bulk edit --- src/pages/events/summit-event-list-page/helpers.js | 9 +++++++++ src/pages/events/summit-event-list-page/index.js | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/pages/events/summit-event-list-page/helpers.js b/src/pages/events/summit-event-list-page/helpers.js index 05b430ed1..5e96eefbb 100644 --- a/src/pages/events/summit-event-list-page/helpers.js +++ b/src/pages/events/summit-event-list-page/helpers.js @@ -241,6 +241,15 @@ export const getOptionalColumns = ( // eslint-disable-next-line react/jsx-props-no-spreading {...extraProps} value={extraProps.row.track?.id} + onChange={(ev) => + extraProps.onChange({ + target: { + value: + allTracks.find((track) => track.id === ev.target.value) ?? + null + } + }) + } /> ); } diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index 34c4d3001..b6d7be4c1 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -264,6 +264,16 @@ const SummitEventListPage = ({ }} // eslint-disable-next-line react/jsx-props-no-spreading {...extraProps} + onChange={(ev) => + extraProps.onChange({ + target: { + value: + currentSummit.event_types.find( + (type) => type.id === ev.target.value + ) ?? null + } + }) + } /> ), render: (e) => e.name From a864eee092e0b64352cbd006c1e4ec27ae167efa Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 19 Oct 2026 18:07:56 -0500 Subject: [PATCH 29/39] fix: allow escape and tab keystrokes on saved views. replace deprecated inputProps --- src/components/filters/saved-views/index.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index 5f8ab0e7f..e72986b57 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -229,9 +229,13 @@ const SavedViews = ({ value={search} onChange={handleSearch} placeholder={T.translate("general.placeholders.search")} - // the menu otherwise treats typing as item navigation - onKeyDown={(ev) => ev.stopPropagation()} - inputProps={{ "aria-label": T.translate("general.search") }} + // stop menu typeahead, but let Escape/Tab through to close it + onKeyDown={(ev) => + !(ev.key === "Escape" || ev.key === "Tab") && ev.stopPropagation() + } + slotProps={{ + htmlInput: { "aria-label": T.translate("general.search") } + }} /> {views?.length === 0 && ( From c66da5f5f517238b47fc6e8340a3abb654780e1c Mon Sep 17 00:00:00 2001 From: anna Date: Mon, 19 Oct 2026 18:16:24 -0500 Subject: [PATCH 30/39] fix: search box collapses when it is empty and no search term applied --- .../mui/__tests__/bulk-edit-data-grid.test.js | 13 ++++++++++++- src/components/mui/bulk-edit-data-grid.js | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js index c5098d2d8..c89aa0625 100644 --- a/src/components/mui/__tests__/bulk-edit-data-grid.test.js +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -239,7 +239,7 @@ describe("BulkEditDataGrid", () => { expect(onSearch).toHaveBeenCalledWith("keynote"); }); - test("shows the search box open when a search term is applied, and collapses it when left empty", async () => { + test("keeps the search box open while a search term is applied, even when cleared", async () => { renderGrid({ searchProps: { term: "keynote", onSearch: jest.fn() } }); const searchbox = screen.getByRole("textbox"); @@ -248,6 +248,17 @@ describe("BulkEditDataGrid", () => { await userEvent.clear(searchbox); await userEvent.tab(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + test("collapses the search box when left empty with no term applied", async () => { + renderGrid({ searchProps: { term: "", onSearch: jest.fn() } }); + + await userEvent.click( + screen.getByRole("button", { name: "general.search" }) + ); + await userEvent.tab(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "general.search" }) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 0e02c7c40..e07ce0019 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -127,10 +127,11 @@ const ExpandableQuickFilter = ({ placeholder, term, onSearch }) => { ref={searchRef} sx={{ width: SEARCH_WIDTH }} // SearchInput takes no onBlur either; collapse once focus leaves both - // its input and its clear button with nothing typed + // its input and its clear button with nothing typed or applied onBlur={(ev) => { if (ev.currentTarget.contains(ev.relatedTarget)) return; - if (!ev.currentTarget.querySelector("input").value) setExpanded(false); + if (!ev.currentTarget.querySelector("input").value && !term) + setExpanded(false); }} > From e6ce9e77cf59a4c6456438195cbf8481151ce85e Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 17:58:25 -0500 Subject: [PATCH 31/39] fix: add custom search placeholder and increase size of search bar to fit text --- src/components/mui/bulk-edit-data-grid.js | 2 +- src/i18n/en.json | 1 + src/pages/events/summit-event-list-page/index.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index e07ce0019..9fb866384 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -49,7 +49,7 @@ import { // same select -> "Edit Selected" -> Apply flow const EDIT_COLUMN_MIN_WIDTH = 250; -const SEARCH_WIDTH = 250; +const SEARCH_WIDTH = 330; // the grid uses arrows/space for cell navigation and row selection, which // would otherwise swallow typing inside the bulk-edit inputs diff --git a/src/i18n/en.json b/src/i18n/en.json index 5c01010f9..01f1717ea 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1296,6 +1296,7 @@ "media_uploads": "Media Uploads", "edit_selected": "Edit Selected", "placeholders": { + "search_events": "Search by Id, Title, Abstract, Speaker Title", "event_type": "Filter by Activity Type", "select_fields": "Select data to display" } diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index b6d7be4c1..0f87d30a2 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -345,7 +345,7 @@ const SummitEventListPage = ({ onExport={handleExport} searchProps={{ term, - placeholder: T.translate("general.placeholders.search"), + placeholder: T.translate("event_list.placeholders.search_events"), onSearch: handleSearch }} noRowsLabel={T.translate("event_list.no_events")} From d5338191c0cf1c2c12bf3c9bb904ffa90617908d Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 18:04:52 -0500 Subject: [PATCH 32/39] fix: sort columns options by alphabetical order in column panel --- src/components/mui/bulk-edit-data-grid.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/mui/bulk-edit-data-grid.js b/src/components/mui/bulk-edit-data-grid.js index 9fb866384..38eca097a 100644 --- a/src/components/mui/bulk-edit-data-grid.js +++ b/src/components/mui/bulk-edit-data-grid.js @@ -607,7 +607,8 @@ const BulkEditDataGrid = ({ .map((col) => col.field), searchInputProps: { sx: { "& .MuiInputBase-input": { boxSizing: "content-box" } } - } + }, + sort: "asc" }, toolbar: { views, From e88cc0ccee12a74e08ccc14e910c540cab74f6a5 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 18:05:21 -0500 Subject: [PATCH 33/39] fix: stop clearing list on a saved views dropdown open --- src/components/filters/saved-views/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index e72986b57..0e56cb582 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -161,8 +161,6 @@ const SavedViews = ({ const handleOpen = (ev) => { setAnchorEl(ev.currentTarget); setSearch(""); - // don't show the list from the last time it was opened while reloading - setViews(null); loadViews(""); }; From d1a4472495417bc4279da6373270b6021af9bb43 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 19:00:42 -0500 Subject: [PATCH 34/39] fix: remove update view functionality since it did not align with onSave api --- src/actions/filter-criteria-actions.js | 5 +- .../saved-views/__tests__/saved-views.test.js | 6 +- src/components/filters/saved-views/index.js | 6 +- .../__tests__/summit-event-list-page.test.js | 83 +++++++++++++++---- .../events/summit-event-list-page/index.js | 15 +--- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/actions/filter-criteria-actions.js b/src/actions/filter-criteria-actions.js index d66cf1297..627d27168 100644 --- a/src/actions/filter-criteria-actions.js +++ b/src/actions/filter-criteria-actions.js @@ -25,7 +25,7 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import URI from "urijs"; import Swal from "sweetalert2"; -import debounce from "lodash/debounce" +import debounce from "lodash/debounce"; import { getAccessTokenSafely } from "../utils/methods"; import { @@ -87,8 +87,9 @@ export const saveFilterCriteria = (filterCriteria) => async (dispatch) => { `${window.PERSIST_FILTER_CRITERIA_API}/api/v1/filter-criterias`, filterCriteria, customErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).then((payload) => { dispatch(stopLoading()); + return payload; }); }; diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js index 950aff88d..a7b1b0d40 100644 --- a/src/components/filters/saved-views/__tests__/saved-views.test.js +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -176,7 +176,7 @@ describe("SavedViews", () => { expect(onSave).not.toHaveBeenCalled(); }); - test("saves over the active view and closes once the save resolves", async () => { + test("starts with an empty name and closes once the save resolves", async () => { const onSave = jest.fn(() => Promise.resolve()); renderViews({ onSave, selectedView: VIEWS[0] }); @@ -186,13 +186,11 @@ describe("SavedViews", () => { ); const name = screen.getByRole("textbox", { name: "general.name" }); - expect(name).toHaveValue("Open Activities"); - await userEvent.clear(name); + expect(name).toHaveValue(""); await userEvent.type(name, "Open Keynotes"); await userEvent.click(screen.getByRole("button", { name: "general.save" })); expect(onSave).toHaveBeenCalledWith({ - id: 1, name: "Open Keynotes", visibility: "Everyone" }); diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index 0e56cb582..2673f05a3 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -46,17 +46,15 @@ import { const SaveViewDialog = ({ selectedView, onSave, onClose }) => { const formik = useFormik({ initialValues: { - name: selectedView?.name ?? "", + name: "", visibility: selectedView?.visibility ?? "" }, validationSchema: yup.object({ name: requiredStringValidation().trim(), visibility: requiredStringValidation() }), - // keeps the id of the active view so saving updates it instead of - // creating a new one onSubmit: (values) => - onSave({ id: selectedView?.id, ...values }) + onSave(values) .then(() => onClose()) // the action's error handler reports the failure; keep the dialog // open so the user can retry diff --git a/src/pages/events/__tests__/summit-event-list-page.test.js b/src/pages/events/__tests__/summit-event-list-page.test.js index 0468b1a04..286a68be0 100644 --- a/src/pages/events/__tests__/summit-event-list-page.test.js +++ b/src/pages/events/__tests__/summit-event-list-page.test.js @@ -72,20 +72,22 @@ jest.mock( jest.mock( "../../../components/filters/saved-views", () => - function SavedViewsMock({ onSave }) { + function SavedViewsMock({ selectedView, onSave }) { return ( - + <> + {selectedView?.name} + + ); } ); @@ -117,7 +119,9 @@ describe("SummitEventListPage", () => { mockGridFilterState.parsedFilter = []; mockGridFilterState.filterValues = []; mockGridFilterState.joinOperator = undefined; - saveFilterCriteria.mockReturnValue(() => Promise.resolve()); + saveFilterCriteria.mockReturnValue(() => + Promise.resolve({ response: { id: 1, name: "view", criteria: [] } }) + ); deleteFilterCriteria.mockReturnValue(() => Promise.resolve()); }); @@ -430,6 +434,57 @@ describe("SummitEventListPage", () => { ]); }); + test("switches to the newly saved view", async () => { + saveFilterCriteria.mockReturnValue(() => + Promise.resolve({ + response: { id: 7, name: "My saved filter", criteria: [] } + }) + ); + + renderWithRedux(, { + initialState: { + currentSummitState: { + currentSummit: { + id: 12, + time_zone: { name: "UTC" }, + time_zone_id: "UTC", + selection_plans: [], + tracks: [], + event_types: [], + locations: [], + presentation_action_types: [] + } + }, + mediaUploadListState: { + media_uploads: [] + }, + currentEventListState: { + events: [], + lastPage: 1, + currentPage: 1, + order: "id", + orderDir: 1, + totalEvents: 0, + term: "", + filters: {}, + extraColumns: [], + perPage: 10 + } + } + }); + + expect(screen.queryByText("My saved filter")).not.toBeInTheDocument(); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "save-filter-criteria" }) + ); + await flushPromises(); + }); + + expect(screen.getByText("My saved filter")).toBeInTheDocument(); + }); + test("keeps Activity Type hidden once hidden, and shows it again with Show all", async () => { renderWithRedux(, { initialState: { diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index 0f87d30a2..330148142 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -168,9 +168,8 @@ const SummitEventListPage = ({ history.push(`/app/summits/${currentSummit.id}/events/new`); }; - const handleFilterCriteriaSave = ({ name, id, visibility }) => { + const handleFilterCriteriaSave = ({ name, visibility }) => { const filterToSave = { - id, show_id: currentSummit.id, name, enabled_filters: filterValues.map((f) => f.criteria), @@ -181,15 +180,9 @@ const SummitEventListPage = ({ visibility }; - return saveFilterCriteria(filterToSave).then(() => { - // keep the active view in sync with what was just saved over it - if (id) { - setSelectedFilterCriteria((current) => ({ - ...current, - ...filterToSave - })); - } - }); + return saveFilterCriteria(filterToSave).then(({ response }) => + setSelectedFilterCriteria(response) + ); }; const optionalColumns = getOptionalColumns( From 43d69554f7a415affb8d68141b57ccb499c6ffd2 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 19:05:33 -0500 Subject: [PATCH 35/39] fix: remove unneccesary comment and make saved views pop up close on a fail to make space for error --- .../filters/saved-views/__tests__/saved-views.test.js | 6 +++--- src/components/filters/saved-views/index.js | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js index a7b1b0d40..bde57eee3 100644 --- a/src/components/filters/saved-views/__tests__/saved-views.test.js +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -199,7 +199,7 @@ describe("SavedViews", () => { ); }); - test("keeps the dialog open when the save fails", async () => { + test("closes the dialog when the save fails so the error isn't covered", async () => { let rejectSave; const onSave = jest.fn( () => @@ -227,11 +227,11 @@ describe("SavedViews", () => { screen.getByRole("button", { name: "general.save" }) ).toBeDisabled() ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); await act(async () => rejectSave(new Error("nope"))); - expect(screen.getByRole("dialog")).toBeInTheDocument(); await waitFor(() => - expect(screen.getByRole("button", { name: "general.save" })).toBeEnabled() + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() ); }); }); diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index 2673f05a3..8cf80ed7c 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -55,9 +55,7 @@ const SaveViewDialog = ({ selectedView, onSave, onClose }) => { }), onSubmit: (values) => onSave(values) - .then(() => onClose()) - // the action's error handler reports the failure; keep the dialog - // open so the user can retry + .finally(() => onClose()) .catch(() => {}) }); From e95639021610d6268c4241099007723fe8a3f21d Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 19:10:23 -0500 Subject: [PATCH 36/39] fix: use core ui dialog component for saved view pop up --- src/components/filters/saved-views/index.js | 78 +++++++++------------ 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index 8cf80ed7c..d5282e065 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -19,10 +19,6 @@ import * as yup from "yup"; import { Box, Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, Divider, IconButton, ListItemText, @@ -35,6 +31,7 @@ import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import DeleteIcon from "@mui/icons-material/Delete"; import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; import MuiFormikRadioGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group"; +import CustomDialog from "openstack-uicore-foundation/lib/components/mui/custom-dialog"; import showConfirmDialog from "../../mui/showConfirmDialog"; import { queryFilterCriterias } from "../../../actions/filter-criteria-actions"; import { requiredStringValidation } from "../../../utils/yup"; @@ -65,59 +62,52 @@ const SaveViewDialog = ({ selectedView, onSave, onClose }) => { }; return ( - - {T.translate("saved_views.save_current")} + {/* keeps Enter in the name field submitting */} - - - - - - - - + + - + ); }; From 2a536badbb58d8b641620b700b3c77eebacea836 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 19:21:22 -0500 Subject: [PATCH 37/39] fix: move save view dialog into its own file --- .../components/save-view-dialog/index.js | 114 ++++++++++++++++++ src/components/filters/saved-views/index.js | 96 +-------------- 2 files changed, 115 insertions(+), 95 deletions(-) create mode 100644 src/components/filters/saved-views/components/save-view-dialog/index.js diff --git a/src/components/filters/saved-views/components/save-view-dialog/index.js b/src/components/filters/saved-views/components/save-view-dialog/index.js new file mode 100644 index 000000000..8de22167f --- /dev/null +++ b/src/components/filters/saved-views/components/save-view-dialog/index.js @@ -0,0 +1,114 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import { Box } from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import MuiFormikRadioGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group"; +import CustomDialog from "openstack-uicore-foundation/lib/components/mui/custom-dialog"; +import { requiredStringValidation } from "../../../../../utils/yup"; +import { + VISIBILITY_OPTION_EVERYONE, + VISIBILITY_OPTION_ME +} from "../../../../../utils/filter-criteria-constants"; + +const SaveViewDialog = ({ selectedView, onSave, onClose }) => { + const formik = useFormik({ + initialValues: { + name: "", + visibility: selectedView?.visibility ?? "" + }, + validationSchema: yup.object({ + name: requiredStringValidation().trim(), + visibility: requiredStringValidation() + }), + onSubmit: (values) => + onSave(values) + .finally(() => onClose()) + .catch(() => {}) + }); + + const handleClose = () => { + if (formik.isSubmitting) return; + onClose(); + }; + + return ( + + + {/* keeps Enter in the name field submitting */} + + + + + + + ); +}; + +SaveViewDialog.propTypes = { + selectedView: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + visibility: PropTypes.string + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +SaveViewDialog.defaultProps = { + selectedView: null +}; + +export default SaveViewDialog; diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js index d5282e065..c0b0f3df9 100644 --- a/src/components/filters/saved-views/index.js +++ b/src/components/filters/saved-views/index.js @@ -14,8 +14,6 @@ import React, { useState } from "react"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; -import { FormikProvider, useFormik } from "formik"; -import * as yup from "yup"; import { Box, Button, @@ -29,101 +27,9 @@ import { import AddIcon from "@mui/icons-material/Add"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import DeleteIcon from "@mui/icons-material/Delete"; -import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; -import MuiFormikRadioGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group"; -import CustomDialog from "openstack-uicore-foundation/lib/components/mui/custom-dialog"; import showConfirmDialog from "../../mui/showConfirmDialog"; import { queryFilterCriterias } from "../../../actions/filter-criteria-actions"; -import { requiredStringValidation } from "../../../utils/yup"; -import { - VISIBILITY_OPTION_EVERYONE, - VISIBILITY_OPTION_ME -} from "../../../utils/filter-criteria-constants"; - -const SaveViewDialog = ({ selectedView, onSave, onClose }) => { - const formik = useFormik({ - initialValues: { - name: "", - visibility: selectedView?.visibility ?? "" - }, - validationSchema: yup.object({ - name: requiredStringValidation().trim(), - visibility: requiredStringValidation() - }), - onSubmit: (values) => - onSave(values) - .finally(() => onClose()) - .catch(() => {}) - }); - - const handleClose = () => { - if (formik.isSubmitting) return; - onClose(); - }; - - return ( - - - {/* keeps Enter in the name field submitting */} - - - - - - - ); -}; - -SaveViewDialog.propTypes = { - selectedView: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - visibility: PropTypes.string - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired -}; - -SaveViewDialog.defaultProps = { - selectedView: null -}; +import SaveViewDialog from "./components/save-view-dialog"; // Saved filter criteria as a toolbar dropdown: pick a view to apply it, // delete one, clear the active one, or save the current filters as a view. From d039761ecdee35e3df4bf8d5dfe54d50b1fb45f8 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 21 Oct 2026 19:25:53 -0500 Subject: [PATCH 38/39] fix: move tool bar buttons into their own files --- .../components/column-menu/index.js | 39 +++ .../components/columns-button/index.js | 64 ++++ .../expandable-quick-filter/index.js | 84 ++++++ .../components/toolbar/index.js | 154 ++++++++++ .../mui/bulk-edit-data-grid/constants.js | 18 ++ .../index.js} | 280 +----------------- 6 files changed, 366 insertions(+), 273 deletions(-) create mode 100644 src/components/mui/bulk-edit-data-grid/components/column-menu/index.js create mode 100644 src/components/mui/bulk-edit-data-grid/components/columns-button/index.js create mode 100644 src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js create mode 100644 src/components/mui/bulk-edit-data-grid/components/toolbar/index.js create mode 100644 src/components/mui/bulk-edit-data-grid/constants.js rename src/components/mui/{bulk-edit-data-grid.js => bulk-edit-data-grid/index.js} (63%) diff --git a/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js b/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js new file mode 100644 index 000000000..f3be1ecbc --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js @@ -0,0 +1,39 @@ +/** + * 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 PropTypes from "prop-types"; +import { GridColumnMenu, GridColumnMenuHideItem } from "@mui/x-data-grid"; + +// the column menu's columns item is "Hide column" + "Manage columns"; keep +// only hide (the toolbar has the columns panel), and drop the item entirely +// for columns that can't be hidden so their menu has no empty section +const ColumnMenu = (props) => { + const { colDef } = props; + return ( + + ); +}; + +ColumnMenu.propTypes = { + colDef: PropTypes.shape({ hideable: PropTypes.bool }).isRequired +}; + +export default ColumnMenu; diff --git a/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js b/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js new file mode 100644 index 000000000..2a62e039e --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js @@ -0,0 +1,64 @@ +/** + * 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, { useRef } from "react"; +import PropTypes from "prop-types"; +import { + GridPreferencePanelsValue, + GridToolbarColumnsButton, + gridPreferencePanelStateSelector, + useGridApiContext, + useGridSelector +} from "@mui/x-data-grid"; +import { ICON_ONLY_BUTTON_SX } from "../../constants"; + +// clicking the button while its panel is open should close it, but React 16 +// runs the button's handlers on document, next to the panel's click-away +// listener, so the button can't stop it: the panel closes on pointerup and the +// button's click reopens it. Remember whether it was open when the press +// started and close it again after the click. +const ColumnsButton = ({ buttonRef }) => { + const apiRef = useGridApiContext(); + const preferencePanel = useGridSelector( + apiRef, + gridPreferencePanelStateSelector + ); + const wasOpenRef = useRef(false); + + return ( + { + wasOpenRef.current = + preferencePanel.open && + preferencePanel.openedPanelValue === + GridPreferencePanelsValue.columns; + }, + onClick: () => { + if (wasOpenRef.current) apiRef.current.hidePreferences(); + wasOpenRef.current = false; + } + } + }} + /> + ); +}; + +ColumnsButton.propTypes = { + buttonRef: PropTypes.func.isRequired +}; + +export default ColumnsButton; diff --git a/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js b/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js new file mode 100644 index 000000000..beb3ce13d --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js @@ -0,0 +1,84 @@ +/** + * 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, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, Tooltip } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import { ICON_ONLY_BUTTON_SX } from "../../constants"; + +const SEARCH_WIDTH = 330; + +// search icon that expands into uicore's SearchInput +const ExpandableQuickFilter = ({ placeholder, term, onSearch }) => { + const [expanded, setExpanded] = useState(!!term); + const searchRef = useRef(null); + const focusOnOpenRef = useRef(false); + + // SearchInput takes no autoFocus, so focus its input once opened from the + // icon (but not when it starts open with a term) + useEffect(() => { + if (expanded && focusOnOpenRef.current) { + searchRef.current.querySelector("input").focus(); + focusOnOpenRef.current = false; + } + }, [expanded]); + + if (!expanded) { + return ( + + + + + ) : ( + + )} + {views} + + {onAdd && ( + + )} + {importItems.length > 0 && ( + + + + } + sx={ICON_ONLY_BUTTON_SX} + /> + )} + {(onAdd || importItems.length > 0) && ( + + )} + + {filter} + {onExport && ( + <> + + + - - - ) : ( - - )} - {views} - - {onAdd && ( - - )} - {importItems.length > 0 && ( - - - - } - sx={ICON_ONLY_BUTTON_SX} - /> - )} - {(onAdd || importItems.length > 0) && ( - - )} - - {filter} - {onExport && ( - <> - - -