Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
100 changes: 100 additions & 0 deletions src/components/mui/Dropdown/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<FormControl fullWidth>
{label && <InputLabel id={`${id}-label`}>{label}</InputLabel>}
<Select
value={value}
label={label}
onChange={onChange}
labelId={`${id}-label`}
displayEmpty
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
renderValue={(selected) => {
if (
selected == null ||
selected === "" ||
(Array.isArray(selected) && selected.length === 0)
) {
return <em>{finalPlaceholder}</em>;
}
if (Array.isArray(selected)) {
const lookup = Object.fromEntries(
options.map((o) => [o.value, o.label])
);
return selected
.map((v) => lookup[v])
.filter(Boolean)
.join(", ");
}
const selectedOption = options.find(
({ value }) => value === selected
);
return selectedOption ? selectedOption.label : "";
}}
>
{options?.map((op) => (
<MenuItem key={`selectop-${op.value}`} value={op.value}>
{op.label}
</MenuItem>
))}
</Select>
</FormControl>
);
};

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;
215 changes: 215 additions & 0 deletions src/components/mui/GridFilter/GridFilter.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<FilterButton
filterCount={filterCount}
onClick={() => setOpenModal(true)}
onDelete={handleRemoveAndApply}
/>
<Dialog
open={openModal}
onClose={() => setOpenModal(false)}
maxWidth="md"
fullWidth
>
<DialogContent>
<Box sx={{ display: "flex", gap: 1 }}>
<Typography
variant="body1"
sx={{ fontSize: 16, lineHeight: "32px" }}
>
{T.translate("grid_filter.filter_by")}
</Typography>
<ToggleButtons
options={Object.values(JOIN_OPERATORS)}
value={andOrAny}
onChange={setAndOrAny}
name="and-or-any"
/>
<Typography
variant="body1"
sx={{ fontSize: 16, lineHeight: "32px" }}
>
{T.translate("grid_filter.following")}
</Typography>
</Box>
<Divider sx={{ m: "10px -24px" }} />
<Box>
{filters.map((filter) => (
<Filter
id={filter.id}
key={`grid-filter-${filter.id}`}
criterias={criterias}
value={filter}
onChange={handleChange}
onAdd={handleAdd}
onDelete={handleRemove}
/>
))}
</Box>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2 }}>
<Button
variant="text"
onClick={() => handleClear()}
sx={{ mr: "auto" }}
>
{T.translate("grid_filter.clear_filters")}
</Button>
<Button variant="outlined" onClick={() => setOpenModal(false)}>
{T.translate("grid_filter.cancel")}
</Button>
<Button variant="contained" onClick={() => handleSubmit()}>
{T.translate("grid_filter.apply_filters")}
</Button>
</DialogActions>
</Dialog>
</>
);
};

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);
10 changes: 10 additions & 0 deletions src/components/mui/GridFilter/actions/filter-actions.js
Original file line number Diff line number Diff line change
@@ -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 }));
};
Loading
Loading