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
66 changes: 66 additions & 0 deletions src/actions/__tests__/marketing-actions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @jest-environment jsdom
*/
import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import flushPromises from "flush-promises";
import { getRequest } from "openstack-uicore-foundation/lib/utils/actions";
import { getMarketingSettingsBySelectionPlan } from "../marketing-actions";

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

const storeState = {
currentSummitState: { currentSummit: { id: 1 } }
};

describe("getMarketingSettingsBySelectionPlan - stale response guard", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

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

it("drops an older plan's settings response after a newer plan's response already landed", async () => {
const resolvers = {};
getRequest.mockImplementation(
(requestActionCreator, receiveActionCreator) =>
(params) =>
(dispatch) => {
const { selection_plan_id: id } = params;
return new Promise((resolve) => {
resolvers[id] = () => {
if (requestActionCreator) dispatch(requestActionCreator({}));
dispatch(receiveActionCreator({ response: { id } }));
resolve();
};
});
}
);

const store = mockStore(storeState);

// User opens plan 5, then quickly navigates to plan 8 before plan 5's
// settings fetch settles - both requests are genuinely in flight when
// plan 8's response lands first.
store.dispatch(getMarketingSettingsBySelectionPlan("5"));
await flushPromises();
store.dispatch(getMarketingSettingsBySelectionPlan("8"));
await flushPromises();
resolvers[8]();
await flushPromises();
resolvers[5]();
await flushPromises();

const receivedIds = store
.getActions()
.filter((a) => a.type === "RECEIVE_SELECTION_PLAN_SETTINGS")
.map((a) => a.payload.response.id);

expect(receivedIds).toEqual(["8"]);
});
});
113 changes: 110 additions & 3 deletions src/actions/__tests__/selection-plan-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import thunk from "redux-thunk";
import flushPromises from "flush-promises";
import {
postRequest,
putRequest
putRequest,
getRequest
} from "openstack-uicore-foundation/lib/utils/actions";
import { saveSelectionPlan } from "../selection-plan-actions";
import {
saveSelectionPlan,
getSelectionPlan,
resetSelectionPlanForm
} from "../selection-plan-actions";
import * as methods from "../../utils/methods";

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

jest.mock("../marketing-actions", () => ({
Expand Down Expand Up @@ -137,3 +143,104 @@ describe("saveSelectionPlan", () => {
});
});
});

describe("getSelectionPlan - stale response guard", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

// Only the primary "/selection-plans/{id}" fetch is held open (its
// resolution order is controlled from the test); the allowed-members and
// progress-flags follow-up calls resolve immediately so `await`s in
// getSelectionPlan don't hang.
const isPrimaryFetchUrl = (url) => /\/selection-plans\/[^/]+$/.test(url);

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

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

it("drops an older plan's response after a newer plan's response already landed", async () => {
const resolvers = {};
getRequest.mockImplementation(
(requestActionCreator, receiveActionCreator, url) => () => (dispatch) => {
if (isPrimaryFetchUrl(url)) {
const id = Number(url.split("/").pop());
return new Promise((resolve) => {
resolvers[id] = () => {
dispatch(receiveActionCreator({ response: { id } }));
resolve();
};
});
}
if (requestActionCreator) dispatch(requestActionCreator({}));
dispatch(receiveActionCreator({ response: {} }));
return Promise.resolve();
}
);

const store = mockStore(storeState);

// User opens plan 5, then quickly navigates to plan 8 before plan 5's
// fetch settles - both requests are genuinely in flight when plan 8's
// response lands first.
store.dispatch(getSelectionPlan("5"));
await flushPromises();
store.dispatch(getSelectionPlan("8"));
await flushPromises();

// The newer request (plan 8) resolves first...
resolvers[8]();
await flushPromises();

// ...then the older, superseded request (plan 5) resolves late.
resolvers[5]();
await flushPromises();

const receivedIds = store
.getActions()
.filter((a) => a.type === "RECEIVE_SELECTION_PLAN")
.map((a) => a.payload.response.id);

// Plan 5's stale response must never reach the store - only plan 8's.
expect(receivedIds).toEqual([8]);
});

it("drops a plan's response that lands after resetSelectionPlanForm supersedes it", async () => {
const resolvers = {};
getRequest.mockImplementation(
(requestActionCreator, receiveActionCreator, url) => () => (dispatch) => {
if (isPrimaryFetchUrl(url)) {
const id = Number(url.split("/").pop());
return new Promise((resolve) => {
resolvers[id] = () => {
dispatch(receiveActionCreator({ response: { id } }));
resolve();
};
});
}
if (requestActionCreator) dispatch(requestActionCreator({}));
dispatch(receiveActionCreator({ response: {} }));
return Promise.resolve();
}
);

const store = mockStore(storeState);

// User opens plan 5, then navigates back to the list and clicks
// "Add new" before plan 5's fetch settles.
store.dispatch(getSelectionPlan("5"));
await flushPromises();
store.dispatch(resetSelectionPlanForm());
await flushPromises();
resolvers[5]();
await flushPromises();

const actionTypes = store.getActions().map((a) => a.type);

expect(actionTypes).not.toContain("RECEIVE_SELECTION_PLAN");
expect(actionTypes).toContain("RESET_SELECTION_PLAN_FORM");
});
});
30 changes: 27 additions & 3 deletions src/actions/marketing-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ export const getMarketingSettingsForPrintApp =
});
};

