From d4fc82aeb79700deff3b29e0d5d946a4f7baa6e3 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 28 Jul 2026 18:22:33 -0300 Subject: [PATCH 01/10] chore: core migration - WIP --- src/actions/audit-log-actions.js | 26 +- src/components/audit-logs/index.js | 337 +++++++++----------------- src/pages/audit-log/audit-log-page.js | 23 +- 3 files changed, 123 insertions(+), 263 deletions(-) diff --git a/src/actions/audit-log-actions.js b/src/actions/audit-log-actions.js index 0b9cb3d9a..1c56c90d0 100644 --- a/src/actions/audit-log-actions.js +++ b/src/actions/audit-log-actions.js @@ -19,11 +19,7 @@ import { authErrorHandler, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; -import { - getAccessTokenSafely, - isNumericString, - parseDateRangeFilter -} from "../utils/methods"; +import { getAccessTokenSafely, isNumericString } from "../utils/methods"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR } from "../utils/constants"; export const CLEAR_LOG_PARAMS = "CLEAR_LOG_PARAMS"; @@ -32,22 +28,8 @@ export const RECEIVE_LOG = "RECEIVE_LOG"; const DEFAULT_PER_PAGE_AUDIT_LOG = 100; -const parseFilters = (filters, term = null) => { - const filter = []; - - if (filters.created_date_filter) { - parseDateRangeFilter(filter, filters.created_date_filter, "created"); - } - - if ( - filters.hasOwnProperty("user_id_filter") && - Array.isArray(filters.user_id_filter) && - filters.user_id_filter.length > 0 - ) { - filter.push( - `user_id==${filters.user_id_filter.map((t) => t.id).join("||")}` - ); - } +const parseFilters = (filters = [], term = null) => { + const filter = Array.isArray(filters) ? [...filters] : []; if (term) { const escapedTerm = escapeFilterValue(term); @@ -73,7 +55,7 @@ export const getAuditLog = perPage = DEFAULT_PER_PAGE_AUDIT_LOG, order = null, orderDir = DEFAULT_ORDER_DIR, - filters = {} + filters = [] ) => async (dispatch, getState) => { const { currentSummitState } = getState(); diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 81cb2a9d6..beddd2280 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -1,105 +1,110 @@ import React, { useEffect, useState } from "react"; -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 MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import DateTimePicker from "openstack-uicore-foundation/lib/components/inputs/datetimepicker"; +import { Grid2 } from "@mui/material"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import { + GridFilter, + useGridFilter, + OPERATORS +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import { queryMembers } from "openstack-uicore-foundation/lib/utils/query-actions"; import T from "i18n-react"; -import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { Pagination } from "react-bootstrap"; import { connect } from "react-redux"; import { clearAuditLogParams as clearAuditLogParamsAction, getAuditLog as getAuditLogAction } from "../../actions/audit-log-actions"; -import { - DATE_FILTER_ARRAY_SIZE, - DEFAULT_CURRENT_PAGE -} from "../../utils/constants"; +import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; + +const FILTER_ID = "audit_log_list"; + +const getCriterias = () => [ + { + key: "user_id", + label: T.translate("audit_log.placeholders.user_id"), + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { + queryFunction: queryMembers, + formatOption: (m) => ({ + value: m.id, + label: `${m.first_name} ${m.last_name} (${m.email})` + }), + multiple: true + } + }, + customParser: (f) => [`user_id==${f.value.map((s) => s.value).join("||")}`] + }, + { + key: "created", + label: T.translate("audit_log.date"), + operators: [OPERATORS.BEFORE, OPERATORS.AFTER], + values: { + type: "datetime", + props: { + mode: "datetime" + } + } + } +]; const AuditLogs = ({ entityFilter = [], - currentSummit, term, logEntries, perPage, - lastPage, currentPage, + totalLogEntries, order, orderDir, columns, getAuditLog, - clearAuditLogParams, - filters + clearAuditLogParams }) => { - const [page, setPage] = useState(currentPage); const [searchTerm, setSearchTerm] = useState(term); - const defaultFilters = { - user_id_filter: [], - created_date_filter: Array(DATE_FILTER_ARRAY_SIZE).fill(null) - }; - - const [enabledFilters, setEnabledFilters] = useState( - Object.keys(filters).filter((e) => - Array.isArray(filters[e]) - ? filters[e]?.some((a) => a !== null) - : filters[e]?.length > 0 - ) - ); - const [auditLogFilters, setAuditLogFilters] = useState({ - ...defaultFilters, - ...filters - }); - - const filtersDdl = [ - { label: "Created", value: "created_date_filter" }, - { label: "Member", value: "user_id_filter" } - ]; - - const auditLogTableOptions = { - sortCol: order, - sortDir: orderDir, - actions: {} - }; + const { parsedFilter, resetFilters } = useGridFilter(FILTER_ID); const auditLogColumns = [ { columnKey: "created", - value: T.translate("audit_log.date"), + header: T.translate("audit_log.date"), sortable: true }, { columnKey: "action_description", - value: T.translate("audit_log.action"), + header: T.translate("audit_log.action"), sortable: false }, { columnKey: "event_id", - value: T.translate("audit_log.event"), + header: T.translate("audit_log.event"), sortable: true }, - { columnKey: "user", value: T.translate("audit_log.user"), sortable: false } + { + columnKey: "user", + header: T.translate("audit_log.user"), + sortable: false + } ]; const showColumns = columns ? auditLogColumns.filter((c) => columns.includes(c.columnKey)) : auditLogColumns; - const handleSort = (_index, key, dir) => { - setPage(1); + const handleSort = (key, dir) => { getAuditLog( entityFilter, searchTerm, - 1, + DEFAULT_CURRENT_PAGE, perPage, key, dir, - auditLogFilters + parsedFilter ); }; const handlePageChange = (newPage) => { - setPage(newPage); getAuditLog( entityFilter, searchTerm, @@ -107,87 +112,35 @@ const AuditLogs = ({ perPage, order, orderDir, - auditLogFilters + parsedFilter ); }; - const handleSearch = (newTerm) => { - setSearchTerm(newTerm); - setPage(1); + const handlePerPageChange = (newPerPage) => { getAuditLog( entityFilter, - newTerm, - 1, - perPage, + searchTerm, + DEFAULT_CURRENT_PAGE, + newPerPage, order, orderDir, - auditLogFilters + parsedFilter ); }; - const handleDDLSortByLabel = (ddlArray) => - ddlArray.sort((a, b) => a.label.localeCompare(b.label)); - - const handleFiltersChange = (ev) => { - const { value } = ev.target; - if (value.length < enabledFilters.length) { - if (value.length === 0) { - setEnabledFilters(value); - setAuditLogFilters(defaultFilters); - } else { - const removedFilter = enabledFilters.filter( - (e) => !value.includes(e) - )[0]; - const defaultValue = Array.isArray(auditLogFilters[removedFilter]) - ? [] - : ""; - const newEventFilters = { - ...auditLogFilters, - [removedFilter]: defaultValue - }; - setEnabledFilters(value); - setAuditLogFilters(newEventFilters); - } - } else { - setEnabledFilters(value); - } - }; - - const handleChangeDateFilter = (ev, lastDate) => { - const { value, id } = ev.target; - const newDateFilter = auditLogFilters[id]; - - setAuditLogFilters({ - ...auditLogFilters, - [id]: lastDate - ? [newDateFilter[0], value.unix()] - : [value.unix(), newDateFilter[1]] - }); - }; - - const handleAuditLogFilterChange = (ev) => { - const { value, id } = ev.target; - setAuditLogFilters({ ...auditLogFilters, [id]: value }); - }; - - const handleApplyAuditLogFilters = () => { - setPage(1); + const handleSearch = (newTerm) => { + setSearchTerm(newTerm); getAuditLog( entityFilter, - searchTerm, - 1, + newTerm, + DEFAULT_CURRENT_PAGE, perPage, order, orderDir, - auditLogFilters + parsedFilter ); }; - const getUserFieldValue = (member) => - `${member.first_name ?? ""} ${member.last_name ?? ""} (${ - member.email ?? member.id - })`; - useEffect(() => { getAuditLog( entityFilter, @@ -196,132 +149,62 @@ const AuditLogs = ({ perPage, order, orderDir, - filters + parsedFilter ); - - return () => { + }, [parsedFilter.join(",")]); + + // AuditLogs is reused in different contexts (the standalone audit log + // page, an event's edit form, a ticket's edit page) sharing one Redux- + // backed FILTER_ID, so a filter applied in one context must not leak into + // another mount — reset it along with the log params on unmount. + useEffect( + () => () => { clearAuditLogParams(); - }; - }, []); + resetFilters(); + }, + [] + ); + + const tableOptions = { + sortCol: order, + sortDir: orderDir + }; return ( <> -
-
- + + -
-
- -
- -
-
- -
-
- -
-
-
- {enabledFilters.includes("user_id_filter") && ( -
- -
- )} - {enabledFilters.includes("created_date_filter") && ( - <> -
- handleChangeDateFilter(ev, false)} - timezone={currentSummit.time_zone_id} - value={epochToMomentTimeZone( - auditLogFilters.created_date_filter[0], - currentSummit.time_zone_id - )} - className="event-list-date-picker" - /> -
-
- handleChangeDateFilter(ev, true)} - timezone={currentSummit.time_zone_id} - value={epochToMomentTimeZone( - auditLogFilters.created_date_filter[1], - currentSummit.time_zone_id - )} - className="event-list-date-picker" - /> -
- - )} -
+ + + + + {logEntries.length === 0 && (
{T.translate("audit_log.no_log_entries")}
)} {logEntries.length > 0 && ( - <> - - - + )} ); diff --git a/src/pages/audit-log/audit-log-page.js b/src/pages/audit-log/audit-log-page.js index 4012421f1..ebcf36aca 100644 --- a/src/pages/audit-log/audit-log-page.js +++ b/src/pages/audit-log/audit-log-page.js @@ -9,26 +9,21 @@ * 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 AuditLogs from "../../components/audit-logs"; -import { Breadcrumb } from "react-breadcrumbs"; -const AuditLogPage = ({ totalLogEntries, match }) => { - return ( -
- -

- {" "} - {T.translate("audit_log.log_entries")} ({totalLogEntries}) -

- -
- ); -}; +const AuditLogPage = ({ totalLogEntries }) => ( +
+

+ {T.translate("audit_log.log_entries")} ({totalLogEntries}) +

+ +
+); const mapStateToProps = ({ auditLogState }) => ({ ...auditLogState From 83566450075f6587622121294a4f3c6d7b46224c Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 29 Jul 2026 10:28:44 -0300 Subject: [PATCH 02/10] chore: adjust styles --- src/components/audit-logs/index.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index beddd2280..2ddc054d1 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -74,7 +74,9 @@ const AuditLogs = ({ { columnKey: "action_description", header: T.translate("audit_log.action"), - sortable: false + sortable: false, + width: 600, + truncateText: true }, { columnKey: "event_id", @@ -175,16 +177,18 @@ const AuditLogs = ({ - + - - @@ -197,6 +201,7 @@ const AuditLogs = ({ Date: Wed, 29 Jul 2026 12:32:19 -0300 Subject: [PATCH 03/10] chore: a few bug fixes --- src/components/audit-logs/index.js | 9 +++++++++ src/i18n/en.json | 1 + src/reducers/audit_log/audit-log-reducer.js | 7 ++----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 2ddc054d1..2a52b35e9 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -8,6 +8,7 @@ import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import { queryMembers } from "openstack-uicore-foundation/lib/utils/query-actions"; +import CustomAlert from "openstack-uicore-foundation/lib/components/mui/custom-alert"; import T from "i18n-react"; import { connect } from "react-redux"; import { @@ -64,6 +65,11 @@ const AuditLogs = ({ }) => { const [searchTerm, setSearchTerm] = useState(term); const { parsedFilter, resetFilters } = useGridFilter(FILTER_ID); + const userTimeZone = new Intl.DateTimeFormat(undefined, { + timeZoneName: "long" + }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName").value; const auditLogColumns = [ { @@ -192,6 +198,9 @@ const AuditLogs = ({ + {logEntries.length === 0 && (
{T.translate("audit_log.no_log_entries")}
diff --git a/src/i18n/en.json b/src/i18n/en.json index c3c0e36b2..9a27ef9a3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3791,6 +3791,7 @@ "event": "Event", "order": "Order", "apply_filters": "Apply Filters", + "timezone_info": "All dates appear in user local timezone {tz}", "placeholders": { "search_log": "Search By Action / Entity Id / OTEL Span ID / OTEL Trace ID / Ray ID", "user_id": "Filter by User", diff --git a/src/reducers/audit_log/audit-log-reducer.js b/src/reducers/audit_log/audit-log-reducer.js index dee803c66..572a1179e 100644 --- a/src/reducers/audit_log/audit-log-reducer.js +++ b/src/reducers/audit_log/audit-log-reducer.js @@ -11,8 +11,7 @@ * limitations under the License. * */ -import moment from "moment-timezone"; -import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import { epochToMoment } from "openstack-uicore-foundation/lib/utils/methods"; import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; import { CLEAR_LOG_PARAMS, @@ -66,9 +65,7 @@ const auditLogReducer = (state = DEFAULT_STATE, action) => { user: `${userFullName || e.user.email} ${ e.user?.id ? `(${e.user.id})` : "" }`, - created: moment( - epochToMomentTimeZone(e.created, state.summitTZ) - ).format("MMMM Do YYYY, h:mm a"), + created: epochToMoment(e.created).format("MMMM Do YYYY, h:mm a"), action: formatAuditLog(logEntryAction) }; }); From 137bdb4a514d287643fe247dcb958e95f6fb09bb Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 29 Jul 2026 13:18:43 -0300 Subject: [PATCH 04/10] fix: use parsed action instead of raw action_description --- src/components/audit-logs/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 2a52b35e9..1f7f53fcd 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -78,7 +78,7 @@ const AuditLogs = ({ sortable: true }, { - columnKey: "action_description", + columnKey: "action", header: T.translate("audit_log.action"), sortable: false, width: 600, From 7e4ad525cf6fda8a4ed85a58fec3b7b17887fa86 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 17 Aug 2026 12:36:28 -0300 Subject: [PATCH 05/10] chore: pr review fixes --- src/actions/audit-log-actions.js | 3 +-- src/components/audit-logs/index.js | 5 ++++- src/components/forms/event-form/index.js | 1 + src/pages/audit-log/audit-log-page.js | 5 ++++- src/pages/orders/edit-ticket-page.js | 1 + src/reducers/audit_log/audit-log-reducer.js | 8 +++----- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/actions/audit-log-actions.js b/src/actions/audit-log-actions.js index 1c56c90d0..58442a248 100644 --- a/src/actions/audit-log-actions.js +++ b/src/actions/audit-log-actions.js @@ -61,7 +61,6 @@ export const getAuditLog = const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - const summitTZ = currentSummit.time_zone.name; const summitFilter = [`summit_id==${currentSummit.id}`]; dispatch(startLoading()); @@ -92,7 +91,7 @@ export const getAuditLog = createAction(RECEIVE_LOG), `${window.AUDIT_LOG_API_BASE_URL}/api/v1/audit-logs`, authErrorHandler, - { page, perPage, order, orderDir, term, summitTZ, filters } + { page, perPage, order, orderDir, term, filters } )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 1f7f53fcd..c243ff2b6 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -51,6 +51,7 @@ const getCriterias = () => [ ]; const AuditLogs = ({ + filterId, entityFilter = [], term, logEntries, @@ -64,7 +65,9 @@ const AuditLogs = ({ clearAuditLogParams }) => { const [searchTerm, setSearchTerm] = useState(term); - const { parsedFilter, resetFilters } = useGridFilter(FILTER_ID); + const { parsedFilter, resetFilters } = useGridFilter( + `${FILTER_ID}_${filterId}` + ); const userTimeZone = new Intl.DateTimeFormat(undefined, { timeZoneName: "long" }) diff --git a/src/components/forms/event-form/index.js b/src/components/forms/event-form/index.js index 12a2addf6..dedab726f 100644 --- a/src/components/forms/event-form/index.js +++ b/src/components/forms/event-form/index.js @@ -2240,6 +2240,7 @@ class EventForm extends React.Component { handleClick={this.toggleSection.bind(this, "audit_log")} > (

{T.translate("audit_log.log_entries")} ({totalLogEntries})

- + ); diff --git a/src/pages/orders/edit-ticket-page.js b/src/pages/orders/edit-ticket-page.js index b688da878..994976bbf 100644 --- a/src/pages/orders/edit-ticket-page.js +++ b/src/pages/orders/edit-ticket-page.js @@ -516,6 +516,7 @@ const EditTicketPage = ({ > {entity.badge && ( { return DEFAULT_STATE; } case REQUEST_LOG: { - const { term, order, orderDir, summitTZ } = payload; - return { ...state, term, order, orderDir, summitTZ }; + const { term, order, orderDir, perPage } = payload; + return { ...state, term, order, orderDir, perPage }; } case RECEIVE_LOG: { const { current_page, total, last_page } = payload.response; From a6cd1f881e94ca6fc360573a8ec058062ee21c40 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Mon, 31 Aug 2026 13:41:33 -0300 Subject: [PATCH 06/10] chore: pr review --- .../audit-logs/__tests__/index.test.js | 279 ++++++++++++++++++ src/components/audit-logs/index.js | 26 +- 2 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 src/components/audit-logs/__tests__/index.test.js diff --git a/src/components/audit-logs/__tests__/index.test.js b/src/components/audit-logs/__tests__/index.test.js new file mode 100644 index 000000000..00b262995 --- /dev/null +++ b/src/components/audit-logs/__tests__/index.test.js @@ -0,0 +1,279 @@ +import React from "react"; +import { + fireEvent, + render, + screen, + waitFor, + within +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { Provider } from "react-redux"; +import { applyMiddleware, combineReducers, createStore } from "redux"; +import thunk from "redux-thunk"; +import { allFiltersReducer } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import AuditLogs from "../index"; +import auditLogReducer from "../../../reducers/audit_log/audit-log-reducer"; +import { getAuditLog } from "../../../actions/audit-log-actions"; +import { renderWithRedux } from "../../../utils/test-utils"; + +jest.mock("i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../actions/audit-log-actions", () => ({ + getAuditLog: jest.fn(() => ({ type: "MOCK_GET_AUDIT_LOG" })), + clearAuditLogParams: jest.fn(() => ({ type: "MOCK_CLEAR_AUDIT_LOG_PARAMS" })) +})); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryMembers: (term, callback) => + callback([ + { + id: 42, + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com" + } + ]) +})); + +// Stubs the MUI Autocomplete used by the async "user_id" value field so the +// test can select an option without driving the real popper/listbox. +jest.mock("@mui/material/Autocomplete", () => ({ + __esModule: true, + default: ({ onChange, options }) => ( + + ) +})); + +const currentSummitStateReducer = (state = { currentSummit: {} }) => state; + +const buildStore = () => + createStore( + combineReducers({ + allGridFiltersState: allFiltersReducer, + auditLogState: auditLogReducer, + currentSummitState: currentSummitStateReducer + }), + applyMiddleware(thunk) + ); + +const renderAuditLogs = (props = {}) => { + const store = buildStore(); + render( + + + + ); + return store; +}; + +// Expect a console.error PropTypes warning from GridFilter's `Re` component — +// a pre-existing gap in openstack-uicore-foundation, unrelated to this test. +describe("AuditLogs grid filter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("applying a user_id filter fetches logs with a non-empty parsed filter", async () => { + renderAuditLogs(); + + await userEvent.click( + screen.getByRole("button", { name: "grid_filter.open_filters" }) + ); + + const dialog = await screen.findByRole("dialog"); + const columnSelect = within(dialog).getAllByRole("combobox")[0]; + await userEvent.click(columnSelect); + await userEvent.click( + await screen.findByRole("option", { + name: "audit_log.placeholders.user_id" + }) + ); + + const selectUserOption = await screen.findByTestId("select-user-option"); + await waitFor(() => expect(selectUserOption).toBeEnabled()); + await userEvent.click(selectUserOption); + + await userEvent.click( + within(dialog).getByRole("button", { name: "grid_filter.apply_filters" }) + ); + + await waitFor(() => { + const appliedCall = getAuditLog.mock.calls.find( + (call) => call[6]?.length > 0 + ); + expect(appliedCall).toBeDefined(); + expect(appliedCall[6]).toEqual(["user_id==42"]); + }); + }); +}); + +const baseAuditLogState = { + term: "", + logEntries: [], + currentPage: 1, + lastPage: 1, + perPage: 10, + order: "created", + orderDir: 1, + totalLogEntries: 0 +}; + +const renderWithAuditLogState = (props = {}, auditLogState = {}) => + renderWithRedux( + , + { + initialState: { + currentSummitState: { currentSummit: {} }, + auditLogState: { ...baseAuditLogState, ...auditLogState } + } + } + ); + +describe("AuditLogs columns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Guards the master regression where this column was declared as + // "action_description" while the reducer emits "action", silently + // dropping the Action column on the ticket page (fixed in c0ef613). + test("renders the caller's column subset, wired to the reducer's `action` key", () => { + renderWithAuditLogState( + { columns: ["created", "action", "user"] }, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "Updated Event Title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 1 + } + ); + + // "created" is sortable and currently the active sort column, so MUI + // appends a visually-hidden "sorted ascending" indicator to its header + // text — assert prefixes rather than exact text for that one. + const headers = screen + .getAllByRole("columnheader") + .map((h) => h.textContent); + expect(headers).toHaveLength(3); + expect(headers[0]).toMatch(/^audit_log\.date/); + expect(headers[1]).toBe("audit_log.action"); + expect(headers[2]).toBe("audit_log.user"); + expect(screen.getByText("Updated Event Title")).toBeInTheDocument(); + }); +}); + +describe("AuditLogs sorting", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("clicking a sortable column header re-fetches with that column", () => { + renderWithAuditLogState( + {}, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "Updated Event Title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 1 + } + ); + getAuditLog.mockClear(); + + fireEvent.click(screen.getByText("audit_log.date")); + + expect(getAuditLog).toHaveBeenLastCalledWith( + [], + "", + 1, + 10, + "created", + expect.any(Number), + [] + ); + }); +}); + +describe("AuditLogs pagination", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("changing rows-per-page re-fetches with the new perPage", async () => { + renderWithAuditLogState( + {}, + { + logEntries: [ + { + id: 1, + created: "August 17th 2026, 12:00 pm", + action: "Updated Event Title", + event_id: 55, + user: "Jane Doe (7)" + } + ], + totalLogEntries: 30 + } + ); + getAuditLog.mockClear(); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(await screen.findByRole("option", { name: "20" })); + + await waitFor(() => { + expect(getAuditLog).toHaveBeenLastCalledWith( + [], + "", + 1, + 20, + "created", + 1, + [] + ); + }); + }); +}); + +describe("AuditLogs empty state", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // At zero rows MuiTable doesn't mount at all, so pagination and the + // per-page selector disappear along with it — documenting current + // behaviour rather than asserting it's desirable. + test("shows the empty message and renders no table when there are no log entries", () => { + renderWithAuditLogState({}, { logEntries: [], totalLogEntries: 0 }); + + expect(screen.getByText("audit_log.no_log_entries")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index c243ff2b6..1d6e2c8ad 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -31,11 +31,10 @@ const getCriterias = () => [ formatOption: (m) => ({ value: m.id, label: `${m.first_name} ${m.last_name} (${m.email})` - }), - multiple: true + }) } }, - customParser: (f) => [`user_id==${f.value.map((s) => s.value).join("||")}`] + customParser: (f) => [`user_id==${f.value.value}`] }, { key: "created", @@ -65,9 +64,8 @@ const AuditLogs = ({ clearAuditLogParams }) => { const [searchTerm, setSearchTerm] = useState(term); - const { parsedFilter, resetFilters } = useGridFilter( - `${FILTER_ID}_${filterId}` - ); + const gridFilterId = `${FILTER_ID}_${filterId}`; + const { parsedFilter } = useGridFilter(gridFilterId); const userTimeZone = new Intl.DateTimeFormat(undefined, { timeZoneName: "long" }) @@ -165,16 +163,10 @@ const AuditLogs = ({ }, [parsedFilter.join(",")]); // AuditLogs is reused in different contexts (the standalone audit log - // page, an event's edit form, a ticket's edit page) sharing one Redux- - // backed FILTER_ID, so a filter applied in one context must not leak into - // another mount — reset it along with the log params on unmount. - useEffect( - () => () => { - clearAuditLogParams(); - resetFilters(); - }, - [] - ); + // page, an event's edit form, a ticket's edit page), each with its own + // filterId-scoped Redux entry, so filters don't need to be reset on + // unmount — only the log params. + useEffect(() => () => clearAuditLogParams(), []); const tableOptions = { sortCol: order, @@ -198,7 +190,7 @@ const AuditLogs = ({ placeholder={T.translate("audit_log.placeholders.search_log")} onSearch={handleSearch} /> - + Date: Tue, 1 Sep 2026 11:24:51 -0300 Subject: [PATCH 07/10] chore: pr review - class name filter and fix action_description --- .../audit-logs/__tests__/index.test.js | 17 +++++++++-------- src/components/audit-logs/index.js | 2 +- src/components/forms/event-form/index.js | 5 +---- src/pages/audit-log/audit-log-page.js | 2 +- src/pages/orders/edit-ticket-page.js | 4 ++-- src/reducers/audit_log/audit-log-reducer.js | 9 +++++---- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/components/audit-logs/__tests__/index.test.js b/src/components/audit-logs/__tests__/index.test.js index 00b262995..6a97fabda 100644 --- a/src/components/audit-logs/__tests__/index.test.js +++ b/src/components/audit-logs/__tests__/index.test.js @@ -150,18 +150,19 @@ describe("AuditLogs columns", () => { jest.clearAllMocks(); }); - // Guards the master regression where this column was declared as - // "action_description" while the reducer emits "action", silently - // dropping the Action column on the ticket page (fixed in c0ef613). - test("renders the caller's column subset, wired to the reducer's `action` key", () => { + // Guards the regression where the column read the raw audit.action verb + // (create/update/delete/...) instead of the parsed audit.description + // sentence, and where the ticket page's column subset didn't match the + // reducer's field name — both must stay in sync as "action_description". + test("renders the caller's column subset, wired to the reducer's `action_description` key", () => { renderWithAuditLogState( - { columns: ["created", "action", "user"] }, + { columns: ["created", "action_description", "user"] }, { logEntries: [ { id: 1, created: "August 17th 2026, 12:00 pm", - action: "Updated Event Title", + action_description: "Updated Event Title", event_id: 55, user: "Jane Doe (7)" } @@ -197,7 +198,7 @@ describe("AuditLogs sorting", () => { { id: 1, created: "August 17th 2026, 12:00 pm", - action: "Updated Event Title", + action_description: "Updated Event Title", event_id: 55, user: "Jane Doe (7)" } @@ -234,7 +235,7 @@ describe("AuditLogs pagination", () => { { id: 1, created: "August 17th 2026, 12:00 pm", - action: "Updated Event Title", + action_description: "Updated Event Title", event_id: 55, user: "Jane Doe (7)" } diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 1d6e2c8ad..61b65ca11 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -79,7 +79,7 @@ const AuditLogs = ({ sortable: true }, { - columnKey: "action", + columnKey: "action_description", header: T.translate("audit_log.action"), sortable: false, width: 600, diff --git a/src/components/forms/event-form/index.js b/src/components/forms/event-form/index.js index dedab726f..dd0a0fadd 100644 --- a/src/components/forms/event-form/index.js +++ b/src/components/forms/event-form/index.js @@ -2241,10 +2241,7 @@ class EventForm extends React.Component { > {entity.id !== 0 && ( diff --git a/src/pages/audit-log/audit-log-page.js b/src/pages/audit-log/audit-log-page.js index a99dc3ab2..425475f32 100644 --- a/src/pages/audit-log/audit-log-page.js +++ b/src/pages/audit-log/audit-log-page.js @@ -23,7 +23,7 @@ const AuditLogPage = ({ totalLogEntries }) => ( ); diff --git a/src/pages/orders/edit-ticket-page.js b/src/pages/orders/edit-ticket-page.js index 994976bbf..06bb19314 100644 --- a/src/pages/orders/edit-ticket-page.js +++ b/src/pages/orders/edit-ticket-page.js @@ -519,9 +519,9 @@ const EditTicketPage = ({ filterId={entity.badge.id} entityFilter={[ `event_id==${entity.badge.id}`, - "class_name==SummitAttendeeBadgeAuditLog" + "class_name==SummitAttendeeBadge" ]} - columns={["created", "action", "user"]} + columns={["created", "action_description", "user"]} /> )} diff --git a/src/reducers/audit_log/audit-log-reducer.js b/src/reducers/audit_log/audit-log-reducer.js index 032e27d9f..2a885b3a4 100644 --- a/src/reducers/audit_log/audit-log-reducer.js +++ b/src/reducers/audit_log/audit-log-reducer.js @@ -50,9 +50,10 @@ const auditLogReducer = (state = DEFAULT_STATE, action) => { const { current_page, total, last_page } = payload.response; const logEntries = payload.response.data.map((e) => { - const logEntryAction = e.action.startsWith("Speaker") - ? parseSpeakerAuditLog(e.action) - : e.action; + const rawDescription = e.action_description ?? ""; + const parsedDescription = rawDescription.startsWith("Speaker") + ? parseSpeakerAuditLog(rawDescription) + : rawDescription; const userFullName = `${e.user?.first_name ?? ""} ${ e.user?.last_name ?? "" }`.trim(); @@ -64,7 +65,7 @@ const auditLogReducer = (state = DEFAULT_STATE, action) => { e.user?.id ? `(${e.user.id})` : "" }`, created: epochToMoment(e.created).format("MMMM Do YYYY, h:mm a"), - action: formatAuditLog(logEntryAction) + action_description: formatAuditLog(parsedDescription) }; }); From f30687854a7578d9d1d62ac15da242da28c2e0b5 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 09:27:47 -0300 Subject: [PATCH 08/10] chore: pr review --- src/actions/audit-log-actions.js | 14 ++--- src/components/audit-logs/index.js | 40 +++++++------- src/components/forms/event-form/index.js | 2 +- .../__tests__/audit-log-page.test.js | 53 +++++++++++++++++++ src/pages/orders/edit-ticket-page.js | 2 +- src/reducers/audit_log/audit-log-reducer.js | 3 +- 6 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 src/pages/audit-log/__tests__/audit-log-page.test.js diff --git a/src/actions/audit-log-actions.js b/src/actions/audit-log-actions.js index 58442a248..83ed67ad2 100644 --- a/src/actions/audit-log-actions.js +++ b/src/actions/audit-log-actions.js @@ -20,14 +20,16 @@ import { escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; import { getAccessTokenSafely, isNumericString } from "../utils/methods"; -import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR } from "../utils/constants"; +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + MAX_PER_PAGE +} from "../utils/constants"; export const CLEAR_LOG_PARAMS = "CLEAR_LOG_PARAMS"; export const REQUEST_LOG = "REQUEST_LOG"; export const RECEIVE_LOG = "RECEIVE_LOG"; -const DEFAULT_PER_PAGE_AUDIT_LOG = 100; - const parseFilters = (filters = [], term = null) => { const filter = Array.isArray(filters) ? [...filters] : []; @@ -50,10 +52,10 @@ const parseFilters = (filters = [], term = null) => { export const getAuditLog = ( entityFilter = [], - term = null, + term = "", page = DEFAULT_CURRENT_PAGE, - perPage = DEFAULT_PER_PAGE_AUDIT_LOG, - order = null, + perPage = MAX_PER_PAGE, + order = "created", orderDir = DEFAULT_ORDER_DIR, filters = [] ) => diff --git a/src/components/audit-logs/index.js b/src/components/audit-logs/index.js index 61b65ca11..28c65369f 100644 --- a/src/components/audit-logs/index.js +++ b/src/components/audit-logs/index.js @@ -64,6 +64,11 @@ const AuditLogs = ({ clearAuditLogParams }) => { const [searchTerm, setSearchTerm] = useState(term); + // filterId is a category literal ("standalone"/"activity"/"badge"), not a + // per-entity id: the grid filter is shared across all entities in that + // category (can leak between them, e.g. a stale date range) rather than + // reset per entity, which can't be told apart from applying a filter, or + // persisted per entity, which grows unbounded in localStorage. const gridFilterId = `${FILTER_ID}_${filterId}`; const { parsedFilter } = useGridFilter(gridFilterId); const userTimeZone = new Intl.DateTimeFormat(undefined, { @@ -101,6 +106,23 @@ const AuditLogs = ({ ? auditLogColumns.filter((c) => columns.includes(c.columnKey)) : auditLogColumns; + useEffect(() => { + // we reset pagination and search but keep the filters within a category, see comment above. + setSearchTerm(""); + getAuditLog( + entityFilter, + undefined, + undefined, + undefined, + undefined, + undefined, + parsedFilter + ); + }, [parsedFilter.join(","), entityFilter.join(","), filterId]); + + // AuditLogs is reused in different contexts only reset the log params, not filters. + useEffect(() => () => clearAuditLogParams(), []); + const handleSort = (key, dir) => { getAuditLog( entityFilter, @@ -150,24 +172,6 @@ const AuditLogs = ({ ); }; - useEffect(() => { - getAuditLog( - entityFilter, - searchTerm, - DEFAULT_CURRENT_PAGE, - perPage, - order, - orderDir, - parsedFilter - ); - }, [parsedFilter.join(",")]); - - // AuditLogs is reused in different contexts (the standalone audit log - // page, an event's edit form, a ticket's edit page), each with its own - // filterId-scoped Redux entry, so filters don't need to be reset on - // unmount — only the log params. - useEffect(() => () => clearAuditLogParams(), []); - const tableOptions = { sortCol: order, sortDir: orderDir diff --git a/src/components/forms/event-form/index.js b/src/components/forms/event-form/index.js index dd0a0fadd..25f761796 100644 --- a/src/components/forms/event-form/index.js +++ b/src/components/forms/event-form/index.js @@ -2240,7 +2240,7 @@ class EventForm extends React.Component { handleClick={this.toggleSection.bind(this, "audit_log")} > diff --git a/src/pages/audit-log/__tests__/audit-log-page.test.js b/src/pages/audit-log/__tests__/audit-log-page.test.js new file mode 100644 index 000000000..05ad55d6a --- /dev/null +++ b/src/pages/audit-log/__tests__/audit-log-page.test.js @@ -0,0 +1,53 @@ +/** + * 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 "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import { renderWithRedux } from "../../../utils/test-utils"; +import AuditLogPage from "../audit-log-page"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../components/audit-logs", () => ({ + __esModule: true, + default: (props) => ( +
{JSON.stringify(props)}
+ ) +})); + +const renderPage = (totalLogEntries) => + renderWithRedux(, { + initialState: { auditLogState: { totalLogEntries } } + }); + +describe("AuditLogPage", () => { + it("renders the log entries count in the heading", () => { + renderPage(42); + + expect(screen.getByRole("heading", { level: 3 }).textContent).toBe( + "audit_log.log_entries (42)" + ); + }); + + it("scopes AuditLogs to the standalone context, filtered to SummitEvent audit logs", () => { + renderPage(0); + + const props = JSON.parse(screen.getByTestId("audit-logs-mock").textContent); + expect(props.filterId).toBe("standalone"); + expect(props.entityFilter).toEqual(["class_name==SummitEvent"]); + }); +}); diff --git a/src/pages/orders/edit-ticket-page.js b/src/pages/orders/edit-ticket-page.js index 06bb19314..3e62f10b0 100644 --- a/src/pages/orders/edit-ticket-page.js +++ b/src/pages/orders/edit-ticket-page.js @@ -516,7 +516,7 @@ const EditTicketPage = ({ > {entity.badge && ( Date: Wed, 2 Sep 2026 09:33:20 -0300 Subject: [PATCH 09/10] chore: pr review - reverser order for sort --- .../__tests__/audit-log-actions.test.js | 62 +++++++++++++++++++ src/actions/audit-log-actions.js | 7 ++- src/reducers/audit_log/audit-log-reducer.js | 2 +- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/actions/__tests__/audit-log-actions.test.js diff --git a/src/actions/__tests__/audit-log-actions.test.js b/src/actions/__tests__/audit-log-actions.test.js new file mode 100644 index 000000000..4bd60e077 --- /dev/null +++ b/src/actions/__tests__/audit-log-actions.test.js @@ -0,0 +1,62 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { getAuditLog } from "../audit-log-actions"; +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() +})); + +describe("getAuditLog REVERSED order direction", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let capturedParams; + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + capturedParams = null; + getRequest.mockImplementation(() => (params) => { + capturedParams = params; + return () => + Promise.resolve({ + response: { data: [], total: 0, current_page: 1, last_page: 1 } + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const buildStore = () => + mockStore({ currentSummitState: { currentSummit: { id: 1 } } }); + + // Regression pin: audit-logs-api's parse_order reads "+" as descending and + // "-" as ascending — the opposite of every other list in this app — so the + // default (newest-first) view must send "+", not "-". + it("sends a '+' prefix for the default (newest-first) orderDir", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams.order).toBe("+created"); + }); + + it("sends a '-' prefix when orderDir is flipped to ascending", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "", 1, 100, "created", 1, [])); + await flushPromises(); + + expect(capturedParams.order).toBe("-created"); + }); +}); diff --git a/src/actions/audit-log-actions.js b/src/actions/audit-log-actions.js index 83ed67ad2..b8c226d3d 100644 --- a/src/actions/audit-log-actions.js +++ b/src/actions/audit-log-actions.js @@ -56,7 +56,7 @@ export const getAuditLog = page = DEFAULT_CURRENT_PAGE, perPage = MAX_PER_PAGE, order = "created", - orderDir = DEFAULT_ORDER_DIR, + orderDir = -1, filters = [] ) => async (dispatch, getState) => { @@ -82,9 +82,10 @@ export const getAuditLog = params["filter[]"] = parsedFilters; - // order + // order: audit-logs-api's parse_order reads "+" as desc and "-" as asc, + // the opposite of DEFAULT_ORDER_DIR's usual meaning elsewhere in the app. if (order != null && orderDir != null) { - const orderDirSign = orderDir === DEFAULT_ORDER_DIR ? "+" : "-"; + const orderDirSign = orderDir === DEFAULT_ORDER_DIR ? "-" : "+"; params.order = `${orderDirSign}${order}`; } diff --git a/src/reducers/audit_log/audit-log-reducer.js b/src/reducers/audit_log/audit-log-reducer.js index 70be758c8..42297bbf8 100644 --- a/src/reducers/audit_log/audit-log-reducer.js +++ b/src/reducers/audit_log/audit-log-reducer.js @@ -30,7 +30,7 @@ const DEFAULT_STATE = { lastPage: 1, perPage: MAX_PER_PAGE, order: "created", - orderDir: 1, + orderDir: -1, totalLogEntries: 0 }; From 0f34fddf15d803a908aaa59abf019c80daf90882 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 2 Sep 2026 09:48:18 -0300 Subject: [PATCH 10/10] chore: pr review - tests --- .../__tests__/audit-log-actions.test.js | 65 +++++++++++++++++++ .../audit-logs/__tests__/index.test.js | 16 +++-- .../__tests__/audit-log-reducer.test.js | 62 ++++++++++++++++++ src/reducers/audit_log/audit-log-reducer.js | 58 ++++++++++++++++- src/utils/methods.js | 58 ++--------------- 5 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 src/reducers/audit_log/__tests__/audit-log-reducer.test.js diff --git a/src/actions/__tests__/audit-log-actions.test.js b/src/actions/__tests__/audit-log-actions.test.js index 4bd60e077..aad19a619 100644 --- a/src/actions/__tests__/audit-log-actions.test.js +++ b/src/actions/__tests__/audit-log-actions.test.js @@ -60,3 +60,68 @@ describe("getAuditLog REVERSED order direction", () => { expect(capturedParams.order).toBe("-created"); }); }); + +describe("getAuditLog filters contract", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let capturedParams; + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + capturedParams = null; + getRequest.mockImplementation(() => (params) => { + capturedParams = params; + return () => + Promise.resolve({ + response: { data: [], total: 0, current_page: 1, last_page: 1 } + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const buildStore = () => + mockStore({ currentSummitState: { currentSummit: { id: 1 } } }); + + it("merges summit_id, entityFilter, and the array-shaped grid filter into filter[]", async () => { + const store = buildStore(); + + store.dispatch( + getAuditLog(["event_id==5"], "", 1, 100, "created", -1, ["user_id==42"]) + ); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "event_id==5", + "user_id==42" + ]); + }); + + it("appends entity_id== for a numeric search term", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "123", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "entity_id==123" + ]); + }); + + it("appends action=@ for a non-numeric search term", async () => { + const store = buildStore(); + + store.dispatch(getAuditLog([], "restart", 1, 100, "created", -1, [])); + await flushPromises(); + + expect(capturedParams["filter[]"]).toEqual([ + "summit_id==1", + "action=@restart" + ]); + }); +}); diff --git a/src/components/audit-logs/__tests__/index.test.js b/src/components/audit-logs/__tests__/index.test.js index 6a97fabda..029217799 100644 --- a/src/components/audit-logs/__tests__/index.test.js +++ b/src/components/audit-logs/__tests__/index.test.js @@ -154,6 +154,9 @@ describe("AuditLogs columns", () => { // (create/update/delete/...) instead of the parsed audit.description // sentence, and where the ticket page's column subset didn't match the // reducer's field name — both must stay in sync as "action_description". + // The fixture carries both action and action_description, as real reducer + // output always does, so reading the wrong one renders a visibly wrong + // value ("update") rather than an absence. test("renders the caller's column subset, wired to the reducer's `action_description` key", () => { renderWithAuditLogState( { columns: ["created", "action_description", "user"] }, @@ -162,7 +165,8 @@ describe("AuditLogs columns", () => { { id: 1, created: "August 17th 2026, 12:00 pm", - action_description: "Updated Event Title", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", event_id: 55, user: "Jane Doe (7)" } @@ -181,7 +185,9 @@ describe("AuditLogs columns", () => { expect(headers[0]).toMatch(/^audit_log\.date/); expect(headers[1]).toBe("audit_log.action"); expect(headers[2]).toBe("audit_log.user"); - expect(screen.getByText("Updated Event Title")).toBeInTheDocument(); + expect( + screen.getByText("Presentation 'Keynote' (6714) updated: title") + ).toBeInTheDocument(); }); }); @@ -198,7 +204,8 @@ describe("AuditLogs sorting", () => { { id: 1, created: "August 17th 2026, 12:00 pm", - action_description: "Updated Event Title", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", event_id: 55, user: "Jane Doe (7)" } @@ -235,7 +242,8 @@ describe("AuditLogs pagination", () => { { id: 1, created: "August 17th 2026, 12:00 pm", - action_description: "Updated Event Title", + action: "update", + action_description: "Presentation 'Keynote' (6714) updated: title", event_id: 55, user: "Jane Doe (7)" } diff --git a/src/reducers/audit_log/__tests__/audit-log-reducer.test.js b/src/reducers/audit_log/__tests__/audit-log-reducer.test.js new file mode 100644 index 000000000..27f407aaf --- /dev/null +++ b/src/reducers/audit_log/__tests__/audit-log-reducer.test.js @@ -0,0 +1,62 @@ +import moment from "moment-timezone"; +import { formatAuditLog, parseSpeakerAuditLog } from "../audit-log-reducer"; + +describe("formatAuditLog", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("converts an embedded UTC datetime to the viewer's local timezone", () => { + jest.spyOn(moment.tz, "guess").mockReturnValue("America/New_York"); + + const result = formatAuditLog( + "Presentation updated on 2026-06-01 12:00:00 by admin" + ); + + // 2026-06-01 12:00:00 UTC is 08:00:00 in America/New_York (EDT, UTC-4) + expect(result).toBe("Presentation updated on 2026-06-01 08:00:00 by admin"); + }); + + it("returns the string unchanged when it has no embedded datetime", () => { + expect(formatAuditLog("Presentation updated by admin")).toBe( + "Presentation updated by admin" + ); + }); +}); + +describe("parseSpeakerAuditLog", () => { + it("reports a single addition", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker" + ); + + expect(result).toBe("Speaker jane@example.com was added to the collection"); + }); + + it("reports a single removal", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) removed from featured speakers" + ); + + expect(result).toBe( + "Speaker jane@example.com was removed from the collection" + ); + }); + + it("returns the original string when an add and a remove for the same speaker net out to zero", () => { + const original = + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker|Speaker 'Jane Doe' (jane@example.com) removed from featured speakers"; + + expect(parseSpeakerAuditLog(original)).toBe(original); + }); + + it("joins net changes for multiple speakers", () => { + const result = parseSpeakerAuditLog( + "Speaker 'Jane Doe' (jane@example.com) added as featured speaker|Speaker 'John Roe' (john@example.com) removed from featured speakers" + ); + + expect(result).toBe( + "Speaker jane@example.com was added to the collection|Speaker john@example.com was removed from the collection" + ); + }); +}); diff --git a/src/reducers/audit_log/audit-log-reducer.js b/src/reducers/audit_log/audit-log-reducer.js index 42297bbf8..275e231ee 100644 --- a/src/reducers/audit_log/audit-log-reducer.js +++ b/src/reducers/audit_log/audit-log-reducer.js @@ -11,7 +11,11 @@ * limitations under the License. * */ -import { epochToMoment } from "openstack-uicore-foundation/lib/utils/methods"; +import moment from "moment-timezone"; +import { + epochToMoment, + epochToMomentTimeZone +} from "openstack-uicore-foundation/lib/utils/methods"; import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; import { CLEAR_LOG_PARAMS, @@ -20,7 +24,6 @@ import { } from "../../actions/audit-log-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; -import { formatAuditLog, parseSpeakerAuditLog } from "../../utils/methods"; import { MAX_PER_PAGE } from "../../utils/constants"; const DEFAULT_STATE = { @@ -34,6 +37,57 @@ const DEFAULT_STATE = { totalLogEntries: 0 }; +export const formatAuditLog = (logString) => { + const timeZone = moment.tz.guess(); + const dateTimeRegExp = /\d{4}([.\-/ ])\d{2}\1\d{2} \d{1,2}:\d{2}:\d{2}/g; + const dateTimeMatch = logString.match(dateTimeRegExp); + if (!dateTimeMatch) return logString; + const dt = moment.utc(dateTimeMatch[0], "YYYY-MM-DD HH:mm:ss"); + if (!moment.isMoment(dt)) return logString; + const userDt = epochToMomentTimeZone(dt.unix(), timeZone); + if (!moment.isMoment(userDt)) return logString; + return logString.replace( + dateTimeMatch[0], + userDt.format("YYYY-MM-DD HH:mm:ss") + ); +}; + +export const parseSpeakerAuditLog = (logString) => { + const logEntries = logString.split("|"); + const userChanges = {}; + const emailRegExp = + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + // eslint-disable-next-line + for (const entry of logEntries) { + const emailMatch = entry.match(emailRegExp); + if (!emailMatch) continue; + const email = emailMatch[0]; + if (entry.includes("added")) { + // eslint-disable-next-line no-magic-numbers + userChanges[email] = (userChanges[email] || 0) + 1; + } else if (entry.includes("removed")) { + // eslint-disable-next-line no-magic-numbers + userChanges[email] = (userChanges[email] || 0) - 1; + } + } + + const relevantChanges = []; + // eslint-disable-next-line + for (const [email, changeCount] of Object.entries(userChanges)) { + if (changeCount !== 0) { + relevantChanges.push( + `Speaker ${email} ${ + changeCount > 0 + ? "was added to the collection" + : "was removed from the collection" + }` + ); + } + } + + return relevantChanges.length > 0 ? relevantChanges.join("|") : logString; +}; + // eslint-disable-next-line default-param-last const auditLogReducer = (state = DEFAULT_STATE, action) => { const { type, payload } = action; diff --git a/src/utils/methods.js b/src/utils/methods.js index a6614745b..6c1620847 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -11,6 +11,13 @@ * limitations under the License. */ +/* + * ******************************* IMPORTANT ********************************** + * This utils file should hold only methods used globally across files/components, + * not methods used for one specific purpose/file + * ****************************************************************************** + * */ + import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import { @@ -312,57 +319,6 @@ export const validateAllowedEmailDomainEntry = (entry) => { ); }; -export const parseSpeakerAuditLog = (logString) => { - const logEntries = logString.split("|"); - const userChanges = {}; - const emailRegExp = - /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; - // eslint-disable-next-line - for (const entry of logEntries) { - const emailMatch = entry.match(emailRegExp); - if (!emailMatch) continue; - const email = emailMatch[0]; - if (entry.includes("added")) { - // eslint-disable-next-line no-magic-numbers - userChanges[email] = (userChanges[email] || 0) + 1; - } else if (entry.includes("removed")) { - // eslint-disable-next-line no-magic-numbers - userChanges[email] = (userChanges[email] || 0) - 1; - } - } - - const relevantChanges = []; - // eslint-disable-next-line - for (const [email, changeCount] of Object.entries(userChanges)) { - if (changeCount !== 0) { - relevantChanges.push( - `Speaker ${email} ${ - changeCount > 0 - ? "was added to the collection" - : "was removed from the collection" - }` - ); - } - } - - return relevantChanges.length > 0 ? relevantChanges.join("|") : logString; -}; - -export const formatAuditLog = (logString) => { - const timeZone = moment.tz.guess(); - const dateTimeRegExp = /\d{4}([.\-/ ])\d{2}\1\d{2} \d{1,2}:\d{2}:\d{2}/g; - const dateTimeMatch = logString.match(dateTimeRegExp); - if (!dateTimeMatch) return logString; - const dt = moment.utc(dateTimeMatch[0], "YYYY-MM-DD HH:mm:ss"); - if (!moment.isMoment(dt)) return logString; - const userDt = epochToMomentTimeZone(dt.unix(), timeZone); - if (!moment.isMoment(userDt)) return logString; - return logString.replace( - dateTimeMatch[0], - userDt.format("YYYY-MM-DD HH:mm:ss") - ); -}; - export const getAvailableBookingDates = (summit) => { const isValidStartDate = new Date(summit.begin_allow_booking_date).getTime() > 0;