From e4213b0d93962103b97865d626bc6987933c7865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 15 Oct 2026 02:01:54 -0300 Subject: [PATCH 1/2] fix: migrate room booking 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 --- .../__tests__/room-booking-actions.test.js | 87 +++ src/actions/room-booking-actions.js | 31 +- src/actions/sponsor-reports-actions.js | 32 +- src/components/inputs/email-filter/index.js | 84 --- .../inputs/email-filter/index.module.less | 4 - src/i18n/en.json | 12 +- .../__tests__/refund-booking-popup.test.js | 70 ++ .../room-booking-list-page.helpers.test.js | 33 + .../__tests__/room-booking-list-page.test.js | 168 +++-- .../components/refund-booking-popup.js | 131 ++++ .../room-booking-list-page.helpers.js | 44 ++ .../room_bookings/room-booking-list-page.js | 669 ++++++++---------- .../room-booking-list-reducer.js | 27 +- src/utils/methods.js | 30 + 14 files changed, 809 insertions(+), 613 deletions(-) create mode 100644 src/actions/__tests__/room-booking-actions.test.js delete mode 100644 src/components/inputs/email-filter/index.js delete mode 100644 src/components/inputs/email-filter/index.module.less create mode 100644 src/pages/room_bookings/__tests__/refund-booking-popup.test.js create mode 100644 src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js create mode 100644 src/pages/room_bookings/components/refund-booking-popup.js create mode 100644 src/pages/room_bookings/room-booking-list-page.helpers.js diff --git a/src/actions/__tests__/room-booking-actions.test.js b/src/actions/__tests__/room-booking-actions.test.js new file mode 100644 index 000000000..0786ea319 --- /dev/null +++ b/src/actions/__tests__/room-booking-actions.test.js @@ -0,0 +1,87 @@ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + getRequest, + deleteRequest, + STOP_LOADING +} from "openstack-uicore-foundation/lib/utils/actions"; +import * as methods from "../../utils/methods"; +import { + getRoomBookings, + refundRoomBooking, + cancelRoomBooking, + RECEIVE_ROOM_BOOKINGS +} from "../room-booking-actions"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn(), + deleteRequest: jest.fn() +})); + +const mockStore = configureStore([thunk]); +const MOCK_STATE = { currentSummitState: { currentSummit: { id: 1 } } }; + +describe("room-booking-actions", () => { + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("getRoomBookings drops a stale RECEIVE_ROOM_BOOKINGS when a newer call supersedes it", async () => { + let releaseStale; + let callNum = 0; + getRequest.mockImplementation( + (requestAC, receiveAC) => () => (dispatch) => { + callNum += 1; + if (callNum === 1) { + return new Promise((resolve) => { + releaseStale = () => { + dispatch(receiveAC({ response: { data: [{ id: "stale" }] } })); + resolve(); + }; + }); + } + dispatch(receiveAC({ response: { data: [{ id: "fresh" }] } })); + return Promise.resolve(); + } + ); + + const store = mockStore(MOCK_STATE); + const stalePromise = store.dispatch(getRoomBookings("john")); + await flushPromises(); + await store.dispatch(getRoomBookings("jane")); + releaseStale(); + await stalePromise; + + const receives = store + .getActions() + .filter((a) => a.type === RECEIVE_ROOM_BOOKINGS); + expect(receives).toHaveLength(1); + expect(receives[0].payload.response.data).toEqual([{ id: "fresh" }]); + }); + + test("refund and cancel reject on request failure so callers can react, still stopping the loader", async () => { + // the refund dialog stays open and the list skips its reload only if these reject + deleteRequest.mockImplementation( + () => () => () => Promise.reject(new Error("request failed")) + ); + + const store = mockStore(MOCK_STATE); + + await expect(store.dispatch(refundRoomBooking(7, 101, 25))).rejects.toThrow( + "request failed" + ); + await expect(store.dispatch(cancelRoomBooking(7, 101))).rejects.toThrow( + "request failed" + ); + expect( + store.getActions().filter((a) => a.type === STOP_LOADING) + ).toHaveLength(2); + }); +}); diff --git a/src/actions/room-booking-actions.js b/src/actions/room-booking-actions.js index 316f69391..d057e2767 100644 --- a/src/actions/room-booking-actions.js +++ b/src/actions/room-booking-actions.js @@ -24,11 +24,12 @@ import { showSuccessMessage, getCSV, authErrorHandler, + snackbarErrorHandler, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; import Swal from "sweetalert2"; import history from "../history"; -import { getAccessTokenSafely } from "../utils/methods"; +import { getAccessTokenSafely, sequenced } from "../utils/methods"; import { DEFAULT_PER_PAGE, ERROR_CODE_412 } from "../utils/constants"; export const REQUEST_ROOM_BOOKINGS = "REQUEST_ROOM_BOOKINGS"; @@ -61,6 +62,8 @@ export const RECEIVE_ROOM_BOOKING_AVAILABILITY = const normalizeEntity = (entity) => ({ ...entity }); +const roomBookingsSeq = sequenced(); + const parseFilters = (filters, term) => { const filter = []; if (term) { @@ -133,11 +136,13 @@ export const getRoomBookings = filters = {} ) => async (dispatch, getState) => { + const { isCurrent, guardedDispatch } = roomBookingsSeq(dispatch); const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); + if (!isCurrent()) return Promise.resolve(); + guardedDispatch(startLoading()); const { currentSummit } = currentSummitState; - dispatch(startLoading()); const filter = parseFilters(filters, term); const params = { @@ -163,11 +168,11 @@ export const getRoomBookings = createAction(REQUEST_ROOM_BOOKINGS), createAction(RECEIVE_ROOM_BOOKINGS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/locations/bookable-rooms/all/reservations`, - authErrorHandler, - { order, orderDir, term, filters } - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + snackbarErrorHandler, + { order, orderDir, perPage, term, filters } + )(params)(guardedDispatch) + .catch(() => {}) + .finally(() => guardedDispatch(stopLoading())); }; export const exportRoomBookings = @@ -282,6 +287,7 @@ export const saveRoomBooking = (entity) => async (dispatch, getState) => { export const refundRoomBooking = (roomId, roomBookingId, amount) => async (dispatch, getState) => { + dispatch(startLoading()); const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; @@ -295,10 +301,8 @@ export const refundRoomBooking = createAction(ROOM_BOOKING_REFUNDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/locations/bookable-rooms/${roomId}/reservations/${roomBookingId}/refund`, { amount }, - authErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + snackbarErrorHandler + )(params)(dispatch).finally(() => dispatch(stopLoading())); }; /** @@ -308,6 +312,7 @@ export const refundRoomBooking = */ export const cancelRoomBooking = (roomId, roomBookingId) => async (dispatch, getState) => { + dispatch(startLoading()); const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; @@ -321,8 +326,8 @@ export const cancelRoomBooking = createAction(ROOM_BOOKING_DELETED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/locations/bookable-rooms/${roomId}/reservations/${roomBookingId}/cancel`, {}, - authErrorHandler - )(params)(dispatch).then(() => dispatch(stopLoading())); + snackbarErrorHandler + )(params)(dispatch).finally(() => dispatch(stopLoading())); }; /** ******************** ATTRIBUTE TYPE **************************************************************** */ diff --git a/src/actions/sponsor-reports-actions.js b/src/actions/sponsor-reports-actions.js index 2f7112fdf..c0e94197a 100644 --- a/src/actions/sponsor-reports-actions.js +++ b/src/actions/sponsor-reports-actions.js @@ -13,7 +13,8 @@ import { getAccessTokenSafely, isPositiveIntId, escapeFilterValue, - toCSV + toCSV, + sequenced } from "../utils/methods"; import { DEFAULT_CURRENT_PAGE, @@ -73,35 +74,6 @@ export const SET_PURCHASE_DETAILS_BY_ITEM_SORT = // line for one item together — the shape the warehouse pull sheet needs. export const LINES_ORDER_BY_ITEM = "item_code"; -// Per-thunk sequence-token factory guarding against stale-response commits. -// Two concurrent invocations of the same thunk (different filters/page/sponsor) -// carry different getRequest abort keys (only access_token is stripped from the -// key), so they never cancel each other and whichever response lands LAST would -// win — even the older one. Each begin(dispatch) bumps the thunk's counter and -// returns helpers scoped to that invocation: -// isCurrent() — false once a newer invocation has begun -// guardedDispatch(a) — forwards to dispatch only while current, so a -// superseded invocation cannot mutate state at all -// (REQUEST, RECEIVE, error handlers, start/stopLoading). -// The newest invocation is always current, so the overlay is always cleared by -// whichever call finishes last. A STALE call's 401 re-login is also dropped -// (uicore authErrorHandler reaches doLogin by dispatching a thunk through the -// dispatch it is given) — safe: the fresh call carries the same token, so it -// 401s too and drives the re-login, and uicore's isClearingSessionState guard -// dedupes concurrent attempts. -const sequenced = () => { - let seq = 0; - return (dispatch) => { - seq += 1; - const mySeq = seq; - return { - isCurrent: () => mySeq === seq, - guardedDispatch: (action) => { - if (mySeq === seq) dispatch(action); - } - }; - }; -}; // One sequence per thunk that commits fetched data to the store. const purchaseDetailsSeq = sequenced(); const purchaseLinesSeq = sequenced(); diff --git a/src/components/inputs/email-filter/index.js b/src/components/inputs/email-filter/index.js deleted file mode 100644 index 2eb1c740e..000000000 --- a/src/components/inputs/email-filter/index.js +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from "react"; -import T from "i18n-react/dist/i18n-react"; -import Select from "react-select"; -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; -import styles from "./index.module.less"; -const Index = ({ onChange, id, operatorInitialValue, filterInitialValue }) => { - const operatorOptions = [ - { - label: T.translate("email_filter.operator_contains"), - value: "owner_email=@" - }, - { - label: T.translate("email_filter.operator_not_contains"), - value: "not_owner_email=@" - }, - { - label: T.translate("email_filter.operator_equal"), - value: "owner_email==" - } - ]; - - const [operatorValue, setOperatorValue] = useState( - operatorInitialValue - ? operatorOptions.find((o) => o.value === operatorInitialValue) - : null - ); - const [filterValue, setFilterValue] = useState( - filterInitialValue ? filterInitialValue : "" - ); - - const onChangeOperator = (newOperatorValue) => { - setOperatorValue(newOperatorValue); - if (!newOperatorValue) setFilterValue(""); - - let ev = { - target: { - id: id, - value: filterValue, - type: "email_filter", - operator: newOperatorValue ? newOperatorValue.value : null - } - }; - onChange(ev); - }; - - const onChangeValue = (ev) => { - let { value } = ev.target; - setFilterValue(value); - let e = { - target: { - id: id, - value: value, - type: "email_filter", - operator: operatorValue ? operatorValue?.value : null - } - }; - onChange(e); - }; - - return ( -
-
- -
-
- ); -}; - -export default Index; diff --git a/src/components/inputs/email-filter/index.module.less b/src/components/inputs/email-filter/index.module.less deleted file mode 100644 index 9494376f9..000000000 --- a/src/components/inputs/email-filter/index.module.less +++ /dev/null @@ -1,4 +0,0 @@ -.filter_container { - display: flex; - align-items: baseline; -} diff --git a/src/i18n/en.json b/src/i18n/en.json index 69d6b4d2a..6ae549b55 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1593,9 +1593,11 @@ "amount": "Amount", "paid": "Paid", "refunded": "Refunded", - "delete_booking_warning": "Are you sure you want to delete booking for ", + "delete_booking_warning": "Are you sure you want to delete booking for {name}", "add_room_booking": "Add Offline Room Booking", - "apply_filters": "Apply Filters", + "refund_title": "Refund {owner_name} for room {room_name}", + "user_paid": "User Paid", + "refund_amount": "Refund Amount", "placeholders": { "search_bookings": "Search bookings." } @@ -3970,12 +3972,8 @@ "reg_feed_metadata_created": "Registration Feed Metadata created successfully." }, "email_filter": { - "operator_contains": "Contains", - "operator_not_contains": "Not Contains", - "operator_equal": "Equal", "placeholders": { - "value": "Search by email address", - "operator": "Select Operator" + "value": "Search by email address" } }, "notes": { diff --git a/src/pages/room_bookings/__tests__/refund-booking-popup.test.js b/src/pages/room_bookings/__tests__/refund-booking-popup.test.js new file mode 100644 index 000000000..8f8853e2e --- /dev/null +++ b/src/pages/room_bookings/__tests__/refund-booking-popup.test.js @@ -0,0 +1,70 @@ +import React from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import RefundBookingPopup from "../components/refund-booking-popup"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const booking = { + id: 101, + room_id: 7, + owner_name: "John Doe", + room_name: "Room 128", + amount_str: "$100", + currency: "USD" +}; + +describe("RefundBookingPopup", () => { + test("rejects invalid amounts, locks the dialog while saving and closes only after a successful refund", async () => { + const user = userEvent.setup(); + let rejectSave; + const onSave = jest + .fn() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectSave = reject; + }) + ) + .mockResolvedValueOnce(); + const onClose = jest.fn(); + + render( + + ); + + const amountInput = screen.getByRole("spinbutton"); + const submit = screen.getByRole("button", { + name: "room_booking_list.refund" + }); + const close = screen.getByRole("button", { name: "close" }); + + await user.clear(amountInput); + await user.type(amountInput, "-5"); + await user.click(submit); + expect( + await screen.findByText("validation.non_negative") + ).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + + await user.clear(amountInput); + await user.type(amountInput, "25"); + await user.click(submit); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(booking, 25)); + expect(submit).toBeDisabled(); + expect(close).toBeDisabled(); + + await act(async () => rejectSave(new Error("refund failed"))); + await waitFor(() => expect(submit).toBeEnabled()); + expect(close).toBeEnabled(); + expect(onClose).not.toHaveBeenCalled(); + + await user.click(submit); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js b/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js new file mode 100644 index 000000000..4ec3fd6b2 --- /dev/null +++ b/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js @@ -0,0 +1,33 @@ +import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import { buildRoomBookingFilters } from "../room-booking-list-page.helpers"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +describe("buildRoomBookingFilters", () => { + test("maps each email_filter operator to the legacy EmailFilter operator", () => { + const build = (operator) => + buildRoomBookingFilters([ + { criteria: "email_filter", operator, value: "jane@acme.com" } + ]).email_filter; + + expect(build(OPERATORS.LIKE.value)).toEqual({ + operator: "owner_email=@", + value: "jane@acme.com" + }); + expect(build(OPERATORS.HAS_NOT.value)).toEqual({ + operator: "not_owner_email=@", + value: "jane@acme.com" + }); + expect(build(OPERATORS.IS.value)).toEqual({ + operator: "owner_email==", + value: "jane@acme.com" + }); + expect(buildRoomBookingFilters([]).email_filter).toEqual({ + operator: null, + value: "" + }); + }); +}); diff --git a/src/pages/room_bookings/__tests__/room-booking-list-page.test.js b/src/pages/room_bookings/__tests__/room-booking-list-page.test.js index 9251843e6..27396c165 100644 --- a/src/pages/room_bookings/__tests__/room-booking-list-page.test.js +++ b/src/pages/room_bookings/__tests__/room-booking-list-page.test.js @@ -1,97 +1,117 @@ import React from "react"; -import { render } from "@testing-library/react"; -import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; +import { render, waitFor } from "@testing-library/react"; +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 { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import RoomBookingListPage from "../room-booking-list-page"; +import roomBookingListReducer from "../../../reducers/room_bookings/room-booking-list-reducer"; +import * as methods from "../../../utils/methods"; -const middlewares = [thunk]; -const mockStore = configureStore(middlewares); +const mockFetch = jest.fn(() => Promise.resolve()); +const mockGridFilterState = { parsedFilter: [], filterValues: [] }; -jest.mock("openstack-uicore-foundation/lib/components", () => ({ - Table: ({ children, onSort, ...props }) => ( - - {children} -
- ), - FreeTextSearch: () => null +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn() })); -jest.mock("react-bootstrap", () => ({ - Modal: ({ children }) =>
{children}
, - Pagination: ({ children }) => +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: () => mockGridFilterState })); -jest.mock("sweetalert2", () => ({ - fire: jest.fn(() => Promise.resolve({ value: false })) -})); +jest.mock( + "openstack-uicore-foundation/lib/components/mui/search-input", + () => ({ + __esModule: true, + default: () => null + }) +); -jest.mock("react-breadcrumbs", () => ({ - Breadcrumb: () => null +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } })); -jest.mock("../../../components/inputs/email-filter", () => () => null); +const BASE_STATUS_FILTER = "status==Paid||Reserved||RequestedRefund||Refunded"; -jest.mock("react-router-dom", () => ({ - withRouter: (component) => component -})); +const buildStore = (listOverrides = {}) => + createStore( + combineReducers({ + currentSummitState: ( + state = { currentSummit: { id: 1, time_zone_id: "UTC" } } + ) => state, + currentRoomBookingListState: roomBookingListReducer + }), + { + currentRoomBookingListState: { + ...roomBookingListReducer(undefined, {}), + ...listOverrides + } + }, + applyMiddleware(thunk) + ); -jest.mock("i18n-react/dist/i18n-react", () => ({ - translate: (key) => key -})); +const renderPage = (store) => { + const ui = () => ( + + + + ); + const utils = render(ui()); + return { ...utils, rerenderPage: () => utils.rerender(ui()) }; +}; + +const lastRequestFilter = () => mockFetch.mock.calls.at(-1)[0]["filter[]"]; describe("RoomBookingListPage", () => { - const initialState = { - currentSummitState: { - currentSummit: { - id: 1, - time_zone_id: "America/Denver" - } - }, - currentRoomBookingListState: { - roomBookings: [ - { - id: 101, - created: "1/1/2024, 10:00:00 AM", - room_name: "Speaker Support Rm 128", - start_datetime: "2024-01-15 09:00:00", - end_datetime: "2024-01-15 10:00:00", - owner_name: "John Doe", - owner_email: "john.doe@example.com", - status: "Paid", - amount_str: "$100", - refunded_amount_str: "$0" - } - ], - lastPage: 1, - currentPage: 1, - term: "", - order: "start_datetime", - orderDir: 1, - totalRoomBookings: 1, - perPage: 10, - filters: { - email_filter: { operator: null, value: "" } - } - } - }; + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + getRequest.mockImplementation(() => (params) => () => mockFetch(params)); + mockGridFilterState.parsedFilter = []; + mockGridFilterState.filterValues = []; + }); - test("table_is_wrapped_in_table_responsive_for_horizontal_scrolling", () => { - const store = mockStore(initialState); - const mockHistory = { push: jest.fn() }; + afterEach(() => { + jest.restoreAllMocks(); + mockFetch.mockClear(); + }); - const { container } = render( - - - - ); + test("sends the GridFilter email filter escaped alongside term and base status filter, refetching when it changes", async () => { + const { rerenderPage } = renderPage(buildStore({ term: "john" })); - const tableResponsiveWrapper = container.querySelector(".table-responsive"); - expect(tableResponsiveWrapper).toBeInTheDocument(); + await waitFor(() => + expect(lastRequestFilter()).toEqual([ + "owner_name=@john,room_name=@john", + BASE_STATUS_FILTER + ]) + ); - const table = container.querySelector("table"); - expect(table).toBeInTheDocument(); + mockGridFilterState.filterValues = [ + { + criteria: "email_filter", + operator: OPERATORS.LIKE.value, + value: "jane+test@acme.com" + } + ]; + mockGridFilterState.parsedFilter = ["email_filter=@jane+test@acme.com"]; + rerenderPage(); - expect(tableResponsiveWrapper).toContainElement(table); + await waitFor(() => + expect(lastRequestFilter()).toEqual([ + "owner_name=@john,room_name=@john", + "owner_email=@jane%2Btest@acme.com", + BASE_STATUS_FILTER + ]) + ); + expect(mockFetch).toHaveBeenCalledTimes(2); }); }); diff --git a/src/pages/room_bookings/components/refund-booking-popup.js b/src/pages/room_bookings/components/refund-booking-popup.js new file mode 100644 index 000000000..19b70e012 --- /dev/null +++ b/src/pages/room_bookings/components/refund-booking-popup.js @@ -0,0 +1,131 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Typography +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useFormik, FormikProvider } from "formik"; +import * as yup from "yup"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; + +const RefundBookingPopup = ({ booking, onClose, onSave }) => { + const [isSaving, setIsSaving] = useState(false); + + const formik = useFormik({ + initialValues: { amount: 0 }, + validationSchema: yup.object().shape({ + amount: yup + .number() + .typeError(T.translate("validation.number")) + .required(T.translate("validation.required")) + .integer(T.translate("validation.integer")) + .min(0, T.translate("validation.non_negative")) + }), + onSubmit: ({ amount }) => { + if (isSaving) return; + setIsSaving(true); + onSave(booking, amount) + .then(() => onClose()) + .catch(() => {}) + .finally(() => setIsSaving(false)); + } + }); + + const handleClose = () => { + if (isSaving) return; + onClose(); + }; + + return ( + + + {T.translate("room_booking_list.refund_title", { + owner_name: booking.owner_name, + room_name: booking.room_name + })} + + + + + + + + + + {T.translate("room_booking_list.user_paid")}: + {booking.amount_str} {booking.currency} + + + + + + + + + + + ); +}; + +RefundBookingPopup.propTypes = { + booking: PropTypes.shape({ + id: PropTypes.number.isRequired, + room_id: PropTypes.number, + owner_name: PropTypes.string, + room_name: PropTypes.string, + amount_str: PropTypes.string, + currency: PropTypes.string + }).isRequired, + onClose: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired +}; + +export default RefundBookingPopup; diff --git a/src/pages/room_bookings/room-booking-list-page.helpers.js b/src/pages/room_bookings/room-booking-list-page.helpers.js new file mode 100644 index 000000000..e399faee2 --- /dev/null +++ b/src/pages/room_bookings/room-booking-list-page.helpers.js @@ -0,0 +1,44 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import T from "i18n-react/dist/i18n-react"; +import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; + +const EMAIL_FILTER_OPERATORS = { + [OPERATORS.LIKE.value]: "owner_email=@", + [OPERATORS.HAS_NOT.value]: "not_owner_email=@", + [OPERATORS.IS.value]: "owner_email==" +}; + +export const getCriterias = () => [ + { + key: "email_filter", + label: T.translate("room_booking_list.owner_email"), + operators: [OPERATORS.LIKE, OPERATORS.HAS_NOT, OPERATORS.IS], + values: { + type: "text", + props: { placeholder: T.translate("email_filter.placeholders.value") } + } + } +]; + +export const buildRoomBookingFilters = (filterValues) => { + const emailEntry = filterValues.find((f) => f.criteria === "email_filter"); + + return { + email_filter: { + operator: EMAIL_FILTER_OPERATORS[emailEntry?.operator] ?? null, + value: emailEntry?.value ?? "" + } + }; +}; diff --git a/src/pages/room_bookings/room-booking-list-page.js b/src/pages/room_bookings/room-booking-list-page.js index 6c7c6e133..ab4494197 100644 --- a/src/pages/room_bookings/room-booking-list-page.js +++ b/src/pages/room_bookings/room-booking-list-page.js @@ -11,15 +11,18 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import Swal from "sweetalert2"; -import { Modal, Pagination } from "react-bootstrap"; -import Table from "openstack-uicore-foundation/lib/components/table" -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"; +import { Box, Button, IconButton, Tooltip } from "@mui/material"; +import AttachMoneyIcon from "@mui/icons-material/AttachMoney"; +import EditIcon from "@mui/icons-material/Edit"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { getSummitById } from "../../actions/summit-actions"; +import { + GridFilter, + useGridFilter +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import { getRoomBookings, exportRoomBookings, @@ -27,413 +30,304 @@ import { cancelRoomBooking } from "../../actions/room-booking-actions"; import { + DEFAULT_CURRENT_PAGE, ReservationStatusPaid, ReservationStatusRequestedRefund } from "../../utils/constants"; -import EmailFilter from "../../components/inputs/email-filter"; - -class RoomBookingListPage extends React.Component { - constructor(props) { - super(props); - - this.handleEdit = this.handleEdit.bind(this); - this.handleCancelBooking = this.handleCancelBooking.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.handleExport = this.handleExport.bind(this); - this.handleRefundClick = this.handleRefundClick.bind(this); - this.handleRefund = this.handleRefund.bind(this); - this.handleRefundChange = this.handleRefundChange.bind(this); - this.hasPaid = this.hasPaid.bind(this); - this.isEditable = this.isEditable.bind(this); - this.onCloseModal = this.onCloseModal.bind(this); - this.handleAddRoomBooking = this.handleAddRoomBooking.bind(this); - this.handleApplyFilters = this.handleApplyFilters.bind(this); - - this.state = { - showModal: false, - modalBooking: null, - modalAmount: 0, - filters: { ...this.props.filters } - }; - } - - componentDidMount() { - const { currentSummit, term, order, orderDir, page, perPage, filters } = - this.props; - if (currentSummit) { - this.props.getRoomBookings(term, page, perPage, order, orderDir, filters); +import GridToolbar from "../../components/mui/grid-toolbar"; +import RefundBookingPopup from "./components/refund-booking-popup"; +import { + buildRoomBookingFilters, + getCriterias +} from "./room-booking-list-page.helpers"; + +const FILTER_ID = "room_booking_list"; + +const RoomBookingListPage = ({ + currentSummit, + roomBookings, + currentPage, + perPage, + term, + order, + orderDir, + totalRoomBookings, + history, + getRoomBookings, + exportRoomBookings, + refundRoomBooking, + cancelRoomBooking +}) => { + const [refundBooking, setRefundBooking] = useState(null); + const { parsedFilter, filterValues } = useGridFilter(FILTER_ID); + const roomBookingFilters = buildRoomBookingFilters(filterValues); + + useEffect(() => { + if (currentSummit?.id) { + getRoomBookings( + term, + DEFAULT_CURRENT_PAGE, + perPage, + order, + orderDir, + roomBookingFilters + ); } - } + }, [currentSummit?.id, parsedFilter.join(",")]); + + const findBooking = (bookingId) => + roomBookings.find((rb) => rb.id === bookingId); - handleEdit(room_booking_id) { - const { currentSummit, history } = this.props; + const handleEdit = (roomBookingId) => { history.push( - `/app/summits/${currentSummit.id}/room-bookings/${room_booking_id}` + `/app/summits/${currentSummit.id}/room-bookings/${roomBookingId}` ); - } + }; - handleAddRoomBooking() { - const { currentSummit, history } = this.props; + const handleAddRoomBooking = () => { history.push(`/app/summits/${currentSummit.id}/room-bookings/new`); - } - - handleCancelBooking(bookingId) { - const { - cancelRoomBooking, - roomBookings, - getRoomBookings, + }; + + const handleCancelBooking = (bookingId) => { + const roomBooking = findBooking(bookingId); + + cancelRoomBooking(roomBooking.room_id, bookingId) + .then(() => + getRoomBookings( + term, + DEFAULT_CURRENT_PAGE, + perPage, + order, + orderDir, + roomBookingFilters + ) + ) + .catch(() => {}); + }; + + const handlePageChange = (page) => { + getRoomBookings(term, page, perPage, order, orderDir, roomBookingFilters); + }; + + const handleSort = (key, dir) => { + getRoomBookings( term, - order, - orderDir, + DEFAULT_CURRENT_PAGE, perPage, - filters - } = this.props; - const roomBooking = roomBookings.find((rb) => rb.id === bookingId); - - Swal.fire({ - title: T.translate("general.are_you_sure"), - text: `${T.translate("room_booking_list.delete_booking_warning")} ${ - roomBooking.owner_name - }`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }).then((result) => { - if (result.value) { - cancelRoomBooking(roomBooking.room_id, bookingId).then(() => - getRoomBookings(term, 1, perPage, order, orderDir, filters) - ); - } - }); - } - - handlePageChange(page) { - const { term, order, orderDir, perPage, filters } = this.props; - this.props.getRoomBookings(term, page, perPage, order, orderDir, filters); - } - - handleSort(index, key, dir) { - const { term, page, perPage, filters } = this.props; - this.props.getRoomBookings(term, page, perPage, key, dir, filters); - } - - handleSearch(term) { - const { order, orderDir, page, perPage, filters } = this.props; - this.props.getRoomBookings(term, page, perPage, order, orderDir, filters); - } - - handleExport(ev) { - const { term, order, orderDir, filters } = this.props; - ev.preventDefault(); - this.props.exportRoomBookings(term, order, orderDir, filters); - } - - handleRefundClick(bookingId) { - const { roomBookings } = this.props; - const roomBooking = roomBookings.find((rb) => rb.id === bookingId); - - this.setState({ - showModal: true, - modalBooking: roomBooking - }); - } - - hasPaid(bookingId) { - const { roomBookings } = this.props; - const roomBooking = roomBookings.find((rb) => rb.id === bookingId); - return ( - (roomBooking.status === ReservationStatusPaid || - roomBooking.status === ReservationStatusRequestedRefund) && - roomBooking.amount > 0 + key, + dir, + roomBookingFilters ); - } + }; - isEditable(bookingId) { - const { roomBookings } = this.props; - const roomBooking = roomBookings.find((rb) => rb.id === bookingId); - return roomBooking.status === ReservationStatusPaid; - } - - onCloseModal() { - this.setState({ showModal: false }); - } - - handleRefund() { - const { modalBooking, modalAmount } = this.state; - - this.setState({ - showModal: false - }); - - this.props.refundRoomBooking( - modalBooking.room_id, - modalBooking.id, - modalAmount - ); - } - - handleRefundChange(ev) { - const { value } = ev.target; - this.setState({ modalAmount: parseInt(value) }); - } - - handleApplyFilters() { - const { term, order, orderDir, page, perPage } = this.props; - this.props.getRoomBookings( + const handlePerPageChange = (newPerPage) => { + getRoomBookings( term, - page, - perPage, + DEFAULT_CURRENT_PAGE, + newPerPage, order, orderDir, - this.state.filters + roomBookingFilters ); - } + }; - render() { - const { - currentSummit, - roomBookings, - lastPage, - currentPage, - term, + const handleSearch = (newTerm) => { + getRoomBookings( + newTerm, + DEFAULT_CURRENT_PAGE, + perPage, order, orderDir, - totalRoomBookings, - filters - } = this.props; - const { showModal, modalBooking, modalAmount } = this.state; - - const roomBookingsFormatted = roomBookings.map((rb) => { - const start_datetime = epochToMomentTimeZone( - rb.start_datetime, - currentSummit.time_zone_id - ).format("YYYY-MM-DD h:mm a"); - const end_datetime = epochToMomentTimeZone( - rb.end_datetime, - currentSummit.time_zone_id - ).format("YYYY-MM-DD h:mm a"); - return { - ...rb, - start_datetime, - end_datetime - }; - }); - - const columns = [ - { - columnKey: "created", - value: T.translate("room_booking_list.created"), - sortable: true - }, - { - columnKey: "room_name", - value: T.translate("room_booking_list.room"), - sortable: true - }, - { - columnKey: "start_datetime", - value: T.translate("room_booking_list.start"), - sortable: true - }, - { - columnKey: "end_datetime", - value: T.translate("room_booking_list.end"), - sortable: true - }, - { - columnKey: "owner_name", - value: T.translate("room_booking_list.owner_name"), - sortable: true - }, - { - columnKey: "owner_email", - value: T.translate("room_booking_list.owner_email"), - sortable: true - }, - { - columnKey: "status", - value: T.translate("room_booking_list.status"), - sortable: true - }, - { columnKey: "amount_str", value: T.translate("room_booking_list.paid") }, - { - columnKey: "refunded_amount_str", - value: T.translate("room_booking_list.refunded") - } - ]; + roomBookingFilters + ); + }; - const table_options = { - sortCol: order, - sortDir: orderDir, - actions: { - delete: { onClick: this.handleCancelBooking }, - custom: [ - { - name: "refund", - tooltip: "refund", - icon: , - onClick: this.handleRefundClick, - display: this.hasPaid - }, - { - name: "edit", - tooltip: "edit", - icon: , - onClick: this.handleEdit, - display: this.isEditable - } - ] - } - }; + const handleExport = (ev) => { + ev.preventDefault(); + exportRoomBookings(term, order, orderDir, roomBookingFilters); + }; - if (!currentSummit.id) return
; + const handleRefundClick = (bookingId) => { + setRefundBooking(findBooking(bookingId)); + }; + const hasPaid = (bookingId) => { + const roomBooking = findBooking(bookingId); return ( -
-

- {" "} - {T.translate("room_booking_list.room_booking_list")} ( - {totalRoomBookings}) -

- -
-
- -
-
- - -
-
-
-
- { - this.setState({ - ...this.state, - filters: { - ...this.state.filters, - email_filter: { - operator: ev.target.operator, - value: ev.target.value - } - } - }); - }} - /> -
-
- -
-
- {roomBookings.length === 0 && ( -
{T.translate("room_booking_list.no_room_bookings")}
- )} - - {roomBookings.length > 0 && ( -
- - - - )} - - {modalBooking && ( - - - - REFUND {modalBooking.owner_name} for room{" "} - {modalBooking.room_name} - - - -
- -
-
- User Payed: - {modalBooking.amount_str} {modalBooking.currency} -
-
-
- - -
-
- -
-
- -
-
- -
-
- )} - - ); - } -} + + + + ) + } + ]; + + return ( +
+

{T.translate("room_booking_list.room_booking_list")}

+ + + + + + + + {totalRoomBookings} {T.translate("room_bookings.room_bookings")} + + + {roomBookings.length === 0 && ( +
{T.translate("room_booking_list.no_room_bookings")}
+ )} + + {roomBookings.length > 0 && ( + row.owner_name} + deleteDialogBody={(name) => + T.translate("room_booking_list.delete_booking_warning", { name }) + } + confirmButtonColor="error" + /> + )} + + {refundBooking && ( + setRefundBooking(null)} + onSave={handleRefund} + /> + )} +
+ ); +}; const mapStateToProps = ({ currentSummitState, @@ -444,7 +338,6 @@ const mapStateToProps = ({ }); export default connect(mapStateToProps, { - getSummitById, getRoomBookings, exportRoomBookings, refundRoomBooking, diff --git a/src/reducers/room_bookings/room-booking-list-reducer.js b/src/reducers/room_bookings/room-booking-list-reducer.js index 630f68789..47a7ee806 100644 --- a/src/reducers/room_bookings/room-booking-list-reducer.js +++ b/src/reducers/room_bookings/room-booking-list-reducer.js @@ -9,7 +9,10 @@ * 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { epochToMoment } from "openstack-uicore-foundation/lib/utils/methods"; import { RECEIVE_ROOM_BOOKINGS, @@ -18,8 +21,6 @@ import { } from "../../actions/room-booking-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; -import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; -import { epochToMoment } from "openstack-uicore-foundation/lib/utils/methods"; const DEFAULT_STATE = { roomBookings: [], @@ -44,17 +45,17 @@ const roomBookingListReducer = (state = DEFAULT_STATE, action) => { return DEFAULT_STATE; } case REQUEST_ROOM_BOOKINGS: { - let { order, orderDir, term, filters } = payload; - return { ...state, order, orderDir, term, filters }; + const { order, orderDir, perPage, term, filters } = payload; + return { ...state, order, orderDir, perPage, term, filters }; } case RECEIVE_ROOM_BOOKINGS: { - let { current_page, total, last_page } = payload.response; - let room_bookings = payload.response.data.map((rb) => { - let ownerName = rb.owner.first_name + " " + rb.owner.last_name; - let created = epochToMoment(rb.created).format("M/D/YYYY, h:mm:ss a"); + const { current_page, total, last_page } = payload.response; + const room_bookings = payload.response.data.map((rb) => { + const ownerName = `${rb.owner.first_name} ${rb.owner.last_name}`; + const created = epochToMoment(rb.created).format("M/D/YYYY, h:mm:ss a"); return { ...rb, - created: created, + created, owner_name: ownerName, owner_email: rb.owner.email, room_name: rb.room.name, @@ -73,8 +74,8 @@ const roomBookingListReducer = (state = DEFAULT_STATE, action) => { }; } case ROOM_BOOKING_REFUNDED: { - let roomBooking = payload.response; - let roomBookings = [...state.roomBookings]; + const roomBooking = payload.response; + const roomBookings = [...state.roomBookings]; roomBookings.forEach((rb) => { if (rb.id === roomBooking.id) { rb.status = "Refunded"; @@ -82,7 +83,7 @@ const roomBookingListReducer = (state = DEFAULT_STATE, action) => { } }); - return { ...state, roomBookings: roomBookings }; + return { ...state, roomBookings }; } default: return state; diff --git a/src/utils/methods.js b/src/utils/methods.js index 613919e59..779d425d2 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -673,3 +673,33 @@ export const formatDuration = (duration) => { // Builds "{count} {noun}" from a singular translation key, appending "s" export const countLabel = (translationKey, count) => `${count} ${T.translate(translationKey)}${count === 1 ? "" : "s"}`; + +// Per-thunk sequence-token factory guarding against stale-response commits. +// Two concurrent invocations of the same thunk (different filters/page/sponsor) +// carry different getRequest abort keys (only access_token is stripped from the +// key), so they never cancel each other and whichever response lands LAST would +// win — even the older one. Each begin(dispatch) bumps the thunk's counter and +// returns helpers scoped to that invocation: +// isCurrent() — false once a newer invocation has begun +// guardedDispatch(a) — forwards to dispatch only while current, so a +// superseded invocation cannot mutate state at all +// (REQUEST, RECEIVE, error handlers, start/stopLoading). +// The newest invocation is always current, so the overlay is always cleared by +// whichever call finishes last. A STALE call's 401 re-login is also dropped +// (uicore authErrorHandler reaches doLogin by dispatching a thunk through the +// dispatch it is given) — safe: the fresh call carries the same token, so it +// 401s too and drives the re-login, and uicore's isClearingSessionState guard +// dedupes concurrent attempts. +export const sequenced = () => { + let seq = 0; + return (dispatch) => { + seq += 1; + const mySeq = seq; + return { + isCurrent: () => mySeq === seq, + guardedDispatch: (action) => { + if (mySeq === seq) dispatch(action); + } + }; + }; +}; From b3492ba935b91c1d54effff96681cbd8e09ba04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 16 Oct 2026 16:04:02 -0300 Subject: [PATCH 2/2] fix: rename grid helper file, remove unused condition to render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/room-booking-list-page.helpers.test.js | 2 +- .../{room-booking-list-page.helpers.js => helpers.js} | 0 src/pages/room_bookings/room-booking-list-page.js | 7 +------ 3 files changed, 2 insertions(+), 7 deletions(-) rename src/pages/room_bookings/{room-booking-list-page.helpers.js => helpers.js} (100%) diff --git a/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js b/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js index 4ec3fd6b2..cbdbfc08d 100644 --- a/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js +++ b/src/pages/room_bookings/__tests__/room-booking-list-page.helpers.test.js @@ -1,5 +1,5 @@ import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; -import { buildRoomBookingFilters } from "../room-booking-list-page.helpers"; +import { buildRoomBookingFilters } from "../helpers"; jest.mock("i18n-react/dist/i18n-react", () => ({ __esModule: true, diff --git a/src/pages/room_bookings/room-booking-list-page.helpers.js b/src/pages/room_bookings/helpers.js similarity index 100% rename from src/pages/room_bookings/room-booking-list-page.helpers.js rename to src/pages/room_bookings/helpers.js diff --git a/src/pages/room_bookings/room-booking-list-page.js b/src/pages/room_bookings/room-booking-list-page.js index ab4494197..1681c03a1 100644 --- a/src/pages/room_bookings/room-booking-list-page.js +++ b/src/pages/room_bookings/room-booking-list-page.js @@ -36,10 +36,7 @@ import { } from "../../utils/constants"; import GridToolbar from "../../components/mui/grid-toolbar"; import RefundBookingPopup from "./components/refund-booking-popup"; -import { - buildRoomBookingFilters, - getCriterias -} from "./room-booking-list-page.helpers"; +import { buildRoomBookingFilters, getCriterias } from "./helpers"; const FILTER_ID = "room_booking_list"; @@ -166,8 +163,6 @@ const RoomBookingListPage = ({ const handleRefund = (booking, amount) => refundRoomBooking(booking.room_id, booking.id, amount); - if (!currentSummit.id) return
; - const roomBookingsFormatted = roomBookings.map((rb) => ({ ...rb, start_datetime: epochToMomentTimeZone(