diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js
index 5492628e7..2216019bf 100644
--- a/src/actions/sponsor-forms-actions.js
+++ b/src/actions/sponsor-forms-actions.js
@@ -54,6 +54,7 @@ export const RECEIVE_SPONSOR_MANAGED_FORMS = "RECEIVE_SPONSOR_MANAGED_FORMS";
export const REQUEST_SPONSOR_MANAGED_FORM = "REQUEST_SPONSOR_MANAGED_FORM";
export const RECEIVE_SPONSOR_MANAGED_FORM = "RECEIVE_SPONSOR_MANAGED_FORM";
export const SPONSOR_MANAGED_FORMS_ADDED = "SPONSOR_MANAGED_FORMS_ADDED";
+export const SPONSOR_MANAGED_FORM_DELETED = "SPONSOR_MANAGED_FORM_DELETED";
export const SPONSOR_MANAGED_FORMS_UPGRADED = "SPONSOR_MANAGED_FORMS_UPGRADED";
export const SPONSOR_MANAGED_FORMS_OVERRIDEN =
"SPONSOR_MANAGED_FORMS_OVERRIDEN";
@@ -564,7 +565,7 @@ export const getSponsorManagedForms =
const params = {
page,
fields:
- "id,code,name,is_archived,opens_at,expires_at,items_count,allowed_add_ons",
+ "id,code,name,is_archived,opens_at,expires_at,items_count,allowed_add_ons,assignment_type",
expands: "allowed_add_ons",
per_page: perPage,
access_token: accessToken
@@ -689,6 +690,38 @@ export const upgradeSponsorManagedForm =
});
};
+export const deleteSponsorManagedForm =
+ (formId) => async (dispatch, getState) => {
+ const { currentSummitState, currentSponsorState } = getState();
+ const { currentSummit } = currentSummitState;
+ const {
+ entity: { id: sponsorId }
+ } = currentSponsorState;
+ const accessToken = await getAccessTokenSafely();
+ const params = { access_token: accessToken };
+
+ dispatch(startLoading());
+
+ return deleteRequest(
+ null,
+ createAction(SPONSOR_MANAGED_FORM_DELETED)({ formId }),
+ `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/managed-forms/${formId}`,
+ null,
+ snackbarErrorHandler
+ )(params)(dispatch)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("sponsor_forms.form_delete_success")
+ })
+ );
+ })
+ .finally(() => {
+ dispatch(stopLoading());
+ });
+ };
+
const normalizeSponsorManagedForm = (entity) => {
const normalizedEntity = {
show_form_ids: entity.forms,
diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/__tests__/sponsor-forms-tab.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/__tests__/sponsor-forms-tab.test.js
new file mode 100644
index 000000000..3b35cf89b
--- /dev/null
+++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/__tests__/sponsor-forms-tab.test.js
@@ -0,0 +1,401 @@
+import React from "react";
+import { createStore, combineReducers, applyMiddleware } from "redux";
+import thunk from "redux-thunk";
+import userEvent from "@testing-library/user-event";
+import { act, screen, waitFor, within } from "@testing-library/react";
+import { GlobalConfirmDialog } from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog";
+import { deleteRequest } from "openstack-uicore-foundation/lib/utils/actions";
+import SponsorFormsTab from "../index";
+import { renderWithRedux } from "../../../../../../utils/test-utils";
+import * as methods from "../../../../../../utils/methods";
+import sponsorPageFormsListReducer, {
+ DEFAULT_STATE as sponsorFormsDefaultState
+} from "../../../../../../reducers/sponsors/sponsor-page-forms-list-reducer";
+import {
+ RECEIVE_SPONSOR_MANAGED_FORMS,
+ getSponsorManagedForms,
+ getSponsorCustomizedForms,
+ deleteSponsorCustomizedForm
+} from "../../../../../../actions/sponsor-forms-actions";
+
+// Mocks
+
+jest.mock(
+ "../components/add-sponsor-form-template-popup",
+ () =>
+ function MockAddSponsorFormTemplatePopup({ onClose, onSubmit }) {
+ return (
+
+
+
+
+ );
+ }
+);
+
+jest.mock(
+ "../components/customized-form/customized-form-popup",
+ () =>
+ function MockCustomizedFormPopup({ onClose }) {
+ return (
+
+
+
+ );
+ }
+);
+
+// deleteSponsorManagedForm is intentionally left un-mocked (falls through to
+// jest.requireActual below): the delete-confirm test exercises the real thunk
+// against a real store + reducer, with only the uicore HTTP layer mocked, so
+// the SPONSOR_MANAGED_FORM_DELETED reducer case actually runs.
+jest.mock("../../../../../../actions/sponsor-forms-actions", () => ({
+ ...jest.requireActual("../../../../../../actions/sponsor-forms-actions"),
+ getSponsorManagedForms: jest.fn(() => () => Promise.resolve()),
+ getSponsorCustomizedForms: jest.fn(() => () => Promise.resolve()),
+ saveSponsorManagedForm: jest.fn(() => () => Promise.resolve()),
+ overrideSponsorManagedForm: jest.fn(() => () => Promise.resolve()),
+ archiveSponsorCustomizedForm: jest.fn(() => () => Promise.resolve()),
+ unarchiveSponsorCustomizedForm: jest.fn(() => () => Promise.resolve()),
+ deleteSponsorCustomizedForm: jest.fn(() => () => Promise.resolve())
+}));
+
+jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
+ __esModule: true,
+ ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
+ deleteRequest: jest.fn()
+}));
+
+// Helpers
+
+const createSponsor = (overrides = {}) => ({
+ id: 1,
+ ...overrides
+});
+
+const createManagedForm = (id, overrides = {}) => ({
+ id,
+ code: `MANAGED-${id}`,
+ name: `Managed Form ${id}`,
+ items_count: 0,
+ allowed_add_ons: [],
+ assignment_type: "Explicit",
+ ...overrides
+});
+
+const createCustomizedForm = (id, overrides = {}) => ({
+ id,
+ code: `CODE-${id}`,
+ name: `Form ${id}`,
+ items_count: 0,
+ allowed_add_ons: [],
+ is_archived: false,
+ ...overrides
+});
+
+const defaultState = {
+ sponsorPageFormsListState: {
+ ...sponsorFormsDefaultState,
+ managedForms: {
+ ...sponsorFormsDefaultState.managedForms,
+ forms: [],
+ totalCount: 0
+ },
+ customizedForms: {
+ ...sponsorFormsDefaultState.customizedForms,
+ forms: [],
+ totalCount: 0
+ },
+ showArchived: false,
+ term: ""
+ },
+ currentSummitState: {
+ currentSummit: {
+ id: 1,
+ time_zone: { name: "UTC" }
+ }
+ },
+ currentSponsorState: {
+ entity: { id: 1 },
+ errors: {}
+ }
+};
+
+const renderWithConfirmDialog = (ui, options) =>
+ renderWithRedux(
+ <>
+
+ {ui}
+ >,
+ options
+ );
+
+// Passthrough reducer for state slices the delete flow doesn't mutate.
+const staticReducer =
+ (initialState) =>
+ (state = initialState) =>
+ state;
+
+// Real store + real reducer, seeded via the actual RECEIVE_SPONSOR_MANAGED_FORMS
+// action, so the SPONSOR_MANAGED_FORM_DELETED case genuinely runs and the table
+// re-renders from real state instead of a static mock.
+const createRealStore = (managedFormsData) => {
+ const store = createStore(
+ combineReducers({
+ sponsorPageFormsListState: sponsorPageFormsListReducer,
+ currentSummitState: staticReducer(defaultState.currentSummitState),
+ currentSponsorState: staticReducer(defaultState.currentSponsorState)
+ }),
+ applyMiddleware(thunk)
+ );
+
+ store.dispatch({
+ type: RECEIVE_SPONSOR_MANAGED_FORMS,
+ payload: {
+ response: {
+ current_page: 1,
+ last_page: 1,
+ total: managedFormsData.length,
+ data: managedFormsData
+ }
+ }
+ });
+
+ return store;
+};
+
+describe("SponsorFormsTab", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ // clearAllMocks()/restoreAllMocks() do not reset a plain jest.fn()'s
+ // mockImplementation, so a test that sets one on `deleteRequest` would
+ // otherwise leak it into whichever test runs next in this file.
+ deleteRequest.mockReset();
+ });
+
+ describe("managed forms delete", () => {
+ it("shows the delete action only for EXPLICIT managed forms", () => {
+ renderWithConfirmDialog(, {
+ initialState: {
+ ...defaultState,
+ sponsorPageFormsListState: {
+ ...defaultState.sponsorPageFormsListState,
+ managedForms: {
+ ...defaultState.sponsorPageFormsListState.managedForms,
+ forms: [
+ createManagedForm(1, { assignment_type: "Explicit" }),
+ createManagedForm(2, { assignment_type: "Implicit" })
+ ],
+ totalCount: 2
+ }
+ }
+ }
+ });
+
+ const explicitRow = screen.getByText("Managed Form 1").closest("tr");
+ const implicitRow = screen.getByText("Managed Form 2").closest("tr");
+
+ expect(within(explicitRow).getByTestId("DeleteIcon")).toBeInTheDocument();
+ expect(
+ within(implicitRow).queryByTestId("DeleteIcon")
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls the delete request with the correct form id and removes the row from the table when confirmed", async () => {
+ deleteRequest.mockImplementation(
+ (requestActionCreator, receiveActionCreator) => () => (dispatch) => {
+ dispatch(
+ typeof receiveActionCreator === "function"
+ ? receiveActionCreator({ response: {} })
+ : receiveActionCreator
+ );
+ return Promise.resolve({ response: {} });
+ }
+ );
+
+ const store = createRealStore([
+ createManagedForm(1, { assignment_type: "Explicit" }),
+ createManagedForm(2, { assignment_type: "Explicit" })
+ ]);
+
+ renderWithConfirmDialog(, {
+ store
+ });
+
+ expect(screen.getByText("Managed Form 1")).toBeInTheDocument();
+ expect(screen.getByText("Managed Form 2")).toBeInTheDocument();
+
+ const deleteButtons = screen.getAllByTestId("DeleteIcon");
+ const secondDeleteButton = deleteButtons[1].closest("button");
+ await act(async () => {
+ await userEvent.click(secondDeleteButton);
+ });
+
+ expect(
+ await screen.findByText("general.are_you_sure")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: /general\.yes_delete|confirm/i
+ })
+ );
+ });
+
+ // Verifies the real thunk was invoked with the id of the row that was
+ // actually clicked (form 2), all the way down to the HTTP boundary.
+ await waitFor(() => {
+ expect(deleteRequest).toHaveBeenCalledWith(
+ null,
+ expect.objectContaining({ payload: { formId: 2 } }),
+ expect.stringContaining("/managed-forms/2"),
+ null,
+ expect.any(Function)
+ );
+ });
+
+ // Verifies the real reducer removed the row: form 2 is gone, form 1 stays.
+ await waitFor(() => {
+ expect(screen.queryByText("Managed Form 2")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Managed Form 1")).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(getSponsorManagedForms).toHaveBeenCalledTimes(2); // mount + after delete
+ expect(getSponsorCustomizedForms).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("does not call the delete request and keeps the row when delete is cancelled", async () => {
+ renderWithConfirmDialog(, {
+ initialState: {
+ ...defaultState,
+ sponsorPageFormsListState: {
+ ...defaultState.sponsorPageFormsListState,
+ managedForms: {
+ ...defaultState.sponsorPageFormsListState.managedForms,
+ forms: [createManagedForm(1, { assignment_type: "Explicit" })],
+ totalCount: 1
+ }
+ }
+ }
+ });
+
+ const deleteButton = screen.getByTestId("DeleteIcon").closest("button");
+ await act(async () => {
+ await userEvent.click(deleteButton);
+ });
+
+ expect(
+ await screen.findByText("general.are_you_sure")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ await userEvent.click(
+ await screen.findByRole("button", { name: /cancel|general\.cancel/i })
+ );
+ });
+
+ expect(deleteRequest).not.toHaveBeenCalled();
+ expect(getSponsorManagedForms).toHaveBeenCalledTimes(1); // mount only
+ expect(screen.getByText("Managed Form 1")).toBeInTheDocument();
+ });
+
+ it("keeps the row and does not refresh the lists when the delete request fails", async () => {
+ deleteRequest.mockImplementation(
+ () => () => () => Promise.reject(new Error("delete failed"))
+ );
+
+ const store = createRealStore([
+ createManagedForm(1, { assignment_type: "Explicit" })
+ ]);
+
+ renderWithConfirmDialog(, {
+ store
+ });
+
+ const deleteButton = screen.getByTestId("DeleteIcon").closest("button");
+ await act(async () => {
+ await userEvent.click(deleteButton);
+ });
+
+ expect(
+ await screen.findByText("general.are_you_sure")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: /general\.yes_delete|confirm/i
+ })
+ );
+ });
+
+ // Lets the rejected promise chain (deleteSponsorManagedForm's own
+ // .finally() plus handleManagedDelete's .catch()) settle. If the
+ // .catch() were removed, this would surface as an unhandled rejection
+ // and fail the test.
+ await act(async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 50);
+ });
+ });
+
+ expect(screen.getByText("Managed Form 1")).toBeInTheDocument();
+ expect(getSponsorManagedForms).toHaveBeenCalledTimes(1); // mount only, no refresh on failure
+ expect(getSponsorCustomizedForms).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("customized forms delete", () => {
+ it("calls deleteSponsorCustomizedForm and refreshes both lists when delete is confirmed", async () => {
+ renderWithConfirmDialog(, {
+ initialState: {
+ ...defaultState,
+ sponsorPageFormsListState: {
+ ...defaultState.sponsorPageFormsListState,
+ customizedForms: {
+ ...defaultState.sponsorPageFormsListState.customizedForms,
+ forms: [createCustomizedForm(1)],
+ totalCount: 1
+ }
+ }
+ }
+ });
+
+ const deleteButton = screen.getByTestId("DeleteIcon").closest("button");
+ await act(async () => {
+ await userEvent.click(deleteButton);
+ });
+
+ expect(
+ await screen.findByText("general.are_you_sure")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: /general\.yes_delete|confirm/i
+ })
+ );
+ });
+
+ await waitFor(() => {
+ expect(deleteSponsorCustomizedForm).toHaveBeenCalledWith(1);
+ });
+
+ await waitFor(() => {
+ expect(getSponsorCustomizedForms).toHaveBeenCalledTimes(2); // mount + after delete
+ expect(getSponsorManagedForms).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+});
diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/index.js
index 5e499a935..ee31d1565 100644
--- a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/index.js
+++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/index.js
@@ -34,12 +34,16 @@ import {
getSponsorManagedForms,
saveSponsorManagedForm,
overrideSponsorManagedForm,
- unarchiveSponsorCustomizedForm
+ unarchiveSponsorCustomizedForm,
+ deleteSponsorManagedForm
} from "../../../../../actions/sponsor-forms-actions";
import CustomAlert from "../../../../../components/mui/custom-alert";
import AddSponsorFormTemplatePopup from "./components/add-sponsor-form-template-popup";
import CustomizedFormPopup from "./components/customized-form/customized-form-popup";
-import { DEFAULT_CURRENT_PAGE } from "../../../../../utils/constants";
+import {
+ DEFAULT_CURRENT_PAGE,
+ MANAGED_FORM_ASSIGNMENT_TYPE
+} from "../../../../../utils/constants";
import showConfirmDialog from "../../../../../components/mui/showConfirmDialog";
const SponsorFormsTab = ({
@@ -56,7 +60,8 @@ const SponsorFormsTab = ({
overrideSponsorManagedForm,
archiveSponsorCustomizedForm,
unarchiveSponsorCustomizedForm,
- deleteSponsorCustomizedForm
+ deleteSponsorCustomizedForm,
+ deleteSponsorManagedForm
}) => {
const [openPopup, setOpenPopup] = useState(null);
const [customFormEdit, setCustomFormEdit] = useState(null);
@@ -175,6 +180,29 @@ const SponsorFormsTab = ({
setCustomFormEdit(item);
};
+ const handleManagedDelete = (itemId) => {
+ deleteSponsorManagedForm(itemId)
+ .then(() => {
+ getSponsorCustomizedForms(
+ term,
+ DEFAULT_CURRENT_PAGE,
+ customizedForms.perPage,
+ customizedForms.order,
+ customizedForms.orderDir,
+ showArchived
+ );
+ getSponsorManagedForms(
+ term,
+ DEFAULT_CURRENT_PAGE,
+ managedForms.perPage,
+ managedForms.order,
+ managedForms.orderDir,
+ showArchived
+ );
+ })
+ .catch(() => {});
+ };
+
const handleCustomizedDelete = (itemId) => {
deleteSponsorCustomizedForm(itemId).then(() => {
getSponsorCustomizedForms(
@@ -506,6 +534,10 @@ const SponsorFormsTab = ({
onPageChange={handleManagedPageChange}
onPerPageChange={handleManagedPerPageChange}
onSort={handleManagedSort}
+ onDelete={handleManagedDelete}
+ canDelete={(row) =>
+ row.assignment_type === MANAGED_FORM_ASSIGNMENT_TYPE.EXPLICIT
+ }
/>
@@ -549,5 +581,6 @@ export default connect(mapStateToProps, {
getSponsorCustomizedForms,
archiveSponsorCustomizedForm,
unarchiveSponsorCustomizedForm,
- deleteSponsorCustomizedForm
+ deleteSponsorCustomizedForm,
+ deleteSponsorManagedForm
})(SponsorFormsTab);
diff --git a/src/reducers/sponsors/sponsor-page-forms-list-reducer.js b/src/reducers/sponsors/sponsor-page-forms-list-reducer.js
index da056b21a..c7c4277d4 100644
--- a/src/reducers/sponsors/sponsor-page-forms-list-reducer.js
+++ b/src/reducers/sponsors/sponsor-page-forms-list-reducer.js
@@ -21,7 +21,8 @@ import {
SPONSOR_CUSTOMIZED_FORM_ADDED,
SPONSOR_CUSTOMIZED_FORM_DELETED,
SPONSOR_CUSTOMIZED_FORM_ARCHIVED_CHANGED,
- SPONSOR_CUSTOMIZED_FORM_UPDATED
+ SPONSOR_CUSTOMIZED_FORM_UPDATED,
+ SPONSOR_MANAGED_FORM_DELETED
} from "../../actions/sponsor-forms-actions";
import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
import { getSafePageAfterRemove } from "../../utils/methods";
@@ -122,6 +123,7 @@ const sponsorPageFormsListReducer = (state = DEFAULT_STATE, action) => {
items_count: a.items_count,
allowed_add_ons: a.allowed_add_ons,
is_archived: a.is_archived,
+ assignment_type: a.assignment_type,
opens_at: opensAt,
expires_at: expiresAt
};
@@ -277,6 +279,21 @@ const sponsorPageFormsListReducer = (state = DEFAULT_STATE, action) => {
}
};
}
+ case SPONSOR_MANAGED_FORM_DELETED: {
+ const { formId } = payload;
+ const forms = state.managedForms.forms.filter(
+ (form) => form.id !== formId
+ );
+
+ return {
+ ...state,
+ managedForms: {
+ ...state.managedForms,
+ forms,
+ totalCount: state.managedForms.totalCount - 1
+ }
+ };
+ }
default:
return state;
}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 846f53c8e..b720afcb8 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -332,6 +332,11 @@ export const SPONSOR_MANAGED_PAGE_ASSIGNMENT = {
IMPLICIT: "Implicit"
};
+export const MANAGED_FORM_ASSIGNMENT_TYPE = {
+ EXPLICIT: "Explicit",
+ IMPLICIT: "Implicit"
+};
+
export const ACCESS_ROUTES = {
ADMIN_SPONSORS: "admin-sponsors",
SPONSORS: "sponsors"