From c5e2557ffe0e4e8fe40c0e423f38b14f6c7b99a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 10 Sep 2026 17:45:37 -0300 Subject: [PATCH 01/12] fix: restore selection plan edit route and page, remove popup, update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/layouts/selection-plan-id-layout.js | 13 +- src/layouts/selection-plan-layout.js | 2 +- .../selection-plan-list-page.test.js | 68 +++----- .../edit-selection-plan-page.js | 145 ++++++++++++++---- .../selection-plan-list-page.js | 85 ++-------- .../selection-plans/selection-plan-popup.js | 98 ------------ 6 files changed, 155 insertions(+), 256 deletions(-) delete mode 100644 src/pages/selection-plans/selection-plan-popup.js diff --git a/src/layouts/selection-plan-id-layout.js b/src/layouts/selection-plan-id-layout.js index 04e41a5ed..4fffb8749 100644 --- a/src/layouts/selection-plan-id-layout.js +++ b/src/layouts/selection-plan-id-layout.js @@ -11,6 +11,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") ); @@ -51,6 +54,12 @@ const SelectionPlanIdLayout = ({ }> + - + diff --git a/src/layouts/selection-plan-layout.js b/src/layouts/selection-plan-layout.js index 3a4043345..57d103b6d 100644 --- a/src/layouts/selection-plan-layout.js +++ b/src/layouts/selection-plan-layout.js @@ -38,7 +38,7 @@ const SelectionPlanLayout = ({ match, currentSummit }) => ( strict exact path={`${match.url}/new`} - render={() => } + component={SelectionPlanIdLayout} /> ({ getSelectionPlans: jest.fn(), - getSelectionPlan: jest.fn(), - deleteSelectionPlan: jest.fn(), - resetSelectionPlanForm: jest.fn(), - saveSelectionPlan: jest.fn(), - saveSelectionPlanSettings: jest.fn() -})); - -jest.mock("../../../actions/marketing-actions", () => ({ - getMarketingSettingsBySelectionPlan: jest.fn() + deleteSelectionPlan: jest.fn() })); jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ @@ -50,23 +37,12 @@ jest.mock( }) ); -jest.mock("../edit-selection-plan-page", () => ({ - __esModule: true, - default: ({ onSave }) => ( -
- -
- ) -})); - jest.mock("i18n-react/dist/i18n-react", () => ({ __esModule: true, default: { translate: (key) => key } })); -const mockHistory = { replace: jest.fn() }; +const mockHistory = { push: jest.fn(), replace: jest.fn() }; const mockMatch = { params: {} }; const initialState = { @@ -83,10 +59,6 @@ const initialState = { term: "", order: "id", orderDir: 1 - }, - currentSelectionPlanState: { - entity: { id: 0, name: "" }, - errors: {} } }; @@ -94,39 +66,37 @@ describe("SelectionPlanListPage", () => { beforeEach(() => { jest.clearAllMocks(); getSelectionPlans.mockReturnValue(() => Promise.resolve()); - getSelectionPlan.mockReturnValue(() => Promise.resolve()); deleteSelectionPlan.mockReturnValue(() => Promise.resolve()); - resetSelectionPlanForm.mockReturnValue({ - type: "RESET_SELECTION_PLAN_FORM" - }); - getMarketingSettingsBySelectionPlan.mockReturnValue(() => - Promise.resolve() - ); - saveSelectionPlan.mockReturnValue(() => Promise.resolve({ id: 1 })); - saveSelectionPlanSettings.mockReturnValue(() => Promise.resolve()); }); - it("reloads the list after a successful save", async () => { + it("navigates to the new selection plan route", async () => { renderWithRedux( , { initialState } ); - // Open dialog await userEvent.click( screen.getByRole("button", { name: "selection_plan_list.add_selection_plan" }) ); - expect(screen.getByTestId("edit-selection-plan")).toBeInTheDocument(); - await act(async () => { - await userEvent.click(screen.getByRole("button", { name: "popup-save" })); - await flushPromises(); - }); + expect(mockHistory.push).toHaveBeenCalledWith( + "/app/summits/1/selection-plans/new" + ); + }); - // Call 1: useEffect on mount; call 2: handleSave → refreshSelectionPlans - expect(getSelectionPlans).toHaveBeenCalledTimes(2); + it("navigates to the selection plan edit route", async () => { + renderWithRedux( + , + { initialState } + ); + + await userEvent.click(screen.getByRole("button", { name: "edit-row" })); + + expect(mockHistory.push).toHaveBeenCalledWith( + "/app/summits/1/selection-plans/1" + ); }); it("reloads the list after a successful delete", async () => { diff --git a/src/pages/selection-plans/edit-selection-plan-page.js b/src/pages/selection-plans/edit-selection-plan-page.js index 7eb80ef62..845312507 100644 --- a/src/pages/selection-plans/edit-selection-plan-page.js +++ b/src/pages/selection-plans/edit-selection-plan-page.js @@ -10,10 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. * */ -import React from "react"; +import React, { useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import Swal from "sweetalert2"; +import { Breadcrumb } from "react-breadcrumbs"; +import { Button, Grid2 } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; import SelectionPlanForm from "../../components/forms/selection-plan-form"; import { addAllowedMemberToSelectionPlan, @@ -28,6 +31,8 @@ import { importAllowedMembersCSV, removeAllowedMemberFromSelectionPlan, removeTrackGroupFromSelectionPlan, + saveSelectionPlan, + saveSelectionPlanSettings, unassignProgressFlagFromSelectionPlan, updateProgressFlagOrder, updateRatingTypeOrder, @@ -39,10 +44,12 @@ const EditSelectionPlanPage = ({ entity, allowedMembers, errors, - onSave, + match, history, extraQuestionsOrder, extraQuestionsOrderDir, + saveSelectionPlan, + saveSelectionPlanSettings, updateSelectionPlanExtraQuestionOrder, unassignProgressFlagFromSelectionPlan, deleteSelectionPlanExtraQuestion, @@ -60,6 +67,29 @@ const EditSelectionPlanPage = ({ importAllowedMembersCSV, removeAllowedMemberFromSelectionPlan }) => { + const [isSaving, setIsSaving] = useState(false); + + const onSave = (values) => { + if (isSaving) return Promise.resolve(); + setIsSaving(true); + return saveSelectionPlan(values) + .then((savedEntity) => { + if (!savedEntity?.id) return null; + return saveSelectionPlanSettings( + values.marketing_settings ?? {}, + savedEntity.id + ).then(() => { + if (!values.id) { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${savedEntity.id}` + ); + } + }); + }) + .catch(() => {}) + .finally(() => setIsSaving(false)); + }; + const onDeleteExtraQuestion = (questionId) => { const extraQuestion = entity.extra_questions.find( (t) => t.id === questionId @@ -175,38 +205,85 @@ const EditSelectionPlanPage = ({ }); }; + const title = entity?.id + ? T.translate("general.edit") + : T.translate("general.add"); + const breadcrumb = entity?.id ? entity.name : T.translate("general.new"); + return ( - +
+ + + +

+ {title} {T.translate("edit_selection_plan.selection_plan")} +

+
+ {entity?.id > 0 && ( + + + + )} +
+
+ + + + +
); }; @@ -219,6 +296,8 @@ const mapStateToProps = ({ }); export default connect(mapStateToProps, { + saveSelectionPlan, + saveSelectionPlanSettings, addTrackGroupToSelectionPlan, removeTrackGroupFromSelectionPlan, addEventTypeSelectionPlan, diff --git a/src/pages/selection-plans/selection-plan-list-page.js b/src/pages/selection-plans/selection-plan-list-page.js index d6aaa79ce..64eaa2e57 100644 --- a/src/pages/selection-plans/selection-plan-list-page.js +++ b/src/pages/selection-plans/selection-plan-list-page.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import Box from "@mui/material/Box"; @@ -21,55 +21,23 @@ import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import GridToolbar from "../../components/mui/grid-toolbar"; import { deleteSelectionPlan, - getSelectionPlan, - getSelectionPlans, - resetSelectionPlanForm, - saveSelectionPlan, - saveSelectionPlanSettings + getSelectionPlans } from "../../actions/selection-plan-actions"; -import { getMarketingSettingsBySelectionPlan } from "../../actions/marketing-actions"; -import { DEFAULT_CURRENT_PAGE, MAX_PER_PAGE } from "../../utils/constants"; -import SelectionPlanPopup from "./selection-plan-popup"; +import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; const SelectionPlanListPage = ({ currentSummit, history, selectionPlans, - currentSelectionPlan, totalSelectionPlans, perPage, term, order, orderDir, currentPage, - getSelectionPlan, getSelectionPlans, - resetSelectionPlanForm, - getMarketingSettingsBySelectionPlan, - deleteSelectionPlan, - saveSelectionPlan, - saveSelectionPlanSettings + deleteSelectionPlan }) => { - const [openSelectionPlanPopup, setOpenSelectionPlanPopup] = useState(false); - - const openEditModal = useCallback( - (selectionPlanId) => { - if (!selectionPlanId) return; - - getSelectionPlan(selectionPlanId) - .then(() => - getMarketingSettingsBySelectionPlan( - selectionPlanId, - null, - DEFAULT_CURRENT_PAGE, - MAX_PER_PAGE - ) - ) - .then(() => setOpenSelectionPlanPopup(true)); - }, - [getMarketingSettingsBySelectionPlan, getSelectionPlan] - ); - useEffect(() => { if (currentSummit?.id) { getSelectionPlans(term, DEFAULT_CURRENT_PAGE, perPage, order, orderDir); @@ -81,7 +49,9 @@ const SelectionPlanListPage = ({ const handleEdit = (selectionPlan) => { if (!selectionPlan?.id) return; - openEditModal(selectionPlan.id); + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${selectionPlan.id}` + ); }; const handleDelete = (id) => { @@ -93,26 +63,9 @@ const SelectionPlanListPage = ({ }; const handleNew = () => { - resetSelectionPlanForm(); - setOpenSelectionPlanPopup(true); + history.push(`/app/summits/${currentSummit.id}/selection-plans/new`); }; - const handleClosePopup = () => { - resetSelectionPlanForm(); - setOpenSelectionPlanPopup(false); - }; - - const handleSave = (entity) => - saveSelectionPlan(entity) - .then((savedEntity) => { - if (!savedEntity?.id) return null; - return saveSelectionPlanSettings( - entity.marketing_settings ?? {}, - savedEntity.id - ); - }) - .then(() => refreshSelectionPlans()); - const handleSort = (key, dir) => { getSelectionPlans(term, currentPage, perPage, key, dir); }; @@ -209,35 +162,19 @@ const SelectionPlanListPage = ({ /> )} - - {openSelectionPlanPopup && ( - - )} ); }; const mapStateToProps = ({ currentSummitState, - currentSelectionPlanListState, - currentSelectionPlanState + currentSelectionPlanListState }) => ({ currentSummit: currentSummitState.currentSummit, - ...currentSelectionPlanListState, - currentSelectionPlan: currentSelectionPlanState.entity + ...currentSelectionPlanListState }); export default connect(mapStateToProps, { getSelectionPlans, - getSelectionPlan, - resetSelectionPlanForm, - getMarketingSettingsBySelectionPlan, - deleteSelectionPlan, - saveSelectionPlan, - saveSelectionPlanSettings + deleteSelectionPlan })(SelectionPlanListPage); diff --git a/src/pages/selection-plans/selection-plan-popup.js b/src/pages/selection-plans/selection-plan-popup.js deleted file mode 100644 index 879b97290..000000000 --- a/src/pages/selection-plans/selection-plan-popup.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright 2019 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 Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import CloseIcon from "@mui/icons-material/Close"; -import EditSelectionPlanPage from "./edit-selection-plan-page"; - -const SelectionPlanPopup = ({ isEditing, onClose, onSave, history }) => { - const [isSaving, setIsSaving] = useState(false); - - const handleClose = () => { - if (isSaving) return; - onClose(); - }; - - const handleSave = (values) => { - if (isSaving) return Promise.resolve(); - setIsSaving(true); - return Promise.resolve(onSave(values)) - .then(() => onClose()) - .catch(() => {}) - .finally(() => setIsSaving(false)); - }; - - return ( - - - {isEditing ? T.translate("general.edit") : T.translate("general.add")}{" "} - {T.translate("edit_selection_plan.selection_plan")} - - - - - - - - - - - - - - ); -}; - -SelectionPlanPopup.propTypes = { - isEditing: PropTypes.bool, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, - history: PropTypes.shape({ push: PropTypes.func }).isRequired -}; - -SelectionPlanPopup.defaultProps = { - isEditing: false -}; - -export default SelectionPlanPopup; From 9dddaf5a7c04c424e75921664d2fae9d1a8e272f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 11 Sep 2026 17:44:21 -0300 Subject: [PATCH 02/12] fix: add missing catch on save selection plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../selection-plans/edit-selection-plan-page.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pages/selection-plans/edit-selection-plan-page.js b/src/pages/selection-plans/edit-selection-plan-page.js index 845312507..5196eca79 100644 --- a/src/pages/selection-plans/edit-selection-plan-page.js +++ b/src/pages/selection-plans/edit-selection-plan-page.js @@ -78,13 +78,15 @@ const EditSelectionPlanPage = ({ return saveSelectionPlanSettings( values.marketing_settings ?? {}, savedEntity.id - ).then(() => { - if (!values.id) { - history.push( - `/app/summits/${currentSummit.id}/selection-plans/${savedEntity.id}` - ); - } - }); + ) + .catch(() => {}) + .then(() => { + if (!values.id) { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${savedEntity.id}` + ); + } + }); }) .catch(() => {}) .finally(() => setIsSaving(false)); From 475df1f6b6b356d1564d00cd15b41497e359c681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 11 Sep 2026 18:07:23 -0300 Subject: [PATCH 03/12] fix: keep form in sync with layout, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../selection-plan-id-layout.test.js | 120 ++++++++++++++++++ src/layouts/selection-plan-id-layout.js | 28 ++-- 2 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 src/layouts/__tests__/selection-plan-id-layout.test.js 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..d7284d947 --- /dev/null +++ b/src/layouts/__tests__/selection-plan-id-layout.test.js @@ -0,0 +1,120 @@ +/** + * 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 { Router, Route } 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 gate returns null until the fetch resolves and the loaded entity +// matches the URL id, so the breadcrumb's presence signals the gate is open. +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() +})); + +// Keep the gated subtree trivial so an open gate doesn't drag in the real +// form (which needs a fuller marketing-settings shape than these tests set up). +jest.mock("../../pages/selection-plans/edit-selection-plan-page", () => ({ + __esModule: true, + default: () =>
+})); + +const renderAt = (path, currentSelectionPlan) => { + const history = createMemoryHistory({ initialEntries: [path] }); + return renderWithRedux( + + + , + { + initialState: { + currentSelectionPlanState: { entity: currentSelectionPlan }, + currentSummitState: { currentSummit: { id: 1 } } + } + } + ); +}; + +const settle = () => act(async () => flushPromises()); + +const gateOpen = () => screen.queryByTestId("breadcrumb") !== null; + +describe("SelectionPlanIdLayout selection-plan gate", () => { + beforeEach(() => { + getSelectionPlan.mockReset(); + getMarketingSettingsBySelectionPlan.mockReset(); + getSelectionPlan.mockImplementation(() => () => Promise.resolve()); + getMarketingSettingsBySelectionPlan.mockImplementation( + () => () => Promise.resolve() + ); + }); + + it("stays closed on direct load until the matching plan finishes fetching", async () => { + getSelectionPlan.mockImplementation(() => () => new Promise(() => {})); + renderAt("/app/summits/1/selection-plans/5", { id: 5 }); + expect(gateOpen()).toBe(false); + }); + + it("closes 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(gateOpen()).toBe(true); + + act(() => { + history.push("/app/summits/1/selection-plans/8"); + }); + expect(gateOpen()).toBe(false); + expect(getSelectionPlan).toHaveBeenCalledWith("8"); + }); +}); diff --git a/src/layouts/selection-plan-id-layout.js b/src/layouts/selection-plan-id-layout.js index 4fffb8749..7fbee9c97 100644 --- a/src/layouts/selection-plan-id-layout.js +++ b/src/layouts/selection-plan-id-layout.js @@ -1,4 +1,4 @@ -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"; @@ -29,26 +29,38 @@ const SelectionPlanIdLayout = ({ resetSelectionPlanForm, getMarketingSettingsBySelectionPlan }) => { + const [hasLoaded, setHasLoaded] = useState(false); const selectionPlanId = match.params.selection_plan_id; const breadcrumb = selectionPlanId ? currentSelectionPlan.name : T.translate("general.new"); useEffect(() => { + setHasLoaded(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)); } }, [selectionPlanId]); + if ( + selectionPlanId && + (!hasLoaded || currentSelectionPlan.id !== Number(selectionPlanId)) + ) { + return null; + } + return (
From 1ebd0def9bb78545543bfa6864232b080c5138ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 11 Sep 2026 19:24:48 -0300 Subject: [PATCH 04/12] fix: adjust breadcrumb, fix issue on new selection plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../selection-plan-id-layout.test.js | 64 +++++++++++++++++-- src/layouts/selection-plan-id-layout.js | 5 +- .../edit-selection-plan-page.js | 4 -- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/layouts/__tests__/selection-plan-id-layout.test.js b/src/layouts/__tests__/selection-plan-id-layout.test.js index d7284d947..26a98d28d 100644 --- a/src/layouts/__tests__/selection-plan-id-layout.test.js +++ b/src/layouts/__tests__/selection-plan-id-layout.test.js @@ -13,7 +13,7 @@ import React from "react"; import { screen, act } from "@testing-library/react"; -import { Router, Route } from "react-router-dom"; +import { Router, Route, Switch } from "react-router-dom"; import { createMemoryHistory } from "history"; import flushPromises from "flush-promises"; import { renderWithRedux } from "../../utils/test-utils"; @@ -26,8 +26,7 @@ jest.mock("i18n-react", () => ({ default: { translate: (k) => k } })); -// The gate returns null until the fetch resolves and the loaded entity -// matches the URL id, so the breadcrumb's presence signals the gate is open. +// The gate renders null until it's open, so the breadcrumb's presence is our signal. jest.mock("react-breadcrumbs", () => ({ Breadcrumb: () =>
})); @@ -45,8 +44,7 @@ jest.mock("../../actions/marketing-actions", () => ({ getMarketingSettingsBySelectionPlan: jest.fn() })); -// Keep the gated subtree trivial so an open gate doesn't drag in the real -// form (which needs a fuller marketing-settings shape than these tests set up). +// 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: () =>
@@ -74,6 +72,24 @@ const settle = () => act(async () => flushPromises()); const gateOpen = () => screen.queryByTestId("breadcrumb") !== null; +// Mirrors the sibling /new and /:id(\d+) routes in selection-plan-layout.js. +const NewOrEditHarness = ({ history }) => ( + + + + + + +); + describe("SelectionPlanIdLayout selection-plan gate", () => { beforeEach(() => { getSelectionPlan.mockReset(); @@ -116,5 +132,43 @@ describe("SelectionPlanIdLayout selection-plan gate", () => { }); expect(gateOpen()).toBe(false); expect(getSelectionPlan).toHaveBeenCalledWith("8"); + + // Fetch settles, but the store's entity.id is still "5" — must stay closed. + await settle(); + expect(gateOpen()).toBe(false); + }); + + it("closes 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(gateOpen()).toBe(true); + + // Store still holds plan 5's entity (reset hasn't landed) — gate must close. + act(() => { + history.push("/app/summits/1/selection-plans/new"); + }); + expect(gateOpen()).toBe(false); + }); + + it("opens 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(gateOpen()).toBe(true); }); }); diff --git a/src/layouts/selection-plan-id-layout.js b/src/layouts/selection-plan-id-layout.js index 7fbee9c97..566084976 100644 --- a/src/layouts/selection-plan-id-layout.js +++ b/src/layouts/selection-plan-id-layout.js @@ -54,10 +54,7 @@ const SelectionPlanIdLayout = ({ } }, [selectionPlanId]); - if ( - selectionPlanId && - (!hasLoaded || currentSelectionPlan.id !== Number(selectionPlanId)) - ) { + if (!hasLoaded || currentSelectionPlan.id !== Number(selectionPlanId || 0)) { return null; } diff --git a/src/pages/selection-plans/edit-selection-plan-page.js b/src/pages/selection-plans/edit-selection-plan-page.js index 5196eca79..fc5b09856 100644 --- a/src/pages/selection-plans/edit-selection-plan-page.js +++ b/src/pages/selection-plans/edit-selection-plan-page.js @@ -14,7 +14,6 @@ import React, { useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import Swal from "sweetalert2"; -import { Breadcrumb } from "react-breadcrumbs"; import { Button, Grid2 } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import SelectionPlanForm from "../../components/forms/selection-plan-form"; @@ -44,7 +43,6 @@ const EditSelectionPlanPage = ({ entity, allowedMembers, errors, - match, history, extraQuestionsOrder, extraQuestionsOrderDir, @@ -210,11 +208,9 @@ const EditSelectionPlanPage = ({ const title = entity?.id ? T.translate("general.edit") : T.translate("general.add"); - const breadcrumb = entity?.id ? entity.name : T.translate("general.new"); return (
- Date: Fri, 11 Sep 2026 20:20:45 -0300 Subject: [PATCH 05/12] fix: add missing catch, update test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../selection-plan-id-layout.test.js | 41 +++++++++++-------- src/layouts/selection-plan-id-layout.js | 3 +- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/layouts/__tests__/selection-plan-id-layout.test.js b/src/layouts/__tests__/selection-plan-id-layout.test.js index 26a98d28d..ec1186061 100644 --- a/src/layouts/__tests__/selection-plan-id-layout.test.js +++ b/src/layouts/__tests__/selection-plan-id-layout.test.js @@ -26,7 +26,7 @@ jest.mock("i18n-react", () => ({ default: { translate: (k) => k } })); -// The gate renders null until it's open, so the breadcrumb's presence is our signal. +// The page renders null until it's ready, so the breadcrumb's presence is our signal. jest.mock("react-breadcrumbs", () => ({ Breadcrumb: () =>
})); @@ -70,7 +70,7 @@ const renderAt = (path, currentSelectionPlan) => { const settle = () => act(async () => flushPromises()); -const gateOpen = () => screen.queryByTestId("breadcrumb") !== null; +const isPageRendered = () => screen.queryByTestId("breadcrumb") !== null; // Mirrors the sibling /new and /:id(\d+) routes in selection-plan-layout.js. const NewOrEditHarness = ({ history }) => ( @@ -90,7 +90,7 @@ const NewOrEditHarness = ({ history }) => ( ); -describe("SelectionPlanIdLayout selection-plan gate", () => { +describe("SelectionPlanIdLayout load guard", () => { beforeEach(() => { getSelectionPlan.mockReset(); getMarketingSettingsBySelectionPlan.mockReset(); @@ -100,13 +100,13 @@ describe("SelectionPlanIdLayout selection-plan gate", () => { ); }); - it("stays closed on direct load until the matching plan finishes fetching", async () => { + 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(gateOpen()).toBe(false); + expect(isPageRendered()).toBe(false); }); - it("closes when switching to a different plan id until the store catches up", async () => { + 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"] }); @@ -125,20 +125,20 @@ describe("SelectionPlanIdLayout selection-plan gate", () => { } ); await settle(); - expect(gateOpen()).toBe(true); + expect(isPageRendered()).toBe(true); act(() => { history.push("/app/summits/1/selection-plans/8"); }); - expect(gateOpen()).toBe(false); + expect(isPageRendered()).toBe(false); expect(getSelectionPlan).toHaveBeenCalledWith("8"); - // Fetch settles, but the store's entity.id is still "5" — must stay closed. + // Fetch settles, but the store's entity.id is still "5" — must stay unrendered. await settle(); - expect(gateOpen()).toBe(false); + expect(isPageRendered()).toBe(false); }); - it("closes when navigating from an existing plan to /new until the store reflects the reset", async () => { + 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"] }); @@ -149,16 +149,16 @@ describe("SelectionPlanIdLayout selection-plan gate", () => { } }); await settle(); - expect(gateOpen()).toBe(true); + expect(isPageRendered()).toBe(true); - // Store still holds plan 5's entity (reset hasn't landed) — gate must close. + // Store still holds plan 5's entity (reset hasn't landed) — must not render. act(() => { history.push("/app/summits/1/selection-plans/new"); }); - expect(gateOpen()).toBe(false); + expect(isPageRendered()).toBe(false); }); - it("opens on /new once the store reflects the reset (default) entity", async () => { + it("renders on /new once the store reflects the reset (default) entity", async () => { const history = createMemoryHistory({ initialEntries: ["/app/summits/1/selection-plans/new"] }); @@ -169,6 +169,15 @@ describe("SelectionPlanIdLayout selection-plan gate", () => { } }); await settle(); - expect(gateOpen()).toBe(true); + expect(isPageRendered()).toBe(true); + }); + + it("does not render or throw when the fetch rejects", async () => { + getSelectionPlan.mockImplementation( + () => () => Promise.reject(new Error("fail")) + ); + renderAt("/app/summits/1/selection-plans/5", { id: 0 }); + await expect(settle()).resolves.not.toThrow(); + expect(isPageRendered()).toBe(false); }); }); diff --git a/src/layouts/selection-plan-id-layout.js b/src/layouts/selection-plan-id-layout.js index 566084976..b0d73b510 100644 --- a/src/layouts/selection-plan-id-layout.js +++ b/src/layouts/selection-plan-id-layout.js @@ -50,7 +50,8 @@ const SelectionPlanIdLayout = ({ MAX_PER_PAGE ) ) - .then(() => setHasLoaded(true)); + .then(() => setHasLoaded(true)) + .catch(() => {}); } }, [selectionPlanId]); From c3ea40df7b697a32b7a37642f9fc898b8b273c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 17 Sep 2026 00:13:12 -0300 Subject: [PATCH 06/12] fix: add unit tests, clean layout component, adjust redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../selection-plan-id-layout.test.js | 15 +- src/layouts/selection-plan-id-layout.js | 13 +- src/layouts/selection-plan-layout.js | 14 +- .../edit-selection-plan-page.test.js | 200 ++++++++++++++++++ 4 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 src/pages/selection-plans/__tests__/edit-selection-plan-page.test.js diff --git a/src/layouts/__tests__/selection-plan-id-layout.test.js b/src/layouts/__tests__/selection-plan-id-layout.test.js index ec1186061..206f83d47 100644 --- a/src/layouts/__tests__/selection-plan-id-layout.test.js +++ b/src/layouts/__tests__/selection-plan-id-layout.test.js @@ -12,7 +12,7 @@ * */ import React from "react"; -import { screen, act } from "@testing-library/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"; @@ -52,7 +52,7 @@ jest.mock("../../pages/selection-plans/edit-selection-plan-page", () => ({ const renderAt = (path, currentSelectionPlan) => { const history = createMemoryHistory({ initialEntries: [path] }); - return renderWithRedux( + const result = renderWithRedux( { } } ); + return { ...result, history }; }; const settle = () => act(async () => flushPromises()); @@ -172,12 +173,16 @@ describe("SelectionPlanIdLayout load guard", () => { expect(isPageRendered()).toBe(true); }); - it("does not render or throw when the fetch rejects", async () => { + it("redirects to the selection plans list when the fetch rejects", async () => { getSelectionPlan.mockImplementation( () => () => Promise.reject(new Error("fail")) ); - renderAt("/app/summits/1/selection-plans/5", { id: 0 }); - await expect(settle()).resolves.not.toThrow(); + 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 b0d73b510..583c7c800 100644 --- a/src/layouts/selection-plan-id-layout.js +++ b/src/layouts/selection-plan-id-layout.js @@ -4,6 +4,7 @@ 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 @@ -30,6 +31,7 @@ const SelectionPlanIdLayout = ({ getMarketingSettingsBySelectionPlan }) => { const [hasLoaded, setHasLoaded] = useState(false); + const [hasError, setHasError] = useState(false); const selectionPlanId = match.params.selection_plan_id; const breadcrumb = selectionPlanId ? currentSelectionPlan.name @@ -37,6 +39,7 @@ const SelectionPlanIdLayout = ({ useEffect(() => { setHasLoaded(false); + setHasError(false); if (!selectionPlanId) { resetSelectionPlanForm(); setHasLoaded(true); @@ -51,10 +54,14 @@ const SelectionPlanIdLayout = ({ ) ) .then(() => setHasLoaded(true)) - .catch(() => {}); + .catch(() => setHasError(true)); } }, [selectionPlanId]); + if (hasError) { + return ; + } + if (!hasLoaded || currentSelectionPlan.id !== Number(selectionPlanId || 0)) { return null; } @@ -78,9 +85,7 @@ const SelectionPlanIdLayout = ({ path={`${match.url}/rating-types`} component={SelectionPlanRatingTypesLayout} /> - +
diff --git a/src/layouts/selection-plan-layout.js b/src/layouts/selection-plan-layout.js index 57d103b6d..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 }) => (
( path={`${match.url}/:selection_plan_id(\\d+)`} component={SelectionPlanIdLayout} /> - +
); -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 }) => ( +
+ +
+ ) +})); + +const mockHistory = { push: jest.fn() }; + +const stateFor = (entity) => ({ + currentSummitState: { currentSummit: { id: 1 } }, + currentSelectionPlanState: { + entity, + allowedMembers: { data: [], currentPage: 1, lastPage: 1 }, + errors: {} + } +}); + +describe("EditSelectionPlanPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("saves and redirects to the new plan on create", async () => { + saveSelectionPlan.mockReturnValue(() => Promise.resolve({ id: 42 })); + saveSelectionPlanSettings.mockReturnValue(() => Promise.resolve()); + + renderWithRedux(, { + initialState: stateFor({ id: 0 }) + }); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: "save" })); + await flushPromises(); + }); + + expect(saveSelectionPlan).toHaveBeenCalledWith({ + id: 0, + marketing_settings: {} + }); + expect(saveSelectionPlanSettings).toHaveBeenCalledWith({}, 42); + expect(mockHistory.push).toHaveBeenCalledWith( + "/app/summits/1/selection-plans/42" + ); + }); + + it("saves an existing plan without redirecting", async () => { + saveSelectionPlan.mockReturnValue(() => Promise.resolve({ id: 5 })); + saveSelectionPlanSettings.mockReturnValue(() => Promise.resolve()); + + renderWithRedux(, { + initialState: stateFor({ id: 5 }) + }); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: "save" })); + await flushPromises(); + }); + + expect(saveSelectionPlan).toHaveBeenCalledWith({ + id: 5, + marketing_settings: {} + }); + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + + it("still redirects and re-enables the save button when saving marketing settings fails", async () => { + saveSelectionPlan.mockReturnValue(() => Promise.resolve({ id: 42 })); + saveSelectionPlanSettings.mockReturnValue(() => + Promise.reject(new Error("settings failed")) + ); + + renderWithRedux(, { + initialState: stateFor({ id: 0 }) + }); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: "save" })); + await flushPromises(); + await flushPromises(); + }); + + expect(mockHistory.push).toHaveBeenCalledWith( + "/app/summits/1/selection-plans/42" + ); + expect( + screen.getByRole("button", { name: "general.save" }) + ).not.toBeDisabled(); + }); + + it("does not save twice while a save is already in flight", async () => { + let resolveSave; + saveSelectionPlan.mockReturnValue( + () => + new Promise((resolve) => { + resolveSave = resolve; + }) + ); + + renderWithRedux(, { + initialState: stateFor({ id: 5 }) + }); + + const saveButton = screen.getByRole("button", { name: "save" }); + await userEvent.click(saveButton); + await userEvent.click(saveButton); + + expect(saveSelectionPlan).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSave({ id: 5 }); + await flushPromises(); + }); + }); + + it("does not show the Add New action for an unsaved plan", () => { + renderWithRedux(, { + initialState: stateFor({ id: 0 }) + }); + + expect( + screen.queryByRole("button", { name: "general.add_new" }) + ).not.toBeInTheDocument(); + }); + + it("navigates to /new when Add New is clicked on an existing plan", async () => { + renderWithRedux(, { + initialState: stateFor({ id: 5 }) + }); + + await userEvent.click( + screen.getByRole("button", { name: "general.add_new" }) + ); + + expect(mockHistory.push).toHaveBeenCalledWith( + "/app/summits/1/selection-plans/new" + ); + }); +}); From 8a34514d62488f7dd7305e19425ecf81754b0f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 18 Sep 2026 14:32:06 -0300 Subject: [PATCH 07/12] fix: connect reducer with form, add sequenced to actions, adjust and clean tests, replace swal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/selection-plan-actions.test.js | 73 ++++- src/actions/selection-plan-actions.js | 47 ++- src/components/buttons/add-new-button-mui.js | 26 ++ .../__tests__/selection-plan-form.test.js | 218 ++++++++------ src/components/forms/selection-plan-form.js | 272 +++++++++++++----- .../selection-plan-id-layout.test.js | 188 ------------ .../edit-selection-plan-page.test.js | 125 ++++---- .../selection-plan-list-page.test.js | 13 - .../edit-selection-plan-page.js | 222 +------------- .../selection_plans/selection-plan-reducer.js | 2 + 10 files changed, 547 insertions(+), 639 deletions(-) create mode 100644 src/components/buttons/add-new-button-mui.js delete mode 100644 src/layouts/__tests__/selection-plan-id-layout.test.js diff --git a/src/actions/__tests__/selection-plan-actions.test.js b/src/actions/__tests__/selection-plan-actions.test.js index 8253ac84b..f336d9b91 100644 --- a/src/actions/__tests__/selection-plan-actions.test.js +++ b/src/actions/__tests__/selection-plan-actions.test.js @@ -6,16 +6,18 @@ 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 } 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", () => ({ @@ -137,3 +139,68 @@ 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]); + }); +}); diff --git a/src/actions/selection-plan-actions.js b/src/actions/selection-plan-actions.js index 0dfb0687f..83334c58f 100644 --- a/src/actions/selection-plan-actions.js +++ b/src/actions/selection-plan-actions.js @@ -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(); + 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, @@ -134,13 +160,18 @@ 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) => { diff --git a/src/components/buttons/add-new-button-mui.js b/src/components/buttons/add-new-button-mui.js new file mode 100644 index 000000000..1d93db84c --- /dev/null +++ b/src/components/buttons/add-new-button-mui.js @@ -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 ( + + ); +} + +export default withRouter(AddNewButtonMui); diff --git a/src/components/forms/__tests__/selection-plan-form.test.js b/src/components/forms/__tests__/selection-plan-form.test.js index 35bce9f58..7057dba7e 100644 --- a/src/components/forms/__tests__/selection-plan-form.test.js +++ b/src/components/forms/__tests__/selection-plan-form.test.js @@ -1,8 +1,17 @@ import React from "react"; -import { render, screen, within, waitFor } from "@testing-library/react"; +import { screen, within, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; +import { renderWithRedux } from "../../../utils/test-utils"; import SelectionPlanForm from "../selection-plan-form"; +import { + addAllowedMemberToSelectionPlan, + deleteEventTypeSelectionPlan, + importAllowedMembersCSV, + removeAllowedMemberFromSelectionPlan, + removeTrackGroupFromSelectionPlan, + unassignProgressFlagFromSelectionPlan +} from "../../../actions/selection-plan-actions"; jest.mock("i18n-react/dist/i18n-react", () => ({ __esModule: true, @@ -14,6 +23,14 @@ jest.mock( () => ({ __esModule: true, default: () => null }) ); +jest.mock( + "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog", + () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve(true)) + }) +); + jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ __esModule: true, default: ({ data, onDelete }) => ( @@ -91,9 +108,32 @@ jest.mock("../../inputs/many-2-many-dropdown", () => ({ default: () => null })); -jest.mock("../../../actions/selection-plan-actions", () => ({ - querySelectionPlanExtraQuestions: jest.fn() -})); +// Action creators are dispatched through connect's bindActionCreators, and +// redux-mock-store requires every dispatched action to be a plain object - +// each mock must return one instead of the default undefined. +jest.mock("../../../actions/selection-plan-actions", () => { + const mockAction = () => jest.fn(() => ({ type: "MOCK_ACTION" })); + return { + __esModule: true, + querySelectionPlanExtraQuestions: jest.fn(), + addAllowedMemberToSelectionPlan: mockAction(), + addEventTypeSelectionPlan: mockAction(), + addTrackGroupToSelectionPlan: mockAction(), + assignExtraQuestion2SelectionPlan: mockAction(), + assignProgressFlag2SelectionPlan: mockAction(), + deleteEventTypeSelectionPlan: mockAction(), + deleteRatingType: mockAction(), + deleteSelectionPlanExtraQuestion: mockAction(), + getAllowedMembers: mockAction(), + importAllowedMembersCSV: mockAction(), + removeAllowedMemberFromSelectionPlan: mockAction(), + removeTrackGroupFromSelectionPlan: mockAction(), + unassignProgressFlagFromSelectionPlan: mockAction(), + updateProgressFlagOrder: mockAction(), + updateRatingTypeOrder: mockAction(), + updateSelectionPlanExtraQuestionOrder: mockAction() + }; +}); jest.mock("../../../actions/track-chair-actions", () => ({ querySummitProgressFlags: jest.fn() @@ -130,7 +170,11 @@ const newEntity = { track_groups: [], event_types: [], extra_questions: [], + extraQuestionsOrder: "order", + extraQuestionsOrderDir: 1, allowed_presentation_action_types: [], + actionTypesOrder: "order", + actionTypesOrderDir: 1, allowed_presentation_questions: [], allowed_presentation_editable_questions: [], marketing_settings: {} @@ -139,38 +183,24 @@ const newEntity = { // Existing plan entity (tabs shown) const existingEntity = { ...newEntity, id: 42, name: "Spring CFP" }; +const mockHistory = { push: jest.fn() }; + const baseProps = { - entity: newEntity, - errors: {}, - currentSummit: { id: 1, time_zone_id: "UTC", slug: "test-summit" }, - extraQuestionsOrder: "id", - extraQuestionsOrderDir: 1, - actionTypesOrder: "id", - actionTypesOrderDir: 1, - allowedMembers: { data: [], currentPage: 1, lastPage: 1 }, - onSave: jest.fn(() => Promise.resolve()), - onTrackGroupLink: jest.fn(), - onTrackGroupUnLink: jest.fn(), - onAddEventType: jest.fn(), - onDeleteEventType: jest.fn(), - onAddRatingType: jest.fn(), - onEditRatingType: jest.fn(), - onDeleteRatingType: jest.fn(), - onEditExtraQuestion: jest.fn(), - onDeleteExtraQuestion: jest.fn(), - onAddNewExtraQuestion: jest.fn(), - onAssignExtraQuestion2SelectionPlan: jest.fn(), - onAssignProgressFlag2SelectionPlan: jest.fn(), - onUnassignProgressFlag: jest.fn(), - onUpdateProgressFlagOrder: jest.fn(), - onUpdateRatingTypeOrder: jest.fn(), - updateExtraQuestionOrder: jest.fn(), - onImportAllowedMembers: jest.fn(), - onAllowedMemberAdd: jest.fn(), - onAllowedMemberDelete: jest.fn(), - onAllowedMembersPageChange: jest.fn() + history: mockHistory, + onSave: jest.fn(() => Promise.resolve()) }; +const stateFor = ( + entity, + allowedMembers = { data: [], currentPage: 1, lastPage: 1 }, + errors = {} +) => ({ + currentSummitState: { + currentSummit: { id: 1, time_zone_id: "UTC", slug: "test-summit" } + }, + currentSelectionPlanState: { entity, allowedMembers, errors } +}); + // Mirrors the popup - external submit button via form attribute const FormWithButton = (props) => ( <> @@ -182,11 +212,17 @@ const FormWithButton = (props) => ( ); -const renderForm = (overrides = {}) => { - const merged = { ...baseProps, ...overrides }; - // eslint-disable-next-line react/jsx-props-no-spreading - return render(); -}; +const renderForm = ({ + entity = newEntity, + allowedMembers, + errors, + ...props +} = {}) => + renderWithRedux( + // eslint-disable-next-line react/jsx-props-no-spreading + , + { initialState: stateFor(entity, allowedMembers, errors) } + ); const renderExistingForm = (overrides = {}) => renderForm({ entity: existingEntity, ...overrides }); @@ -312,18 +348,19 @@ describe("SelectionPlanForm - track_groups tab", () => { expect(within(panel).getByText("Group A")).toBeInTheDocument(); }); - it("calls onTrackGroupUnLink when delete is clicked", async () => { - const onTrackGroupUnLink = jest.fn(); + it("calls removeTrackGroupFromSelectionPlan when delete is clicked", async () => { renderExistingForm({ entity: { ...existingEntity, track_groups: [{ id: 7, name: "G", description: "" }] - }, - onTrackGroupUnLink + } }); await clickTab("edit_selection_plan.track_groups"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onTrackGroupUnLink).toHaveBeenCalledWith(existingEntity.id, 7); + expect(removeTrackGroupFromSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 7 + ); }); }); @@ -357,15 +394,16 @@ describe("SelectionPlanForm - event_types tab", () => { expect(within(panel).getByText("Presentation")).toBeInTheDocument(); }); - it("calls onDeleteEventType when delete is clicked", async () => { - const onDeleteEventType = jest.fn(); + it("calls deleteEventTypeSelectionPlan when delete is clicked", async () => { renderExistingForm({ - entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] }, - onDeleteEventType + entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] } }); await clickTab("edit_selection_plan.event_types"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onDeleteEventType).toHaveBeenCalledWith(existingEntity.id, 5); + expect(deleteEventTypeSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 5 + ); }); }); @@ -387,30 +425,31 @@ describe("SelectionPlanForm - extra_questions tab", () => { ).toBeInTheDocument(); }); - it("calls onAddNewExtraQuestion when Add button is clicked", async () => { - const onAddNewExtraQuestion = jest.fn(); - renderExistingForm({ onAddNewExtraQuestion }); + it("navigates to the new extra question route when Add button is clicked", async () => { + renderExistingForm(); await clickTab("edit_selection_plan.extra_questions"); await userEvent.click( screen.getByRole("button", { name: "edit_selection_plan.add_extra_questions" }) ); - expect(onAddNewExtraQuestion).toHaveBeenCalledTimes(1); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/extra-questions/new` + ); }); - it("renders extra questions and calls onEditExtraQuestion on edit", async () => { - const onEditExtraQuestion = jest.fn(); + it("renders extra questions and navigates to edit route on edit", async () => { renderExistingForm({ entity: { ...existingEntity, extra_questions: [{ id: 10, name: "q1", label: "Q One", type: "text" }] - }, - onEditExtraQuestion + } }); await clickTab("edit_selection_plan.extra_questions"); await userEvent.click(screen.getByRole("button", { name: "edit" })); - expect(onEditExtraQuestion).toHaveBeenCalledWith(10); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/extra-questions/10` + ); }); }); @@ -458,30 +497,31 @@ describe("SelectionPlanForm - track_chair_settings tab", () => { ).toBeInTheDocument(); }); - it("calls onAddRatingType when Add Rating Type is clicked", async () => { - const onAddRatingType = jest.fn(); - renderExistingForm({ onAddRatingType }); + it("navigates to the new rating type route when Add Rating Type is clicked", async () => { + renderExistingForm(); await clickTab("track_chair_settings.title"); await userEvent.click( screen.getByRole("button", { name: "track_chair_settings.add_rating_type" }) ); - expect(onAddRatingType).toHaveBeenCalledTimes(1); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/rating-types/new` + ); }); - it("renders rating types and calls onEditRatingType on edit", async () => { - const onEditRatingType = jest.fn(); + it("renders rating types and navigates to edit route on edit", async () => { renderExistingForm({ entity: { ...existingEntity, track_chair_rating_types: [{ id: 20, name: "Excellent", weight: 10 }] - }, - onEditRatingType + } }); await clickTab("track_chair_settings.title"); await userEvent.click(screen.getByRole("button", { name: "edit" })); - expect(onEditRatingType).toHaveBeenCalledWith(20); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/rating-types/20` + ); }); }); @@ -505,18 +545,21 @@ describe("SelectionPlanForm - presentation_action_types tab", () => { ).toBeInTheDocument(); }); - it("renders action types and calls onUnassignProgressFlag on delete", async () => { - const onUnassignProgressFlag = jest.fn(); + it("renders action types and calls unassignProgressFlagFromSelectionPlan on delete", async () => { renderExistingForm({ entity: { ...existingEntity, allowed_presentation_action_types: [{ id: 30, label: "Approve" }] - }, - onUnassignProgressFlag + } }); await clickTab("edit_selection_plan.presentation_action_types"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onUnassignProgressFlag).toHaveBeenCalledWith(30); + await waitFor(() => + expect(unassignProgressFlagFromSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 30 + ) + ); }); }); @@ -525,8 +568,9 @@ describe("SelectionPlanForm - presentation_action_types tab", () => { // --------------------------------------------------------------------------- describe("SelectionPlanForm - allowed_members tab", () => { - const membersProps = { - entity: { ...existingEntity, is_hidden: false }, + const membersEntity = { ...existingEntity, is_hidden: false }; + const membersOverrides = { + entity: membersEntity, allowedMembers: { data: [{ id: 1, email: "user@example.com" }], currentPage: 1, @@ -534,17 +578,18 @@ describe("SelectionPlanForm - allowed_members tab", () => { } }; - it("renders members and calls onAllowedMemberDelete on delete", async () => { - const onAllowedMemberDelete = jest.fn(); - renderExistingForm({ ...membersProps, onAllowedMemberDelete }); + it("renders members and calls removeAllowedMemberFromSelectionPlan on delete", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onAllowedMemberDelete).toHaveBeenCalledWith(existingEntity.id, 1); + expect(removeAllowedMemberFromSelectionPlan).toHaveBeenCalledWith( + membersEntity.id, + 1 + ); }); - it("calls onAllowedMemberAdd when Add is clicked with an email", async () => { - const onAllowedMemberAdd = jest.fn(); - renderExistingForm({ ...membersProps, onAllowedMemberAdd }); + it("calls addAllowedMemberToSelectionPlan when Add is clicked with an email", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); const panel = document.getElementById("tabpanel-allowed_members"); const emailInput = within(panel).getByRole("textbox"); @@ -552,23 +597,22 @@ describe("SelectionPlanForm - allowed_members tab", () => { await userEvent.click( within(panel).getByRole("button", { name: "general.add" }) ); - expect(onAllowedMemberAdd).toHaveBeenCalledWith( - existingEntity.id, + expect(addAllowedMemberToSelectionPlan).toHaveBeenCalledWith( + membersEntity.id, "new@test.com" ); }); - it("calls onImportAllowedMembers when import modal is confirmed", async () => { - const onImportAllowedMembers = jest.fn(); - renderExistingForm({ ...membersProps, onImportAllowedMembers }); + it("calls importAllowedMembersCSV when import modal is confirmed", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); const panel = document.getElementById("tabpanel-allowed_members"); await userEvent.click( within(panel).getByRole("button", { name: "edit_selection_plan.import" }) ); await userEvent.click(screen.getByRole("button", { name: "ingest" })); - expect(onImportAllowedMembers).toHaveBeenCalledWith( - existingEntity.id, + expect(importAllowedMembersCSV).toHaveBeenCalledWith( + membersEntity.id, expect.any(File) ); }); diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js index 416062d9e..7ad0bd502 100644 --- a/src/components/forms/selection-plan-form.js +++ b/src/components/forms/selection-plan-form.js @@ -12,11 +12,13 @@ * */ import React, { useState, useEffect } from "react"; +import { connect } from "react-redux"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; import { useFormik, FormikProvider } from "formik"; import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; +import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog"; import Box from "@mui/material/Box"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; @@ -30,6 +32,24 @@ import TrackChairSettingsTab from "./selection-plan-form/track-chair-settings-ta import PresentationActionTypesTab from "./selection-plan-form/presentation-action-types-tab"; import AllowedMembersTab from "./selection-plan-form/allowed-members-tab"; import CfpSettingsTab from "./selection-plan-form/cfp-settings-tab"; +import { + addAllowedMemberToSelectionPlan, + addEventTypeSelectionPlan, + addTrackGroupToSelectionPlan, + assignExtraQuestion2SelectionPlan, + assignProgressFlag2SelectionPlan, + deleteEventTypeSelectionPlan, + deleteRatingType, + deleteSelectionPlanExtraQuestion, + getAllowedMembers, + importAllowedMembersCSV, + removeAllowedMemberFromSelectionPlan, + removeTrackGroupFromSelectionPlan, + unassignProgressFlagFromSelectionPlan, + updateProgressFlagOrder, + updateRatingTypeOrder, + updateSelectionPlanExtraQuestionOrder +} from "../../actions/selection-plan-actions"; const DATE_FIELDS = [ "submission_begin_date", @@ -64,32 +84,25 @@ const SelectionPlanForm = (props) => { entity: propsEntity, errors: propsErrors, currentSummit, - extraQuestionsOrderDir, - extraQuestionsOrder, - actionTypesOrderDir, - actionTypesOrder, allowedMembers, + history, onSave, - onTrackGroupLink, - onTrackGroupUnLink, - onAddEventType, - onDeleteEventType, - onAddRatingType, - onEditRatingType, - onDeleteRatingType, - onEditExtraQuestion, - onDeleteExtraQuestion, - onAddNewExtraQuestion, - onAssignExtraQuestion2SelectionPlan, - onAssignProgressFlag2SelectionPlan, - onUnassignProgressFlag, - onUpdateProgressFlagOrder, - onUpdateRatingTypeOrder, - updateExtraQuestionOrder, - onImportAllowedMembers, - onAllowedMemberAdd, - onAllowedMemberDelete, - onAllowedMembersPageChange + addTrackGroupToSelectionPlan, + removeTrackGroupFromSelectionPlan, + addEventTypeSelectionPlan, + deleteEventTypeSelectionPlan, + deleteSelectionPlanExtraQuestion, + updateSelectionPlanExtraQuestionOrder, + assignExtraQuestion2SelectionPlan, + deleteRatingType, + updateRatingTypeOrder, + assignProgressFlag2SelectionPlan, + unassignProgressFlagFromSelectionPlan, + updateProgressFlagOrder, + addAllowedMemberToSelectionPlan, + removeAllowedMemberFromSelectionPlan, + getAllowedMembers, + importAllowedMembersCSV } = props; const [activeTab, setActiveTab] = useState("main"); @@ -143,6 +156,113 @@ const SelectionPlanForm = (props) => { } }, [formik.values.is_hidden]); + const onUpdateExtraQuestionOrder = (questions, questionId, newOrder) => { + updateSelectionPlanExtraQuestionOrder( + propsEntity.id, + questions, + questionId, + newOrder + ); + }; + + const onEditExtraQuestion = (questionId) => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/extra-questions/${questionId}` + ); + }; + + const onAddNewExtraQuestion = () => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/extra-questions/new` + ); + }; + + const onDeleteExtraQuestion = async (questionId) => { + const extraQuestion = propsEntity.extra_questions.find( + (t) => t.id === questionId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "edit_selection_plan.extra_question_remove_warning" + )} ${extraQuestion.name}`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + deleteSelectionPlanExtraQuestion(propsEntity.id, questionId); + } + }; + + const onAddRatingType = () => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/rating-types/new` + ); + }; + + const onEditRatingType = (ratingTypeId) => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/rating-types/${ratingTypeId}` + ); + }; + + const onUpdateRatingTypeOrder = (ratingTypes, ratingTypeId, newOrder) => { + updateRatingTypeOrder(propsEntity.id, ratingTypes, ratingTypeId, newOrder); + }; + + const onDeleteRatingType = async (ratingTypeId) => { + const ratingType = propsEntity.track_chair_rating_types.find( + (t) => t.id === ratingTypeId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate("edit_selection_plan.rating_type_remove_warning")} ${ + ratingType.name + }`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + deleteRatingType(propsEntity.id, ratingTypeId); + } + }; + + const onUpdateProgressFlagOrder = ( + progressFlags, + progressFlagId, + newOrder + ) => { + updateProgressFlagOrder( + propsEntity.id, + progressFlags, + progressFlagId, + newOrder + ); + }; + + const onUnassignProgressFlag = async (progressFlagId) => { + const ratingType = propsEntity.allowed_presentation_action_types.find( + (t) => t.id === progressFlagId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "edit_selection_plan.presentation_action_type_remove_warning" + )} ${ratingType.label}`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + unassignProgressFlagFromSelectionPlan(propsEntity.id, progressFlagId); + } + }; + const isNewPlan = formik.values.id === 0; const tabs = [ @@ -231,27 +351,27 @@ const SelectionPlanForm = (props) => {