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( + { + 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',