diff --git a/package.json b/package.json
index 3cf6d1bf..7fa21f40 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.22",
+ "version": "5.0.23-beta.1",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
@@ -82,12 +82,12 @@
"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",
"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",
@@ -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/index.js b/src/components/index.js
index cc3dff08..118b18ce 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 {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/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}
+ />
+
+ >
+ );
+};
+
+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..3dbb16ba
--- /dev/null
+++ b/src/components/mui/GridFilter/actions/filter-actions.js
@@ -0,0 +1,10 @@
+import { createAction } from "../../../../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..972b2ac2
--- /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..9ed88a98
--- /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, only use with customParser
+ HAS_NOT: {
+ value: "!>>",
+ label: T.translate("grid_filter.operators.has_not")
+ }, // not available on API, only use with customParser
+ 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..cf08770c
--- /dev/null
+++ b/src/components/mui/__tests__/Dropdown.test.jsx
@@ -0,0 +1,110 @@
+/**
+ * @jest-environment jsdom
+ */
+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", () => ({
+ __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..c4c422c2
--- /dev/null
+++ b/src/components/mui/__tests__/GridFilter.test.jsx
@@ -0,0 +1,95 @@
+/**
+ * @jest-environment jsdom
+ */
+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";
+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..c3cb08d6
--- /dev/null
+++ b/src/components/mui/__tests__/ToggleButtons.test.jsx
@@ -0,0 +1,47 @@
+/**
+ * @jest-environment jsdom
+ */
+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"];
+
+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',
diff --git a/yarn.lock b/yarn.lock
index 8ab62c6b..d1589c57 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==
@@ -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==
@@ -8880,7 +8875,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 +9096,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 +9126,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 +9147,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==
@@ -9359,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==
@@ -10417,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==