// Sequence-guard (see sequenced()): SelectionPlanIdLayout dispatches a fresh
// getMarketingSettingsBySelectionPlan(id) on every route param change, and
// concurrent calls for different plan ids never abort each other (the
// selection_plan_id lives in the query, and uicore's getRequest only aborts
// an identical URL+query) - a stale response landing after a newer one would
// merge the wrong plan's settings into whatever entity is current at that
// moment. guardedDispatch drops the REQUEST/RECEIVE/loading dispatches from
// a superseded call.
const sequenced = () => {
let seq = 0;
return (dispatch) => {
seq += 1;
const mySeq = seq;
return {
isCurrent: () => mySeq === seq,
guardedDispatch: (action) => {
if (mySeq === seq) dispatch(action);
}
};
};
};
const selectionPlanSettingsSeq = sequenced();

export const getMarketingSettingsBySelectionPlan =
(
selectionPlanId,
Expand All @@ -151,8 +174,9 @@ export const getMarketingSettingsBySelectionPlan =
(dispatch, getState) => {
const { currentSummitState } = getState();
const { currentSummit } = currentSummitState;
const { guardedDispatch } = selectionPlanSettingsSeq(dispatch);

dispatch(startLoading());
guardedDispatch(startLoading());

const params = {
page,
Expand All @@ -176,8 +200,8 @@ export const getMarketingSettingsBySelectionPlan =
`${window.MARKETING_API_BASE_URL}/api/public/v1/config-values/all/shows/${currentSummit.id}`,
authErrorHandler,
{ order, orderDir, term }
)(params)(dispatch).then(() => {
dispatch(stopLoading());
)(params)(guardedDispatch).then(() => {
guardedDispatch(stopLoading());
});
};

Expand Down
48 changes: 40 additions & 8 deletions src/actions/selection-plan-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,39 @@ export const getSelectionPlans =
});
};

// Sequence-guard (see sequenced()): SelectionPlanIdLayout dispatches a fresh
// getSelectionPlan(id) on every route param change, and concurrent calls for
// different plan ids never abort each other - a stale response landing after
// a newer one would overwrite the store's entity with the wrong plan's data,
// which the layout's render guard can never recover from on its own (it only
// compares the store id against the URL id; nothing re-triggers a fetch).
// guardedDispatch drops the RECEIVE/loading dispatches from a superseded call.
const sequenced = () => {
let seq = 0;
return (dispatch) => {
seq += 1;
const mySeq = seq;
return {
isCurrent: () => mySeq === seq,
guardedDispatch: (action) => {
if (mySeq === seq) dispatch(action);
}
};
};
};
const getSelectionPlanSeq = sequenced();
Comment thread
tomrndom marked this conversation as resolved.

export const getSelectionPlan =
(selectionPlanId) => async (dispatch, getState) => {
const { currentSummitState } = getState();
const { isCurrent, guardedDispatch } = getSelectionPlanSeq(dispatch);
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;

dispatch(startLoading());
// Superseded while awaiting the token -> don't fire a request at all.
if (!isCurrent()) return Promise.resolve();

guardedDispatch(startLoading());

const params = {
access_token: accessToken,
Expand All @@ -134,16 +160,22 @@ export const getSelectionPlan =
createAction(RECEIVE_SELECTION_PLAN),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`,
snackbarErrorHandler
)(params)(dispatch).then(async () => {
await dispatch(getAllowedMembers(selectionPlanId));
await dispatch(
getSelectionPlanProgressFlags(currentSummit.id, selectionPlanId)
);
dispatch(stopLoading());
});
)(params)(guardedDispatch)
.then(async () => {
// Superseded while the entity was in flight -> skip the follow-up
// requests entirely rather than let them write stale data too.
if (!isCurrent()) return;
await dispatch(getAllowedMembers(selectionPlanId));
if (!isCurrent()) return;
await dispatch(
getSelectionPlanProgressFlags(currentSummit.id, selectionPlanId)
);
})
.finally(() => guardedDispatch(stopLoading()));
};

export const resetSelectionPlanForm = () => (dispatch) => {
getSelectionPlanSeq(dispatch); // invalidates any in-flight getSelectionPlan
dispatch(createAction(RESET_SELECTION_PLAN_FORM)({}));
};

Expand Down
26 changes: 26 additions & 0 deletions src/components/buttons/add-new-button-mui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from "react";
import { withRouter } from "react-router-dom";
import Button from "@mui/material/Button";
import AddIcon from "@mui/icons-material/Add";
import T from "i18n-react";

function AddNewButtonMui({ entity, history }) {
if (!entity?.id) return null;

const handleClick = () => {
history.push("new");
};

return (
<Button
variant="contained"
onClick={handleClick}
startIcon={<AddIcon />}
sx={{ float: "right" }}
>
{T.translate("general.add_new")}
</Button>
);
}

export default withRouter(AddNewButtonMui);
Loading
Loading