diff --git a/src/components/index.js b/src/components/index.js index 127092d3..ea54b8e6 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/AsyncSelectInput.jsx b/src/components/mui/AsyncSelectInput.jsx new file mode 100644 index 00000000..3bc1d34e --- /dev/null +++ b/src/components/mui/AsyncSelectInput.jsx @@ -0,0 +1,180 @@ +/** + * 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 { ASYNC_SELECT_SAFETY_TIMEOUT, 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); + const mountedRef = useRef(false); + const requestIdRef = useRef(0); + // Backstops a queryFunction whose callback never fires (e.g. a non-404 + // HTTP error swallowed upstream) so the spinner doesn't spin forever. + const safetyTimeoutRef = 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 (minSearchLength > 0 && (!searchTerm || searchTerm.length < minSearchLength)) { + setOptions([]); + return; + } + // Capture the ID for this request so the callback can discard stale ones. + requestIdRef.current += 1; + const thisRequestId = requestIdRef.current; + setLoading(true); + + if (safetyTimeoutRef.current) clearTimeout(safetyTimeoutRef.current); + safetyTimeoutRef.current = setTimeout(() => { + if (mountedRef.current && thisRequestId === requestIdRef.current) { + setLoading(false); + } + }, ASYNC_SELECT_SAFETY_TIMEOUT); + + queryFunction(searchTerm, (rawResults) => { + if (!mountedRef.current || thisRequestId !== requestIdRef.current) return; + clearTimeout(safetyTimeoutRef.current); + // queryFunction implementations may invoke the callback with something + // other than an array (e.g. an Error on auth failure), so guard here + // rather than assume the shape. + const items = Array.isArray(rawResults) ? rawResults : []; + setOptions(items.map((item) => ({ ...formatOption(item), raw: item }))); + setLoading(false); + }); + }; + + useEffect(() => { + mountedRef.current = true; + fetchOptions(""); + return () => { + mountedRef.current = false; + if (debounceRef.current) clearTimeout(debounceRef.current); + if (safetyTimeoutRef.current) clearTimeout(safetyTimeoutRef.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("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/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js new file mode 100644 index 00000000..4da11ea2 --- /dev/null +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -0,0 +1,241 @@ +/** + * 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 } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +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"; +import showConfirmDialog from "../showConfirmDialog"; + +const BulkEditTable = ({ + options, + columns, + data, + onSort, + onUpdate, + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + idKey, + onEdit, + onDelete, + getName, + deleteDialogTitle, + deleteDialogBody, + deleteDialogConfirmText, + confirmButtonColor +}) => { + const { + selectedRows, + isSelected, + toggleRow, + isAllSelected, + toggleAll, + editField, + editEnabled, + enterEditMode, + cancel, + reset + } = useRowSelection(idKey); + + const dataIds = data.map((row) => row[idKey]).join(","); + + // reset selection/edit state whenever the set of rows shown changes + // (pagination, filtering, sorting, search, etc.) + useEffect(() => { + reset(); + }, [dataIds]); + + const getSortDir = (columnKey) => + columnKey === options.sortCol ? options.sortDir : null; + + const handleUpdateEvents = (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + Promise.resolve(onUpdate(selectedRows)) + .then(() => reset()) + .catch((error) => { + console.error("Error updating events:", error); + }); + }; + + // Wraps the onDelete prop with the same confirm-before-delete UX other mui + // tables already have (see mui-table.js's handleDelete), so consumers don't + // need to wire up their own dialog. Matches mui-table.js's contract: + // onDelete is called with the row's id, not the full row. + const handleDelete = async (item) => { + const isConfirmed = await showConfirmDialog({ + title: deleteDialogTitle || T.translate("general.are_you_sure"), + text: + typeof deleteDialogBody === "function" + ? deleteDialogBody(getName(item)) + : deleteDialogBody || + `${T.translate("general.row_remove_warning")} ${getName(item)}`, + iconType: "warning", + confirmButtonColor: confirmButtonColor || "error", + confirmButtonText: + deleteDialogConfirmText || T.translate("general.yes_delete") + }); + + if (isConfirmed) { + onDelete(item[idKey]); + } + }; + + return ( + + 0} + onEdit={enterEditMode} + onApply={handleUpdateEvents} + onCancel={cancel} + /> + + + + + + + { + if (!editEnabled) toggleAll(data); + }} + disabled={editEnabled} + slotProps={{ input: { "aria-label": "select all" } }} + /> + + {columns.map((col, i) => { + const sortable = !!col.sortable; + const colWidth = col.width ?? ""; + + return ( + + {col.header ?? col.label ?? col.value} + + ); + })} + {(onEdit || onDelete) && ( + + {options.actionsHeader || " "} + + )} + + + + {columns.length > 0 && + data.map((row) => ( + r[idKey] === row[idKey]) || row} + onToggle={() => toggleRow(row)} + onFieldChange={(key, value) => + editField(row[idKey], key, value) + } + columns={columns} + onEdit={onEdit} + onDelete={onDelete ? handleDelete : null} + /> + ))} + +
+
+ {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, + idKey: PropTypes.string, + totalRows: PropTypes.number, + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func, + onEdit: PropTypes.func, + onDelete: PropTypes.func, + getName: PropTypes.func, + deleteDialogTitle: PropTypes.string, + deleteDialogBody: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + deleteDialogConfirmText: PropTypes.string, + confirmButtonColor: PropTypes.string +}; + +BulkEditTable.defaultProps = { + idKey: "id", + onEdit: null, + onDelete: null, + getName: (item) => item.name, + deleteDialogTitle: null, + deleteDialogBody: null, + deleteDialogConfirmText: null, + confirmButtonColor: null +}; + +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..aa9a182f --- /dev/null +++ b/src/components/mui/BulkEditTable/BulkEditTable.module.less @@ -0,0 +1,71 @@ +/** + * 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. + * */ + +.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..335f874c --- /dev/null +++ b/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js @@ -0,0 +1,113 @@ +/** + * 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 userEvent from "@testing-library/user-event"; +import { + act, + fireEvent, + render, + screen, + waitFor +} from "@testing-library/react"; +import BulkEditTable from "../BulkEditTable"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +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("bulk_edit_table.edit_selected")); + await act(async () => { + await user.click(screen.getByText("bulk_edit_table.apply_changes")); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 1 })]) + ); + }); + }); + + test("changing the selection while editing preserves in-progress edits", async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(() => Promise.resolve()); + const editableColumns = [ + { columnKey: "id", value: "id", sortable: true }, + { columnKey: "title", value: "title", sortable: true, editableField: true } + ]; + + render( + + ); + + const checkboxes = screen.getAllByRole("checkbox"); + + // select row 1 and enter edit mode + await user.click(checkboxes[1]); + await user.click(screen.getByText("bulk_edit_table.edit_selected")); + + // type an edit into row 1's editable title cell + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Edited title" } + }); + + // widen the selection via "select all" while still editing + fireEvent.click(checkboxes[0]); + + await act(async () => { + await user.click(screen.getByText("bulk_edit_table.apply_changes")); + }); + + await waitFor(() => { + // selection is frozen while editing, so "select all" is a no-op and + // the in-progress edit on row 1 survives to onUpdate + expect(onUpdate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 1, title: "Edited title" }) + ]) + ); + }); + }); +}); diff --git a/src/components/mui/BulkEditTable/components/Cell.js b/src/components/mui/BulkEditTable/components/Cell.js new file mode 100644 index 00000000..802f781e --- /dev/null +++ b/src/components/mui/BulkEditTable/components/Cell.js @@ -0,0 +1,68 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import TextField from "@mui/material/TextField"; +import T from "i18n-react/dist/i18n-react"; + +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: (ev) => onChange({ target: { value: ev.target.value, id: col.columnKey } }), + 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..dd808c81 --- /dev/null +++ b/src/components/mui/BulkEditTable/components/Heading.js @@ -0,0 +1,77 @@ +/** + * 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 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("bulk_edit_table.sorted_desc") + : T.translate("bulk_edit_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..dcd5ee7d --- /dev/null +++ b/src/components/mui/BulkEditTable/components/Row.js @@ -0,0 +1,145 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import PropTypes from "prop-types"; +import Box 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, + onEdit, + onDelete, + idKey + } = props; + + const isEditingRow = isSelected && editEnabled; + + const onRowChange = (ev) => { + const { value, id } = ev.target; + onFieldChange(id, value); + }; + + return ( + + + { + if (!editEnabled) onToggle(); + }} + disabled={editEnabled} + slotProps={{ input: { "aria-label": `Select row ${row[idKey]}` } }} + /> + + {columns.map((col) => ( + + + + ))} + {(onEdit || onDelete) && ( + + + {onEdit && ( + onEdit(row)} + sx={{ padding: 0 }} + aria-label={`Edit row ${row[idKey]}`} + > + + + )} + {onDelete && ( + onDelete(row)} + sx={{ padding: 0 }} + aria-label={`Delete row ${row[idKey]}`} + > + + + )} + + + )} + + ); +}; + +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, + onEdit: PropTypes.func, + onDelete: PropTypes.func, + idKey: PropTypes.string +}; + +Row.defaultProps = { + idKey: "id", + onEdit: null, + onDelete: null +}; + +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..39d40485 --- /dev/null +++ b/src/components/mui/BulkEditTable/components/Toolbar.js @@ -0,0 +1,47 @@ +/** + * 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 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..56f96cfc --- /dev/null +++ b/src/components/mui/BulkEditTable/hooks/useRowSelection.js @@ -0,0 +1,66 @@ +/** + * 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 { useState } from "react"; + +const useRowSelection = (idKey = "id") => { + const [selectedRows, setSelectedRows] = useState([]); + const [editEnabled, setEditEnabled] = useState(false); + + const isSelected = (rowId) => selectedRows.some((row) => row[idKey] === rowId); + + const toggleRow = (row) => { + setSelectedRows((current) => + current.some((r) => r[idKey] === row[idKey]) + ? current.filter((r) => r[idKey] !== row[idKey]) + : [...current, row] + ); + }; + + const isAllSelected = (rows) => + rows.length > 0 && rows.every((row) => isSelected(row[idKey])); + + const toggleAll = (rows) => { + setSelectedRows((current) => + rows.length > 0 && rows.every((row) => current.some((r) => r[idKey] === row[idKey])) + ? [] + : rows + ); + }; + + const editField = (rowId, key, value) => { + setSelectedRows((current) => + current.map((row) => (row[idKey] === 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..10215c87 --- /dev/null +++ b/src/components/mui/BulkEditTable/index.js @@ -0,0 +1,16 @@ +/** + * 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 BulkEditTable from "./BulkEditTable"; + +export default BulkEditTable; diff --git a/src/components/mui/CompanySelectInput.jsx b/src/components/mui/CompanySelectInput.jsx new file mode 100644 index 00000000..dce13305 --- /dev/null +++ b/src/components/mui/CompanySelectInput.jsx @@ -0,0 +1,45 @@ +/** + * 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 { queryCompaniesRaw } from "../../utils/query-actions"; + +const defaultFormatOption = (company) => ({ + value: company.id, + label: company.name +}); + +const CompanySelectInput = ({ queryFunction, placeholder, ...rest }) => ( + +); + +CompanySelectInput.propTypes = { + queryFunction: PropTypes.func, + formatOption: PropTypes.func, + placeholder: PropTypes.string +}; + +CompanySelectInput.defaultProps = { + queryFunction: null, + formatOption: defaultFormatOption +}; + +export default CompanySelectInput; diff --git a/src/components/mui/DateTimeInput.jsx b/src/components/mui/DateTimeInput.jsx new file mode 100644 index 00000000..2ffea2cb --- /dev/null +++ b/src/components/mui/DateTimeInput.jsx @@ -0,0 +1,122 @@ +/** + * 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, + timezone, + onChange, + ...rest +}) => { + // value is always a unix timestamp, which carries no timezone of its own — + // moment.unix() alone renders it in the browser's local zone. Re-anchor it + // in `timezone` (the summit's zone, typically) so the picker displays and + // edits the same wall-clock time the summit sees, regardless of where the + // admin's browser happens to be. + const momentValue = value + ? timezone + ? moment.unix(value).tz(timezone) + : moment.unix(value) + : null; + const finalPlaceholder = + placeholder || T.translate("placeholders.date"); + + const handleChange = (newValue) => { + if (!newValue?.isValid()) { + onChange({ target: { value: null } }); + return; + } + // The picker has no notion of `timezone` — it hands back a plain + // wall-clock reading. Re-interpret that same reading as belonging to + // `timezone` (rather than the instant it'd represent in the browser's + // local zone) before converting to an epoch. + const zonedValue = timezone + ? moment.tz(newValue.format("YYYY-MM-DD HH:mm:ss"), timezone) + : newValue; + onChange({ target: { value: zonedValue.unix() } }); + }; + + 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, + // IANA zone name (e.g. a summit's time_zone_id) the value is displayed/ + // edited in. Omit to keep the browser's local zone (previous behavior). + timezone: PropTypes.string, + onChange: PropTypes.func.isRequired +}; + +DateTimeInput.defaultProps = { + value: null, + mode: "datetime", + label: "", + placeholder: "", + disabled: false, + timezone: null +}; + +export default DateTimeInput; diff --git a/src/components/mui/Dropdown/index.jsx b/src/components/mui/Dropdown/index.jsx index 96bdb834..2f68dde1 100644 --- a/src/components/mui/Dropdown/index.jsx +++ b/src/components/mui/Dropdown/index.jsx @@ -26,11 +26,16 @@ const Dropdown = ({ ...rest }) => { const finalPlaceholder = - placeholder || T.translate("general.select_an_option"); + placeholder || T.translate("placeholders.select"); + const normalizedOptions = options ?? []; return ( - {label && {label}} + {label && ( + + {label} + + )} {}} + onClick={() => onChange(moment.unix(1700000000))} + /> + ); + } +})); + const mockStore = configureStore([thunk]); const makeStore = (filters = []) => @@ -235,6 +271,329 @@ 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", + "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 = (initialValue, onChange = jest.fn()) => { + // Wrap in local state so onChange feeds back into a re-render, matching + // real usage where the parent re-renders Filter with the updated value. + // A static value prop would let React snap the controlled input back to + // blank between events, hiding blur-time clamping behavior. + const Wrapper = () => { + const [value, setValue] = React.useState(initialValue); + const handleChange = (updated) => { + onChange(updated); + setValue(updated); + }; + return ( + + ); + }; + 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 on blur", () => { + const { input, onChange } = renderFilter({ + id: "0", + criteria: "attendees", + operator: ">", + value: null + }); + + fireEvent.change(input, { target: { value: "500" } }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ value: 500 })); + + fireEvent.blur(input); + expect(onChange).toHaveBeenLastCalledWith(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", "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("placeholders.async") + ).toBeInTheDocument(); + }); +}); + +describe("Filter - speaker value type", () => { + beforeEach(() => querySpeakersRaw.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 querySpeakersRaw scoped to summitId", () => { + renderSpeakerFilter(); + expect(querySpeakersRaw).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(querySpeakersRaw).not.toHaveBeenCalled(); + }); + + test("uses the speaker-specific default placeholder, not the generic async one", () => { + renderSpeakerFilter(); + expect( + screen.getByPlaceholderText("placeholders.speaker") + ).toBeInTheDocument(); + }); +}); + +describe("Filter - company value type", () => { + beforeEach(() => queryCompaniesRaw.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 queryCompaniesRaw", () => { + renderCompanyFilter(); + expect(queryCompaniesRaw).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(queryCompaniesRaw).not.toHaveBeenCalled(); + }); + + test("uses the company-specific default placeholder, not the generic async one", () => { + renderCompanyFilter(); + expect( + screen.getByPlaceholderText("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 +658,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 +785,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.warn when no customParser is provided", () => { + const errorSpy = jest.spyOn(console, "warn").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, "warn").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__/NumberInput.test.jsx b/src/components/mui/__tests__/NumberInput.test.jsx new file mode 100644 index 00000000..706c5e1f --- /dev/null +++ b/src/components/mui/__tests__/NumberInput.test.jsx @@ -0,0 +1,69 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import NumberInput from "../NumberInput"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +describe("NumberInput", () => { + test("typing an intermediate value below min is not rewritten", () => { + const onChange = jest.fn(); + render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "1" } }); + expect(onChange).toHaveBeenLastCalledWith({ target: { value: 1 } }); + + fireEvent.change(input, { target: { value: "15" } }); + expect(onChange).toHaveBeenLastCalledWith({ target: { value: 15 } }); + }); + + test("min is enforced on blur", () => { + const onChange = jest.fn(); + render(); + + fireEvent.blur(screen.getByRole("spinbutton")); + expect(onChange).toHaveBeenLastCalledWith({ target: { value: 10 } }); + }); + + test("max is not clamped while typing, only on blur", () => { + const onChange = jest.fn(); + // Wrap in local state so onChange feeds back into a re-render, matching + // real usage. A static value prop would let React snap the controlled + // input back to blank between events, hiding blur-time clamping. + const Wrapper = () => { + const [value, setValue] = React.useState(null); + const handleChange = (e) => { + onChange(e); + setValue(e.target.value); + }; + return ( + + ); + }; + render(); + const input = screen.getByRole("spinbutton"); + + fireEvent.change(input, { target: { value: "150" } }); + expect(onChange).toHaveBeenLastCalledWith({ target: { value: 150 } }); + + fireEvent.blur(input); + expect(onChange).toHaveBeenLastCalledWith({ target: { value: 100 } }); + }); + + test("does not emit onChange on blur when the value is already within bounds", () => { + const onChange = jest.fn(); + render( + + ); + + fireEvent.blur(screen.getByRole("spinbutton")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/mui/__tests__/mui-formik-dropdown-radio.test.js b/src/components/mui/__tests__/mui-formik-dropdown-radio.test.js index 9918cbc7..5639962d 100644 --- a/src/components/mui/__tests__/mui-formik-dropdown-radio.test.js +++ b/src/components/mui/__tests__/mui-formik-dropdown-radio.test.js @@ -48,7 +48,7 @@ describe("MuiFormikDropdownRadio", () => { test("shows default i18n placeholder when no value selected", () => { renderWithFormik({}); - expect(screen.getByText("general.select_an_option")).toBeInTheDocument(); + expect(screen.getByText("placeholders.select")).toBeInTheDocument(); }); test("shows custom placeholder when provided", () => { diff --git a/src/components/mui/__tests__/useGridFilter.test.jsx b/src/components/mui/__tests__/useGridFilter.test.jsx index e9c88e7b..240e1ebc 100644 --- a/src/components/mui/__tests__/useGridFilter.test.jsx +++ b/src/components/mui/__tests__/useGridFilter.test.jsx @@ -172,3 +172,69 @@ 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 } + }); + }); + + test("falls back to JOIN_OPERATORS.ALL when given an invalid joinOperator", () => { + const store = storeWith("f", []); + const savedCriteria = [ + { criteria: "track_id", operator: "==", value: [36333], parsed: ["track_id==36333"] } + ]; + + const { current } = renderHookWithStore(() => useGridFilter("f"), store); + current.setFilters(savedCriteria, "garbage"); + + const actions = store.getActions(); + expect(actions[0]).toMatchObject({ + type: SAVE_FILTERS, + payload: { id: "f", filters: savedCriteria, 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 2860019d..39441db4 100644 --- a/src/components/mui/editable-table/mui-table-editable.js +++ b/src/components/mui/editable-table/mui-table-editable.js @@ -12,6 +12,7 @@ * */ import * as 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"; @@ -20,7 +21,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,13 +30,9 @@ 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 TableCellContent from "../table/table-cell-content"; +import CustomTablePagination from "../table/CustomTablePagination"; const ARCHIVED_CELL_SX = { backgroundColor: "background.light", @@ -164,24 +160,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) => @@ -383,31 +361,52 @@ const MuiTableEditable = ({ - + {perPage && currentPage && onPageChange && ( + + )} ); }; +MuiTableEditable.propTypes = { + columns: PropTypes.arrayOf( + PropTypes.shape({ + columnKey: PropTypes.string.isRequired, + header: PropTypes.node, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + align: PropTypes.string, + sortable: PropTypes.bool, + editable: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + validation: PropTypes.shape({ schema: PropTypes.object }), + render: PropTypes.func + }) + ), + data: PropTypes.arrayOf(PropTypes.object), + totalRows: PropTypes.number, + // Pagination only renders when all three are provided — see line 366. + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func, + onSort: PropTypes.func, + options: PropTypes.shape({ + sortCol: PropTypes.string, + sortDir: PropTypes.oneOf([1, -1]), + disableProp: PropTypes.string + }), + getName: PropTypes.func, + onEdit: PropTypes.func, + onArchive: PropTypes.func, + onDelete: PropTypes.func, + onCellChange: PropTypes.func, + deleteDialogBody: PropTypes.func +}; + export default MuiTableEditable; diff --git a/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js index 997f3ecc..9fdc3f39 100644 --- a/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js @@ -26,7 +26,7 @@ import T from "i18n-react/dist/i18n-react"; const MuiFormikDropdownCheckbox = ({ name, label, placeholder, options, ...rest }) => { const [field, meta, helpers] = useField(name); - const finalPlaceholder = placeholder || T.translate("general.select_an_option"); + const finalPlaceholder = placeholder || T.translate("placeholders.select"); const allSelected = options.every(({ value }) => field.value?.includes(value) ); diff --git a/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js index f983431d..3e16e7e8 100644 --- a/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js +++ b/src/components/mui/formik-inputs/mui-formik-dropdown-radio.js @@ -25,7 +25,7 @@ import T from "i18n-react/dist/i18n-react"; const MuiFormikDropdownRadio = ({ name, label, options, placeholder, ...rest }) => { const finalPlaceholder = - placeholder || T.translate("general.select_an_option"); + placeholder || T.translate("placeholders.select"); const [field, meta, helpers] = useField(name); const handleChange = (event) => { diff --git a/src/components/mui/formik-inputs/mui-formik-select-v2.js b/src/components/mui/formik-inputs/mui-formik-select-v2.js index e17a3854..27b3d6a0 100644 --- a/src/components/mui/formik-inputs/mui-formik-select-v2.js +++ b/src/components/mui/formik-inputs/mui-formik-select-v2.js @@ -13,7 +13,7 @@ import { useField } from "formik"; const MuiFormikSelectV2 = ({ name, label, placeholder, options, ...rest }) => { const [field, meta] = useField(name); const finalPlaceholder = - placeholder || T.translate("general.select_an_option"); + placeholder || T.translate("placeholders.select"); return ( diff --git a/src/components/mui/sortable-table/mui-table-sortable.js b/src/components/mui/sortable-table/mui-table-sortable.js index 325ef608..0ad27a52 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,13 +31,9 @@ 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 TableCellContent from "../table/table-cell-content"; +import CustomTablePagination from "../table/CustomTablePagination"; const MuiTableSortable = ({ columns = [], @@ -60,24 +55,6 @@ const MuiTableSortable = ({ updateOrderKey = "order", tableSx = {} }) => { - 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) => { @@ -287,28 +264,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..36a636c1 --- /dev/null +++ b/src/components/mui/table/CustomTablePagination.js @@ -0,0 +1,84 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import * 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 perPageOptions = React.useMemo(() => { + if (!onPerPageChange) return [perPage]; + return BASE_PER_PAGE_OPTIONS.includes(perPage) + ? BASE_PER_PAGE_OPTIONS + : [...BASE_PER_PAGE_OPTIONS, perPage].sort((a, b) => a - b); + }, [perPage, onPerPageChange]); + + const handlePageChange = (_, newPage) => { + onPageChange(newPage + 1); + }; + + const handleRowsPerPageChange = (ev) => { + onPerPageChange(parseInt(ev.target.value, 10)); + }; + + 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 8965adef..2b24baed 100644 --- a/src/components/mui/table/mui-table.js +++ b/src/components/mui/table/mui-table.js @@ -23,19 +23,18 @@ import { TableCell, TableContainer, TableHead, - TablePagination, TableRow, TableSortLabel } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; 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 TableCellContent from "./table-cell-content"; +import CustomTablePagination from "./CustomTablePagination"; const ARCHIVED_CELL_SX = { backgroundColor: "background.light", @@ -77,32 +76,7 @@ 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 {sortCol, sortDir} = options; const getDisabledSx = (row) => options.disableProp && row[options.disableProp] ? ARCHIVED_CELL_SX : {}; @@ -314,38 +288,13 @@ const MuiTable = ({ {/* PAGINATION */} - {perPage && currentPage && ( - )} diff --git a/src/i18n/en.json b/src/i18n/en.json index b90b191e..21e5fc17 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -26,7 +26,6 @@ "are_you_sure": "Are you sure?", "yes_delete": "Yes, delete.", "remove_warning": "Are you sure you want to delete this?", - "select_an_option": "Select an option...", "all": "All", "row_remove_warning": "Are you sure you want to delete row ", "edit": "Edit", @@ -47,7 +46,14 @@ "download": "Download" }, "placeholders": { - "notes": "Enter your notes here..." + "notes": "Enter your notes here...", + "text": "Enter a value", + "select": "Select an option...", + "number": "Type a number", + "date": "Select a date", + "speaker": "Type a speaker name", + "company": "Type a company name", + "async": "Type and select" }, "alerts": { "confirm_delete_title": "Confirm Deletion", @@ -163,7 +169,6 @@ "following": " one of the following: ", "select_criteria": "Select criteria", "select_operator": "Select operator", - "select_values": "Select values", "filters": "Filters", "clear_filters": "Clear Filters", "cancel": "Cancel", @@ -183,7 +188,15 @@ "between": "between", "between_strict": "between strict", "all": "all", - "any": "any" + "any": "any", + "before": "before", + "after": "after" } + }, + "bulk_edit_table": { + "apply_changes": "Apply changes", + "edit_selected": "Edit selected", + "sorted_desc": "sorted descending", + "sorted_asc": "sorted ascending" } } diff --git a/src/utils/constants.js b/src/utils/constants.js index 02d6d521..56fc75c7 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -11,6 +11,7 @@ export const DEBOUNCE_WAIT_250 = 250; export const DEBOUNCE_WAIT = 500; export const NOTIFICATION_TIMEOUT = 2000; +export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000; export const DEFAULT_PER_PAGE = 10; export const TWENTY_PER_PAGE = 20; export const FIFTY_PER_PAGE = 50; diff --git a/src/utils/query-actions.js b/src/utils/query-actions.js index 56bb38aa..c3e0501b 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -163,11 +163,12 @@ export const fetchAllSummits = async (onlyActive) => { .then((json) => json.data); }; -/** - * @type {DebouncedFunc<(function(*, *, *, *=): Promise)|*>} - */ -export const querySpeakers = debounce(async (summitId, input, callback, per_page = DEFAULT_PER_PAGE ) => { - +// Undebounced: callers that already debounce per-instance (e.g. +// AsyncSelectInput/SpeakerSelectInput) should use this directly. Wiring the +// module-level debounced `querySpeakers` below as a per-instance default +// coalesces calls across separate mounted instances (they share one timer) +// and stacks a second debounce on top of the caller's own. +export const querySpeakersRaw = async (summitId, input, callback, per_page = DEFAULT_PER_PAGE) => { let endpoint = URI(`/api/v1/${summitId ? `summits/${summitId}/speakers`:`speakers`}`); @@ -182,8 +183,12 @@ export const querySpeakers = debounce(async (summitId, input, callback, per_page } _fetch(endpoint, callback); +}; -}, DEBOUNCE_WAIT); +/** + * @type {DebouncedFunc<(function(*, *, *, *=): Promise)|*>} + */ +export const querySpeakers = debounce(querySpeakersRaw, DEBOUNCE_WAIT); /** * @type {DebouncedFunc<(function(*, *, *, *=): Promise)|*>} @@ -316,10 +321,9 @@ export const queryGroups = debounce(async (input, callback, per_page = DEFAULT_P }, DEBOUNCE_WAIT); -/** - * @type {DebouncedFunc<(function(*, *, *=): Promise)|*>} - */ -export const queryCompanies = debounce(async (input, callback, per_page = DEFAULT_PER_PAGE) => { +// Undebounced counterpart of queryCompanies below — see querySpeakersRaw for +// why per-instance callers (e.g. CompanySelectInput) should use this instead. +export const queryCompaniesRaw = async (input, callback, per_page = DEFAULT_PER_PAGE) => { let endpoint = URI(`/api/v1/companies`); @@ -333,7 +337,12 @@ export const queryCompanies = debounce(async (input, callback, per_page = DEFAUL } _fetch(endpoint, callback); -}, DEBOUNCE_WAIT); +}; + +/** + * @type {DebouncedFunc<(function(*, *, *=): Promise)|*>} + */ +export const queryCompanies = debounce(queryCompaniesRaw, DEBOUNCE_WAIT); /** * @type {DebouncedFunc<(function(*, *, *, *=): Promise)|*>} diff --git a/webpack.common.js b/webpack.common.js index d9956110..162da07a 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -105,7 +105,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',