diff --git a/package.json b/package.json
index af9119bd..63e74fcd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.34",
+ "version": "5.0.36-beta.2",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
diff --git a/src/components/index.js b/src/components/index.js
index 7cea9067..63d31ddd 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -78,6 +78,8 @@ export {useSnackbarMessage} from './mui/SnackbarNotification/Context'
export {default as MuiInfiniteTable} from './mui/infinite-table'
export {default as MuiEditableTable} from './mui/editable-table/mui-table-editable'
export {default as MuiTable} from './mui/table/mui-table'
+export {default as MuiCustomTablePagination} from './mui/table/CustomTablePagination'
+export {default as MuiBulkEditTable} from './mui/BulkEditTable'
export {default as MuiSponsorOrderGrid} from './mui/SponsorOrderGrid'
export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow, FeeRow as MuiFeeRow, PaymentRow as MuiPaymentRow, RefundRow as MuiRefundRow, DiscountRow as MuiDiscountRow} from './mui/table/extra-rows'
export {default as MuiFormikAsyncSelect} from './mui/formik-inputs/mui-formik-async-select'
diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js
new file mode 100644
index 00000000..260487f8
--- /dev/null
+++ b/src/components/mui/BulkEditTable/BulkEditTable.js
@@ -0,0 +1,160 @@
+import React, { useEffect } from "react";
+import PropTypes from "prop-types";
+import Box from "@mui/material/Box";
+import Paper from "@mui/material/Paper";
+import Table from "@mui/material/Table";
+import TableBody from "@mui/material/TableBody";
+import TableCell from "@mui/material/TableCell";
+import TableContainer from "@mui/material/TableContainer";
+import TableHead from "@mui/material/TableHead";
+import TableRow from "@mui/material/TableRow";
+import Checkbox from "@mui/material/Checkbox";
+import Toolbar from "./components/Toolbar";
+import Heading from "./components/Heading";
+import Row from "./components/Row";
+import useRowSelection from "./hooks/useRowSelection";
+import styles from "./BulkEditTable.module.less";
+import CustomTablePagination from "../table/CustomTablePagination";
+
+const BulkEditTable = ({ options, columns, data, onSort, onUpdate, totalRows, perPage, currentPage, onPageChange, onPerPageChange }) => {
+ const {
+ selectedRows,
+ isSelected,
+ toggleRow,
+ isAllSelected,
+ toggleAll,
+ editField,
+ editEnabled,
+ enterEditMode,
+ cancel,
+ reset
+ } = useRowSelection();
+
+ const dataIds = data.map((row) => row.id).join(",");
+
+ // reset selection/edit state whenever the set of rows shown changes
+ // (pagination, filtering, sorting, search, etc.)
+ useEffect(() => {
+ reset();
+ }, [dataIds]);
+
+ const getSortDir = (columnKey) =>
+ columnKey === options.sortCol ? options.sortDir : null;
+
+ const handleUpdateEvents = (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+ onUpdate(selectedRows)
+ .then(() => reset())
+ .catch((error) => {
+ console.error("Error updating events:", error);
+ });
+ };
+
+ return (
+
+ 0}
+ onEdit={enterEditMode}
+ onApply={handleUpdateEvents}
+ onCancel={cancel}
+ />
+
+
+
+
+
+
+ toggleAll(data)}
+ slotProps={{ input: { "aria-label": "select all" } }}
+ />
+
+ {columns.map((col, i) => {
+ const sortable = !!col.sortable;
+ const colWidth = col.width ?? "";
+
+ return (
+
+ {col.label}
+
+ );
+ })}
+ {options.actions && (
+
+ {options.actionsHeader || " "}
+
+ )}
+
+
+
+ {columns.length > 0 &&
+ data.map((row) => (
+ r.id === row.id) || row}
+ onToggle={() => toggleRow(row)}
+ onFieldChange={(key, value) =>
+ editField(row.id, key, value)
+ }
+ columns={columns}
+ actions={options.actions}
+ />
+ ))}
+
+
+
+ {perPage && currentPage && onPageChange && (
+
+ )}
+
+
+ );
+};
+
+BulkEditTable.propTypes = {
+ options: PropTypes.object.isRequired,
+ columns: PropTypes.array.isRequired,
+ data: PropTypes.array.isRequired,
+ onSort: PropTypes.func.isRequired,
+ onUpdate: PropTypes.func.isRequired,
+ totalRows: PropTypes.number,
+ perPage: PropTypes.number,
+ currentPage: PropTypes.number,
+ onPageChange: PropTypes.func,
+ onPerPageChange: PropTypes.func
+};
+
+export default BulkEditTable;
diff --git a/src/components/mui/BulkEditTable/BulkEditTable.module.less b/src/components/mui/BulkEditTable/BulkEditTable.module.less
new file mode 100644
index 00000000..d6b85b87
--- /dev/null
+++ b/src/components/mui/BulkEditTable/BulkEditTable.module.less
@@ -0,0 +1,58 @@
+.tableWrapper {
+ width: 100%;
+ overflow-x: auto;
+ position: relative;
+
+ td {
+ max-width: 150px;
+ text-overflow: ellipsis;
+ overflow-wrap: break-word;
+ vertical-align: middle;
+
+ &.dataColumn {
+ min-width: 150px;
+ }
+ }
+
+ // shared by header (th) and body (td) cells so the checkbox/action columns
+ // stay pinned and aligned in both rows while the data columns scroll
+ // horizontally. Background color is intentionally NOT set here: header
+ // cells need the header's grey, body cells need white, set via sx where
+ // each is rendered (an explicit color is required, `inherit` resolves to
+ // transparent here and lets the scrolling columns show through).
+ .checkColumn {
+ text-align: center;
+ position: sticky;
+ z-index: 5;
+ left: 0;
+ }
+
+ .actionColumn {
+ text-align: center;
+ position: sticky;
+ z-index: 5;
+ right: 0;
+ width: 60px;
+ min-width: 60px;
+ max-width: 60px;
+ }
+
+ .bulkEditCol {
+ min-width: 250px;
+ }
+}
+
+.dottedBorderLeft {
+ position: relative;
+ border-left: none;
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ border-left: 1px dashed #e0e0e0;
+ height: 60%;
+ align-self: center;
+ }
+}
diff --git a/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js b/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js
new file mode 100644
index 00000000..753201ad
--- /dev/null
+++ b/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js
@@ -0,0 +1,48 @@
+import React from "react";
+import userEvent from "@testing-library/user-event";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import BulkEditTable from "../BulkEditTable";
+
+describe("BulkEditTable", () => {
+ const baseProps = {
+ options: {
+ className: "test-table",
+ actions: {}
+ },
+ columns: [
+ { columnKey: "id", value: "id", sortable: true },
+ { columnKey: "title", value: "title", sortable: true }
+ ],
+ onSort: jest.fn(),
+ data: [
+ { id: 1, title: "Event 1" },
+ { id: 2, title: "Event 2" }
+ ]
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("applies bulk updates to selected rows", async () => {
+ const user = userEvent.setup();
+ const onUpdate = jest.fn(() => Promise.resolve());
+
+ render();
+
+ const checkboxes = screen.getAllByRole("checkbox");
+
+ await user.click(checkboxes[1]);
+ await user.click(screen.getByText("event_list.edit_selected"));
+ await act(async () => {
+ await user.click(screen.getByText("bulk_actions_page.btn_apply_changes"));
+ });
+
+ await waitFor(() => {
+ expect(onUpdate).toHaveBeenCalledTimes(1);
+ expect(onUpdate).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ id: 1 })])
+ );
+ });
+ });
+});
diff --git a/src/components/mui/BulkEditTable/components/Cell.js b/src/components/mui/BulkEditTable/components/Cell.js
new file mode 100644
index 00000000..e63378e5
--- /dev/null
+++ b/src/components/mui/BulkEditTable/components/Cell.js
@@ -0,0 +1,56 @@
+import React from "react";
+import PropTypes from "prop-types";
+import TextField from "@mui/material/TextField";
+
+const DEFAULT_PLACEHOLDER = "Enter text...";
+
+const Cell = ({ col, row, editRow, isEditingRow, onChange }) => {
+ if (isEditingRow && col.editableField === true) {
+ return (
+
+ );
+ }
+
+ if (isEditingRow && col.editableField) {
+ // editableField functions may short-circuit (e.g. `cond && `) and
+ // return `undefined` rather than `false`, which React rejects as a component return value.
+ return (
+ col.editableField({
+ value:
+ editRow[col.columnKey]?.id ??
+ editRow[col.columnKey]?.value ??
+ editRow[col.columnKey],
+ onChange,
+ row: editRow,
+ rowData: editRow[col.columnKey]
+ }) ?? null
+ );
+ }
+
+ if (col.render) {
+ return col.render(row[col.columnKey], row) ?? null;
+ }
+
+ return (
+ {row[col.columnKey] ?? null}
+ );
+};
+
+Cell.propTypes = {
+ col: PropTypes.object.isRequired,
+ row: PropTypes.object.isRequired,
+ editRow: PropTypes.object.isRequired,
+ isEditingRow: PropTypes.bool,
+ onChange: PropTypes.func
+};
+
+export default Cell;
diff --git a/src/components/mui/BulkEditTable/components/Heading.js b/src/components/mui/BulkEditTable/components/Heading.js
new file mode 100644
index 00000000..5640c110
--- /dev/null
+++ b/src/components/mui/BulkEditTable/components/Heading.js
@@ -0,0 +1,64 @@
+import React from "react";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import Box from "@mui/material/Box";
+import TableCell from "@mui/material/TableCell";
+import TableSortLabel from "@mui/material/TableSortLabel";
+import { visuallyHidden } from "@mui/utils";
+
+const Heading = (props) => {
+ const {
+ editEnabled,
+ sortable,
+ sortDir,
+ onSort,
+ columnIndex,
+ columnKey,
+ width,
+ children
+ } = props;
+
+ const handleSort = () => {
+ if (!onSort || !sortable || editEnabled) return;
+
+ onSort(columnIndex, columnKey, sortDir ? sortDir * -1 : 1);
+ };
+
+ const headerSx = width ? { width, minWidth: width, maxWidth: width } : {};
+
+ if (!sortable || editEnabled) {
+ return {children};
+ }
+
+ return (
+
+
+ {children}
+ {sortDir ? (
+
+ {sortDir === -1
+ ? T.translate("mui_table.sorted_desc")
+ : T.translate("mui_table.sorted_asc")}
+
+ ) : null}
+
+
+ );
+};
+
+Heading.propTypes = {
+ editEnabled: PropTypes.bool,
+ onSort: PropTypes.func,
+ sortDir: PropTypes.number,
+ columnIndex: PropTypes.number,
+ columnKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ sortable: PropTypes.bool,
+ width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ children: PropTypes.node
+};
+
+export default Heading;
diff --git a/src/components/mui/BulkEditTable/components/Row.js b/src/components/mui/BulkEditTable/components/Row.js
new file mode 100644
index 00000000..8a306243
--- /dev/null
+++ b/src/components/mui/BulkEditTable/components/Row.js
@@ -0,0 +1,122 @@
+import React from "react";
+import PropTypes from "prop-types";
+import Box from "@mui/material/Box";
+import TableRow from "@mui/material/TableRow";
+import TableCell from "@mui/material/TableCell";
+import Checkbox from "@mui/material/Checkbox";
+import IconButton from "@mui/material/IconButton";
+import EditIcon from "@mui/icons-material/Edit";
+import DeleteIcon from "@mui/icons-material/Delete";
+import Cell from "./Cell";
+import styles from "../BulkEditTable.module.less";
+
+// the 250px min-width while editing comes from the .bulkEditCol class
+// (applied via className below) so it isn't duplicated here
+const getCellStyle = (col) => ({
+ ...(col.width
+ ? { width: col.width, minWidth: col.width, maxWidth: col.width }
+ : {}),
+ ...col.customStyle
+});
+
+const Row = (props) => {
+ const {
+ row,
+ columns,
+ editEnabled,
+ isSelected,
+ editRow,
+ onToggle,
+ onFieldChange,
+ actions
+ } = props;
+
+ const isEditingRow = isSelected && editEnabled;
+
+ const onRowChange = (ev) => {
+ const { value, id } = ev.target;
+ onFieldChange(id, value);
+ };
+
+ return (
+
+
+
+
+ {row.id}
+ {columns
+ .filter((col) => col.columnKey !== "id")
+ .map((col) => (
+
+ |
+
+ ))}
+ {(actions?.edit || actions?.delete) && (
+
+
+ {actions.edit && (
+ actions.edit.onClick(row)}
+ sx={{ padding: 0 }}
+ aria-label={`Edit event ${row.id}`}
+ >
+
+
+ )}
+ {actions.delete && (
+ actions.delete.onClick(row)}
+ sx={{ padding: 0 }}
+ aria-label={`Delete event ${row.id}`}
+ >
+
+
+ )}
+
+
+ )}
+
+ );
+};
+
+Row.propTypes = {
+ row: PropTypes.object.isRequired,
+ columns: PropTypes.array.isRequired,
+ editEnabled: PropTypes.bool,
+ isSelected: PropTypes.bool,
+ editRow: PropTypes.object.isRequired,
+ onToggle: PropTypes.func,
+ onFieldChange: PropTypes.func,
+ actions: PropTypes.object
+};
+
+export default Row;
diff --git a/src/components/mui/BulkEditTable/components/Toolbar.js b/src/components/mui/BulkEditTable/components/Toolbar.js
new file mode 100644
index 00000000..d574f686
--- /dev/null
+++ b/src/components/mui/BulkEditTable/components/Toolbar.js
@@ -0,0 +1,34 @@
+import React from "react";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+
+const Toolbar = ({ editEnabled, hasSelection, onEdit, onApply, onCancel }) => (
+
+ {editEnabled ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+);
+
+Toolbar.propTypes = {
+ editEnabled: PropTypes.bool,
+ hasSelection: PropTypes.bool,
+ onEdit: PropTypes.func,
+ onApply: PropTypes.func,
+ onCancel: PropTypes.func
+};
+
+export default Toolbar;
diff --git a/src/components/mui/BulkEditTable/hooks/useRowSelection.js b/src/components/mui/BulkEditTable/hooks/useRowSelection.js
new file mode 100644
index 00000000..46710b35
--- /dev/null
+++ b/src/components/mui/BulkEditTable/hooks/useRowSelection.js
@@ -0,0 +1,49 @@
+import { useState } from "react";
+
+const useRowSelection = () => {
+ const [selectedRows, setSelectedRows] = useState([]);
+ const [editEnabled, setEditEnabled] = useState(false);
+
+ const isSelected = (rowId) => selectedRows.some((row) => row.id === rowId);
+
+ const toggleRow = (row) => {
+ setSelectedRows((current) =>
+ isSelected(row.id)
+ ? current.filter((r) => r.id !== row.id)
+ : [...current, row]
+ );
+ };
+
+ const isAllSelected = (rows) =>
+ rows.length > 0 && rows.every((row) => isSelected(row.id));
+
+ const toggleAll = (rows) => {
+ setSelectedRows(isAllSelected(rows) ? [] : rows);
+ };
+
+ const editField = (rowId, key, value) => {
+ setSelectedRows((current) =>
+ current.map((row) => (row.id === rowId ? { ...row, [key]: value } : row))
+ );
+ };
+
+ const reset = () => {
+ setSelectedRows([]);
+ setEditEnabled(false);
+ };
+
+ return {
+ selectedRows,
+ isSelected,
+ toggleRow,
+ isAllSelected,
+ toggleAll,
+ editField,
+ editEnabled,
+ enterEditMode: () => setEditEnabled(true),
+ cancel: reset,
+ reset
+ };
+};
+
+export default useRowSelection;
diff --git a/src/components/mui/BulkEditTable/index.js b/src/components/mui/BulkEditTable/index.js
new file mode 100644
index 00000000..37f44e22
--- /dev/null
+++ b/src/components/mui/BulkEditTable/index.js
@@ -0,0 +1,3 @@
+import BulkEditTable from "./BulkEditTable";
+
+export default BulkEditTable;
diff --git a/src/components/mui/Dropdown/index.jsx b/src/components/mui/Dropdown/index.jsx
index 96bdb834..5d2dedce 100644
--- a/src/components/mui/Dropdown/index.jsx
+++ b/src/components/mui/Dropdown/index.jsx
@@ -86,7 +86,7 @@ Dropdown.propTypes = {
label: PropTypes.string.isRequired,
disabled: PropTypes.bool
})
- ).isRequired,
+ ),
label: PropTypes.string,
placeholder: PropTypes.string,
onChange: PropTypes.func.isRequired
@@ -95,7 +95,8 @@ Dropdown.propTypes = {
Dropdown.defaultProps = {
value: null,
label: "",
- placeholder: ""
+ placeholder: "",
+ options: null
};
export default Dropdown;
diff --git a/src/components/mui/GridFilter/GridFilter.jsx b/src/components/mui/GridFilter/GridFilter.jsx
index 67519d20..88282e65 100644
--- a/src/components/mui/GridFilter/GridFilter.jsx
+++ b/src/components/mui/GridFilter/GridFilter.jsx
@@ -29,7 +29,7 @@ import Filter from "./components/Filter";
import FilterButton from "./components/FilterButton";
import { saveFilters } from "./actions/filter-actions";
import useGridFilter from "./hooks/useGridFilter";
-import { JOIN_OPERATORS, OPERATORS, EMPTY_FILTER } from "./utils";
+import { JOIN_OPERATORS, OPERATORS, EMPTY_FILTER, ASYNC_VALUE_TYPES } from "./utils";
const OPERATOR_VALUES = Object.values(OPERATORS).map((op) => op.value);
@@ -55,14 +55,21 @@ const GridFilter = ({ id, criterias, hideJoinOperators = false, onApply, saveFil
}, [valuesString, joinOperator, openModal]);
const parseFilter = (filter) => {
- const parser = criterias.find(
- ({ key }) => key === filter.criteria
- )?.customParser;
+ const criteria = criterias.find(({ key }) => key === filter.criteria);
+ const parser = criteria?.customParser;
+
+ if (!parser && ASYNC_VALUE_TYPES.includes(criteria?.values?.type)) {
+ console.error(
+ `GridFilter: criteria "${filter.criteria}" uses async value type "${criteria.values.type}" but defines no customParser — its value will not serialize into the API filter string correctly.`
+ );
+ }
if (parser) {
return parser(filter);
}
+ // TODO: use escapeFilterValue
+
const value = Array.isArray(filter.value)
? filter.value.join("||")
: filter.value;
diff --git a/src/components/mui/GridFilter/components/Filter.jsx b/src/components/mui/GridFilter/components/Filter.jsx
index eadb8be7..9996237b 100644
--- a/src/components/mui/GridFilter/components/Filter.jsx
+++ b/src/components/mui/GridFilter/components/Filter.jsx
@@ -83,32 +83,37 @@ const Filter = ({ id, value, criterias, onChange, onAdd, onDelete }) => {
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx
new file mode 100644
index 00000000..5d50fea9
--- /dev/null
+++ b/src/components/mui/GridFilter/components/ValueInput/AsyncSelectInput.jsx
@@ -0,0 +1,155 @@
+/**
+ * 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 Autocomplete from "@mui/material/Autocomplete";
+import TextField from "@mui/material/TextField";
+import CircularProgress from "@mui/material/CircularProgress";
+import { DEBOUNCE_WAIT_250 } from "../../../../../utils/constants";
+
+const defaultFormatOption = (item) => ({
+ value: item.id,
+ label: item.name
+});
+
+const optionShape = PropTypes.shape({
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ label: PropTypes.string,
+ raw: PropTypes.object
+});
+
+const AsyncSelectInput = ({
+ id,
+ value,
+ label,
+ placeholder,
+ disabled,
+ multiple,
+ queryFunction,
+ formatOption,
+ debounceWait,
+ minSearchLength,
+ onChange,
+ ...rest
+}) => {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const debounceRef = useRef(null);
+
+ // Filter.jsx passes `options` generically to every ValueInput type (meant
+ // for the sync `select` type); this type fetches its own, so it's stripped
+ // out here rather than spread onto the Autocomplete below.
+ const { options: _staleOptions, ...autocompleteProps } = rest;
+
+ const fetchOptions = (searchTerm) => {
+ if (searchTerm && searchTerm.length < minSearchLength) {
+ setOptions([]);
+ return;
+ }
+ setLoading(true);
+ queryFunction(searchTerm, (rawResults) => {
+ setOptions((rawResults || []).map((item) => ({ ...formatOption(item), raw: item })));
+ setLoading(false);
+ });
+ };
+
+ useEffect(() => {
+ fetchOptions("");
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleInputChange = (event, newInputValue, reason) => {
+ if (reason !== "input") return;
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => fetchOptions(newInputValue), debounceWait);
+ };
+
+ const handleChange = (event, selected) => {
+ onChange({ target: { value: multiple ? selected || [] : selected || null } });
+ };
+
+ // Filter.jsx's single-value default is "" (not null); treat it as empty.
+ const normalizedValue = multiple ? value || [] : value || null;
+ const finalPlaceholder =
+ placeholder || T.translate("grid_filter.placeholders.async");
+
+ return (
+ option?.label || ""}
+ isOptionEqualToValue={(option, val) => option.value === val.value}
+ renderInput={(params) => (
+
+ {loading && }
+ {params.InputProps?.endAdornment}
+ >
+ )
+ }
+ }}
+ />
+ )}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...autocompleteProps}
+ />
+ );
+};
+
+AsyncSelectInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([optionShape, PropTypes.arrayOf(optionShape), PropTypes.string]),
+ label: PropTypes.string,
+ placeholder: PropTypes.string,
+ disabled: PropTypes.bool,
+ multiple: PropTypes.bool,
+ queryFunction: PropTypes.func.isRequired,
+ formatOption: PropTypes.func,
+ debounceWait: PropTypes.number,
+ minSearchLength: PropTypes.number,
+ onChange: PropTypes.func.isRequired
+};
+
+AsyncSelectInput.defaultProps = {
+ value: null,
+ label: "",
+ placeholder: "",
+ disabled: false,
+ multiple: false,
+ formatOption: defaultFormatOption,
+ debounceWait: DEBOUNCE_WAIT_250,
+ minSearchLength: 0
+};
+
+export default AsyncSelectInput;
diff --git a/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx
new file mode 100644
index 00000000..1b60dd79
--- /dev/null
+++ b/src/components/mui/GridFilter/components/ValueInput/CompanySelectInput.jsx
@@ -0,0 +1,44 @@
+/**
+ * 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 AsyncSelectInput from "./AsyncSelectInput";
+import { queryCompanies } from "../../../../../utils/query-actions";
+
+const defaultFormatOption = (company) => ({
+ value: company.id,
+ label: company.name
+});
+
+const CompanySelectInput = ({ queryFunction, placeholder, ...rest }) => (
+
+);
+
+CompanySelectInput.propTypes = {
+ queryFunction: PropTypes.func,
+ placeholder: PropTypes.string
+};
+
+CompanySelectInput.defaultProps = {
+ queryFunction: null,
+ formatOption: defaultFormatOption
+};
+
+export default CompanySelectInput;
diff --git a/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx b/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx
new file mode 100644
index 00000000..39e74131
--- /dev/null
+++ b/src/components/mui/GridFilter/components/ValueInput/DateTimeInput.jsx
@@ -0,0 +1,99 @@
+/**
+ * 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 moment from "moment-timezone";
+import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
+
+// mode controls which views the single DateTimePicker exposes, keeping the
+// stored value a unix timestamp (the convention used across this app) in
+// every case, regardless of whether the user picks a date, a time, or both.
+const MODE_VIEWS = {
+ date: ["year", "month", "day"],
+ time: ["hours", "minutes"],
+ datetime: ["year", "month", "day", "hours", "minutes"]
+};
+
+const MODE_FORMATS = {
+ date: "MM/DD/YYYY",
+ time: "hh:mm A",
+ datetime: "MM/DD/YYYY hh:mm A"
+};
+
+const DateTimeInput = ({
+ id,
+ value,
+ mode,
+ label,
+ placeholder,
+ disabled,
+ onChange,
+ ...rest
+}) => {
+ const momentValue = value ? moment.unix(value) : null;
+ const finalPlaceholder =
+ placeholder || T.translate("grid_filter.placeholders.date");
+
+ const handleChange = (newValue) => {
+ onChange({
+ target: { value: newValue?.isValid() ? newValue.unix() : null }
+ });
+ };
+
+ return (
+
+
+
+ );
+};
+
+DateTimeInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.number,
+ mode: PropTypes.oneOf(["date", "time", "datetime"]),
+ label: PropTypes.string,
+ placeholder: PropTypes.string,
+ disabled: PropTypes.bool,
+ onChange: PropTypes.func.isRequired
+};
+
+DateTimeInput.defaultProps = {
+ value: null,
+ mode: "datetime",
+ label: "",
+ placeholder: "",
+ disabled: false
+};
+
+export default DateTimeInput;
diff --git a/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx b/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx
new file mode 100644
index 00000000..54693484
--- /dev/null
+++ b/src/components/mui/GridFilter/components/ValueInput/NumberInput.jsx
@@ -0,0 +1,108 @@
+/**
+ * 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 TextField from "@mui/material/TextField";
+import T from "i18n-react/dist/i18n-react";
+
+const BLOCKED_KEYS = ["e", "E"];
+
+const NumberInput = ({
+ id,
+ value,
+ label,
+ placeholder,
+ disabled,
+ min,
+ max,
+ integer,
+ onChange,
+ ...rest
+}) => {
+ const handleChange = (e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ onChange({ target: { value: null } });
+ return;
+ }
+
+ const parsed = integer ? parseInt(raw, 10) : parseFloat(raw);
+ // React skips forcing the DOM value of a focused number input back to the
+ // controlled value when the typed string doesn't parse yet (e.g. "-" or
+ // "1."), so just wait for more input instead of clobbering it.
+ if (Number.isNaN(parsed)) return;
+
+ let clamped = parsed;
+ if (min != null) clamped = Math.max(clamped, min);
+ if (max != null) clamped = Math.min(clamped, max);
+ // only force-normalize the DOM when clamping changed the typed value;
+ // otherwise leave it alone so e.g. a trailing "." isn't stripped mid-typing
+ if (clamped !== parsed) e.target.value = clamped;
+ onChange({ target: { value: clamped } });
+ };
+
+ const finalPlaceholder =
+ placeholder || T.translate("grid_filter.placeholders.number");
+
+ return (
+ {
+ if (BLOCKED_KEYS.includes(e.key)) e.preventDefault();
+ if (integer && (e.key === "." || e.key === ",")) e.preventDefault();
+ }}
+ slotProps={{
+ htmlInput: {
+ ...(min != null ? { min } : {}),
+ ...(max != null ? { max } : {}),
+ ...(integer ? { step: 1 } : {})
+ }
+ }}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
+ />
+ );
+};
+
+NumberInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.number,
+ label: PropTypes.string,
+ placeholder: PropTypes.string,
+ disabled: PropTypes.bool,
+ min: PropTypes.number,
+ max: PropTypes.number,
+ integer: PropTypes.bool,
+ onChange: PropTypes.func.isRequired
+};
+
+NumberInput.defaultProps = {
+ value: null,
+ label: "",
+ placeholder: "",
+ disabled: false,
+ min: null,
+ max: null,
+ integer: false
+};
+
+export default NumberInput;
diff --git a/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx b/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx
new file mode 100644
index 00000000..b2ee55ac
--- /dev/null
+++ b/src/components/mui/GridFilter/components/ValueInput/SpeakerSelectInput.jsx
@@ -0,0 +1,46 @@
+/**
+ * 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 AsyncSelectInput from "./AsyncSelectInput";
+import { querySpeakers } from "../../../../../utils/query-actions";
+
+const defaultFormatOption = (speaker) => ({
+ value: speaker.id,
+ label: `${speaker.first_name} ${speaker.last_name} (${speaker.email || speaker.id})`
+});
+
+const SpeakerSelectInput = ({ summitId, queryFunction, placeholder, ...rest }) => (
+ querySpeakers(summitId, input, callback))}
+ placeholder={placeholder || T.translate("grid_filter.placeholders.speaker")}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
+ />
+);
+
+SpeakerSelectInput.propTypes = {
+ summitId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ queryFunction: PropTypes.func,
+ placeholder: PropTypes.string
+};
+
+SpeakerSelectInput.defaultProps = {
+ summitId: null,
+ queryFunction: null,
+ formatOption: defaultFormatOption
+};
+
+export default SpeakerSelectInput;
diff --git a/src/components/mui/GridFilter/components/ValueInput/index.jsx b/src/components/mui/GridFilter/components/ValueInput/index.jsx
index ab4abe92..c565f1b7 100644
--- a/src/components/mui/GridFilter/components/ValueInput/index.jsx
+++ b/src/components/mui/GridFilter/components/ValueInput/index.jsx
@@ -15,8 +15,21 @@ import React from "react";
import TextField from "@mui/material/TextField";
import PropTypes from "prop-types";
import Dropdown from "../../../Dropdown";
+import DateTimeInput from "./DateTimeInput";
+import NumberInput from "./NumberInput";
+import AsyncSelectInput from "./AsyncSelectInput";
+import SpeakerSelectInput from "./SpeakerSelectInput";
+import CompanySelectInput from "./CompanySelectInput";
-const INPUT_TYPE_MAP = { text: TextField, select: Dropdown };
+const INPUT_TYPE_MAP = {
+ text: TextField,
+ select: Dropdown,
+ datetime: DateTimeInput,
+ number: NumberInput,
+ asyncSelect: AsyncSelectInput,
+ speaker: SpeakerSelectInput,
+ company: CompanySelectInput
+};
const ValueInput = ({ type, ...rest }) => {
const Component = type ? INPUT_TYPE_MAP[type] : Dropdown; // use dropdown as a placeholder
@@ -26,11 +39,14 @@ const ValueInput = ({ type, ...rest }) => {
ValueInput.propTypes = {
id: PropTypes.string.isRequired,
- type: PropTypes.string.isRequired,
+ // not required: the trailing "new" filter row has no criteria selected
+ // yet, so there's no type to pass — ValueInput falls back to Dropdown.
+ type: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
- PropTypes.array
+ PropTypes.array,
+ PropTypes.object
]),
options: PropTypes.arrayOf(
PropTypes.shape({
@@ -46,6 +62,7 @@ ValueInput.propTypes = {
};
ValueInput.defaultProps = {
+ type: null,
value: null,
label: "",
placeholder: "",
diff --git a/src/components/mui/GridFilter/hooks/useGridFilter.jsx b/src/components/mui/GridFilter/hooks/useGridFilter.jsx
index 972b2ac2..d800d71f 100644
--- a/src/components/mui/GridFilter/hooks/useGridFilter.jsx
+++ b/src/components/mui/GridFilter/hooks/useGridFilter.jsx
@@ -21,13 +21,22 @@ const useGridFilter = (id) => {
const resetFilters = () => dispatch(saveFilters(id));
+ // Lets the host push filters into the store from outside the dialog —
+ // e.g. applying a previously saved filter. The shape it expects matches
+ // what GridFilter persists itself: [{ criteria, operator, value, parsed }],
+ // so a saved filter's `criteria` array (as returned by the API) can be
+ // passed through directly.
+ const setFilters = (filters = [], joinOperator = JOIN_OPERATORS.ALL) =>
+ dispatch(saveFilters(id, filters, joinOperator));
+
return {
filterValues,
filterCount: filterValues.length,
joinOperator,
parsedFilter,
valuesWithIds,
- resetFilters
+ resetFilters,
+ setFilters
};
};
diff --git a/src/components/mui/GridFilter/readme.md b/src/components/mui/GridFilter/readme.md
index 2202f506..c57d14b4 100644
--- a/src/components/mui/GridFilter/readme.md
+++ b/src/components/mui/GridFilter/readme.md
@@ -133,9 +133,30 @@ const { filterValues, parsedFilter, joinOperator, filterCount } =
| `joinOperator` | `"all"` or `"any"` |
| `filterCount` | Number of active filters (useful for badge counts) |
| `resetFilters` | Function — clears all active filters from the store |
+| `setFilters` | Function `(filters, joinOperator?) => void` — pushes filters into the store from outside the dialog |
The hook reads from `allGridFiltersState` in the Redux store, so it stays in sync with whatever was last applied via the dialog.
+# setting filters from the host (e.g. applying a saved filter)
+
+`setFilters(filters, joinOperator)` lets the host populate a `GridFilter`'s state without going through the dialog UI — for example, when the user picks a previously saved filter from some other "saved filters" feature. It writes straight to the Redux store under the hook's `id`, the same place `GridFilter` itself writes to on Apply.
+
+`filters` must be an array shaped like `[{ criteria, operator, value, parsed }]` — the same shape `GridFilter` produces internally and the shape returned in `onApply`. `parsed` should already contain the resolved API filter strings (`GridFilter` does not re-run `customParser` for filters set this way, since it has no React component instance to read `criterias`/`customParser` from at that point).
+
+If your saved filters are persisted via an API that round-trips exactly what `onApply` produced (criteria/operator/value/parsed per row), the saved `criteria` array can be passed to `setFilters` unmodified:
+
+```js
+import useGridFilter from "components/GridFilter/hooks/useGridFilter";
+
+const { setFilters } = useGridFilter("speakers-filter");
+
+const applySavedFilter = (savedFilter) => {
+ setFilters(savedFilter.criteria);
+};
+```
+
+Once set, `GridFilter`'s badge count and dialog rows pick the values up automatically (same as if the user had applied them by hand), and `parsedFilter` from the hook is immediately available to refetch data with — `setFilters` does not call `onApply`, so trigger any necessary refetch yourself after calling it.
+
# hideJoinOperators
By default the dialog shows an **All / Any** toggle that lets the user choose whether filters are ANDed or ORed together. Pass `hideJoinOperators` to hide the toggle UI — but note that this **only removes the control from the dialog; it does not change the active join operator**. The dialog always initializes from the join operator last persisted in the Redux store (which defaults to `"all"` on first load). If the user previously applied filters with `"any"` and that value is still in the store, it will continue to be used when the toggle is hidden — silently producing OR-joined results.
@@ -193,6 +214,92 @@ Each option in the returned array may include a `disabled: true` field; the corr
A static array still works exactly as before — the function form is opt-in.
+# datetime values
+
+For date/time criteria, use `type: "datetime"`. It renders a single MUI `DateTimePicker` and stores the value as a unix timestamp (seconds), consistent with how dates are represented elsewhere in this app. Use `props.mode` to control which views are shown — the stored value is always a unix timestamp regardless of mode.
+
+| `mode` | Shows |
+| ---------- | ------------- |
+| `date` | date only |
+| `time` | time only |
+| `datetime` | date and time (default) |
+
+```jsx
+{
+ key: "created",
+ label: "Created",
+ operators: [OPERATORS.BEFORE, OPERATORS.AFTER],
+ values: {
+ type: "datetime",
+ props: { mode: "date" }
+ }
+}
+```
+
+# numeric values
+
+For numeric criteria, use `type: "number"`. It renders a `TextField` of `type="number"` and stores/emits the value as an actual `Number` (not a string). Optional props: `min`, `max` (clamped on change), and `integer` (blocks decimal entry and the `e`/`E` exponent keys).
+
+```jsx
+{
+ key: "attendees",
+ label: "Attendees",
+ operators: [OPERATORS.GREATER, OPERATORS.LESS],
+ values: {
+ type: "number",
+ props: { min: 0, integer: true }
+ }
+}
+```
+
+# async select values
+
+For criteria backed by a remote search-as-you-type lookup, use `type: "asyncSelect"` (generic) or one of the preset entity types: `type: "speaker"` or `type: "company"`. These render a MUI `Autocomplete` and **always store the full selected option object** (or array of objects, when `multiple`) as `{ value, label, raw }` — `raw` is the untouched API entity, so a `customParser` can pull whatever field it needs (`.raw.id`, `.raw.name`, etc.).
+
+**`customParser` is mandatory for these types** — there is no default/shipped parser, even for `speaker`/`company`. The default `parseFilter` only knows how to serialize plain scalars; an object run through it produces `criteria==[object Object]`. If a criteria uses an async type and has no `customParser`, `GridFilter` logs a `console.error` to catch the mistake early — it does not throw or block applying filters.
+
+Common props (generic `asyncSelect`):
+
+| Prop | Description |
+| --- | --- |
+| `queryFunction(input, callback)` | required — fetches results; `callback(rawItems)` receives a plain array of raw API objects |
+| `formatOption(item) => {value, label}` | maps a raw item to display shape (default: `{value: item.id, label: item.name}`) |
+| `multiple` | allow selecting more than one option |
+| `debounceWait` | debounce delay in ms before querying on typing (default: `DEBOUNCE_WAIT_250`) |
+| `minSearchLength` | skip querying until the input reaches this length (default: `0`) |
+
+```jsx
+{
+ key: "created_by_company",
+ label: "Submitter Company",
+ operators: [OPERATORS.IS],
+ values: {
+ type: "company",
+ props: { multiple: true }
+ },
+ customParser: (f) => [
+ `created_by_company==${f.value.map((c) => escapeFilterValue(c.raw.name)).join("||")}`
+ ]
+}
+```
+
+`speaker` additionally accepts `summitId` (scopes the default query to a summit) and `company`/`speaker` both accept a `queryFunction` override for non-default scoped queries (e.g. `querySpeakerCompany`, `queryAllCompanies`):
+
+```jsx
+{
+ key: "speaker_id",
+ label: "Speaker",
+ operators: [OPERATORS.IS],
+ values: {
+ type: "speaker",
+ props: { summitId: currentSummit.id, multiple: true }
+ },
+ customParser: (f) => [
+ `speaker_id==${f.value.map((s) => s.value).join("||")}`
+ ]
+}
+```
+
# custom parser
For criteria that require non-standard API encoding, provide a `customParser` function on the criteria object. It receives the filter and must return an array of API filter strings. See the `selection_status` example in the usage section above.
diff --git a/src/components/mui/GridFilter/utils.js b/src/components/mui/GridFilter/utils.js
index 9ed88a98..2f3a4f91 100644
--- a/src/components/mui/GridFilter/utils.js
+++ b/src/components/mui/GridFilter/utils.js
@@ -27,7 +27,9 @@ export const OPERATORS = {
BETWEEN_STRICT: {
value: "()",
label: T.translate("grid_filter.operators.between_strict")
- }
+ },
+ BEFORE: { value: "<=", label: T.translate("grid_filter.operators.before") },
+ AFTER: { value: ">=", label: T.translate("grid_filter.operators.after") }
};
export const JOIN_OPERATORS = {
@@ -40,4 +42,9 @@ export const EMPTY_FILTER = {
operator: null,
value: null,
id: "new"
-};
\ No newline at end of file
+};
+
+// ValueInput types whose stored value is an option object (or array of
+// option objects), not a plain scalar — these always require a customParser
+// since the default parseFilter only knows how to serialize scalars.
+export const ASYNC_VALUE_TYPES = ["asyncSelect", "speaker", "company"];
\ No newline at end of file
diff --git a/src/components/mui/__tests__/GridFilter.test.jsx b/src/components/mui/__tests__/GridFilter.test.jsx
index 4f64c719..df2c36a8 100644
--- a/src/components/mui/__tests__/GridFilter.test.jsx
+++ b/src/components/mui/__tests__/GridFilter.test.jsx
@@ -9,12 +9,18 @@ import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import { GridFilter, OPERATORS, JOIN_OPERATORS, SAVE_FILTERS } from "../GridFilter";
import Filter from "../GridFilter/components/Filter";
+import { querySpeakers, queryCompanies } from "../../../utils/query-actions";
jest.mock("i18n-react/dist/i18n-react", () => ({
__esModule: true,
default: { translate: (key) => key }
}));
+jest.mock("../../../utils/query-actions", () => ({
+ querySpeakers: jest.fn((summitId, input, callback) => callback([])),
+ queryCompanies: jest.fn((input, callback) => callback([]))
+}));
+
// MUI Fade never fires its exit callback in jsdom (no CSS transition events),
// so dialogs stay in the DOM after close. This makes it synchronous.
jest.mock(
@@ -24,6 +30,36 @@ jest.mock(
inProp ? children : null
);
+jest.mock("@mui/x-date-pickers/LocalizationProvider", () => ({
+ LocalizationProvider: ({ children }) => children
+}));
+
+jest.mock("@mui/x-date-pickers/AdapterMoment", () => ({
+ AdapterMoment: function AdapterMoment() {}
+}));
+
+// stub DateTimePicker as a plain input; clicking it fires onChange with a
+// fixed moment so tests can assert the resulting unix timestamp.
+jest.mock("@mui/x-date-pickers/DateTimePicker", () => ({
+ DateTimePicker: ({ value, onChange, views, format, slotProps }) => {
+ const React = require("react");
+ const moment = require("moment-timezone");
+ const tf = slotProps?.textField || {};
+ return (
+ {}}
+ onClick={() => onChange(moment.unix(1700000000))}
+ />
+ );
+ }
+}));
+
const mockStore = configureStore([thunk]);
const makeStore = (filters = []) =>
@@ -235,6 +271,315 @@ describe("Filter - options as a function", () => {
});
});
+describe("Filter - datetime value type", () => {
+ const dateCriteria = {
+ key: "created",
+ label: "Created",
+ operators: [OPERATORS.BEFORE, OPERATORS.AFTER],
+ values: {
+ type: "datetime",
+ props: { mode: "date" }
+ }
+ };
+
+ const renderFilter = (value) =>
+ render(
+
+ );
+
+ test("renders the picker with mode-specific views and format", () => {
+ renderFilter({ id: "0", criteria: "created", operator: "<=", value: null });
+
+ const input = screen.getByTestId("test-value");
+ expect(input).toHaveAttribute("data-views", "year,month,day");
+ expect(input).toHaveAttribute("data-format", "MM/DD/YYYY");
+ });
+
+ test("propagates the selected date as a unix timestamp", () => {
+ const onChange = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByTestId("test-value"));
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ value: 1700000000 })
+ );
+ });
+
+ test("uses the default translated placeholder when none is provided", () => {
+ renderFilter({ id: "0", criteria: "created", operator: "<=", value: null });
+
+ expect(screen.getByTestId("test-value")).toHaveAttribute(
+ "placeholder",
+ "grid_filter.placeholders.date"
+ );
+ });
+});
+
+describe("Filter - number value type", () => {
+ const numberCriteria = {
+ key: "attendees",
+ label: "Attendees",
+ operators: [OPERATORS.GREATER, OPERATORS.LESS],
+ values: {
+ type: "number",
+ props: { min: 0, max: 100, integer: true }
+ }
+ };
+
+ const renderFilter = (value, onChange = jest.fn()) => {
+ render(
+
+ );
+ return { onChange, input: screen.getByRole("spinbutton") };
+ };
+
+ test("renders with min/max/step attributes from props", () => {
+ const { input } = renderFilter({
+ id: "0",
+ criteria: "attendees",
+ operator: ">",
+ value: null
+ });
+
+ expect(input).toHaveAttribute("min", "0");
+ expect(input).toHaveAttribute("max", "100");
+ expect(input).toHaveAttribute("step", "1");
+ });
+
+ test("propagates a typed value as a Number", () => {
+ const { input, onChange } = renderFilter({
+ id: "0",
+ criteria: "attendees",
+ operator: ">",
+ value: null
+ });
+
+ fireEvent.change(input, { target: { value: "42" } });
+
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: 42 }));
+ });
+
+ test("clamps the value to max", () => {
+ const { input, onChange } = renderFilter({
+ id: "0",
+ criteria: "attendees",
+ operator: ">",
+ value: null
+ });
+
+ fireEvent.change(input, { target: { value: "500" } });
+
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: 100 }));
+ });
+
+ test("clears to null when the input is emptied", () => {
+ const { input, onChange } = renderFilter({
+ id: "0",
+ criteria: "attendees",
+ operator: ">",
+ value: 10
+ });
+
+ fireEvent.change(input, { target: { value: "" } });
+
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: null }));
+ });
+
+ test("uses the default translated placeholder when none is provided", () => {
+ const { input } = renderFilter({
+ id: "0",
+ criteria: "attendees",
+ operator: ">",
+ value: null
+ });
+
+ expect(input).toHaveAttribute("placeholder", "grid_filter.placeholders.number");
+ });
+});
+
+describe("Filter - asyncSelect value type", () => {
+ const makeCriteria = (queryFunction, props = {}) => [
+ {
+ key: "tag",
+ label: "Tag",
+ operators: [OPERATORS.IS],
+ values: {
+ type: "asyncSelect",
+ props: { queryFunction, multiple: true, ...props }
+ }
+ }
+ ];
+
+ test("calls queryFunction on mount with an empty search term", () => {
+ const queryFunction = jest.fn((input, callback) => callback([]));
+ render(
+
+ );
+ expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function));
+ });
+
+ test("renders a preselected value's label without re-fetching it", () => {
+ const queryFunction = jest.fn((input, callback) => callback([]));
+ render(
+
+ );
+ expect(screen.getByText("Keynote")).toBeInTheDocument();
+ });
+
+ test("uses the default translated placeholder when none is provided", () => {
+ const queryFunction = jest.fn((input, callback) => callback([]));
+ render(
+
+ );
+ expect(
+ screen.getByPlaceholderText("grid_filter.placeholders.async")
+ ).toBeInTheDocument();
+ });
+});
+
+describe("Filter - speaker value type", () => {
+ beforeEach(() => querySpeakers.mockClear());
+
+ const speakerCriteria = (props = {}) => [
+ {
+ key: "speaker_id",
+ label: "Speaker",
+ operators: [OPERATORS.IS],
+ values: { type: "speaker", props: { summitId: 42, multiple: true, ...props } }
+ }
+ ];
+
+ const renderSpeakerFilter = (props) =>
+ render(
+
+ );
+
+ test("defaults queryFunction to querySpeakers scoped to summitId", () => {
+ renderSpeakerFilter();
+ expect(querySpeakers).toHaveBeenCalledWith(42, "", expect.any(Function));
+ });
+
+ test("a queryFunction override takes precedence over the summitId default", () => {
+ const queryFunction = jest.fn((input, callback) => callback([]));
+ renderSpeakerFilter({ queryFunction });
+ expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function));
+ expect(querySpeakers).not.toHaveBeenCalled();
+ });
+
+ test("uses the speaker-specific default placeholder, not the generic async one", () => {
+ renderSpeakerFilter();
+ expect(
+ screen.getByPlaceholderText("grid_filter.placeholders.speaker")
+ ).toBeInTheDocument();
+ });
+});
+
+describe("Filter - company value type", () => {
+ beforeEach(() => queryCompanies.mockClear());
+
+ const companyCriteria = (props = {}) => [
+ {
+ key: "company_id",
+ label: "Company",
+ operators: [OPERATORS.IS],
+ values: { type: "company", props: { multiple: true, ...props } }
+ }
+ ];
+
+ const renderCompanyFilter = (props) =>
+ render(
+
+ );
+
+ test("defaults queryFunction to queryCompanies", () => {
+ renderCompanyFilter();
+ expect(queryCompanies).toHaveBeenCalledWith("", expect.any(Function));
+ });
+
+ test("a queryFunction override takes precedence over the default", () => {
+ const queryFunction = jest.fn((input, callback) => callback([]));
+ renderCompanyFilter({ queryFunction });
+ expect(queryFunction).toHaveBeenCalledWith("", expect.any(Function));
+ expect(queryCompanies).not.toHaveBeenCalled();
+ });
+
+ test("uses the company-specific default placeholder, not the generic async one", () => {
+ renderCompanyFilter();
+ expect(
+ screen.getByPlaceholderText("grid_filter.placeholders.company")
+ ).toBeInTheDocument();
+ });
+
+ test("a criteria-provided placeholder overrides the default", () => {
+ renderCompanyFilter({ placeholder: "Custom placeholder" });
+ expect(screen.getByPlaceholderText("Custom placeholder")).toBeInTheDocument();
+ });
+});
+
// ─── parseFilter / handleSubmit ──────────────────────────────────────────────
//
// These are private closures; exercised by seeding the Redux store with filter
@@ -299,6 +644,48 @@ describe("GridFilter – parseFilter / handleSubmit", () => {
expect(filters[0].parsed).toEqual(["track==1||2||3"]);
});
+ test("datetime value → unix timestamp in the API string", () => {
+ const c = [
+ {
+ key: "created",
+ label: "Created",
+ operators: [OPERATORS.BEFORE],
+ values: { type: "datetime", props: { mode: "date" } }
+ }
+ ];
+ const { onApply } = renderWithFilters(
+ [{ criteria: "created", operator: "<=", value: 1700000000 }],
+ { criterias: c }
+ );
+
+ openFilterDialog();
+ applyFilters();
+
+ const [filters] = onApply.mock.calls[0];
+ expect(filters[0].parsed).toEqual(["created<=1700000000"]);
+ });
+
+ test("number value → unquoted numeric string in the API string", () => {
+ const c = [
+ {
+ key: "attendees",
+ label: "Attendees",
+ operators: [OPERATORS.GREATER],
+ values: { type: "number", props: { min: 0 } }
+ }
+ ];
+ const { onApply } = renderWithFilters(
+ [{ criteria: "attendees", operator: ">", value: 10 }],
+ { criterias: c }
+ );
+
+ openFilterDialog();
+ applyFilters();
+
+ const [filters] = onApply.mock.calls[0];
+ expect(filters[0].parsed).toEqual(["attendees>10"]);
+ });
+
test("delegates to customParser and uses its return value as parsed", () => {
const customParser = jest.fn().mockReturnValue(["custom==result"]);
const c = [
@@ -384,4 +771,61 @@ describe("GridFilter – parseFilter / handleSubmit", () => {
);
});
});
+
+ describe("async value types require customParser", () => {
+ const companyCriteria = (customParser) => [
+ {
+ key: "created_by_company",
+ label: "Submitter Company",
+ operators: [OPERATORS.IS],
+ values: {
+ type: "company",
+ props: { multiple: true, queryFunction: jest.fn((i, cb) => cb([])) }
+ },
+ ...(customParser ? { customParser } : {})
+ }
+ ];
+
+ const companyValue = [
+ { value: 1, label: "Acme", raw: { id: 1, name: "Acme" } }
+ ];
+
+ afterEach(() => jest.restoreAllMocks());
+
+ test("logs a console.error when no customParser is provided", () => {
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ renderWithFilters(
+ [{ criteria: "created_by_company", operator: "==", value: companyValue }],
+ { criterias: companyCriteria() }
+ );
+
+ openFilterDialog();
+ applyFilters();
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'criteria "created_by_company" uses async value type "company"'
+ )
+ );
+ });
+
+ test("does not log when a customParser is provided, and uses its return value", () => {
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ const customParser = (f) => [
+ `created_by_company==${f.value.map((c) => c.raw.name).join("||")}`
+ ];
+
+ const { onApply } = renderWithFilters(
+ [{ criteria: "created_by_company", operator: "==", value: companyValue }],
+ { criterias: companyCriteria(customParser) }
+ );
+
+ openFilterDialog();
+ applyFilters();
+
+ expect(errorSpy).not.toHaveBeenCalled();
+ const [filters] = onApply.mock.calls[0];
+ expect(filters[0].parsed).toEqual(["created_by_company==Acme"]);
+ });
+ });
});
diff --git a/src/components/mui/__tests__/useGridFilter.test.jsx b/src/components/mui/__tests__/useGridFilter.test.jsx
index e9c88e7b..9a3b2351 100644
--- a/src/components/mui/__tests__/useGridFilter.test.jsx
+++ b/src/components/mui/__tests__/useGridFilter.test.jsx
@@ -172,3 +172,53 @@ describe("useGridFilter – resetFilters", () => {
});
});
});
+
+// ─── setFilters ────────────────────────────────────────────────────────────
+
+describe("useGridFilter – setFilters", () => {
+ test("dispatches SAVE_FILTERS with the hook's id and the given filters/joinOperator", () => {
+ const store = storeWith("f", []);
+ const savedCriteria = [
+ { id: "track_id-0", criteria: "track_id", operator: "==", value: [36333], parsed: ["track_id==36333"] }
+ ];
+
+ const { current } = renderHookWithStore(() => useGridFilter("f"), store);
+ current.setFilters(savedCriteria, JOIN_OPERATORS.ANY);
+
+ const actions = store.getActions();
+ expect(actions).toHaveLength(1);
+ expect(actions[0]).toMatchObject({
+ type: SAVE_FILTERS,
+ payload: { id: "f", filters: savedCriteria, joinOperator: JOIN_OPERATORS.ANY }
+ });
+ });
+
+ test("defaults joinOperator to JOIN_OPERATORS.ALL when omitted", () => {
+ const store = storeWith("f", []);
+ const savedCriteria = [
+ { criteria: "selection_status", operator: "==", value: ["accepted"], parsed: ["selection_status==accepted"] }
+ ];
+
+ const { current } = renderHookWithStore(() => useGridFilter("f"), store);
+ current.setFilters(savedCriteria);
+
+ const actions = store.getActions();
+ expect(actions[0]).toMatchObject({
+ type: SAVE_FILTERS,
+ payload: { id: "f", filters: savedCriteria, joinOperator: JOIN_OPERATORS.ALL }
+ });
+ });
+
+ test("defaults filters to [] when called with no arguments", () => {
+ const store = storeWith("f", []);
+
+ const { current } = renderHookWithStore(() => useGridFilter("f"), store);
+ current.setFilters();
+
+ const actions = store.getActions();
+ expect(actions[0]).toMatchObject({
+ type: SAVE_FILTERS,
+ payload: { id: "f", filters: [], joinOperator: JOIN_OPERATORS.ALL }
+ });
+ });
+});
diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js
index 3a9e75c9..dcfeaa45 100644
--- a/src/components/mui/editable-table/mui-table-editable.js
+++ b/src/components/mui/editable-table/mui-table-editable.js
@@ -20,7 +20,6 @@ import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
-import TablePagination from "@mui/material/TablePagination";
import TableSortLabel from "@mui/material/TableSortLabel";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
@@ -30,12 +29,8 @@ import DeleteIcon from "@mui/icons-material/Delete";
import { visuallyHidden } from "@mui/utils";
import styles from "./mui-table-editable.module.less";
-import {
- DEFAULT_PER_PAGE,
- FIFTY_PER_PAGE,
- TWENTY_PER_PAGE
-} from "../../../utils/constants";
import showConfirmDialog from "../showConfirmDialog";
+import CustomTablePagination from "../table/CustomTablePagination";
const ARCHIVED_CELL_SX = {
backgroundColor: "background.light",
@@ -162,24 +157,6 @@ const MuiTableEditable = ({
// State to track which cell is currently being edited
const [editingCell, setEditingCell] = React.useState(null);
- const handleChangePage = (_, newPage) => {
- onPageChange(newPage + 1);
- };
-
- const handleChangeRowsPerPage = (ev) => {
- onPerPageChange(ev.target.value);
- };
-
- const basePerPageOptions = [
- DEFAULT_PER_PAGE,
- TWENTY_PER_PAGE,
- FIFTY_PER_PAGE
- ];
-
- const customPerPageOptions = basePerPageOptions.includes(perPage)
- ? basePerPageOptions
- : [...basePerPageOptions, perPage].sort((a, b) => a - b);
-
const { sortCol, sortDir } = options;
const getArchivedCellSx = (row) =>
@@ -386,28 +363,15 @@ const MuiTableEditable = ({
-
+ {perPage && currentPage && onPageChange && (
+
+ )}
);
diff --git a/src/components/mui/sortable-table/mui-table-sortable.js b/src/components/mui/sortable-table/mui-table-sortable.js
index ade4f26d..866499e3 100644
--- a/src/components/mui/sortable-table/mui-table-sortable.js
+++ b/src/components/mui/sortable-table/mui-table-sortable.js
@@ -19,7 +19,6 @@ import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
-import TablePagination from "@mui/material/TablePagination";
import TableSortLabel from "@mui/material/TableSortLabel";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
@@ -32,12 +31,8 @@ import { visuallyHidden } from "@mui/utils";
import styles from "./styles.module.less";
-import {
- DEFAULT_PER_PAGE,
- FIFTY_PER_PAGE,
- TWENTY_PER_PAGE
-} from "../../../utils/constants";
import showConfirmDialog from "../showConfirmDialog";
+import CustomTablePagination from "../table/CustomTablePagination";
const MuiTableSortable = ({
columns = [],
@@ -58,24 +53,6 @@ const MuiTableSortable = ({
idKey = "id",
updateOrderKey = "order"
}) => {
- const handleChangePage = (_, newPage) => {
- onPageChange(newPage + 1);
- };
-
- const handleChangeRowsPerPage = (ev) => {
- onPerPageChange(ev.target.value);
- };
-
- const basePerPageOptions = [
- DEFAULT_PER_PAGE,
- TWENTY_PER_PAGE,
- FIFTY_PER_PAGE
- ];
-
- const customPerPageOptions = basePerPageOptions.includes(perPage)
- ? basePerPageOptions
- : [...basePerPageOptions, perPage].sort((a, b) => a - b);
-
const { sortCol, sortDir } = options;
const handleDragEnd = (result) => {
@@ -286,28 +263,13 @@ const MuiTableSortable = ({
{/* PAGINATION */}
- {onPerPageChange && onPageChange && (
-
)}
diff --git a/src/components/mui/table/CustomTablePagination.js b/src/components/mui/table/CustomTablePagination.js
new file mode 100644
index 00000000..095dbe16
--- /dev/null
+++ b/src/components/mui/table/CustomTablePagination.js
@@ -0,0 +1,87 @@
+/**
+ * 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 * as React from "react";
+import T from "i18n-react/dist/i18n-react";
+import TablePagination from "@mui/material/TablePagination";
+import PropTypes from "prop-types";
+import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../utils/constants";
+
+const PAGINATION_SX = {
+ ".MuiTablePagination-toolbar": {
+ alignItems: "baseline",
+ marginTop: "1.6rem"
+ },
+ ".MuiTablePagination-selectLabel": {
+ color: "rgba(0, 0, 0, 0.6)",
+ fontSize: "12px",
+ fontWeight: "normal"
+ },
+ ".MuiTablePagination-select": {
+ color: "rgba(0, 0, 0, 0.6)",
+ fontSize: "12px",
+ fontWeight: "normal"
+ },
+ ".MuiTablePagination-spacer": {
+ display: "none"
+ },
+ ".MuiTablePagination-displayedRows": {
+ marginLeft: "auto"
+ }
+};
+
+const BASE_PER_PAGE_OPTIONS = [DEFAULT_PER_PAGE, TWENTY_PER_PAGE, FIFTY_PER_PAGE];
+
+const CustomTablePagination = ({ totalRows, perPage, currentPage, onPageChange, onPerPageChange }) => {
+ const initialPerPage = React.useRef(perPage);
+
+ let perPageOptions = BASE_PER_PAGE_OPTIONS.includes(initialPerPage.current)
+ ? BASE_PER_PAGE_OPTIONS
+ : [...BASE_PER_PAGE_OPTIONS, initialPerPage.current].sort((a, b) => a - b);
+
+ if (!onPerPageChange) {
+ perPageOptions = [initialPerPage.current];
+ }
+
+ const handlePageChange = (_, newPage) => {
+ onPageChange(newPage + 1);
+ };
+
+ const handleRowsPerPageChange = (ev) => {
+ onPerPageChange(ev.target.value);
+ };
+
+ return (
+
+ );
+};
+
+CustomTablePagination.propTypes = {
+ totalRows: PropTypes.number,
+ perPage: PropTypes.number.isRequired,
+ currentPage: PropTypes.number.isRequired,
+ onPageChange: PropTypes.func.isRequired,
+ onPerPageChange: PropTypes.func
+};
+
+export default CustomTablePagination;
diff --git a/src/components/mui/table/mui-table.js b/src/components/mui/table/mui-table.js
index 136bf35e..51cbeedb 100644
--- a/src/components/mui/table/mui-table.js
+++ b/src/components/mui/table/mui-table.js
@@ -24,7 +24,6 @@ import {
TableCell,
TableContainer,
TableHead,
- TablePagination,
TableRow,
TableSortLabel
} from "@mui/material";
@@ -33,11 +32,11 @@ import DeleteIcon from "@mui/icons-material/Delete";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import {visuallyHidden} from "@mui/utils";
-import {DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE} from "../../../utils/constants";
import showConfirmDialog from "../showConfirmDialog";
import styles from "./mui-table.module.less";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import PropTypes from "prop-types";
+import CustomTablePagination from "./CustomTablePagination";
const ARCHIVED_CELL_SX = {
backgroundColor: "background.light",
@@ -78,31 +77,6 @@ const MuiTable = ({
const totalColumnsCount =
columns.length + (onEdit ? 1 : 0) + (onDelete ? 1 : 0) + (onArchive ? 1 : 0) + (onSelect ? 1 : 0);
- const handleChangePage = (_, newPage) => {
- onPageChange(newPage + 1);
- };
-
- const handleChangeRowsPerPage = (ev) => {
- onPerPageChange(ev.target.value);
- };
-
- const basePerPageOptions = [
- DEFAULT_PER_PAGE,
- TWENTY_PER_PAGE,
- FIFTY_PER_PAGE
- ];
-
- const initialPerPage = React.useRef(perPage);
-
- let customPerPageOptions = basePerPageOptions.includes(initialPerPage.current)
- ? basePerPageOptions
- : [...basePerPageOptions, initialPerPage.current].sort((a, b) => a - b);
-
- // remove per page selection if no action passed
- if (!onPerPageChange) {
- customPerPageOptions = [initialPerPage.current];
- }
-
const {sortCol, sortDir} = options;
const getDisabledSx = (row) =>
@@ -332,38 +306,13 @@ const MuiTable = ({
{/* PAGINATION */}
- {perPage && currentPage && (
-
)}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index b90b191e..6da27fe8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -183,7 +183,16 @@
"between": "between",
"between_strict": "between strict",
"all": "all",
- "any": "any"
+ "any": "any",
+ "before": "before",
+ "after": "after"
+ },
+ "placeholders": {
+ "number": "Type a number",
+ "date": "Select a date",
+ "speaker": "Type a speaker name",
+ "company": "Type a company name",
+ "async": "Type and select"
}
}
}
diff --git a/webpack.common.js b/webpack.common.js
index fddd1a4e..cf183e12 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -104,7 +104,9 @@ module.exports = {
'components/mui/infinite-table': './src/components/mui/infinite-table/index.js',
'components/mui/editable-table': './src/components/mui/editable-table/mui-table-editable.js',
'components/mui/sortable-table': './src/components/mui/sortable-table/mui-table-sortable.js',
+ 'components/mui/bulk-edit-table': './src/components/mui/BulkEditTable/index.js',
'components/mui/table': './src/components/mui/table/mui-table.js',
+ 'components/mui/table/custom-table-pagination': './src/components/mui/table/CustomTablePagination.js',
'components/mui/table/extra-rows': './src/components/mui/table/extra-rows/index.js',
'components/mui/formik-inputs/additional-input': './src/components/mui/formik-inputs/additional-input/additional-input.js',
'components/mui/formik-inputs/additional-input-list': './src/components/mui/formik-inputs/additional-input/additional-input-list.js',