From 331931a10e9f1b8fc55a69944495da978e20aec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 14 Aug 2026 21:46:13 -0300 Subject: [PATCH 01/11] fix: migrate email log list page into MUI/uicore components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/async-select-input.js | 61 ++++ src/components/mui/chip-multi-select.js | 56 ++++ src/i18n/en.json | 5 +- src/pages/emails/email-log-list-page.js | 355 +++++++++++------------ src/styles/email-logs-page.less | 7 - 5 files changed, 298 insertions(+), 186 deletions(-) create mode 100644 src/components/mui/async-select-input.js create mode 100644 src/components/mui/chip-multi-select.js delete mode 100644 src/styles/email-logs-page.less diff --git a/src/components/mui/async-select-input.js b/src/components/mui/async-select-input.js new file mode 100644 index 000000000..eb6ce8b03 --- /dev/null +++ b/src/components/mui/async-select-input.js @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; +import { Autocomplete, CircularProgress, TextField } from "@mui/material"; + +const AsyncSelectInput = ({ + id, + label, + value, + onChange, + queryFunction, + formatOption = (item) => ({ value: item.id, label: item.name }) +}) => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchOptions = (input) => { + setLoading(true); + queryFunction(input, (results) => { + setOptions(results.map(formatOption)); + setLoading(false); + }); + }; + + useEffect(() => { + fetchOptions(""); + }, []); + + return ( + option.label || ""} + isOptionEqualToValue={(option, val) => option.value === val.value} + onInputChange={(ev, newInput) => fetchOptions(newInput)} + onChange={(ev, selected) => + onChange({ target: { id, value: selected?.value ?? "" } }) + } + renderInput={(params) => ( + + {loading && } + {params.InputProps?.endAdornment} + + ) + } + }} + /> + )} + /> + ); +}; + +export default AsyncSelectInput; diff --git a/src/components/mui/chip-multi-select.js b/src/components/mui/chip-multi-select.js new file mode 100644 index 000000000..2a0e59efa --- /dev/null +++ b/src/components/mui/chip-multi-select.js @@ -0,0 +1,56 @@ +import React from "react"; +import { + Box, + Chip, + FormControl, + InputLabel, + MenuItem, + OutlinedInput, + Select +} from "@mui/material"; +import CancelIcon from "@mui/icons-material/Cancel"; + +const ChipMultiSelect = ({ id, label, value, onChange, options, sx }) => { + const handleDelete = (val) => + onChange({ target: { value: value.filter((v) => v !== val) } }); + + return ( + + {label} + + + ); +}; + +export default ChipMultiSelect; diff --git a/src/i18n/en.json b/src/i18n/en.json index 4b290f79f..c4cd1dbff 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3276,6 +3276,7 @@ } }, "emails": { + "emails": "Emails", "email_templates": "Email Templates", "sent": "Sent", "templates": "Templates", @@ -3330,6 +3331,9 @@ "email_logs": "Email Logs", "email_list": "Email List", "apply_filters": "Apply Filters", + "enabled_filters": "Enabled Filters", + "all": "All", + "not_sent": "Not Sent", "email_templates": "Email Templates", "subject": "Subject", "from_email": "From Email", @@ -3339,7 +3343,6 @@ "payload": "Payload", "select_fields": "Show Columns", "placeholders": { - "select_fields": "Select data to display", "template": "Filter by Template", "sent_date_from": "Filter Sent Date from", "sent_date_to": "Filter Sent Date to" diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 417c16571..1796a482c 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -14,37 +14,47 @@ 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 FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import Table from "openstack-uicore-foundation/lib/components/table" -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import DateTimePicker from "openstack-uicore-foundation/lib/components/inputs/datetimepicker"; +import { + Box, + Button, + Grid2, + ToggleButton, + ToggleButtonGroup +} from "@mui/material"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { SegmentedControl } from "segmented-control"; -import { getSentEmails } from "../../actions/email-actions"; -import "../../styles/email-logs-page.less"; -import EmailTemplateInput from "../../components/inputs/email-template-input"; +import { getSentEmails, queryTemplates } from "../../actions/email-actions"; +import ChipMultiSelect from "../../components/mui/chip-multi-select"; +import AsyncSelectInput from "../../components/mui/async-select-input"; import { DATE_FILTER_ARRAY_SIZE, DEFAULT_CURRENT_PAGE } from "../../utils/constants"; -const SentEmailListPage = function ({ +const SentEmailListPage = ({ emails, - lastPage, currentPage, term, order, orderDir, totalEmails, - match, perPage, filters, - getSentEmails, - ...props -}) { + getSentEmails +}) => { useEffect(() => { - getSentEmails(term, currentPage, perPage, order, orderDir, filters); + getSentEmails( + term, + DEFAULT_CURRENT_PAGE, + perPage, + order, + orderDir, + filters + ); }, []); const defaultFilters = { @@ -70,10 +80,21 @@ const SentEmailListPage = function ({ getSentEmails(term, newPage, perPage, order, orderDir, emailFilters); }; - const handleSort = (index, key, dir, func) => { + const handleSort = (index, key, dir) => { getSentEmails(term, currentPage, perPage, key, dir, emailFilters); }; + const handlePerPageChange = (newPerPage) => { + getSentEmails( + term, + DEFAULT_CURRENT_PAGE, + newPerPage, + order, + orderDir, + emailFilters + ); + }; + const handleSearch = (newTerm) => { getSentEmails( newTerm, @@ -113,37 +134,19 @@ const SentEmailListPage = function ({ } }; - const handleChangeDateFilter = (ev, lastDate) => { - const { value, id } = ev.target; - const newDateFilter = emailFilters[id]; + const handleChangeDateFilter = (value, lastDate) => { + const newDateFilter = emailFilters.sent_date_filter; setEmailFilters({ ...emailFilters, - [id]: lastDate - ? [newDateFilter[0], value.unix()] - : [value.unix(), newDateFilter[1]] + sent_date_filter: lastDate + ? [newDateFilter[0], value ? value.unix() : null] + : [value ? value.unix() : null, newDateFilter[1]] }); }; const handleEmailFilterChange = (ev) => { - const { type, id } = ev.target; - let { value } = ev.target; - if (type === "operatorinput") { - value = Array.isArray(value) - ? value - : `${ev.target.operator}${ev.target.value}`; - if (id === "duration_filter") { - value = Array.isArray(value) - ? value - : `${ev.target.operator}${ev.target.value}`; - } - } - if (type === "mediatypeinput") { - value = { - operator: ev.target.operator, - value: ev.target.value - }; - } + const { id, value } = ev.target; setEmailFilters({ ...emailFilters, [id]: value }); }; @@ -170,11 +173,11 @@ const SentEmailListPage = function ({ }; const fieldNames = [ - { columnKey: "last_error", value: "last_error" }, + { columnKey: "last_error", header: "last_error" }, { columnKey: "payload", - value: "payload", - render: (row, data) =>
{data}
+ header: "payload", + render: (row, data) => {data} } ]; @@ -183,7 +186,7 @@ const SentEmailListPage = function ({ .map((f2) => { let c = { columnKey: f2.columnKey, - value: T.translate(`email_logs.${f2.value}`), + header: T.translate(`email_logs.${f2.header}`), sortable: f2.sortable }; // optional fields @@ -198,20 +201,20 @@ const SentEmailListPage = function ({ { columnKey: "id", value: T.translate("general.id"), sortable: true }, { columnKey: "template", - value: T.translate("email_logs.email_templates"), + header: T.translate("email_logs.email_templates"), styles: { wordBreak: "break-all" }, sortable: true }, - { columnKey: "subject", value: T.translate("email_logs.subject") }, - { columnKey: "from_email", value: T.translate("email_logs.from_email") }, + { columnKey: "subject", header: T.translate("email_logs.subject") }, + { columnKey: "from_email", header: T.translate("email_logs.from_email") }, { columnKey: "to_email", - value: T.translate("email_logs.to_email"), + header: T.translate("email_logs.to_email"), styles: { wordBreak: "break-word" } }, { columnKey: "sent_date", - value: T.translate("email_logs.sent_date"), + header: T.translate("email_logs.sent_date"), sortable: true } ]; @@ -236,172 +239,168 @@ const SentEmailListPage = function ({ return (
-

- {" "} - {T.translate("email_logs.email_list")} ({totalEmails}) -

-
-
- -
-
-
-
-
- {T.translate("email_logs.email_list")} + + + + {totalEmails} {T.translate("emails.emails")} + + + + + + + + + + + -
-
- -
-
-
+ + + + {enabledFilters.includes("is_sent_filter") && ( -
- handleSetSentFilter(newValue)} - style={{ + + handleSetSentFilter(newValue)} + sx={(theme) => ({ width: "100%", height: 40, - color: "#337ab7", - fontSize: "10px" - }} - /> -
+ "& .MuiToggleButtonGroup-grouped": { + flex: 1, + "&.Mui-selected": { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + "&:hover": { + backgroundColor: theme.palette.primary.dark + } + } + } + })} + > + + {T.translate("email_logs.all")} + + + {T.translate("emails.sent")} + + + {T.translate("email_logs.not_sent")} + + +
)} {enabledFilters.includes("sent_date_filter") && ( - <> -
+ + handleChangeDateFilter(ev, false)} + label={T.translate("email_logs.placeholders.sent_date_from")} + format="YYYY-MM-DD HH:mm" + onChange={(value) => handleChangeDateFilter(value, false)} timezone="UTC" value={epochToMomentTimeZone( emailFilters.sent_date_filter[0], "UTC" )} - className="event-list-date-picker" + slotProps={{ + textField: { fullWidth: true, size: "small" } + }} /> -
-
+ + handleChangeDateFilter(ev, true)} + label={T.translate("email_logs.placeholders.sent_date_to")} + format="YYYY-MM-DD HH:mm" + onChange={(value) => handleChangeDateFilter(value, true)} timezone="UTC" value={epochToMomentTimeZone( emailFilters.sent_date_filter[1], "UTC" )} - className="event-list-date-picker" + slotProps={{ + textField: { fullWidth: true, size: "small" } + }} /> -
- + + )} {enabledFilters.includes("template_filter") && ( -
- + ({ + value: t.identifier, + label: t.identifier + })} /> -
+ )} -
-
-
- - -
-
+ + + + {emails.length === 0 &&
{T.translate("emails.no_emails")}
} {emails.length > 0 && ( - <> -
- - - - + )} ); diff --git a/src/styles/email-logs-page.less b/src/styles/email-logs-page.less deleted file mode 100644 index 3e3ad68c4..000000000 --- a/src/styles/email-logs-page.less +++ /dev/null @@ -1,7 +0,0 @@ -.email-logs-table-wrapper { - overflow: auto; -} - -.email-table-payload { - max-width: 300px; -} From fec3705eb537871a693b93f07a57cd0428b8e40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 18 Aug 2026 12:53:24 -0300 Subject: [PATCH 02/11] fix: clean localization provider, adjust async input, add onError for query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/email-actions.js | 7 +- src/components/mui/async-select-input.js | 61 ---------------- src/pages/emails/email-log-list-page.js | 89 +++++++++++++++++++----- 3 files changed, 77 insertions(+), 80 deletions(-) delete mode 100644 src/components/mui/async-select-input.js diff --git a/src/actions/email-actions.js b/src/actions/email-actions.js index 2e0799df0..63f6ff57c 100644 --- a/src/actions/email-actions.js +++ b/src/actions/email-actions.js @@ -251,7 +251,7 @@ const normalizeEntity = (entity) => { return normalizedEntity; }; -export const queryTemplates = debounce(async (input, callback) => { +export const queryTemplates = debounce(async (input, callback, onError) => { const accessToken = await getAccessTokenSafely(); const endpoint = URI(`${window.EMAIL_API_BASE_URL}/api/v1/mail-templates`); @@ -274,7 +274,10 @@ export const queryTemplates = debounce(async (input, callback) => { callback(options); }) - .catch(fetchErrorHandler); + .catch((err) => { + fetchErrorHandler(err); + if (onError) onError(err); + }); }, DEBOUNCE_WAIT); /** ********************************************************************************************************* */ diff --git a/src/components/mui/async-select-input.js b/src/components/mui/async-select-input.js deleted file mode 100644 index eb6ce8b03..000000000 --- a/src/components/mui/async-select-input.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Autocomplete, CircularProgress, TextField } from "@mui/material"; - -const AsyncSelectInput = ({ - id, - label, - value, - onChange, - queryFunction, - formatOption = (item) => ({ value: item.id, label: item.name }) -}) => { - const [options, setOptions] = useState([]); - const [loading, setLoading] = useState(false); - - const fetchOptions = (input) => { - setLoading(true); - queryFunction(input, (results) => { - setOptions(results.map(formatOption)); - setLoading(false); - }); - }; - - useEffect(() => { - fetchOptions(""); - }, []); - - return ( - option.label || ""} - isOptionEqualToValue={(option, val) => option.value === val.value} - onInputChange={(ev, newInput) => fetchOptions(newInput)} - onChange={(ev, selected) => - onChange({ target: { id, value: selected?.value ?? "" } }) - } - renderInput={(params) => ( - - {loading && } - {params.InputProps?.endAdornment} - - ) - } - }} - /> - )} - /> - ); -}; - -export default AsyncSelectInput; diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 1796a482c..5be5b7af9 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -15,21 +15,21 @@ import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import { + Autocomplete, Box, Button, + CircularProgress, Grid2, + TextField, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import { getSentEmails, queryTemplates } from "../../actions/email-actions"; import ChipMultiSelect from "../../components/mui/chip-multi-select"; -import AsyncSelectInput from "../../components/mui/async-select-input"; import { DATE_FILTER_ARRAY_SIZE, DEFAULT_CURRENT_PAGE @@ -75,6 +75,26 @@ const SentEmailListPage = ({ ...filters }); const [selectedColumns, setSelectedColumns] = useState([]); + const [templateOptions, setTemplateOptions] = useState([]); + const [templateLoading, setTemplateLoading] = useState(false); + + const fetchTemplateOptions = (input) => { + setTemplateLoading(true); + queryTemplates( + input, + (results) => { + setTemplateOptions( + results.map((t) => ({ value: t.identifier, label: t.identifier })) + ); + setTemplateLoading(false); + }, + () => setTemplateLoading(false) + ); + }; + + useEffect(() => { + fetchTemplateOptions(""); + }, []); const handlePageChange = (newPage) => { getSentEmails(term, newPage, perPage, order, orderDir, emailFilters); @@ -328,11 +348,12 @@ const SentEmailListPage = ({ )} {enabledFilters.includes("sent_date_filter") && ( - + <> handleChangeDateFilter(value, false)} timezone="UTC" value={epochToMomentTimeZone( @@ -347,7 +368,8 @@ const SentEmailListPage = ({ handleChangeDateFilter(value, true)} timezone="UTC" value={epochToMomentTimeZone( @@ -359,20 +381,53 @@ const SentEmailListPage = ({ }} /> - + )} {enabledFilters.includes("template_filter") && ( - ({ - value: t.identifier, - label: t.identifier - })} + option.label || ""} + isOptionEqualToValue={(option, val) => option.value === val.value} + onInputChange={(ev, newInput) => fetchTemplateOptions(newInput)} + onChange={(ev, selected) => + handleEmailFilterChange({ + target: { + id: "template_filter", + value: selected?.value ?? "" + } + }) + } + renderInput={(params) => ( + + {templateLoading && ( + + )} + {params.InputProps?.endAdornment} + + ) + } + }} + /> + )} /> )} From 2da35c45d00811339044bc7cbf2d69e23d9e6bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 18 Aug 2026 13:19:38 -0300 Subject: [PATCH 03/11] fix: remove chip multi select, add ref on tempalte request from coderabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/mui/chip-multi-select.js | 56 ---------------- src/pages/emails/email-log-list-page.js | 86 ++++++++++++++++++++----- 2 files changed, 69 insertions(+), 73 deletions(-) delete mode 100644 src/components/mui/chip-multi-select.js diff --git a/src/components/mui/chip-multi-select.js b/src/components/mui/chip-multi-select.js deleted file mode 100644 index 2a0e59efa..000000000 --- a/src/components/mui/chip-multi-select.js +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import { - Box, - Chip, - FormControl, - InputLabel, - MenuItem, - OutlinedInput, - Select -} from "@mui/material"; -import CancelIcon from "@mui/icons-material/Cancel"; - -const ChipMultiSelect = ({ id, label, value, onChange, options, sx }) => { - const handleDelete = (val) => - onChange({ target: { value: value.filter((v) => v !== val) } }); - - return ( - - {label} - - - ); -}; - -export default ChipMultiSelect; diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 5be5b7af9..de904672d 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import { @@ -19,7 +19,12 @@ import { Box, Button, CircularProgress, + FormControl, Grid2, + InputLabel, + MenuItem, + OutlinedInput, + Select, TextField, ToggleButton, ToggleButtonGroup @@ -29,7 +34,6 @@ import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import { getSentEmails, queryTemplates } from "../../actions/email-actions"; -import ChipMultiSelect from "../../components/mui/chip-multi-select"; import { DATE_FILTER_ARRAY_SIZE, DEFAULT_CURRENT_PAGE @@ -77,18 +81,24 @@ const SentEmailListPage = ({ const [selectedColumns, setSelectedColumns] = useState([]); const [templateOptions, setTemplateOptions] = useState([]); const [templateLoading, setTemplateLoading] = useState(false); + const templateRequestSeqRef = useRef(0); const fetchTemplateOptions = (input) => { + const seq = ++templateRequestSeqRef.current; setTemplateLoading(true); queryTemplates( input, (results) => { + if (seq !== templateRequestSeqRef.current) return; setTemplateOptions( results.map((t) => ({ value: t.identifier, label: t.identifier })) ); setTemplateLoading(false); }, - () => setTemplateLoading(false) + () => { + if (seq !== templateRequestSeqRef.current) return; + setTemplateLoading(false); + } ); }; @@ -295,13 +305,35 @@ const SentEmailListPage = ({ - + + + {T.translate("email_logs.enabled_filters")} + + + - - - - {enabledFilters.includes("is_sent_filter") && ( - - handleSetSentFilter(newValue)} - sx={(theme) => ({ - width: "100%", - height: 40, - "& .MuiToggleButtonGroup-grouped": { - flex: 1, - "&.Mui-selected": { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - "&:hover": { - backgroundColor: theme.palette.primary.dark - } - } - } - })} - > - - {T.translate("email_logs.all")} - - - {T.translate("emails.sent")} - - - {T.translate("email_logs.not_sent")} - - - - )} - {enabledFilters.includes("sent_date_filter") && ( - <> - - handleChangeDateFilter(value, false)} - timezone="UTC" - value={epochToMomentTimeZone( - emailFilters.sent_date_filter[0], - "UTC" - )} - slotProps={{ - textField: { fullWidth: true, size: "small" } - }} - /> - - - handleChangeDateFilter(value, true)} - timezone="UTC" - value={epochToMomentTimeZone( - emailFilters.sent_date_filter[1], - "UTC" - )} - slotProps={{ - textField: { fullWidth: true, size: "small" } - }} - /> - - - )} - {enabledFilters.includes("template_filter") && ( - - option.label || ""} - isOptionEqualToValue={(option, val) => option.value === val.value} - onInputChange={(ev, newInput) => fetchTemplateOptions(newInput)} - onChange={(ev, selected) => - handleEmailFilterChange({ - target: { - id: "template_filter", - value: selected?.value ?? "" - } - }) - } - renderInput={(params) => ( - - {templateLoading && ( - - )} - {params.InputProps?.endAdornment} - - ) - } - }} - /> - )} - /> - - )} - diff --git a/src/reducers/emails/__tests__/email-log-list-reducer.test.js b/src/reducers/emails/__tests__/email-log-list-reducer.test.js index a928f18a4..7d0c286ff 100644 --- a/src/reducers/emails/__tests__/email-log-list-reducer.test.js +++ b/src/reducers/emails/__tests__/email-log-list-reducer.test.js @@ -17,7 +17,7 @@ function createDefaultState() { emails: [], term: "", order: "id", - orderDir: 0, + orderDir: 1, currentPage: 1, lastPage: 1, perPage: 10, diff --git a/src/reducers/emails/email-log-list-reducer.js b/src/reducers/emails/email-log-list-reducer.js index 8c5b6197d..026668d45 100644 --- a/src/reducers/emails/email-log-list-reducer.js +++ b/src/reducers/emails/email-log-list-reducer.js @@ -19,7 +19,7 @@ const DEFAULT_STATE = { emails: [], term: "", order: "id", - orderDir: 0, + orderDir: 1, currentPage: 1, lastPage: 1, perPage: 10, From 86a27276fda80347c2823e0d84a1a8a98ae6ed12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 8 Sep 2026 10:00:33 -0300 Subject: [PATCH 07/11] fix: hide join operatores on email log list page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/pages/emails/email-log-list-page.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 1b6f90f5d..1f01f8c45 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -195,7 +195,11 @@ const SentEmailListPage = ({ placeholder={T.translate("emails.placeholders.search_emails")} /> - + From 2577350d582fba34be506779409ff6c0f72e4196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 8 Sep 2026 10:40:28 -0300 Subject: [PATCH 08/11] fix: adjust filter names, simplify customParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../emails/email-log-list-page.helpers.js | 25 ++++++++++--------- src/pages/emails/email-log-list-page.js | 7 ++---- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/pages/emails/email-log-list-page.helpers.js b/src/pages/emails/email-log-list-page.helpers.js index 39b6e9d02..187d77e8f 100644 --- a/src/pages/emails/email-log-list-page.helpers.js +++ b/src/pages/emails/email-log-list-page.helpers.js @@ -18,7 +18,7 @@ import { DATE_FILTER_ARRAY_SIZE } from "../../utils/constants"; export const getCriterias = () => [ { - key: "is_sent", + key: "is_sent_filter", label: T.translate("email_logs.is_sent_filter"), operators: [OPERATORS.IS], values: { @@ -32,7 +32,7 @@ export const getCriterias = () => [ } }, { - key: "sent_date", + key: "sent_date_filter", label: T.translate("email_logs.sent_date"), operators: [OPERATORS.BEFORE, OPERATORS.AFTER], values: { @@ -41,7 +41,7 @@ export const getCriterias = () => [ } }, { - key: "template", + key: "template_filter", label: T.translate("email_logs.template_filter"), operators: [OPERATORS.IS], values: { @@ -52,22 +52,23 @@ export const getCriterias = () => [ multiple: false } }, - customParser: (f) => { - const value = f.value?.value ?? f.value; - return value ? [`${f.criteria}${f.operator}${value}`] : undefined; - } + customParser: (f) => [`template_filter==${f.value.value}`] } ]; -export const buildEmailFiltersFromGridFilter = (filterValues) => { - const isSentEntry = filterValues.find((f) => f.criteria === "is_sent"); +export const buildEmailFilters = (filterValues) => { + const isSentEntry = filterValues.find((f) => f.criteria === "is_sent_filter"); const afterEntry = filterValues.find( - (f) => f.criteria === "sent_date" && f.operator === OPERATORS.AFTER.value + (f) => + f.criteria === "sent_date_filter" && f.operator === OPERATORS.AFTER.value ); const beforeEntry = filterValues.find( - (f) => f.criteria === "sent_date" && f.operator === OPERATORS.BEFORE.value + (f) => + f.criteria === "sent_date_filter" && f.operator === OPERATORS.BEFORE.value + ); + const templateEntry = filterValues.find( + (f) => f.criteria === "template_filter" ); - const templateEntry = filterValues.find((f) => f.criteria === "template"); const sentDateFilter = Array(DATE_FILTER_ARRAY_SIZE).fill(null); sentDateFilter[0] = afterEntry?.value ?? null; diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 1f01f8c45..27fa86c4e 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -31,10 +31,7 @@ import { } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import { getSentEmails } from "../../actions/email-actions"; import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; -import { - buildEmailFiltersFromGridFilter, - getCriterias -} from "./email-log-list-page.helpers"; +import { buildEmailFilters, getCriterias } from "./email-log-list-page.helpers"; const FILTER_ID = "email_log_list"; @@ -49,7 +46,7 @@ const SentEmailListPage = ({ getSentEmails }) => { const { parsedFilter, filterValues } = useGridFilter(FILTER_ID); - const emailFilters = buildEmailFiltersFromGridFilter(filterValues); + const emailFilters = buildEmailFilters(filterValues); useEffect(() => { getSentEmails( From 501a2c00e3d940ef87e62d81b5e7f4046d26829f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 8 Sep 2026 17:16:28 -0300 Subject: [PATCH 09/11] fix: update uicore version, update reducer, restore action, set col width for payload, update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/email-actions.js | 9 +- .../email-log-list-page.helpers.test.js | 60 +++++ .../__tests__/email-log-list-page.test.js | 248 ++++++++++++++++++ src/pages/emails/email-log-list-page.js | 9 +- src/reducers/emails/email-log-list-reducer.js | 12 +- 5 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 src/pages/emails/__tests__/email-log-list-page.helpers.test.js create mode 100644 src/pages/emails/__tests__/email-log-list-page.test.js diff --git a/src/actions/email-actions.js b/src/actions/email-actions.js index 63f6ff57c..62f54d116 100644 --- a/src/actions/email-actions.js +++ b/src/actions/email-actions.js @@ -251,7 +251,7 @@ const normalizeEntity = (entity) => { return normalizedEntity; }; -export const queryTemplates = debounce(async (input, callback, onError) => { +export const queryTemplates = debounce(async (input, callback) => { const accessToken = await getAccessTokenSafely(); const endpoint = URI(`${window.EMAIL_API_BASE_URL}/api/v1/mail-templates`); @@ -274,10 +274,7 @@ export const queryTemplates = debounce(async (input, callback, onError) => { callback(options); }) - .catch((err) => { - fetchErrorHandler(err); - if (onError) onError(err); - }); + .catch(fetchErrorHandler); }, DEBOUNCE_WAIT); /** ********************************************************************************************************* */ @@ -355,7 +352,7 @@ export const getSentEmails = createAction(RECEIVE_EMAILS), `${window.EMAIL_API_BASE_URL}/api/v1/mails`, authErrorHandler, - { order, orderDir, term, filters } + { order, orderDir, term, page, perPage, filters } )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/pages/emails/__tests__/email-log-list-page.helpers.test.js b/src/pages/emails/__tests__/email-log-list-page.helpers.test.js new file mode 100644 index 000000000..f6123a591 --- /dev/null +++ b/src/pages/emails/__tests__/email-log-list-page.helpers.test.js @@ -0,0 +1,60 @@ +import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import { buildEmailFilters } from "../email-log-list-page.helpers"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../actions/email-actions", () => ({ + queryTemplates: jest.fn() +})); + +describe("buildEmailFilters", () => { + test("maps AFTER to sent_date_filter[0] and BEFORE to sent_date_filter[1]", () => { + const result = buildEmailFilters([ + { + criteria: "sent_date_filter", + operator: OPERATORS.BEFORE.value, + value: 200 + }, + { + criteria: "sent_date_filter", + operator: OPERATORS.AFTER.value, + value: 100 + } + ]); + + expect(result.sent_date_filter).toEqual([100, 200]); + }); + + test("returns [null, null] when no date criteria is present", () => { + const result = buildEmailFilters([]); + + expect(result.sent_date_filter).toEqual([null, null]); + }); + + test("unwraps template_filter from the async option object", () => { + const result = buildEmailFilters([ + { + criteria: "template_filter", + operator: OPERATORS.IS.value, + value: { value: "welcome-email", label: "welcome-email" } + } + ]); + + expect(result.template_filter).toBe("welcome-email"); + }); + + test("defaults template_filter to an empty string when absent", () => { + const result = buildEmailFilters([]); + + expect(result.template_filter).toBe(""); + }); + + test("defaults is_sent_filter to null when absent", () => { + const result = buildEmailFilters([]); + + expect(result.is_sent_filter).toBeNull(); + }); +}); diff --git a/src/pages/emails/__tests__/email-log-list-page.test.js b/src/pages/emails/__tests__/email-log-list-page.test.js new file mode 100644 index 000000000..2b693c958 --- /dev/null +++ b/src/pages/emails/__tests__/email-log-list-page.test.js @@ -0,0 +1,248 @@ +import React from "react"; +import { screen, act, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { createStore, combineReducers, applyMiddleware } from "redux"; +import thunk from "redux-thunk"; +import { Provider } from "react-redux"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import EmailLogListPage from "../email-log-list-page"; +import emailLogListReducer from "../../../reducers/emails/email-log-list-reducer"; +import * as methods from "../../../utils/methods"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn() +})); + +jest.mock("openstack-uicore-foundation/lib/components/mui/grid-filter", () => ({ + __esModule: true, + ...jest.requireActual( + "openstack-uicore-foundation/lib/components/mui/grid-filter" + ), + GridFilter: () => null, + useGridFilter: () => ({ parsedFilter: [], filterValues: [] }) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/search-input", + () => ({ + __esModule: true, + default: () => null + }) +); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, columns, onPageChange, onPerPageChange }) => ( +
+ + + {data.map((row) => ( +
+ {columns.map((col) => ( + + {col.render ? col.render(row) : row[col.columnKey]} + + ))} +
+ ))} +
+ ) +})); + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const buildStore = (emailLogListOverrides) => + createStore( + combineReducers({ + currentSummitState: (state = { currentSummit: { id: 1 } }) => state, + emailLogListState: emailLogListReducer + }), + { + emailLogListState: { + ...emailLogListReducer(undefined, {}), + ...emailLogListOverrides + } + }, + applyMiddleware(thunk) + ); + +describe("SentEmailListPage", () => { + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + getRequest.mockImplementation( + () => () => () => + Promise.resolve({ + response: { total: 0, last_page: 1, current_page: 1, data: [] } + }) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Show Columns", () => { + const mockPayloadObject = { + summit_reassign_ticket_till_date: "Monday 1 January 2027 12:00 AM PST", + order_owner_full_name: "Jane Doe", + order_owner_company: "Acme Corp", + order_owner_email: "jane.doe@acme.test", + owner_first_name: "John", + owner_last_name: "Smith", + owner_company: "Acme Corp", + owner_email: "john.smith@acme.test", + owner_full_name: "John Smith", + support_email: "support@example.test", + summit_virtual_site_oauth2_client_id: "abcXYZ123.client", + summit_marketing_site_oauth2_client_id: "abcXYZ123.client", + summit_marketing_site_oauth2_scopes: + "openid profile email offline_access", + summit_id: 99, + summit_name: "Test Summit 2027", + summit_logo: "https://example.test/logo.svg", + summit_virtual_site_url: + "https://idp.example.test/auth/password/set/token?client_id=abcXYZ123.client", + summit_marketing_site_url: + "https://idp.example.test/auth/password/set/token?client_id=abcXYZ123.client", + raw_summit_virtual_site_url: "https://virtual.example.test/a", + raw_summit_marketing_site_url: "https://marketing.example.test", + summit_date: "January 1, 2027", + summit_dates_label: "January 1-3, 2027", + summit_schedule_url: "", + summit_site_url: "https://marketing.example.test/", + registration_link: null, + virtual_event_site_link: "https://virtual.example.test/a", + main_venue_address: "123 Main St, Testville, TS", + summit_marketing_site_url_magic_link: "", + edit_ticket_link: + "https://marketing.example.test/#login=1&email=john.smith@acme.test&BackUrl=/a/my-tickets", + EMAIL_TEMPLATE_GENERIC_BANNER: "https://example.test/banner.png", + EMAIL_TEMPLATE_DRAFT_INSTRUCTIONS_URL: + "

https://docs.example.test/draft

", + EMAIL_TEMPLATE_DRAFT_DUE_DATE: "March 1 - 5", + EMAIL_TEMPLATE_FINAL_DUE_DATE: "April 1", + EMAIL_TEMPLATE_REVIEW_PERIOD: "March 1 - 5, 2027", + EMAIL_TEMPLATE_SPEAKER_PORTAL_URL: "https://speaker.example.test/plans", + EMAIL_TEMPLATE_SPEAKER_ACCEPTED_INTRO: + "

Thank you for your submission.

", + EMAIL_TEMPLATE_SPEAKER_ACCEPTED_NEXT_STEPS: "

Next steps go here.

", + EMAIL_TEMPLATE_GREETING: "Hello", + EMAIL_TEMPLATE_GENERIC_SPEAKER_BANNER: + "https://example.test/speaker-banner.png", + EMAIL_TEMPLATE_TICKET_TOP_GRAPHIC: "https://example.test/ticket-top.jpg", + EMAIL_TEMPLATE_TICKET_BOTTOM_GRAPHIC: + "https://example.test/ticket-bottom.jpg", + EMAIL_TEMPLATE_PRIMARY_COLOR: "#111111", + EMAIL_TEMPLATE_SECONDARY_COLOR: "#eeeeee" + }; + + // The reducer hands the page an already-serialized string, not the object. + const serializedPayload = JSON.stringify(mockPayloadObject); + + it("renders the full serialized payload when Payload is selected in Show Columns", async () => { + const store = buildStore({ + emails: [ + { + id: 1, + template: "welcome-email", + subject: "Welcome", + from_email: "from@test.com", + to_email: "to@test.com", + sent_date: "2020-01-01", + last_error: "N/A", + payload: serializedPayload + } + ], + totalEmails: 1 + }); + + render( + + + + ); + + await act(async () => { + await userEvent.click( + screen.getByRole("combobox", { name: "email_logs.select_fields" }) + ); + }); + await act(async () => { + await userEvent.click( + screen.getByRole("option", { name: "email_logs.payload" }) + ); + }); + + expect(screen.getByTestId("cell-payload")).toHaveTextContent( + serializedPayload + ); + }); + }); + + describe("pagination", () => { + const requestedMeta = []; + + beforeEach(() => { + requestedMeta.length = 0; + getRequest.mockImplementation( + ( + requestActionCreator, + _receiveActionCreator, + _url, + _errorHandler, + meta + ) => + () => + (dispatch) => { + requestedMeta.push(meta); + dispatch(requestActionCreator(meta)); + return Promise.resolve({ + response: { + total: 1, + last_page: 1, + current_page: meta.page, + data: [] + } + }); + } + ); + }); + + it("keeps sending the updated page size after a page change", async () => { + const store = buildStore({ emails: [{ id: 1 }] }); + + await act(async () => { + render( + + + + ); + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "set-per-page-50" }) + ); + }); + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "go-to-page-2" }) + ); + }); + + expect(store.getState().emailLogListState.perPage).toBe(50); + expect(requestedMeta.at(-1).perPage).toBe(50); + expect(requestedMeta.at(-1).page).toBe(2); + }); + }); +}); diff --git a/src/pages/emails/email-log-list-page.js b/src/pages/emails/email-log-list-page.js index 27fa86c4e..aa9ec3d91 100644 --- a/src/pages/emails/email-log-list-page.js +++ b/src/pages/emails/email-log-list-page.js @@ -104,7 +104,8 @@ const SentEmailListPage = ({ { columnKey: "payload", header: "payload", - render: (row, data) => {data} + width: 300, + render: (row) => row.payload } ]; @@ -121,6 +122,8 @@ const SentEmailListPage = ({ if (f2.hasOwnProperty("render")) c = { ...c, render: f2.render }; + if (f2.hasOwnProperty("width")) c = { ...c, width: f2.width }; + return c; }); @@ -129,15 +132,13 @@ const SentEmailListPage = ({ { columnKey: "template", header: T.translate("email_logs.email_templates"), - styles: { wordBreak: "break-all" }, sortable: true }, { columnKey: "subject", header: T.translate("email_logs.subject") }, { columnKey: "from_email", header: T.translate("email_logs.from_email") }, { columnKey: "to_email", - header: T.translate("email_logs.to_email"), - styles: { wordBreak: "break-word" } + header: T.translate("email_logs.to_email") }, { columnKey: "sent_date", diff --git a/src/reducers/emails/email-log-list-reducer.js b/src/reducers/emails/email-log-list-reducer.js index 026668d45..e132a9372 100644 --- a/src/reducers/emails/email-log-list-reducer.js +++ b/src/reducers/emails/email-log-list-reducer.js @@ -34,9 +34,17 @@ const emailLogListReducer = (state = DEFAULT_STATE, action) => { return DEFAULT_STATE; } case REQUEST_EMAILS: { - const { order, orderDir, term, filters } = payload; + const { order, orderDir, term, page, perPage, filters } = payload; - return { ...state, order, orderDir, term, filters }; + return { + ...state, + order, + orderDir, + term, + currentPage: page, + perPage, + filters + }; } case RECEIVE_EMAILS: { let { total, last_page, current_page, data } = payload.response; From b68f33fc87ff4801238988a8f5ae6fe64ea6758a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 9 Sep 2026 11:01:26 -0300 Subject: [PATCH 10/11] fix: remove pagination test on email log list page, keep reducer tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/email-log-list-page.test.js | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/src/pages/emails/__tests__/email-log-list-page.test.js b/src/pages/emails/__tests__/email-log-list-page.test.js index 2b693c958..ec42f273b 100644 --- a/src/pages/emails/__tests__/email-log-list-page.test.js +++ b/src/pages/emails/__tests__/email-log-list-page.test.js @@ -188,61 +188,4 @@ describe("SentEmailListPage", () => { ); }); }); - - describe("pagination", () => { - const requestedMeta = []; - - beforeEach(() => { - requestedMeta.length = 0; - getRequest.mockImplementation( - ( - requestActionCreator, - _receiveActionCreator, - _url, - _errorHandler, - meta - ) => - () => - (dispatch) => { - requestedMeta.push(meta); - dispatch(requestActionCreator(meta)); - return Promise.resolve({ - response: { - total: 1, - last_page: 1, - current_page: meta.page, - data: [] - } - }); - } - ); - }); - - it("keeps sending the updated page size after a page change", async () => { - const store = buildStore({ emails: [{ id: 1 }] }); - - await act(async () => { - render( - - - - ); - }); - - await act(async () => { - await userEvent.click( - screen.getByRole("button", { name: "set-per-page-50" }) - ); - }); - await act(async () => { - await userEvent.click( - screen.getByRole("button", { name: "go-to-page-2" }) - ); - }); - - expect(store.getState().emailLogListState.perPage).toBe(50); - expect(requestedMeta.at(-1).perPage).toBe(50); - expect(requestedMeta.at(-1).page).toBe(2); - }); - }); }); From 6f0884a3348d49331a75375a2be0d6ff4b29c5ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 9 Sep 2026 12:25:30 -0300 Subject: [PATCH 11/11] fix: adjust orderDir to display as default by descending id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/reducers/emails/__tests__/email-log-list-reducer.test.js | 2 +- src/reducers/emails/email-log-list-reducer.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reducers/emails/__tests__/email-log-list-reducer.test.js b/src/reducers/emails/__tests__/email-log-list-reducer.test.js index 7d0c286ff..5b3c34930 100644 --- a/src/reducers/emails/__tests__/email-log-list-reducer.test.js +++ b/src/reducers/emails/__tests__/email-log-list-reducer.test.js @@ -17,7 +17,7 @@ function createDefaultState() { emails: [], term: "", order: "id", - orderDir: 1, + orderDir: -1, currentPage: 1, lastPage: 1, perPage: 10, diff --git a/src/reducers/emails/email-log-list-reducer.js b/src/reducers/emails/email-log-list-reducer.js index e132a9372..bfcc26bfd 100644 --- a/src/reducers/emails/email-log-list-reducer.js +++ b/src/reducers/emails/email-log-list-reducer.js @@ -19,7 +19,7 @@ const DEFAULT_STATE = { emails: [], term: "", order: "id", - orderDir: 1, + orderDir: -1, currentPage: 1, lastPage: 1, perPage: 10,