diff --git a/src/actions/__tests__/form-template-item-actions.test.js b/src/actions/__tests__/form-template-item-actions.test.js new file mode 100644 index 000000000..280cfa754 --- /dev/null +++ b/src/actions/__tests__/form-template-item-actions.test.js @@ -0,0 +1,42 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { getFormTemplateItem } from "../form-template-item-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"), + getRequest: jest.fn() +})); + +describe("getFormTemplateItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("still dispatches STOP_LOADING when the request fails", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("API error")) + ); + + const store = mockStore({}); + + await store.dispatch(getFormTemplateItem(123, 1)).catch(() => {}); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("STOP_LOADING"); + }); +}); diff --git a/src/actions/__tests__/inventory-item-actions.test.js b/src/actions/__tests__/inventory-item-actions.test.js new file mode 100644 index 000000000..80da2df86 --- /dev/null +++ b/src/actions/__tests__/inventory-item-actions.test.js @@ -0,0 +1,42 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { getInventoryItem } from "../inventory-item-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"), + getRequest: jest.fn() +})); + +describe("getInventoryItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("still dispatches STOP_LOADING when the request fails", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("API error")) + ); + + const store = mockStore({}); + + await store.dispatch(getInventoryItem(1)).catch(() => {}); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("STOP_LOADING"); + }); +}); diff --git a/src/actions/__tests__/inventory-shared-actions.test.js b/src/actions/__tests__/inventory-shared-actions.test.js new file mode 100644 index 000000000..e26afb3b2 --- /dev/null +++ b/src/actions/__tests__/inventory-shared-actions.test.js @@ -0,0 +1,43 @@ +import { deleteRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { deleteFile } from "../inventory-shared-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() +})); + +describe("deleteFile", () => { + const dispatch = jest.fn(); + const settings = { + url: "http://test-api/images", + deletedActionName: "SOME_FILE_DELETED" + }; + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("resolves true when the delete request succeeds", async () => { + deleteRequest.mockImplementation(() => () => () => Promise.resolve()); + + const result = await deleteFile(10, settings)(dispatch); + + expect(result).toBe(true); + }); + + it("resolves false (without rejecting) when the delete request fails", async () => { + deleteRequest.mockImplementation( + () => () => () => Promise.reject(new Error("boom")) + ); + + const result = await deleteFile(10, settings)(dispatch); + + expect(result).toBe(false); + }); +}); diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 34bb57728..346693c7c 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -6,13 +6,22 @@ import thunk from "redux-thunk"; import flushPromises from "flush-promises"; import { getRequest, - putRequest + postRequest, + putRequest, + deleteRequest } from "openstack-uicore-foundation/lib/utils/actions"; import { getSponsorForms, normalizeFormTemplate, normalizeSponsorCustomizedForm, - updateFormTemplateTiers + updateFormTemplateTiers, + removeItemFile, + removeSponsorCustomizedFormItemImages, + saveSponsorFormItem, + updateSponsorFormItem, + saveSponsorFormManagedItem, + getSponsorFormItem, + getSponsorFormManagedItem } from "../sponsor-forms-actions"; import * as methods from "../../utils/methods"; @@ -21,7 +30,8 @@ jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), postRequest: jest.fn(), getRequest: jest.fn(), - putRequest: jest.fn() + putRequest: jest.fn(), + deleteRequest: jest.fn() })); describe("Sponsor Forms Actions", () => { @@ -288,4 +298,442 @@ describe("Sponsor Forms Actions", () => { ); }); }); + + describe("removeItemFile", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + deleteRequest.mockImplementation( + (requestActionCreator, receiveAction) => () => (dispatch) => { + if (typeof receiveAction === "function") { + dispatch(receiveAction({ response: {} })); + } else { + dispatch(receiveAction); + } + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("dispatches SPONSOR_FORM_ITEM_FILE_DELETED with fileId and itemId", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + store.dispatch(removeItemFile(7, 99, 555)); + await flushPromises(); + + expect(deleteRequest).toHaveBeenCalledWith( + null, + { + type: "SPONSOR_FORM_ITEM_FILE_DELETED", + payload: { fileId: 555, itemId: 99 } + }, + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/99/images/555`, + null, + expect.any(Function) + ); + + const dispatched = store + .getActions() + .find((a) => a.type === "SPONSOR_FORM_ITEM_FILE_DELETED"); + expect(dispatched.payload).toEqual({ fileId: 555, itemId: 99 }); + }); + }); + + describe("removeSponsorCustomizedFormItemImages", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + deleteRequest.mockImplementation( + (requestActionCreator, receiveAction) => () => (dispatch) => { + if (typeof receiveAction === "function") { + dispatch(receiveAction({ response: {} })); + } else { + dispatch(receiveAction); + } + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("dispatches SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED with fileId and itemId", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } }, + currentSponsorState: { entity: { id: 5 } } + }); + + store.dispatch(removeSponsorCustomizedFormItemImages(7, 99, 555)); + await flushPromises(); + + expect(deleteRequest).toHaveBeenCalledWith( + null, + { + type: "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED", + payload: { fileId: 555, itemId: 99 } + }, + `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/5/sponsor-forms/7/items/99/images/555`, + null, + expect.any(Function) + ); + + const dispatched = store + .getActions() + .find((a) => a.type === "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED"); + expect(dispatched.payload).toEqual({ fileId: 555, itemId: 99 }); + }); + }); + + describe("saveSponsorFormItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + postRequest.mockImplementation( + () => () => () => Promise.resolve({ response: { id: 100 } }) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("omits images from the create request body and POSTs new uploads to the images subresource", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + const entity = { + name: "Item", + images: [{ file_path: "data:image/png;base64,AAA" }], + meta_fields: [] + }; + + await store.dispatch(saveSponsorFormItem(7, entity)); + await flushPromises(); + + expect(postRequest).toHaveBeenNthCalledWith( + 1, + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items`, + expect.not.objectContaining({ images: expect.anything() }), + expect.any(Function) + ); + + // The created item's id (100, from the mocked response) is used to + // POST the new upload to the images subresource - the only path that + // actually materializes the file server-side. + expect(postRequest).toHaveBeenNthCalledWith( + 2, + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100/images`, + { file_path: "data:image/png;base64,AAA" }, + expect.any(Function), + { file_path: "data:image/png;base64,AAA" } + ); + }); + }); + + describe("updateSponsorFormItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + putRequest.mockImplementation( + () => () => () => Promise.resolve({ response: { id: 100 } }) + ); + // Clear call history left by the sibling saveSponsorFormItem tests - + // this describe's assertions inspect postRequest's call log directly. + postRequest.mockClear(); + postRequest.mockImplementation( + () => () => () => Promise.resolve({ response: {} }) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("omits persisted images from the update request body so they are never round-tripped", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + const entity = { + id: 100, + name: "Item", + images: [{ id: 5, file_url: "https://cdn/a.png" }], + meta_fields: [] + }; + + await store.dispatch(updateSponsorFormItem(7, entity)); + await flushPromises(); + + expect(putRequest).toHaveBeenCalledWith( + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100`, + expect.not.objectContaining({ images: expect.anything() }), + expect.any(Function) + ); + + // The image already has an id (persisted) - it must never be resent, + // since the backend replaces the whole collection on update and can't + // preserve a cloned-from-inventory image's external id. + const hitImagesEndpoint = postRequest.mock.calls.some( + ([, , url]) => url && url.includes("/images") + ); + expect(hitImagesEndpoint).toBe(false); + }); + + it("POSTs new (id-less) uploads to the images subresource after the update succeeds", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + const entity = { + id: 100, + name: "Item", + images: [ + { id: 5, file_url: "https://cdn/a.png" }, + { file_path: "data:image/png;base64,BBB" } + ], + meta_fields: [] + }; + + await store.dispatch(updateSponsorFormItem(7, entity)); + await flushPromises(); + + const imagesCalls = postRequest.mock.calls.filter( + ([, , url]) => url && url.includes("/images") + ); + expect(imagesCalls).toHaveLength(1); + expect(postRequest).toHaveBeenCalledWith( + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100/images`, + { file_path: "data:image/png;base64,BBB" }, + expect.any(Function), + { file_path: "data:image/png;base64,BBB" } + ); + + // The received-action creator must produce SPONSOR_FORM_ITEM_IMAGE_ADDED + // carrying {response, itemId} - not the item-shaped SPONSOR_FORM_ITEM_UPDATED, + // which a reducer could misread as an item and corrupt an unrelated one. + const [, receivedActionCreator] = imagesCalls[0]; + const uploadedImage = { id: 55, file_url: "https://cdn/new.png" }; + expect(receivedActionCreator({ response: uploadedImage })).toEqual({ + type: "SPONSOR_FORM_ITEM_IMAGE_ADDED", + payload: { response: uploadedImage, itemId: 100 } + }); + }); + }); + + describe("saveSponsorFormManagedItem", () => { + const mockStore = configureStore([thunk]); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + putRequest.mockClear(); + putRequest.mockImplementation( + () => () => () => Promise.resolve({ response: {} }) + ); + postRequest.mockClear(); + postRequest.mockImplementation( + () => () => () => Promise.resolve({ response: { id: 200 } }) + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("omits persisted images from the managed-item update request body", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } }, + currentSponsorState: { entity: { id: 7 } } + }); + + const entity = { + id: 100, + name: "Item", + images: [{ id: 5, file_url: "https://cdn/a.png" }], + meta_fields: [] + }; + + await store.dispatch(saveSponsorFormManagedItem(9, entity)); + await flushPromises(); + + expect(putRequest).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/7/sponsor-forms/9/items/100`, + expect.not.objectContaining({ images: expect.anything() }), + expect.any(Function), + entity + ); + + // The image already has an id (persisted) - it must never be resent, + // since the update endpoint replaces the whole collection. + const hitImagesEndpoint = postRequest.mock.calls.some( + ([, , url]) => url && url.includes("/images") + ); + expect(hitImagesEndpoint).toBe(false); + }); + + it("POSTs new (id-less) uploads to the images subresource after the managed-item update succeeds", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } }, + currentSponsorState: { entity: { id: 7 } } + }); + + const entity = { + id: 100, + name: "Item", + images: [ + { id: 5, file_url: "https://cdn/a.png" }, + { file_path: "data:image/png;base64,CCC" } + ], + meta_fields: [] + }; + + await store.dispatch(saveSponsorFormManagedItem(9, entity)); + await flushPromises(); + + expect(postRequest).toHaveBeenCalledWith( + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/7/sponsor-forms/9/items/100/images`, + { file_path: "data:image/png;base64,CCC" }, + expect.any(Function), + { file_path: "data:image/png;base64,CCC" } + ); + + // Must be its own SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED type, not the + // item-shaped SPONSOR_FORM_MANAGED_ITEM_UPDATED - that reducer case + // reads payload.response as an item and would corrupt whichever + // unrelated list item happens to share the uploaded image's id. + const [, receivedActionCreator] = postRequest.mock.calls.find( + ([, , url]) => url && url.includes("/images") + ); + const uploadedImage = { id: 55, file_url: "https://cdn/new.png" }; + expect(receivedActionCreator({ response: uploadedImage })).toEqual({ + type: "SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED", + payload: { response: uploadedImage, itemId: 100 } + }); + }); + + it("omits images from the managed-item create request body and POSTs new uploads using the created item's id", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } }, + currentSponsorState: { entity: { id: 7 } } + }); + + const entity = { + name: "Item", + images: [{ file_path: "data:image/png;base64,DDD" }], + meta_fields: [] + }; + + await store.dispatch(saveSponsorFormManagedItem(9, entity)); + await flushPromises(); + + expect(postRequest).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/7/sponsor-forms/9/items`, + expect.not.objectContaining({ images: expect.anything() }), + expect.any(Function), + entity + ); + + expect(postRequest).toHaveBeenNthCalledWith( + 2, + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/7/sponsor-forms/9/items/200/images`, + { file_path: "data:image/png;base64,DDD" }, + expect.any(Function), + { file_path: "data:image/png;base64,DDD" } + ); + }); + }); + + describe("getSponsorFormItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("still dispatches STOP_LOADING when the request fails", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("API error")) + ); + + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + await store.dispatch(getSponsorFormItem(7, 100)).catch(() => {}); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("STOP_LOADING"); + }); + }); + + describe("getSponsorFormManagedItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("still dispatches STOP_LOADING when the request fails", async () => { + getRequest.mockImplementation( + () => () => () => Promise.reject(new Error("API error")) + ); + + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } }, + currentSponsorState: { entity: { id: 7 } } + }); + + await store.dispatch(getSponsorFormManagedItem(9, 100)).catch(() => {}); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("STOP_LOADING"); + }); + }); }); diff --git a/src/actions/form-template-item-actions.js b/src/actions/form-template-item-actions.js index fe6d3510b..261e7be0b 100644 --- a/src/actions/form-template-item-actions.js +++ b/src/actions/form-template-item-actions.js @@ -137,7 +137,7 @@ export const getFormTemplateItem = createAction(RECEIVE_FORM_TEMPLATE_ITEM), `${window.INVENTORY_API_BASE_URL}/api/v1/form-templates/${formTemplateId}/items/${formTemplateItemId}`, snackbarErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -387,7 +387,8 @@ export const deleteItemImage = ( ) => { const settings = { url: `${window.INVENTORY_API_BASE_URL}/api/v1/form-templates/${formTemplateId}/items/${formTemplateItemId}/images`, - deletedActionName: FORM_TEMPLATE_ITEM_IMAGE_DELETED + deletedActionName: FORM_TEMPLATE_ITEM_IMAGE_DELETED, + payload: { formTemplateItemId } }; return deleteFile(imageId, settings); }; diff --git a/src/actions/inventory-item-actions.js b/src/actions/inventory-item-actions.js index 99dae7462..d47e9beae 100644 --- a/src/actions/inventory-item-actions.js +++ b/src/actions/inventory-item-actions.js @@ -159,7 +159,7 @@ export const getInventoryItem = (inventoryItemId) => async (dispatch) => { createAction(RECEIVE_INVENTORY_ITEM), `${window.INVENTORY_API_BASE_URL}/api/v1/inventory-items/${inventoryItemId}`, snackbarErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -351,7 +351,8 @@ const saveItemImages = (inventoryItem) => { export const deleteInventoryItemImage = (inventoryItemId, imageId) => { const settings = { url: `${window.INVENTORY_API_BASE_URL}/api/v1/inventory-items/${inventoryItemId}/images`, - deletedActionName: INVENTORY_ITEM_IMAGE_DELETED + deletedActionName: INVENTORY_ITEM_IMAGE_DELETED, + payload: { inventoryItemId } }; return deleteFile(imageId, settings); }; diff --git a/src/actions/inventory-shared-actions.js b/src/actions/inventory-shared-actions.js index 69da56874..d959fb35c 100644 --- a/src/actions/inventory-shared-actions.js +++ b/src/actions/inventory-shared-actions.js @@ -234,7 +234,11 @@ export const saveFiles = if (file.id) { return putRequest( null, - createAction(settings.updatedActionName), + ({ response }) => + createAction(settings.updatedActionName)({ + response, + ...settings.payload + }), `${settings.url}${file.id}/`, file, snackbarErrorHandler, @@ -243,7 +247,11 @@ export const saveFiles = } return postRequest( null, - createAction(settings.addedActionName), + ({ response }) => + createAction(settings.addedActionName)({ + response, + ...settings.payload + }), settings.url, file, snackbarErrorHandler, @@ -261,7 +269,7 @@ export const deleteFile = if (!settingsValidation.isValid) { console.error(settingsValidation.error); - return; + return false; } const accessToken = await getAccessTokenSafely(); @@ -273,13 +281,16 @@ export const deleteFile = return deleteRequest( null, - createAction(settings.deletedActionName)({ fileId }), + createAction(settings.deletedActionName)({ fileId, ...settings.payload }), `${settings.url}/${fileId}`, null, - snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + settings.errorHandler ?? snackbarErrorHandler + )(params)(dispatch) + .then(() => true) + .catch(() => false) + .finally(() => { + dispatch(stopLoading()); + }); }; /* ************************************ ARCHIVE ************************************ */ diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 5492628e7..f524f2c6a 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -31,6 +31,7 @@ import { getAccessTokenSafely, normalizeSelectAllField } from "../utils/methods"; +import { saveFiles, deleteFile } from "./inventory-shared-actions"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -97,7 +98,10 @@ export const SPONSOR_CUSTOMIZED_FORM_ITEMS_ADDED = "SPONSOR_CUSTOMIZED_FORM_ITEMS_ADDED"; export const RESET_SPONSOR_FORM_MANAGED_ITEM = "RESET_SPONSOR_FORM_MANAGED_ITEM"; - +export const SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED = + "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED"; +export const SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED = + "SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED"; // ITEMS export const REQUEST_SPONSOR_FORM_ITEMS = "REQUEST_SPONSOR_FORM_ITEMS"; export const RECEIVE_SPONSOR_FORM_ITEMS = "RECEIVE_SPONSOR_FORM_ITEMS"; @@ -105,8 +109,8 @@ export const RECEIVE_SPONSOR_FORM_ITEM = "RECEIVE_SPONSOR_FORM_ITEM"; export const SPONSOR_FORM_ITEM_UPDATED = "SPONSOR_FORM_ITEM_UPDATED"; export const RESET_SPONSOR_FORM_ITEM = "RESET_SPONSOR_FORM_ITEM"; export const SPONSOR_FORM_ITEM_DELETED = "SPONSOR_FORM_ITEM_DELETED"; -export const SPONSOR_FORM_ITEM_IMAGES_UPDATED = - "SPONSOR_FORM_ITEM_IMAGES_UPDATED"; +export const SPONSOR_FORM_ITEM_FILE_DELETED = "SPONSOR_FORM_ITEM_FILE_DELETED"; +export const SPONSOR_FORM_ITEM_IMAGE_ADDED = "SPONSOR_FORM_ITEM_IMAGE_ADDED"; export const SPONSOR_FORM_ITEMS_ADDED = "SPONSOR_FORM_ITEMS_ADDED"; export const SPONSOR_FORM_ITEM_ARCHIVED = "SPONSOR_FORM_ITEM_ARCHIVED"; export const SPONSOR_FORM_ITEM_UNARCHIVED = "SPONSOR_FORM_ITEM_UNARCHIVED"; @@ -849,7 +853,8 @@ export const getSponsorCustomizedFormItems = const params = { page, per_page: perPage, - access_token: accessToken + access_token: accessToken, + expand: "images" }; filter.push(`is_archived==${showArchived ? 1 : 0}`); @@ -1198,7 +1203,7 @@ export const getSponsorFormItem = createAction(RECEIVE_SPONSOR_FORM_ITEM), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${itemId}`, authErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -1231,35 +1236,19 @@ export const deleteSponsorFormItem = }); }; -const saveItemImages = - (formId, formItemId, images) => async (dispatch, getState) => { +export const removeItemFile = + (formId, formItemId, fileId) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - const accessToken = await getAccessTokenSafely(); - const params = { access_token: accessToken }; - const promises = images.map((file) => { - if (file.id) { - return putRequest( - null, - createAction(SPONSOR_FORM_ITEM_IMAGES_UPDATED), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images/${file.id}`, - file, - authErrorHandler, - file - )(params)(dispatch); - } - return postRequest( - null, - createAction(SPONSOR_FORM_ITEM_IMAGES_UPDATED), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, - file, - authErrorHandler, - file - )(params)(dispatch); - }); + const settings = { + url: `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, + deletedActionName: SPONSOR_FORM_ITEM_FILE_DELETED, + payload: { itemId: formItemId }, + errorHandler: snackbarErrorHandler + }; - return Promise.all(promises); + return deleteFile(fileId, settings)(dispatch); }; export const saveSponsorFormItem = @@ -1271,7 +1260,8 @@ export const saveSponsorFormItem = dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + expand: "images" }; const normalizedEntity = normalizeItem(entity); @@ -1283,28 +1273,20 @@ export const saveSponsorFormItem = normalizedEntity, snackbarErrorHandler )(params)(dispatch) - .then(({ response }) => { - const promises = [Promise.resolve(0)]; - - if (normalizedEntity.images?.length > 0) { - const savingImages = saveItemImages( - formId, - response.id, - normalizedEntity.images - )(dispatch, getState); - - promises.push(savingImages); - } - - return Promise.all(promises).then(() => { + .then(({ response }) => + saveNewItemImages( + formId, + response.id, + entity.images + )(dispatch, getState).then(() => { dispatch( snackbarSuccessHandler({ title: T.translate("general.success"), html: T.translate("sponsor_form_item_list.edit_item.created") }) ); - }); - }) + }) + ) .finally(() => { dispatch(stopLoading()); }); @@ -1319,7 +1301,8 @@ export const updateSponsorFormItem = dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + expand: "images" }; const normalizedEntity = normalizeItem(entity); @@ -1331,28 +1314,20 @@ export const updateSponsorFormItem = normalizedEntity, snackbarErrorHandler )(params)(dispatch) - .then(() => { - const promises = [Promise.resolve(0)]; - - if (normalizedEntity.images?.length > 0) { - const savingImages = saveItemImages( - formId, - entity.id, - normalizedEntity.images - )(dispatch, getState); - - promises.push(savingImages); - } - - return Promise.all(promises).then(() => { + .then(() => + saveNewItemImages( + formId, + entity.id, + entity.images + )(dispatch, getState).then(() => { dispatch( snackbarSuccessHandler({ title: T.translate("general.success"), html: T.translate("sponsor_form_item_list.edit_item.updated") }) ); - }); - }) + }) + ) .catch((err) => { throw err; }) @@ -1439,17 +1414,19 @@ const normalizeItem = (entity) => { meta_fields, quantity_limit_per_show, quantity_limit_per_sponsor, - default_quantity, - images + default_quantity } = entity; if (meta_fields) { normalizedEntity.meta_fields = meta_fields.filter((mf) => !!mf.name); } - if (images) { - normalizedEntity.images = images?.filter((img) => img.file_path); - } + // Images are never round-tripped inline: the item add/update endpoint's + // nested-images path only clones the file name (no S3 copy) and, on + // update, replaces the whole collection - wiping cloned-from-inventory + // images whose id it can't preserve. New uploads are persisted separately + // via saveNewItemImages once the item itself is saved. + delete normalizedEntity.images; if (quantity_limit_per_show === "") delete normalizedEntity.quantity_limit_per_show; @@ -1460,6 +1437,26 @@ const normalizeItem = (entity) => { return normalizedEntity; }; +const saveNewItemImages = + (formId, formItemId, images = []) => + async (dispatch, getState) => { + const newImages = images.filter((img) => img.file_path); + + if (newImages.length === 0) return Promise.resolve(); + + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + + const settings = { + url: `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, + addedActionName: SPONSOR_FORM_ITEM_IMAGE_ADDED, + updatedActionName: SPONSOR_FORM_ITEM_IMAGE_ADDED, + payload: { itemId: formItemId } + }; + + return saveFiles(newImages, settings)(dispatch); + }; + export const addInventoryItems = (formId, itemIds) => async (dispatch, getState) => { const { currentSummitState } = getState(); @@ -1509,6 +1506,7 @@ export const saveSponsorFormManagedItem = dispatch(startLoading()); const params = { + expand: "images", access_token: accessToken }; @@ -1523,16 +1521,22 @@ export const saveSponsorFormManagedItem = snackbarErrorHandler, entity )(params)(dispatch) - .then(() => { - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate( - "edit_sponsor.forms_tab.form_manage_items.item_updated" - ) - }) - ); - }) + .then(() => + saveNewManagedItemImages( + formId, + entity.id, + entity.images + )(dispatch, getState).then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate( + "edit_sponsor.forms_tab.form_manage_items.item_updated" + ) + }) + ); + }) + ) .finally(() => { dispatch(stopLoading()); }); @@ -1554,9 +1558,15 @@ export const saveSponsorFormManagedItem = snackbarErrorHandler, entity )(params)(dispatch) - .then(() => { - dispatch(snackbarSuccessHandler(successMessage)); - }) + .then(({ response }) => + saveNewManagedItemImages( + formId, + response.id, + entity.images + )(dispatch, getState).then(() => { + dispatch(snackbarSuccessHandler(successMessage)); + }) + ) .finally(() => { dispatch(stopLoading()); }); @@ -1571,13 +1581,39 @@ const normalizeManagedItem = (entity) => { normalizedEntity.meta_fields = normalizedEntity.meta_fields?.filter( (mf) => mf.name ); - normalizedEntity.images = normalizedEntity.images?.filter( - (img) => img.file_path - ); + + // Images are never round-tripped inline here either - see normalizeItem's + // comment on the sibling (non-customized) save path. New uploads are + // persisted separately via saveNewManagedItemImages once the item itself + // is saved. + delete normalizedEntity.images; return normalizedEntity; }; +const saveNewManagedItemImages = + (formId, formItemId, images = []) => + async (dispatch, getState) => { + const newImages = images.filter((img) => img.file_path); + + if (newImages.length === 0) return Promise.resolve(); + + const { currentSummitState, currentSponsorState } = getState(); + const { currentSummit } = currentSummitState; + const { + entity: { id: sponsorId } + } = currentSponsorState; + + const settings = { + url: `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images`, + addedActionName: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + updatedActionName: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + payload: { itemId: formItemId } + }; + + return saveFiles(newImages, settings)(dispatch); + }; + export const deleteSponsorFormManagedItem = (formId, itemId) => async (dispatch, getState) => { const { currentSummitState, currentSponsorState } = getState(); @@ -1634,7 +1670,7 @@ export const getSponsorFormManagedItem = createAction(RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEM), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${itemId}`, authErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -1752,3 +1788,21 @@ export const unarchiveSponsorCustomizedFormItem = .catch(() => {}) .finally(() => dispatch(stopLoading())); }; + +export const removeSponsorCustomizedFormItemImages = + (formId, formItemId, fileId) => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState } = getState(); + const { currentSummit } = currentSummitState; + const { + entity: { id: sponsorId } + } = currentSponsorState; + + const settings = { + url: `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images`, + deletedActionName: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, + payload: { itemId: formItemId }, + errorHandler: snackbarErrorHandler + }; + + return deleteFile(fileId, settings)(dispatch); + }; diff --git a/src/pages/sponsors-global/form-templates/__tests__/form-template-item-list-page.test.js b/src/pages/sponsors-global/form-templates/__tests__/form-template-item-list-page.test.js index 36b926b73..ff042d08b 100644 --- a/src/pages/sponsors-global/form-templates/__tests__/form-template-item-list-page.test.js +++ b/src/pages/sponsors-global/form-templates/__tests__/form-template-item-list-page.test.js @@ -1,14 +1,22 @@ import React from "react"; -import { waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; import FormTemplateItemListPage from "../form-template-item-list-page"; import { renderWithRedux } from "../../../../utils/test-utils"; -import { getFormTemplateItems } from "../../../../actions/form-template-item-actions"; +import { + getFormTemplateItems, + getFormTemplateItem, + deleteItemImage +} from "../../../../actions/form-template-item-actions"; import { getFormTemplate } from "../../../../actions/form-template-actions"; import { DEFAULT_CURRENT_PAGE } from "../../../../utils/constants"; jest.mock("../../../../actions/form-template-item-actions", () => ({ ...jest.requireActual("../../../../actions/form-template-item-actions"), - getFormTemplateItems: jest.fn(() => () => Promise.resolve()) + getFormTemplateItems: jest.fn(() => () => Promise.resolve()), + getFormTemplateItem: jest.fn(() => () => Promise.resolve()), + deleteItemImage: jest.fn(() => () => Promise.resolve(true)) })); jest.mock("../../../../actions/form-template-actions", () => ({ @@ -16,6 +24,31 @@ jest.mock("../../../../actions/form-template-actions", () => ({ getFormTemplate: jest.fn(() => () => Promise.resolve()) })); +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onEdit }) => ( +
+ {data.map((row) => ( + + ))} +
+ ) +})); + +jest.mock( + "../sponsor-inventory-popup", + () => + function MockSponsorInventoryDialog({ onImageDeleted }) { + return ( + + ); + } +); + describe("FormTemplateItemListPage", () => { const formTemplateId = 123; const initialPage = 2; @@ -23,9 +56,12 @@ describe("FormTemplateItemListPage", () => { const order = "name"; const orderDir = 1; const showArchived = false; - const buildInitialState = () => ({ + const buildInitialState = ({ + formTemplateItems = [], + currentFormTemplateItem = {} + } = {}) => ({ currentFormTemplateItemListState: { - formTemplateItems: [], + formTemplateItems, term: "", order, orderDir, @@ -40,7 +76,7 @@ describe("FormTemplateItemListPage", () => { errors: {} }, currentFormTemplateItemState: { - entity: {}, + entity: currentFormTemplateItem, errors: {} } }); @@ -74,4 +110,64 @@ describe("FormTemplateItemListPage", () => { }); }); }); + + describe("image removal guard", () => { + const openItemDialog = async () => { + const user = userEvent.setup(); + await user.click(screen.getByText("edit-row-1")); + await waitFor(() => expect(getFormTemplateItem).toHaveBeenCalled()); + await user.click(screen.getByText("mock-remove-item-image")); + }; + + test.each([ + ["an unsaved entity (no id)", {}, null], + ["a persisted item, delete succeeds", { id: 55 }, true], + ["a persisted item, delete fails", { id: 55 }, false] + ])( + "removing an image for %s", + async (_label, currentFormTemplateItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + deleteItemImage.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + + renderWithRedux( + , + { + initialState: buildInitialState({ + formTemplateItems: [{ id: 1, code: "A", name: "Item A" }], + currentFormTemplateItem + }) + } + ); + + await openItemDialog(); + + if (deleteSucceeds === null) { + expect(deleteItemImage).not.toHaveBeenCalled(); + return; + } + + expect(deleteItemImage).toHaveBeenCalledWith( + formTemplateId, + currentFormTemplateItem.id, + 999 + ); + + if (deleteSucceeds) { + await flushPromises(); + expect(getFormTemplateItem).toHaveBeenCalledTimes(1); + } else { + await waitFor(() => + expect(getFormTemplateItem).toHaveBeenCalledTimes(2) + ); + expect(getFormTemplateItem).toHaveBeenLastCalledWith( + formTemplateId, + currentFormTemplateItem.id + ); + } + } + ); + }); }); diff --git a/src/pages/sponsors-global/form-templates/form-template-item-list-page.js b/src/pages/sponsors-global/form-templates/form-template-item-list-page.js index 1acff070a..69e339a6e 100644 --- a/src/pages/sponsors-global/form-templates/form-template-item-list-page.js +++ b/src/pages/sponsors-global/form-templates/form-template-item-list-page.js @@ -187,6 +187,14 @@ const FormTemplateItemListPage = ({ ).catch(() => {}) ); + const handleRemoveImage = (imageId) => { + if (!currentFormTemplateItem?.id) return; + const itemId = currentFormTemplateItem.id; + deleteItemImage(formTemplateId, itemId, imageId).then((success) => { + if (!success) getFormTemplateItem(formTemplateId, itemId).catch(() => {}); + }); + }; + const columns = [ { columnKey: "code", @@ -319,7 +327,7 @@ const FormTemplateItemListPage = ({ onClose={() => setShowInventoryItemModal(false)} onMetaFieldTypeDeleted={deleteItemMetaFieldType} onMetaFieldTypeValueDeleted={deleteItemMetaFieldTypeValue} - onImageDeleted={deleteItemImage} + onImageDeleted={handleRemoveImage} /> )} diff --git a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js index 8e4668f82..2fadffba1 100644 --- a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js +++ b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js @@ -43,6 +43,7 @@ const SponsorItemDialog = ({ onSave, onMetaFieldTypeDeleted, onMetaFieldTypeValueDeleted, + onImageDeleted, entity: initialEntity }) => { const [isSaving, setIsSaving] = useState(false); @@ -93,6 +94,10 @@ const SponsorItemDialog = ({ onClose(); }; + const handleIDeleteImage = (id) => { + if (id && onImageDeleted) onImageDeleted(id); + }; + return ( @@ -263,6 +269,7 @@ SponsorItemDialog.propTypes = { onSave: PropTypes.func.isRequired, onMetaFieldTypeDeleted: PropTypes.func, onMetaFieldTypeValueDeleted: PropTypes.func, + onImageDeleted: PropTypes.func, entity: PropTypes.object }; diff --git a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js new file mode 100644 index 000000000..51bdb54a6 --- /dev/null +++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js @@ -0,0 +1,120 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; +import InventoryListPage from "../inventory-list-page"; +import { renderWithRedux } from "../../../../utils/test-utils"; +import { + getInventoryItem, + deleteInventoryItemImage +} from "../../../../actions/inventory-item-actions"; + +jest.mock("../../../../actions/inventory-item-actions", () => ({ + ...jest.requireActual("../../../../actions/inventory-item-actions"), + getInventoryItems: jest.fn(() => () => Promise.resolve()), + getInventoryItem: jest.fn(() => () => Promise.resolve()), + deleteInventoryItemImage: jest.fn(() => () => Promise.resolve(true)) +})); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onEdit }) => ( +
+ {data.map((row) => ( + + ))} +
+ ) +})); + +jest.mock( + "../../form-templates/sponsor-inventory-popup", + () => + function MockSponsorInventoryDialog({ onImageDeleted }) { + return ( + + ); + } +); + +const buildInitialState = ({ + inventoryItems = [], + currentInventoryItem = {} +} = {}) => ({ + currentInventoryItemListState: { + inventoryItems, + term: "", + order: "name", + orderDir: 1, + currentPage: 1, + lastPage: 1, + perPage: 10, + totalInventoryItems: inventoryItems.length, + showArchived: false + }, + currentInventoryItemState: { + entity: currentInventoryItem, + errors: {} + } +}); + +describe("InventoryListPage image removal guard", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const openItemDialog = async () => { + const user = userEvent.setup(); + await user.click(screen.getByText("edit-row-1")); + await waitFor(() => expect(getInventoryItem).toHaveBeenCalled()); + await user.click(screen.getByText("mock-remove-item-image")); + }; + + test.each([ + ["an unsaved entity (no id)", {}, null], + ["a persisted item, delete succeeds", { id: 77 }, true], + ["a persisted item, delete fails", { id: 77 }, false] + ])( + "removing an image for %s", + async (_label, currentInventoryItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + deleteInventoryItemImage.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + + renderWithRedux(, { + initialState: buildInitialState({ + inventoryItems: [{ id: 1, code: "A", name: "Item A" }], + currentInventoryItem + }) + }); + + await openItemDialog(); + + if (deleteSucceeds === null) { + expect(deleteInventoryItemImage).not.toHaveBeenCalled(); + return; + } + + expect(deleteInventoryItemImage).toHaveBeenCalledWith( + currentInventoryItem.id, + 999 + ); + + if (deleteSucceeds) { + await flushPromises(); + expect(getInventoryItem).toHaveBeenCalledTimes(1); + } else { + await waitFor(() => expect(getInventoryItem).toHaveBeenCalledTimes(2)); + expect(getInventoryItem).toHaveBeenLastCalledWith( + currentInventoryItem.id + ); + } + } + ); +}); diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index a3ba172f5..2919aabc1 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -149,6 +149,14 @@ const InventoryListPage = ({ ? unarchiveInventoryItem(item) : archiveInventoryItem(item); + const handleRemoveImage = (imageId) => { + if (!currentInventoryItem?.id) return; + const itemId = currentInventoryItem.id; + deleteInventoryItemImage(itemId, imageId).then((success) => { + if (!success) getInventoryItem(itemId).catch(() => {}); + }); + }; + const columns = [ { columnKey: "code", @@ -291,7 +299,7 @@ const InventoryListPage = ({ onClose={handleClose} onMetaFieldTypeDeleted={deleteInventoryItemMetaFieldType} onMetaFieldTypeValueDeleted={deleteInventoryItemMetaFieldTypeValue} - onImageDeleted={deleteInventoryItemImage} + onImageDeleted={handleRemoveImage} /> )} @@ -312,7 +320,6 @@ export default connect(mapStateToProps, { getInventoryItem, resetInventoryItemForm, saveInventoryItem, - deleteInventoryItemImage, deleteInventoryItemMetaFieldType, deleteInventoryItemMetaFieldTypeValue, diff --git a/src/pages/sponsors/sponsor-form-item-list-page/__tests__/sponsor-form-item-list-page.test.js b/src/pages/sponsors/sponsor-form-item-list-page/__tests__/sponsor-form-item-list-page.test.js index 73f7a77c7..0916fc17c 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/__tests__/sponsor-form-item-list-page.test.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/__tests__/sponsor-form-item-list-page.test.js @@ -1,14 +1,17 @@ import React from "react"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; import SponsorFormItemListPage from "../index"; import { renderWithRedux } from "../../../../utils/test-utils"; jest.mock("../../../../actions/sponsor-forms-actions", () => ({ ...jest.requireActual("../../../../actions/sponsor-forms-actions"), getSponsorFormItems: jest.fn(() => () => Promise.resolve()), + getSponsorFormItem: jest.fn(() => () => Promise.resolve()), updateSponsorFormItem: jest.fn(() => () => Promise.resolve()), - addInventoryItems: jest.fn(() => () => Promise.resolve()) + addInventoryItems: jest.fn(() => () => Promise.resolve()), + removeItemFile: jest.fn(() => () => Promise.resolve(true)) })); jest.mock("../../../../actions/inventory-item-actions", () => ({ @@ -26,10 +29,24 @@ jest.mock( } ); +jest.mock( + "../components/sponsor-form-item-popup", + () => + function MockSponsorFormItemPopup({ onRemoveImage }) { + return ( + + ); + } +); + const { getSponsorFormItems, + getSponsorFormItem, updateSponsorFormItem, - addInventoryItems + addInventoryItems, + removeItemFile } = require("../../../../actions/sponsor-forms-actions"); const buildItem = (id) => ({ @@ -44,7 +61,7 @@ const buildItem = (id) => ({ images: [] }); -const renderPage = () => +const renderPage = (currentItem = {}) => renderWithRedux( initialState: { sponsorFormItemsListState: { items: [buildItem(1)], - currentItem: {}, + currentItem, currentPage: 3, perPage: 5, order: "code", @@ -122,3 +139,53 @@ describe("SponsorFormItemListPage inline cell edit", () => { ); }); }); + +describe("SponsorFormItemListPage image removal guard", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const openItemPopup = async () => { + const user = userEvent.setup(); + await user.click(screen.getByText("sponsor_form_item_list.add_item")); + await user.click(screen.getByText("mock-remove-item-image")); + }; + + it.each([ + ["an unsaved entity (no id)", {}, null], + ["a persisted item, delete succeeds", { id: 42 }, true], + ["a persisted item, delete fails", { id: 42 }, false] + ])( + "removing an image for %s", + async (_label, currentItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + removeItemFile.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + + renderPage(currentItem); + + await openItemPopup(); + + if (deleteSucceeds === null) { + expect(removeItemFile).not.toHaveBeenCalled(); + return; + } + + expect(removeItemFile).toHaveBeenCalledWith("FORM1", currentItem.id, 999); + + if (deleteSucceeds) { + await flushPromises(); + expect(getSponsorFormItem).not.toHaveBeenCalled(); + } else { + await waitFor(() => + expect(getSponsorFormItem).toHaveBeenCalledWith( + "FORM1", + currentItem.id + ) + ); + } + } + ); +}); diff --git a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js index 7e337d2de..9e80401a5 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-form.js @@ -34,7 +34,7 @@ const buildInitialValues = (data) => ({ ...data }); addIssAfterDateFieldValidator(); -const SponsorFormItemForm = ({ initialValues, onSubmit, isSaving }) => { +const SponsorFormItemForm = ({ initialValues, onSubmit, isSaving, onImageDeleted }) => { const formik = useFormik({ initialValues: buildInitialValues(initialValues), validationSchema: yup.object({ @@ -57,6 +57,10 @@ const SponsorFormItemForm = ({ initialValues, onSubmit, isSaving }) => { // SCROLL TO ERROR useScrollToError(formik); + const handleDeleteImage = (id) => { + if (id && onImageDeleted) onImageDeleted(id); + }; + return ( { id="item-image-upload" name="images" maxFiles={MAX_INVENTORY_IMAGES_UPLOAD_QTY} + onDelete={handleDeleteImage} allowedExtensions={getFileUploadAllowedExtensions()} /> diff --git a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-popup.js b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-popup.js index b908a5c4d..4d2be9764 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-popup.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-item-popup.js @@ -11,7 +11,7 @@ import { import CloseIcon from "@mui/icons-material/Close"; import SponsorFormItemForm from "./sponsor-form-item-form"; -const SponsorFormItemPopup = ({ item, onClose, onSave }) => { +const SponsorFormItemPopup = ({ item, onClose, onSave, onRemoveImage }) => { const [isSaving, setIsSaving] = useState(false); const handleClose = () => { @@ -24,10 +24,14 @@ const SponsorFormItemPopup = ({ item, onClose, onSave }) => { setIsSaving(true); onSave(values) .then(() => onClose()) - .catch(() => {}) + .catch(() => { }) .finally(() => setIsSaving(false)); }; + const handleRemoveImage = (imageId) => { + onRemoveImage(imageId); + }; + return ( { initialValues={item} onSubmit={handleOnSave} isSaving={isSaving} + onImageDeleted={handleRemoveImage} /> ); @@ -67,6 +72,7 @@ const SponsorFormItemPopup = ({ item, onClose, onSave }) => { SponsorFormItemPopup.propTypes = { onClose: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, + onRemoveImage: PropTypes.func.isRequired, item: PropTypes.object }; diff --git a/src/pages/sponsors/sponsor-form-item-list-page/index.js b/src/pages/sponsors/sponsor-form-item-list-page/index.js index 260924bd8..87721eef9 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -36,7 +36,8 @@ import { addInventoryItems, resetSponsorFormItem, archiveSponsorFormItem, - unarchiveSponsorFormItem + unarchiveSponsorFormItem, + removeItemFile } from "../../../actions/sponsor-forms-actions"; import { getInventoryItems } from "../../../actions/inventory-item-actions"; import SponsorFormItemPopup from "./components/sponsor-form-item-popup"; @@ -65,7 +66,8 @@ const SponsorFormItemListPage = ({ addInventoryItems, resetSponsorFormItem, archiveSponsorFormItem, - unarchiveSponsorFormItem + unarchiveSponsorFormItem, + removeItemFile }) => { const [openPopup, setOpenPopup] = useState(null); const { form_id: formId } = match.params; @@ -129,6 +131,13 @@ const SponsorFormItemListPage = ({ ); }; + const handleRemoveItemImage = (imageId) => { + if (!currentItem?.id) return; + removeItemFile(formId, currentItem.id, imageId).then((success) => { + if (!success) getSponsorFormItem(formId, currentItem.id).catch(() => {}); + }); + }; + const handleAddFromInventory = (itemIds) => addInventoryItems(formId, itemIds).then(() => getSponsorFormItems( @@ -359,6 +368,7 @@ const SponsorFormItemListPage = ({ item={currentItem} onSave={handleSaveItem} onClose={handleClosePopup} + onRemoveImage={handleRemoveItemImage} /> )} {openPopup === "inventory" && ( @@ -391,5 +401,6 @@ export default connect(mapStateToProps, { resetSponsorFormItem, getInventoryItems, archiveSponsorFormItem, - unarchiveSponsorFormItem + unarchiveSponsorFormItem, + removeItemFile })(SponsorFormItemListPage); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/__tests__/sponsor-forms-manage-items.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/__tests__/sponsor-forms-manage-items.test.js new file mode 100644 index 000000000..b0ebaf4e1 --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/__tests__/sponsor-forms-manage-items.test.js @@ -0,0 +1,133 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import flushPromises from "flush-promises"; +import SponsorFormsManageItems from "../sponsor-forms-manage-items"; +import { renderWithRedux } from "../../../../../../../../utils/test-utils"; +import { + getSponsorFormManagedItem, + removeSponsorCustomizedFormItemImages +} from "../../../../../../../../actions/sponsor-forms-actions"; + +jest.mock("../../../../../../../../actions/sponsor-forms-actions", () => ({ + ...jest.requireActual( + "../../../../../../../../actions/sponsor-forms-actions" + ), + getSponsorCustomizedFormItems: jest.fn(() => () => Promise.resolve()), + getSponsorFormManagedItem: jest.fn(() => () => Promise.resolve()), + removeSponsorCustomizedFormItemImages: jest.fn( + () => () => Promise.resolve(true) + ) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/editable-table", + () => ({ + __esModule: true, + default: ({ data, onEdit }) => ( +
+ {data.map((row) => ( + + ))} +
+ ) + }) +); + +jest.mock( + "../../../../../../../sponsors-global/form-templates/sponsor-inventory-popup", + () => + function MockSponsorInventoryDialog({ onImageDeleted }) { + return ( + + ); + } +); + +const buildInitialState = ({ items = [], currentItem = {} } = {}) => ({ + sponsorCustomizedFormItemsListState: { + items, + showArchived: false, + term: "", + order: "name", + orderDir: 1, + currentPage: 1, + lastPage: 1, + perPage: 10, + totalCount: items.length, + currentItem + }, + loggedUserState: { + member: { + groups: [{ id: 1, code: "super-admins" }] + } + } +}); + +describe("SponsorFormsManageItems image removal guard", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const openItemDialog = async () => { + const user = userEvent.setup(); + await user.click(screen.getByText("edit-row-1")); + await waitFor(() => expect(getSponsorFormManagedItem).toHaveBeenCalled()); + await user.click(screen.getByText("mock-remove-item-image")); + }; + + test.each([ + ["an unsaved entity (no id)", {}, null], + ["a persisted item, delete succeeds", { id: 33 }, true], + ["a persisted item, delete fails", { id: 33 }, false] + ])( + "removing an image for %s", + async (_label, currentItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + removeSponsorCustomizedFormItemImages.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + + renderWithRedux( + , + { + initialState: buildInitialState({ + items: [{ id: 1, code: "A", name: "Item A" }], + currentItem + }) + } + ); + + await openItemDialog(); + + if (deleteSucceeds === null) { + expect(removeSponsorCustomizedFormItemImages).not.toHaveBeenCalled(); + return; + } + + expect(removeSponsorCustomizedFormItemImages).toHaveBeenCalledWith( + "FORM1", + currentItem.id, + 999 + ); + + if (deleteSucceeds) { + await flushPromises(); + expect(getSponsorFormManagedItem).toHaveBeenCalledTimes(1); + } else { + await waitFor(() => + expect(getSponsorFormManagedItem).toHaveBeenCalledTimes(2) + ); + expect(getSponsorFormManagedItem).toHaveBeenLastCalledWith( + "FORM1", + currentItem.id + ); + } + } + ); +}); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js index 86a48eae5..cff1c1065 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-forms-manage-items.js @@ -36,7 +36,8 @@ import { deleteSponsorFormManagedItem, resetSponsorFormManagedItem, unarchiveSponsorCustomizedFormItem, - getSponsorFormManagedItem + getSponsorFormManagedItem, + removeSponsorCustomizedFormItemImages } from "../../../../../../../actions/sponsor-forms-actions"; import CustomAlert from "../../../../../../../components/mui/custom-alert"; import SponsorInventoryDialog from "../../../../../../sponsors-global/form-templates/sponsor-inventory-popup"; @@ -67,7 +68,8 @@ const SponsorFormsManageItems = ({ deleteSponsorFormManagedItem, archiveSponsorCustomizedFormItem, unarchiveSponsorCustomizedFormItem, - getSponsorFormManagedItem + getSponsorFormManagedItem, + removeSponsorCustomizedFormItemImages }) => { const [openPopup, setOpenPopup] = useState(null); @@ -200,6 +202,16 @@ const SponsorFormsManageItems = ({ ); }; + const handleImageRemove = (imageId) => { + if (!currentInventoryItem?.id) return; + const itemId = currentInventoryItem.id; + removeSponsorCustomizedFormItemImages(formId, itemId, imageId).then( + (success) => { + if (!success) getSponsorFormManagedItem(formId, itemId).catch(() => {}); + } + ); + }; + const sponsorItemColumns = [ { columnKey: "code", @@ -382,6 +394,7 @@ const SponsorFormsManageItems = ({ entity={currentInventoryItem} onSave={handleItemSave} onClose={handleClose} + onImageDeleted={handleImageRemove} /> )} @@ -409,7 +422,8 @@ export default Restrict( deleteSponsorFormManagedItem, getSponsorFormManagedItem, archiveSponsorCustomizedFormItem, - unarchiveSponsorCustomizedFormItem + unarchiveSponsorCustomizedFormItem, + removeSponsorCustomizedFormItemImages })(SponsorFormsManageItems), ACCESS_ROUTES.ADMIN_SPONSORS ); diff --git a/src/reducers/sponsors/__tests__/sponsor-customized-form-items-list-reducer.test.js b/src/reducers/sponsors/__tests__/sponsor-customized-form-items-list-reducer.test.js index 23ac56ae5..4ce1d24bd 100644 --- a/src/reducers/sponsors/__tests__/sponsor-customized-form-items-list-reducer.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-customized-form-items-list-reducer.test.js @@ -1,6 +1,8 @@ import sponsorCustomizedFormItemsListReducer from "../sponsor-customized-form-items-list-reducer"; import { RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEM, + SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, + SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, SPONSOR_FORM_MANAGED_ITEM_UPDATED } from "../../../actions/sponsor-forms-actions"; @@ -46,7 +48,7 @@ const buildItem = (overrides = {}) => ({ describe("sponsorCustomizedFormItemsListReducer", () => { describe("RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEM", () => { - it("maps file_url to file_path on each image — the edit-form image fix", () => { + it("stores images as received from the API, without a file_path mapping", () => { const result = sponsorCustomizedFormItemsListReducer(DEFAULT_STATE, { type: RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEM, payload: { @@ -62,13 +64,11 @@ describe("sponsorCustomizedFormItemsListReducer", () => { expect(result.currentItem.images).toEqual([ { id: 10, - file_url: "https://cdn/a.png", - file_path: "https://cdn/a.png" + file_url: "https://cdn/a.png" }, { id: 11, - file_url: "https://cdn/b.png", - file_path: "https://cdn/b.png" + file_url: "https://cdn/b.png" } ]); }); @@ -92,6 +92,140 @@ describe("sponsorCustomizedFormItemsListReducer", () => { }); }); + describe("SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED", () => { + it("removes the image from currentItem and its matching list item", () => { + const state = { + ...DEFAULT_STATE, + currentItem: { + ...DEFAULT_STATE.currentItem, + id: 1, + images: [{ id: 10 }, { id: 11 }] + }, + items: [ + buildItem({ id: 1, images: [{ id: 10 }, { id: 11 }] }), + buildItem({ id: 2, images: [{ id: 12 }] }) + ] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, + payload: { fileId: 10, itemId: 1 } + }); + + expect(result.currentItem.images).toEqual([{ id: 11 }]); + expect(result.items[0].images).toEqual([{ id: 11 }]); + expect(result.items[1].images).toEqual([{ id: 12 }]); + }); + + it("handles a currentItem with no images without throwing", () => { + const state = { + ...DEFAULT_STATE, + currentItem: { ...DEFAULT_STATE.currentItem, id: 1, images: undefined }, + items: [buildItem({ id: 1, images: undefined })] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, + payload: { fileId: 10, itemId: 1 } + }); + + expect(result.currentItem.images).toEqual([]); + }); + + it("leaves currentItem untouched when the deleted file belongs to a different item", () => { + // Regression for the race where the delete for item A resolves after + // the dialog switched to item B (RECEIVE_SPONSOR_CUSTOMIZED_FORM_ITEM + // replaced currentItem in between) — only A's row should update. + const state = { + ...DEFAULT_STATE, + currentItem: { + ...DEFAULT_STATE.currentItem, + id: 2, + images: [{ id: 12 }] + }, + items: [ + buildItem({ id: 1, images: [{ id: 10 }, { id: 11 }] }), + buildItem({ id: 2, images: [{ id: 12 }] }) + ] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, + payload: { fileId: 10, itemId: 1 } + }); + + expect(result.currentItem).toEqual(state.currentItem); + expect(result.items[0].images).toEqual([{ id: 11 }]); + expect(result.items[1].images).toEqual([{ id: 12 }]); + }); + }); + + describe("SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED", () => { + it.each([ + [ + "with existing images", + [{ id: 10 }], + { id: 11 }, + [{ id: 10 }, { id: 11 }] + ], + ["with no images (undefined)", undefined, { id: 10 }, [{ id: 10 }]] + ])( + "appends the new image to currentItem and its matching list item (%s)", + (_label, initialImages, newImage, expectedImages) => { + const state = { + ...DEFAULT_STATE, + currentItem: { + ...DEFAULT_STATE.currentItem, + id: 1, + images: initialImages + }, + items: [ + buildItem({ id: 1, images: initialImages }), + buildItem({ id: 2, images: [{ id: 12 }] }) + ] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + payload: { response: newImage, itemId: 1 } + }); + + expect(result.currentItem.images).toEqual(expectedImages); + expect(result.items[0].images).toEqual(expectedImages); + expect(result.items[1].images).toEqual([{ id: 12 }]); + } + ); + + it("leaves currentItem untouched when the new image belongs to a different item", () => { + // This is exactly the case that broke before this action got its own + // type: an image upload response ({id, file_url}) used to be dispatched + // as SPONSOR_FORM_MANAGED_ITEM_UPDATED, which that reducer case reads + // as an item - matching state.items by the image's id and clobbering + // whichever unrelated item happened to share that numeric id. + const state = { + ...DEFAULT_STATE, + currentItem: { + ...DEFAULT_STATE.currentItem, + id: 2, + images: [{ id: 12 }] + }, + items: [ + buildItem({ id: 1, images: [{ id: 10 }] }), + buildItem({ id: 2, images: [{ id: 12 }] }) + ] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + payload: { response: { id: 11 }, itemId: 1 } + }); + + expect(result.currentItem).toEqual(state.currentItem); + expect(result.items[0].images).toEqual([{ id: 10 }, { id: 11 }]); + expect(result.items[1].images).toEqual([{ id: 12 }]); + }); + }); + describe("SPONSOR_FORM_MANAGED_ITEM_UPDATED", () => { it("replaces the matching list item and preserves its images as-is", () => { const images = [{ id: 20, file_url: "https://cdn/img.png" }]; @@ -121,5 +255,31 @@ describe("sponsorCustomizedFormItemsListReducer", () => { expect(result.items[0].images).toBe(images); expect(result.items[1].name).toBe("Other"); }); + + it("preserves the existing item's images when the response omits them", () => { + // Regression for the handleCellEdit path (sponsor-forms-manage-items.js) + // which saves via this action with no follow-up refetch - a response + // that omits images must not clobber the row's thumbnail. + const images = [{ id: 20, file_url: "https://cdn/img.png" }]; + const state = { + ...DEFAULT_STATE, + items: [buildItem({ id: 1, name: "Before", images })] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_FORM_MANAGED_ITEM_UPDATED, + payload: { + response: buildItem({ + id: 1, + name: "After", + early_bird_rate: 500, + images: undefined + }) + } + }); + + expect(result.items[0].name).toBe("After"); + expect(result.items[0].images).toBe(images); + }); }); }); diff --git a/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js b/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js index 89a1bea7d..501f37b4c 100644 --- a/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js +++ b/src/reducers/sponsors/__tests__/sponsor-form-items-list-reducer.test.js @@ -8,7 +8,10 @@ import { RESET_SPONSOR_FORM_ITEM, SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, - SPONSOR_FORM_ITEM_UNARCHIVED + SPONSOR_FORM_ITEM_FILE_DELETED, + SPONSOR_FORM_ITEM_IMAGE_ADDED, + SPONSOR_FORM_ITEM_UNARCHIVED, + SPONSOR_FORM_ITEM_UPDATED } from "../../../actions/sponsor-forms-actions"; function createDefaultState() { @@ -189,6 +192,55 @@ describe("SponsorFormItemsListReducer", () => { } }); }); + + it("keeps images in their API shape (id, file_url) without a file_path mapping", () => { + const item = { + id: "A", + code: "A", + name: "A", + early_bird_rate: 100, + standard_rate: 100, + onsite_rate: 100, + default_quantity: "100", + is_archived: true, + images: [ + { id: 10, file_url: "https://cdn/a.png" }, + { id: 11, file_url: "https://cdn/b.png" } + ], + meta_fields: [] + }; + + result = SponsorFormItemsListReducer(initialState, { + type: RECEIVE_SPONSOR_FORM_ITEM, + payload: { response: item } + }); + + expect(result.currentItem.images).toEqual([ + { id: 10, file_url: "https://cdn/a.png" }, + { id: 11, file_url: "https://cdn/b.png" } + ]); + }); + + it("defaults images to [] when the response omits the field", () => { + const item = { + id: "A", + code: "A", + name: "A", + early_bird_rate: 100, + standard_rate: 100, + onsite_rate: 100, + default_quantity: "100", + is_archived: true, + meta_fields: [] + }; + + result = SponsorFormItemsListReducer(initialState, { + type: RECEIVE_SPONSOR_FORM_ITEM, + payload: { response: item } + }); + + expect(result.currentItem.images).toEqual([]); + }); }); describe("RESET_SPONSOR_FORM_ITEM", () => { @@ -268,6 +320,263 @@ describe("SponsorFormItemsListReducer", () => { }); }); + describe("SPONSOR_FORM_ITEM_FILE_DELETED", () => { + it("removes the image from currentItem and its matching list item", () => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "A", + images: [{ id: "IMG_1" }, { id: "IMG_2" }] + }, + items: [ + { id: "A", images: [{ id: "IMG_1" }, { id: "IMG_2" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_FILE_DELETED, + payload: { fileId: "IMG_1", itemId: "A" } + }); + + expect(result.currentItem.images).toStrictEqual([{ id: "IMG_2" }]); + expect(result.items).toStrictEqual([ + { id: "A", images: [{ id: "IMG_2" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ]); + }); + + it("leaves currentItem untouched when the deleted file belongs to a different item", () => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "B", + images: [{ id: "IMG_3" }] + }, + items: [ + { id: "A", images: [{ id: "IMG_1" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_FILE_DELETED, + payload: { fileId: "IMG_1", itemId: "A" } + }); + + expect(result.currentItem).toStrictEqual(state.currentItem); + expect(result.items).toStrictEqual([ + { id: "A", images: [] }, + { id: "B", images: [{ id: "IMG_3" }] } + ]); + }); + + it("defaults the matching list item's images to [] when it has none", () => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "A", + images: undefined + }, + items: [ + { id: "A", images: undefined }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_FILE_DELETED, + payload: { fileId: "IMG_1", itemId: "A" } + }); + + expect(result.currentItem.images).toStrictEqual([]); + expect(result.items[0].images).toStrictEqual([]); + }); + }); + + describe("SPONSOR_FORM_ITEM_IMAGE_ADDED", () => { + it.each([ + [ + "with existing images", + [{ id: "IMG_1" }], + { id: "IMG_2" }, + [{ id: "IMG_1" }, { id: "IMG_2" }] + ], + [ + "with no images (undefined)", + undefined, + { id: "IMG_1" }, + [{ id: "IMG_1" }] + ] + ])( + "appends the new image to currentItem and its matching list item (%s)", + (_label, initialImages, newImage, expectedImages) => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "A", + images: initialImages + }, + items: [ + { id: "A", images: initialImages }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_IMAGE_ADDED, + payload: { response: newImage, itemId: "A" } + }); + + expect(result.currentItem.images).toStrictEqual(expectedImages); + expect(result.items).toStrictEqual([ + { id: "A", images: expectedImages }, + { id: "B", images: [{ id: "IMG_3" }] } + ]); + } + ); + + it("leaves currentItem untouched when the new image belongs to a different item", () => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "B", + images: [{ id: "IMG_3" }] + }, + items: [ + { id: "A", images: [{ id: "IMG_1" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_IMAGE_ADDED, + payload: { response: { id: "IMG_2" }, itemId: "A" } + }); + + expect(result.currentItem).toStrictEqual(state.currentItem); + expect(result.items).toStrictEqual([ + { id: "A", images: [{ id: "IMG_1" }, { id: "IMG_2" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ]); + }); + }); + + describe("SPONSOR_FORM_ITEM_UPDATED", () => { + it("replaces the matching list item's fields from the response", () => { + const state = { + ...initialState, + items: [ + { + id: "A", + code: "A", + name: "Before", + early_bird_rate: "$1.00", + standard_rate: "$1.00", + onsite_rate: "$1.00", + default_quantity: "100", + is_archived: false, + images: [{ id: "IMG_1" }] + }, + { + id: "B", + code: "B", + name: "B", + early_bird_rate: "$1.00", + standard_rate: "$1.00", + onsite_rate: "$1.00", + default_quantity: "100", + is_archived: false, + images: [] + } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_UPDATED, + payload: { + response: { + id: "A", + code: "A", + name: "After", + early_bird_rate: 500, + standard_rate: 600, + onsite_rate: 700, + default_quantity: "200", + is_archived: true + } + } + }); + + expect(result.items).toStrictEqual([ + { + id: "A", + code: "A", + name: "After", + early_bird_rate: "$5.00", + standard_rate: "$6.00", + onsite_rate: "$7.00", + default_quantity: "200", + is_archived: true, + images: [{ id: "IMG_1" }] + }, + { + id: "B", + code: "B", + name: "B", + early_bird_rate: "$1.00", + standard_rate: "$1.00", + onsite_rate: "$1.00", + default_quantity: "100", + is_archived: false, + images: [] + } + ]); + }); + + it("leaves the list unchanged when the updated item isn't in the current page (e.g. a freshly created item)", () => { + const state = { + ...initialState, + items: [ + { + id: "B", + code: "B", + name: "B", + early_bird_rate: "$1.00", + standard_rate: "$1.00", + onsite_rate: "$1.00", + default_quantity: "100", + is_archived: false, + images: [] + } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_UPDATED, + payload: { + response: { + id: "NEW", + code: "NEW", + name: "New item", + early_bird_rate: 100, + standard_rate: 100, + onsite_rate: 100, + default_quantity: "1", + is_archived: false + } + } + }); + + expect(result.items).toStrictEqual(state.items); + }); + }); + describe("SPONSOR_FORM_ITEM_ARCHIVED", () => { it("execution", () => { result = SponsorFormItemsListReducer( diff --git a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js index 84c58770f..1070469a7 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -21,8 +21,10 @@ import { SPONSOR_CUSTOMIZED_FORM_ITEM_DELETED, SPONSOR_CUSTOMIZED_FORM_ITEM_UNARCHIVED, SPONSOR_FORM_MANAGED_ITEM_UPDATED, + SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, SPONSOR_CUSTOMIZED_FORM_ITEMS_ADDED, - RESET_SPONSOR_FORM_MANAGED_ITEM + RESET_SPONSOR_FORM_MANAGED_ITEM, + SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED } from "../../actions/sponsor-forms-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { getSafePageAfterRemove } from "../../utils/methods"; @@ -111,14 +113,47 @@ const sponsorCustomizedFormItemsListReducer = ( const currentItem = { ...item, - images: (item.images || []).map((img) => ({ - ...img, - file_path: img.file_url - })), + images: item.images ?? [], meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; } + case SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED: { + const { fileId, itemId } = payload; + const currentItem = + state.currentItem.id === itemId + ? { + ...state.currentItem, + images: + state.currentItem.images?.filter((img) => img.id !== fileId) ?? + [] + } + : state.currentItem; + const items = state.items.map((item) => + item.id === itemId + ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } + : item + ); + return { ...state, currentItem, items }; + } + case SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED: { + const { response: image, itemId } = payload; + const currentItem = + state.currentItem.id === itemId + ? { + ...state.currentItem, + images: [...(state.currentItem.images ?? []), image] + } + : state.currentItem; + + const items = state.items.map((item) => + item.id === itemId + ? { ...item, images: [...(item.images ?? []), image] } + : item + ); + + return { ...state, currentItem, items }; + } case SPONSOR_CUSTOMIZED_FORM_ITEM_DELETED: { const { itemId } = payload; const items = state.items.filter((it) => it.id !== itemId); @@ -166,7 +201,7 @@ const sponsorCustomizedFormItemsListReducer = ( onsite_rate: formatRateFromCents(updatedItem.onsite_rate), default_quantity: updatedItem.default_quantity, is_archived: updatedItem.is_archived, - images: updatedItem.images + images: updatedItem.images ?? item.images } : item ); diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 6c656794c..803c71898 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -20,7 +20,10 @@ import { RESET_SPONSOR_FORM_ITEM, SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, - SPONSOR_FORM_ITEM_UNARCHIVED + SPONSOR_FORM_ITEM_FILE_DELETED, + SPONSOR_FORM_ITEM_IMAGE_ADDED, + SPONSOR_FORM_ITEM_UNARCHIVED, + SPONSOR_FORM_ITEM_UPDATED } from "../../actions/sponsor-forms-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { getSafePageAfterRemove } from "../../utils/methods"; @@ -99,12 +102,11 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { } case RECEIVE_SPONSOR_FORM_ITEM: { const item = payload.response; - const currentItem = { ...item, - meta_fields: item.meta_fields.length > 0 ? item.meta_fields : [] + images: item.images ?? [], + meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; - return { ...state, currentItem }; } case RESET_SPONSOR_FORM_ITEM: { @@ -116,6 +118,66 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { return { ...state, items }; } + case SPONSOR_FORM_ITEM_FILE_DELETED: { + const { fileId, itemId } = payload; + const currentItem = + state.currentItem.id === itemId + ? { + ...state.currentItem, + images: + state.currentItem.images?.filter((img) => img.id !== fileId) ?? + [] + } + : state.currentItem; + + const items = state.items.map((item) => + item.id === itemId + ? { + ...item, + images: item.images?.filter((img) => img.id !== fileId) ?? [] + } + : item + ); + + return { ...state, currentItem, items }; + } + case SPONSOR_FORM_ITEM_IMAGE_ADDED: { + const { response: image, itemId } = payload; + const currentItem = + state.currentItem.id === itemId + ? { + ...state.currentItem, + images: [...(state.currentItem.images ?? []), image] + } + : state.currentItem; + + const items = state.items.map((item) => + item.id === itemId + ? { ...item, images: [...(item.images ?? []), image] } + : item + ); + + return { ...state, currentItem, items }; + } + case SPONSOR_FORM_ITEM_UPDATED: { + const updatedItem = payload.response; + const items = state.items.map((item) => + item.id === updatedItem.id + ? { + id: updatedItem.id, + code: updatedItem.code, + name: updatedItem.name, + early_bird_rate: formatRateFromCents(updatedItem.early_bird_rate), + standard_rate: formatRateFromCents(updatedItem.standard_rate), + onsite_rate: formatRateFromCents(updatedItem.onsite_rate), + default_quantity: updatedItem.default_quantity, + is_archived: updatedItem.is_archived, + images: updatedItem.images ?? item.images + } + : item + ); + return { ...state, items }; + } case SPONSOR_FORM_ITEM_ARCHIVED: { const { id: itemId } = payload.response; const { totalCount, perPage, currentPage } = state; diff --git a/src/reducers/sponsors_inventory/__tests__/form-template-item-list-reducer.test.js b/src/reducers/sponsors_inventory/__tests__/form-template-item-list-reducer.test.js new file mode 100644 index 000000000..528424976 --- /dev/null +++ b/src/reducers/sponsors_inventory/__tests__/form-template-item-list-reducer.test.js @@ -0,0 +1,38 @@ +import formTemplateItemListReducer from "../form-template-item-list-reducer"; +import { FORM_TEMPLATE_ITEM_IMAGE_DELETED } from "../../../actions/form-template-item-actions"; + +describe("formTemplateItemListReducer", () => { + describe("FORM_TEMPLATE_ITEM_IMAGE_DELETED", () => { + it("removes the deleted image from the matching item's images in the list", () => { + const state = { + formTemplateItems: [ + { id: 1, images: [{ id: 10 }] }, + { id: 2, images: [{ id: 20 }] } + ] + }; + + const result = formTemplateItemListReducer(state, { + type: FORM_TEMPLATE_ITEM_IMAGE_DELETED, + payload: { fileId: 10, formTemplateItemId: 1 } + }); + + expect(result.formTemplateItems).toEqual([ + { id: 1, images: [] }, + { id: 2, images: [{ id: 20 }] } + ]); + }); + + it("leaves other items untouched when the deleted image belongs to a different item", () => { + const state = { + formTemplateItems: [{ id: 1, images: [{ id: 10 }] }] + }; + + const result = formTemplateItemListReducer(state, { + type: FORM_TEMPLATE_ITEM_IMAGE_DELETED, + payload: { fileId: 999, formTemplateItemId: 2 } + }); + + expect(result.formTemplateItems).toEqual(state.formTemplateItems); + }); + }); +}); diff --git a/src/reducers/sponsors_inventory/__tests__/inventory-item-list-reducer.test.js b/src/reducers/sponsors_inventory/__tests__/inventory-item-list-reducer.test.js new file mode 100644 index 000000000..8bcc53606 --- /dev/null +++ b/src/reducers/sponsors_inventory/__tests__/inventory-item-list-reducer.test.js @@ -0,0 +1,38 @@ +import inventoryItemListReducer from "../inventory-item-list-reducer"; +import { INVENTORY_ITEM_IMAGE_DELETED } from "../../../actions/inventory-item-actions"; + +describe("inventoryItemListReducer", () => { + describe("INVENTORY_ITEM_IMAGE_DELETED", () => { + it("removes the deleted image from the matching item's images in the list", () => { + const state = { + inventoryItems: [ + { id: 1, images: [{ id: 10 }] }, + { id: 2, images: [{ id: 20 }] } + ] + }; + + const result = inventoryItemListReducer(state, { + type: INVENTORY_ITEM_IMAGE_DELETED, + payload: { fileId: 10, inventoryItemId: 1 } + }); + + expect(result.inventoryItems).toEqual([ + { id: 1, images: [] }, + { id: 2, images: [{ id: 20 }] } + ]); + }); + + it("leaves other items untouched when the deleted image belongs to a different item", () => { + const state = { + inventoryItems: [{ id: 1, images: [{ id: 10 }] }] + }; + + const result = inventoryItemListReducer(state, { + type: INVENTORY_ITEM_IMAGE_DELETED, + payload: { fileId: 999, inventoryItemId: 2 } + }); + + expect(result.inventoryItems).toEqual(state.inventoryItems); + }); + }); +}); diff --git a/src/reducers/sponsors_inventory/__tests__/inventory-item-reducer.test.js b/src/reducers/sponsors_inventory/__tests__/inventory-item-reducer.test.js new file mode 100644 index 000000000..7698e8d70 --- /dev/null +++ b/src/reducers/sponsors_inventory/__tests__/inventory-item-reducer.test.js @@ -0,0 +1,22 @@ +import inventoryItemReducer from "../inventory-item-reducer"; +import { INVENTORY_ITEM_IMAGE_DELETED } from "../../../actions/inventory-item-actions"; + +describe("inventoryItemReducer", () => { + describe("INVENTORY_ITEM_IMAGE_DELETED", () => { + it("removes the deleted image by fileId from entity.images", () => { + const state = { + entity: { + id: 1, + images: [{ id: 10 }, { id: 11 }] + } + }; + + const result = inventoryItemReducer(state, { + type: INVENTORY_ITEM_IMAGE_DELETED, + payload: { fileId: 10 } + }); + + expect(result.entity.images).toEqual([{ id: 11 }]); + }); + }); +}); diff --git a/src/reducers/sponsors_inventory/form-template-item-list-reducer.js b/src/reducers/sponsors_inventory/form-template-item-list-reducer.js index d040b7a6b..81435b6d9 100644 --- a/src/reducers/sponsors_inventory/form-template-item-list-reducer.js +++ b/src/reducers/sponsors_inventory/form-template-item-list-reducer.js @@ -18,7 +18,8 @@ import { FORM_TEMPLATE_ITEM_DELETED, CHANGE_FORM_TEMPLATE_ITEM_SEARCH_TERM, FORM_TEMPLATE_ITEM_ARCHIVED, - FORM_TEMPLATE_ITEM_UNARCHIVED + FORM_TEMPLATE_ITEM_UNARCHIVED, + FORM_TEMPLATE_ITEM_IMAGE_DELETED } from "../../actions/form-template-item-actions"; import { getSafePageAfterRemove } from "../../utils/methods"; @@ -143,6 +144,18 @@ const formTemplateItemListReducer = (state = DEFAULT_STATE, action = {}) => { ) }; } + case FORM_TEMPLATE_ITEM_IMAGE_DELETED: { + const { fileId, formTemplateItemId } = payload; + const updatedFormTemplateItems = state.formTemplateItems.map((item) => + item.id === formTemplateItemId + ? { + ...item, + images: item.images?.filter((img) => img.id !== fileId) ?? [] + } + : item + ); + return { ...state, formTemplateItems: updatedFormTemplateItems }; + } default: return state; } diff --git a/src/reducers/sponsors_inventory/inventory-item-list-reducer.js b/src/reducers/sponsors_inventory/inventory-item-list-reducer.js index 955ea04bb..dd93fae7a 100644 --- a/src/reducers/sponsors_inventory/inventory-item-list-reducer.js +++ b/src/reducers/sponsors_inventory/inventory-item-list-reducer.js @@ -23,7 +23,8 @@ import { SET_SELECTED_ALL_INVENTORY_ITEMS, INVENTORY_ITEM_ARCHIVED, INVENTORY_ITEM_UNARCHIVED, - INVENTORY_ITEM_IMAGE_SAVED + INVENTORY_ITEM_IMAGE_SAVED, + INVENTORY_ITEM_IMAGE_DELETED } from "../../actions/inventory-item-actions"; import { getSafePageAfterRemove } from "../../utils/methods"; @@ -246,6 +247,18 @@ const inventoryItemListReducer = (state = DEFAULT_STATE, action = {}) => { ); return { ...state, inventoryItems: updatedInventoryItems }; } + case INVENTORY_ITEM_IMAGE_DELETED: { + const { fileId, inventoryItemId } = payload; + const updatedInventoryItems = state.inventoryItems.map((item) => + item.id === inventoryItemId + ? { + ...item, + images: item.images?.filter((img) => img.id !== fileId) ?? [] + } + : item + ); + return { ...state, inventoryItems: updatedInventoryItems }; + } default: return state; } diff --git a/src/reducers/sponsors_inventory/inventory-item-reducer.js b/src/reducers/sponsors_inventory/inventory-item-reducer.js index 7ecf1753a..58425540a 100644 --- a/src/reducers/sponsors_inventory/inventory-item-reducer.js +++ b/src/reducers/sponsors_inventory/inventory-item-reducer.js @@ -185,10 +185,8 @@ const inventoryItemReducer = (state = DEFAULT_STATE, action) => { }; } case INVENTORY_ITEM_IMAGE_DELETED: { - const { imageId } = payload; - const images = state.entity.images.filter( - (image) => image.id !== imageId - ); + const { fileId } = payload; + const images = state.entity.images.filter((image) => image.id !== fileId); return { ...state, entity: {