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..cbdbfc08d
--- /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 "../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 }) => (
-
- ),
- 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 (
+
+ );
+};
+
+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/helpers.js b/src/pages/room_bookings/helpers.js
new file mode 100644
index 000000000..e399faee2
--- /dev/null
+++ b/src/pages/room_bookings/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..1681c03a1 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,299 @@ 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 "./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(",")]);
- handleEdit(room_booking_id) {
- const { currentSummit, history } = this.props;
+ const findBooking = (bookingId) =>
+ roomBookings.find((rb) => rb.id === bookingId);
+
+ 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
- }
- }
- });
- }}
- />
-
-
-
+
+
+ )
+ },
+ {
+ columnKey: "edit",
+ header: "",
+ width: 40,
+ align: "center",
+ dottedBorder: true,
+ headSx: { p: 0 },
+ cellSx: { p: 0 },
+ render: (row) =>
+ isEditable(row.id) && (
+
+ handleEdit(row.id)}
>
- {T.translate("room_booking_list.apply_filters")}
-
-
-
- {roomBookings.length === 0 && (
-
{T.translate("room_booking_list.no_room_bookings")}
- )}
-
- {roomBookings.length > 0 && (
-
- )}
-
- {modalBooking && (
-
-
-
- REFUND {modalBooking.owner_name} for room{" "}
- {modalBooking.room_name}
-
-
-
-
-
-
- )}
-
- );
- }
-}
+
+
+
+ )
+ }
+ ];
+
+ 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 +333,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);
+ }
+ };
+ };
+};