Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@mui/icons-material": "^6.4.3",
"@mui/material": "^6.4.3",
"@mui/x-date-pickers": "^7.26.0",
"@mui/x-tree-view": "^7.26.0",
Comment thread
santipalenque marked this conversation as resolved.
"@react-pdf/renderer": "^4.4.1",
"@sentry/react": "^8.32.0",
"@sentry/webpack-plugin": "^2.22.4",
Expand Down Expand Up @@ -101,7 +102,6 @@
"prop-types": "^15.8.1",
"qr-scanner": "^1.4.2",
"react": "^16.13.1",
"react-accessible-treeview": "^2.6.2",
"react-beautiful-dnd": "^13.1.1",
"react-bootstrap": "^0.31.5",
"react-breadcrumbs": "^2.1.6",
Expand Down
155 changes: 155 additions & 0 deletions src/actions/__tests__/email-flows-events-actions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @jest-environment jsdom
*/
import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import { getRequest } from "openstack-uicore-foundation/lib/utils/actions";
import {
getEmailFlowEvents,
getEmailFlowEvent
} from "../email-flows-events-actions";
import * as methods from "../../utils/methods";
import { createMockSummit } from "../../utils/test-utils";

jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
__esModule: true,
...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
getRequest: jest.fn()
}));

describe("getEmailFlowEvents", () => {
const mockStore = configureStore([thunk]);

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
getRequest.mockImplementation(() => () => () => Promise.resolve({}));
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it("forwards perPage in the request-action payload so redux keeps the page size", async () => {
const store = mockStore({
currentSummitState: { currentSummit: createMockSummit() }
});

await getEmailFlowEvents(
"welcome",
1,
50,
"flow_name",
-1
)(store.dispatch, store.getState);

const [, , , , requestPayload] = getRequest.mock.calls[0];
expect(requestPayload).toEqual({
order: "flow_name",
orderDir: -1,
term: "welcome",
perPage: 50
});
});
});

