Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial changes for saving product images upload statuses in UserDefaults #15196

Open
wants to merge 29 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
358daa8
refactor: moved `ProductImageStatus` into Networking layer and relate…
pmusolino Feb 18, 2025
992cc5c
Add Codable conformance and new file for storing product image status…
pmusolino Feb 18, 2025
b7b2078
fix: spaces in `ProductImageStatus`
pmusolino Feb 18, 2025
a804b1e
Add siteID and productID to ProductImageStatus
pmusolino Feb 18, 2025
e3c87b8
fix: when a product is published and `updateProductID(_:)` is called,…
pmusolino Feb 18, 2025
66c6694
- Update `ProductImageStatus.swift` to make `productID` optional in `…
pmusolino Feb 18, 2025
6c678cf
update: unit tests regarding product image upload classes
pmusolino Feb 18, 2025
d506946
update: removed superfluous comment
pmusolino Feb 18, 2025
51a1cfd
fix: product image variation management, which allow maximum one imag…
pmusolino Feb 18, 2025
70e0fa9
update: release-notes 21.8
pmusolino Feb 19, 2025
27a1146
fix: failing unit tests
pmusolino Feb 19, 2025
1f96482
Merge branch 'trunk' into feat/save-product-image-upload-statuses-in-…
pmusolino Feb 19, 2025
c6be542
Merge branch 'trunk' into feat/save-product-image-upload-statuses-in-…
pmusolino Feb 19, 2025
561582a
update: release-notes
pmusolino Feb 23, 2025
309d947
Merge commit 'a6877f9a55e964ace617c9e82a8b8897db842c53' into feat/sav…
pmusolino Feb 23, 2025
c84cef6
- Update `ProductImageStatus.swift` to use non-optional `ProductOrVar…
pmusolino Feb 23, 2025
1344637
fix: test_hasUnsavedChangesOnImages_stays_false_after_uploading_and_s…
pmusolino Feb 24, 2025
f5803bb
refactor: Set up the observer first to wait for the status change to …
pmusolino Feb 24, 2025
1d85fd2
Fix: asset state transition issue by updating .phAsset comparison log…
pmusolino Feb 24, 2025
e012096
update: explicit injection of mock store dependencies in tests to avo…
pmusolino Feb 24, 2025
67dd20a
update: omit the unused siteID and productID to avoid confusion
pmusolino Feb 25, 2025
aafaa19
Update method parameter from `.product(id: productID.id)` to `.produc…
pmusolino Feb 25, 2025
0c362dc
Enhance error handling in ProductImageStatus
pmusolino Feb 25, 2025
6df709a
Update the upload failure condition to include siteID and productID c…
pmusolino Feb 25, 2025
666adbe
fix: passing siteID and productID in uploadFailure
pmusolino Feb 25, 2025
a66c800
fix: typo
pmusolino Feb 25, 2025
d2eb66d
Merge branch 'trunk' into feat/save-product-image-upload-statuses-in-…
pmusolino Feb 25, 2025
9659be1
- Modify `ProductImageActionHandlerTests.swift` to use `siteID` inste…
pmusolino Feb 25, 2025
6ea99b3
Update: Removed `ProductImagesUserDefaultsStatuses` since it has been…
pmusolino Feb 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@
457453E725A72C9700276508 /* CreateProductVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457453E625A72C9700276508 /* CreateProductVariation.swift */; };
457A574025D1817E000797AD /* ShippingLabelAddressVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457A573F25D1817E000797AD /* ShippingLabelAddressVerification.swift */; };
457FC68C2382B2FD00B41B02 /* product-update.json in Resources */ = {isa = PBXBuildFile; fileRef = 457FC68B2382B2FD00B41B02 /* product-update.json */; };
4587D1152D64CBC2001971E4 /* ProductImageStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */; };
4587D11F2D64D887001971E4 /* ProductOrVariationID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */; };
458C6DE425AC72A1009B300D /* StoredProductSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 458C6DE325AC72A1009B300D /* StoredProductSettings.swift */; };
4595827C264D5B3F000A4413 /* ShippingLabelPackageSelected.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4595827B264D5B3F000A4413 /* ShippingLabelPackageSelected.swift */; };
4599FC5824A624BD0056157A /* ProductTagListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4599FC5724A624BD0056157A /* ProductTagListMapper.swift */; };
Expand Down Expand Up @@ -1690,6 +1692,8 @@
457453E625A72C9700276508 /* CreateProductVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProductVariation.swift; sourceTree = "<group>"; };
457A573F25D1817E000797AD /* ShippingLabelAddressVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddressVerification.swift; sourceTree = "<group>"; };
457FC68B2382B2FD00B41B02 /* product-update.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "product-update.json"; sourceTree = "<group>"; };
4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStatus.swift; sourceTree = "<group>"; };
4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductOrVariationID.swift; sourceTree = "<group>"; };
458C6DE325AC72A1009B300D /* StoredProductSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredProductSettings.swift; sourceTree = "<group>"; };
4595827B264D5B3F000A4413 /* ShippingLabelPackageSelected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackageSelected.swift; sourceTree = "<group>"; };
4599FC5724A624BD0056157A /* ProductTagListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTagListMapper.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2692,6 +2696,15 @@
path = "Carriers and Rates";
sourceTree = "<group>";
};
4587D1132D64CB70001971E4 /* ProductImageInBackground */ = {
isa = PBXGroup;
children = (
4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */,
4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */,
);
path = ProductImageInBackground;
sourceTree = "<group>";
};
45AE58142306ADA8001901E3 /* Refund */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -2925,6 +2938,7 @@
B557DA1020975F60005962F4 /* Settings */,
B505F6EB20BEFD9100BB1B69 /* Tools */,
B53EF539218138EB003E146F /* Validators */,
4587D1132D64CB70001971E4 /* ProductImageInBackground */,
B557D9E6209753AA005962F4 /* Networking.h */,
B557D9E7209753AA005962F4 /* Info.plist */,
3FA7D9FA2D547DC700CE5611 /* Networking.xcconfig */,
Expand Down Expand Up @@ -5313,6 +5327,7 @@
02C254B925637BA000A04423 /* OrderShippingLabelListMapper.swift in Sources */,
261CF1B8255AE62D0090D8D3 /* PaymentGatewayRemote.swift in Sources */,
CE0A0F1522396BF00075ED8D /* ProductAttribute.swift in Sources */,
4587D11F2D64D887001971E4 /* ProductOrVariationID.swift in Sources */,
026CF620237D69D6009563D4 /* ProductVariationsRemote.swift in Sources */,
03DCB7402624AD7D00C8953D /* CouponListMapper.swift in Sources */,
077F39D626A58E4500ABEADC /* SystemStatusRemote.swift in Sources */,
Expand Down Expand Up @@ -5357,6 +5372,7 @@
B518662420A099BF00037A38 /* AlamofireNetwork.swift in Sources */,
CE070A3A2BBC56CC00017578 /* GiftCardStatsRemote.swift in Sources */,
311D412E2783C07D00052F64 /* StripeAccountMapper.swift in Sources */,
4587D1152D64CBC2001971E4 /* ProductImageStatus.swift in Sources */,
DE78DE482B2AEBEC002E58DE /* WordPressPageMapper.swift in Sources */,
CE430676234BA7920073CBFF /* RefundListMapper.swift in Sources */,
45551F122523E7F1007EF104 /* UserAgent.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import Foundation
import Photos
import UIKit

/// The status of a Product image.
///

public enum ProductImageStatus: Equatable, Codable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you added Codable conformance to ProductImageStatus, please also add tests to confirm that both the encoding and decoding work as expected.

/// An image asset is being uploaded.
///
case uploading(asset: ProductImageAssetType, siteID: Int64, productID: ProductOrVariationID)

/// The Product image exists remotely.
///
case remote(image: ProductImage, siteID: Int64, productID: ProductOrVariationID)

/// An image asset upload failed.
///
case uploadFailure(asset: ProductImageAssetType, error: Error, siteID: Int64, productID: ProductOrVariationID)

private enum CodingKeys: String, CodingKey {
case type
case asset
case image
case error
case siteID
case productID
case errorDomain
case errorCode
case errorUserInfo
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let typeString = try container.decode(String.self, forKey: .type)

switch typeString {
case "uploading":
let asset = try container.decode(ProductImageAssetType.self, forKey: .asset)
let sID = try container.decode(Int64.self, forKey: .siteID)
let pID = try container.decode(ProductOrVariationID.self, forKey: .productID)
self = .uploading(asset: asset, siteID: sID, productID: pID)
case "remote":
let image = try container.decode(ProductImage.self, forKey: .image)
let sID = try container.decode(Int64.self, forKey: .siteID)
let pID = try container.decode(ProductOrVariationID.self, forKey: .productID)
self = .remote(image: image, siteID: sID, productID: pID)
case "uploadFailure":
let asset = try container.decode(ProductImageAssetType.self, forKey: .asset)
let sID = try container.decode(Int64.self, forKey: .siteID)
let pID = try container.decode(ProductOrVariationID.self, forKey: .productID)
let domain = try container.decode(String.self, forKey: .errorDomain)
let code = try container.decode(Int.self, forKey: .errorCode)
let userInfo = try container.decode([String: String]?.self, forKey: .errorUserInfo)
self = .uploadFailure(asset: asset, error: NSError(domain: domain, code: code, userInfo: userInfo), siteID: sID, productID: pID)
default:
throw DecodingError.dataCorruptedError(forKey: .type,
in: container,
debugDescription: "Invalid type value: \(typeString)")
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .uploading(let asset, let siteID, let productID):
try container.encode("uploading", forKey: .type)
try container.encode(asset, forKey: .asset)
try container.encode(siteID, forKey: .siteID)
try container.encode(productID, forKey: .productID)
case .remote(let image, let siteID, let productID):
try container.encode("remote", forKey: .type)
try container.encode(image, forKey: .image)
try container.encode(siteID, forKey: .siteID)
try container.encode(productID, forKey: .productID)
case .uploadFailure(let asset, let error, let siteID, let productID):
try container.encode("uploadFailure", forKey: .type)
try container.encode(asset, forKey: .asset)
try container.encode(siteID, forKey: .siteID)
try container.encode(productID, forKey: .productID)

let errorMessage = error as NSError
try container.encode(errorMessage.domain, forKey: .errorDomain)
try container.encode(errorMessage.code, forKey: .errorCode)
try container.encode(errorMessage.userInfo as? [String: String], forKey: .errorUserInfo)
}
}

public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.uploading(lAsset, lSiteID, lProductID), .uploading(rAsset, rSiteID, rProductID)):
return lAsset == rAsset && lSiteID == rSiteID && lProductID == rProductID
case let (.remote(lImage, lSiteID, lProductID), .remote(rImage, rSiteID, rProductID)):
return lImage == rImage && lSiteID == rSiteID && lProductID == rProductID
case let (.uploadFailure(lAsset, lError, lSiteID, lProductID), .uploadFailure(rAsset, rError, rSiteID, rProductID)):
return lAsset == rAsset &&
(lError as NSError) == (rError as NSError) &&
lSiteID == rSiteID &&
lProductID == rProductID
default:
return false
}
}
}

/// The type of product image asset.
public enum ProductImageAssetType: Equatable, Codable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you added Codable conformance to ProductImageAssetType , please also add tests to confirm that both the encoding and decoding work as expected.

/// `PHAsset` from device photo library or camera capture.
case phAsset(asset: PHAsset)

/// `UIImage` from image processing. The filename and alt text need to be provided separately.
case uiImage(image: UIImage, filename: String?, altText: String?)

private enum CodingKeys: String, CodingKey {
case type
case asset // For phAsset: localIdentifier string
case imageData // For uiImage: base64 encoded image data
case filename
case altText
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let typeString = try container.decode(String.self, forKey: .type)
switch typeString {
case "phAsset":
let localIdentifier = try container.decode(String.self, forKey: .asset)
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
guard let asset = fetchResult.firstObject else {
throw DecodingError.dataCorruptedError(forKey: .asset,
in: container,
debugDescription: "No PHAsset found with localIdentifier \(localIdentifier)")
}
self = .phAsset(asset: asset)
case "uiImage":
let base64String = try container.decode(String.self, forKey: .imageData)
guard let imageData = Data(base64Encoded: base64String),
let image = UIImage(data: imageData) else {
throw DecodingError.dataCorruptedError(forKey: .imageData,
in: container,
debugDescription: "Invalid image data")
}
let filename = try container.decodeIfPresent(String.self, forKey: .filename)
let altText = try container.decodeIfPresent(String.self, forKey: .altText)
self = .uiImage(image: image, filename: filename, altText: altText)
default:
throw DecodingError.dataCorruptedError(forKey: .type,
in: container,
debugDescription: "Unknown type \(typeString)")
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .phAsset(let asset):
try container.encode("phAsset", forKey: .type)
try container.encode(asset.localIdentifier, forKey: .asset)
case .uiImage(let image, let filename, let altText):
try container.encode("uiImage", forKey: .type)
guard let imageData = image.pngData() else {
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to encode UIImage as PNG")
throw EncodingError.invalidValue(image, context)
}
let base64String = imageData.base64EncodedString()
try container.encode(base64String, forKey: .imageData)
try container.encode(filename, forKey: .filename)
try container.encode(altText, forKey: .altText)
}
}

public static func == (lhs: ProductImageAssetType, rhs: ProductImageAssetType) -> Bool {
switch (lhs, rhs) {
case (.phAsset(let lAsset), .phAsset(let rAsset)):
return lAsset.localIdentifier == rAsset.localIdentifier
case (.uiImage(let lImage, let lFilename, let lAltText),
.uiImage(let rImage, let rFilename, let rAltText)):
return lImage.pngData() == rImage.pngData() &&
lFilename == rFilename &&
lAltText == rAltText
default:
return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// Identifiable data about a product or product variation.
public enum ProductOrVariationID: Equatable, Hashable, Codable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you added Codable conformance to ProductOrVariationID , please also add tests to confirm that both the encoding and decoding work as expected.

case product(id: Int64)
case variation(productID: Int64, variationID: Int64)

/// Returns the product ID for product type and variation ID for variation type.
public var id: Int64 {
switch self {
case .product(let id):
return id
case .variation(_, let variationID):
return variationID
}
}

private enum CodingKeys: String, CodingKey {
case type
case id
case productID
case variationID
}

private enum CaseType: String, Codable {
case product
case variation
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let caseType = try container.decode(CaseType.self, forKey: .type)
switch caseType {
case .product:
let id = try container.decode(Int64.self, forKey: .id)
self = .product(id: id)
case .variation:
let productID = try container.decode(Int64.self, forKey: .productID)
let variationID = try container.decode(Int64.self, forKey: .variationID)
self = .variation(productID: productID, variationID: variationID)
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .product(let id):
try container.encode(CaseType.product, forKey: .type)
try container.encode(id, forKey: .id)
case .variation(let productID, let variationID):
try container.encode(CaseType.variation, forKey: .type)
try container.encode(productID, forKey: .productID)
try container.encode(variationID, forKey: .variationID)
}
}
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

21.9
-----
- [internal] Assign `siteID` and `productID` to image product upload statuses. [https://github.com/woocommerce/woocommerce-ios/pull/15196]


21.8
Expand Down
19 changes: 3 additions & 16 deletions WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import Foundation
import struct Yosemite.ProductImage
import enum Yosemite.ProductAction
import protocol Yosemite.StoresManager
import enum Yosemite.ProductImageStatus
import enum Yosemite.ProductImageAssetType
import enum Yosemite.ProductOrVariationID

/// Information about a background product image upload error.
struct ProductImageUploadErrorInfo {
Expand All @@ -12,22 +15,6 @@ struct ProductImageUploadErrorInfo {
let error: ProductImageUploaderError
}

/// Identifiable data about a product or product variation.
enum ProductOrVariationID: Equatable, Hashable {
case product(id: Int64)
case variation(productID: Int64, variationID: Int64)

/// Returns the product ID for product type and variation ID for variation type.
var id: Int64 {
switch self {
case .product(let id):
return id
case .variation(_, let variationID):
return variationID
}
}
}

/// Identifiable information about a specific product or product variation of different sites for image upload.
struct ProductImageUploaderKey: Equatable, Hashable {
let siteID: Int64
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ private extension ProductImagesCollectionViewDataSource {

func configureImageCell(_ cell: UICollectionViewCell, productImageStatus: ProductImageStatus, isFirstImage: Bool) {
switch productImageStatus {
case .remote(let image):
case .remote(let image, _, _):
configureRemoteImageCell(cell, productImage: image, isFirstImage: isFirstImage)
case .uploading(let asset):
case .uploading(let asset, _, _):
switch asset {
case .phAsset(let asset):
configureUploadingImageCell(cell, asset: asset)
case .uiImage(let image, _, _):
configureUploadingImageCell(cell, image: image)
}
case let .uploadFailure(asset, _):
case let .uploadFailure(asset, _, _, _):
switch asset {
case .phAsset(let asset):
configureFailedImageCell(cell, asset: asset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ extension ProductImagesHeaderTableViewCell: UICollectionViewDelegate {
switch viewModel?.items[indexPath.item] {
case .image(let status):
switch status {
case .remote(let image):
case .remote(let image, _, _):
onImageSelected?(image, indexPath)
case .uploading:
onImageSelected?(nil, indexPath)
case let .uploadFailure(asset, error):
case let .uploadFailure(asset, error, _, _):
onFailedUploadSelected?(asset, error)
}
case .addImage:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import Yosemite

extension ProductFormDataModel {
var imageStatuses: [ProductImageStatus] {
return images.map({ ProductImageStatus.remote(image: $0) })
return images.map({ ProductImageStatus.remote(image: $0, siteID: siteID, productID: .product(id: productID)) })
}
}

extension Product {
var imageStatuses: [ProductImageStatus] {
return images.map({ ProductImageStatus.remote(image: $0) })
return images.map({ ProductImageStatus.remote(image: $0, siteID: siteID, productID: .product(id: productID)) })
}

/// Returns the URL of the first image, if available. Otherwise, nil is returned.
Expand Down
Loading