From c87ef77febb664d90df2a099a63d8c4c67744b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 1 Jul 2026 20:37:14 -0300 Subject: [PATCH 01/24] fix: add missing file remove calls from inventory popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 21 +++++++++++++++++++ .../form-template-item-list-page.js | 6 +++++- .../form-templates/sponsor-inventory-popup.js | 7 +++++++ .../inventory/inventory-list-page.js | 7 +++++-- .../components/sponsor-form-item-form.js | 7 ++++++- .../components/sponsor-form-item-popup.js | 13 +++++++++--- .../sponsor-forms-manage-items.js | 5 +++++ 7 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 5492628e7..045f35465 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -107,6 +107,7 @@ 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_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"; @@ -1262,6 +1263,26 @@ const saveItemImages = return Promise.all(promises); }; +export const removeItemFile = + (formId, formItemId, fileId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + const params = { access_token: accessToken }; + + dispatch(startLoading()); + + return deleteRequest( + null, + createAction(SPONSOR_FORM_ITEM_FILE_DELETED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images/${fileId}`, + null, + snackbarErrorHandler + )(params)(dispatch).finally(() => { + dispatch(stopLoading()); + }); + }; + export const saveSponsorFormItem = (formId, entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); 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..d0fe99db9 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,10 @@ const FormTemplateItemListPage = ({ ).catch(() => {}) ); + const handleRemoveImage = (imageId) => { + deleteItemImage(formTemplateId, currentFormTemplateItem.id, imageId); + }; + const columns = [ { columnKey: "code", @@ -319,7 +323,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..9f051c6c1 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(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/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index a3ba172f5..662b55024 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -149,6 +149,10 @@ const InventoryListPage = ({ ? unarchiveInventoryItem(item) : archiveInventoryItem(item); + const handleRemoveImage = (imageId) => { + deleteInventoryItemImage(currentInventoryItem.id, imageId); + }; + const columns = [ { columnKey: "code", @@ -291,7 +295,7 @@ const InventoryListPage = ({ onClose={handleClose} onMetaFieldTypeDeleted={deleteInventoryItemMetaFieldType} onMetaFieldTypeValueDeleted={deleteInventoryItemMetaFieldTypeValue} - onImageDeleted={deleteInventoryItemImage} + onImageDeleted={handleRemoveImage} /> )} @@ -312,7 +316,6 @@ export default connect(mapStateToProps, { getInventoryItem, resetInventoryItemForm, saveInventoryItem, - deleteInventoryItemImage, deleteInventoryItemMetaFieldType, deleteInventoryItemMetaFieldTypeValue, 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..aba32b529 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(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..fe0b73f0c 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 ( { {T.translate( `sponsor_form_item_list.edit_item.${item?.id ? "edit" : "new"}` - )} + )}{" "} + laksjdlkajsdklajds { initialValues={item} onSubmit={handleOnSave} isSaving={isSaving} + onImageDeleted={handleRemoveImage} /> ); @@ -67,6 +73,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-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..cad4a61d1 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 @@ -200,6 +200,10 @@ const SponsorFormsManageItems = ({ ); }; + const handleImageRemove = (imageId) => { + console.log("CHECK IMAGE ", imageId); + }; + const sponsorItemColumns = [ { columnKey: "code", @@ -382,6 +386,7 @@ const SponsorFormsManageItems = ({ entity={currentInventoryItem} onSave={handleItemSave} onClose={handleClose} + onImageDeleted={handleImageRemove} /> )} From c751cb5b1cce5e3e8b7e6eded510784eaee45d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 9 Jul 2026 09:26:35 -0300 Subject: [PATCH 02/24] fix: add managed form item image remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 35 ++++++++++++++++--- .../sponsor-forms-manage-items.js | 15 +++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 045f35465..91ed50524 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -850,7 +850,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}`); @@ -1575,9 +1576,12 @@ export const saveSponsorFormManagedItem = snackbarErrorHandler, entity )(params)(dispatch) - .then(() => { - dispatch(snackbarSuccessHandler(successMessage)); - }) + .then(({ response }) => + saveImages(response.id).then(() => { + dispatch(snackbarSuccessHandler(successMessage)); + return response; + }) + ) .finally(() => { dispatch(stopLoading()); }); @@ -1773,3 +1777,26 @@ 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 accessToken = await getAccessTokenSafely(); + const params = { access_token: accessToken }; + + dispatch(startLoading()); + + return deleteRequest( + null, + createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_DELETED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images/${fileId}`, + null, + snackbarErrorHandler + )(params)(dispatch).finally(() => { + dispatch(stopLoading()); + }); + }; 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 cad4a61d1..0d6fd64fe 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); @@ -201,7 +203,11 @@ const SponsorFormsManageItems = ({ }; const handleImageRemove = (imageId) => { - console.log("CHECK IMAGE ", imageId); + removeSponsorCustomizedFormItemImages( + formId, + currentInventoryItem.id, + imageId + ); }; const sponsorItemColumns = [ @@ -414,7 +420,8 @@ export default Restrict( deleteSponsorFormManagedItem, getSponsorFormManagedItem, archiveSponsorCustomizedFormItem, - unarchiveSponsorCustomizedFormItem + unarchiveSponsorCustomizedFormItem, + removeSponsorCustomizedFormItemImages })(SponsorFormsManageItems), ACCESS_ROUTES.ADMIN_SPONSORS ); From 60bc6163e4a11fdb700814b6da19e6eca866d85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 9 Jul 2026 09:53:29 -0300 Subject: [PATCH 03/24] fix: add catchs, clean code, new action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 5 +++-- .../form-templates/sponsor-inventory-popup.js | 2 +- .../components/sponsor-form-item-form.js | 2 +- .../components/sponsor-form-item-popup.js | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 91ed50524..a1011d397 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -97,7 +97,8 @@ 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"; // ITEMS export const REQUEST_SPONSOR_FORM_ITEMS = "REQUEST_SPONSOR_FORM_ITEMS"; export const RECEIVE_SPONSOR_FORM_ITEMS = "RECEIVE_SPONSOR_FORM_ITEMS"; @@ -1792,7 +1793,7 @@ export const removeSponsorCustomizedFormItemImages = return deleteRequest( null, - createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_DELETED), + createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images/${fileId}`, null, snackbarErrorHandler 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 9f051c6c1..2fadffba1 100644 --- a/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js +++ b/src/pages/sponsors-global/form-templates/sponsor-inventory-popup.js @@ -95,7 +95,7 @@ const SponsorItemDialog = ({ }; const handleIDeleteImage = (id) => { - if (id) onImageDeleted(id); + if (id && onImageDeleted) onImageDeleted(id); }; return ( 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 aba32b529..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 @@ -58,7 +58,7 @@ const SponsorFormItemForm = ({ initialValues, onSubmit, isSaving, onImageDeleted useScrollToError(formik); const handleDeleteImage = (id) => { - if (id) onImageDeleted(id); + if (id && onImageDeleted) onImageDeleted(id); }; return ( 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 fe0b73f0c..b0d36d881 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 @@ -48,7 +48,6 @@ const SponsorFormItemPopup = ({ item, onClose, onSave, onRemoveImage }) => { {T.translate( `sponsor_form_item_list.edit_item.${item?.id ? "edit" : "new"}` )}{" "} - laksjdlkajsdklajds Date: Thu, 9 Jul 2026 09:55:18 -0300 Subject: [PATCH 04/24] fix: clean blank space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../components/sponsor-form-item-popup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b0d36d881..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 @@ -47,7 +47,7 @@ const SponsorFormItemPopup = ({ item, onClose, onSave, onRemoveImage }) => { {T.translate( `sponsor_form_item_list.edit_item.${item?.id ? "edit" : "new"}` - )}{" "} + )} Date: Sat, 1 Aug 2026 02:36:18 -0300 Subject: [PATCH 05/24] fix: remove undefined function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index a1011d397..c2c1cc66a 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1577,12 +1577,9 @@ export const saveSponsorFormManagedItem = snackbarErrorHandler, entity )(params)(dispatch) - .then(({ response }) => - saveImages(response.id).then(() => { - dispatch(snackbarSuccessHandler(successMessage)); - return response; - }) - ) + .then(() => { + dispatch(snackbarSuccessHandler(successMessage)); + }) .finally(() => { dispatch(stopLoading()); }); From 1cbbf74976f2da17fcff12d7f9f0646a48975d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Sat, 1 Aug 2026 02:45:33 -0300 Subject: [PATCH 06/24] fix: adjust image delete with current popup structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../sponsors/sponsor-form-item-list-page/index.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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..ae69d28f5 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,10 @@ const SponsorFormItemListPage = ({ ); }; + const handleRemoveItemImage = (imageId) => { + removeItemFile(formId, currentItem, imageId); + }; + const handleAddFromInventory = (itemIds) => addInventoryItems(formId, itemIds).then(() => getSponsorFormItems( @@ -359,6 +365,7 @@ const SponsorFormItemListPage = ({ item={currentItem} onSave={handleSaveItem} onClose={handleClosePopup} + onRemoveImage={handleRemoveItemImage} /> )} {openPopup === "inventory" && ( @@ -391,5 +398,6 @@ export default connect(mapStateToProps, { resetSponsorFormItem, getInventoryItems, archiveSponsorFormItem, - unarchiveSponsorFormItem + unarchiveSponsorFormItem, + removeItemFile })(SponsorFormItemListPage); From d14330b13a54cbca6c943bd71e4b925fcf1e3edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Sun, 2 Aug 2026 21:02:59 -0300 Subject: [PATCH 07/24] fix: adjust reducer, pass currentItem id on remove image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 2 +- .../sponsor-form-item-list-page/index.js | 5 ++--- .../sponsors/sponsor-form-items-list-reducer.js | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index c2c1cc66a..b82612f47 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1276,7 +1276,7 @@ export const removeItemFile = return deleteRequest( null, - createAction(SPONSOR_FORM_ITEM_FILE_DELETED), + createAction(SPONSOR_FORM_ITEM_FILE_DELETED)({ fileId }), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images/${fileId}`, null, snackbarErrorHandler 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 ae69d28f5..39cce493b 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -131,9 +131,8 @@ const SponsorFormItemListPage = ({ ); }; - const handleRemoveItemImage = (imageId) => { - removeItemFile(formId, currentItem, imageId); - }; + const handleRemoveItemImage = (imageId) => + removeItemFile(formId, currentItem?.id, imageId); const handleAddFromInventory = (itemIds) => addInventoryItems(formId, itemIds).then(() => diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 6c656794c..911c335ec 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -20,6 +20,7 @@ import { RESET_SPONSOR_FORM_ITEM, SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, + SPONSOR_FORM_ITEM_FILE_DELETED, SPONSOR_FORM_ITEM_UNARCHIVED } from "../../actions/sponsor-forms-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; @@ -116,6 +117,21 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { return { ...state, items }; } + case SPONSOR_FORM_ITEM_FILE_DELETED: { + const { fileId } = payload; + const currentItem = { + ...state.currentItem, + images: state.currentItem.images.filter((img) => img.id !== fileId) + }; + + const items = state.items.map((item) => + item.id === currentItem.id + ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } + : item + ); + + return { ...state, currentItem, items }; + } case SPONSOR_FORM_ITEM_ARCHIVED: { const { id: itemId } = payload.response; const { totalCount, perPage, currentPage } = state; From 0345ee99101d3dad1dbab1c743c8e6b0589f5df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 3 Aug 2026 23:52:58 -0300 Subject: [PATCH 08/24] fix: add unit tests, add case for SPONSOR_FORM_ITEM_FILE_DELETED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/sponsor-forms-actions.test.js | 109 +++++++++++++++++- src/actions/sponsor-forms-actions.js | 7 +- ...customized-form-items-list-reducer.test.js | 42 +++++++ .../sponsor-form-items-list-reducer.test.js | 55 +++++++++ ...nsor-customized-form-items-list-reducer.js | 17 ++- .../sponsor-form-items-list-reducer.js | 17 ++- 6 files changed, 235 insertions(+), 12 deletions(-) diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 34bb57728..05ac03b63 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -6,13 +6,16 @@ import thunk from "redux-thunk"; import flushPromises from "flush-promises"; import { getRequest, - putRequest + putRequest, + deleteRequest } from "openstack-uicore-foundation/lib/utils/actions"; import { getSponsorForms, normalizeFormTemplate, normalizeSponsorCustomizedForm, - updateFormTemplateTiers + updateFormTemplateTiers, + removeItemFile, + removeSponsorCustomizedFormItemImages } from "../sponsor-forms-actions"; import * as methods from "../../utils/methods"; @@ -21,7 +24,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 +292,103 @@ 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", 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 } + }, + `${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 }); + }); + }); }); diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index b82612f47..955bb27f2 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1276,7 +1276,10 @@ export const removeItemFile = return deleteRequest( null, - createAction(SPONSOR_FORM_ITEM_FILE_DELETED)({ fileId }), + createAction(SPONSOR_FORM_ITEM_FILE_DELETED)({ + fileId, + itemId: formItemId + }), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images/${fileId}`, null, snackbarErrorHandler @@ -1790,7 +1793,7 @@ export const removeSponsorCustomizedFormItemImages = return deleteRequest( null, - createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED), + createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED)({ fileId }), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images/${fileId}`, null, snackbarErrorHandler 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..4187aad1c 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,7 @@ 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_UPDATED } from "../../../actions/sponsor-forms-actions"; @@ -92,6 +93,47 @@ 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 } + }); + + 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 } + }); + + expect(result.currentItem.images).toEqual([]); + }); + }); + 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" }]; 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..7afffa763 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,6 +8,7 @@ import { RESET_SPONSOR_FORM_ITEM, SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, + SPONSOR_FORM_ITEM_FILE_DELETED, SPONSOR_FORM_ITEM_UNARCHIVED } from "../../../actions/sponsor-forms-actions"; @@ -268,6 +269,60 @@ 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" }] } + ]); + }); + }); + 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..e835ff6c8 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -22,7 +22,8 @@ import { SPONSOR_CUSTOMIZED_FORM_ITEM_UNARCHIVED, SPONSOR_FORM_MANAGED_ITEM_UPDATED, 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"; @@ -119,6 +120,20 @@ const sponsorCustomizedFormItemsListReducer = ( }; return { ...state, currentItem }; } + case SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED: { + const { fileId } = payload; + const currentItem = { + ...state.currentItem, + images: + state.currentItem.images?.filter((img) => img.id !== fileId) ?? [] + }; + const items = state.items.map((item) => + item.id === currentItem.id + ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } + : item + ); + return { ...state, currentItem, items }; + } case SPONSOR_CUSTOMIZED_FORM_ITEM_DELETED: { const { itemId } = payload; const items = state.items.filter((it) => it.id !== itemId); diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 911c335ec..bc3db5ea0 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -118,14 +118,19 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { return { ...state, items }; } case SPONSOR_FORM_ITEM_FILE_DELETED: { - const { fileId } = payload; - const currentItem = { - ...state.currentItem, - images: state.currentItem.images.filter((img) => img.id !== fileId) - }; + 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 === currentItem.id + item.id === itemId ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } : item ); From 28b9ed57429637f9add0fe1637339e3c282c2e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 4 Aug 2026 01:05:29 -0300 Subject: [PATCH 09/24] fix: reuse deleteItem code, adjust reducer, fix bug with double image save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/sponsor-forms-actions.test.js | 110 ++++++++++++++- src/actions/inventory-shared-actions.js | 12 +- src/actions/sponsor-forms-actions.js | 129 ++++-------------- ...customized-form-items-list-reducer.test.js | 31 ++++- ...nsor-customized-form-items-list-reducer.js | 18 ++- 5 files changed, 181 insertions(+), 119 deletions(-) diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 05ac03b63..943db3ea0 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -6,6 +6,7 @@ import thunk from "redux-thunk"; import flushPromises from "flush-promises"; import { getRequest, + postRequest, putRequest, deleteRequest } from "openstack-uicore-foundation/lib/utils/actions"; @@ -15,7 +16,9 @@ import { normalizeSponsorCustomizedForm, updateFormTemplateTiers, removeItemFile, - removeSponsorCustomizedFormItemImages + removeSponsorCustomizedFormItemImages, + saveSponsorFormItem, + updateSponsorFormItem } from "../sponsor-forms-actions"; import * as methods from "../../utils/methods"; @@ -365,7 +368,7 @@ describe("Sponsor Forms Actions", () => { jest.restoreAllMocks(); }); - it("dispatches SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED with fileId", async () => { + it("dispatches SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED with fileId and itemId", async () => { const store = mockStore({ currentSummitState: { currentSummit: { id: 42 } }, currentSponsorState: { entity: { id: 5 } } @@ -378,7 +381,7 @@ describe("Sponsor Forms Actions", () => { null, { type: "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED", - payload: { fileId: 555 } + payload: { fileId: 555, itemId: 99 } }, `${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/5/sponsor-forms/7/items/99/images/555`, null, @@ -388,7 +391,106 @@ describe("Sponsor Forms Actions", () => { const dispatched = store .getActions() .find((a) => a.type === "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED"); - expect(dispatched.payload).toEqual({ fileId: 555 }); + 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("sends the images in the create request body and makes no follow-up image request", 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).toHaveBeenCalledWith( + null, + expect.any(Function), + `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items`, + expect.objectContaining({ + images: [{ file_path: "data:image/png;base64,AAA" }] + }), + expect.any(Function) + ); + + // The item-create request itself now saves and associates the + // images — a follow-up per-image request would create duplicates. + const hitImagesEndpoint = postRequest.mock.calls.some(([, , url]) => + url.includes("/images") + ); + expect(hitImagesEndpoint).toBe(false); + }); + }); + + describe("updateSponsorFormItem", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN"); + + putRequest.mockImplementation( + () => () => () => Promise.resolve({ response: { id: 100 } }) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("sends the images in the update request body and makes no follow-up image request", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + const entity = { + id: 100, + name: "Item", + images: [{ id: 5, file_path: "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.objectContaining({ + images: [{ id: 5, file_path: "https://cdn/a.png" }] + }), + expect.any(Function) + ); + + // The item-update request itself now saves and associates the + // images — a follow-up per-image request would create duplicates. + const hitImagesEndpoint = putRequest.mock.calls.some(([, , url]) => + url.includes("/images") + ); + expect(hitImagesEndpoint).toBe(false); }); }); }); diff --git a/src/actions/inventory-shared-actions.js b/src/actions/inventory-shared-actions.js index 69da56874..db31b94d5 100644 --- a/src/actions/inventory-shared-actions.js +++ b/src/actions/inventory-shared-actions.js @@ -273,13 +273,15 @@ 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) + .catch(() => {}) + .finally(() => { + dispatch(stopLoading()); + }); }; /* ************************************ ARCHIVE ************************************ */ diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 955bb27f2..1d573e501 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 { deleteFile } from "./inventory-shared-actions"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -106,8 +107,6 @@ 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_ITEMS_ADDED = "SPONSOR_FORM_ITEMS_ADDED"; export const SPONSOR_FORM_ITEM_ARCHIVED = "SPONSOR_FORM_ITEM_ARCHIVED"; @@ -1234,58 +1233,19 @@ export const deleteSponsorFormItem = }); }; -const saveItemImages = - (formId, formItemId, images) => 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); - }); - - return Promise.all(promises); - }; - export const removeItemFile = (formId, formItemId, fileId) => async (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - const accessToken = await getAccessTokenSafely(); - const params = { access_token: accessToken }; - dispatch(startLoading()); + 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 deleteRequest( - null, - createAction(SPONSOR_FORM_ITEM_FILE_DELETED)({ - fileId, - itemId: formItemId - }), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images/${fileId}`, - null, - snackbarErrorHandler - )(params)(dispatch).finally(() => { - dispatch(stopLoading()); - }); + return deleteFile(fileId, settings)(dispatch); }; export const saveSponsorFormItem = @@ -1309,27 +1269,13 @@ 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(() => { - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_form_item_list.edit_item.created") - }) - ); - }); + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("sponsor_form_item_list.edit_item.created") + }) + ); }) .finally(() => { dispatch(stopLoading()); @@ -1358,26 +1304,12 @@ export const updateSponsorFormItem = 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(() => { - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_form_item_list.edit_item.updated") - }) - ); - }); + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("sponsor_form_item_list.edit_item.updated") + }) + ); }) .catch((err) => { throw err; @@ -1786,18 +1718,13 @@ export const removeSponsorCustomizedFormItemImages = const { entity: { id: sponsorId } } = currentSponsorState; - const accessToken = await getAccessTokenSafely(); - const params = { access_token: accessToken }; - dispatch(startLoading()); + 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 deleteRequest( - null, - createAction(SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED)({ fileId }), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images/${fileId}`, - null, - snackbarErrorHandler - )(params)(dispatch).finally(() => { - dispatch(stopLoading()); - }); + return deleteFile(fileId, settings)(dispatch); }; 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 4187aad1c..062b3ec20 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 @@ -110,7 +110,7 @@ describe("sponsorCustomizedFormItemsListReducer", () => { const result = sponsorCustomizedFormItemsListReducer(state, { type: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, - payload: { fileId: 10 } + payload: { fileId: 10, itemId: 1 } }); expect(result.currentItem.images).toEqual([{ id: 11 }]); @@ -127,11 +127,38 @@ describe("sponsorCustomizedFormItemsListReducer", () => { const result = sponsorCustomizedFormItemsListReducer(state, { type: SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED, - payload: { fileId: 10 } + 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_UPDATED", () => { 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 e835ff6c8..8f2ec2b94 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -121,14 +121,18 @@ const sponsorCustomizedFormItemsListReducer = ( return { ...state, currentItem }; } case SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED: { - const { fileId } = payload; - const currentItem = { - ...state.currentItem, - images: - state.currentItem.images?.filter((img) => img.id !== fileId) ?? [] - }; + 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 === currentItem.id + item.id === itemId ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } : item ); From 0047fd15e2155b3d3eecd1303f483b75910fb465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 4 Aug 2026 10:37:23 -0300 Subject: [PATCH 10/24] fix: add catch for no images on reducer delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/reducers/sponsors/sponsor-form-items-list-reducer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index bc3db5ea0..aeaf2d62c 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -123,9 +123,9 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { state.currentItem.id === itemId ? { ...state.currentItem, - images: state.currentItem.images.filter( - (img) => img.id !== fileId - ) + images: + state.currentItem.images?.filter((img) => img.id !== fileId) ?? + [] } : state.currentItem; From ce8df2dc6f7982995abdfa9d3d48db5036691a28 Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 6 Aug 2026 16:56:02 -0300 Subject: [PATCH 11/24] test: add failing coverage for RECEIVE_SPONSOR_FORM_ITEM dropping file_path on images Mirrors the customized-item reducer's existing file_url -> file_path mapping. Currently red: RECEIVE_SPONSOR_FORM_ITEM stores fetched images as-is, so normalizeItem strips them from the update body and ShowFormItemService.update() deletes all images on save. --- .../sponsor-form-items-list-reducer.test.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 7afffa763..d9570101a 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 @@ -190,6 +190,42 @@ describe("SponsorFormItemsListReducer", () => { } }); }); + + it("maps file_url to file_path on each image - mirrors the customized-item reducer's edit-form image fix", () => { + 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", + file_path: "https://cdn/a.png" + }, + { + id: 11, + file_url: "https://cdn/b.png", + file_path: "https://cdn/b.png" + } + ]); + }); }); describe("RESET_SPONSOR_FORM_ITEM", () => { From d5a4f43ea29d4a11897582ee39e9605dd6d4f388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 7 Aug 2026 02:27:29 -0300 Subject: [PATCH 12/24] fix: change reducer to fix mapping images, add guard on delete images from item without id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../sponsors-global/inventory/inventory-list-page.js | 1 + src/pages/sponsors/sponsor-form-item-list-page/index.js | 6 ++++-- .../components/manage-items/sponsor-forms-manage-items.js | 1 + src/reducers/sponsors/sponsor-form-items-list-reducer.js | 8 +++++--- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index 662b55024..81fa856c2 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -150,6 +150,7 @@ const InventoryListPage = ({ : archiveInventoryItem(item); const handleRemoveImage = (imageId) => { + if (!currentInventoryItem?.id) return; deleteInventoryItemImage(currentInventoryItem.id, imageId); }; 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 39cce493b..4eb853833 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -131,8 +131,10 @@ const SponsorFormItemListPage = ({ ); }; - const handleRemoveItemImage = (imageId) => - removeItemFile(formId, currentItem?.id, imageId); + const handleRemoveItemImage = (imageId) => { + if (!currentItem?.id) return; + removeItemFile(formId, currentItem.id, imageId); + }; const handleAddFromInventory = (itemIds) => addInventoryItems(formId, itemIds).then(() => 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 0d6fd64fe..828eab25e 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 @@ -203,6 +203,7 @@ const SponsorFormsManageItems = ({ }; const handleImageRemove = (imageId) => { + if (!currentInventoryItem?.id) return; removeSponsorCustomizedFormItemImages( formId, currentInventoryItem.id, diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index aeaf2d62c..3f530461a 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -100,12 +100,14 @@ 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 || []).map((img) => ({ + ...img, + file_path: img.file_url + })), + meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; - return { ...state, currentItem }; } case RESET_SPONSOR_FORM_ITEM: { From c72289f6fcaaa8364f7ac044097c0d41e069d0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 13 Aug 2026 14:36:40 -0300 Subject: [PATCH 13/24] fix: change image saving, remove from entity, and save after on .then scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/sponsor-forms-actions.test.js | 74 +++++++++++++---- src/actions/sponsor-forms-actions.js | 82 ++++++++++++++----- 2 files changed, 117 insertions(+), 39 deletions(-) diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 943db3ea0..381d8b4ef 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -411,7 +411,7 @@ describe("Sponsor Forms Actions", () => { jest.restoreAllMocks(); }); - it("sends the images in the create request body and makes no follow-up image request", async () => { + it("omits images from the create request body and POSTs new uploads to the images subresource", async () => { const store = mockStore({ currentSummitState: { currentSummit: { id: 42 } } }); @@ -425,22 +425,27 @@ describe("Sponsor Forms Actions", () => { await store.dispatch(saveSponsorFormItem(7, entity)); await flushPromises(); - expect(postRequest).toHaveBeenCalledWith( + expect(postRequest).toHaveBeenNthCalledWith( + 1, null, expect.any(Function), `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items`, - expect.objectContaining({ - images: [{ file_path: "data:image/png;base64,AAA" }] - }), + expect.not.objectContaining({ images: expect.anything() }), expect.any(Function) ); - // The item-create request itself now saves and associates the - // images — a follow-up per-image request would create duplicates. - const hitImagesEndpoint = postRequest.mock.calls.some(([, , url]) => - url.includes("/images") + // 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" } ); - expect(hitImagesEndpoint).toBe(false); }); }); @@ -454,13 +459,19 @@ describe("Sponsor Forms Actions", () => { 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("sends the images in the update request body and makes no follow-up image request", async () => { + it("omits persisted images from the update request body so they are never round-tripped", async () => { const store = mockStore({ currentSummitState: { currentSummit: { id: 42 } } }); @@ -479,18 +490,45 @@ describe("Sponsor Forms Actions", () => { null, expect.any(Function), `${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100`, - expect.objectContaining({ - images: [{ id: 5, file_path: "https://cdn/a.png" }] - }), + expect.not.objectContaining({ images: expect.anything() }), expect.any(Function) ); - // The item-update request itself now saves and associates the - // images — a follow-up per-image request would create duplicates. - const hitImagesEndpoint = putRequest.mock.calls.some(([, , url]) => - url.includes("/images") + // 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_path: "https://cdn/a.png" }, + { file_path: "data:image/png;base64,BBB" } + ], + meta_fields: [] + }; + + await store.dispatch(updateSponsorFormItem(7, entity)); + await flushPromises(); + + 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" } + ); + }); }); }); diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 1d573e501..43ba71a3d 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1269,14 +1269,20 @@ export const saveSponsorFormItem = normalizedEntity, snackbarErrorHandler )(params)(dispatch) - .then(() => { - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_form_item_list.edit_item.created") - }) - ); - }) + .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()); }); @@ -1303,14 +1309,20 @@ export const updateSponsorFormItem = normalizedEntity, snackbarErrorHandler )(params)(dispatch) - .then(() => { - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_form_item_list.edit_item.updated") - }) - ); - }) + .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; }) @@ -1397,17 +1409,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; @@ -1418,6 +1432,32 @@ const normalizeItem = (entity) => { return normalizedEntity; }; +const saveNewItemImages = + (formId, formItemId, images = []) => + async (dispatch, getState) => { + const newImages = images.filter((img) => !img.id && img.file_path); + + if (newImages.length === 0) return Promise.resolve(); + + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + const params = { access_token: accessToken }; + + const promises = newImages.map((file) => + postRequest( + null, + createAction(SPONSOR_FORM_ITEM_UPDATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, + file, + snackbarErrorHandler, + file + )(params)(dispatch) + ); + + return Promise.all(promises); + }; + export const addInventoryItems = (formId, itemIds) => async (dispatch, getState) => { const { currentSummitState } = getState(); From 50da82f927f14bea37f10611eb2d8d2b104c118d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 19 Aug 2026 14:53:36 -0300 Subject: [PATCH 14/24] fix: adjust action for new items, store on reducer like it comes from API, adjust tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/sponsor-forms-actions.test.js | 8 ++++++-- src/actions/sponsor-forms-actions.js | 2 +- .../sponsor-form-items-list-reducer.test.js | 14 +++----------- .../sponsors/sponsor-form-items-list-reducer.js | 4 ---- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 381d8b4ef..99f6bbe12 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -479,7 +479,7 @@ describe("Sponsor Forms Actions", () => { const entity = { id: 100, name: "Item", - images: [{ id: 5, file_path: "https://cdn/a.png" }], + images: [{ id: 5, file_url: "https://cdn/a.png" }], meta_fields: [] }; @@ -512,7 +512,7 @@ describe("Sponsor Forms Actions", () => { id: 100, name: "Item", images: [ - { id: 5, file_path: "https://cdn/a.png" }, + { id: 5, file_url: "https://cdn/a.png" }, { file_path: "data:image/png;base64,BBB" } ], meta_fields: [] @@ -521,6 +521,10 @@ describe("Sponsor Forms Actions", () => { 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), diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 43ba71a3d..4efe15af8 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1435,7 +1435,7 @@ const normalizeItem = (entity) => { const saveNewItemImages = (formId, formItemId, images = []) => async (dispatch, getState) => { - const newImages = images.filter((img) => !img.id && img.file_path); + const newImages = images.filter((img) => img.file_path); if (newImages.length === 0) return Promise.resolve(); 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 d9570101a..46acaee43 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 @@ -191,7 +191,7 @@ describe("SponsorFormItemsListReducer", () => { }); }); - it("maps file_url to file_path on each image - mirrors the customized-item reducer's edit-form image fix", () => { + it("keeps images in their API shape (id, file_url) without a file_path mapping", () => { const item = { id: "A", code: "A", @@ -214,16 +214,8 @@ describe("SponsorFormItemsListReducer", () => { }); expect(result.currentItem.images).toEqual([ - { - id: 10, - file_url: "https://cdn/a.png", - file_path: "https://cdn/a.png" - }, - { - id: 11, - file_url: "https://cdn/b.png", - file_path: "https://cdn/b.png" - } + { id: 10, file_url: "https://cdn/a.png" }, + { id: 11, file_url: "https://cdn/b.png" } ]); }); }); diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 3f530461a..ad6127f95 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -102,10 +102,6 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { const item = payload.response; const currentItem = { ...item, - images: (item.images || []).map((img) => ({ - ...img, - file_path: img.file_url - })), meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; From 99e0271caaf9634d38120bb739467b3f07feea6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 19 Aug 2026 16:51:37 -0300 Subject: [PATCH 15/24] fix: adjust reducers on sponsor form item list and customized items to keep API data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../sponsor-form-items-list-reducer.test.js | 44 +++++++++++++++++++ ...nsor-customized-form-items-list-reducer.js | 4 -- .../sponsor-form-items-list-reducer.js | 6 ++- 3 files changed, 49 insertions(+), 5 deletions(-) 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 46acaee43..7644e16b0 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 @@ -218,6 +218,27 @@ describe("SponsorFormItemsListReducer", () => { { 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", () => { @@ -349,6 +370,29 @@ describe("SponsorFormItemsListReducer", () => { { 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_ARCHIVED", () => { 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 8f2ec2b94..2b35b0ea8 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -112,10 +112,6 @@ const sponsorCustomizedFormItemsListReducer = ( const currentItem = { ...item, - images: (item.images || []).map((img) => ({ - ...img, - file_path: img.file_url - })), meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index ad6127f95..54bfd9631 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -102,6 +102,7 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { const item = payload.response; const currentItem = { ...item, + images: item.images ?? [], meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; @@ -129,7 +130,10 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { const items = state.items.map((item) => item.id === itemId - ? { ...item, images: item.images?.filter((img) => img.id !== fileId) } + ? { + ...item, + images: item.images?.filter((img) => img.id !== fileId) ?? [] + } : item ); From da95302aebb8d6295541e1b9c59957260e248c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 20 Aug 2026 07:46:28 -0300 Subject: [PATCH 16/24] fix: adjusting test to match the reducer adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../sponsor-customized-form-items-list-reducer.test.js | 6 ++---- .../sponsors/sponsor-customized-form-items-list-reducer.js | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) 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 062b3ec20..57465d3c5 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 @@ -63,13 +63,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" } ]); }); 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 2b35b0ea8..c1c57e894 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -112,6 +112,7 @@ const sponsorCustomizedFormItemsListReducer = ( const currentItem = { ...item, + images: item.images ?? [], meta_fields: (item.meta_fields ?? []).length > 0 ? item.meta_fields : [] }; return { ...state, currentItem }; From aca490bdfeba4c77e07005b3ff9da92be61c4482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 20 Aug 2026 14:01:37 -0300 Subject: [PATCH 17/24] fix: add cases for image added, save managed item images after save, update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../__tests__/sponsor-forms-actions.test.js | 142 +++++++++++++++++- src/actions/sponsor-forms-actions.js | 89 ++++++++--- .../form-template-item-list-page.js | 1 + ...customized-form-items-list-reducer.test.js | 74 ++++++++- .../sponsor-form-items-list-reducer.test.js | 81 ++++++++++ ...nsor-customized-form-items-list-reducer.js | 19 +++ .../sponsor-form-items-list-reducer.js | 19 +++ 7 files changed, 406 insertions(+), 19 deletions(-) diff --git a/src/actions/__tests__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 99f6bbe12..985611629 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -18,7 +18,8 @@ import { removeItemFile, removeSponsorCustomizedFormItemImages, saveSponsorFormItem, - updateSponsorFormItem + updateSponsorFormItem, + saveSponsorFormManagedItem } from "../sponsor-forms-actions"; import * as methods from "../../utils/methods"; @@ -533,6 +534,145 @@ describe("Sponsor Forms Actions", () => { expect.any(Function), { file_path: "data:image/png;base64,BBB" } ); + + // The received-action creator must produce SPONSOR_FORM_ITEM_IMAGE_ADDED + // carrying {image, 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: { image: 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: { image: 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" } + ); }); }); }); diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 4efe15af8..62bc96ff4 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -100,6 +100,8 @@ 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"; @@ -108,6 +110,7 @@ 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_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"; @@ -1447,7 +1450,11 @@ const saveNewItemImages = const promises = newImages.map((file) => postRequest( null, - createAction(SPONSOR_FORM_ITEM_UPDATED), + ({ response: image }) => + createAction(SPONSOR_FORM_ITEM_IMAGE_ADDED)({ + image, + itemId: formItemId + }), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, file, snackbarErrorHandler, @@ -1521,16 +1528,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()); }); @@ -1552,9 +1565,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()); }); @@ -1569,13 +1588,49 @@ 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 accessToken = await getAccessTokenSafely(); + const params = { access_token: accessToken }; + + const promises = newImages.map((file) => + postRequest( + null, + ({ response: image }) => + createAction(SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED)({ + image, + itemId: formItemId + }), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images`, + file, + snackbarErrorHandler, + file + )(params)(dispatch) + ); + + return Promise.all(promises); + }; + export const deleteSponsorFormManagedItem = (formId, itemId) => async (dispatch, getState) => { const { currentSummitState, currentSponsorState } = getState(); 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 d0fe99db9..9d7acc6ed 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 @@ -188,6 +188,7 @@ const FormTemplateItemListPage = ({ ); const handleRemoveImage = (imageId) => { + if (!currentFormTemplateItem?.id) return; deleteItemImage(formTemplateId, currentFormTemplateItem.id, imageId); }; 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 57465d3c5..3259668d4 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 @@ -2,6 +2,7 @@ import sponsorCustomizedFormItemsListReducer from "../sponsor-customized-form-it 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"; @@ -47,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: { @@ -159,6 +160,77 @@ describe("sponsorCustomizedFormItemsListReducer", () => { }); }); + describe("SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED", () => { + it("appends the new image to currentItem and its matching list item", () => { + const state = { + ...DEFAULT_STATE, + currentItem: { + ...DEFAULT_STATE.currentItem, + id: 1, + images: [{ id: 10 }] + }, + 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: { image: { id: 11 }, itemId: 1 } + }); + + expect(result.currentItem.images).toEqual([{ id: 10 }, { id: 11 }]); + expect(result.items[0].images).toEqual([{ id: 10 }, { id: 11 }]); + 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: { image: { 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 }]); + }); + + it("defaults the matching list item's images to [] before appending when it has none", () => { + const state = { + ...DEFAULT_STATE, + currentItem: { ...DEFAULT_STATE.currentItem, id: 1, images: undefined }, + items: [buildItem({ id: 1, images: undefined })] + }; + + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + payload: { image: { id: 10 }, itemId: 1 } + }); + + expect(result.currentItem.images).toEqual([{ id: 10 }]); + expect(result.items[0].images).toEqual([{ id: 10 }]); + }); + }); + 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" }]; 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 7644e16b0..ca6360219 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 @@ -9,6 +9,7 @@ import { SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, SPONSOR_FORM_ITEM_FILE_DELETED, + SPONSOR_FORM_ITEM_IMAGE_ADDED, SPONSOR_FORM_ITEM_UNARCHIVED } from "../../../actions/sponsor-forms-actions"; @@ -395,6 +396,86 @@ describe("SponsorFormItemsListReducer", () => { }); }); + describe("SPONSOR_FORM_ITEM_IMAGE_ADDED", () => { + it("appends the new image to currentItem and its matching list item", () => { + const state = { + ...initialState, + currentItem: { + ...initialState.currentItem, + id: "A", + images: [{ id: "IMG_1" }] + }, + items: [ + { id: "A", images: [{ id: "IMG_1" }] }, + { id: "B", images: [{ id: "IMG_3" }] } + ] + }; + + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_IMAGE_ADDED, + payload: { image: { id: "IMG_2" }, itemId: "A" } + }); + + expect(result.currentItem.images).toStrictEqual([ + { id: "IMG_1" }, + { id: "IMG_2" } + ]); + expect(result.items).toStrictEqual([ + { id: "A", images: [{ id: "IMG_1" }, { id: "IMG_2" }] }, + { 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: { image: { 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" }] } + ]); + }); + + it("defaults the matching list item's images to [] before appending 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_IMAGE_ADDED, + payload: { image: { id: "IMG_1" }, itemId: "A" } + }); + + expect(result.currentItem.images).toStrictEqual([{ id: "IMG_1" }]); + expect(result.items[0].images).toStrictEqual([{ id: "IMG_1" }]); + }); + }); + 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 c1c57e894..e40cb2d37 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -21,6 +21,7 @@ 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, SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED @@ -135,6 +136,24 @@ const sponsorCustomizedFormItemsListReducer = ( ); return { ...state, currentItem, items }; } + case SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED: { + const { 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); diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 54bfd9631..0130675d9 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -21,6 +21,7 @@ import { SPONSOR_FORM_ITEM_ARCHIVED, SPONSOR_FORM_ITEM_DELETED, SPONSOR_FORM_ITEM_FILE_DELETED, + SPONSOR_FORM_ITEM_IMAGE_ADDED, SPONSOR_FORM_ITEM_UNARCHIVED } from "../../actions/sponsor-forms-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; @@ -139,6 +140,24 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { return { ...state, currentItem, items }; } + case SPONSOR_FORM_ITEM_IMAGE_ADDED: { + const { 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_ARCHIVED: { const { id: itemId } = payload.response; const { totalCount, perPage, currentPage } = state; From d6da910db40c02d1714c5b34c14dbb320abc3a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 20 Aug 2026 14:33:50 -0300 Subject: [PATCH 18/24] fix: adjust inventory remove image reducer, update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../form-template-item-list-page.test.js | 82 +++++++++++++- .../__tests__/inventory-list-page.test.js | 98 ++++++++++++++++ .../sponsor-form-item-list-page.test.js | 52 ++++++++- .../sponsor-forms-manage-items.test.js | 107 ++++++++++++++++++ ...customized-form-items-list-reducer.test.js | 69 ++++++----- .../sponsor-form-items-list-reducer.test.js | 88 +++++++------- .../__tests__/inventory-item-reducer.test.js | 22 ++++ .../inventory-item-reducer.js | 6 +- 8 files changed, 424 insertions(+), 100 deletions(-) create mode 100644 src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js create mode 100644 src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/__tests__/sponsor-forms-manage-items.test.js create mode 100644 src/reducers/sponsors_inventory/__tests__/inventory-item-reducer.test.js 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..d0324c0f5 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,21 @@ 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 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()) })); jest.mock("../../../../actions/form-template-actions", () => ({ @@ -16,6 +23,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 +55,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 +75,7 @@ describe("FormTemplateItemListPage", () => { errors: {} }, currentFormTemplateItemState: { - entity: {}, + entity: currentFormTemplateItem, errors: {} } }); @@ -74,4 +109,39 @@ 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", { id: 55 }, [formTemplateId, 55, 999]] + ])( + "calling deleteItemImage for %s", + async (_label, currentFormTemplateItem, expectedCall) => { + renderWithRedux( + , + { + initialState: buildInitialState({ + formTemplateItems: [{ id: 1, code: "A", name: "Item A" }], + currentFormTemplateItem + }) + } + ); + + await openItemDialog(); + + if (expectedCall) { + expect(deleteItemImage).toHaveBeenCalledWith(...expectedCall); + } else { + expect(deleteItemImage).not.toHaveBeenCalled(); + } + } + ); + }); }); 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..1320c44f7 --- /dev/null +++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js @@ -0,0 +1,98 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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()) +})); + +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", { id: 77 }, [77, 999]] + ])( + "calling deleteInventoryItemImage for %s", + async (_label, currentInventoryItem, expectedCall) => { + renderWithRedux(, { + initialState: buildInitialState({ + inventoryItems: [{ id: 1, code: "A", name: "Item A" }], + currentInventoryItem + }) + }); + + await openItemDialog(); + + if (expectedCall) { + expect(deleteInventoryItemImage).toHaveBeenCalledWith(...expectedCall); + } else { + expect(deleteInventoryItemImage).not.toHaveBeenCalled(); + } + } + ); +}); 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..996c12921 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 @@ -8,7 +8,8 @@ jest.mock("../../../../actions/sponsor-forms-actions", () => ({ ...jest.requireActual("../../../../actions/sponsor-forms-actions"), getSponsorFormItems: jest.fn(() => () => Promise.resolve()), updateSponsorFormItem: jest.fn(() => () => Promise.resolve()), - addInventoryItems: jest.fn(() => () => Promise.resolve()) + addInventoryItems: jest.fn(() => () => Promise.resolve()), + removeItemFile: jest.fn(() => () => Promise.resolve()) })); jest.mock("../../../../actions/inventory-item-actions", () => ({ @@ -26,10 +27,23 @@ jest.mock( } ); +jest.mock( + "../components/sponsor-form-item-popup", + () => + function MockSponsorFormItemPopup({ onRemoveImage }) { + return ( + + ); + } +); + const { getSponsorFormItems, updateSponsorFormItem, - addInventoryItems + addInventoryItems, + removeItemFile } = require("../../../../actions/sponsor-forms-actions"); const buildItem = (id) => ({ @@ -44,7 +58,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 +136,33 @@ 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", { id: 42 }, ["FORM1", 42, 999]] + ])( + "calling removeItemFile for %s", + async (_label, currentItem, expectedCall) => { + renderPage(currentItem); + + await openItemPopup(); + + if (expectedCall) { + expect(removeItemFile).toHaveBeenCalledWith(...expectedCall); + } else { + expect(removeItemFile).not.toHaveBeenCalled(); + } + } + ); +}); 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..137742d81 --- /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,107 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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()) +})); + +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", { id: 33 }, ["FORM1", 33, 999]] + ])( + "calling removeSponsorCustomizedFormItemImages for %s", + async (_label, currentItem, expectedCall) => { + renderWithRedux( + , + { + initialState: buildInitialState({ + items: [{ id: 1, code: "A", name: "Item A" }], + currentItem + }) + } + ); + + await openItemDialog(); + + if (expectedCall) { + expect(removeSponsorCustomizedFormItemImages).toHaveBeenCalledWith( + ...expectedCall + ); + } else { + expect(removeSponsorCustomizedFormItemImages).not.toHaveBeenCalled(); + } + } + ); +}); 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 3259668d4..0de72b749 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 @@ -161,29 +161,40 @@ describe("sponsorCustomizedFormItemsListReducer", () => { }); describe("SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED", () => { - it("appends the new image to currentItem and its matching list item", () => { - const state = { - ...DEFAULT_STATE, - currentItem: { - ...DEFAULT_STATE.currentItem, - id: 1, - images: [{ id: 10 }] - }, - items: [ - buildItem({ id: 1, images: [{ id: 10 }] }), - buildItem({ id: 2, images: [{ id: 12 }] }) - ] - }; + 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: { image: { id: 11 }, itemId: 1 } - }); + const result = sponsorCustomizedFormItemsListReducer(state, { + type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, + payload: { image: newImage, itemId: 1 } + }); - expect(result.currentItem.images).toEqual([{ id: 10 }, { id: 11 }]); - expect(result.items[0].images).toEqual([{ id: 10 }, { id: 11 }]); - expect(result.items[1].images).toEqual([{ id: 12 }]); - }); + 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 @@ -213,22 +224,6 @@ describe("sponsorCustomizedFormItemsListReducer", () => { expect(result.items[0].images).toEqual([{ id: 10 }, { id: 11 }]); expect(result.items[1].images).toEqual([{ id: 12 }]); }); - - it("defaults the matching list item's images to [] before appending when it has none", () => { - const state = { - ...DEFAULT_STATE, - currentItem: { ...DEFAULT_STATE.currentItem, id: 1, images: undefined }, - items: [buildItem({ id: 1, images: undefined })] - }; - - const result = sponsorCustomizedFormItemsListReducer(state, { - type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, - payload: { image: { id: 10 }, itemId: 1 } - }); - - expect(result.currentItem.images).toEqual([{ id: 10 }]); - expect(result.items[0].images).toEqual([{ id: 10 }]); - }); }); describe("SPONSOR_FORM_MANAGED_ITEM_UPDATED", () => { 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 ca6360219..60d2b77ad 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 @@ -397,34 +397,47 @@ describe("SponsorFormItemsListReducer", () => { }); describe("SPONSOR_FORM_ITEM_IMAGE_ADDED", () => { - it("appends the new image to currentItem and its matching list item", () => { - const state = { - ...initialState, - currentItem: { - ...initialState.currentItem, - id: "A", - images: [{ id: "IMG_1" }] - }, - items: [ - { id: "A", images: [{ id: "IMG_1" }] }, - { id: "B", images: [{ id: "IMG_3" }] } - ] - }; + 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: { image: { id: "IMG_2" }, itemId: "A" } - }); + result = SponsorFormItemsListReducer(state, { + type: SPONSOR_FORM_ITEM_IMAGE_ADDED, + payload: { image: newImage, itemId: "A" } + }); - expect(result.currentItem.images).toStrictEqual([ - { id: "IMG_1" }, - { id: "IMG_2" } - ]); - expect(result.items).toStrictEqual([ - { id: "A", images: [{ id: "IMG_1" }, { id: "IMG_2" }] }, - { id: "B", images: [{ id: "IMG_3" }] } - ]); - }); + 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 = { @@ -451,29 +464,6 @@ describe("SponsorFormItemsListReducer", () => { { id: "B", images: [{ id: "IMG_3" }] } ]); }); - - it("defaults the matching list item's images to [] before appending 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_IMAGE_ADDED, - payload: { image: { id: "IMG_1" }, itemId: "A" } - }); - - expect(result.currentItem.images).toStrictEqual([{ id: "IMG_1" }]); - expect(result.items[0].images).toStrictEqual([{ id: "IMG_1" }]); - }); }); describe("SPONSOR_FORM_ITEM_ARCHIVED", () => { 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/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: { From 7ca7b44dc64bbd509f7e393ef6c4ec888fbeded2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 20 Aug 2026 17:12:57 -0300 Subject: [PATCH 19/24] fix: adjust expand on images, and inline update, add case for sponsor form item update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/sponsor-forms-actions.js | 1 + .../sponsor-form-items-list-reducer.test.js | 113 +++++++++++++++++- ...nsor-customized-form-items-list-reducer.js | 2 +- .../sponsor-form-items-list-reducer.js | 22 +++- 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 62bc96ff4..fc14082b6 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1514,6 +1514,7 @@ export const saveSponsorFormManagedItem = dispatch(startLoading()); const params = { + expand: "images", access_token: accessToken }; 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 60d2b77ad..9743bcb09 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 @@ -10,7 +10,8 @@ import { SPONSOR_FORM_ITEM_DELETED, SPONSOR_FORM_ITEM_FILE_DELETED, SPONSOR_FORM_ITEM_IMAGE_ADDED, - SPONSOR_FORM_ITEM_UNARCHIVED + SPONSOR_FORM_ITEM_UNARCHIVED, + SPONSOR_FORM_ITEM_UPDATED } from "../../../actions/sponsor-forms-actions"; function createDefaultState() { @@ -466,6 +467,116 @@ describe("SponsorFormItemsListReducer", () => { }); }); + 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 e40cb2d37..bbd105375 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -201,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 0130675d9..0a34a6e68 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -22,7 +22,8 @@ import { SPONSOR_FORM_ITEM_DELETED, SPONSOR_FORM_ITEM_FILE_DELETED, SPONSOR_FORM_ITEM_IMAGE_ADDED, - SPONSOR_FORM_ITEM_UNARCHIVED + 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"; @@ -158,6 +159,25 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { 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; From 08b09206a383f173e703f2cea1067f8501647abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 20 Aug 2026 17:23:18 -0300 Subject: [PATCH 20/24] fix: add test case for inline edit and images omission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- ...customized-form-items-list-reducer.test.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 0de72b749..9a0a8fc40 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 @@ -255,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); + }); }); }); From 88e23fa81459674d8dfc446eb6ab151574f4bd9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 21 Aug 2026 00:33:28 -0300 Subject: [PATCH 21/24] fix: expand images on save, return flag on delete images, reuse saveFiles, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../inventory-shared-actions.test.js | 43 ++++++++++++++ .../__tests__/sponsor-forms-actions.test.js | 6 +- src/actions/form-template-item-actions.js | 3 +- src/actions/inventory-item-actions.js | 3 +- src/actions/inventory-shared-actions.js | 17 ++++-- src/actions/sponsor-forms-actions.js | 56 +++++++------------ .../form-template-item-list-page.test.js | 2 +- .../form-template-item-list-page.js | 5 +- .../__tests__/inventory-list-page.test.js | 2 +- .../inventory/inventory-list-page.js | 5 +- .../sponsor-forms-manage-items.test.js | 4 +- .../sponsor-forms-manage-items.js | 9 +-- ...customized-form-items-list-reducer.test.js | 4 +- .../sponsor-form-items-list-reducer.test.js | 4 +- ...nsor-customized-form-items-list-reducer.js | 2 +- .../sponsor-form-items-list-reducer.js | 2 +- .../form-template-item-list-reducer.test.js | 38 +++++++++++++ .../inventory-item-list-reducer.test.js | 38 +++++++++++++ .../form-template-item-list-reducer.js | 15 ++++- .../inventory-item-list-reducer.js | 15 ++++- 20 files changed, 210 insertions(+), 63 deletions(-) create mode 100644 src/actions/__tests__/inventory-shared-actions.test.js create mode 100644 src/reducers/sponsors_inventory/__tests__/form-template-item-list-reducer.test.js create mode 100644 src/reducers/sponsors_inventory/__tests__/inventory-item-list-reducer.test.js 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 985611629..16d93b111 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -536,13 +536,13 @@ describe("Sponsor Forms Actions", () => { ); // The received-action creator must produce SPONSOR_FORM_ITEM_IMAGE_ADDED - // carrying {image, itemId} - not the item-shaped SPONSOR_FORM_ITEM_UPDATED, + // 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: { image: uploadedImage, itemId: 100 } + payload: { response: uploadedImage, itemId: 100 } }); }); }); @@ -635,7 +635,7 @@ describe("Sponsor Forms Actions", () => { const uploadedImage = { id: 55, file_url: "https://cdn/new.png" }; expect(receivedActionCreator({ response: uploadedImage })).toEqual({ type: "SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED", - payload: { image: uploadedImage, itemId: 100 } + payload: { response: uploadedImage, itemId: 100 } }); }); diff --git a/src/actions/form-template-item-actions.js b/src/actions/form-template-item-actions.js index fe6d3510b..7160c3369 100644 --- a/src/actions/form-template-item-actions.js +++ b/src/actions/form-template-item-actions.js @@ -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..cf6449e8e 100644 --- a/src/actions/inventory-item-actions.js +++ b/src/actions/inventory-item-actions.js @@ -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 db31b94d5..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(); @@ -278,7 +286,8 @@ export const deleteFile = null, settings.errorHandler ?? snackbarErrorHandler )(params)(dispatch) - .catch(() => {}) + .then(() => true) + .catch(() => false) .finally(() => { dispatch(stopLoading()); }); diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index fc14082b6..913591f2c 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -31,7 +31,7 @@ import { getAccessTokenSafely, normalizeSelectAllField } from "../utils/methods"; -import { deleteFile } from "./inventory-shared-actions"; +import { saveFiles, deleteFile } from "./inventory-shared-actions"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -1260,7 +1260,8 @@ export const saveSponsorFormItem = dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + expand: "images" }; const normalizedEntity = normalizeItem(entity); @@ -1300,7 +1301,8 @@ export const updateSponsorFormItem = dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + expand: "images" }; const normalizedEntity = normalizeItem(entity); @@ -1444,25 +1446,15 @@ const saveNewItemImages = const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; - const accessToken = await getAccessTokenSafely(); - const params = { access_token: accessToken }; - const promises = newImages.map((file) => - postRequest( - null, - ({ response: image }) => - createAction(SPONSOR_FORM_ITEM_IMAGE_ADDED)({ - image, - itemId: formItemId - }), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/show-forms/${formId}/items/${formItemId}/images`, - file, - snackbarErrorHandler, - file - )(params)(dispatch) - ); + 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 Promise.all(promises); + return saveFiles(newImages, settings)(dispatch); }; export const addInventoryItems = @@ -1611,25 +1603,15 @@ const saveNewManagedItemImages = const { entity: { id: sponsorId } } = currentSponsorState; - const accessToken = await getAccessTokenSafely(); - const params = { access_token: accessToken }; - const promises = newImages.map((file) => - postRequest( - null, - ({ response: image }) => - createAction(SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED)({ - image, - itemId: formItemId - }), - `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsor-forms/${formId}/items/${formItemId}/images`, - file, - snackbarErrorHandler, - file - )(params)(dispatch) - ); + 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 Promise.all(promises); + return saveFiles(newImages, settings)(dispatch); }; export const deleteSponsorFormManagedItem = 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 d0324c0f5..56b3d313a 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 @@ -15,7 +15,7 @@ jest.mock("../../../../actions/form-template-item-actions", () => ({ ...jest.requireActual("../../../../actions/form-template-item-actions"), getFormTemplateItems: jest.fn(() => () => Promise.resolve()), getFormTemplateItem: jest.fn(() => () => Promise.resolve()), - deleteItemImage: jest.fn(() => () => Promise.resolve()) + deleteItemImage: jest.fn(() => () => Promise.resolve(true)) })); jest.mock("../../../../actions/form-template-actions", () => ({ 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 9d7acc6ed..9362e0c6f 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 @@ -189,7 +189,10 @@ const FormTemplateItemListPage = ({ const handleRemoveImage = (imageId) => { if (!currentFormTemplateItem?.id) return; - deleteItemImage(formTemplateId, currentFormTemplateItem.id, imageId); + const itemId = currentFormTemplateItem.id; + deleteItemImage(formTemplateId, itemId, imageId).then((success) => { + if (!success) getFormTemplateItem(formTemplateId, itemId); + }); }; const columns = [ 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 index 1320c44f7..d5a0e6b6b 100644 --- a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js +++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js @@ -12,7 +12,7 @@ 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()) + deleteInventoryItemImage: jest.fn(() => () => Promise.resolve(true)) })); jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index 81fa856c2..8132e4591 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -151,7 +151,10 @@ const InventoryListPage = ({ const handleRemoveImage = (imageId) => { if (!currentInventoryItem?.id) return; - deleteInventoryItemImage(currentInventoryItem.id, imageId); + const itemId = currentInventoryItem.id; + deleteInventoryItemImage(itemId, imageId).then((success) => { + if (!success) getInventoryItem(itemId); + }); }; const columns = [ 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 index 137742d81..35fa79475 100644 --- 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 @@ -14,7 +14,9 @@ jest.mock("../../../../../../../../actions/sponsor-forms-actions", () => ({ ), getSponsorCustomizedFormItems: jest.fn(() => () => Promise.resolve()), getSponsorFormManagedItem: jest.fn(() => () => Promise.resolve()), - removeSponsorCustomizedFormItemImages: jest.fn(() => () => Promise.resolve()) + removeSponsorCustomizedFormItemImages: jest.fn( + () => () => Promise.resolve(true) + ) })); jest.mock( 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 828eab25e..17fd3a2e2 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 @@ -204,10 +204,11 @@ const SponsorFormsManageItems = ({ const handleImageRemove = (imageId) => { if (!currentInventoryItem?.id) return; - removeSponsorCustomizedFormItemImages( - formId, - currentInventoryItem.id, - imageId + const itemId = currentInventoryItem.id; + removeSponsorCustomizedFormItemImages(formId, itemId, imageId).then( + (success) => { + if (!success) getSponsorFormManagedItem(formId, itemId); + } ); }; 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 9a0a8fc40..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 @@ -187,7 +187,7 @@ describe("sponsorCustomizedFormItemsListReducer", () => { const result = sponsorCustomizedFormItemsListReducer(state, { type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, - payload: { image: newImage, itemId: 1 } + payload: { response: newImage, itemId: 1 } }); expect(result.currentItem.images).toEqual(expectedImages); @@ -217,7 +217,7 @@ describe("sponsorCustomizedFormItemsListReducer", () => { const result = sponsorCustomizedFormItemsListReducer(state, { type: SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED, - payload: { image: { id: 11 }, itemId: 1 } + payload: { response: { id: 11 }, itemId: 1 } }); expect(result.currentItem).toEqual(state.currentItem); 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 9743bcb09..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 @@ -429,7 +429,7 @@ describe("SponsorFormItemsListReducer", () => { result = SponsorFormItemsListReducer(state, { type: SPONSOR_FORM_ITEM_IMAGE_ADDED, - payload: { image: newImage, itemId: "A" } + payload: { response: newImage, itemId: "A" } }); expect(result.currentItem.images).toStrictEqual(expectedImages); @@ -456,7 +456,7 @@ describe("SponsorFormItemsListReducer", () => { result = SponsorFormItemsListReducer(state, { type: SPONSOR_FORM_ITEM_IMAGE_ADDED, - payload: { image: { id: "IMG_2" }, itemId: "A" } + payload: { response: { id: "IMG_2" }, itemId: "A" } }); expect(result.currentItem).toStrictEqual(state.currentItem); 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 bbd105375..1070469a7 100644 --- a/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-customized-form-items-list-reducer.js @@ -137,7 +137,7 @@ const sponsorCustomizedFormItemsListReducer = ( return { ...state, currentItem, items }; } case SPONSOR_FORM_MANAGED_ITEM_IMAGE_ADDED: { - const { image, itemId } = payload; + const { response: image, itemId } = payload; const currentItem = state.currentItem.id === itemId ? { diff --git a/src/reducers/sponsors/sponsor-form-items-list-reducer.js b/src/reducers/sponsors/sponsor-form-items-list-reducer.js index 0a34a6e68..803c71898 100644 --- a/src/reducers/sponsors/sponsor-form-items-list-reducer.js +++ b/src/reducers/sponsors/sponsor-form-items-list-reducer.js @@ -142,7 +142,7 @@ const sponsorFormItemsListReducer = (state = DEFAULT_STATE, action) => { return { ...state, currentItem, items }; } case SPONSOR_FORM_ITEM_IMAGE_ADDED: { - const { image, itemId } = payload; + const { response: image, itemId } = payload; const currentItem = state.currentItem.id === itemId ? { 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/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; } From b49c185a29871e6862e779effc70d20f159710c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 21 Aug 2026 00:57:26 -0300 Subject: [PATCH 22/24] fix: adjust test, add check on removeItem for sponsor form item to re fetch on fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../form-template-item-list-page.test.js | 38 +++++++++++++++--- .../__tests__/inventory-list-page.test.js | 34 +++++++++++++--- .../sponsor-form-item-list-page.test.js | 37 +++++++++++++---- .../sponsor-form-item-list-page/index.js | 4 +- .../sponsor-forms-manage-items.test.js | 40 +++++++++++++++---- 5 files changed, 125 insertions(+), 28 deletions(-) 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 56b3d313a..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,6 +1,7 @@ import React from "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 { @@ -120,10 +121,17 @@ describe("FormTemplateItemListPage", () => { test.each([ ["an unsaved entity (no id)", {}, null], - ["a persisted item", { id: 55 }, [formTemplateId, 55, 999]] + ["a persisted item, delete succeeds", { id: 55 }, true], + ["a persisted item, delete fails", { id: 55 }, false] ])( - "calling deleteItemImage for %s", - async (_label, currentFormTemplateItem, expectedCall) => { + "removing an image for %s", + async (_label, currentFormTemplateItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + deleteItemImage.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + renderWithRedux( , { @@ -136,10 +144,28 @@ describe("FormTemplateItemListPage", () => { await openItemDialog(); - if (expectedCall) { - expect(deleteItemImage).toHaveBeenCalledWith(...expectedCall); - } else { + 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/inventory/__tests__/inventory-list-page.test.js b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js index d5a0e6b6b..51bdb54a6 100644 --- a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js +++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js @@ -1,6 +1,7 @@ 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 { @@ -75,10 +76,17 @@ describe("InventoryListPage image removal guard", () => { test.each([ ["an unsaved entity (no id)", {}, null], - ["a persisted item", { id: 77 }, [77, 999]] + ["a persisted item, delete succeeds", { id: 77 }, true], + ["a persisted item, delete fails", { id: 77 }, false] ])( - "calling deleteInventoryItemImage for %s", - async (_label, currentInventoryItem, expectedCall) => { + "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" }], @@ -88,10 +96,24 @@ describe("InventoryListPage image removal guard", () => { await openItemDialog(); - if (expectedCall) { - expect(deleteInventoryItemImage).toHaveBeenCalledWith(...expectedCall); - } else { + 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/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 996c12921..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,15 +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()), - removeItemFile: jest.fn(() => () => Promise.resolve()) + removeItemFile: jest.fn(() => () => Promise.resolve(true)) })); jest.mock("../../../../actions/inventory-item-actions", () => ({ @@ -41,6 +43,7 @@ jest.mock( const { getSponsorFormItems, + getSponsorFormItem, updateSponsorFormItem, addInventoryItems, removeItemFile @@ -150,18 +153,38 @@ describe("SponsorFormItemListPage image removal guard", () => { it.each([ ["an unsaved entity (no id)", {}, null], - ["a persisted item", { id: 42 }, ["FORM1", 42, 999]] + ["a persisted item, delete succeeds", { id: 42 }, true], + ["a persisted item, delete fails", { id: 42 }, false] ])( - "calling removeItemFile for %s", - async (_label, currentItem, expectedCall) => { + "removing an image for %s", + async (_label, currentItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + removeItemFile.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + renderPage(currentItem); await openItemPopup(); - if (expectedCall) { - expect(removeItemFile).toHaveBeenCalledWith(...expectedCall); - } else { + 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/index.js b/src/pages/sponsors/sponsor-form-item-list-page/index.js index 4eb853833..64c1fde55 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -133,7 +133,9 @@ const SponsorFormItemListPage = ({ const handleRemoveItemImage = (imageId) => { if (!currentItem?.id) return; - removeItemFile(formId, currentItem.id, imageId); + removeItemFile(formId, currentItem.id, imageId).then((success) => { + if (!success) getSponsorFormItem(formId, currentItem.id); + }); }; const handleAddFromInventory = (itemIds) => 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 index 35fa79475..b0ebaf4e1 100644 --- 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 @@ -1,6 +1,7 @@ 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 { @@ -81,10 +82,17 @@ describe("SponsorFormsManageItems image removal guard", () => { test.each([ ["an unsaved entity (no id)", {}, null], - ["a persisted item", { id: 33 }, ["FORM1", 33, 999]] + ["a persisted item, delete succeeds", { id: 33 }, true], + ["a persisted item, delete fails", { id: 33 }, false] ])( - "calling removeSponsorCustomizedFormItemImages for %s", - async (_label, currentItem, expectedCall) => { + "removing an image for %s", + async (_label, currentItem, deleteSucceeds) => { + if (deleteSucceeds !== null) { + removeSponsorCustomizedFormItemImages.mockImplementation( + () => () => Promise.resolve(deleteSucceeds) + ); + } + renderWithRedux( , { @@ -97,12 +105,28 @@ describe("SponsorFormsManageItems image removal guard", () => { await openItemDialog(); - if (expectedCall) { - expect(removeSponsorCustomizedFormItemImages).toHaveBeenCalledWith( - ...expectedCall - ); - } else { + 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 + ); } } ); From 206897c6452fea82829947449d9988fc0187ede9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 21 Aug 2026 01:06:14 -0300 Subject: [PATCH 23/24] fix: move stopLoading into .finally, add catch on re fetch if image delete fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/form-template-item-actions.js | 2 +- src/actions/inventory-item-actions.js | 2 +- src/actions/sponsor-forms-actions.js | 4 ++-- .../form-templates/form-template-item-list-page.js | 2 +- src/pages/sponsors-global/inventory/inventory-list-page.js | 2 +- src/pages/sponsors/sponsor-form-item-list-page/index.js | 2 +- .../components/manage-items/sponsor-forms-manage-items.js | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/actions/form-template-item-actions.js b/src/actions/form-template-item-actions.js index 7160c3369..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()); }); }; diff --git a/src/actions/inventory-item-actions.js b/src/actions/inventory-item-actions.js index cf6449e8e..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()); }); }; diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 913591f2c..f524f2c6a 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1203,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()); }); }; @@ -1670,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()); }); }; 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 9362e0c6f..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 @@ -191,7 +191,7 @@ const FormTemplateItemListPage = ({ if (!currentFormTemplateItem?.id) return; const itemId = currentFormTemplateItem.id; deleteItemImage(formTemplateId, itemId, imageId).then((success) => { - if (!success) getFormTemplateItem(formTemplateId, itemId); + if (!success) getFormTemplateItem(formTemplateId, itemId).catch(() => {}); }); }; diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index 8132e4591..2919aabc1 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -153,7 +153,7 @@ const InventoryListPage = ({ if (!currentInventoryItem?.id) return; const itemId = currentInventoryItem.id; deleteInventoryItemImage(itemId, imageId).then((success) => { - if (!success) getInventoryItem(itemId); + if (!success) getInventoryItem(itemId).catch(() => {}); }); }; 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 64c1fde55..87721eef9 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -134,7 +134,7 @@ const SponsorFormItemListPage = ({ const handleRemoveItemImage = (imageId) => { if (!currentItem?.id) return; removeItemFile(formId, currentItem.id, imageId).then((success) => { - if (!success) getSponsorFormItem(formId, currentItem.id); + if (!success) getSponsorFormItem(formId, currentItem.id).catch(() => {}); }); }; 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 17fd3a2e2..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 @@ -207,7 +207,7 @@ const SponsorFormsManageItems = ({ const itemId = currentInventoryItem.id; removeSponsorCustomizedFormItemImages(formId, itemId, imageId).then( (success) => { - if (!success) getSponsorFormManagedItem(formId, itemId); + if (!success) getSponsorFormManagedItem(formId, itemId).catch(() => {}); } ); }; From bf30b4c34a776a7e54f5fe3e6105fe81a2edda78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 21 Aug 2026 01:17:14 -0300 Subject: [PATCH 24/24] fix: add tests to check stopLoading on actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../form-template-item-actions.test.js | 42 +++++++++++++ .../__tests__/inventory-item-actions.test.js | 42 +++++++++++++ .../__tests__/sponsor-forms-actions.test.js | 63 ++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/actions/__tests__/form-template-item-actions.test.js create mode 100644 src/actions/__tests__/inventory-item-actions.test.js 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__/sponsor-forms-actions.test.js b/src/actions/__tests__/sponsor-forms-actions.test.js index 16d93b111..346693c7c 100644 --- a/src/actions/__tests__/sponsor-forms-actions.test.js +++ b/src/actions/__tests__/sponsor-forms-actions.test.js @@ -19,7 +19,9 @@ import { removeSponsorCustomizedFormItemImages, saveSponsorFormItem, updateSponsorFormItem, - saveSponsorFormManagedItem + saveSponsorFormManagedItem, + getSponsorFormItem, + getSponsorFormManagedItem } from "../sponsor-forms-actions"; import * as methods from "../../utils/methods"; @@ -675,4 +677,63 @@ describe("Sponsor Forms Actions", () => { ); }); }); + + 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"); + }); + }); });