From fc19bf62acb728dc15cf3c6231787377a380f5ff Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 7 May 2026 17:56:58 -0300 Subject: [PATCH 1/9] chore: building ui - WIP --- .../GridFilter/components/Dropdown.jsx | 78 ++++++++++++ .../GridFilter/components/Filter.jsx | 106 +++++++++++++++++ .../GridFilter/components/ToggleButtons.jsx | 46 +++++++ .../components/ValueInput/index.jsx | 47 ++++++++ src/components/GridFilter/index.jsx | 112 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 src/components/GridFilter/components/Dropdown.jsx create mode 100644 src/components/GridFilter/components/Filter.jsx create mode 100644 src/components/GridFilter/components/ToggleButtons.jsx create mode 100644 src/components/GridFilter/components/ValueInput/index.jsx create mode 100644 src/components/GridFilter/index.jsx diff --git a/src/components/GridFilter/components/Dropdown.jsx b/src/components/GridFilter/components/Dropdown.jsx new file mode 100644 index 000000000..f124ae7e3 --- /dev/null +++ b/src/components/GridFilter/components/Dropdown.jsx @@ -0,0 +1,78 @@ +/** + * 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]), + 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; \ No newline at end of file diff --git a/src/components/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx new file mode 100644 index 000000000..2bfb604cd --- /dev/null +++ b/src/components/GridFilter/components/Filter.jsx @@ -0,0 +1,106 @@ +/** + * 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, Grid2 } from "react-bootstrap"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import PropTypes from "prop-types"; +import Box from "@mui/material/Box"; +import Dropdown from "./Dropdown"; +import ValueInput from "./ValueInput"; + +const Filter = ({ id, value, criteriaSettings, operatorSettings, valueSettings, onChange, onAdd, onDelete }) => { + const handleChange = (prop, val) => { + onChange({ ...value, [prop]: val }); + }; + + return ( + + + + handleChange("criteria", val)} + /> + handleChange("operator", val)} + /> + handleChange("value", val)} + /> + + + + + + + + ); +}; + +Filter.propTypes = { + id: PropTypes.string, + value: PropTypes.shape({ + criteria: PropTypes.string, + operator: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool, PropTypes.array]) + }), + criteriaSettings: PropTypes.shape({ + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.string + })).isRequired, + placeholder: PropTypes.string + }).isRequired, + operatorSettings: PropTypes.shape({ + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.string + })).isRequired, + placeholder: PropTypes.string + }).isRequired, + valueSettings: PropTypes.shape({ + type: PropTypes.string, // class name of the component to render the value + props: PropTypes.object, // props to pass to the component + }).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/GridFilter/components/ToggleButtons.jsx b/src/components/GridFilter/components/ToggleButtons.jsx new file mode 100644 index 000000000..d7eddfb13 --- /dev/null +++ b/src/components/GridFilter/components/ToggleButtons.jsx @@ -0,0 +1,46 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { ToggleButtonGroup, ToggleButton } from "react-bootstrap"; +import PropTypes from "prop-types"; + +const ToggleButtons = ({ options, value, onChange }) => { + return ( + + {options.map((option) => ( + + {option} + + ))} + + ); +}; + +ToggleButtons.propTypes = { + options: PropTypes.arrayOf(PropTypes.string).isRequired, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, +} + +ToggleButtons.defaultProps = { + value: null, +} + +export default ToggleButtons; \ No newline at end of file diff --git a/src/components/GridFilter/components/ValueInput/index.jsx b/src/components/GridFilter/components/ValueInput/index.jsx new file mode 100644 index 000000000..227b9b0d9 --- /dev/null +++ b/src/components/GridFilter/components/ValueInput/index.jsx @@ -0,0 +1,47 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import 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 = INPUT_TYPE_MAP[type]; + // 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; \ No newline at end of file diff --git a/src/components/GridFilter/index.jsx b/src/components/GridFilter/index.jsx new file mode 100644 index 000000000..dbd6a6420 --- /dev/null +++ b/src/components/GridFilter/index.jsx @@ -0,0 +1,112 @@ +/** + * 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, { useState } from "react"; +import T from "i18n-react/dist/i18n-react"; +import { Button, Dialog, DialogActions, DialogContent, Divider, IconButton, Typography } from "@mui/material"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import ToggleButtons from "./components/ToggleButtons"; +import Box from "@mui/material/Box"; +import Filter from "./components/Filter"; + +// sample settings +/* +settings = { + criteria: { + options: [ + { value: "name", label: "Name" }, + { value: "email", label: "Email" }, + ], + placeholder: "Select a criteria", + }, + operator: { + options: [ + { value: "eq", label: "Equals" }, + { value: "ne", label: "Not Equals" }, + { value: "gt", label: "Greater Than" }, + { value: "lt", label: "Less Than" }, + ], + placeholder: "Select an operator", + }, + value: { + type: "ValueInput", // class name of the component to render the value + props: { + type: "text", // props to pass to the component + }, + } +} + + + */ + +const GridFilter = ({values, settings}) => { + const [openModal, setOpenModal] = useState(false); + + + const handleChange = (val) => {} + + const handleClear = () => {} + + const handleSubmit = () => {} + + return ( + <> + setOpenModal(true)} + sx={{ mr: 1 }} + > + + + setOpenModal(false)} + maxWidth="md" + fullWidth + > + + {T.translate("grid_filter.filter_by")} + handleChange(val)} /> + {T.translate("grid_filter.following")} + + + {values.map(({criteria, value}, index) => ( + {}} + onAdd={() => {}} + onDelete={() => {}} + /> + ))} + + + + + + + + + + + ); +}; From 59c92f1ad9fc4d24e0447b87ec589a124d3de3cf Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Fri, 8 May 2026 09:16:30 -0300 Subject: [PATCH 2/9] chore: define contract --- .../GridFilter/components/Filter.jsx | 46 +++++-- src/components/GridFilter/index.jsx | 121 ++++++++++++------ 2 files changed, 119 insertions(+), 48 deletions(-) diff --git a/src/components/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx index 2bfb604cd..66f5f6cb9 100644 --- a/src/components/GridFilter/components/Filter.jsx +++ b/src/components/GridFilter/components/Filter.jsx @@ -20,7 +20,19 @@ import Box from "@mui/material/Box"; import Dropdown from "./Dropdown"; import ValueInput from "./ValueInput"; -const Filter = ({ id, value, criteriaSettings, operatorSettings, valueSettings, onChange, onAdd, onDelete }) => { +const Filter = ({ + id, + value, + criteria, + criteriaOptions, + onChange, + onAdd, + onDelete +}) => { + const criteriaSettings = { options: criteriaOptions }; + const operatorSettings = criteria.operators; + const valueSettings = criteria.values; + const handleChange = (prop, val) => { onChange({ ...value, [prop]: val }); }; @@ -32,6 +44,7 @@ const Filter = ({ id, value, criteriaSettings, operatorSettings, valueSettings, handleChange("criteria", val)} @@ -39,6 +52,7 @@ const Filter = ({ id, value, criteriaSettings, operatorSettings, valueSettings, handleChange("operator", val)} @@ -47,6 +61,7 @@ const Filter = ({ id, value, criteriaSettings, operatorSettings, valueSettings, id={`${id}-value`} value={value.value} type={valueSettings.type} + placeholder={T.translate("grid_filter.select_values")} // eslint-disable-next-line react/jsx-props-no-spreading {...valueSettings.props} onChange={(val) => handleChange("value", val)} @@ -74,25 +89,34 @@ Filter.propTypes = { value: PropTypes.shape({ criteria: PropTypes.string, operator: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool, PropTypes.array]) + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.array + ]) }), criteriaSettings: PropTypes.shape({ - options: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.string - })).isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.string + }) + ).isRequired, placeholder: PropTypes.string }).isRequired, operatorSettings: PropTypes.shape({ - options: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.string - })).isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.string + }) + ).isRequired, placeholder: PropTypes.string }).isRequired, valueSettings: PropTypes.shape({ type: PropTypes.string, // class name of the component to render the value - props: PropTypes.object, // props to pass to the component + props: PropTypes.object // props to pass to the component }).isRequired, onChange: PropTypes.func.isRequired, onAdd: PropTypes.func.isRequired, diff --git a/src/components/GridFilter/index.jsx b/src/components/GridFilter/index.jsx index dbd6a6420..af2a00f0d 100644 --- a/src/components/GridFilter/index.jsx +++ b/src/components/GridFilter/index.jsx @@ -13,51 +13,96 @@ import React, { useState } from "react"; import T from "i18n-react/dist/i18n-react"; -import { Button, Dialog, DialogActions, DialogContent, Divider, IconButton, Typography } from "@mui/material"; +import { Button, Box, Dialog, DialogActions, DialogContent, Divider, IconButton, Typography } from "@mui/material"; import FilterListIcon from "@mui/icons-material/FilterList"; import ToggleButtons from "./components/ToggleButtons"; -import Box from "@mui/material/Box"; import Filter from "./components/Filter"; -// sample settings + +// sample props /* -settings = { - criteria: { - options: [ - { value: "name", label: "Name" }, - { value: "email", label: "Email" }, - ], - placeholder: "Select a criteria", - }, - operator: { - options: [ - { value: "eq", label: "Equals" }, - { value: "ne", label: "Not Equals" }, - { value: "gt", label: "Greater Than" }, - { value: "lt", label: "Less Than" }, - ], - placeholder: "Select an operator", - }, - value: { - type: "ValueInput", // class name of the component to render the value - props: { - type: "text", // props to pass to the component +criterias = [ + { + key: "tracks", + label: "Tracks", + operators: [ + {value: "==", label: "is"}, + {value: "=@", label: "like"}, + ], + values: { + type: "select", + props: { + options: [ + {value: 1, label: "OpenStack"}, + {value: 2, label: "FnTech"} + ], + multi: true, + placeholder: "Select Tracks" + }, + }, }, + { + key: "sponsor", + label: "Sponsor", + operators: [ + {value: "==", label: "is"}, + {value: "=@", label: "like"}, + ], + values: { + type: "text", + props: { + placeholder: "Type Sponsor Name" + }, + }, + } + ] + + +value = [ + { + criteria: "tracks", + operator: "==", + value: [1, 2] + }, + { + criteria: "sponsor", + operator: "=@", + value: "openstack" } -} +] */ -const GridFilter = ({values, settings}) => { +const GridFilter = ({values, criterias}) => { const [openModal, setOpenModal] = useState(false); + const [filters, setFilters] = useState(values); + const criteriaOptions = criterias.map(c => ({label: c.label, value: c.key})) + + const handleChange = (filter) => { + setFilters(prevFilters => ({...prevFilters, filter})) + console.log("change filter", filter); + } - const handleChange = (val) => {} + const handleAdd = () => { + const emptyFilter = {}; + setFilters(prevFilters => ({...prevFilters, emptyFilter})) + console.log("add filter"); + } - const handleClear = () => {} + const handleRemove = (filter) => { + setFilters(prevFilters => prevFilters.filter(f => f !== filter.criteria)) + console.log("remove filter", filter); + } - const handleSubmit = () => {} + const handleClear = () => { + console.log("clear filters"); + } + + const handleSubmit = () => { + console.log("save filters", filters); + } return ( <> @@ -80,18 +125,20 @@ const GridFilter = ({values, settings}) => { {T.translate("grid_filter.following")} - {values.map(({criteria, value}, index) => ( + {values.map((value, index) => { + const criteria = criterias.find(c => c.key === value.criteria); + + return ( {}} - onAdd={() => {}} - onDelete={() => {}} + onChange={handleChange} + onAdd={handleAdd} + onDelete={handleRemove} /> - ))} + )})} From 341551233dd7a8b2d381c8bb5f2ef04dd2c9fe27 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 11 May 2026 19:03:24 -0300 Subject: [PATCH 3/9] chore: refactor speaker list page --- .../GridFilter/components/Filter.jsx | 127 +- .../GridFilter/components/ToggleButtons.jsx | 6 +- .../components/ValueInput/index.jsx | 1 + src/components/GridFilter/index.jsx | 143 +- src/i18n/en.json | 10 + .../summit-speakers-list-page.js | 1225 ----------------- .../components/send-email-modal.js | 395 ++++++ .../summit-speakers-list-page/index.js | 421 ++++++ 8 files changed, 1020 insertions(+), 1308 deletions(-) delete mode 100644 src/pages/summit_speakers/summit-speakers-list-page.js create mode 100644 src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js create mode 100644 src/pages/summit_speakers/summit-speakers-list-page/index.js diff --git a/src/components/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx index 66f5f6cb9..50e7efc33 100644 --- a/src/components/GridFilter/components/Filter.jsx +++ b/src/components/GridFilter/components/Filter.jsx @@ -11,81 +11,114 @@ * limitations under the License. * */ -import React from "react"; -import { Button, Grid2 } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import T from "i18n-react/dist/i18n-react"; +import { Grid2, Button, Box } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; import PropTypes from "prop-types"; -import Box from "@mui/material/Box"; import Dropdown from "./Dropdown"; import ValueInput from "./ValueInput"; const Filter = ({ id, value, - criteria, - criteriaOptions, + criterias, onChange, onAdd, onDelete }) => { - const criteriaSettings = { options: criteriaOptions }; - const operatorSettings = criteria.operators; - const valueSettings = criteria.values; + const [selectedCriteria, setSelectedCriteria] = useState(null); + const [selectedOperator, setSelectedOperator] = useState(null); + const [selectedValue, setSelectedValue] = useState(null); + const criteriaOptions = criterias.map(({ key, label }) => ({ value: key, label })); + const criteriaObj = criterias.find(({ key }) => key === selectedCriteria); + const operatorOptions = criteriaObj?.operators || []; + const valueSettings = criteriaObj?.values || {}; + + useEffect(() => { + if(value){ + setSelectedCriteria(value.criteria); + setSelectedOperator(value.operator); + setSelectedValue(value.value); + } + }, [value]) const handleChange = (prop, val) => { onChange({ ...value, [prop]: val }); }; + + // TODO: no es mejor hacer el change en el state del padre ??? pq guardo el state aca ??? + + const handleChangeCriteria = (ev) => { + const val = ev.target.value; + setSelectedCriteria(val); + handleChange("criteria", val); + } + + const handleChangeOperator = (ev) => { + const val = ev.target.value; + setSelectedOperator(val); + handleChange("operator", val); + } + + const handleChangeValue = (ev) => { + const val = ev.target.value; + setSelectedValue(val); + handleChange("value", val); + } + return ( handleChange("criteria", val)} + options={criteriaOptions} + onChange={handleChangeCriteria} /> handleChange("operator", val)} + options={operatorOptions} + onChange={handleChangeOperator} /> handleChange("value", val)} + onChange={handleChangeValue} /> - - + {value ? ( + + ) : ( + + )} ); }; Filter.propTypes = { - id: PropTypes.string, + id: PropTypes.string.isRequired, value: PropTypes.shape({ criteria: PropTypes.string, operator: PropTypes.string, @@ -96,28 +129,22 @@ Filter.propTypes = { PropTypes.array ]) }), - criteriaSettings: PropTypes.shape({ - options: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.string - }) - ).isRequired, - placeholder: PropTypes.string - }).isRequired, - operatorSettings: PropTypes.shape({ - options: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - label: PropTypes.string + criterias: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + operators: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired + }) + ), + values: PropTypes.shape({ + type: PropTypes.string.isRequired, + props: PropTypes.object.isRequired }) - ).isRequired, - placeholder: PropTypes.string - }).isRequired, - valueSettings: PropTypes.shape({ - type: PropTypes.string, // class name of the component to render the value - props: PropTypes.object // props to pass to the component - }).isRequired, + }) + ).isRequired, onChange: PropTypes.func.isRequired, onAdd: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired diff --git a/src/components/GridFilter/components/ToggleButtons.jsx b/src/components/GridFilter/components/ToggleButtons.jsx index d7eddfb13..551bf56d7 100644 --- a/src/components/GridFilter/components/ToggleButtons.jsx +++ b/src/components/GridFilter/components/ToggleButtons.jsx @@ -15,9 +15,9 @@ import React from "react"; import { ToggleButtonGroup, ToggleButton } from "react-bootstrap"; import PropTypes from "prop-types"; -const ToggleButtons = ({ options, value, onChange }) => { - return ( +const ToggleButtons = ({ name, options, value, onChange }) => ( { ))} ); -}; ToggleButtons.propTypes = { + name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.string).isRequired, value: PropTypes.string, onChange: PropTypes.func.isRequired, diff --git a/src/components/GridFilter/components/ValueInput/index.jsx b/src/components/GridFilter/components/ValueInput/index.jsx index 227b9b0d9..853750168 100644 --- a/src/components/GridFilter/components/ValueInput/index.jsx +++ b/src/components/GridFilter/components/ValueInput/index.jsx @@ -20,6 +20,7 @@ const INPUT_TYPE_MAP = { text: TextField, select: Dropdown }; const ValueInput = ({type, ...rest}) => { const Component = INPUT_TYPE_MAP[type]; + console.log(type, typeof Component); // eslint-disable-next-line react/jsx-props-no-spreading return Component ? : null; } diff --git a/src/components/GridFilter/index.jsx b/src/components/GridFilter/index.jsx index af2a00f0d..a3eef55cb 100644 --- a/src/components/GridFilter/index.jsx +++ b/src/components/GridFilter/index.jsx @@ -12,12 +12,36 @@ * */ import React, { useState } from "react"; +import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; -import { Button, Box, Dialog, DialogActions, DialogContent, Divider, IconButton, Typography } from "@mui/material"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + Divider, + IconButton, + Typography +} from "@mui/material"; import FilterListIcon from "@mui/icons-material/FilterList"; import ToggleButtons from "./components/ToggleButtons"; import Filter from "./components/Filter"; +const OPERATORS = [ + { value: "==", label: "is" }, + { value: "=@", label: "like" }, + { value: "@@", label: "like start" }, + { value: "<>", label: "is not" }, + { value: ">>", label: "has" }, + { value: "!>>", label: "has not" }, + { value: "<", label: "less than" }, + { value: "<=", label: "less than or equal to" }, + { value: ">", label: "greater than" }, + { value: ">=", label: "greater than or equal to" }, + { value: "[]", label: "between" }, + { value: "()", label: "between strict" } +]; // sample props /* @@ -74,44 +98,47 @@ value = [ */ -const GridFilter = ({values, criterias}) => { +const GridFilter = ({ values, criterias, onApply }) => { const [openModal, setOpenModal] = useState(false); const [filters, setFilters] = useState(values); - const criteriaOptions = criterias.map(c => ({label: c.label, value: c.key})) - + const criteriaOptions = criterias.map((c) => ({ + label: c.label, + value: c.key + })); const handleChange = (filter) => { - setFilters(prevFilters => ({...prevFilters, filter})) + setFilters((prevFilters) => ({ ...prevFilters, filter })); console.log("change filter", filter); - } + }; const handleAdd = () => { - const emptyFilter = {}; - setFilters(prevFilters => ({...prevFilters, emptyFilter})) console.log("add filter"); - } + }; const handleRemove = (filter) => { - setFilters(prevFilters => prevFilters.filter(f => f !== filter.criteria)) + setFilters((prevFilters) => + prevFilters.filter((f) => f !== filter.criteria) + ); console.log("remove filter", filter); - } + }; const handleClear = () => { console.log("clear filters"); - } + }; const handleSubmit = () => { console.log("save filters", filters); - } + onApply(filters); + }; return ( <> setOpenModal(true)} - sx={{ mr: 1 }} + sx={{ mr: 1, top: "-6px", position: "relative" }} > - + { fullWidth > - {T.translate("grid_filter.filter_by")} - handleChange(val)} /> - {T.translate("grid_filter.following")} + + + {T.translate("grid_filter.filter_by")} + + handleChange(val)} + name="and-or-any" + /> + + {T.translate("grid_filter.following")} + + {values.map((value, index) => { - const criteria = criterias.find(c => c.key === value.criteria); + const criteria = criterias.find((c) => c.key === value.criteria); return ( - - )})} + + ); + })} + @@ -157,3 +202,41 @@ const GridFilter = ({values, criterias}) => { ); }; + +GridFilter.propTypes = { + values: PropTypes.arrayOf( + PropTypes.shape({ + criteria: PropTypes.string.isRequired, + operator: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ) + ]).isRequired + }) + ), + criterias: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + operators: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired + }) + ), + values: PropTypes.shape({ + type: PropTypes.string.isRequired, + props: PropTypes.object.isRequired + }) + }) + ).isRequired +}; + +GridFilter.defaultProps = { + values: [] +}; + +export default GridFilter; diff --git a/src/i18n/en.json b/src/i18n/en.json index 6e09b6b04..71e0027c2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4211,5 +4211,15 @@ "country": "Country", "save": "Save", "cancel": "Cancel" + }, + "grid_filter": { + "filter_by": "Filter by ", + "following": " one of the following: ", + "select_criteria": "Select criteria", + "select_operator": "Select operator", + "select_value": "Select value", + "clear_filters": "Clear Filters", + "cancel": "Cancel", + "apply_filters": "Apply Filters" } } diff --git a/src/pages/summit_speakers/summit-speakers-list-page.js b/src/pages/summit_speakers/summit-speakers-list-page.js deleted file mode 100644 index 24e47df74..000000000 --- a/src/pages/summit_speakers/summit-speakers-list-page.js +++ /dev/null @@ -1,1225 +0,0 @@ -/** - * Copyright 2017 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 { connect } from "react-redux"; -import T from "i18n-react/dist/i18n-react"; -import Swal from "sweetalert2"; -import { Modal, Pagination } from "react-bootstrap"; -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import SelectableTable from "openstack-uicore-foundation/lib/components/table-selectable" -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; -import SpeakerPromoCodeSpecForm from "../../components/forms/speakers-promo-code-spec-form"; -import { - initSpeakersList, - getSpeakersBySummit, - exportSummitSpeakers, - selectSummitSpeaker, - unselectSummitSpeaker, - selectAllSummitSpeakers, - unselectAllSummitSpeakers, - setCurrentFlowEvent, - sendSpeakerEmails -} from "../../actions/speaker-actions"; -import { - initSubmittersList, - getSubmittersBySummit, - exportSummitSubmitters, - selectSummitSubmitter, - unselectSummitSubmitter, - selectAllSummitSubmitters, - unselectAllSummitSubmitters, - setCurrentSubmitterFlowEvent, - sendSubmitterEmails -} from "../../actions/submitter-actions"; -import { - validateSpecs, - resetPromoCodeSpecForm -} from "../../actions/promocode-specification-actions"; -import { - EXISTING_SPEAKERS_PROMO_CODE, - EXISTING_SPEAKERS_DISCOUNT_CODE, - AUTO_GENERATED_SPEAKERS_PROMO_CODE, - AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE -} from "../../actions/promocode-actions"; - -import { ALL_FILTER, SpeakersSources as sources } from "../../utils/constants"; -import { validateEmail } from "../../utils/methods"; -import MediaTypeFilter from "../../components/filters/media-type-filter"; - -import "../../styles/speakers-list-page.less"; - -class SummitSpeakersListPage extends React.Component { - constructor(props) { - super(props); - - this.getSubjectProps = this.getSubjectProps.bind(this); - this.export = this.export.bind(this); - this.getBySummit = this.getBySummit.bind(this); - this.handleSpeakerSubmitterSourceChange = - this.handleSpeakerSubmitterSourceChange.bind(this); - this.handleEdit = this.handleEdit.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.handleExport = this.handleExport.bind(this); - this.handleSelected = this.handleSelected.bind(this); - this.handleSelectedAll = this.handleSelectedAll.bind(this); - this.handleChangeSelectionPlanFilter = - this.handleChangeSelectionPlanFilter.bind(this); - this.handleChangeTrackFilter = this.handleChangeTrackFilter.bind(this); - this.handleChangeTrackGroupFilter = - this.handleChangeTrackGroupFilter.bind(this); - this.handleChangeActivityTypeFilter = - this.handleChangeActivityTypeFilter.bind(this); - this.handleChangeSelectionStatusFilter = - this.handleChangeSelectionStatusFilter.bind(this); - this.handleChangeFlowEvent = this.handleChangeFlowEvent.bind(this); - this.showEmailSendModal = this.showEmailSendModal.bind(this); - this.handleSendEmails = this.handleSendEmails.bind(this); - this.handleChangePromoCodeStrategy = - this.handleChangePromoCodeStrategy.bind(this); - this.handleOrAndFilter = this.handleOrAndFilter.bind(this); - this.handleChangeMediaUploadTypeFilter = - this.handleChangeMediaUploadTypeFilter.bind(this); - - this.state = { - testRecipient: "", - showSendEmailModal: false, - excerptRecipient: "", - source: sources.speakers, - promoCodeStrategy: 0, - speakerFilters: { - orAndFilter: ALL_FILTER - } - }; - } - - componentDidMount() { - const { currentSummit, initSubmittersList, initSpeakersList } = this.props; - initSubmittersList(); - initSpeakersList(); - if (currentSummit) { - const { - term, - page, - order, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - } - - getSubjectProps() { - const { source } = this.state; - return source === sources.speakers - ? this.props.speakersProps - : this.props.submittersProps; - } - - getBySummit(term, page, perPage, order, orderDir, filters) { - const { source } = this.state; - const callable = - source === sources.speakers - ? this.props.getSpeakersBySummit - : this.props.getSubmittersBySummit; - callable(term, page, perPage, order, orderDir, filters, source); - } - - export(term, order, orderDir, filters) { - const { source } = this.state; - const callable = - source === sources.speakers - ? this.props.exportSummitSpeakers - : this.props.exportSummitSubmitters; - callable(term, order, orderDir, filters, source); - } - - handleSpeakerSubmitterSourceChange(ev) { - const { value } = ev.target; - const { - term, - order, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - const { initSubmittersList, initSpeakersList } = this.props; - this.setState({ ...this.state, source: value }, function () { - initSubmittersList(); - initSpeakersList(); - this.getBySummit(term, 1, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - }); - } - - handleEdit(itemId) { - if (this.state.source === sources.speakers) { - const { history } = this.props; - history.push(`/app/speakers/${itemId}`); - } - } - - handlePageChange(page) { - const { - term, - order, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleSort(index, key, dir) { - const { - term, - page, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, key, dir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleSearch(term) { - const { - order, - orderDir, - page, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleChangeSelectionPlanFilter(ev) { - const { value: newSelectionPlanFilter } = ev.target; - const { - term, - order, - page, - orderDir, - perPage, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter: newSelectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleChangeTrackFilter(ev) { - const { value: newTrackFilter } = ev.target; - const { - term, - order, - page, - orderDir, - perPage, - selectionPlanFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter: newTrackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleChangeTrackGroupFilter(ev) { - const { value: newTrackGroupFilter } = ev.target; - const { - term, - order, - page, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter: newTrackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleChangeActivityTypeFilter(ev) { - const { value: newActivityTypeFilter } = ev.target; - const { - term, - order, - page, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter: newActivityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleChangeMediaUploadTypeFilter(ev) { - const { value, operator } = ev.target; - const { - term, - order, - page, - orderDir, - perPage, - activityTypeFilter, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - if (operator && value.length > 0) { - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter: { operator, value } - }); - // get speakers if the media upload types filter is clear - } else if (mediaUploadTypeFilter.value.length > 0 && value.length === 0) { - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter: { operator: null, value: [] } - }); - } - } - - handleChangeSelectionStatusFilter(ev) { - let { value: newSelectionStatusFilter } = ev.target; - // exclusive filters tests .... - if (newSelectionStatusFilter.includes("only_rejected")) { - newSelectionStatusFilter = ["only_rejected"]; - } else if (newSelectionStatusFilter.includes("only_alternate")) { - newSelectionStatusFilter = ["only_alternate"]; - } else if (newSelectionStatusFilter.includes("only_accepted")) { - newSelectionStatusFilter = ["only_accepted"]; - } else if (newSelectionStatusFilter.includes("accepted_alternate")) { - newSelectionStatusFilter = ["accepted_alternate"]; - } else if (newSelectionStatusFilter.includes("accepted_rejected")) { - newSelectionStatusFilter = ["accepted_rejected"]; - } else if (newSelectionStatusFilter.includes("alternate_rejected")) { - newSelectionStatusFilter = ["alternate_rejected"]; - } - - const { - term, - order, - page, - orderDir, - perPage, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - orAndFilter, - selectionStatusFilter: newSelectionStatusFilter, - mediaUploadTypeFilter - }); - } - - handleChangeFlowEvent(ev) { - const { value } = ev.target; - const { source } = this.state; - if (source === sources.speakers) { - this.props.setCurrentFlowEvent(value); - } else { - this.props.setCurrentSubmitterFlowEvent(value); - } - } - - handleSendEmails(ev) { - ev.stopPropagation(); - ev.preventDefault(); - const { currentPromocodeSpecification } = this.props; - const { promoCodeStrategy, testRecipient, source } = this.state; - const isSpeakerMode = source === sources.speakers; - const excerptRecipient = this.ingestEmailRef.value; - const shouldSendCopy2Submitter = - isSpeakerMode && this.shouldSendCopy2SubmitterRef.checked; - const { - term, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - - this.props.validateSpecs( - promoCodeStrategy, - currentPromocodeSpecification.entity, - () => { - this.setState({ - showSendEmailModal: false, - excerptRecipient: "", - testRecipient: "", - promoCodeStrategy: 0 - }); - // send emails - - const callable = isSpeakerMode - ? this.props.sendSpeakerEmails - : this.props.sendSubmitterEmails; - - callable( - term, - { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }, - testRecipient, - excerptRecipient, - shouldSendCopy2Submitter, - source, - promoCodeStrategy, - currentPromocodeSpecification.entity - ); - } - ); - } - - handleChangePromoCodeStrategy(ev) { - const { value } = ev.target; - this.setState({ ...this.state, promoCodeStrategy: value }); - this.props.resetPromoCodeSpecForm(); - } - - showEmailSendModal(ev) { - ev.stopPropagation(); - ev.preventDefault(); - - const { source, testRecipient } = this.state; - const { currentFlowEvent, selectedCount } = this.getSubjectProps(); - - if (!currentFlowEvent) { - Swal.fire( - "Validation error", - T.translate("summit_speakers_list.select_template"), - "warning" - ); - return false; - } - - if (selectedCount === 0) { - const content = - source === sources.speakers - ? T.translate("summit_speakers_list.select_items") - : T.translate("summit_submitters_list.select_items"); - Swal.fire("Validation error", content, "warning"); - return false; - } - - if (testRecipient !== "" && !validateEmail(testRecipient)) { - Swal.fire( - "Validation error", - T.translate("summit_speakers_list.invalid_recipient_email"), - "warning" - ); - return false; - } - - this.setState({ - ...this.state, - showSendEmailModal: true, - excerptRecipient: "" - }); - } - - handleExport(ev) { - const { - term, - order, - orderDir, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - const { - speakerFilters: { orAndFilter } - } = this.state; - ev.preventDefault(); - this.export(term, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - orAndFilter, - mediaUploadTypeFilter - }); - } - - handleSelected(item_id, isSelected) { - const { source } = this.state; - if (isSelected) { - if (source === sources.speakers) { - this.props.selectSummitSpeaker(item_id); - } else { - this.props.selectSummitSubmitter(item_id); - } - return; - } - if (source === sources.speakers) { - this.props.unselectSummitSpeaker(item_id); - } else { - this.props.unselectSummitSubmitter(item_id); - } - } - - handleSelectedAll(ev) { - const selectedAll = ev.target.checked; - const { source } = this.state; - if (source === sources.speakers) { - this.props.selectAllSummitSpeakers(); - } else { - this.props.selectAllSummitSubmitters(); - } - if (!selectedAll) { - // clear all selected - if (source === sources.speakers) { - this.props.unselectAllSummitSpeakers(); - } else { - this.props.unselectAllSummitSubmitters(); - } - } - } - - handleOrAndFilter(ev) { - const { - term, - order, - page, - orderDir, - perPage, - trackFilter, - trackGroupFilter, - selectionPlanFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter - } = this.getSubjectProps(); - this.setState({ - ...this.state, - speakerFilters: { ...this.state.speakerFilters, orAndFilter: ev } - }); - this.getBySummit(term, page, perPage, order, orderDir, { - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - mediaUploadTypeFilter, - selectionStatusFilter, - orAndFilter: ev - }); - } - - render() { - const { currentSummit, currentPromocodeSpecification } = this.props; - - const { testRecipient, source, promoCodeStrategy } = this.state; - - const { - items, - lastPage, - currentPage, - term, - order, - orderDir, - totalItems, - selectedCount, - selectedAll, - selectionPlanFilter, - trackFilter, - trackGroupFilter, - activityTypeFilter, - selectionStatusFilter, - mediaUploadTypeFilter, - currentFlowEvent - } = this.getSubjectProps(); - - const columns = [ - { - columnKey: "full_name", - value: T.translate("general.name"), - sortable: true - }, - { - columnKey: "email", - value: T.translate("general.email"), - sortable: true - }, - { - columnKey: "accepted_presentations_count", - value: T.translate("summit_speakers_list.accepted") - }, - { - columnKey: "alternate_presentations_count", - value: T.translate("summit_speakers_list.alternate") - }, - { - columnKey: "rejected_presentations_count", - value: T.translate("summit_speakers_list.rejected") - } - ]; - - const selectionPlansDDL = currentSummit.selection_plans.map( - (selectionPlan) => ({ - label: selectionPlan.name, - value: selectionPlan.id - }) - ); - const tracksDDL = currentSummit.tracks.map((track) => ({ - label: track.name, - value: track.id - })); - const trackGroupsDDL = currentSummit.track_groups.map((trackGroup) => ({ - label: trackGroup.name, - value: trackGroup.id - })); - const activityTypesDDL = currentSummit.event_types.map((type) => ({ - label: type.name, - value: type.id - })); - - const selectionStatusDDL = [ - { label: "Accepted", value: "accepted" }, - { label: "Alternate", value: "alternate" }, - { label: "Rejected", value: "rejected" }, - { label: "Only Rejected", value: "only_rejected" }, - { label: "Only Accepted", value: "only_accepted" }, - { label: "Only Alternate", value: "only_alternate" }, - { label: "Accepted/Alternate", value: "accepted_alternate" }, - { label: "Accepted/Rejected", value: "accepted_rejected" }, - { label: "Alternate/Rejected", value: "alternate_rejected" } - ]; - - const speakerSubmitterSourceSelectorDDL = [ - { - label: T.translate("summit_speakers_list.speakers"), - value: sources.speakers - }, - { - label: T.translate("summit_submitters_list.submitters"), - value: sources.submitters - }, - { - label: T.translate("summit_submitters_list.submitters_no_speakers"), - value: sources.submitters_no_speakers - } - ]; - - const emailFlowDDL = - this.state.source === sources.speakers - ? [ - { label: "-- SELECT EMAIL EVENT --", value: "" }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ALTERNATE", - value: - "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ALTERNATE" - }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_REJECTED", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_REJECTED" - }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_REJECTED", - value: - "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_REJECTED" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ONLY" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_ONLY" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_REJECTED_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_REJECTED_ONLY" - } - ] - : [ - { label: "-- SELECT EMAIL EVENT --", value: "" }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ALTERNATE", - value: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ALTERNATE" - }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_REJECTED", - value: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_REJECTED" - }, - { - label: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_REJECTED", - value: - "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_REJECTED" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ONLY" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_ONLY" - }, - { - label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_REJECTED_ONLY", - value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_REJECTED_ONLY" - } - ]; - - const promoCodeStrategiesDDL = [ - { - label: T.translate("summit_speakers_list.select_promo_code_strategy"), - value: 0 - }, - { - label: T.translate("summit_speakers_list.select_speaker_promo_code"), - value: EXISTING_SPEAKERS_PROMO_CODE - }, - { - label: T.translate("summit_speakers_list.select_speaker_discount_code"), - value: EXISTING_SPEAKERS_DISCOUNT_CODE - }, - { - label: T.translate( - "summit_speakers_list.select_auto_generate_speaker_promo_code" - ), - value: AUTO_GENERATED_SPEAKERS_PROMO_CODE - }, - { - label: T.translate( - "summit_speakers_list.select_auto_generate_speaker_discount_code" - ), - value: AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE - } - ]; - - const table_options = { - sortCol: order, - sortDir: orderDir, - actions: { - edit: { - onClick: this.handleEdit, - onSelected: this.handleSelected, - onSelectedAll: this.handleSelectedAll - } - }, - selectedAll - }; - - if (!currentSummit.id) return
; - - return ( -
-

- {" "} - {this.state.source === sources.speakers - ? T.translate("summit_speakers_list.summit_speakers_list") - : T.translate("summit_submitters_list.summit_submitters_list")}{" "} - ({totalItems}) -

-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
- - this.setState({ testRecipient: ev.target.value }) - } - placeholder={T.translate( - "summit_speakers_list.placeholders.test_recipient" - )} - /> -
-
- -
-
- - {items.length === 0 && ( -
- {this.state.source === sources.speakers - ? T.translate("summit_speakers_list.no_speakers") - : T.translate("summit_submitters_list.no_submitters")} -
- )} - - {items.length > 0 && ( -
- - - {T.translate("summit_speakers_list.items_qty", { - qty: selectedCount - })} - - - - - - - this.setState({ ...this.state, showSendEmailModal: false }) - } - backdrop={false} - > - - - {this.state.source === sources.speakers - ? T.translate("summit_speakers_list.send_emails_title") - : T.translate("summit_submitters_list.send_emails_title")} - - - -
-
- {T.translate("summit_speakers_list.send_email_warning", { - template: currentFlowEvent, - qty: selectedCount - })} -
- {this.state.testRecipient !== "" && ( -
- {T.translate( - "summit_speakers_list.email_test_recipient", - { - email: this.state.testRecipient - } - )} -
- )} -
- -
- -
-
- -
-
- -
- { - this.ingestEmailRef = node; - }} - /> -
- {this.state.source === sources.speakers && ( -
-
- { - this.shouldSendCopy2SubmitterRef = node; - }} - /> - -
-
- )} -
-
- - - - -
-
- )} -
- ); - } -} - -const mapStateToProps = ({ - currentSummitState, - currentSummitSpeakersListState, - currentSummitSubmittersListState, - currentPromocodeSpecificationState -}) => ({ - currentSummit: currentSummitState.currentSummit, - speakersProps: currentSummitSpeakersListState, - submittersProps: currentSummitSubmittersListState, - currentPromocodeSpecification: currentPromocodeSpecificationState -}); - -export default connect(mapStateToProps, { - initSpeakersList, - getSpeakersBySummit, - exportSummitSpeakers, - selectSummitSpeaker, - unselectSummitSpeaker, - selectAllSummitSpeakers, - unselectAllSummitSpeakers, - setCurrentFlowEvent, - sendSpeakerEmails, - initSubmittersList, - getSubmittersBySummit, - exportSummitSubmitters, - selectSummitSubmitter, - unselectSummitSubmitter, - selectAllSummitSubmitters, - unselectAllSummitSubmitters, - setCurrentSubmitterFlowEvent, - sendSubmitterEmails, - validateSpecs, - resetPromoCodeSpecForm -})(SummitSpeakersListPage); diff --git a/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js b/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js new file mode 100644 index 000000000..903dca1ce --- /dev/null +++ b/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js @@ -0,0 +1,395 @@ +import React, { useState } from "react"; +import T from "i18n-react"; +import { connect } from "react-redux"; +import Swal from "sweetalert2"; +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import { Modal } from "react-bootstrap"; +import { validateEmail } from "../../../../utils/methods"; +import { SpeakersSources as sources } from "../../../../utils/constants"; +import SpeakerPromoCodeSpecForm from "../../../../components/forms/speakers-promo-code-spec-form"; +import { + AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE, + AUTO_GENERATED_SPEAKERS_PROMO_CODE, + EXISTING_SPEAKERS_DISCOUNT_CODE, + EXISTING_SPEAKERS_PROMO_CODE +} from "../../../../actions/promocode-actions"; +import { + sendSpeakerEmails, + setCurrentFlowEvent +} from "../../../../actions/speaker-actions"; +import { + sendSubmitterEmails, + setCurrentSubmitterFlowEvent +} from "../../../../actions/submitter-actions"; +import { + resetPromoCodeSpecForm, + validateSpecs +} from "../../../../actions/promocode-specification-actions"; + +const emailFlowSpeakersDDL = [ + { label: "-- SELECT EMAIL EVENT --", value: "" }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ALTERNATE", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ALTERNATE" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_REJECTED", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_REJECTED" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_REJECTED", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_REJECTED" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ONLY" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ALTERNATE_ONLY" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_REJECTED_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_REJECTED_ONLY" + } +]; +const emailFlowSubmittersDDL = [ + { label: "-- SELECT EMAIL EVENT --", value: "" }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ALTERNATE", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ALTERNATE" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_REJECTED", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_REJECTED" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_REJECTED", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_REJECTED" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ONLY" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ALTERNATE_ONLY" + }, + { + label: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_REJECTED_ONLY", + value: "SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_REJECTED_ONLY" + } +]; + +const promoCodeStrategiesDDL = [ + { + label: T.translate("summit_speakers_list.select_promo_code_strategy"), + value: 0 + }, + { + label: T.translate("summit_speakers_list.select_speaker_promo_code"), + value: EXISTING_SPEAKERS_PROMO_CODE + }, + { + label: T.translate("summit_speakers_list.select_speaker_discount_code"), + value: EXISTING_SPEAKERS_DISCOUNT_CODE + }, + { + label: T.translate( + "summit_speakers_list.select_auto_generate_speaker_promo_code" + ), + value: AUTO_GENERATED_SPEAKERS_PROMO_CODE + }, + { + label: T.translate( + "summit_speakers_list.select_auto_generate_speaker_discount_code" + ), + value: AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE + } +]; + +const SendEmailModal = ({ + source, + filterValues, + speakersProps, + submittersProps, + currentSummit, + currentPromocodeSpecification, + setCurrentFlowEvent, + setCurrentSubmitterFlowEvent, + validateSpecs, + resetPromoCodeSpecForm, + sendSpeakerEmails, + sendSubmitterEmails +}) => { + const [openModal, setOpenModal] = useState(false); + const [promoCodeStrategy, setPromoCodeStrategy] = useState(null); + const [testRecipient, setTestRecipient] = useState(""); + const [modalValues, setModalValues] = useState({ + ingest_email: "", + should_send_copy_2_submitter: false + }); + const isSpeakerMode = source === sources.speakers; + const subjectProps = isSpeakerMode ? speakersProps : submittersProps; + const { currentFlowEvent, selectedCount } = subjectProps; + const emailFlowDDL = isSpeakerMode + ? emailFlowSpeakersDDL + : emailFlowSubmittersDDL; + + const handleOpenModal = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + + if (!currentFlowEvent) { + Swal.fire( + "Validation error", + T.translate("summit_speakers_list.select_template"), + "warning" + ); + return false; + } + + if (selectedCount === 0) { + const content = + source === sources.speakers + ? T.translate("summit_speakers_list.select_items") + : T.translate("summit_submitters_list.select_items"); + Swal.fire("Validation error", content, "warning"); + return false; + } + + if (testRecipient !== "" && !validateEmail(testRecipient)) { + Swal.fire( + "Validation error", + T.translate("summit_speakers_list.invalid_recipient_email"), + "warning" + ); + return false; + } + + setOpenModal(true); + setModalValues({ ingest_email: "", should_send_copy_2_submitter: false }); + }; + + const handleChangeFlowEvent = (ev) => { + const { value } = ev.target; + if (isSpeakerMode) { + setCurrentFlowEvent(value); + } else { + setCurrentSubmitterFlowEvent(value); + } + }; + + const handleChangePromoCodeStrategy = (ev) => { + const { value } = ev.target; + setPromoCodeStrategy(value); + resetPromoCodeSpecForm(); + }; + + const handleChangeTestRecipient = (ev) => { + const { value } = ev.target; + setTestRecipient(value); + }; + + const handleModalChange = (ev) => { + const { id, value, checked } = ev.target; + setModalValues({ + ...modalValues, + [id]: checked || value + }); + }; + + const onValidationSuccess = () => { + const { + ingest_email: excerptRecipient, + should_send_copy_2_submitter: sendCopy + } = modalValues; + const shouldSendCopy2Submitter = isSpeakerMode && sendCopy; + const { term } = subjectProps; + + setOpenModal(false); + setTestRecipient(""); + setPromoCodeStrategy(null); + setModalValues({ ingest_email: "", should_send_copy_2_submitter: false }); + + // send emails + const sendEmails = isSpeakerMode ? sendSpeakerEmails : sendSubmitterEmails; + + sendEmails( + term, + filterValues, + testRecipient, + excerptRecipient, + shouldSendCopy2Submitter, + source, + promoCodeStrategy, + currentPromocodeSpecification.entity + ); + }; + + const handleSendEmails = (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + + validateSpecs( + promoCodeStrategy, + currentPromocodeSpecification.entity, + onValidationSuccess + ); + }; + + return ( + <> +
+
+ +
+
+ +
+
+ +
+
+ setOpenModal(false)} + backdrop={false} + > + + + {isSpeakerMode + ? T.translate("summit_speakers_list.send_emails_title") + : T.translate("summit_submitters_list.send_emails_title")} + + + +
+
+ {T.translate("summit_speakers_list.send_email_warning", { + template: currentFlowEvent, + qty: selectedCount + })} +
+ {testRecipient !== "" && ( +
+ {T.translate("summit_speakers_list.email_test_recipient", { + email: testRecipient + })} +
+ )} +
+ +
+ +
+
+ +
+
+ +
+ +
+ {isSpeakerMode && ( +
+
+ + +
+
+ )} +
+
+ + + + +
+ + ); +}; + +const mapStateToProps = ({ + currentSummitState, + currentSummitSpeakersListState, + currentSummitSubmittersListState, + currentPromocodeSpecificationState +}) => ({ + currentSummit: currentSummitState.currentSummit, + speakersProps: currentSummitSpeakersListState, + submittersProps: currentSummitSubmittersListState, + currentPromocodeSpecification: currentPromocodeSpecificationState +}); + +export default connect(mapStateToProps, { + setCurrentFlowEvent, + sendSpeakerEmails, + setCurrentSubmitterFlowEvent, + sendSubmitterEmails, + validateSpecs, + resetPromoCodeSpecForm +})(SendEmailModal); diff --git a/src/pages/summit_speakers/summit-speakers-list-page/index.js b/src/pages/summit_speakers/summit-speakers-list-page/index.js new file mode 100644 index 000000000..c96f74657 --- /dev/null +++ b/src/pages/summit_speakers/summit-speakers-list-page/index.js @@ -0,0 +1,421 @@ +/** + * Copyright 2017 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, useState } from "react"; +import { connect } from "react-redux"; +import T from "i18n-react/dist/i18n-react"; +import { Pagination } from "react-bootstrap"; +import SelectableTable from "openstack-uicore-foundation/lib/components/table-selectable"; +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; +import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"; +import GridFilter from "../../../components/GridFilter"; +import { + exportSummitSpeakers, + getSpeakersBySummit, + initSpeakersList, + selectAllSummitSpeakers, + selectSummitSpeaker, + unselectAllSummitSpeakers, + unselectSummitSpeaker +} from "../../../actions/speaker-actions"; +import { + exportSummitSubmitters, + getSubmittersBySummit, + initSubmittersList, + selectAllSummitSubmitters, + selectSummitSubmitter, + unselectAllSummitSubmitters, + unselectSummitSubmitter +} from "../../../actions/submitter-actions"; + +import { SpeakersSources as sources } from "../../../utils/constants"; +import "../../../styles/speakers-list-page.less"; +import SendEmailModal from "./components/send-email-modal"; + +const selectionStatusOptions = [ + { label: "Accepted", value: "accepted" }, + { label: "Alternate", value: "alternate" }, + { label: "Rejected", value: "rejected" }, + { label: "Only Rejected", value: "only_rejected" }, + { label: "Only Accepted", value: "only_accepted" }, + { label: "Only Alternate", value: "only_alternate" }, + { label: "Accepted/Alternate", value: "accepted_alternate" }, + { label: "Accepted/Rejected", value: "accepted_rejected" }, + { label: "Alternate/Rejected", value: "alternate_rejected" } +]; + +const getCriterias = (summit) => [ + { + key: "selection_plan", + label: "Selection Plan", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: summit.selection_plans.map((sp) => ({ + label: sp.name, + value: sp.id + })), + placeholder: "Filter by Selection Plan" + } + } + }, + { + key: "track", + label: "Track", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: summit.tracks.map((t) => ({ label: t.name, value: t.id })), + placeholder: "Filter by Track" + } + } + }, + { + key: "activity_type", + label: "Activity Type", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: summit.event_types.map((type) => ({ + label: type.name, + value: type.id + })), + placeholder: "Filter by Activity Type" + } + } + }, + { + key: "selection_status", + label: "Selection Status", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: [...selectionStatusOptions], + placeholder: "Filter by Selection Status" + } + } + }, + { + key: "track_group", + label: "Track Group", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: summit.track_groups.map((trackGroup) => ({ + label: trackGroup.name, + value: trackGroup.id + })), + placeholder: "Filter by Track Groups" + } + } + }, + { + key: "media_upload_type", + label: "Media Upload Type", + operators: [ + { value: ">>", label: "has" }, + { value: "!>>", label: "has not" } + ], + values: { + type: "select", + props: { + options: [{ value: "async", label: "Async" }], + placeholder: "Filter by MediaUploads Type" + } + } + } +]; + +const sourceOptions = [ + { + label: T.translate("summit_speakers_list.speakers"), + value: sources.speakers + }, + { + label: T.translate("summit_submitters_list.submitters"), + value: sources.submitters + }, + { + label: T.translate("summit_submitters_list.submitters_no_speakers"), + value: sources.submitters_no_speakers + } +]; + +const SummitSpeakersListPage = ({ + filterValues, + currentSummit, + history, + speakersProps, + submittersProps, + getSpeakersBySummit, + getSubmittersBySummit, + exportSummitSpeakers, + exportSummitSubmitters, + selectSummitSpeaker, + unselectSummitSpeaker, + selectSummitSubmitter, + unselectSummitSubmitter, + selectAllSummitSpeakers, + selectAllSummitSubmitters, + unselectAllSummitSpeakers, + unselectAllSummitSubmitters +}) => { + const [source, setSource] = useState(sources.speakers); + const isSpeakerMode = source === sources.speakers; + const subjectProps = isSpeakerMode ? speakersProps : submittersProps; + + useEffect(() => { + initSubmittersList(); + initSpeakersList(); + + if (currentSummit) { + getBySummit(); + } + }, [currentSummit, source]); + + const getBySummit = (params = {}) => { + const { term, page, perPage, order, orderDir } = subjectProps; + + const mergedParams = { term, page, perPage, order, orderDir, ...params }; + + const getSubjects = isSpeakerMode + ? getSpeakersBySummit + : getSubmittersBySummit; + + const { term: t, page: p, perPage: pp, order: o, orderDir: od } = mergedParams; + + getSubjects(t, p, pp, o, od, filterValues, source); + }; + + const handleSourceChange = (ev) => { + const { value } = ev.target; + setSource(value); + }; + + const handleEdit = (itemId) => { + if (isSpeakerMode) { + history.push(`/app/speakers/${itemId}`); + } + }; + + const handlePageChange = (page) => { + getBySummit({ page }); + }; + + const handleSort = (index, key, dir) => { + getBySummit({ order: key, orderDir: dir }); + }; + + const handleSearch = (term) => { + getBySummit({ term }); + }; + + const handleExport = (ev) => { + ev.preventDefault(); + const { term, order, orderDir } = subjectProps; + const exportSubjects = isSpeakerMode + ? exportSummitSpeakers + : exportSummitSubmitters; + + exportSubjects(term, order, orderDir, filterValues, source); + }; + + const handleSelected = (itemId, isSelected) => { + const select = isSpeakerMode ? selectSummitSpeaker : selectSummitSubmitter; + const unselect = isSpeakerMode ? unselectSummitSpeaker : unselectSummitSubmitter; + + if (isSelected) select(itemId); + else unselect(itemId); + }; + + const handleSelectedAll = (ev) => { + const selectedAll = ev.target.checked; + const selectAll = isSpeakerMode ? selectAllSummitSpeakers : selectAllSummitSubmitters; + const unselectAll = isSpeakerMode ? unselectAllSummitSpeakers : unselectAllSummitSubmitters; + + if (selectedAll) selectAll(); + else unselectAll(); + }; + + const { + items, + lastPage, + currentPage, + term, + order, + orderDir, + totalItems, + selectedCount, + selectedAll + } = subjectProps; + + const columns = [ + { + columnKey: "full_name", + value: T.translate("general.name"), + sortable: true + }, + { + columnKey: "email", + value: T.translate("general.email"), + sortable: true + }, + { + columnKey: "accepted_presentations_count", + value: T.translate("summit_speakers_list.accepted") + }, + { + columnKey: "alternate_presentations_count", + value: T.translate("summit_speakers_list.alternate") + }, + { + columnKey: "rejected_presentations_count", + value: T.translate("summit_speakers_list.rejected") + } + ]; + + const tableOptions = { + sortCol: order, + sortDir: orderDir, + actions: { + edit: { + onClick: handleEdit, + onSelected: handleSelected, + onSelectedAll: handleSelectedAll + } + }, + selectedAll + }; + + if (!currentSummit.id) return
; + + return ( +
+

+ {" "} + {isSpeakerMode + ? T.translate("summit_speakers_list.summit_speakers_list") + : T.translate("summit_submitters_list.summit_submitters_list")}{" "} + ({totalItems}) +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + {items.length === 0 && ( +
+ {isSpeakerMode + ? T.translate("summit_speakers_list.no_speakers") + : T.translate("summit_submitters_list.no_submitters")} +
+ )} + + {items.length > 0 && ( +
+ + + {T.translate("summit_speakers_list.items_qty", { + qty: selectedCount + })} + + + + +
+ )} +
+ ); +}; + +const mapStateToProps = ({ + currentSummitState, + currentSummitSpeakersListState, + currentSummitSubmittersListState, +}) => ({ + currentSummit: currentSummitState.currentSummit, + speakersProps: currentSummitSpeakersListState, + submittersProps: currentSummitSubmittersListState +}); + +export default connect(mapStateToProps, { + initSpeakersList, + getSpeakersBySummit, + exportSummitSpeakers, + selectSummitSpeaker, + unselectSummitSpeaker, + selectAllSummitSpeakers, + unselectAllSummitSpeakers, + initSubmittersList, + getSubmittersBySummit, + exportSummitSubmitters, + selectSummitSubmitter, + unselectSummitSubmitter, + selectAllSummitSubmitters, + unselectAllSummitSubmitters +})(SummitSpeakersListPage); From bccdbd29e6d27a9959926fba708fefafacd3f84f Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 12 May 2026 18:48:29 -0300 Subject: [PATCH 4/9] chore: continue work on filter - WIP --- package.json | 2 +- src/actions/speaker-actions.js | 5 +- .../GridFilter/actions/filter-actions.js | 9 + .../GridFilter/components/Dropdown.jsx | 91 +++---- .../GridFilter/components/Filter.jsx | 82 +++---- .../GridFilter/components/FilterButton.jsx | 59 +++++ .../GridFilter/components/RoundButton.jsx | 44 ++++ .../GridFilter/components/ToggleButtons.jsx | 63 +++-- .../components/ValueInput/index.jsx | 32 ++- .../GridFilter/hooks/useGridFilter.jsx | 31 +++ src/components/GridFilter/index.jsx | 224 +++++++++++------- src/components/GridFilter/readme.md | 6 + .../reducers/all-filters-reducer.js | 43 ++++ .../GridFilter/reducers/filter-reducer.js | 33 +++ src/components/GridFilter/utils.js | 16 ++ src/i18n/en.json | 3 +- .../components/send-email-modal.js | 4 +- .../summit-speakers-list-page/index.js | 103 ++++++-- src/store.js | 2 + yarn.lock | 19 +- 20 files changed, 615 insertions(+), 256 deletions(-) create mode 100644 src/components/GridFilter/actions/filter-actions.js create mode 100644 src/components/GridFilter/components/FilterButton.jsx create mode 100644 src/components/GridFilter/components/RoundButton.jsx create mode 100644 src/components/GridFilter/hooks/useGridFilter.jsx create mode 100644 src/components/GridFilter/readme.md create mode 100644 src/components/GridFilter/reducers/all-filters-reducer.js create mode 100644 src/components/GridFilter/reducers/filter-reducer.js create mode 100644 src/components/GridFilter/utils.js diff --git a/package.json b/package.json index 67ffb6622..705d3e1a6 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "react-dropzone": "^4.2.13", "react-final-form": "^6.5.9", "react-google-maps": "^9.4.5", - "react-redux": "^5.0.7", + "react-redux": "^7.1.0", "react-router": "^4.3.1", "react-router-dom": "^4.3.1", "react-rte": "^0.16.3", diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index fa8a8b9f9..432dfaaf4 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -889,19 +889,18 @@ export const getSpeakersBySummit = perPage = DEFAULT_PER_PAGE, order = "full_name", orderDir = DEFAULT_ORDER_DIR, - filters = {} + filters = [] ) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - const filter = parseFilters(filters); + const filter = [...filters]; dispatch(startLoading()); if (term) { const filterTerm = buildTermFilter(term); - filter.push(filterTerm.join(",")); } diff --git a/src/components/GridFilter/actions/filter-actions.js b/src/components/GridFilter/actions/filter-actions.js new file mode 100644 index 000000000..2d20d6bcf --- /dev/null +++ b/src/components/GridFilter/actions/filter-actions.js @@ -0,0 +1,9 @@ +import { createAction } from "openstack-uicore-foundation/lib/utils/actions"; + +export const SAVE_FILTERS = "SAVE_FILTERS"; + +export const saveFilters = + (id, filters = [], joinOperator = "all") => + (dispatch) => { + dispatch(createAction(SAVE_FILTERS)({ id, filters, joinOperator })); + }; diff --git a/src/components/GridFilter/components/Dropdown.jsx b/src/components/GridFilter/components/Dropdown.jsx index f124ae7e3..fbd66e184 100644 --- a/src/components/GridFilter/components/Dropdown.jsx +++ b/src/components/GridFilter/components/Dropdown.jsx @@ -13,66 +13,71 @@ import React from "react"; import T from "i18n-react/dist/i18n-react"; -import { - Select, - FormControl, - MenuItem, - InputLabel -} from "@mui/material"; +import { Select, FormControl, MenuItem, InputLabel } from "@mui/material"; import PropTypes from "prop-types"; - -const Dropdown = ({id, value, options, placeholder, label, onChange, ...rest}) => { +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]), - options: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - label: PropTypes.string.isRequired, - })).isRequired, + 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, -} + onChange: PropTypes.func.isRequired +}; Dropdown.defaultProps = { value: null, label: "", - placeholder: "", -} + placeholder: "" +}; -export default Dropdown; \ No newline at end of file +export default Dropdown; diff --git a/src/components/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx index 50e7efc33..3674e0774 100644 --- a/src/components/GridFilter/components/Filter.jsx +++ b/src/components/GridFilter/components/Filter.jsx @@ -11,87 +11,69 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; +import React from "react"; import T from "i18n-react/dist/i18n-react"; -import { Grid2, Button, Box } from "@mui/material"; +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"; -const Filter = ({ - id, - value, - criterias, - onChange, - onAdd, - onDelete -}) => { - const [selectedCriteria, setSelectedCriteria] = useState(null); - const [selectedOperator, setSelectedOperator] = useState(null); - const [selectedValue, setSelectedValue] = useState(null); - const criteriaOptions = criterias.map(({ key, label }) => ({ value: key, label })); - const criteriaObj = criterias.find(({ key }) => key === selectedCriteria); +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 || {}; - useEffect(() => { - if(value){ - setSelectedCriteria(value.criteria); - setSelectedOperator(value.operator); - setSelectedValue(value.value); - } - }, [value]) - const handleChange = (prop, val) => { onChange({ ...value, [prop]: val }); }; - - // TODO: no es mejor hacer el change en el state del padre ??? pq guardo el state aca ??? - const handleChangeCriteria = (ev) => { const val = ev.target.value; - setSelectedCriteria(val); handleChange("criteria", val); - } + }; const handleChangeOperator = (ev) => { const val = ev.target.value; - setSelectedOperator(val); handleChange("operator", val); - } + }; const handleChangeValue = (ev) => { const val = ev.target.value; - setSelectedValue(val); handleChange("value", val); - } + }; return ( - + - + - {value ? ( - + + ) : ( - + onAdd()} + disabled={!value?.criteria || !value?.operator || !value?.value} + sx={{ ml: "4px" }} + > + + )} diff --git a/src/components/GridFilter/components/FilterButton.jsx b/src/components/GridFilter/components/FilterButton.jsx new file mode 100644 index 000000000..67fdf2190 --- /dev/null +++ b/src/components/GridFilter/components/FilterButton.jsx @@ -0,0 +1,59 @@ +/** + * 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/GridFilter/components/RoundButton.jsx b/src/components/GridFilter/components/RoundButton.jsx new file mode 100644 index 000000000..887595059 --- /dev/null +++ b/src/components/GridFilter/components/RoundButton.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/GridFilter/components/ToggleButtons.jsx b/src/components/GridFilter/components/ToggleButtons.jsx index 551bf56d7..542a3a580 100644 --- a/src/components/GridFilter/components/ToggleButtons.jsx +++ b/src/components/GridFilter/components/ToggleButtons.jsx @@ -12,35 +12,54 @@ * */ import React from "react"; -import { ToggleButtonGroup, ToggleButton } from "react-bootstrap"; +import { ToggleButtonGroup, ToggleButton } from "@mui/material"; import PropTypes from "prop-types"; -const ToggleButtons = ({ name, options, value, onChange }) => ( - - {options.map((option) => ( - - {option} - - ))} - - ); +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 = { - name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.string).isRequired, value: PropTypes.string, - onChange: PropTypes.func.isRequired, -} + color: PropTypes.string, + onChange: PropTypes.func.isRequired +}; ToggleButtons.defaultProps = { value: null, -} + color: "primary" +}; -export default ToggleButtons; \ No newline at end of file +export default ToggleButtons; diff --git a/src/components/GridFilter/components/ValueInput/index.jsx b/src/components/GridFilter/components/ValueInput/index.jsx index 853750168..5c457e2fb 100644 --- a/src/components/GridFilter/components/ValueInput/index.jsx +++ b/src/components/GridFilter/components/ValueInput/index.jsx @@ -18,31 +18,37 @@ import Dropdown from "../Dropdown"; const INPUT_TYPE_MAP = { text: TextField, select: Dropdown }; -const ValueInput = ({type, ...rest}) => { - const Component = INPUT_TYPE_MAP[type]; - console.log(type, typeof Component); +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, - })), + 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, -} + onChange: PropTypes.func.isRequired +}; ValueInput.defaultProps = { value: null, label: "", placeholder: "", options: null -} +}; -export default ValueInput; \ No newline at end of file +export default ValueInput; diff --git a/src/components/GridFilter/hooks/useGridFilter.jsx b/src/components/GridFilter/hooks/useGridFilter.jsx new file mode 100644 index 000000000..cc3c97912 --- /dev/null +++ b/src/components/GridFilter/hooks/useGridFilter.jsx @@ -0,0 +1,31 @@ +import { useSelector } from "react-redux"; + +export const EMPTY_FILTER = { + criteria: null, + operator: null, + value: null, + id: "new" +}; + +const useGridFilter = (id) => { + const allFilters = useSelector( + (state) => state.allGridFiltersState.allFilters + ); + const filter = allFilters.find((f) => f.id === id) || {}; + const { filterValues = [], joinOperator = "all", parsedFilter = [] } = filter; + + const valuesWithIds = filterValues.map((v, i) => ({ + ...v, + id: `${v.criteria}-${i}` + })); + + return { + filterValues, + filterCount: filterValues.length, + joinOperator, + parsedFilter, + valuesWithIds + }; +}; + +export default useGridFilter; diff --git a/src/components/GridFilter/index.jsx b/src/components/GridFilter/index.jsx index a3eef55cb..21f7ca57d 100644 --- a/src/components/GridFilter/index.jsx +++ b/src/components/GridFilter/index.jsx @@ -11,8 +11,9 @@ * limitations under the License. * */ -import React, { useState } from "react"; +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, @@ -21,27 +22,14 @@ import { DialogActions, DialogContent, Divider, - IconButton, Typography } from "@mui/material"; -import FilterListIcon from "@mui/icons-material/FilterList"; import ToggleButtons from "./components/ToggleButtons"; import Filter from "./components/Filter"; - -const OPERATORS = [ - { value: "==", label: "is" }, - { value: "=@", label: "like" }, - { value: "@@", label: "like start" }, - { value: "<>", label: "is not" }, - { value: ">>", label: "has" }, - { value: "!>>", label: "has not" }, - { value: "<", label: "less than" }, - { value: "<=", label: "less than or equal to" }, - { value: ">", label: "greater than" }, - { value: ">=", label: "greater than or equal to" }, - { value: "[]", label: "between" }, - { value: "()", label: "between strict" } -]; +import FilterButton from "./components/FilterButton"; +import { saveFilters } from "./actions/filter-actions"; +import useGridFilter, { EMPTY_FILTER } from "./hooks/useGridFilter"; +import { JOIN_OPERATORS } from "./utils"; // sample props /* @@ -65,6 +53,41 @@ criterias = [ }, }, }, + { + key: "selection_status", + label: "Selection Status", + operators: [{ value: "==", label: "is" }], + values: { + type: "select", + props: { + options: [...selectionStatusOptions], + placeholder: "Filter by Selection Status" + } + }, + customParser: (f) => { + 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", @@ -94,101 +117,138 @@ value = [ value: "openstack" } ] - - */ -const GridFilter = ({ values, criterias, onApply }) => { +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(values); - const criteriaOptions = criterias.map((c) => ({ - label: c.label, - value: c.key - })); + 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) { + return [`${filter.criteria}${filter.operator}${value}`]; + } + }; const handleChange = (filter) => { - setFilters((prevFilters) => ({ ...prevFilters, filter })); - console.log("change filter", filter); + setFilters((prevFilters) => + prevFilters.map((f) => (f.id === filter.id ? filter : f)) + ); }; const handleAdd = () => { - console.log("add filter"); + 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 !== filter.criteria) - ); - console.log("remove filter", filter); + setFilters((prevFilters) => prevFilters.filter((f) => f.id !== filter.id)); }; const handleClear = () => { - console.log("clear filters"); + setFilters([EMPTY_FILTER]); }; const handleSubmit = () => { - console.log("save filters", filters); - onApply(filters); + // remove empty filters and adding parsed string for API + const validFilters = filters + .filter((f) => f.criteria && f.operator && f.value) + .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)} - sx={{ mr: 1, top: "-6px", position: "relative" }} - > - - + onDelete={handleRemoveAndApply} + /> setOpenModal(false)} maxWidth="md" fullWidth > - + - + {T.translate("grid_filter.filter_by")} handleChange(val)} + options={Object.values(JOIN_OPERATORS)} + value={andOrAny} + onChange={setAndOrAny} name="and-or-any" /> - + {T.translate("grid_filter.following")} - + - {values.map((value, index) => { - const criteria = criterias.find((c) => c.key === value.criteria); - - return ( - - ); - })} - + {filters.map((filter) => ( + + ))} -
-
+
-
- +
+
- + {items.length === 0 && (
@@ -396,7 +457,7 @@ const SummitSpeakersListPage = ({ const mapStateToProps = ({ currentSummitState, currentSummitSpeakersListState, - currentSummitSubmittersListState, + currentSummitSubmittersListState }) => ({ currentSummit: currentSummitState.currentSummit, speakersProps: currentSummitSpeakersListState, diff --git a/src/store.js b/src/store.js index 2fbc1a166..f791c4473 100644 --- a/src/store.js +++ b/src/store.js @@ -16,6 +16,7 @@ import { loggedUserReducer } from "openstack-uicore-foundation/lib/security/redu import thunk from "redux-thunk"; import { persistStore, persistCombineReducers } from "redux-persist"; import storage from "redux-persist/es/storage"; +import allFiltersReducer from "./components/GridFilter/reducers/all-filters-reducer"; import baseReducer from "./reducers/base-reducer"; import currentSummitReducer from "./reducers/summits/current-summit-reducer"; import directoryReducer from "./reducers/summits/directory-reducer"; @@ -184,6 +185,7 @@ const config = { const reducers = persistCombineReducers(config, { loggedUserState: loggedUserReducer, + allGridFiltersState: allFiltersReducer, baseState: baseReducer, directoryState: directoryReducer, currentSummitState: currentSummitReducer, diff --git a/yarn.lock b/yarn.lock index baefb5da5..d130cb695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10013,7 +10013,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.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -10033,7 +10033,7 @@ react-is@^19.0.0, react-is@^19.2.3: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.5.tgz#7e7b54143e9313fed787b23fd4295d5a23872ad9" integrity sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ== -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.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== @@ -10054,20 +10054,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.yarnpkg.com/react-redux/-/react-redux-5.1.2.tgz#b19cf9e21d694422727bf798e934a916c4080f57" - 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.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== From 61d23bc393fa72b48aa054e04c05ad2efe83c41d Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Fri, 15 May 2026 09:59:51 -0300 Subject: [PATCH 5/9] chore: API integration and tests --- src/actions/speaker-actions.js | 120 +--------------- src/actions/submitter-actions.js | 136 +----------------- .../GridFilter/{index.jsx => GridFilter.jsx} | 92 +----------- .../GridFilter/__tests__/Dropdown.test.jsx | 83 +++++++++++ .../GridFilter/__tests__/GridFilter.test.jsx | 94 ++++++++++++ .../__tests__/ToggleButtons.test.jsx | 46 ++++++ .../GridFilter/actions/filter-actions.js | 3 +- .../GridFilter/components/Filter.jsx | 22 ++- .../GridFilter/hooks/useGridFilter.jsx | 16 ++- src/components/GridFilter/index.js | 5 + src/components/GridFilter/readme.md | 131 ++++++++++++++++- .../GridFilter/reducers/filter-reducer.js | 2 +- src/components/GridFilter/utils.js | 46 ++++-- src/i18n/en.json | 18 ++- .../summit-speakers-list-page/index.js | 84 ++++++++--- .../summit-speakers-list-reducer.js | 8 +- .../summit-submitters-list-reducer.js | 8 +- src/store.js | 2 +- 18 files changed, 522 insertions(+), 394 deletions(-) rename src/components/GridFilter/{index.jsx => GridFilter.jsx} (73%) create mode 100644 src/components/GridFilter/__tests__/Dropdown.test.jsx create mode 100644 src/components/GridFilter/__tests__/GridFilter.test.jsx create mode 100644 src/components/GridFilter/__tests__/ToggleButtons.test.jsx create mode 100644 src/components/GridFilter/index.js diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index 432dfaaf4..f1c785b9c 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -772,116 +772,6 @@ export const removeFeaturedSpeaker = /* SPEAKERS BY SUMMIT */ /** ************************************************************************************************* */ -const parseFilters = (filters) => { - const filter = []; - - if ( - filters?.selectionPlanFilter && - Array.isArray(filters.selectionPlanFilter) && - filters.selectionPlanFilter.length > 0 - ) { - filter.push( - `presentations_selection_plan_id==${filters.selectionPlanFilter.join( - "||" - )}` - ); - } - - if ( - filters?.trackFilter && - Array.isArray(filters.trackFilter) && - filters.trackFilter.length > 0 - ) { - filter.push(`presentations_track_id==${filters.trackFilter.join("||")}`); - } - - if ( - filters?.trackGroupFilter && - Array.isArray(filters.trackGroupFilter) && - filters.trackGroupFilter.length > 0 - ) { - filter.push( - `presentations_track_group_id==${filters.trackGroupFilter.join("||")}` - ); - } - - if ( - filters?.activityTypeFilter && - Array.isArray(filters.activityTypeFilter) && - filters.activityTypeFilter.length > 0 - ) { - filter.push( - `presentations_type_id==${filters.activityTypeFilter.join("||")}` - ); - } - - if ( - filters?.selectionStatusFilter && - Array.isArray(filters.selectionStatusFilter) && - filters.selectionStatusFilter.length > 0 - ) { - // exclusive filters - if (filters.selectionStatusFilter.includes("only_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("only_accepted")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("only_alternate")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==true"); - } else if (filters.selectionStatusFilter.includes("accepted_alternate")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==true"); - } else if (filters.selectionStatusFilter.includes("accepted_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("alternate_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==true"); - } else { - filter.push( - filters.selectionStatusFilter.reduce( - (accumulator, at) => - `${ - accumulator + (accumulator !== "" ? "," : "") - }has_${at}_presentations==true`, - "" - ) - ); - } - } - - if ( - filters?.mediaUploadTypeFilter && - filters.mediaUploadTypeFilter.operator !== null && - Array.isArray(filters.mediaUploadTypeFilter.value) && - filters.mediaUploadTypeFilter.value.length > 0 - ) { - filter.push( - `${ - filters.mediaUploadTypeFilter.operator - }${filters.mediaUploadTypeFilter.value - .map((v) => v.id) - .join( - filters.mediaUploadTypeFilter.operator === - "has_media_upload_with_type==" - ? "||" - : "&&" - )}` - ); - } - - // return checkOrFilter(filters, filter); - return filter; -}; - export const getSpeakersBySummit = ( term = null, @@ -947,7 +837,7 @@ export const getSpeakersBySummit = }; export const exportSummitSpeakers = - (term = null, order = "id", orderDir = DEFAULT_ORDER_DIR, filters = {}) => + (term = null, order = "id", orderDir = DEFAULT_ORDER_DIR, filters = []) => async (dispatch, getState) => { const csvMIME = "text/csv;charset=utf-8"; const pageSize = 500; @@ -966,7 +856,7 @@ export const exportSummitSpeakers = dispatch(startLoading()); - const filter = parseFilters(filters); + const filter = [...filters]; if (term) { const filterTerm = buildTermFilter(term); @@ -1017,7 +907,7 @@ export const exportSummitSpeakers = export const sendSpeakerEmails = ( term = null, - filters = {}, + filters = [], testRecipient = "", excerptRecipient = "", shouldSendCopy2Submitter = false, @@ -1046,7 +936,7 @@ export const sendSpeakerEmails = if (!selectedAll && selectedItems.length > 0) { // we don't need the filter criteria, we have the ids filter.push(`id==${selectedItems.join("||")}`); - const originalFilters = parseFilters(filters); + const originalFilters = [...filters]; if (term) { const filterTerm = buildTermFilter(term); originalFilters.push(filterTerm.join(",")); @@ -1054,7 +944,7 @@ export const sendSpeakerEmails = payload.original_filter = originalFilters; } else { - filter = parseFilters(filters); + filter = [...filters]; if (term) { const filterTerm = buildTermFilter(term); diff --git a/src/actions/submitter-actions.js b/src/actions/submitter-actions.js index 57da93e08..689232a02 100644 --- a/src/actions/submitter-actions.js +++ b/src/actions/submitter-actions.js @@ -54,7 +54,7 @@ export const getSubmittersBySummit = perPage = DEFAULT_PER_PAGE, order = "full_name", orderDir = DEFAULT_ORDER_DIR, - filters = {}, + filters = [], source = null ) => async (dispatch, getState) => { @@ -62,7 +62,7 @@ export const getSubmittersBySummit = const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - const filter = parseFilters(filters); + const filter = [...filters]; if (source === sources.submitters_no_speakers) { filter.push("is_speaker==false"); @@ -133,7 +133,7 @@ export const exportSummitSubmitters = access_token: accessToken }; - const filter = parseFilters(filters); + const filter = [...filters]; if (source === sources.submitters_no_speakers) { filter.push("is_speaker==false"); @@ -209,7 +209,7 @@ export const sendSubmitterEmails = if (!selectedAll && selectedItems.length > 0) { // we don't need the filter criteria, we have the ids filter.push(`id==${selectedItems.join("||")}`); - const originalFilters = parseFilters(filters); + const originalFilters = [...filters]; if (source && source === sources.submitters_no_speakers) { originalFilters.push("is_speaker==false"); @@ -222,7 +222,7 @@ export const sendSubmitterEmails = payload.original_filter = originalFilters; } else { - filter = parseFilters(filters); + filter = [...filters]; if (source && source === sources.submitters_no_speakers) { filter.push("is_speaker==false"); @@ -292,132 +292,6 @@ export const setCurrentSubmitterFlowEvent = (value) => (dispatch) => { dispatch(createAction(SET_SUBMITTERS_CURRENT_FLOW_EVENT)(value)); }; -const parseFilters = (filters) => { - const filter = []; - - if ( - filters.hasOwnProperty("selectionPlanFilter") && - Array.isArray(filters.selectionPlanFilter) && - filters.selectionPlanFilter.length > 0 - ) { - filter.push( - `presentations_selection_plan_id==${filters.selectionPlanFilter.reduce( - (accumulator, sp) => - `${accumulator + (accumulator !== "" ? "||" : "")}${sp}`, - "" - )}` - ); - } - - if ( - filters.hasOwnProperty("trackFilter") && - Array.isArray(filters.trackFilter) && - filters.trackFilter.length > 0 - ) { - filter.push( - `presentations_track_id==${filters.trackFilter.reduce( - (accumulator, t) => - `${accumulator + (accumulator !== "" ? "||" : "")}${t}`, - "" - )}` - ); - } - - if ( - filters.hasOwnProperty("trackGroupFilter") && - Array.isArray(filters.trackGroupFilter) && - filters.trackGroupFilter.length > 0 - ) { - filter.push( - `presentations_track_group_id==${filters.trackGroupFilter.reduce( - (accumulator, t) => - `${accumulator + (accumulator !== "" ? "||" : "")}${t}`, - "" - )}` - ); - } - - if ( - filters.hasOwnProperty("activityTypeFilter") && - Array.isArray(filters.activityTypeFilter) && - filters.activityTypeFilter.length > 0 - ) { - filter.push( - `presentations_type_id==${filters.activityTypeFilter.reduce( - (accumulator, at) => - `${accumulator + (accumulator !== "" ? "||" : "")}${at}`, - "" - )}` - ); - } - - if ( - filters.hasOwnProperty("selectionStatusFilter") && - Array.isArray(filters.selectionStatusFilter) && - filters.selectionStatusFilter.length > 0 - ) { - // exclusive filters - if (filters.selectionStatusFilter.includes("only_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("only_accepted")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("only_alternate")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==true"); - } else if (filters.selectionStatusFilter.includes("accepted_alternate")) { - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==true"); - } else if (filters.selectionStatusFilter.includes("accepted_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==false"); - } else if (filters.selectionStatusFilter.includes("alternate_rejected")) { - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==true"); - } else { - filter.push( - filters.selectionStatusFilter.reduce( - (accumulator, at) => - `${ - accumulator + (accumulator !== "" ? "," : "") - }has_${at}_presentations==true`, - "" - ) - ); - } - } - - if ( - filters.hasOwnProperty("mediaUploadTypeFilter") && - filters.mediaUploadTypeFilter.operator !== null && - Array.isArray(filters.mediaUploadTypeFilter.value) && - filters.mediaUploadTypeFilter.value.length > 0 - ) { - filter.push( - `${ - filters.mediaUploadTypeFilter.operator - }${filters.mediaUploadTypeFilter.value - .map((v) => v.id) - .join( - filters.mediaUploadTypeFilter.operator === - "has_media_upload_with_type==" - ? "||" - : "&&" - )}` - ); - } - - // return checkOrFilter(filters, filter); - return filter; -}; - const buildTermFilter = (term) => { const escapedTerm = escapeFilterValue(term); diff --git a/src/components/GridFilter/index.jsx b/src/components/GridFilter/GridFilter.jsx similarity index 73% rename from src/components/GridFilter/index.jsx rename to src/components/GridFilter/GridFilter.jsx index 21f7ca57d..edcc7fb58 100644 --- a/src/components/GridFilter/index.jsx +++ b/src/components/GridFilter/GridFilter.jsx @@ -29,95 +29,9 @@ import Filter from "./components/Filter"; import FilterButton from "./components/FilterButton"; import { saveFilters } from "./actions/filter-actions"; import useGridFilter, { EMPTY_FILTER } from "./hooks/useGridFilter"; -import { JOIN_OPERATORS } from "./utils"; +import { JOIN_OPERATORS, OPERATORS } from "./utils"; -// sample props -/* -criterias = [ - { - key: "tracks", - label: "Tracks", - operators: [ - {value: "==", label: "is"}, - {value: "=@", label: "like"}, - ], - values: { - type: "select", - props: { - options: [ - {value: 1, label: "OpenStack"}, - {value: 2, label: "FnTech"} - ], - multi: true, - placeholder: "Select Tracks" - }, - }, - }, - { - key: "selection_status", - label: "Selection Status", - operators: [{ value: "==", label: "is" }], - values: { - type: "select", - props: { - options: [...selectionStatusOptions], - placeholder: "Filter by Selection Status" - } - }, - customParser: (f) => { - 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: [ - {value: "==", label: "is"}, - {value: "=@", label: "like"}, - ], - values: { - type: "text", - props: { - placeholder: "Type Sponsor Name" - }, - }, - } - ] - - -value = [ - { - criteria: "tracks", - operator: "==", - value: [1, 2] - }, - { - criteria: "sponsor", - operator: "=@", - value: "openstack" - } -] - */ +const OPERATOR_VALUES = Object.values(OPERATORS).map((op) => op.value); const GridFilter = ({ id, criterias, onApply, saveFilters }) => { const { joinOperator, filterCount, valuesWithIds } = useGridFilter(id); @@ -271,7 +185,7 @@ GridFilter.propTypes = { label: PropTypes.string.isRequired, operators: PropTypes.arrayOf( PropTypes.shape({ - value: PropTypes.string.isRequired, + value: PropTypes.oneOf(OPERATOR_VALUES).isRequired, label: PropTypes.string.isRequired }) ), diff --git a/src/components/GridFilter/__tests__/Dropdown.test.jsx b/src/components/GridFilter/__tests__/Dropdown.test.jsx new file mode 100644 index 000000000..53d61ebdc --- /dev/null +++ b/src/components/GridFilter/__tests__/Dropdown.test.jsx @@ -0,0 +1,83 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import Dropdown from "../components/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("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/GridFilter/__tests__/GridFilter.test.jsx b/src/components/GridFilter/__tests__/GridFilter.test.jsx new file mode 100644 index 000000000..6cb648bbc --- /dev/null +++ b/src/components/GridFilter/__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 "../index"; + +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/GridFilter/__tests__/ToggleButtons.test.jsx b/src/components/GridFilter/__tests__/ToggleButtons.test.jsx new file mode 100644 index 000000000..e13b9820a --- /dev/null +++ b/src/components/GridFilter/__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 "../components/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/components/GridFilter/actions/filter-actions.js b/src/components/GridFilter/actions/filter-actions.js index 2d20d6bcf..cdb345d53 100644 --- a/src/components/GridFilter/actions/filter-actions.js +++ b/src/components/GridFilter/actions/filter-actions.js @@ -1,9 +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 = "all") => + (id, filters = [], joinOperator = JOIN_OPERATORS.ALL) => (dispatch) => { dispatch(createAction(SAVE_FILTERS)({ id, filters, joinOperator })); }; diff --git a/src/components/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx index 3674e0774..acde85769 100644 --- a/src/components/GridFilter/components/Filter.jsx +++ b/src/components/GridFilter/components/Filter.jsx @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React from "react"; +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"; @@ -20,6 +20,9 @@ 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 }) => ({ @@ -34,6 +37,21 @@ const Filter = ({ id, value, criterias, onChange, onAdd, onDelete }) => { 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 handleChangeCriteria = (ev) => { const val = ev.target.value; handleChange("criteria", val); @@ -123,7 +141,7 @@ Filter.propTypes = { label: PropTypes.string.isRequired, operators: PropTypes.arrayOf( PropTypes.shape({ - value: PropTypes.string.isRequired, + value: PropTypes.oneOf(OPERATOR_VALUES).isRequired, label: PropTypes.string.isRequired }) ), diff --git a/src/components/GridFilter/hooks/useGridFilter.jsx b/src/components/GridFilter/hooks/useGridFilter.jsx index cc3c97912..fbfca1732 100644 --- a/src/components/GridFilter/hooks/useGridFilter.jsx +++ b/src/components/GridFilter/hooks/useGridFilter.jsx @@ -1,4 +1,6 @@ -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; +import { saveFilters } from "../actions/filter-actions"; +import { JOIN_OPERATORS } from "../utils"; export const EMPTY_FILTER = { criteria: null, @@ -8,23 +10,31 @@ export const EMPTY_FILTER = { }; const useGridFilter = (id) => { + const dispatch = useDispatch(); const allFilters = useSelector( (state) => state.allGridFiltersState.allFilters ); const filter = allFilters.find((f) => f.id === id) || {}; - const { filterValues = [], joinOperator = "all", parsedFilter = [] } = filter; + 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 + valuesWithIds, + resetFilters }; }; diff --git a/src/components/GridFilter/index.js b/src/components/GridFilter/index.js new file mode 100644 index 000000000..fd8c830d8 --- /dev/null +++ b/src/components/GridFilter/index.js @@ -0,0 +1,5 @@ +export { default as GridFilter } from "./GridFilter"; +export { OPERATORS, JOIN_OPERATORS } from "./utils"; +export { default as useGridFilter, EMPTY_FILTER } 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/GridFilter/readme.md b/src/components/GridFilter/readme.md index ad5dbe128..52e07302e 100644 --- a/src/components/GridFilter/readme.md +++ b/src/components/GridFilter/readme.md @@ -2,5 +2,132 @@ # set up -- Need to add the all-filters-reducer to the host app store -- Then access the allFilters state from it with reducer connect and filterBy key +- 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/GridFilter/reducers/filter-reducer.js b/src/components/GridFilter/reducers/filter-reducer.js index a174d393f..8bba8c09d 100644 --- a/src/components/GridFilter/reducers/filter-reducer.js +++ b/src/components/GridFilter/reducers/filter-reducer.js @@ -3,7 +3,7 @@ import { JOIN_OPERATORS } from "../utils"; const INITIAL_STATE = { id: null, - joinOperator: "all", + joinOperator: JOIN_OPERATORS.ALL, filterValues: [], parsedFilter: [] }; diff --git a/src/components/GridFilter/utils.js b/src/components/GridFilter/utils.js index e2f1db30c..c96fe1ff8 100644 --- a/src/components/GridFilter/utils.js +++ b/src/components/GridFilter/utils.js @@ -1,16 +1,36 @@ +import T from "i18n-react/dist/i18n-react"; + export const OPERATORS = { - IS: { value: "==", label: "is" }, - LIKE: { value: "=@", label: "like" }, - LIKE_START: { value: "@@", label: "like start" }, - IS_NOT: { value: "<>", label: "is not" }, - HAS: { value: ">>", label: "has" }, - HAS_NOT: { value: "!>>", label: "has not" }, - LESS: { value: "<", label: "less than" }, - LESS_OR_EQUAL: { value: "<=", label: "less than or equal to" }, - GREATER: { value: ">", label: "greater than" }, - GREATER_OR_EQUAL: { value: ">=", label: "greater than or equal to" }, - BETWEEN: { value: "[]", label: "between" }, - BETWEEN_STRICT: { value: "()", label: "between strict" } + 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: "all", ANY: "any" }; +export const JOIN_OPERATORS = { + ALL: T.translate("grid_filter.operators.all"), + ANY: T.translate("grid_filter.operators.any") +}; diff --git a/src/i18n/en.json b/src/i18n/en.json index ae8a42b5c..2bf1365f0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4221,6 +4221,22 @@ "filters": "Filters", "clear_filters": "Clear Filters", "cancel": "Cancel", - "apply_filters": "Apply Filters" + "apply_filters": "Apply 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/src/pages/summit_speakers/summit-speakers-list-page/index.js b/src/pages/summit_speakers/summit-speakers-list-page/index.js index 22813b9c0..c70a7a532 100644 --- a/src/pages/summit_speakers/summit-speakers-list-page/index.js +++ b/src/pages/summit_speakers/summit-speakers-list-page/index.js @@ -18,8 +18,11 @@ import { Pagination } from "react-bootstrap"; import SelectableTable from "openstack-uicore-foundation/lib/components/table-selectable"; import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"; -import GridFilter from "../../../components/GridFilter"; -import useGridFilter from "../../../components/GridFilter/hooks/useGridFilter"; +import { + GridFilter, + useGridFilter, + OPERATORS +} from "../../../components/GridFilter"; import { exportSummitSpeakers, getSpeakersBySummit, @@ -38,8 +41,11 @@ import { unselectAllSummitSubmitters, unselectSummitSubmitter } from "../../../actions/submitter-actions"; - -import { SpeakersSources as sources } from "../../../utils/constants"; +import { getMediaUploads } from "../../../actions/media-upload-actions"; +import { + MAX_PER_PAGE, + SpeakersSources as sources +} from "../../../utils/constants"; import "../../../styles/speakers-list-page.less"; import SendEmailModal from "./components/send-email-modal"; @@ -57,11 +63,11 @@ const selectionStatusOptions = [ { label: "Alternate/Rejected", value: "alternate_rejected" } ]; -const getCriterias = (summit) => [ +const getCriterias = (summit, mediaUploadTypes) => [ { key: "presentations_selection_plan_id", label: "Selection Plan", - operators: [{ value: "==", label: "is" }], + operators: [OPERATORS.IS], values: { type: "select", props: { @@ -76,7 +82,7 @@ const getCriterias = (summit) => [ { key: "presentations_track_id", label: "Track", - operators: [{ value: "==", label: "is" }], + operators: [OPERATORS.IS], values: { type: "select", props: { @@ -88,7 +94,7 @@ const getCriterias = (summit) => [ { key: "presentations_type_id", label: "Activity Type", - operators: [{ value: "==", label: "is" }], + operators: [OPERATORS.IS], values: { type: "select", props: { @@ -103,7 +109,7 @@ const getCriterias = (summit) => [ { key: "selection_status", label: "Selection Status", - operators: [{ value: "==", label: "is" }], + operators: [OPERATORS.IS], values: { type: "select", props: { @@ -164,7 +170,7 @@ const getCriterias = (summit) => [ { key: "presentations_track_group_id", label: "Track Group", - operators: [{ value: "==", label: "is" }], + operators: [OPERATORS.IS], values: { type: "select", props: { @@ -177,18 +183,35 @@ const getCriterias = (summit) => [ } }, { - key: "has_media_upload_with_type", + key: "media_upload_with_type", label: "Media Upload Type", - operators: [ - { value: ">>", label: "has" }, - { value: "!>>", label: "has not" } - ], + operators: [OPERATORS.HAS, OPERATORS.HAS_NOT], values: { type: "select", props: { - options: [{ value: "async", label: "Async" }], - placeholder: "Filter by MediaUploads Type" + options: mediaUploadTypes.map((type) => ({ + value: type.id, + label: type.name + })), + placeholder: "Filter by Media Upload Type" + } + }, + customParser: (f) => { + const filter = []; + + if (f.operator === OPERATORS.HAS.value) { + const value = Array.isArray(filter.value) + ? filter.value.join("||") + : filter.value; + filter.push(`has_media_upload_with_type==${value}`); + } else { + const value = Array.isArray(filter.value) + ? filter.value.join("&&") + : filter.value; + filter.push(`has_not_media_upload_with_type==${value}`); } + + return filter; } } ]; @@ -213,6 +236,8 @@ const SummitSpeakersListPage = ({ history, speakersProps, submittersProps, + mediaUploadTypes, + getMediaUploads, getSpeakersBySummit, getSubmittersBySummit, exportSummitSpeakers, @@ -229,12 +254,23 @@ const SummitSpeakersListPage = ({ const [source, setSource] = useState(sources.speakers); const isSpeakerMode = source === sources.speakers; const subjectProps = isSpeakerMode ? speakersProps : submittersProps; - const { parsedFilter } = useGridFilter(FILTER_ID); + const { parsedFilter, resetFilters } = useGridFilter(FILTER_ID); + + useEffect(() => { + if (currentSummit) { + getMediaUploads("", 1, MAX_PER_PAGE, "name", 1); + } + }, [currentSummit]); + // reset filters if source changes useEffect(() => { initSubmittersList(); initSpeakersList(); + resetFilters(); + }, [currentSummit, source]); + // fetch speakers/submitters list if filters change + useEffect(() => { if (currentSummit) { getBySummit(); } @@ -398,7 +434,10 @@ const SummitSpeakersListPage = ({ />
- +
- - - -
- - ); -}; - -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/GridFilter/__tests__/Dropdown.test.jsx b/src/components/GridFilter/__tests__/Dropdown.test.jsx deleted file mode 100644 index abf88a588..000000000 --- a/src/components/GridFilter/__tests__/Dropdown.test.jsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @jest-environment jsdom - */ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import Dropdown from "../components/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/GridFilter/__tests__/GridFilter.test.jsx b/src/components/GridFilter/__tests__/GridFilter.test.jsx deleted file mode 100644 index 6cb648bbc..000000000 --- a/src/components/GridFilter/__tests__/GridFilter.test.jsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @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 "../index"; - -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/GridFilter/__tests__/ToggleButtons.test.jsx b/src/components/GridFilter/__tests__/ToggleButtons.test.jsx deleted file mode 100644 index e13b9820a..000000000 --- a/src/components/GridFilter/__tests__/ToggleButtons.test.jsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @jest-environment jsdom - */ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import ToggleButtons from "../components/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/components/GridFilter/actions/filter-actions.js b/src/components/GridFilter/actions/filter-actions.js deleted file mode 100644 index cdb345d53..000000000 --- a/src/components/GridFilter/actions/filter-actions.js +++ /dev/null @@ -1,10 +0,0 @@ -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/GridFilter/components/Dropdown.jsx b/src/components/GridFilter/components/Dropdown.jsx deleted file mode 100644 index b851d4cc8..000000000 --- a/src/components/GridFilter/components/Dropdown.jsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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/GridFilter/components/Filter.jsx b/src/components/GridFilter/components/Filter.jsx deleted file mode 100644 index 88bc318f9..000000000 --- a/src/components/GridFilter/components/Filter.jsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 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/GridFilter/components/FilterButton.jsx b/src/components/GridFilter/components/FilterButton.jsx deleted file mode 100644 index 38e5dabd3..000000000 --- a/src/components/GridFilter/components/FilterButton.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 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/GridFilter/components/RoundButton.jsx b/src/components/GridFilter/components/RoundButton.jsx deleted file mode 100644 index 887595059..000000000 --- a/src/components/GridFilter/components/RoundButton.jsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 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/GridFilter/components/ToggleButtons.jsx b/src/components/GridFilter/components/ToggleButtons.jsx deleted file mode 100644 index 542a3a580..000000000 --- a/src/components/GridFilter/components/ToggleButtons.jsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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/GridFilter/components/ValueInput/index.jsx b/src/components/GridFilter/components/ValueInput/index.jsx deleted file mode 100644 index 5c457e2fb..000000000 --- a/src/components/GridFilter/components/ValueInput/index.jsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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/GridFilter/hooks/useGridFilter.jsx b/src/components/GridFilter/hooks/useGridFilter.jsx deleted file mode 100644 index fbfca1732..000000000 --- a/src/components/GridFilter/hooks/useGridFilter.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useDispatch, useSelector } from "react-redux"; -import { saveFilters } from "../actions/filter-actions"; -import { JOIN_OPERATORS } from "../utils"; - -export const EMPTY_FILTER = { - criteria: null, - operator: null, - value: null, - id: "new" -}; - -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/GridFilter/index.js b/src/components/GridFilter/index.js deleted file mode 100644 index fd8c830d8..000000000 --- a/src/components/GridFilter/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { default as GridFilter } from "./GridFilter"; -export { OPERATORS, JOIN_OPERATORS } from "./utils"; -export { default as useGridFilter, EMPTY_FILTER } 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/GridFilter/readme.md b/src/components/GridFilter/readme.md deleted file mode 100644 index 52e07302e..000000000 --- a/src/components/GridFilter/readme.md +++ /dev/null @@ -1,133 +0,0 @@ -## 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/GridFilter/reducers/all-filters-reducer.js b/src/components/GridFilter/reducers/all-filters-reducer.js deleted file mode 100644 index dacdbf346..000000000 --- a/src/components/GridFilter/reducers/all-filters-reducer.js +++ /dev/null @@ -1,43 +0,0 @@ -import { LOGOUT_USER } from "openstack-uicore-foundation/lib/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/GridFilter/reducers/filter-reducer.js b/src/components/GridFilter/reducers/filter-reducer.js deleted file mode 100644 index a0b7d4a74..000000000 --- a/src/components/GridFilter/reducers/filter-reducer.js +++ /dev/null @@ -1,34 +0,0 @@ -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/GridFilter/utils.js b/src/components/GridFilter/utils.js deleted file mode 100644 index c96fe1ff8..000000000 --- a/src/components/GridFilter/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -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") -}; diff --git a/src/i18n/en.json b/src/i18n/en.json index dbeced3fe..6e09b6b04 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4211,33 +4211,5 @@ "country": "Country", "save": "Save", "cancel": "Cancel" - }, - "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/src/pages/summit_speakers/summit-speakers-list-page/index.js b/src/pages/summit_speakers/summit-speakers-list-page/index.js index a189a74e5..d918c235c 100644 --- a/src/pages/summit_speakers/summit-speakers-list-page/index.js +++ b/src/pages/summit_speakers/summit-speakers-list-page/index.js @@ -22,7 +22,7 @@ import { GridFilter, useGridFilter, OPERATORS -} from "../../../components/GridFilter"; +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import { exportSummitSpeakers, getSpeakersBySummit, diff --git a/src/store.js b/src/store.js index 7d398010b..a28be6fd9 100644 --- a/src/store.js +++ b/src/store.js @@ -16,7 +16,7 @@ import { loggedUserReducer } from "openstack-uicore-foundation/lib/security/redu import thunk from "redux-thunk"; import { persistStore, persistCombineReducers } from "redux-persist"; import storage from "redux-persist/es/storage"; -import { allFiltersReducer } from "./components/GridFilter"; +import { allFiltersReducer } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import baseReducer from "./reducers/base-reducer"; import currentSummitReducer from "./reducers/summits/current-summit-reducer"; import directoryReducer from "./reducers/summits/directory-reducer"; diff --git a/yarn.lock b/yarn.lock index d130cb695..f8722e6b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9056,10 +9056,10 @@ open@^10.0.3: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -openstack-uicore-foundation@5.0.22: - version "5.0.22" - resolved "https://registry.npmjs.org/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.22.tgz#5e30b2d71fe854b08edc08a40723e8bc01f07423" - integrity sha512-vM+K8soybtHALLCIBPccwA3tFef9xxXGQqSCKOIahwgazVzgMHlt+2hM4cDUvenGkkHf/WNogDfNCumEryJ0/Q== +openstack-uicore-foundation@5.0.21-beta.1: + version "5.0.21-beta.1" + resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.21-beta.1.tgz#f20aaf7025635009328254f024e0fe143e9aed60" + integrity sha512-Y5AUEEe9UXVIIBb8ZIM7qtc0RPn6xemyJm7BOT0kwekwcuMISR8RCjUbBh0FzZtn+SC1AmhABr/nLlJGEBlf3Q== optionator@^0.9.1: version "0.9.4" From e61f8cd2d6bda1cf96eea145f48a1cc3ff6ab671 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 21 May 2026 12:06:19 -0300 Subject: [PATCH 9/9] chore: PR review --- package.json | 4 +- src/actions/speaker-actions.js | 1 - src/actions/submitter-actions.js | 5 +- .../components/send-email-modal.js | 2 + .../summit-speakers-list-page/index.js | 112 +++++++++++------- yarn.lock | 12 +- 6 files changed, 79 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index 9d9a103a9..d1efda7c9 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "moment": "^2.29.1", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.33", - "openstack-uicore-foundation": "5.0.21-beta.1", + "openstack-uicore-foundation": "5.0.23-beta.1", "p-limit": "^6.1.0", "path-browserify": "^1.0.1", "postcss-loader": "^6.2.1", @@ -124,7 +124,7 @@ "react-switch": "^6.0.0", "react-tooltip": "^5.28.0", "react-window": "^1.8.10", - "redux": "^3.7.2", + "redux": "^4.2.1", "redux-persist": "^5.10.0", "redux-thunk": "^2.3.0", "segmented-control": "0.1.12", diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index f1c785b9c..7c35e17a6 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -828,7 +828,6 @@ export const getSpeakersBySummit = page, perPage, term, - ...filters, currentSummitId: currentSummit.id } )(params)(dispatch).then(() => { diff --git a/src/actions/submitter-actions.js b/src/actions/submitter-actions.js index 689232a02..c99775329 100644 --- a/src/actions/submitter-actions.js +++ b/src/actions/submitter-actions.js @@ -108,7 +108,6 @@ export const getSubmittersBySummit = page, perPage, term, - ...filters, currentSummitId: currentSummit.id } )(params)(dispatch).then(() => { @@ -121,7 +120,7 @@ export const exportSummitSubmitters = term = null, order = "id", orderDir = DEFAULT_ORDER_DIR, - filters = {}, + filters = [], source = null ) => async (dispatch, getState) => { @@ -180,7 +179,7 @@ export const sendSubmitterEmails = ( /* eslint-disable */ term = null, - filters = {}, + filters = [], testRecipient = "", excerptRecipient = "", // not used only left to keep the signature diff --git a/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js b/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js index d07637d82..283948dc4 100644 --- a/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js +++ b/src/pages/summit_speakers/summit-speakers-list-page/components/send-email-modal.js @@ -325,6 +325,7 @@ const SendEmailModal = ({
@@ -337,6 +338,7 @@ const SendEmailModal = ({
[ }, customParser: (f) => { 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; - case "accepted_alternate": - filter.push("has_rejected_presentations==false"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==true"); - break; - case "accepted_rejected": - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==true"); - filter.push("has_alternate_presentations==false"); - break; - case "alternate_rejected": - filter.push("has_rejected_presentations==true"); - filter.push("has_accepted_presentations==false"); - filter.push("has_alternate_presentations==true"); - break; - case "accepted": - filter.push("has_accepted_presentations==true"); - break; - case "rejected": - filter.push("has_rejected_presentations==true"); - break; - case "alternate": - filter.push("has_alternate_presentations==true"); - break; - default: - break; - } + + if (Array.isArray(f.value) && f.value.length > 0) { + // if user chose a combined option then we ignore single options, only single options can be combined + const combinedOptions = [ + "only_rejected", + "only_accepted", + "only_alternate", + "accepted_alternate", + "accepted_rejected", + "alternate_rejected" + ]; + const hasCombinedOption = f.value.some((v) => + combinedOptions.includes(v) + ); + + f.value.forEach((val) => { + switch (val) { + 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; + case "accepted_alternate": + filter.push("has_rejected_presentations==false"); + filter.push("has_accepted_presentations==true"); + filter.push("has_alternate_presentations==true"); + break; + case "accepted_rejected": + filter.push("has_rejected_presentations==true"); + filter.push("has_accepted_presentations==true"); + filter.push("has_alternate_presentations==false"); + break; + case "alternate_rejected": + filter.push("has_rejected_presentations==true"); + filter.push("has_accepted_presentations==false"); + filter.push("has_alternate_presentations==true"); + break; + case "accepted": + if (!hasCombinedOption) { + filter.push("has_accepted_presentations==true"); + } + break; + case "rejected": + if (!hasCombinedOption) { + filter.push("has_rejected_presentations==true"); + } + break; + case "alternate": + if (!hasCombinedOption) { + filter.push("has_alternate_presentations==true"); + } + break; + default: + break; + } + }); } return filter; } @@ -281,7 +303,7 @@ const SummitSpeakersListPage = ({ }, [currentSummit, source, parsedFilter.join(",")]); const getBySummit = (params = {}) => { - const { term, page, perPage, order, orderDir } = subjectProps; + const { term, currentPage: page, perPage, order, orderDir } = subjectProps; const mergedParams = { term, page, perPage, order, orderDir, ...params }; diff --git a/yarn.lock b/yarn.lock index f8722e6b4..6a2656481 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9056,10 +9056,10 @@ open@^10.0.3: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -openstack-uicore-foundation@5.0.21-beta.1: - version "5.0.21-beta.1" - resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.21-beta.1.tgz#f20aaf7025635009328254f024e0fe143e9aed60" - integrity sha512-Y5AUEEe9UXVIIBb8ZIM7qtc0RPn6xemyJm7BOT0kwekwcuMISR8RCjUbBh0FzZtn+SC1AmhABr/nLlJGEBlf3Q== +openstack-uicore-foundation@5.0.23-beta.1: + version "5.0.23-beta.1" + resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.23-beta.1.tgz#c83fe409e8caa9a5b6d54e690fc8985dd3146c8a" + integrity sha512-uD9M/lS0T/stPYiD0RGF1KayZvbkTO2s1X8PKZKZm5wZ1aSy7Q80w9zvL7BpwYjjlRaK9HJuPOIaTUITiFWQtg== optionator@^0.9.1: version "0.9.4" @@ -10260,7 +10260,7 @@ redux-thunk@^2.3.0: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q== -redux@^3.6.0, redux@^3.7.2: +redux@^3.6.0: version "3.7.2" resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b" integrity sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A== @@ -10270,7 +10270,7 @@ redux@^3.6.0, redux@^3.7.2: loose-envify "^1.1.0" symbol-observable "^1.0.3" -redux@^4.0.0, redux@^4.0.4, redux@^4.2.0: +redux@^4.0.0, redux@^4.0.4, redux@^4.2.0, redux@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==