Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/actions/__tests__/room-booking-actions.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 18 additions & 13 deletions src/actions/room-booking-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not repo standard

const { currentSummit } = currentSummitState;

dispatch(startLoading());
const filter = parseFilters(filters, term);

const params = {
Expand All @@ -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 =
Expand Down Expand Up @@ -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;
Expand All @@ -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()));
};

/**
Expand All @@ -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;
Expand All @@ -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 **************************************************************** */
Expand Down
32 changes: 2 additions & 30 deletions src/actions/sponsor-reports-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
getAccessTokenSafely,
isPositiveIntId,
escapeFilterValue,
toCSV
toCSV,
sequenced
} from "../utils/methods";
import {
DEFAULT_CURRENT_PAGE,
Expand Down Expand Up @@ -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();
Expand Down
84 changes: 0 additions & 84 deletions src/components/inputs/email-filter/index.js

This file was deleted.

4 changes: 0 additions & 4 deletions src/components/inputs/email-filter/index.module.less

This file was deleted.

12 changes: 5 additions & 7 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading