Skip to content

Commit 3fe7850

Browse files
authored
Background image upload: Fix issue showing uploaded images while saving is in progress (#15107)
2 parents 276dc76 + 5b269e1 commit 3fe7850

File tree

6 files changed

+97
-32
lines changed

6 files changed

+97
-32
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- [*] Now "Suggested by AI" label is visible in dark mode in Blaze campaign creation flow. [https://github.com/woocommerce/woocommerce-ios/pull/15088]
88
- [*] Improved image loading in Blaze Campaign Creation: displays a redacted and shimmering effects when loading product image and falls back to a placeholder if no image is available. [https://github.com/woocommerce/woocommerce-ios/pull/15098]
99
- [*] Product List: Display syncing animation on items with image upload in progress [https://github.com/woocommerce/woocommerce-ios/pull/15052]
10+
- [*] Background image upload: Fix issue showing uploaded images while saving is in progress [https://github.com/woocommerce/woocommerce-ios/pull/15107]
1011
- [*] Background image upload: Fix missing error notice in iPhones [https://github.com/woocommerce/woocommerce-ios/pull/15117]
1112
- [*] Filters applied in product selector no longer affect the main product list screen. [https://github.com/woocommerce/woocommerce-ios/pull/14764]
1213

WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
110110

111111
private var actionHandlersByProduct: [Key: ProductImageActionHandler] = [:]
112112
private var imagesSaverByProduct: [Key: ProductImagesSaver] = [:]
113-
private var initialStatusesByProduct: [Key: [ProductImageStatus]] = [:]
114113

115114
@Published private var activeUploadsPublisher: [ProductImageUploaderKey] = []
116115

@@ -125,12 +124,11 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
125124

126125
func actionHandler(key: ProductImageUploaderKey, originalStatuses: [ProductImageStatus]) -> ProductImageActionHandler {
127126
let actionHandler: ProductImageActionHandler
128-
if let handler = actionHandlersByProduct[key], handler.productImageStatuses.hasPendingUpload {
127+
if let handler = actionHandlersByProduct[key] {
129128
actionHandler = handler
130129
} else {
131130
actionHandler = ProductImageActionHandler(siteID: key.siteID, productID: key.productOrVariationID, imageStatuses: originalStatuses, stores: stores)
132131
actionHandlersByProduct[key] = actionHandler
133-
initialStatusesByProduct[key] = originalStatuses
134132
observeStatusUpdates(key: key, actionHandler: actionHandler)
135133
}
136134

@@ -171,14 +169,24 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
171169
guard let handler = actionHandlersByProduct[key] else {
172170
return false
173171
}
174-
if let productImagesSaver = imagesSaverByProduct[key], productImagesSaver.imageStatusesToSave.isNotEmpty {
172+
let productImagesSaver = imagesSaverByProduct[key]
173+
174+
if let productImagesSaver, productImagesSaver.imageStatusesToSave.isNotEmpty {
175175
// If there are images scheduled to be saved, there are no unsaved changes if the image statuses to save match the latest image statuses.
176176
return handler.productImageStatuses != productImagesSaver.imageStatusesToSave
177177
} else {
178-
// Otherwise, there are unsaved changes if there is any pending image upload or any difference in the remote image IDs between the
178+
if handler.productImageStatuses.hasPendingUpload {
179+
return true
180+
}
181+
182+
/// If there's a product saved in background, compare the images to determine unsaved changes.
183+
if let savedProduct = productImagesSaver?.savedProduct {
184+
return handler.productImageStatuses.images.map { $0.imageID } != savedProduct.images.map { $0.imageID }
185+
}
186+
187+
// Otherwise, there are unsaved changes if there is any difference in the remote image IDs between the
179188
// original and latest product.
180-
return handler.productImageStatuses.hasPendingUpload ||
181-
handler.productImageStatuses.images.map { $0.imageID } != originalImages.map { $0.imageID }
189+
return handler.productImageStatuses.images.map { $0.imageID } != originalImages.map { $0.imageID }
182190
}
183191
}
184192

@@ -187,12 +195,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
187195
// The product has to exist remotely in order to save its images remotely.
188196
// In product creation, this save function should be called after a new product is saved remotely for the first time.
189197
guard key.isLocalID == false else {
190-
removeProductFromActiveUploads(key: key)
191198
return onProductSave(.failure(ProductImageUploaderError.noRemoteProductIDFound))
192199
}
193200

194201
guard let handler = actionHandlersByProduct[key] else {
195-
removeProductFromActiveUploads(key: key)
196202
return onProductSave(.failure(ProductImageUploaderError.noActionHandlerFound))
197203
}
198204

@@ -208,7 +214,6 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
208214

209215
imagesSaver.saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: handler) { [weak self] result in
210216
guard let self = self else { return }
211-
removeProductFromActiveUploads(key: key)
212217
onProductSave(result)
213218
if case let .failure(error) = result {
214219
self.errorsSubject.send(.init(siteID: key.siteID,
@@ -229,7 +234,6 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
229234

230235
actionHandlersByProduct = [:]
231236
imagesSaverByProduct = [:]
232-
initialStatusesByProduct = [:]
233237
}
234238
}
235239

@@ -254,10 +258,9 @@ private extension ProductImageUploader {
254258

255259
if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload {
256260
activeUploadsPublisher.append(key)
257-
} else if let initialStatuses = initialStatusesByProduct[key],
258-
initialStatuses == productImageStatuses,
259-
activeUploadsPublisher.contains(key) {
260-
/// When upload is reset, remove the key from active uploads
261+
} else if activeUploadsPublisher.contains(key), !productImageStatuses.hasPendingUpload {
262+
/// When all pending uploads are completed or removed,
263+
/// remove the key from active uploads
261264
removeProductFromActiveUploads(key: key)
262265
}
263266

@@ -274,7 +277,6 @@ private extension ProductImageUploader {
274277

275278
func removeProductFromActiveUploads(key: Key) {
276279
activeUploadsPublisher.removeAll(where: { $0 == key })
277-
initialStatusesByProduct.removeValue(forKey: key)
278280
}
279281
}
280282

WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ final class ProductsTabProductTableViewCell: UITableViewCell {
5858
super.updateConfiguration(using: state)
5959
updateDefaultBackgroundConfiguration(using: state)
6060
}
61+
62+
override func prepareForReuse() {
63+
super.prepareForReuse()
64+
syncingOverlayView?.removeFromSuperview()
65+
selectedProductImageOverlayView?.removeFromSuperview()
66+
syncingOverlayView = nil
67+
selectedProductImageOverlayView = nil
68+
}
6169
}
6270

6371
extension ProductsTabProductTableViewCell: SearchResultCell {

WooCommerce/Classes/ViewRelated/Products/Media/ProductImagesSaver.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ final class ProductImagesSaver {
1010
imageStatusesToSaveSubject.send(imageStatusesToSave)
1111
}
1212
}
13+
14+
private(set) var savedProduct: Product?
15+
1316
// The use of a `PassthroughSubject` sent on variable `didSet` instead of a `@Published` is because `@Published`
1417
// subscription happens in `willSet` while we want to update the statuses after `didSet`.
1518
private let imageStatusesToSaveSubject: PassthroughSubject<[ProductImageStatus], Never> = .init()
@@ -38,6 +41,9 @@ final class ProductImagesSaver {
3841
/// - onProductSave: called after the product is updated remotely with the uploaded images.
3942
func saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: ProductImageActionHandlerProtocol,
4043
onProductSave: @escaping (Result<[ProductImage], Error>) -> Void) {
44+
/// Reset previously saved product
45+
savedProduct = nil
46+
4147
let imageStatuses = imageActionHandler.productImageStatuses
4248
guard imageStatuses.hasPendingUpload else {
4349
return saveProductImages(imageStatuses.images, onProductSave: onProductSave)
@@ -72,6 +78,7 @@ private extension ProductImagesSaver {
7278
guard let self = self else { return }
7379
switch result {
7480
case .success(let product):
81+
savedProduct = product
7582
onProductSave(.success(product.images))
7683
self.analytics.track(event:
7784
.ImageUpload.savingProductAfterBackgroundImageUploadSuccess(productOrVariation: .product))

WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -594,26 +594,16 @@ final class ProductImageUploaderTests: XCTestCase {
594594
let uploadedMedia = Media.fake().copy(mediaID: 645)
595595
stores.whenReceivingAction(ofType: MediaAction.self) { action in
596596
if case let .uploadMedia(_, _, _, _, _, onCompletion) = action {
597+
XCTAssertEqual(activeUploads, [key])
597598
onCompletion(.success(uploadedMedia))
598-
}
599-
}
600-
actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: asset))
601-
602-
// Then
603-
waitUntil {
604-
activeUploads == [key]
605-
}
606599

607-
// When
608-
stores.whenReceivingAction(ofType: ProductAction.self) { action in
609-
if case let .updateProductImages(_, _, images, onCompletion) = action {
610-
onCompletion(.success(.fake().copy(images: images)))
600+
// Then
601+
self.waitUntil {
602+
activeUploads == []
603+
}
611604
}
612605
}
613-
imageUploader.saveProductImagesWhenNoneIsPendingUploadAnymore(key: key) { _ in
614-
// Then
615-
XCTAssertEqual(activeUploads, [])
616-
}
606+
actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: asset))
617607
}
618608

619609
func test_product_is_removed_from_activeUploads_when_upload_is_cancelled() {

WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImagesSaverTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,63 @@ final class ProductImagesSaverTests: XCTestCase {
181181
XCTAssertEqual(savedImages, [image])
182182
}
183183

184+
func test_savedProduct_is_updated_correctly() {
185+
// Given
186+
let stores = MockStoresManager(sessionManager: .testingInstance)
187+
let actionHandler = ProductImageActionHandler(siteID: siteID, productID: .product(id: productID), imageStatuses: [], stores: stores)
188+
let asset: ProductImageAssetType = .phAsset(asset: PHAsset())
189+
let imagesSaver = ProductImagesSaver(siteID: siteID, productOrVariationID: .product(id: productID), stores: stores)
190+
let expectedProduct = Product.fake().copy(images: [])
191+
192+
// Mocks successful product images update.
193+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
194+
if case let .updateProductImages(_, _, _, onCompletion) = action {
195+
onCompletion(.success(expectedProduct))
196+
}
197+
}
198+
199+
// Uploads an image and waits for the image upload completion closure to be called later.
200+
let imageUploadCompletion: ((Result<Media, Error>) -> Void) = waitFor { promise in
201+
stores.whenReceivingAction(ofType: MediaAction.self) { action in
202+
if case let .uploadMedia(_, _, _, _, _, onCompletion) = action {
203+
promise(onCompletion)
204+
}
205+
}
206+
actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: asset)
207+
}
208+
209+
waitFor { promise in
210+
// Saves product images.
211+
imagesSaver.saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: actionHandler) { _ in
212+
promise(())
213+
}
214+
XCTAssertNil(imagesSaver.savedProduct)
215+
216+
// When
217+
// Mocks successful image upload.
218+
imageUploadCompletion(.success(.fake().copy(mediaID: 645)))
219+
}
220+
221+
// Then
222+
XCTAssertEqual(imagesSaver.savedProduct, expectedProduct)
223+
224+
// When
225+
// Uploads another image and save.
226+
let imageUploadCompletionAfterSave: ((Result<Media, Error>) -> Void) = waitFor { promise in
227+
stores.whenReceivingAction(ofType: MediaAction.self) { action in
228+
if case let .uploadMedia(_, _, _, _, _, onCompletion) = action {
229+
promise(onCompletion)
230+
}
231+
}
232+
actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: asset)
233+
}
234+
imageUploadCompletionAfterSave(.success(.fake().copy(mediaID: 606)))
235+
imagesSaver.saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: actionHandler) { _ in }
236+
237+
// Then the saved product is reset immediately
238+
XCTAssertNil(imagesSaver.savedProduct)
239+
}
240+
184241
// MARK: - Analytics
185242

186243
func test_savingProductAfterBackgroundImageUploadSuccess_is_tracked_on_variation_update_success() throws {

0 commit comments

Comments
 (0)