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 ( - - - )} {importItems.length > 0 && ( )} - {(onAdd || importItems.length > 0) && ( - - )} - - {filter} {onExport && ( - <> - - - + )} + + + {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={