// getRequest only aborts an identical URL, so a second call with different
// params races the first. The per-thunk sequence must both skip the superseded
// call's request and drop any dispatch it still tries to make.
describe("stale-response guards", () => {
const mockStore = configureStore([thunk]);
const storeState = {
currentSummitState: { currentSummit: createMockSummit() }
};

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it("getEmailFlowEvents issues only the newest of two concurrent calls", async () => {
const store = mockStore(storeState);
getRequest.mockImplementation(() => () => () => Promise.resolve({}));

await Promise.all([
getEmailFlowEvents(
"a",
1,
10,
"flow_name",
1
)(store.dispatch, store.getState),
getEmailFlowEvents(
"b",
2,
50,
"flow_name",
-1
)(store.dispatch, store.getState)
]);

expect(getRequest).toHaveBeenCalledTimes(1);
expect(getRequest.mock.calls[0][4]).toEqual({
order: "flow_name",
orderDir: -1,
term: "b",
perPage: 50
});
});

it("getEmailFlowEvents drops a superseded call's late dispatch", async () => {
const dispatched = [];
const store = mockStore(storeState);
store.dispatch = jest.fn((action) => {
if (action && action.type) dispatched.push(action.type);
return action;
});

const guards = [];
getRequest.mockImplementation(() => () => (guardedDispatch) => {
guards.push(guardedDispatch);
return Promise.resolve({});
});

// first call completes and hands us its guarded dispatch
await getEmailFlowEvents(
"a",
1,
10,
"flow_name",
1
)(store.dispatch, store.getState);
// a newer call then supersedes it
await getEmailFlowEvents(
"b",
2,
50,
"flow_name",
-1
)(store.dispatch, store.getState);

dispatched.length = 0;
guards[0]({ type: "STALE_RECEIVE" });
guards[1]({ type: "FRESH_RECEIVE" });

expect(dispatched).not.toContain("STALE_RECEIVE");
expect(dispatched).toContain("FRESH_RECEIVE");
});

it("getEmailFlowEvent issues only the newest of two concurrent calls", async () => {
const store = mockStore(storeState);
getRequest.mockImplementation(() => () => () => Promise.resolve({}));

await Promise.all([
getEmailFlowEvent(1)(store.dispatch, store.getState),
getEmailFlowEvent(2)(store.dispatch, store.getState)
]);

expect(getRequest).toHaveBeenCalledTimes(1);
expect(getRequest.mock.calls[0][2]).toContain("/email-flows-events/2");
});
});
86 changes: 63 additions & 23 deletions src/actions/email-flows-events-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* 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 {
Expand All @@ -18,38 +18,61 @@ import {
createAction,
stopLoading,
startLoading,
showSuccessMessage,
setSnackbarMessage,
authErrorHandler,
escapeFilterValue
} from "openstack-uicore-foundation/lib/utils/actions";
import { getAccessTokenSafely } from "../utils/methods";
import { DEFAULT_PER_PAGE } from "../utils/constants";

export const REQUEST_EMAIL_FLOW_EVENTS = "REQUEST_EMAIL_FLOW_EVENTS";
export const RECEIVE_EMAIL_FLOW_EVENTS = "RECEIVE_EMAIL_FLOW_EVENTS";
export const RECEIVE_EMAIL_FLOW_EVENT = "RECEIVE_EMAIL_FLOW_EVENT";
export const RESET_EMAIL_FLOW_EVENT_FORM = "RESET_EMAIL_FLOW_EVENT_FORM";
export const UPDATE_EMAIL_FLOW_EVENT = "UPDATE_EMAIL_FLOW_EVENT";
export const EMAIL_FLOW_EVENT_UPDATED = "EMAIL_FLOW_EVENT_UPDATED";
export const EMAIL_FLOW_EVENT_DELETED = "EMAIL_FLOW_EVENT_DELETED";

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 emailFlowEventsSeq = sequenced();
const emailFlowEventSeq = sequenced();

export const getEmailFlowEvents =
(
term = null,
term = "",
page = 1,
perPage = 10,
perPage = DEFAULT_PER_PAGE,
order = "email_template_identifier",
orderDir = 1
) =>
async (dispatch, getState) => {
const { currentSummitState } = getState();
// getRequest only aborts an identical URL, so calls with different
// page/sort/term race; the guard drops every dispatch from a superseded one.
const { isCurrent, guardedDispatch } = emailFlowEventsSeq(dispatch);
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;

dispatch(startLoading());
if (!isCurrent()) return Promise.resolve();

guardedDispatch(startLoading());
const filter = [];

const params = {
page: page,
page,
per_page: perPage,
access_token: accessToken
};
Expand All @@ -64,7 +87,7 @@ export const getEmailFlowEvents =
// order
if (order != null && orderDir != null) {
const orderDirSign = orderDir === 1 ? "+" : "-";
params["order"] = `${orderDirSign}${order}`;
params.order = `${orderDirSign}${order}`;
}

if (filter.length > 0) {
Expand All @@ -75,19 +98,23 @@ export const getEmailFlowEvents =
createAction(REQUEST_EMAIL_FLOW_EVENTS),
createAction(RECEIVE_EMAIL_FLOW_EVENTS),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events`,
// TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect)
authErrorHandler,
{ order, orderDir, term }
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
{ order, orderDir, term, perPage }
)(params)(guardedDispatch)
.catch(() => {})
.finally(() => guardedDispatch(stopLoading()));
};

export const getEmailFlowEvent = (eventId) => async (dispatch, getState) => {
const { currentSummitState } = getState();
const { isCurrent, guardedDispatch } = emailFlowEventSeq(dispatch);
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;

dispatch(startLoading());
if (!isCurrent()) return Promise.resolve();

guardedDispatch(startLoading());

const params = {
access_token: accessToken,
Expand All @@ -98,35 +125,48 @@ export const getEmailFlowEvent = (eventId) => async (dispatch, getState) => {
null,
createAction(RECEIVE_EMAIL_FLOW_EVENT),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events/${eventId}`,
// TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect)
authErrorHandler
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
)(params)(guardedDispatch)
.catch(() => {})
.finally(() => guardedDispatch(stopLoading()));
};

export const resetEmailFlowEventForm = () => (dispatch, getState) => {
export const resetEmailFlowEventForm = () => (dispatch) => {
dispatch(createAction(RESET_EMAIL_FLOW_EVENT_FORM)({}));
};

export const saveEmailFlowEvent = (entity) => async (dispatch, getState) => {
const { currentSummitState } = getState();

dispatch(startLoading());

const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;

const params = {
access_token: accessToken
};

dispatch(startLoading());

putRequest(
return putRequest(
createAction(UPDATE_EMAIL_FLOW_EVENT),
createAction(EMAIL_FLOW_EVENT_UPDATED),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events/${entity.id}`,
entity,
// TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect)
authErrorHandler,
entity
)(params)(dispatch).then((payload) => {
dispatch(showSuccessMessage(T.translate("edit_email_flow_event.saved")));
});
)(params)(dispatch)
.then(() => {
dispatch(
setSnackbarMessage({
html: T.translate("edit_email_flow_event.saved"),
type: "success"
})
);
})
.finally(() => {
dispatch(stopLoading());
})
.catch(() => {});
};
Loading
Loading