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 c89aa0625..a488e7317 100644
--- a/src/components/mui/__tests__/bulk-edit-data-grid.test.js
+++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js
@@ -12,6 +12,21 @@ jest.mock("i18n-react/dist/i18n-react", () => ({
jest.mock("../showConfirmDialog", () => jest.fn());
+// tests seed the applied filters before rendering
+const mockGridFilterState = {
+ valuesWithIds: [],
+ setFilters: jest.fn(),
+ joinOperator: undefined
+};
+
+jest.mock("openstack-uicore-foundation/lib/components/mui/grid-filter", () => ({
+ ...jest.requireActual(
+ "openstack-uicore-foundation/lib/components/mui/grid-filter"
+ ),
+ GridFilter: () => null,
+ useGridFilter: () => mockGridFilterState
+}));
+
const columns = [
{ columnKey: "id", label: "Id", sortable: true },
{ columnKey: "title", label: "Title", sortable: true, editableField: true }
@@ -228,10 +243,7 @@ describe("BulkEditDataGrid", () => {
const onSearch = jest.fn();
renderGrid({ searchProps: { term: "", onSearch } });
- await userEvent.click(
- screen.getByRole("button", { name: "general.search" })
- );
- // opening the search focuses its input
+ await userEvent.click(screen.getByRole("textbox"));
await userEvent.keyboard("keynote");
expect(onSearch).not.toHaveBeenCalled();
@@ -239,30 +251,36 @@ describe("BulkEditDataGrid", () => {
expect(onSearch).toHaveBeenCalledWith("keynote");
});
- 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");
- expect(searchbox).toHaveValue("keynote");
-
- 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" })
- ).toBeInTheDocument();
+ test("removing a filter chip keeps the other filters and the join operator", async () => {
+ const roomA = { criteria: "location_id", operator: "==", value: [2] };
+ const roomB = { criteria: "location_id", operator: "==", value: [7] };
+ mockGridFilterState.valuesWithIds = [
+ { ...roomA, id: "location_id-0" },
+ { ...roomB, id: "location_id-1" }
+ ];
+ mockGridFilterState.joinOperator = "ANY";
+ const criterias = [
+ {
+ key: "location_id",
+ label: "Location",
+ operators: [{ value: "==", label: "is" }],
+ values: {
+ type: "select",
+ props: {
+ options: [
+ { value: 2, label: "Room A" },
+ { value: 7, label: "Room B" }
+ ]
+ }
+ }
+ }
+ ];
+ renderGrid({ filterProps: { id: "grid_filter", criterias } });
+
+ const roomAChip = screen.getByRole("button", { name: /Room A/ });
+ await userEvent.click(within(roomAChip).getByTestId("CancelIcon"));
+
+ expect(mockGridFilterState.setFilters).toHaveBeenCalledWith([roomB], "ANY");
});
test("adds from the add button and lists every import option under the import icon", async () => {
diff --git a/src/components/mui/bulk-edit-data-grid/components/applied-filters/index.js b/src/components/mui/bulk-edit-data-grid/components/applied-filters/index.js
new file mode 100644
index 000000000..c86cd7c74
--- /dev/null
+++ b/src/components/mui/bulk-edit-data-grid/components/applied-filters/index.js
@@ -0,0 +1,68 @@
+/**
+ * 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 { Box, Chip } from "@mui/material";
+import FilterAltIcon from "@mui/icons-material/FilterAlt";
+import { useGridFilter } from "openstack-uicore-foundation/lib/components/mui/grid-filter";
+import { getFilterChipLabel } from "../../../../../pages/events/summit-event-list-page/helpers";
+
+// own component so useGridFilter only runs when the grid has a filter
+const AppliedFilters = ({ id, criterias }) => {
+ const { valuesWithIds, setFilters, joinOperator } = useGridFilter(id);
+
+ if (valuesWithIds.length === 0) return null;
+
+ const handleRemoveFilter = (filterId) =>
+ setFilters(
+ valuesWithIds
+ .filter((f) => f.id !== filterId)
+ .map(({ id: _id, ...filter }) => filter),
+ joinOperator
+ );
+
+ return (
+
+ {valuesWithIds.map((filter) => (
+ }
+ label={getFilterChipLabel(filter, criterias)}
+ onDelete={() => handleRemoveFilter(filter.id)}
+ />
+ ))}
+
+ );
+};
+
+AppliedFilters.propTypes = {
+ id: PropTypes.string.isRequired,
+ criterias: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired
+ })
+ ).isRequired
+};
+
+export default AppliedFilters;
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
deleted file mode 100644
index beb3ce13d..000000000
--- a/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * 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 (
-
- }
- onClick={() => {
- focusOnOpenRef.current = true;
- setExpanded(true);
- }}
- aria-label={T.translate("general.search")}
- sx={ICON_ONLY_BUTTON_SX}
- />
-
- );
- }
-
- return (
- {
- if (ev.currentTarget.contains(ev.relatedTarget)) return;
- if (!ev.currentTarget.querySelector("input").value && !term)
- setExpanded(false);
- }}
- >
-
-
- );
-};
-
-ExpandableQuickFilter.propTypes = {
- placeholder: PropTypes.string,
- term: PropTypes.string,
- onSearch: PropTypes.func.isRequired
-};
-
-ExpandableQuickFilter.defaultProps = {
- placeholder: "",
- term: ""
-};
-
-export default ExpandableQuickFilter;
diff --git a/src/components/mui/bulk-edit-data-grid/components/toolbar/index.js b/src/components/mui/bulk-edit-data-grid/components/toolbar/index.js
index a7718bf07..7dc1d7d5b 100644
--- a/src/components/mui/bulk-edit-data-grid/components/toolbar/index.js
+++ b/src/components/mui/bulk-edit-data-grid/components/toolbar/index.js
@@ -14,14 +14,19 @@
import React from "react";
import PropTypes from "prop-types";
import T from "i18n-react/dist/i18n-react";
-import { Box, Button, Divider, Tooltip } from "@mui/material";
+import { Box, Button, Tooltip } from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { GridToolbarContainer } from "@mui/x-data-grid";
+import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input";
+import { GridFilter } from "openstack-uicore-foundation/lib/components/mui/grid-filter";
import MenuButton from "../../../menu-button";
import ColumnsButton from "../columns-button";
-import ExpandableQuickFilter from "../expandable-quick-filter";
-import { ICON_ONLY_BUTTON_SX } from "../../constants";
+import AppliedFilters from "../applied-filters";
+import { FILTER_ICON_SX, ICON_ONLY_BUTTON_SX } from "../../constants";
+
+const SEARCH_WIDTH = 330;
const Toolbar = ({
views,
@@ -29,7 +34,7 @@ const Toolbar = ({
addLabel,
importItems,
importLabel,
- filter,
+ filterProps,
onExport,
searchProps,
editEnabled,
@@ -40,7 +45,7 @@ const Toolbar = ({
setColumnsButtonEl
}) => (
{editEnabled ? (
<>
@@ -54,7 +59,7 @@ const Toolbar = ({
) : (
+ )}
+
+
+ {filterProps && (
+
- >
+ )}
+
+ {searchProps && (
+
+
+
+ )}
+
+ {filterProps && (
+
)}
);
@@ -125,7 +140,15 @@ Toolbar.propTypes = {
PropTypes.shape({ label: PropTypes.string, onClick: PropTypes.func })
),
importLabel: PropTypes.string,
- filter: PropTypes.node,
+ filterProps: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ criterias: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired
+ })
+ ).isRequired
+ }),
onExport: PropTypes.func,
searchProps: PropTypes.shape({
placeholder: PropTypes.string,
@@ -146,7 +169,7 @@ Toolbar.defaultProps = {
addLabel: "",
importItems: [],
importLabel: "",
- filter: null,
+ filterProps: null,
onExport: null,
searchProps: null
};
diff --git a/src/components/mui/bulk-edit-data-grid/constants.js b/src/components/mui/bulk-edit-data-grid/constants.js
index 903db1a75..3a90a55c0 100644
--- a/src/components/mui/bulk-edit-data-grid/constants.js
+++ b/src/components/mui/bulk-edit-data-grid/constants.js
@@ -11,11 +11,29 @@
* limitations under the License.
* */
+import { alpha } from "@mui/material";
+
// icon-only toolbar buttons; the label stays as tooltip/aria-label
export const ICON_ONLY_BUTTON_SX = {
minWidth: 0,
+ // the theme's 16px small-button padding is too wide around a lone icon
+ px: 1,
"& .MuiButton-startIcon": { m: 0 }
};
+// uicore GridFilter's IconButton, sized and colored like the buttons above
+export const FILTER_ICON_SX = {
+ borderRadius: 1,
+ // the theme's small-button padding with px: 1, around a small button's icon
+ py: "9px",
+ px: 1,
+ "& svg": { fontSize: 18 },
+ color: "primary.main",
+ "&:hover": {
+ bgcolor: (theme) =>
+ alpha(theme.palette.primary.main, theme.palette.action.hoverOpacity)
+ }
+};
+
// GridActionsCellItem forces small icons; match uicore table's medium (22px)
export const ACTION_ICON_SX = { fontSize: "1.375rem" };
diff --git a/src/components/mui/bulk-edit-data-grid/index.js b/src/components/mui/bulk-edit-data-grid/index.js
index 50e085ccc..e5d6c4ffa 100644
--- a/src/components/mui/bulk-edit-data-grid/index.js
+++ b/src/components/mui/bulk-edit-data-grid/index.js
@@ -98,7 +98,7 @@ const BulkEditDataGrid = ({
addLabel,
importItems,
importLabel,
- filter,
+ filterProps,
onExport,
searchProps,
noRowsLabel
@@ -359,7 +359,7 @@ const BulkEditDataGrid = ({
addLabel,
importItems,
importLabel,
- filter,
+ filterProps,
onExport,
searchProps,
editEnabled,
@@ -430,8 +430,16 @@ BulkEditDataGrid.propTypes = {
),
// tooltip / aria-label of the import icon
importLabel: PropTypes.string,
- // rendered in the grid toolbar, e.g. a uicore
- filter: PropTypes.node,
+ // id and criterias of the uicore GridFilter the toolbar renders
+ filterProps: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ criterias: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired
+ })
+ ).isRequired
+ }),
onExport: PropTypes.func,
// same shape as GridToolbar's searchProps
searchProps: PropTypes.shape({
@@ -461,7 +469,7 @@ BulkEditDataGrid.defaultProps = {
addLabel: "",
importItems: [],
importLabel: "",
- filter: null,
+ filterProps: null,
onExport: null,
searchProps: null,
noRowsLabel: null
diff --git a/src/pages/events/summit-event-list-page/__tests__/helpers.test.js b/src/pages/events/summit-event-list-page/__tests__/helpers.test.js
index 4c927de32..ee6b4b3da 100644
--- a/src/pages/events/summit-event-list-page/__tests__/helpers.test.js
+++ b/src/pages/events/summit-event-list-page/__tests__/helpers.test.js
@@ -3,7 +3,8 @@ import {
toApiSortKey,
toUiSortKey,
getOptionalColumns,
- getCriterias
+ getCriterias,
+ getFilterChipLabel
} from "../helpers";
// formatDuration relies on the moment-duration-format plugin, which is only
@@ -247,3 +248,46 @@ describe("getCriterias", () => {
).toEqual(["has_not_media_upload_with_type==1&&2"]);
});
});
+
+describe("getFilterChipLabel", () => {
+ const summit = {
+ selection_plans: [],
+ locations: [
+ { id: 2, name: "Room A" },
+ { id: 7, name: "Room B" }
+ ],
+ tracks: [],
+ event_types: [],
+ presentation_action_types: []
+ };
+ const criterias = getCriterias(summit, []);
+
+ test("shows option labels for a multiple select instead of ids", () => {
+ const label = getFilterChipLabel(
+ { criteria: "location_id", operator: OPERATORS.IS.value, value: [2, 7] },
+ criterias
+ );
+ expect(label).toMatch(/Room A, Room B$/);
+ });
+
+ test("shows the label of async option objects", () => {
+ const label = getFilterChipLabel(
+ {
+ criteria: "tags",
+ operator: OPERATORS.IS.value,
+ value: [{ value: 1, label: "keynote" }]
+ },
+ criterias
+ );
+ expect(label).toMatch(/keynote$/);
+ });
+
+ test("uses the criteria's own operator label where operator values collide", () => {
+ const label = getFilterChipLabel(
+ { criteria: "created", operator: OPERATORS.BEFORE.value, value: 0 },
+ criterias
+ );
+ expect(label).toContain(` ${OPERATORS.BEFORE.label} `);
+ expect(OPERATORS.BEFORE.label).not.toBe(OPERATORS.LESS_OR_EQUAL.label);
+ });
+});
diff --git a/src/pages/events/summit-event-list-page/helpers.js b/src/pages/events/summit-event-list-page/helpers.js
index efc7f7691..5363ec666 100644
--- a/src/pages/events/summit-event-list-page/helpers.js
+++ b/src/pages/events/summit-event-list-page/helpers.js
@@ -30,6 +30,7 @@ import { formatDate, formatDuration } from "../../../utils/methods";
import {
DEFAULT_Z_INDEX,
HIGH_Z_INDEX,
+ LONG_DATETIME_FORMAT,
SECONDS_TO_MINUTES
} from "../../../utils/constants";
import { buildNameIdDDL } from "../../../utils/events/summit-event-list-page.utils";
@@ -117,20 +118,20 @@ export const formatEventData = (e, summit) => {
? formatDate(
e.published_date,
summit.time_zone.name,
- "MMMM Do YYYY, h:mm a"
+ LONG_DATETIME_FORMAT
)
: "No",
start_date_display: e.start_date
- ? formatDate(e.start_date, summit.time_zone.name, "MMMM Do YYYY, h:mm a")
+ ? formatDate(e.start_date, summit.time_zone.name, LONG_DATETIME_FORMAT)
: "TBD",
end_date_display: e.end_date
- ? formatDate(e.end_date, summit.time_zone.name, "MMMM Do YYYY, h:mm a")
+ ? formatDate(e.end_date, summit.time_zone.name, LONG_DATETIME_FORMAT)
: "TBD",
created_display: e.created
- ? formatDate(e.created, summit.time_zone_id, "MMMM Do YYYY, h:mm a")
+ ? formatDate(e.created, summit.time_zone_id, LONG_DATETIME_FORMAT)
: "TBD",
modified: e.last_edited
- ? formatDate(e.last_edited, summit.time_zone_id, "MMMM Do YYYY, h:mm a")
+ ? formatDate(e.last_edited, summit.time_zone_id, LONG_DATETIME_FORMAT)
: "TBD"
};
};
@@ -1105,3 +1106,28 @@ export const getCriterias = (summit, mediaUploadTypes) => [
}
}
];
+
+// chip text for an applied filter, e.g. "Location: is Room A, Room B"
+export const getFilterChipLabel = (filter, criterias) => {
+ const criteria = criterias.find(({ key }) => key === filter.criteria);
+ if (!criteria) return filter.criteria;
+
+ // the criteria's own operators, since BEFORE and LESS_OR_EQUAL share "<="
+ const operator = criteria.operators.find(
+ (op) => op.value === filter.operator
+ );
+ const { type, props } = criteria.values;
+ const options = props?.options;
+
+ const toLabel = (v) => {
+ // async inputs store option objects
+ if (v != null && typeof v === "object") return v.label;
+ if (type === "datetime") return formatDate(v, "LOC", LONG_DATETIME_FORMAT);
+ return options?.find((o) => o.value === v)?.label ?? v;
+ };
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
+ const operatorLabel = operator?.label ?? filter.operator;
+ const valueLabels = values.map(toLabel).join(", ");
+
+ return `${criteria.label}: ${operatorLabel} ${valueLabels}`;
+};
diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js
index 0072ddfc5..50bc227a2 100644
--- a/src/pages/events/summit-event-list-page/index.js
+++ b/src/pages/events/summit-event-list-page/index.js
@@ -15,10 +15,7 @@ import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown";
-import {
- GridFilter,
- useGridFilter
-} from "openstack-uicore-foundation/lib/components/mui/grid-filter";
+import { useGridFilter } from "openstack-uicore-foundation/lib/components/mui/grid-filter";
import BulkEditDataGrid from "../../../components/mui/bulk-edit-data-grid";
import {
bulkUpdateEvents,
@@ -301,6 +298,7 @@ const SummitEventListPage = ({
if (!currentSummit.id) return
;
const tableData = events.map((e) => formatEventData(e, currentSummit));
+ const criterias = getCriterias(currentSummit, mediaUploadTypes);
return (
@@ -316,12 +314,7 @@ const SummitEventListPage = ({
columns={tableColumns}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityModelChange}
- filter={
-
- }
+ filterProps={{ id: FILTER_ID, criterias }}
views={