From 5be9012e715120c7e8db6d0df4aa1c0c847c2d28 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 9 Oct 2026 17:02:43 +0200 Subject: [PATCH 1/5] feat(sponsors): force delete of sponsor extra questions for admins Sponsors can't delete extra questions, because a device may hold answers the server has not seen yet. An admin who has checked the devices can confirm a delete is safe, so summit-api now lets admins force it, with a reason, an explicit confirmation when collected answers are destroyed, and an audit entry. The extra questions list shows admins only a "Force delete" action per question. It opens a confirmation dialog with what is at stake (collected answers on the server and the sponsor's scan activity per rep), a required checklist (devices checked, 0 pending uploads, third party devices confirmed), a required reason, and a second confirmation in red when collected answers will be destroyed. It calls DELETE with force=true, the reason in the body and delete_answers=true only when that confirmation was given. The regular delete is untouched. Admin is the same set of groups summit-api allows: super-admins, administrators and summit-front-end-administrators, as a new access route. A missing route makes hasAccess() true for everybody, so a test checks it against the real yml. --- src/access-routes.yml | 6 + ...xtra-question-force-delete-actions.test.js | 145 +++++++++ src/actions/sponsor-actions.js | 62 ++++ src/i18n/en.json | 21 ++ .../member-force-delete-access.test.js | 40 +++ .../__tests__/extra-questions.test.js | 88 ++++++ .../force-delete-extra-question-popup.test.js | 182 ++++++++++++ .../sponsor-general-form/extra-questions.js | 41 ++- .../force-delete-extra-question-popup.js | 275 ++++++++++++++++++ .../tabs/sponsor-general-form/index.js | 12 + 10 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 src/actions/__tests__/sponsor-extra-question-force-delete-actions.test.js create mode 100644 src/models/__tests__/member-force-delete-access.test.js create mode 100644 src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/extra-questions.test.js create mode 100644 src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js create mode 100644 src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js diff --git a/src/access-routes.yml b/src/access-routes.yml index 5d0d4ae8f..6beba1c20 100644 --- a/src/access-routes.yml +++ b/src/access-routes.yml @@ -55,6 +55,12 @@ admin-sponsors: - administrators - summit-front-end-administrators +# force delete of sponsor extra questions: summit-api only lets admins do it +sponsors-extra-questions-force-delete: + - super-admins + - administrators + - summit-front-end-administrators + general: - super-admins - administrators diff --git a/src/actions/__tests__/sponsor-extra-question-force-delete-actions.test.js b/src/actions/__tests__/sponsor-extra-question-force-delete-actions.test.js new file mode 100644 index 000000000..64bf5f933 --- /dev/null +++ b/src/actions/__tests__/sponsor-extra-question-force-delete-actions.test.js @@ -0,0 +1,145 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + deleteRequest, + getRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { + forceDeleteExtraQuestion, + getSponsorExtraQuestionUsage, + SPONSOR_EXTRA_QUESTION_DELETED +} from "../sponsor-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"), + deleteRequest: jest.fn(), + getRequest: jest.fn() +})); + +const ENDPOINT = + "http://api.test/api/v1/summits/7/sponsors/5/extra-questions/9"; + +describe("sponsor extra question force delete actions", () => { + const mockStore = configureStore([thunk]); + let store; + + beforeEach(() => { + window.API_BASE_URL = "http://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + store = mockStore({ currentSummitState: { currentSummit: { id: 7 } } }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); + + describe("forceDeleteExtraQuestion", () => { + // deleteRequest(request, receive, endpoint, payload, errorHandler)(params)(dispatch) + const mockDelete = (outcome) => { + const withDispatch = jest.fn(() => outcome); + const withParams = jest.fn(() => withDispatch); + deleteRequest.mockImplementation(() => withParams); + return withParams; + }; + + it("sends force=true and the reason, without delete_answers when the server holds no answers", async () => { + const withParams = mockDelete(Promise.resolve({ response: {} })); + + await store.dispatch( + forceDeleteExtraQuestion(5, 9, { reason: "created by mistake" }) + ); + + expect(deleteRequest).toHaveBeenCalledWith( + null, + expect.anything(), + ENDPOINT, + { reason: "created by mistake" }, + expect.any(Function) + ); + expect(withParams).toHaveBeenCalledWith({ + access_token: "TOKEN", + force: true + }); + }); + + it("sends delete_answers=true only when the answers were confirmed", async () => { + const withParams = mockDelete(Promise.resolve({ response: {} })); + + await store.dispatch( + forceDeleteExtraQuestion(5, 9, { + reason: "sponsor insists", + deleteAnswers: true + }) + ); + + expect(withParams).toHaveBeenCalledWith({ + access_token: "TOKEN", + force: true, + delete_answers: true + }); + }); + + it("reports the success once the question is deleted", async () => { + mockDelete(Promise.resolve({ response: {} })); + + await store.dispatch(forceDeleteExtraQuestion(5, 9, { reason: "x" })); + await flushPromises(); + + // the reducer removes the question on the action given to the request + expect(deleteRequest.mock.calls[0][1]).toEqual({ + type: SPONSOR_EXTRA_QUESTION_DELETED, + payload: { questionId: 9 } + }); + expect( + store.getActions().some((a) => a.type === "SET_SNACKBAR_MESSAGE") + ).toBe(true); + }); + + it("rejects and does not report success when the delete fails", async () => { + mockDelete(Promise.reject(new Error("412"))); + + await expect( + store.dispatch(forceDeleteExtraQuestion(5, 9, { reason: "x" })) + ).rejects.toThrow("412"); + expect( + store.getActions().some((a) => a.type === "SET_SNACKBAR_MESSAGE") + ).toBe(false); + }); + }); + + describe("getSponsorExtraQuestionUsage", () => { + it("reads the usage of the question and resolves with it", async () => { + const usage = { answers_count: 3, reps_count: 1, reps: [] }; + getRequest.mockImplementation( + () => () => () => Promise.resolve({ response: usage }) + ); + + await expect( + store.dispatch(getSponsorExtraQuestionUsage(5, 9)) + ).resolves.toEqual(usage); + expect(getRequest).toHaveBeenCalledWith( + null, + expect.anything(), + `${ENDPOINT}/usage`, + expect.any(Function) + ); + }); + + it("rejects when the usage can't be read", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("403")) + ); + + await expect( + store.dispatch(getSponsorExtraQuestionUsage(5, 9)) + ).rejects.toThrow("403"); + }); + }); +}); diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js index 1e9301b86..0dfe1ed8c 100644 --- a/src/actions/sponsor-actions.js +++ b/src/actions/sponsor-actions.js @@ -76,6 +76,8 @@ export const RECEIVE_SPONSOR_EXTRA_QUESTION_META = export const SPONSOR_EXTRA_QUESTION_ORDER_UPDATED = "SPONSOR_EXTRA_QUESTION_ORDER_UPDATED"; export const SPONSOR_EXTRA_QUESTION_DELETED = "SPONSOR_EXTRA_QUESTION_DELETED"; +export const RECEIVE_SPONSOR_EXTRA_QUESTION_USAGE = + "RECEIVE_SPONSOR_EXTRA_QUESTION_USAGE"; export const RECEIVE_SPONSOR_EXTRA_QUESTION = "RECEIVE_SPONSOR_EXTRA_QUESTION"; export const UPDATE_SPONSOR_EXTRA_QUESTION = "UPDATE_SPONSOR_EXTRA_QUESTION"; export const SPONSOR_EXTRA_QUESTION_UPDATED = "SPONSOR_EXTRA_QUESTION_UPDATED"; @@ -863,6 +865,66 @@ export const deleteExtraQuestion = .finally(() => dispatch(stopLoading())); }; +/** + * What a force delete of the question affects: collected answers on the server and the sponsor's + * badge scan activity per rep. It does not prove devices have nothing pending. + */ +export const getSponsorExtraQuestionUsage = + (sponsorId, questionId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + const params = { + access_token: accessToken + }; + + return getRequest( + null, + createAction(RECEIVE_SPONSOR_EXTRA_QUESTION_USAGE), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/extra-questions/${questionId}/usage`, + snackbarErrorHandler + )(params)(dispatch) + .then(({ response }) => response) + .finally(() => dispatch(stopLoading())); + }; + +/** + * Admin only. Sponsors can't delete questions (devices may hold answers the server has not seen), + * an admin who checked the devices can force it with a reason. When the server holds collected + * answers, deleteAnswers confirms they are destroyed. + */ +export const forceDeleteExtraQuestion = + (sponsorId, questionId, { reason, deleteAnswers = false }) => + async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + const params = { + access_token: accessToken, + force: true, + ...(deleteAnswers ? { delete_answers: true } : {}) + }; + + return deleteRequest( + null, + createAction(SPONSOR_EXTRA_QUESTION_DELETED)({ questionId }), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/extra-questions/${questionId}`, + { reason }, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_sponsor.extra_question_force_deleted") + }) + ); + }) + .finally(() => dispatch(stopLoading())); + }; + export const saveSponsorExtraQuestion = (entity) => async (dispatch, getState) => { const { currentSummitState, currentSponsorState } = getState(); diff --git a/src/i18n/en.json b/src/i18n/en.json index 71dc17271..f23789673 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2493,6 +2493,27 @@ "extra_question_created": "Extra Question created successfully", "extra_question_deleted": "Extra Question deleted successfully", "extra_question_remove_warning": "Please verify you want to delete {name}", + "extra_question_force_deleted": "Extra Question force deleted successfully", + "force_delete": "Force delete", + "force_delete_title": "Force delete extra question", + "force_delete_intro": "Sponsors can't delete questions, because devices may hold answers the server has not seen yet. Only force it if you have confirmed that no device has anything pending to upload. This action is audited.", + "force_delete_question": "Question: {name}", + "force_delete_loading": "Checking collected answers and scan activity...", + "force_delete_usage_error": "The usage of this question could not be loaded, so it can't be force deleted now.", + "force_delete_answers_count": "Collected answers on the server: {count}", + "force_delete_scan_activity": "Badge scan activity", + "force_delete_no_scans": "This sponsor has no badge scans yet.", + "force_delete_rep_activity": "{name} ({email}): {scans} scans, last scan {date}", + "force_delete_activity_note": "This does not show pending uploads on the devices, only the devices can show that.", + "force_delete_checklist": "Before deleting, confirm that:", + "force_delete_check_devices": "All of this sponsor's devices are in FN hands, or each device has been checked.", + "force_delete_check_pending": "Each device shows 0 pending uploads. Sync it first if it doesn't.", + "force_delete_check_third_party": "Any bring-your-own or third-party devices have been confirmed by the sponsor as fully synced.", + "force_delete_reason": "Reason", + "force_delete_reason_placeholder": "e.g. All 4 devices returned to the FN desk, 0 pending uploads on each, confirmed with the sponsor", + "force_delete_reason_required": "A reason is required.", + "force_delete_delete_answers": "Permanently delete {count} collected answers", + "force_delete_delete_answers_warning": "The collected answers to this question will be destroyed and can't be recovered.", "remove_warning_ads": "Are you sure you want to delete advertisement ", "remove_warning_materials": "Are you sure you want to delete material ", "remove_warning_social_networks": "Are you sure you want to delete social network ", diff --git a/src/models/__tests__/member-force-delete-access.test.js b/src/models/__tests__/member-force-delete-access.test.js new file mode 100644 index 000000000..4d42d6d79 --- /dev/null +++ b/src/models/__tests__/member-force-delete-access.test.js @@ -0,0 +1,40 @@ +import Member from "../member"; + +// The real access-routes data: with the yml transform stub (or a missing key) hasAccess() +// always returns true, which would show the force delete to everybody. +jest.mock("../../access-routes.yml", () => { + const fs = require("fs"); + const path = require("path"); + const yaml = require("js-yaml"); + return yaml.load( + fs.readFileSync(path.join(__dirname, "../../access-routes.yml"), "utf8") + ); +}); + +const memberWithGroups = (...codes) => + new Member({ groups: codes.map((code) => ({ code })) }); + +describe("sponsors-extra-questions-force-delete access", () => { + const ROUTE = "sponsors-extra-questions-force-delete"; + + it.each([ + "super-admins", + "administrators", + "summit-front-end-administrators" + ])("is granted to %s", (group) => { + expect(memberWithGroups(group).hasAccess(ROUTE)).toBe(true); + }); + + it.each([ + "sponsors", + "sponsor-external-users", + "badge-printers", + "summit-room-administrators" + ])("is not granted to %s", (group) => { + expect(memberWithGroups(group).hasAccess(ROUTE)).toBe(false); + }); + + it("is not granted to a member without groups", () => { + expect(memberWithGroups().hasAccess(ROUTE)).toBe(false); + }); +}); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/extra-questions.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/extra-questions.test.js new file mode 100644 index 000000000..d909f0d59 --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/extra-questions.test.js @@ -0,0 +1,88 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import SponsorExtraQuestions from "../extra-questions"; + +const QUESTIONS = [ + { id: 9, name: "FAVORITE_COLOR", label: "Color?", type: "Text", order: 1 }, + { id: 10, name: "NEWSLETTER", label: "News?", type: "CheckBox", order: 2 } +]; + +const renderList = (props = {}) => { + const handlers = { + getSponsorExtraQuestion: jest.fn(() => Promise.resolve()), + saveSponsorExtraQuestion: jest.fn(() => Promise.resolve({})), + saveSponsorExtraQuestionValue: jest.fn(() => Promise.resolve()), + resetSponsorExtraQuestionForm: jest.fn(), + onExtraQuestionReOrder: jest.fn(), + onExtraQuestionDelete: jest.fn(), + getSponsorExtraQuestionUsage: jest.fn(() => + Promise.resolve({ answers_count: 0, reps_count: 0, reps: [] }) + ), + onExtraQuestionForceDelete: jest.fn(() => Promise.resolve()), + ...props + }; + render( + + ); + return handlers; +}; + +describe("SponsorExtraQuestions force delete", () => { + it("hides the force delete action from non admins", () => { + renderList({ canForceDelete: false }); + + expect( + screen.queryByRole("button", { name: "edit_sponsor.force_delete" }) + ).not.toBeInTheDocument(); + }); + + it("shows a force delete action per question to admins", () => { + renderList({ canForceDelete: true }); + + expect( + screen.getAllByRole("button", { name: "edit_sponsor.force_delete" }) + ).toHaveLength(QUESTIONS.length); + }); + + it("opens the confirmation for the chosen question and force deletes it on the sponsor", async () => { + const handlers = renderList({ canForceDelete: true }); + + await userEvent.click( + screen.getAllByRole("button", { name: "edit_sponsor.force_delete" })[0] + ); + + await waitFor(() => + expect(handlers.getSponsorExtraQuestionUsage).toHaveBeenCalledWith(5, 9) + ); + expect( + screen.getByText("edit_sponsor.force_delete_title") + ).toBeInTheDocument(); + + await screen.findByTestId("force-delete-reason"); + await userEvent.click(screen.getByTestId("force-delete-check-devices")); + await userEvent.click(screen.getByTestId("force-delete-check-pending")); + await userEvent.click(screen.getByTestId("force-delete-check-third_party")); + await userEvent.type( + screen.getByTestId("force-delete-reason"), + "created by mistake" + ); + await userEvent.click( + screen.getByRole("button", { name: "edit_sponsor.force_delete" }) + ); + + await waitFor(() => + expect(handlers.onExtraQuestionForceDelete).toHaveBeenCalledWith(5, 9, { + reason: "created by mistake", + deleteAnswers: false + }) + ); + // the plain delete is never used for a force delete + expect(handlers.onExtraQuestionDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js new file mode 100644 index 000000000..a34f3de8a --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js @@ -0,0 +1,182 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ForceDeleteExtraQuestionPopup from "../force-delete-extra-question-popup"; + +const QUESTION = { id: 9, name: "FAVORITE_COLOR" }; + +const buildUsage = (overrides = {}) => ({ + answers_count: 0, + reps_count: 1, + reps: [ + { + member_id: 1, + first_name: "Ana", + last_name: "Perez", + email: "ana@acme.com", + scans_count: 4, + last_scan_date: 1572019200 + } + ], + ...overrides +}); + +const renderPopup = ({ + usage = buildUsage(), + getUsage = jest.fn(() => Promise.resolve(usage)), + onConfirm = jest.fn(() => Promise.resolve()), + onClose = jest.fn() +} = {}) => { + render( + + ); + return { getUsage, onConfirm, onClose }; +}; + +const forceDeleteButton = () => + screen.getByRole("button", { name: "edit_sponsor.force_delete" }); + +const tickChecklist = async () => { + await userEvent.click(screen.getByTestId("force-delete-check-devices")); + await userEvent.click(screen.getByTestId("force-delete-check-pending")); + await userEvent.click(screen.getByTestId("force-delete-check-third_party")); +}; + +const typeReason = (text) => + userEvent.type(screen.getByTestId("force-delete-reason"), text); + +describe("ForceDeleteExtraQuestionPopup", () => { + it("loads the usage of the question and shows what is at stake", async () => { + const { getUsage } = renderPopup({ + usage: buildUsage({ answers_count: 23 }) + }); + + expect(getUsage).toHaveBeenCalledWith(9); + expect( + await screen.findByText("edit_sponsor.force_delete_answers_count") + ).toBeInTheDocument(); + expect( + screen.getByText("edit_sponsor.force_delete_rep_activity") + ).toBeInTheDocument(); + }); + + it("keeps the button disabled until the usage is loaded", async () => { + renderPopup({ getUsage: jest.fn(() => new Promise(() => {})) }); + + expect( + screen.getByText("edit_sponsor.force_delete_loading") + ).toBeInTheDocument(); + expect(forceDeleteButton()).toBeDisabled(); + }); + + it("can't force delete when the usage can't be loaded", async () => { + renderPopup({ getUsage: jest.fn(() => Promise.reject(new Error("403"))) }); + + expect( + await screen.findByText("edit_sponsor.force_delete_usage_error") + ).toBeInTheDocument(); + expect(forceDeleteButton()).toBeDisabled(); + }); + + it("needs every checklist item and a reason before the button enables", async () => { + renderPopup(); + await screen.findByTestId("force-delete-reason"); + + expect(forceDeleteButton()).toBeDisabled(); + + await typeReason("0 pending uploads on each device"); + expect(forceDeleteButton()).toBeDisabled(); + + await userEvent.click(screen.getByTestId("force-delete-check-devices")); + await userEvent.click(screen.getByTestId("force-delete-check-pending")); + expect(forceDeleteButton()).toBeDisabled(); + + await userEvent.click(screen.getByTestId("force-delete-check-third_party")); + expect(forceDeleteButton()).toBeEnabled(); + }); + + it("doesn't accept a blank reason", async () => { + renderPopup(); + await screen.findByTestId("force-delete-reason"); + + await tickChecklist(); + await typeReason(" "); + + expect(forceDeleteButton()).toBeDisabled(); + }); + + it("deletes a question with no collected answers without asking to destroy any", async () => { + const { onConfirm, onClose } = renderPopup(); + await screen.findByTestId("force-delete-reason"); + + expect( + screen.queryByTestId("force-delete-delete-answers") + ).not.toBeInTheDocument(); + + await tickChecklist(); + await typeReason(" created by mistake "); + await userEvent.click(forceDeleteButton()); + + expect(onConfirm).toHaveBeenCalledWith(9, { + reason: "created by mistake", + deleteAnswers: false + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("asks a second confirmation before destroying collected answers", async () => { + const { onConfirm } = renderPopup({ + usage: buildUsage({ answers_count: 23 }) + }); + await screen.findByTestId("force-delete-reason"); + + await tickChecklist(); + await typeReason("sponsor insists"); + + // everything else is done but the collected answers are not confirmed + expect(forceDeleteButton()).toBeDisabled(); + expect( + screen.getByText("edit_sponsor.force_delete_delete_answers_warning") + ).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("force-delete-delete-answers")); + expect(forceDeleteButton()).toBeEnabled(); + + await userEvent.click(forceDeleteButton()); + expect(onConfirm).toHaveBeenCalledWith(9, { + reason: "sponsor insists", + deleteAnswers: true + }); + }); + + it("stays open when the delete fails so it can be retried or cancelled", async () => { + const onConfirm = jest.fn(() => Promise.reject(new Error("412"))); + const { onClose } = renderPopup({ onConfirm }); + await screen.findByTestId("force-delete-reason"); + + await tickChecklist(); + await typeReason("sponsor insists"); + await userEvent.click(forceDeleteButton()); + + await waitFor(() => expect(onConfirm).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); + await waitFor(() => expect(forceDeleteButton()).toBeEnabled()); + }); + + it("closes without deleting on cancel", async () => { + const { onConfirm, onClose } = renderPopup(); + await screen.findByTestId("force-delete-reason"); + + await userEvent.click( + screen.getByRole("button", { name: "general.cancel" }) + ); + + expect(onClose).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js index ad7c73321..b729bcc29 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js @@ -21,6 +21,7 @@ import { MAX_PER_PAGE } from "../../../../../utils/constants"; import AddSponsorExtraQuestionPopup from "./add-extra-question-popup"; +import ForceDeleteExtraQuestionPopup from "./force-delete-extra-question-popup"; const SponsorExtraQuestions = ({ sponsorId, @@ -31,11 +32,15 @@ const SponsorExtraQuestions = ({ saveSponsorExtraQuestionValue, resetSponsorExtraQuestionForm, onExtraQuestionReOrder, - onExtraQuestionDelete + onExtraQuestionDelete, + canForceDelete = false, + getSponsorExtraQuestionUsage, + onExtraQuestionForceDelete }) => { const [tableData, setTableData] = useState(extraQuestions); const [showAddExtraQuestionPopup, setShowAddExtraQuestionPopup] = useState(false); + const [questionToForceDelete, setQuestionToForceDelete] = useState(null); useEffect(() => { const sortedExtraQuestions = extraQuestions.sort( @@ -68,6 +73,9 @@ const SponsorExtraQuestions = ({ onExtraQuestionDelete(sponsorId, extraQuestionId); }; + const handleForceDeleteExtraQuestion = (extraQuestionId, options) => + onExtraQuestionForceDelete(sponsorId, extraQuestionId, options); + const handleSubmitExtraQuestion = (extraQuestion) => { const { valuesToSave, ...extraQuestionToSave } = extraQuestion; @@ -106,7 +114,26 @@ const SponsorExtraQuestions = ({ { columnKey: "name", header: T.translate("generic_extra_question_list.question_id") - } + }, + // sponsors can't delete questions, only admins can force it + ...(canForceDelete + ? [ + { + columnKey: "force_delete", + header: "", + align: "right", + render: (c) => ( + + ) + } + ] + : []) ]; return ( @@ -206,6 +233,16 @@ const SponsorExtraQuestions = ({ )} + {questionToForceDelete && ( + + getSponsorExtraQuestionUsage(sponsorId, questionId) + } + onConfirm={handleForceDeleteExtraQuestion} + onClose={() => setQuestionToForceDelete(null)} + /> + )} {showAddExtraQuestionPopup && ( { + const [usage, setUsage] = useState(null); + const [usageFailed, setUsageFailed] = useState(false); + const [checks, setChecks] = useState(NO_CHECKS); + const [reason, setReason] = useState(""); + const [reasonTouched, setReasonTouched] = useState(false); + const [deleteAnswers, setDeleteAnswers] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let active = true; + getUsage(extraQuestion.id) + .then((response) => { + if (active) setUsage(response); + }) + .catch(() => { + if (active) setUsageFailed(true); + }); + return () => { + active = false; + }; + }, [extraQuestion.id]); + + const answersCount = usage?.answers_count ?? 0; + const reasonMissing = reason.trim() === ""; + const allChecked = CHECKLIST.every(({ key }) => checks[key]); + const answersConfirmed = answersCount === 0 || deleteAnswers; + const canSubmit = + !!usage && allChecked && !reasonMissing && answersConfirmed && !submitting; + + const handleCheck = (key) => (ev) => + setChecks((prev) => ({ ...prev, [key]: ev.target.checked })); + + const handleConfirm = () => { + if (!canSubmit) return; + setSubmitting(true); + onConfirm(extraQuestion.id, { + reason: reason.trim(), + deleteAnswers: answersCount > 0 && deleteAnswers + }) + .then(() => onClose()) + // the error is shown by the request, keep the dialog open to retry or cancel + .catch(() => setSubmitting(false)); + }; + + const renderActivity = () => { + const reps = usage.reps || []; + return ( + + + {T.translate("edit_sponsor.force_delete_scan_activity")} + + {reps.length === 0 && ( + + {T.translate("edit_sponsor.force_delete_no_scans")} + + )} + {reps.map((rep) => ( + + {T.translate("edit_sponsor.force_delete_rep_activity", { + name: `${rep.first_name} ${rep.last_name}`.trim(), + email: rep.email, + scans: rep.scans_count, + date: moment.unix(rep.last_scan_date).format("YYYY-MM-DD HH:mm") + })} + + ))} + + {T.translate("edit_sponsor.force_delete_activity_note")} + + + ); + }; + + return ( + + + {T.translate("edit_sponsor.force_delete_title")} + + + + + {T.translate("edit_sponsor.force_delete_intro")} + + + {T.translate("edit_sponsor.force_delete_question", { + name: extraQuestion.name + })} + + + {!usage && !usageFailed && ( + + + + {T.translate("edit_sponsor.force_delete_loading")} + + + )} + + {usageFailed && ( + + {T.translate("edit_sponsor.force_delete_usage_error")} + + )} + + {usage && ( + <> + + {T.translate("edit_sponsor.force_delete_answers_count", { + count: answersCount + })} + + {renderActivity()} + + + {T.translate("edit_sponsor.force_delete_checklist")} + + + {CHECKLIST.map(({ key, label }) => ( + + } + label={T.translate(label)} + /> + ))} + + + setReason(ev.target.value)} + onBlur={() => setReasonTouched(true)} + error={reasonTouched && reasonMissing} + helperText={ + reasonTouched && reasonMissing + ? T.translate("edit_sponsor.force_delete_reason_required") + : "" + } + inputProps={{ "data-testid": "force-delete-reason" }} + required + /> + + {answersCount > 0 && ( + + + {T.translate( + "edit_sponsor.force_delete_delete_answers_warning" + )} + + setDeleteAnswers(ev.target.checked)} + inputProps={{ + "data-testid": "force-delete-delete-answers" + }} + /> + } + label={T.translate( + "edit_sponsor.force_delete_delete_answers", + { + count: answersCount + } + )} + sx={{ color: "error.main" }} + /> + + )} + + )} + + + + + + + + ); +}; + +ForceDeleteExtraQuestionPopup.propTypes = { + extraQuestion: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string + }).isRequired, + getUsage: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default ForceDeleteExtraQuestionPopup; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js index 9ad9868a9..fc829932a 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js @@ -14,6 +14,7 @@ import React from "react"; import { connect } from "react-redux"; import { Box } from "@mui/material"; +import Member from "../../../../../models/member"; import SponsorHeader from "./sponsor-header"; import Sponsorship from "./sponsorship"; import BadgeScanSettings from "./badge-scan-settings"; @@ -21,7 +22,9 @@ import SponsorExtraQuestions from "./extra-questions"; import { addTierToSponsor, deleteExtraQuestion, + forceDeleteExtraQuestion, getSponsorExtraQuestion, + getSponsorExtraQuestionUsage, getSponsorLeadReportSettingsMeta, getSponsorSponsorships, removeAddonToSponsorship, @@ -53,6 +56,8 @@ const SponsorGeneralForm = ({ saveSponsorExtraQuestionValue, resetSponsorExtraQuestionForm, deleteExtraQuestion, + forceDeleteExtraQuestion, + getSponsorExtraQuestionUsage, updateExtraQuestionOrder, saveSponsor }) => { @@ -90,6 +95,11 @@ const SponsorGeneralForm = ({ saveSponsorExtraQuestionValue={saveSponsorExtraQuestionValue} onExtraQuestionDelete={deleteExtraQuestion} onExtraQuestionReOrder={updateExtraQuestionOrder} + canForceDelete={new Member(member).hasAccess( + "sponsors-extra-questions-force-delete" + )} + getSponsorExtraQuestionUsage={getSponsorExtraQuestionUsage} + onExtraQuestionForceDelete={forceDeleteExtraQuestion} /> ); @@ -119,6 +129,8 @@ export default connect(mapStateToProps, { saveSponsorExtraQuestionValue, resetSponsorExtraQuestionForm, deleteExtraQuestion, + forceDeleteExtraQuestion, + getSponsorExtraQuestionUsage, updateExtraQuestionOrder, saveSponsor })(SponsorGeneralForm); From b6278c59c29b8b80dd072d89e712a6d0ce929f56 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 9 Oct 2026 18:11:16 +0200 Subject: [PATCH 2/5] fix(sponsors): block force delete dialog dismissal while the request runs ESC and backdrop clicks closed the dialog mid request, losing the retry state when the delete failed. Co-Authored-By: Claude Opus 5.5 --- .../force-delete-extra-question-popup.test.js | 26 +++++++++++++++++++ .../force-delete-extra-question-popup.js | 16 ++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js index a34f3de8a..e7f1169a2 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/__tests__/force-delete-extra-question-popup.test.js @@ -168,6 +168,32 @@ describe("ForceDeleteExtraQuestionPopup", () => { await waitFor(() => expect(forceDeleteButton()).toBeEnabled()); }); + it("can't be dismissed while the delete request runs", async () => { + const onConfirm = jest.fn(() => new Promise(() => {})); + const { onClose } = renderPopup({ onConfirm }); + await screen.findByTestId("force-delete-reason"); + + await tickChecklist(); + await typeReason("sponsor insists"); + await userEvent.click(forceDeleteButton()); + expect(onConfirm).toHaveBeenCalled(); + + await userEvent.keyboard("{Escape}"); + await userEvent.click(document.querySelector(".MuiBackdrop-root")); + + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("closes on ESC when nothing is being deleted", async () => { + const { onClose } = renderPopup(); + await screen.findByTestId("force-delete-reason"); + + await userEvent.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalled(); + }); + it("closes without deleting on cancel", async () => { const { onConfirm, onClose } = renderPopup(); await screen.findByTestId("force-delete-reason"); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js index 8e218d0b9..42980f671 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js @@ -130,8 +130,20 @@ const ForceDeleteExtraQuestionPopup = ({ ); }; + const handleClose = () => { + // don't let ESC or a backdrop click dismiss the dialog while the delete request runs + if (submitting) return; + onClose(); + }; + return ( - + {T.translate("edit_sponsor.force_delete_title")} @@ -246,7 +258,7 @@ const ForceDeleteExtraQuestionPopup = ({ - + )} + + ))} + + ) + }) +); + const QUESTIONS = [ { id: 9, name: "FAVORITE_COLOR", label: "Color?", type: "Text", order: 1 }, { id: 10, name: "NEWSLETTER", label: "News?", type: "CheckBox", order: 2 } @@ -15,11 +38,10 @@ const renderList = (props = {}) => { saveSponsorExtraQuestionValue: jest.fn(() => Promise.resolve()), resetSponsorExtraQuestionForm: jest.fn(), onExtraQuestionReOrder: jest.fn(), - onExtraQuestionDelete: jest.fn(), + onExtraQuestionDelete: jest.fn(() => Promise.resolve()), getSponsorExtraQuestionUsage: jest.fn(() => Promise.resolve({ answers_count: 0, reps_count: 0, reps: [] }) ), - onExtraQuestionForceDelete: jest.fn(() => Promise.resolve()), ...props }; render( @@ -33,56 +55,50 @@ const renderList = (props = {}) => { return handlers; }; -describe("SponsorExtraQuestions force delete", () => { - it("hides the force delete action from non admins", () => { - renderList({ canForceDelete: false }); +describe("SponsorExtraQuestions delete", () => { + it("hides the trash from non admins", () => { + renderList({ canDelete: false }); - expect( - screen.queryByRole("button", { name: "edit_sponsor.force_delete" }) - ).not.toBeInTheDocument(); + expect(screen.queryByText(/^trash-/)).not.toBeInTheDocument(); }); - it("shows a force delete action per question to admins", () => { - renderList({ canForceDelete: true }); + it("shows the trash per question to admins", () => { + renderList({ canDelete: true }); - expect( - screen.getAllByRole("button", { name: "edit_sponsor.force_delete" }) - ).toHaveLength(QUESTIONS.length); + expect(screen.getAllByText(/^trash-/)).toHaveLength(QUESTIONS.length); }); - it("opens the confirmation for the chosen question and force deletes it on the sponsor", async () => { - const handlers = renderList({ canForceDelete: true }); + it("opens the confirmation for the chosen question and deletes it on the sponsor", async () => { + const handlers = renderList({ canDelete: true }); - await userEvent.click( - screen.getAllByRole("button", { name: "edit_sponsor.force_delete" })[0] - ); + await userEvent.click(screen.getByText("trash-9")); await waitFor(() => expect(handlers.getSponsorExtraQuestionUsage).toHaveBeenCalledWith(5, 9) ); expect( - screen.getByText("edit_sponsor.force_delete_title") + screen.getByText("edit_sponsor.delete_question_title") ).toBeInTheDocument(); + // nothing is deleted until the dialog is confirmed + expect(handlers.onExtraQuestionDelete).not.toHaveBeenCalled(); - await screen.findByTestId("force-delete-reason"); - await userEvent.click(screen.getByTestId("force-delete-check-devices")); - await userEvent.click(screen.getByTestId("force-delete-check-pending")); - await userEvent.click(screen.getByTestId("force-delete-check-third_party")); + await screen.findByTestId("delete-reason"); + await userEvent.click(screen.getByTestId("delete-check-devices")); + await userEvent.click(screen.getByTestId("delete-check-pending")); + await userEvent.click(screen.getByTestId("delete-check-third_party")); await userEvent.type( - screen.getByTestId("force-delete-reason"), + screen.getByTestId("delete-reason"), "created by mistake" ); await userEvent.click( - screen.getByRole("button", { name: "edit_sponsor.force_delete" }) + screen.getByRole("button", { name: "edit_sponsor.delete_question" }) ); await waitFor(() => - expect(handlers.onExtraQuestionForceDelete).toHaveBeenCalledWith(5, 9, { + expect(handlers.onExtraQuestionDelete).toHaveBeenCalledWith(5, 9, { reason: "created by mistake", deleteAnswers: false }) ); - // the plain delete is never used for a force delete - expect(handlers.onExtraQuestionDelete).not.toHaveBeenCalled(); }); }); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/delete-extra-question-popup.js similarity index 81% rename from src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js rename to src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/delete-extra-question-popup.js index 42980f671..f83316ddc 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/force-delete-extra-question-popup.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/delete-extra-question-popup.js @@ -31,24 +31,24 @@ import { } from "@mui/material"; const CHECKLIST = [ - { key: "devices", label: "edit_sponsor.force_delete_check_devices" }, - { key: "pending", label: "edit_sponsor.force_delete_check_pending" }, + { key: "devices", label: "edit_sponsor.delete_question_check_devices" }, + { key: "pending", label: "edit_sponsor.delete_question_check_pending" }, { key: "third_party", - label: "edit_sponsor.force_delete_check_third_party" + label: "edit_sponsor.delete_question_check_third_party" } ]; const NO_CHECKS = { devices: false, pending: false, third_party: false }; /** - * Admin only confirmation to force delete a sponsor extra question. + * Admin only confirmation to delete a sponsor extra question. * Sponsors can't delete questions because devices may hold answers the server has not seen, only * an admin who checked the devices can tell a delete is safe: the dialog shows what is at stake, * asks to confirm the checklist and a reason, and a second confirmation when collected answers * will be destroyed. */ -const ForceDeleteExtraQuestionPopup = ({ +const DeleteExtraQuestionPopup = ({ extraQuestion, getUsage, onConfirm, @@ -103,16 +103,16 @@ const ForceDeleteExtraQuestionPopup = ({ return ( - {T.translate("edit_sponsor.force_delete_scan_activity")} + {T.translate("edit_sponsor.delete_question_scan_activity")} {reps.length === 0 && ( - {T.translate("edit_sponsor.force_delete_no_scans")} + {T.translate("edit_sponsor.delete_question_no_scans")} )} {reps.map((rep) => ( - {T.translate("edit_sponsor.force_delete_rep_activity", { + {T.translate("edit_sponsor.delete_question_rep_activity", { name: `${rep.first_name} ${rep.last_name}`.trim(), email: rep.email, scans: rep.scans_count, @@ -124,7 +124,7 @@ const ForceDeleteExtraQuestionPopup = ({ variant="caption" sx={{ display: "block", mt: 1, color: "text.secondary" }} > - {T.translate("edit_sponsor.force_delete_activity_note")} + {T.translate("edit_sponsor.delete_question_activity_note")} ); @@ -145,15 +145,15 @@ const ForceDeleteExtraQuestionPopup = ({ fullWidth > - {T.translate("edit_sponsor.force_delete_title")} + {T.translate("edit_sponsor.delete_question_title")} - {T.translate("edit_sponsor.force_delete_intro")} + {T.translate("edit_sponsor.delete_question_intro")} - {T.translate("edit_sponsor.force_delete_question", { + {T.translate("edit_sponsor.delete_question_question", { name: extraQuestion.name })} @@ -162,28 +162,28 @@ const ForceDeleteExtraQuestionPopup = ({ - {T.translate("edit_sponsor.force_delete_loading")} + {T.translate("edit_sponsor.delete_question_loading")} )} {usageFailed && ( - {T.translate("edit_sponsor.force_delete_usage_error")} + {T.translate("edit_sponsor.delete_question_usage_error")} )} {usage && ( <> - {T.translate("edit_sponsor.force_delete_answers_count", { + {T.translate("edit_sponsor.delete_question_answers_count", { count: answersCount })} {renderActivity()} - {T.translate("edit_sponsor.force_delete_checklist")} + {T.translate("edit_sponsor.delete_question_checklist")} {CHECKLIST.map(({ key, label }) => ( @@ -194,7 +194,7 @@ const ForceDeleteExtraQuestionPopup = ({ checked={checks[key]} onChange={handleCheck(key)} inputProps={{ - "data-testid": `force-delete-check-${key}` + "data-testid": `delete-check-${key}` }} /> } @@ -208,9 +208,9 @@ const ForceDeleteExtraQuestionPopup = ({ fullWidth multiline minRows={2} - label={T.translate("edit_sponsor.force_delete_reason")} + label={T.translate("edit_sponsor.delete_question_reason")} placeholder={T.translate( - "edit_sponsor.force_delete_reason_placeholder" + "edit_sponsor.delete_question_reason_placeholder" )} value={reason} onChange={(ev) => setReason(ev.target.value)} @@ -218,10 +218,10 @@ const ForceDeleteExtraQuestionPopup = ({ error={reasonTouched && reasonMissing} helperText={ reasonTouched && reasonMissing - ? T.translate("edit_sponsor.force_delete_reason_required") + ? T.translate("edit_sponsor.delete_question_reason_required") : "" } - inputProps={{ "data-testid": "force-delete-reason" }} + inputProps={{ "data-testid": "delete-reason" }} required /> @@ -229,7 +229,7 @@ const ForceDeleteExtraQuestionPopup = ({ {T.translate( - "edit_sponsor.force_delete_delete_answers_warning" + "edit_sponsor.delete_question_delete_answers_warning" )} setDeleteAnswers(ev.target.checked)} inputProps={{ - "data-testid": "force-delete-delete-answers" + "data-testid": "delete-delete-answers" }} /> } label={T.translate( - "edit_sponsor.force_delete_delete_answers", + "edit_sponsor.delete_question_delete_answers", { count: answersCount } @@ -267,14 +267,14 @@ const ForceDeleteExtraQuestionPopup = ({ disabled={!canSubmit} onClick={handleConfirm} > - {T.translate("edit_sponsor.force_delete")} + {T.translate("edit_sponsor.delete_question")} ); }; -ForceDeleteExtraQuestionPopup.propTypes = { +DeleteExtraQuestionPopup.propTypes = { extraQuestion: PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string @@ -284,4 +284,4 @@ ForceDeleteExtraQuestionPopup.propTypes = { onClose: PropTypes.func.isRequired }; -export default ForceDeleteExtraQuestionPopup; +export default DeleteExtraQuestionPopup; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js index b729bcc29..6034b3f02 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/extra-questions.js @@ -21,7 +21,7 @@ import { MAX_PER_PAGE } from "../../../../../utils/constants"; import AddSponsorExtraQuestionPopup from "./add-extra-question-popup"; -import ForceDeleteExtraQuestionPopup from "./force-delete-extra-question-popup"; +import DeleteExtraQuestionPopup from "./delete-extra-question-popup"; const SponsorExtraQuestions = ({ sponsorId, @@ -33,14 +33,13 @@ const SponsorExtraQuestions = ({ resetSponsorExtraQuestionForm, onExtraQuestionReOrder, onExtraQuestionDelete, - canForceDelete = false, - getSponsorExtraQuestionUsage, - onExtraQuestionForceDelete + canDelete = false, + getSponsorExtraQuestionUsage }) => { const [tableData, setTableData] = useState(extraQuestions); const [showAddExtraQuestionPopup, setShowAddExtraQuestionPopup] = useState(false); - const [questionToForceDelete, setQuestionToForceDelete] = useState(null); + const [questionToDelete, setQuestionToDelete] = useState(null); useEffect(() => { const sortedExtraQuestions = extraQuestions.sort( @@ -69,12 +68,15 @@ const SponsorExtraQuestions = ({ ); }; + // the table confirms first, then the dialog shows what is at stake and asks for the reason const handleDeleteExtraQuestion = (extraQuestionId) => { - onExtraQuestionDelete(sponsorId, extraQuestionId); + setQuestionToDelete( + extraQuestions.find((q) => q.id === extraQuestionId) || null + ); }; - const handleForceDeleteExtraQuestion = (extraQuestionId, options) => - onExtraQuestionForceDelete(sponsorId, extraQuestionId, options); + const handleConfirmDelete = (extraQuestionId, options) => + onExtraQuestionDelete(sponsorId, extraQuestionId, options); const handleSubmitExtraQuestion = (extraQuestion) => { const { valuesToSave, ...extraQuestionToSave } = extraQuestion; @@ -114,26 +116,7 @@ const SponsorExtraQuestions = ({ { columnKey: "name", header: T.translate("generic_extra_question_list.question_id") - }, - // sponsors can't delete questions, only admins can force it - ...(canForceDelete - ? [ - { - columnKey: "force_delete", - header: "", - align: "right", - render: (c) => ( - - ) - } - ] - : []) + } ]; return ( @@ -222,7 +205,8 @@ const SponsorExtraQuestions = ({ currentPage={DEFAULT_CURRENT_PAGE} perPage={MAX_PER_PAGE} onEdit={handleEditExtraQuestion} - onDelete={handleDeleteExtraQuestion} + // sponsors can't delete questions, the trash is only for admins + onDelete={canDelete ? handleDeleteExtraQuestion : undefined} onReorder={handleReorder} deleteDialogBody={(name) => T.translate("edit_sponsor.extra_question_remove_warning", { @@ -233,14 +217,14 @@ const SponsorExtraQuestions = ({ )} - {questionToForceDelete && ( - getSponsorExtraQuestionUsage(sponsorId, questionId) } - onConfirm={handleForceDeleteExtraQuestion} - onClose={() => setQuestionToForceDelete(null)} + onConfirm={handleConfirmDelete} + onClose={() => setQuestionToDelete(null)} /> )} {showAddExtraQuestionPopup && ( diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js index fc829932a..fbe2f7bb8 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-general-form/index.js @@ -22,7 +22,6 @@ import SponsorExtraQuestions from "./extra-questions"; import { addTierToSponsor, deleteExtraQuestion, - forceDeleteExtraQuestion, getSponsorExtraQuestion, getSponsorExtraQuestionUsage, getSponsorLeadReportSettingsMeta, @@ -56,7 +55,6 @@ const SponsorGeneralForm = ({ saveSponsorExtraQuestionValue, resetSponsorExtraQuestionForm, deleteExtraQuestion, - forceDeleteExtraQuestion, getSponsorExtraQuestionUsage, updateExtraQuestionOrder, saveSponsor @@ -95,11 +93,10 @@ const SponsorGeneralForm = ({ saveSponsorExtraQuestionValue={saveSponsorExtraQuestionValue} onExtraQuestionDelete={deleteExtraQuestion} onExtraQuestionReOrder={updateExtraQuestionOrder} - canForceDelete={new Member(member).hasAccess( - "sponsors-extra-questions-force-delete" + canDelete={new Member(member).hasAccess( + "sponsors-extra-questions-delete" )} getSponsorExtraQuestionUsage={getSponsorExtraQuestionUsage} - onExtraQuestionForceDelete={forceDeleteExtraQuestion} /> ); @@ -129,7 +126,6 @@ export default connect(mapStateToProps, { saveSponsorExtraQuestionValue, resetSponsorExtraQuestionForm, deleteExtraQuestion, - forceDeleteExtraQuestion, getSponsorExtraQuestionUsage, updateExtraQuestionOrder, saveSponsor From eb489aec7a7ca6c8db5fd00013fb6f9352d3b9ad Mon Sep 17 00:00:00 2001 From: romanetar Date: Sun, 11 Oct 2026 15:47:35 +0200 Subject: [PATCH 4/5] chore(sponsors): remove unused legacy sponsor-form component Nothing imports it, and its extra question delete call used the old deleteExtraQuestion signature. Co-Authored-By: Claude Sonnet 5.5 --- src/components/forms/sponsor-form.js | 795 --------------------------- 1 file changed, 795 deletions(-) delete mode 100644 src/components/forms/sponsor-form.js diff --git a/src/components/forms/sponsor-form.js b/src/components/forms/sponsor-form.js deleted file mode 100644 index 05c2915ee..000000000 --- a/src/components/forms/sponsor-form.js +++ /dev/null @@ -1,795 +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 from "react"; -import T from "i18n-react/dist/i18n-react"; -import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; -import Swal from "sweetalert2"; -import { Pagination } from "react-bootstrap"; -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import SortableTable from "openstack-uicore-foundation/lib/components/table-sortable" -import CompanyInput from "openstack-uicore-foundation/lib/components/inputs/company-input" -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import Panel from "openstack-uicore-foundation/lib/components/sections/panel" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input" -import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input" -import Table from "openstack-uicore-foundation/lib/components/table"; -import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; -import { isEmpty, scrollToError, shallowEqual } from "../../utils/methods"; -import EventInput from "../inputs/event-input"; -import SummitSponsorshipTypeInput from "../inputs/summit-sponsorship-type-input"; -import ExtraQuestionsTable from "../tables/extra-questions-table"; -import { - denormalizeLeadReportSettings, - renderOptions -} from "../../models/lead-report-settings"; -import { MILLISECONDS_IN_SECOND } from "../../utils/constants"; - -class SponsorForm extends React.Component { - constructor(props) { - super(props); - - const { entity, errors } = props; - - this.state = { - entity: { ...entity }, - showSection: "", - errors, - selectedColumns: [] - }; - - this.handleChangeMember = this.handleChangeMember.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.handleUploadHeaderImage = this.handleUploadHeaderImage.bind(this); - this.handleUploadSideImage = this.handleUploadSideImage.bind(this); - this.handleUploadHeaderMobileImage = - this.handleUploadHeaderMobileImage.bind(this); - this.handleUploadCarouselImage = this.handleUploadCarouselImage.bind(this); - this.handleRemoveFile = this.handleRemoveFile.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleAdvertisementAdd = this.handleAdvertisementAdd.bind(this); - this.handleMaterialAdd = this.handleMaterialAdd.bind(this); - this.handleSocialNetworkAdd = this.handleSocialNetworkAdd.bind(this); - this.handleExtraQuestionAdd = this.handleExtraQuestionAdd.bind(this); - this.handleAdvertisementEdit = this.handleAdvertisementEdit.bind(this); - this.handleMaterialEdit = this.handleMaterialEdit.bind(this); - this.handleSocialNetworkEdit = this.handleSocialNetworkEdit.bind(this); - this.handleExtraQuestionEdit = this.handleExtraQuestionEdit.bind(this); - this.handleColumnsChange = this.handleColumnsChange.bind(this); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; - state.selectedColumns = []; - const { - lead_report_setting: { columns } - } = state.entity; - - if (columns) { - state.selectedColumns = renderOptions( - denormalizeLeadReportSettings(columns) - ).map((c) => c.value); - } - } - - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; - } - - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } - - handleChange(ev) { - const entity = { ...this.state.entity }; - const errors = { ...this.state.errors }; - let { value, id } = ev.target; - - if (ev.target.type === "checkbox") { - value = ev.target.checked; - } - - if (ev.target.type === "datetime") { - value = value.valueOf() / MILLISECONDS_IN_SECOND; - } - - errors[id] = ""; - entity[id] = value; - this.setState({ entity, errors }); - } - - handleChangeMember(ev) { - const { onAddMember, onRemoveMember, entity } = this.props; - - const currentMembers = this.state.entity.members; - const currentMemberIds = currentMembers.map((m) => m.id); - const newMembers = ev.target.value; - const newMemberIds = newMembers.map((m) => m.id); - - newMembers.forEach((mem) => { - if (!currentMemberIds.includes(mem.id)) { - onAddMember(entity.id, mem); - } - }); - - currentMemberIds.forEach((memId) => { - if (!newMemberIds.includes(memId)) { - onRemoveMember(entity.id, memId); - } - }); - } - - handleUploadHeaderImage(file) { - const formData = new FormData(); - formData.append("file", file); - this.props.onAttachImage(this.state.entity, formData, "header_image"); - } - - handleUploadHeaderMobileImage(file) { - const formData = new FormData(); - formData.append("file", file); - this.props.onAttachImage( - this.state.entity, - formData, - "header_mobile_image" - ); - } - - handleUploadSideImage(file) { - const formData = new FormData(); - formData.append("file", file); - this.props.onAttachImage(this.state.entity, formData, "side_image"); - } - - handleUploadCarouselImage(file) { - const formData = new FormData(); - formData.append("file", file); - this.props.onAttachImage(this.state.entity, formData, "carousel"); - } - - handleRemoveFile(picAttr) { - const entity = { ...this.state.entity }; - entity[picAttr] = ""; - this.setState({ entity }); - this.props.onRemoveImage(entity, picAttr); - } - - toggleSection(section, ev) { - const { showSection } = this.state; - const newShowSection = showSection === section ? "main" : section; - ev.preventDefault(); - - this.setState({ showSection: newShowSection }); - } - - handlePageChange(page, collection) { - const { entity } = this.state; - switch (collection) { - case "ads": - { - const { - entity: { - ads_collection: { perPage } - } - } = this.state; - this.props.getSponsorAdvertisements(entity.id, page, perPage); - } - break; - case "materials": - { - const { - entity: { - materials_collection: { perPage } - } - } = this.state; - this.props.getSponsorMaterials(entity.id, page, perPage); - } - break; - case "social_networks": - { - const { - entity: { - social_networks_collection: { perPage } - } - } = this.state; - this.props.getSponsorSocialNetworks(entity.id, page, perPage); - } - break; - default: - break; - } - } - - handleAdvertisementAdd(ev) { - const { entity, history } = this.props; - ev.preventDefault(); - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/ads/new` - ); - } - - handleAdvertisementEdit(advertisementId) { - const { entity, history } = this.props; - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/ads/${advertisementId}` - ); - } - - handleMaterialAdd(ev) { - const { entity, history } = this.props; - ev.preventDefault(); - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/materials/new` - ); - } - - handleMaterialEdit(materialId) { - const { entity, history } = this.props; - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/materials/${materialId}` - ); - } - - handleSocialNetworkAdd(ev) { - const { entity, history } = this.props; - ev.preventDefault(); - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/social-networks/new` - ); - } - - handleSocialNetworkEdit(socialNetworkId) { - const { entity, history } = this.props; - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/social-networks/${socialNetworkId}` - ); - } - - handleExtraQuestionAdd(ev) { - const { entity, history } = this.props; - ev.preventDefault(); - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/extra-questions/new` - ); - } - - handleExtraQuestionEdit(extraQuestionId) { - const { entity, history } = this.props; - history.push( - `/app/summits/${entity.summit_id}/sponsors/${entity.id}/extra-questions/${extraQuestionId}` - ); - } - - handleSubmit(ev) { - ev.preventDefault(); - this.props.onSubmit(this.state.entity); - } - - hasErrors(field) { - const { errors } = this.state; - if (field in errors) { - return errors[field]; - } - - return ""; - } - - handleDelete(element, collection) { - const { entity } = this.state; - const { onAdvertisementDelete, onMaterialDelete, onSocialNetworkDelete } = - this.props; - - const deleteElement = entity[`${collection}_collection`][ - `${collection}` - ].find((s) => s.id === element); - - Swal.fire({ - title: T.translate("general.are_you_sure"), - text: `${T.translate(`edit_sponsor.remove_warning_${collection}`)} ${ - deleteElement.name || deleteElement.link - }`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }).then((result) => { - if (result.value) { - if (collection === "ads") { - onAdvertisementDelete(element); - } else if (collection === "materials") { - onMaterialDelete(element); - } else { - onSocialNetworkDelete(element); - } - } - }); - } - - handleColumnsChange(ev) { - const { entity, upsertSponsorLeadReportSettings } = this.props; - const { value } = ev.target; - const newColumns = value; - this.setState({ ...this.state, selectedColumns: newColumns }); - upsertSponsorLeadReportSettings(entity.id, newColumns); - } - - render() { - const { entity, showSection } = this.state; - const { - currentSummit, - onCreateCompany, - canEditSponsors, - canEditSponsorExtraQuestions, - canEditLeadReportSettings, - availableLeadReportColumns - } = this.props; - - const advertisement_columns = [ - { columnKey: "link", value: T.translate("edit_sponsor.link") }, - { columnKey: "text", value: T.translate("edit_sponsor.text") }, - { columnKey: "alt", value: T.translate("edit_sponsor.alt") } - ]; - - const advertisement_table_options = { - actions: { - edit: { onClick: this.handleAdvertisementEdit }, - delete: { onClick: (id) => this.handleDelete(id, "ads") } - } - }; - - const materials_columns = [ - { columnKey: "link", value: T.translate("edit_summit.link") }, - { columnKey: "name", value: T.translate("edit_summit.name") }, - { columnKey: "type", value: T.translate("edit_sponsor.type") } - ]; - - const materials_table_options = { - actions: { - edit: { onClick: this.handleMaterialEdit }, - delete: { onClick: (id) => this.handleDelete(id, "materials") } - } - }; - - const social_networks_columns = [ - { columnKey: "link", value: T.translate("edit_sponsor.link") }, - { - columnKey: "icon_css_class", - value: T.translate("edit_sponsor.icon_css_class") - }, - { columnKey: "is_enabled", value: T.translate("edit_summit.enabled") } - ]; - - const social_networks_table_options = { - actions: { - edit: { onClick: this.handleSocialNetworkEdit }, - delete: { onClick: (id) => this.handleDelete(id, "social_networks") } - } - }; - - return ( -
- -
-
- - -
-
-
- - -
-
-
-
-
- - -
-
- {entity.id !== 0 && canEditSponsors && ( -
-
- - - member.hasOwnProperty("email") - ? `${member.first_name} ${member.last_name} (${member.email})` - : `${member.first_name} ${member.last_name} (${member.id})` - } - /> -
-
- )} - - {entity.id !== 0 && canEditSponsors && ( - <> -
- -
-
- - -
-
-
-
- -