From 7be4fbfbfaf3580b7806961099fbdb6499ffc279 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 18 May 2026 11:43:05 -0300 Subject: [PATCH 1/7] chore: move GridFilter to uicore --- src/components/index.js | 4 + src/components/mui/Dropdown/index.jsx | 100 ++++++++ src/components/mui/GridFilter/GridFilter.jsx | 215 ++++++++++++++++++ .../mui/GridFilter/actions/filter-actions.js | 10 + .../mui/GridFilter/components/Filter.jsx | 172 ++++++++++++++ .../GridFilter/components/FilterButton.jsx | 60 +++++ .../components/ValueInput/index.jsx | 54 +++++ .../mui/GridFilter/hooks/useGridFilter.jsx | 34 +++ src/components/mui/GridFilter/index.js | 5 + src/components/mui/GridFilter/readme.md | 133 +++++++++++ .../reducers/all-filters-reducer.js | 43 ++++ .../mui/GridFilter/reducers/filter-reducer.js | 34 +++ src/components/mui/GridFilter/utils.js | 43 ++++ src/components/mui/RoundButton/index.jsx | 44 ++++ src/components/mui/ToggleButtons/index.jsx | 65 ++++++ .../mui/__tests__/Dropdown.test.jsx | 109 +++++++++ .../mui/__tests__/GridFilter.test.jsx | 94 ++++++++ .../mui/__tests__/ToggleButtons.test.jsx | 46 ++++ src/i18n/en.json | 28 +++ webpack.common.js | 4 + 20 files changed, 1297 insertions(+) create mode 100644 src/components/mui/Dropdown/index.jsx create mode 100644 src/components/mui/GridFilter/GridFilter.jsx create mode 100644 src/components/mui/GridFilter/actions/filter-actions.js create mode 100644 src/components/mui/GridFilter/components/Filter.jsx create mode 100644 src/components/mui/GridFilter/components/FilterButton.jsx create mode 100644 src/components/mui/GridFilter/components/ValueInput/index.jsx create mode 100644 src/components/mui/GridFilter/hooks/useGridFilter.jsx create mode 100644 src/components/mui/GridFilter/index.js create mode 100644 src/components/mui/GridFilter/readme.md create mode 100644 src/components/mui/GridFilter/reducers/all-filters-reducer.js create mode 100644 src/components/mui/GridFilter/reducers/filter-reducer.js create mode 100644 src/components/mui/GridFilter/utils.js create mode 100644 src/components/mui/RoundButton/index.jsx create mode 100644 src/components/mui/ToggleButtons/index.jsx create mode 100644 src/components/mui/__tests__/Dropdown.test.jsx create mode 100644 src/components/mui/__tests__/GridFilter.test.jsx create mode 100644 src/components/mui/__tests__/ToggleButtons.test.jsx diff --git a/src/components/index.js b/src/components/index.js index cc3dff08..5982bc38 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -117,6 +117,10 @@ export {default as MuiStatusChip} from './mui/StatusChip' export {default as MuiUploadBtn} from './mui/UploadBtn' export {default as MuiUploadDialog} from './mui/UploadDialog' export {default as MuiInfoNote} from './mui/InfoNote' +export {default as MuiDropdown} from './mui/Dropdown' +export {default as MuiRoundButton} from './mui/RoundButton' +export {default as MuiToggleButtons} from './mui/ToggleButtons' +export {default as MuiGridFilter, OPERATORS as FILTER_OPERATORS, JOIN_OPERATORS as FILTER_JOIN_OPERATORS, EMPTY_FILTER as FILTER_EMPTY_FILTER, useGridFilter as useGridFilter, allFiltersReducer as allFiltersReducer, saveFilters as saveGridFilters, SAVE_FILTERS as SAVE_FILTERS } from './mui/GridFilter' // these include 3rd party deps // export {default as ExtraQuestionsForm } from './extra-questions/index.js'; diff --git a/src/components/mui/Dropdown/index.jsx b/src/components/mui/Dropdown/index.jsx new file mode 100644 index 00000000..b851d4cc --- /dev/null +++ b/src/components/mui/Dropdown/index.jsx @@ -0,0 +1,100 @@ +/** + * 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 T from "i18n-react/dist/i18n-react"; +import { Select, FormControl, MenuItem, InputLabel } from "@mui/material"; +import PropTypes from "prop-types"; + +const Dropdown = ({ + id, + value, + options, + placeholder, + label, + onChange, + ...rest +}) => { + const finalPlaceholder = + placeholder || T.translate("general.select_an_option"); + + return ( + + {label && {label}} + + + ); +}; + +Dropdown.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.array + ]), + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + label: PropTypes.string.isRequired + }) + ).isRequired, + label: PropTypes.string, + placeholder: PropTypes.string, + onChange: PropTypes.func.isRequired +}; + +Dropdown.defaultProps = { + value: null, + label: "", + placeholder: "" +}; + +export default Dropdown; diff --git a/src/components/mui/GridFilter/GridFilter.jsx b/src/components/mui/GridFilter/GridFilter.jsx new file mode 100644 index 00000000..7638e0bb --- /dev/null +++ b/src/components/mui/GridFilter/GridFilter.jsx @@ -0,0 +1,215 @@ +/** + * 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, useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import T from "i18n-react/dist/i18n-react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + Divider, + Typography +} from "@mui/material"; +import ToggleButtons from "../ToggleButtons"; +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"; + +const OPERATOR_VALUES = Object.values(OPERATORS).map((op) => op.value); + +const GridFilter = ({ id, criterias, onApply, saveFilters }) => { + const { joinOperator, filterCount, valuesWithIds } = useGridFilter(id); + const valuesString = useMemo( + () => valuesWithIds.map((v) => v.id).join(","), + [valuesWithIds] + ); + const [openModal, setOpenModal] = useState(false); + const [filters, setFilters] = useState([]); + const [andOrAny, setAndOrAny] = useState(joinOperator); + + useEffect(() => { + if (openModal) { + // we want to rest to applied filters when closing modal (Cancel) + setFilters([...valuesWithIds, EMPTY_FILTER]); + setAndOrAny(joinOperator); + } + }, [valuesString, joinOperator, openModal]); + + const parseFilter = (filter) => { + const parser = criterias.find( + ({ key }) => key === filter.criteria + )?.customParser; + + if (parser) { + return parser(filter); + } + + const value = Array.isArray(filter.value) + ? filter.value.join("||") + : filter.value; + if (value != null && value !== "") { + return [`${filter.criteria}${filter.operator}${value}`]; + } + }; + + const handleChange = (filter) => { + setFilters((prevFilters) => + prevFilters.map((f) => (f.id === filter.id ? filter : f)) + ); + }; + + const handleAdd = () => { + setFilters((prevFilters) => { + // replacing "new" id and adding new empty filter + const currentFilters = prevFilters.map((f, i) => ({ + ...f, + id: `${f.criteria}-${i}` + })); + return [...currentFilters, EMPTY_FILTER]; + }); + }; + + const handleRemove = (filter) => { + setFilters((prevFilters) => prevFilters.filter((f) => f.id !== filter.id)); + }; + + const handleClear = () => { + setFilters([EMPTY_FILTER]); + }; + + const handleSubmit = () => { + // remove empty filters and adding parsed string for API + const validFilters = filters + .filter( + (f) => + f.criteria != null && + f.operator != null && + f.value != null && + f.value !== "" && + !(Array.isArray(f.value) && f.value.length === 0) + ) + .map((f) => ({ ...f, parsed: parseFilter(f) })); + + saveFilters(id, validFilters, andOrAny); + onApply(validFilters, andOrAny); + setOpenModal(false); + }; + + const handleRemoveAndApply = () => { + saveFilters(id); + onApply([], JOIN_OPERATORS.ALL); + }; + + return ( + <> + setOpenModal(true)} + onDelete={handleRemoveAndApply} + /> + setOpenModal(false)} + maxWidth="md" + fullWidth + > + + + + {T.translate("grid_filter.filter_by")} + + + + {T.translate("grid_filter.following")} + + + + + {filters.map((filter) => ( + + ))} + + + + + + + + + + + ); +}; + +GridFilter.propTypes = { + id: PropTypes.string.isRequired, + criterias: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + operators: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOf(OPERATOR_VALUES).isRequired, + label: PropTypes.string.isRequired + }) + ), + values: PropTypes.shape({ + type: PropTypes.string.isRequired, + props: PropTypes.object.isRequired + }) + }) + ).isRequired, + onApply: PropTypes.func, + saveFilters: PropTypes.func.isRequired +}; + +GridFilter.defaultProps = { + onApply: () => {} +}; + +export default connect(null, { saveFilters })(GridFilter); diff --git a/src/components/mui/GridFilter/actions/filter-actions.js b/src/components/mui/GridFilter/actions/filter-actions.js new file mode 100644 index 00000000..cdb345d5 --- /dev/null +++ b/src/components/mui/GridFilter/actions/filter-actions.js @@ -0,0 +1,10 @@ +import { createAction } from "openstack-uicore-foundation/lib/utils/actions"; +import { JOIN_OPERATORS } from "../utils"; + +export const SAVE_FILTERS = "SAVE_FILTERS"; + +export const saveFilters = + (id, filters = [], joinOperator = JOIN_OPERATORS.ALL) => + (dispatch) => { + dispatch(createAction(SAVE_FILTERS)({ id, filters, joinOperator })); + }; diff --git a/src/components/mui/GridFilter/components/Filter.jsx b/src/components/mui/GridFilter/components/Filter.jsx new file mode 100644 index 00000000..33524063 --- /dev/null +++ b/src/components/mui/GridFilter/components/Filter.jsx @@ -0,0 +1,172 @@ +/** + * 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 T from "i18n-react/dist/i18n-react"; +import { Box, Grid2, IconButton } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import PropTypes from "prop-types"; +import Dropdown from "../../Dropdown"; +import ValueInput from "./ValueInput"; +import RoundButton from "../../RoundButton"; +import { OPERATORS } from "../utils"; + +const OPERATOR_VALUES = Object.values(OPERATORS).map((op) => op.value); + +const Filter = ({ id, value, criterias, onChange, onAdd, onDelete }) => { + const criteriaOptions = criterias.map(({ key, label }) => ({ + value: key, + label + })); + const criteriaObj = criterias.find(({ key }) => key === value?.criteria); + const operatorOptions = criteriaObj?.operators || []; + const valueSettings = criteriaObj?.values || {}; + const defaultValue = valueSettings.props?.multiple ? [] : ""; + + const handleChange = (prop, val) => { + onChange({ ...value, [prop]: val }); + }; + + // auto-select the operator when only one is available for the selected criteria + useEffect(() => { + if (operatorOptions.length === 1 && !value?.operator) { + handleChange("operator", operatorOptions[0].value); + } + }, [operatorOptions.length, value?.criteria]); + + // auto-select the value when only one option is available for the selected criteria + useEffect(() => { + const options = valueSettings.props?.options; + if (options?.length === 1 && !value?.value) { + handleChange("value", options[0].value); + } + }, [valueSettings.props?.options?.length, value?.criteria]); + + const isAddDisabled = + value?.criteria == null || + value?.operator == null || + value?.value == null || + value?.value === "" || + (Array.isArray(value?.value) && value.value.length === 0); + + const handleChangeCriteria = (ev) => { + const val = ev.target.value; + onChange({ ...value, criteria: val, operator: null, value: null }); + }; + + const handleChangeOperator = (ev) => { + const val = ev.target.value; + onChange({ ...value, operator: val, value: null }); + }; + + const handleChangeValue = (ev) => { + const val = ev.target.value; + handleChange("value", val); + }; + + return ( + + + + + + + + + + {value?.id !== "new" ? ( + onDelete(value)} + size="large" + > + + + ) : ( + onAdd()} + disabled={isAddDisabled} + sx={{ ml: "4px" }} + > + + + )} + + + ); +}; + +Filter.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.shape({ + id: PropTypes.string, + criteria: PropTypes.string, + operator: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.array + ]) + }), + criterias: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + operators: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOf(OPERATOR_VALUES).isRequired, + label: PropTypes.string.isRequired + }) + ), + values: PropTypes.shape({ + type: PropTypes.string.isRequired, + props: PropTypes.object.isRequired + }) + }) + ).isRequired, + onChange: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +Filter.defaultProps = { + value: null +}; + +export default Filter; diff --git a/src/components/mui/GridFilter/components/FilterButton.jsx b/src/components/mui/GridFilter/components/FilterButton.jsx new file mode 100644 index 00000000..38e5dabd --- /dev/null +++ b/src/components/mui/GridFilter/components/FilterButton.jsx @@ -0,0 +1,60 @@ +/** + * 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 { Chip, IconButton } from "@mui/material"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import T from "i18n-react/dist/i18n-react"; + +const FilterButton = ({ filterCount, onClick, onDelete }) => { + if (filterCount > 0) { + return ( + } + label={`${filterCount} ${T.translate("grid_filter.filters")}`} + onClick={onClick} + onDelete={onDelete} + sx={{ + "& .MuiChip-label": { fontSize: "13px" }, + backgroundColor: "grey.700", + color: "white", + "& .MuiChip-icon": { color: "white" }, + "& .MuiChip-deleteIcon": { + color: "rgba(255,255,255,0.7)", + "&:hover": { color: "white" } + } + }} + /> + ); + } + + return ( + + + + ); +}; + +FilterButton.propTypes = { + filterCount: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +export default FilterButton; diff --git a/src/components/mui/GridFilter/components/ValueInput/index.jsx b/src/components/mui/GridFilter/components/ValueInput/index.jsx new file mode 100644 index 00000000..02a405be --- /dev/null +++ b/src/components/mui/GridFilter/components/ValueInput/index.jsx @@ -0,0 +1,54 @@ +/** + * 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 TextField from "@mui/material/TextField"; +import PropTypes from "prop-types"; +import Dropdown from "../../../Dropdown"; + +const INPUT_TYPE_MAP = { text: TextField, select: Dropdown }; + +const ValueInput = ({ type, ...rest }) => { + const Component = type ? INPUT_TYPE_MAP[type] : Dropdown; // use dropdown as a placeholder + // eslint-disable-next-line react/jsx-props-no-spreading + return Component ? : null; +}; + +ValueInput.propTypes = { + id: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.array + ]), + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + label: PropTypes.string.isRequired + }) + ), + label: PropTypes.string, + placeholder: PropTypes.string, + onChange: PropTypes.func.isRequired +}; + +ValueInput.defaultProps = { + value: null, + label: "", + placeholder: "", + options: null +}; + +export default ValueInput; diff --git a/src/components/mui/GridFilter/hooks/useGridFilter.jsx b/src/components/mui/GridFilter/hooks/useGridFilter.jsx new file mode 100644 index 00000000..6fb2865b --- /dev/null +++ b/src/components/mui/GridFilter/hooks/useGridFilter.jsx @@ -0,0 +1,34 @@ +import { useDispatch, useSelector } from "react-redux"; +import { saveFilters } from "../actions/filter-actions"; +import { JOIN_OPERATORS } from "../utils"; + +const useGridFilter = (id) => { + const dispatch = useDispatch(); + const allFilters = useSelector( + (state) => state.allGridFiltersState.allFilters + ); + const filter = allFilters.find((f) => f.id === id) || {}; + const { + filterValues = [], + joinOperator = JOIN_OPERATORS.ALL, + parsedFilter = [] + } = filter; + + const valuesWithIds = filterValues.map((v, i) => ({ + ...v, + id: `${v.criteria}-${i}` + })); + + const resetFilters = () => dispatch(saveFilters(id)); + + return { + filterValues, + filterCount: filterValues.length, + joinOperator, + parsedFilter, + valuesWithIds, + resetFilters + }; +}; + +export default useGridFilter; diff --git a/src/components/mui/GridFilter/index.js b/src/components/mui/GridFilter/index.js new file mode 100644 index 00000000..5a2da0eb --- /dev/null +++ b/src/components/mui/GridFilter/index.js @@ -0,0 +1,5 @@ +export { default as GridFilter } from "./GridFilter"; +export { OPERATORS, JOIN_OPERATORS, EMPTY_FILTER } from "./utils"; +export { default as useGridFilter } from "./hooks/useGridFilter"; +export { default as allFiltersReducer } from "./reducers/all-filters-reducer"; +export { saveFilters, SAVE_FILTERS } from "./actions/filter-actions"; diff --git a/src/components/mui/GridFilter/readme.md b/src/components/mui/GridFilter/readme.md new file mode 100644 index 00000000..52e07302 --- /dev/null +++ b/src/components/mui/GridFilter/readme.md @@ -0,0 +1,133 @@ +## GRID FILTER + +# set up + +- Add `all-filters-reducer` to the host app store under the key `allGridFiltersState` +- The reducer is at `GridFilter/reducers/all-filters-reducer.js` + +# usage + +Mount `` with a unique `id` and a `criterias` array. Each criteria defines the column key, display label, which operators are allowed, and how the value input should render. + +```jsx +import { GridFilter, OPERATORS } from "components/GridFilter"; + + { + const filter = []; + if (f.value) { + switch (f.value) { + case "only_rejected": + filter.push("has_rejected_presentations==true"); + filter.push("has_accepted_presentations==false"); + filter.push("has_alternate_presentations==false"); + break; + case "only_accepted": + filter.push("has_rejected_presentations==false"); + filter.push("has_accepted_presentations==true"); + filter.push("has_alternate_presentations==false"); + break; + case "only_alternate": + filter.push("has_rejected_presentations==false"); + filter.push("has_accepted_presentations==false"); + filter.push("has_alternate_presentations==true"); + break; + } + } + return filter; + } + }, + { + key: "sponsor", + label: "Sponsor", + operators: [OPERATORS.IS, OPERATORS.LIKE], + values: { + type: "text", + props: { placeholder: "Type Sponsor Name" } + } + } + ]} + onApply={(filters, joinOperator) => { + // joinOperator: "all" | "any" + // filters: [{ criteria, operator, value, parsed }] + // e.g.: + // [ + // { criteria: "tracks", operator: "==", value: [1, 2], parsed: ["tracks==1||2"] }, + // { criteria: "sponsor", operator: "=@", value: "openstack", parsed: ["sponsor=@openstack"] } + // ] + }} +/>; +``` + +Use `OPERATORS` from `utils.js` when building `criterias` — this ensures only valid operator values are passed and avoids PropTypes warnings. + +Available operators: + +| Key | Value | Label | +| ---------------- | ----- | ------------------------ | +| IS | `==` | is | +| IS_NOT | `<>` | is not | +| LIKE | `=@` | like | +| LIKE_START | `@@` | like start | +| HAS | `>>` | has | +| HAS_NOT | `!>>` | has not | +| LESS | `<` | less than | +| LESS_OR_EQUAL | `<=` | less than or equal to | +| GREATER | `>` | greater than | +| GREATER_OR_EQUAL | `>=` | greater than or equal to | +| BETWEEN | `[]` | between | +| BETWEEN_STRICT | `()` | between strict | + +# reading filter state (hook) + +If you need to read the current filter state outside of `onApply` — for example to rehydrate the UI or build an API query — use the `useGridFilter` hook: + +```js +import useGridFilter from "components/GridFilter/hooks/useGridFilter"; + +const { filterValues, parsedFilter, joinOperator, filterCount } = + useGridFilter("speakers-filter"); +``` + +| Return value | Description | +| -------------- | --------------------------------------------------- | +| `filterValues` | Raw filter array `[{ criteria, operator, value }]` | +| `parsedFilter` | API-ready strings e.g. `["full_name=@john"]` | +| `joinOperator` | `"all"` or `"any"` | +| `filterCount` | Number of active filters (useful for badge counts) | +| `resetFilters` | Function — clears all active filters from the store | + +The hook reads from `allGridFiltersState` in the Redux store, so it stays in sync with whatever was last applied via the dialog. + +# 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/reducers/all-filters-reducer.js b/src/components/mui/GridFilter/reducers/all-filters-reducer.js new file mode 100644 index 00000000..491632ed --- /dev/null +++ b/src/components/mui/GridFilter/reducers/all-filters-reducer.js @@ -0,0 +1,43 @@ +import { LOGOUT_USER } from "../../../security/actions"; +import filterReducer from "./filter-reducer"; +import { SAVE_FILTERS } from "../actions/filter-actions"; + +const DEFAULT_STATE = { + allFilters: [] +}; + +const allFiltersReducer = (state = DEFAULT_STATE, action) => { + const { type, payload } = action; + + switch (type) { + case LOGOUT_USER: + return DEFAULT_STATE; + + case SAVE_FILTERS: { + const { id } = payload; + const { allFilters } = state; + const filterExists = allFilters.find((f) => f.id === id); + let newFilters; + + if (filterExists) { + newFilters = allFilters.map((f) => { + if (f.id === id) { + return filterReducer(f, { ...action, type: `FIL_${type}` }); + } + return f; + }); + } else { + newFilters = [ + ...allFilters, + filterReducer(null, { ...action, type: `FIL_${type}` }) + ]; + } + + return { ...state, allFilters: newFilters }; + } + default: + return state; + } +}; + +export default allFiltersReducer; diff --git a/src/components/mui/GridFilter/reducers/filter-reducer.js b/src/components/mui/GridFilter/reducers/filter-reducer.js new file mode 100644 index 00000000..a0b7d4a7 --- /dev/null +++ b/src/components/mui/GridFilter/reducers/filter-reducer.js @@ -0,0 +1,34 @@ +import { SAVE_FILTERS } from "../actions/filter-actions"; +import { JOIN_OPERATORS } from "../utils"; + +const INITIAL_STATE = { + id: null, + joinOperator: JOIN_OPERATORS.ALL, + filterValues: [], + parsedFilter: [] +}; + +const filterReducer = (state = INITIAL_STATE, action) => { + const { type, payload } = action; + + switch (type) { + case `FIL_${SAVE_FILTERS}`: { + const { id, filters, joinOperator } = payload; + const safeFilters = Array.isArray(filters) ? filters : []; + let parsedFilter = safeFilters.flatMap((f) => f?.parsed ?? []); + if (joinOperator === JOIN_OPERATORS.ANY) + parsedFilter = parsedFilter.map((p) => `or(${p})`); + return { + ...state, + id, + filterValues: safeFilters, + joinOperator, + parsedFilter + }; + } + default: + return state; + } +}; + +export default filterReducer; diff --git a/src/components/mui/GridFilter/utils.js b/src/components/mui/GridFilter/utils.js new file mode 100644 index 00000000..d7bbc3e5 --- /dev/null +++ b/src/components/mui/GridFilter/utils.js @@ -0,0 +1,43 @@ +import T from "i18n-react/dist/i18n-react"; + +export const OPERATORS = { + IS: { value: "==", label: T.translate("grid_filter.operators.is") }, + IS_NOT: { value: "<>", label: T.translate("grid_filter.operators.is_not") }, + LIKE: { value: "=@", label: T.translate("grid_filter.operators.like") }, + LIKE_START: { + value: "@@", + label: T.translate("grid_filter.operators.like_start") + }, + HAS: { value: ">>", label: T.translate("grid_filter.operators.has") }, // not available on API + HAS_NOT: { + value: "!>>", + label: T.translate("grid_filter.operators.has_not") + }, // not available on API + LESS: { value: "<", label: T.translate("grid_filter.operators.less") }, + LESS_OR_EQUAL: { + value: "<=", + label: T.translate("grid_filter.operators.less_or_equal") + }, + GREATER: { value: ">", label: T.translate("grid_filter.operators.greater") }, + GREATER_OR_EQUAL: { + value: ">=", + label: T.translate("grid_filter.operators.greater_or_equal") + }, + BETWEEN: { value: "[]", label: T.translate("grid_filter.operators.between") }, + BETWEEN_STRICT: { + value: "()", + label: T.translate("grid_filter.operators.between_strict") + } +}; + +export const JOIN_OPERATORS = { + ALL: T.translate("grid_filter.operators.all"), + ANY: T.translate("grid_filter.operators.any") +}; + +export const EMPTY_FILTER = { + criteria: null, + operator: null, + value: null, + id: "new" +}; \ No newline at end of file diff --git a/src/components/mui/RoundButton/index.jsx b/src/components/mui/RoundButton/index.jsx new file mode 100644 index 00000000..88759505 --- /dev/null +++ b/src/components/mui/RoundButton/index.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 { Button } from "@mui/material"; +import PropTypes from "prop-types"; + +const RoundButton = ({ children, sx = {}, ...props }) => ( + +); + +RoundButton.propTypes = { + children: PropTypes.node.isRequired, + sx: PropTypes.object +}; + +RoundButton.defaultProps = { + sx: {} +}; + +export default RoundButton; diff --git a/src/components/mui/ToggleButtons/index.jsx b/src/components/mui/ToggleButtons/index.jsx new file mode 100644 index 00000000..542a3a58 --- /dev/null +++ b/src/components/mui/ToggleButtons/index.jsx @@ -0,0 +1,65 @@ +/** + * 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 { ToggleButtonGroup, ToggleButton } from "@mui/material"; +import PropTypes from "prop-types"; + +const ToggleButtons = ({ options, value, onChange, color = "primary" }) => ( + { + if (newValue !== null) onChange(newValue); + }} + sx={(theme) => { + const theColor = theme.palette[color]?.main ?? theme.palette.primary.main; + return { + border: `1px solid ${theColor}`, + overflow: "hidden", + "& .MuiToggleButtonGroup-grouped": { + color: theColor, + fontSize: "14px", + padding: "2px 16px", + "&.Mui-selected": { + backgroundColor: theColor, + color: "#fff", + "&:hover": { backgroundColor: theColor } + }, + "&:hover": { backgroundColor: `${theColor}18` } + } + }; + }} + > + {options.map((option) => ( + + {option} + + ))} + +); + +ToggleButtons.propTypes = { + options: PropTypes.arrayOf(PropTypes.string).isRequired, + value: PropTypes.string, + color: PropTypes.string, + onChange: PropTypes.func.isRequired +}; + +ToggleButtons.defaultProps = { + value: null, + color: "primary" +}; + +export default ToggleButtons; diff --git a/src/components/mui/__tests__/Dropdown.test.jsx b/src/components/mui/__tests__/Dropdown.test.jsx new file mode 100644 index 00000000..f58ad9b6 --- /dev/null +++ b/src/components/mui/__tests__/Dropdown.test.jsx @@ -0,0 +1,109 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import Dropdown from "../Dropdown"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const options = [ + { value: "a", label: "Option A" }, + { value: "b", label: "Option B" }, + { value: "c", label: "Option C" } +]; + +describe("Dropdown", () => { + test("renders placeholder when no value is selected", () => { + render( + + ); + expect(screen.getByText("Pick one")).toBeInTheDocument(); + }); + + test("renders the selected option label", () => { + render( + + ); + expect(screen.getByText("Option B")).toBeInTheDocument(); + }); + + test("shows all options when opened", () => { + render( + + ); + fireEvent.mouseDown(screen.getByRole("combobox")); + expect( + screen.getByRole("option", { name: "Option A" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option B" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option C" }) + ).toBeInTheDocument(); + }); + + test("renders placeholder when value is an empty array", () => { + render( + + ); + expect(screen.getByText("Pick one")).toBeInTheDocument(); + }); + + test("renders joined labels when value is an array", () => { + render( + + ); + expect(screen.getByText("Option A, Option C")).toBeInTheDocument(); + }); + + test("calls onChange when an option is selected", () => { + const onChange = jest.fn(); + render( + + ); + fireEvent.mouseDown(screen.getByRole("combobox")); + fireEvent.click(screen.getByRole("option", { name: "Option A" })); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/src/components/mui/__tests__/GridFilter.test.jsx b/src/components/mui/__tests__/GridFilter.test.jsx new file mode 100644 index 00000000..e568bb31 --- /dev/null +++ b/src/components/mui/__tests__/GridFilter.test.jsx @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { GridFilter, OPERATORS } from "../GridFilter"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +// 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( + "@mui/material/Fade", + () => + ({ children, in: inProp }) => + inProp ? children : null +); + +const mockStore = configureStore([thunk]); + +const makeStore = (filters = []) => + mockStore({ allGridFiltersState: { allFilters: filters } }); + +const criterias = [ + { + key: "track", + label: "Track", + operators: [OPERATORS.IS], + values: { + type: "select", + props: { + options: [ + { value: 1, label: "OpenStack" }, + { value: 2, label: "FnTech" } + ], + placeholder: "Select Track" + } + } + }, + { + key: "sponsor", + label: "Sponsor", + operators: [OPERATORS.IS, OPERATORS.LIKE], + values: { + type: "text", + props: { placeholder: "Type Sponsor Name" } + } + } +]; + +const renderGridFilter = (props = {}) => + render( + + + + ); + +describe("GridFilter", () => { + test("renders the filter button", () => { + renderGridFilter(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("opens the dialog when the filter button is clicked", () => { + renderGridFilter(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + test("dialog contains apply and cancel buttons", () => { + renderGridFilter(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("grid_filter.apply_filters")).toBeInTheDocument(); + expect(screen.getByText("grid_filter.cancel")).toBeInTheDocument(); + }); + + test("closes the dialog when cancel is clicked", () => { + renderGridFilter(); + fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByText("grid_filter.cancel")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/ToggleButtons.test.jsx b/src/components/mui/__tests__/ToggleButtons.test.jsx new file mode 100644 index 00000000..413b9e82 --- /dev/null +++ b/src/components/mui/__tests__/ToggleButtons.test.jsx @@ -0,0 +1,46 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ToggleButtons from "../ToggleButtons"; + +const options = ["all", "any"]; + +describe("ToggleButtons", () => { + test("renders all options", () => { + render( + + ); + expect(screen.getByText("all")).toBeInTheDocument(); + expect(screen.getByText("any")).toBeInTheDocument(); + }); + + test("marks the active option as selected", () => { + render( + + ); + expect(screen.getByText("all").closest("button")).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(screen.getByText("any").closest("button")).toHaveAttribute( + "aria-pressed", + "false" + ); + }); + + test("calls onChange when a different option is clicked", () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByText("any")); + expect(onChange).toHaveBeenCalledWith("any"); + }); + + test("does not call onChange when the active option is clicked", () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByText("all")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 01f7bf27..7642c5f9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -138,5 +138,33 @@ "total": "Total", "rate": "Rate", "action": "Action" + }, + "grid_filter": { + "filter_by": "Filter by ", + "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", + "apply_filters": "Apply Filters", + "open_filters": "Open filters", + "operators": { + "is": "is", + "is_not": "is not", + "like": "like", + "like_start": "like start", + "has": "has", + "has_not": "has not", + "less": "less than", + "less_or_equal": "less than or equal to", + "greater": "greater than", + "greater_or_equal": "greater than or equal to", + "between": "between", + "between_strict": "between strict", + "all": "all", + "any": "any" + } } } diff --git a/webpack.common.js b/webpack.common.js index eae3e602..b3264bfe 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -87,6 +87,9 @@ module.exports = { 'components/mui/custom-alert': './src/components/mui/custom-alert.js', 'components/mui/dnd-list': './src/components/mui/dnd-list.js', 'components/mui/dropdown-checkbox': './src/components/mui/dropdown-checkbox.js', + 'components/mui/dropdown': './src/components/mui/Dropdown', + 'components/mui/toggle-buttons': './src/components/mui/ToggleButtons', + 'components/mui/round-button': './src/components/mui/RoundButton', 'components/mui/menu-button': './src/components/mui/menu-button.js', 'components/mui/search-input': './src/components/mui/search-input.js', 'components/mui/show-confirm-dialog': './src/components/mui/showConfirmDialog.js', @@ -146,6 +149,7 @@ module.exports = { 'components/mui/upload-btn': './src/components/mui/UploadBtn/index.js', 'components/mui/upload-dialog': './src/components/mui/UploadDialog/index.js', 'components/mui/info-note': './src/components/mui/InfoNote/index.jsx', + 'components/mui/grid-filter': './src/components/mui/GridFilter/index.js', // models 'models/index': './src/models', From b0fad12c75fc114cfe65b1e5d625ac65c64dbd1d Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 18 May 2026 11:56:34 -0300 Subject: [PATCH 2/7] chore: fix links and upgrade react-redux --- package.json | 4 ++-- .../mui/GridFilter/actions/filter-actions.js | 2 +- yarn.lock | 23 ++++--------------- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 3cf6d1bf..e23e25b5 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "react-dropzone": "^4.2.9", "react-final-form": "^6.5.9", "react-google-maps": "^9.4.5", - "react-redux": "^5.0.7", + "react-redux": "^7.1.0", "react-rte": "^0.16.3", "react-select": "^2.4.3", "react-star-ratings": "^2.3.0", @@ -149,7 +149,7 @@ "react-dropzone": "^4.2.9", "react-final-form": "^6.5.9", "react-google-maps": "^9.4.5", - "react-redux": "^5.0.7", + "react-redux": "^7.1.0", "react-rte": "^0.16.3", "react-select": "^2.4.3", "react-star-ratings": "^2.3.0", diff --git a/src/components/mui/GridFilter/actions/filter-actions.js b/src/components/mui/GridFilter/actions/filter-actions.js index cdb345d5..3dbb16ba 100644 --- a/src/components/mui/GridFilter/actions/filter-actions.js +++ b/src/components/mui/GridFilter/actions/filter-actions.js @@ -1,4 +1,4 @@ -import { createAction } from "openstack-uicore-foundation/lib/utils/actions"; +import { createAction } from "../../../../utils/actions"; import { JOIN_OPERATORS } from "../utils"; export const SAVE_FILTERS = "SAVE_FILTERS"; diff --git a/yarn.lock b/yarn.lock index 8ab62c6b..11a25606 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6142,7 +6142,7 @@ interpret@^2.2.0: resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -invariant@^2.1.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.4: +invariant@^2.1.0, invariant@^2.2.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -8880,7 +8880,7 @@ prop-types-extra@^1.0.1: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -9101,7 +9101,7 @@ react-input-autosize@^2.2.1: dependencies: prop-types "^15.5.8" -react-is@^16.13.1, react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0: +react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -9131,7 +9131,7 @@ react-is@^19.2.3: resolved "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz" integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== -react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: +react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== @@ -9152,20 +9152,7 @@ react-overlays@^0.7.4: prop-types-extra "^1.0.1" warning "^3.0.0" -react-redux@^5.0.7: - version "5.1.2" - resolved "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz" - integrity sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q== - dependencies: - "@babel/runtime" "^7.1.2" - hoist-non-react-statics "^3.3.0" - invariant "^2.2.4" - loose-envify "^1.1.0" - prop-types "^15.6.1" - react-is "^16.6.0" - react-lifecycles-compat "^3.0.0" - -react-redux@^7.2.0: +react-redux@^7.1.0, react-redux@^7.2.0: version "7.2.9" resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz" integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== From 169acb2e38b8c3f0f1d10ecfc117d06f302b9d41 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 18 May 2026 11:57:41 -0300 Subject: [PATCH 3/7] v5.0.21-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e23e25b5..8f3334a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.22", + "version": "5.0.21-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From c7fd4ab29de9f9c2b9b4357d5a76ea9ca3785a37 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 18 May 2026 12:05:03 -0300 Subject: [PATCH 4/7] chore: fix tests --- src/components/mui/__tests__/Dropdown.test.jsx | 1 + src/components/mui/__tests__/GridFilter.test.jsx | 1 + src/components/mui/__tests__/ToggleButtons.test.jsx | 1 + 3 files changed, 3 insertions(+) diff --git a/src/components/mui/__tests__/Dropdown.test.jsx b/src/components/mui/__tests__/Dropdown.test.jsx index f58ad9b6..cf08770c 100644 --- a/src/components/mui/__tests__/Dropdown.test.jsx +++ b/src/components/mui/__tests__/Dropdown.test.jsx @@ -3,6 +3,7 @@ */ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; import Dropdown from "../Dropdown"; jest.mock("i18n-react/dist/i18n-react", () => ({ diff --git a/src/components/mui/__tests__/GridFilter.test.jsx b/src/components/mui/__tests__/GridFilter.test.jsx index e568bb31..c4c422c2 100644 --- a/src/components/mui/__tests__/GridFilter.test.jsx +++ b/src/components/mui/__tests__/GridFilter.test.jsx @@ -3,6 +3,7 @@ */ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import thunk from "redux-thunk"; diff --git a/src/components/mui/__tests__/ToggleButtons.test.jsx b/src/components/mui/__tests__/ToggleButtons.test.jsx index 413b9e82..c3cb08d6 100644 --- a/src/components/mui/__tests__/ToggleButtons.test.jsx +++ b/src/components/mui/__tests__/ToggleButtons.test.jsx @@ -3,6 +3,7 @@ */ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; import ToggleButtons from "../ToggleButtons"; const options = ["all", "any"]; From de77de4c10a571955b2610625c70928976275626 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 21 May 2026 12:00:22 -0300 Subject: [PATCH 5/7] chore: PR review fixes --- package.json | 2 +- src/components/index.js | 2 +- .../mui/GridFilter/hooks/useGridFilter.jsx | 2 +- src/components/mui/GridFilter/utils.js | 4 ++-- yarn.lock | 21 +++---------------- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 8f3334a4..77216e19 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "react-select": "^2.4.3", "react-star-ratings": "^2.3.0", "react-tooltip": "^5.28.0", - "redux": "^3.7.2", + "redux": "^4.2.1", "redux-mock-store": "^1.5.4", "redux-persist": "^5.10.0", "redux-thunk": "^2.3.0", diff --git a/src/components/index.js b/src/components/index.js index 5982bc38..118b18ce 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -120,7 +120,7 @@ export {default as MuiInfoNote} from './mui/InfoNote' export {default as MuiDropdown} from './mui/Dropdown' export {default as MuiRoundButton} from './mui/RoundButton' export {default as MuiToggleButtons} from './mui/ToggleButtons' -export {default as MuiGridFilter, OPERATORS as FILTER_OPERATORS, JOIN_OPERATORS as FILTER_JOIN_OPERATORS, EMPTY_FILTER as FILTER_EMPTY_FILTER, useGridFilter as useGridFilter, allFiltersReducer as allFiltersReducer, saveFilters as saveGridFilters, SAVE_FILTERS as SAVE_FILTERS } from './mui/GridFilter' +export {GridFilter as MuiGridFilter, OPERATORS as FILTER_OPERATORS, JOIN_OPERATORS as FILTER_JOIN_OPERATORS, EMPTY_FILTER as FILTER_EMPTY_FILTER, useGridFilter, allFiltersReducer, saveFilters as saveGridFilters, SAVE_FILTERS } from './mui/GridFilter' // these include 3rd party deps // export {default as ExtraQuestionsForm } from './extra-questions/index.js'; diff --git a/src/components/mui/GridFilter/hooks/useGridFilter.jsx b/src/components/mui/GridFilter/hooks/useGridFilter.jsx index 6fb2865b..972b2ac2 100644 --- a/src/components/mui/GridFilter/hooks/useGridFilter.jsx +++ b/src/components/mui/GridFilter/hooks/useGridFilter.jsx @@ -5,7 +5,7 @@ import { JOIN_OPERATORS } from "../utils"; const useGridFilter = (id) => { const dispatch = useDispatch(); const allFilters = useSelector( - (state) => state.allGridFiltersState.allFilters + (state) => state.allGridFiltersState?.allFilters ?? [] ); const filter = allFilters.find((f) => f.id === id) || {}; const { diff --git a/src/components/mui/GridFilter/utils.js b/src/components/mui/GridFilter/utils.js index d7bbc3e5..9ed88a98 100644 --- a/src/components/mui/GridFilter/utils.js +++ b/src/components/mui/GridFilter/utils.js @@ -8,11 +8,11 @@ export const OPERATORS = { value: "@@", label: T.translate("grid_filter.operators.like_start") }, - HAS: { value: ">>", label: T.translate("grid_filter.operators.has") }, // not available on API + HAS: { value: ">>", label: T.translate("grid_filter.operators.has") }, // not available on API, only use with customParser HAS_NOT: { value: "!>>", label: T.translate("grid_filter.operators.has_not") - }, // not available on API + }, // not available on API, only use with customParser LESS: { value: "<", label: T.translate("grid_filter.operators.less") }, LESS_OR_EQUAL: { value: "<=", diff --git a/yarn.lock b/yarn.lock index 11a25606..d1589c57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7399,11 +7399,6 @@ lodash-es@^4.17.21: resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz" integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== -lodash-es@^4.2.1: - version "4.17.21" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz" @@ -7424,7 +7419,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@>=4.17.21, lodash@^4.15.0, lodash@^4.16.2, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1, lodash@~4.17.10: +lodash@>=4.17.21, lodash@^4.15.0, lodash@^4.16.2, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@~4.17.10: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -9346,17 +9341,7 @@ redux-thunk@^2.3.0: resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz" integrity sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q== -redux@^3.7.2: - version "3.7.2" - resolved "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz" - integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== - dependencies: - lodash "^4.2.1" - lodash-es "^4.2.1" - loose-envify "^1.1.0" - symbol-observable "^1.0.3" - -redux@^4.0.0, redux@^4.0.4: +redux@^4.0.0, redux@^4.0.4, redux@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== @@ -10404,7 +10389,7 @@ sweetalert2@^8.15.2: resolved "https://registry.npmjs.org/sweetalert2/-/sweetalert2-8.19.0.tgz" integrity sha512-nFL++N3bitkEkd487Tv4i5ZxusmnoAAXHjtk7lp603Opxb8wlvVnz3hqa7qiIw6QFL04JC810E6qVQNf8s0vYQ== -symbol-observable@^1.0.3, symbol-observable@^1.0.4: +symbol-observable@^1.0.4: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== From f9c57c23850a04b8389aaa31aeb41c06543a6964 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 21 May 2026 12:03:05 -0300 Subject: [PATCH 6/7] v5.0.23-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77216e19..7fa21f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.21-beta.1", + "version": "5.0.23-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 9e1c3f2ae246265b45e50f2b613386efbfb755f9 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 21 May 2026 18:02:33 -0300 Subject: [PATCH 7/7] fix: pr review --- src/components/mui/GridFilter/components/Filter.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mui/GridFilter/components/Filter.jsx b/src/components/mui/GridFilter/components/Filter.jsx index 33524063..e607a9d2 100644 --- a/src/components/mui/GridFilter/components/Filter.jsx +++ b/src/components/mui/GridFilter/components/Filter.jsx @@ -48,7 +48,7 @@ const Filter = ({ id, value, criterias, onChange, onAdd, onDelete }) => { // auto-select the value when only one option is available for the selected criteria useEffect(() => { const options = valueSettings.props?.options; - if (options?.length === 1 && !value?.value) { + if (options?.length === 1 && value?.value == null) { handleChange("value", options[0].value); } }, [valueSettings.props?.options?.length, value?.criteria]);