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"