diff --git a/src/layouts/__tests__/selection-plan-id-layout.test.js b/src/layouts/__tests__/selection-plan-id-layout.test.js
new file mode 100644
index 000000000..206f83d47
--- /dev/null
+++ b/src/layouts/__tests__/selection-plan-id-layout.test.js
@@ -0,0 +1,188 @@
+/**
+ * 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 from "react";
+import { screen, act, waitFor } from "@testing-library/react";
+import { Router, Route, Switch } from "react-router-dom";
+import { createMemoryHistory } from "history";
+import flushPromises from "flush-promises";
+import { renderWithRedux } from "../../utils/test-utils";
+import { getSelectionPlan } from "../../actions/selection-plan-actions";
+import { getMarketingSettingsBySelectionPlan } from "../../actions/marketing-actions";
+import SelectionPlanIdLayout from "../selection-plan-id-layout";
+
+jest.mock("i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (k) => k }
+}));
+
+// The page renders null until it's ready, so the breadcrumb's presence is our signal.
+jest.mock("react-breadcrumbs", () => ({
+ Breadcrumb: () =>
+}));
+
+jest.mock("../../actions/selection-plan-actions", () => ({
+ __esModule: true,
+ ...jest.requireActual("../../actions/selection-plan-actions"),
+ getSelectionPlan: jest.fn(),
+ resetSelectionPlanForm: jest.fn(() => ({ type: "RESET_SELECTION_PLAN_FORM" }))
+}));
+
+jest.mock("../../actions/marketing-actions", () => ({
+ __esModule: true,
+ ...jest.requireActual("../../actions/marketing-actions"),
+ getMarketingSettingsBySelectionPlan: jest.fn()
+}));
+
+// Stub the page: the real form needs a fuller marketing-settings shape than set up here.
+jest.mock("../../pages/selection-plans/edit-selection-plan-page", () => ({
+ __esModule: true,
+ default: () =>
+}));
+
+const renderAt = (path, currentSelectionPlan) => {
+ const history = createMemoryHistory({ initialEntries: [path] });
+ const result = renderWithRedux(
+
+
+ ,
+ {
+ initialState: {
+ currentSelectionPlanState: { entity: currentSelectionPlan },
+ currentSummitState: { currentSummit: { id: 1 } }
+ }
+ }
+ );
+ return { ...result, history };
+};
+
+const settle = () => act(async () => flushPromises());
+
+const isPageRendered = () => screen.queryByTestId("breadcrumb") !== null;
+
+// Mirrors the sibling /new and /:id(\d+) routes in selection-plan-layout.js.
+const NewOrEditHarness = ({ history }) => (
+
+
+
+
+
+
+);
+
+describe("SelectionPlanIdLayout load guard", () => {
+ beforeEach(() => {
+ getSelectionPlan.mockReset();
+ getMarketingSettingsBySelectionPlan.mockReset();
+ getSelectionPlan.mockImplementation(() => () => Promise.resolve());
+ getMarketingSettingsBySelectionPlan.mockImplementation(
+ () => () => Promise.resolve()
+ );
+ });
+
+ it("does not render on direct load until the matching plan finishes fetching", async () => {
+ getSelectionPlan.mockImplementation(() => () => new Promise(() => {}));
+ renderAt("/app/summits/1/selection-plans/5", { id: 5 });
+ expect(isPageRendered()).toBe(false);
+ });
+
+ it("stops rendering when switching to a different plan id until the store catches up", async () => {
+ const history = createMemoryHistory({
+ initialEntries: ["/app/summits/1/selection-plans/5"]
+ });
+ renderWithRedux(
+
+
+ ,
+ {
+ initialState: {
+ currentSelectionPlanState: { entity: { id: 5 } },
+ currentSummitState: { currentSummit: { id: 1 } }
+ }
+ }
+ );
+ await settle();
+ expect(isPageRendered()).toBe(true);
+
+ act(() => {
+ history.push("/app/summits/1/selection-plans/8");
+ });
+ expect(isPageRendered()).toBe(false);
+ expect(getSelectionPlan).toHaveBeenCalledWith("8");
+
+ // Fetch settles, but the store's entity.id is still "5" — must stay unrendered.
+ await settle();
+ expect(isPageRendered()).toBe(false);
+ });
+
+ it("stops rendering when navigating from an existing plan to /new until the store reflects the reset", async () => {
+ const history = createMemoryHistory({
+ initialEntries: ["/app/summits/1/selection-plans/5"]
+ });
+ renderWithRedux(, {
+ initialState: {
+ currentSelectionPlanState: { entity: { id: 5 } },
+ currentSummitState: { currentSummit: { id: 1 } }
+ }
+ });
+ await settle();
+ expect(isPageRendered()).toBe(true);
+
+ // Store still holds plan 5's entity (reset hasn't landed) — must not render.
+ act(() => {
+ history.push("/app/summits/1/selection-plans/new");
+ });
+ expect(isPageRendered()).toBe(false);
+ });
+
+ it("renders on /new once the store reflects the reset (default) entity", async () => {
+ const history = createMemoryHistory({
+ initialEntries: ["/app/summits/1/selection-plans/new"]
+ });
+ renderWithRedux(, {
+ initialState: {
+ currentSelectionPlanState: { entity: { id: 0 } },
+ currentSummitState: { currentSummit: { id: 1 } }
+ }
+ });
+ await settle();
+ expect(isPageRendered()).toBe(true);
+ });
+
+ it("redirects to the selection plans list when the fetch rejects", async () => {
+ getSelectionPlan.mockImplementation(
+ () => () => Promise.reject(new Error("fail"))
+ );
+ const { history } = renderAt("/app/summits/1/selection-plans/5", {
+ id: 0
+ });
+ await waitFor(() =>
+ expect(history.location.pathname).toBe("/app/summits/1/selection-plans")
+ );
+ expect(isPageRendered()).toBe(false);
+ });
+});
diff --git a/src/layouts/selection-plan-id-layout.js b/src/layouts/selection-plan-id-layout.js
index 04e41a5ed..583c7c800 100644
--- a/src/layouts/selection-plan-id-layout.js
+++ b/src/layouts/selection-plan-id-layout.js
@@ -1,9 +1,10 @@
-import React, { Suspense, useEffect } from "react";
+import React, { Suspense, useEffect, useState } from "react";
import { connect } from "react-redux";
import { Redirect, Route, Switch } from "react-router-dom";
import { Breadcrumb } from "react-breadcrumbs";
import T from "i18n-react";
import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader";
+import NoMatchPage from "../pages/no-match-page";
import {
getSelectionPlan,
resetSelectionPlanForm
@@ -11,6 +12,9 @@ import {
import { getMarketingSettingsBySelectionPlan } from "../actions/marketing-actions";
import { MAX_PER_PAGE } from "../utils/constants";
+const EditSelectionPlanPage = React.lazy(() =>
+ import("../pages/selection-plans/edit-selection-plan-page")
+);
const SelectionPlanExtraQuestionsLayout = React.lazy(() =>
import("./selection-plan-extra-questions-layout")
);
@@ -26,31 +30,53 @@ const SelectionPlanIdLayout = ({
resetSelectionPlanForm,
getMarketingSettingsBySelectionPlan
}) => {
+ const [hasLoaded, setHasLoaded] = useState(false);
+ const [hasError, setHasError] = useState(false);
const selectionPlanId = match.params.selection_plan_id;
const breadcrumb = selectionPlanId
? currentSelectionPlan.name
: T.translate("general.new");
useEffect(() => {
+ setHasLoaded(false);
+ setHasError(false);
if (!selectionPlanId) {
resetSelectionPlanForm();
+ setHasLoaded(true);
} else {
- getSelectionPlan(selectionPlanId).then(() =>
- getMarketingSettingsBySelectionPlan(
- selectionPlanId,
- null,
- 1,
- MAX_PER_PAGE
+ getSelectionPlan(selectionPlanId)
+ .then(() =>
+ getMarketingSettingsBySelectionPlan(
+ selectionPlanId,
+ null,
+ 1,
+ MAX_PER_PAGE
+ )
)
- );
+ .then(() => setHasLoaded(true))
+ .catch(() => setHasError(true));
}
}, [selectionPlanId]);
+ if (hasError) {
+ return ;
+ }
+
+ if (!hasLoaded || currentSelectionPlan.id !== Number(selectionPlanId || 0)) {
+ return null;
+ }
+
return (
}>
+
-
+
diff --git a/src/layouts/selection-plan-layout.js b/src/layouts/selection-plan-layout.js
index 3a4043345..b5db7329c 100644
--- a/src/layouts/selection-plan-layout.js
+++ b/src/layouts/selection-plan-layout.js
@@ -12,14 +12,14 @@
* */
import React from "react";
-import { connect } from "react-redux";
-import { Redirect, Route, Switch } from "react-router-dom";
+import { Route, Switch } from "react-router-dom";
import T from "i18n-react/dist/i18n-react";
import { Breadcrumb } from "react-breadcrumbs";
import SelectionPlanListPage from "../pages/selection-plans/selection-plan-list-page";
import SelectionPlanIdLayout from "./selection-plan-id-layout";
+import NoMatchPage from "../pages/no-match-page";
-const SelectionPlanLayout = ({ match, currentSummit }) => (
+const SelectionPlanLayout = ({ match }) => (
);
-const mapStateToProps = ({ currentSummitState }) => ({
- ...currentSummitState
-});
-
-export default connect(mapStateToProps, {})(SelectionPlanLayout);
+export default SelectionPlanLayout;
diff --git a/src/pages/selection-plans/__tests__/edit-selection-plan-page.test.js b/src/pages/selection-plans/__tests__/edit-selection-plan-page.test.js
new file mode 100644
index 000000000..935822639
--- /dev/null
+++ b/src/pages/selection-plans/__tests__/edit-selection-plan-page.test.js
@@ -0,0 +1,200 @@
+/**
+ * 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 from "react";
+import { screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import flushPromises from "flush-promises";
+import { renderWithRedux } from "../../../utils/test-utils";
+import EditSelectionPlanPage from "../edit-selection-plan-page";
+import {
+ saveSelectionPlan,
+ saveSelectionPlanSettings
+} from "../../../actions/selection-plan-actions";
+
+jest.mock("../../../actions/selection-plan-actions", () => ({
+ __esModule: true,
+ saveSelectionPlan: jest.fn(),
+ saveSelectionPlanSettings: jest.fn(),
+ addTrackGroupToSelectionPlan: jest.fn(),
+ removeTrackGroupFromSelectionPlan: jest.fn(),
+ addEventTypeSelectionPlan: jest.fn(),
+ deleteEventTypeSelectionPlan: jest.fn(),
+ updateSelectionPlanExtraQuestionOrder: jest.fn(),
+ deleteSelectionPlanExtraQuestion: jest.fn(),
+ updateRatingTypeOrder: jest.fn(),
+ deleteRatingType: jest.fn(),
+ assignExtraQuestion2SelectionPlan: jest.fn(),
+ assignProgressFlag2SelectionPlan: jest.fn(),
+ updateProgressFlagOrder: jest.fn(),
+ unassignProgressFlagFromSelectionPlan: jest.fn(),
+ addAllowedMemberToSelectionPlan: jest.fn(),
+ removeAllowedMemberFromSelectionPlan: jest.fn(),
+ getAllowedMembers: jest.fn(),
+ importAllowedMembersCSV: jest.fn()
+}));
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+// Stub the real form: it needs a fuller entity/marketing-settings shape than
+// set up here. Exposes onSave so the page's save/redirect logic can be
+// exercised directly, mirroring the entity passed in.
+jest.mock("../../../components/forms/selection-plan-form", () => ({
+ __esModule: true,
+ default: ({ onSave, entity }) => (
+