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 20 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
20 changes: 20 additions & 0 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@
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 */; };
4587D11D2D64D559001971E4 /* ProductImagesUserDefaultsStatuses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4587D11C2D64D556001971E4 /* ProductImagesUserDefaultsStatuses.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 +1693,9 @@
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>"; };
4587D11C2D64D556001971E4 /* ProductImagesUserDefaultsStatuses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImagesUserDefaultsStatuses.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 +2698,16 @@
path = "Carriers and Rates";
sourceTree = "<group>";
};
4587D1132D64CB70001971E4 /* ProductImageInBackground */ = {
isa = PBXGroup;
children = (
4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */,
4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */,
4587D11C2D64D556001971E4 /* ProductImagesUserDefaultsStatuses.swift */,
);
path = ProductImageInBackground;
sourceTree = "<group>";
};
45AE58142306ADA8001901E3 /* Refund */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -2925,6 +2941,7 @@
B557DA1020975F60005962F4 /* Settings */,
B505F6EB20BEFD9100BB1B69 /* Tools */,
B53EF539218138EB003E146F /* Validators */,
4587D1132D64CB70001971E4 /* ProductImageInBackground */,
B557D9E6209753AA005962F4 /* Networking.h */,
B557D9E7209753AA005962F4 /* Info.plist */,
3FA7D9FA2D547DC700CE5611 /* Networking.xcconfig */,
Expand Down Expand Up @@ -5313,6 +5330,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,12 +5375,14 @@
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 */,
CED9BCC12D3EAF7B00C063B8 /* WooShippingAddressValidationError.swift in Sources */,
45CDAFAB2434CA9300F83C22 /* ProductCatalogVisibility.swift in Sources */,
3148977027232982007A86BD /* SystemStatus.swift in Sources */,
4587D11D2D64D559001971E4 /* ProductImagesUserDefaultsStatuses.swift in Sources */,
B557DA1820979D51005962F4 /* Credentials.swift in Sources */,
DEC51AF72769A15B009F3DF4 /* SystemStatusReport+Security.swift in Sources */,
EE1D9A9F2ACD6BA60020D817 /* AIProduct.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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
}

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 errorMessage = try container.decode(String.self, forKey: .error)
let error = NSError(domain: "ProductImageStatus", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage])
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like we'll lose information about the error here. The information is necessary because we're showing details about the upload failures. Please also retain the domain and error code while encoding/decoding.

let sID = try container.decode(Int64.self, forKey: .siteID)
let pID = try container.decode(ProductOrVariationID.self, forKey: .productID)
self = .uploadFailure(asset: asset, error: error, 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.encodeIfPresent(productID, forKey: .productID)
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason why encodeIfPresent is necessary? I think productID is non-optional here.

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)
let errorMessage = (error as NSError).localizedDescription
try container.encode(errorMessage, forKey: .error)
try container.encode(siteID, forKey: .siteID)
try container.encodeIfPresent(productID, forKey: .productID)
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment about encodeIfPresent as above.

}
}

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,99 @@
import Foundation

/// Save product image upload statuses in User Defaults.
/// This class is declared in the Networking layer because it will also be accessed by the background URLSession operations.
///
final class ProductImagesUserDefaultsStatuses {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please consider renaming this to ProductImageStatusStorage and injecting user defaults into its initializer for testability.

private static let key = "savedProductUploadImageStatuses"

static func addStatus(_ status: ProductImageStatus) {
var statuses = getAllStatuses()
statuses.append(status)
saveAllStatuses(statuses)
}

static func removeStatus(_ status: ProductImageStatus) {
var statuses = getAllStatuses()
statuses.removeAll(where: { $0 == status })
saveAllStatuses(statuses)
}

static func findStatus(where predicate: (ProductImageStatus) -> Bool) -> ProductImageStatus? {
return getAllStatuses().first(where: predicate)
}

static func getAllStatuses() -> [ProductImageStatus] {
guard let data = UserDefaults.standard.data(forKey: key) else {
return []
}
do {
let statuses = try JSONDecoder().decode([ProductImageStatus].self, from: data)
return statuses
} catch {
DDLogError("Error decoding saved product image statuses: \(error)")
return []
}
}

static func getAllStatuses(for siteID: Int64, productID: ProductOrVariationID?) -> [ProductImageStatus] {
return getAllStatuses().filter { status in
switch status {
case .remote(_, let sID, let pID):
if let filterProductID = productID {
return sID == siteID && pID == filterProductID
} else {
return false
}
case .uploading(_, let sID, let pID),
.uploadFailure(_, _, let sID, let pID):
if let filterProductID = productID {
return sID == siteID && pID == filterProductID
} else {
return sID == siteID && pID == nil
Copy link
Contributor

Choose a reason for hiding this comment

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

Product ID is non-optional in image statuses so this check will likely fail.

}
}
}
}

static func clearAllStatuses() {
UserDefaults.standard.removeObject(forKey: key)
}

private static func saveAllStatuses(_ statuses: [ProductImageStatus]) {
do {
let data = try JSONEncoder().encode(statuses)
UserDefaults.standard.set(data, forKey: key)
} catch {
DDLogError("Error encoding saved product image statuses: \(error)")
}
}
}

extension ProductImagesUserDefaultsStatuses {
static func setAllStatuses(_ statuses: [ProductImageStatus]) {
saveAllStatuses(statuses)
}

static func setAllStatuses(_ statuses: [ProductImageStatus], for siteID: Int64, productID: ProductOrVariationID?) {
// Merge with existing, removing any old statuses for this site/product
var all = getAllStatuses().filter { st in
switch st {
case .remote(_, let sID, let pID):
if let filterProductID = productID {
return !(sID == siteID && pID == filterProductID)
} else {
return true
}
case .uploading(_, let sID, let pID),
.uploadFailure(_, _, let sID, let pID):
if let filterProductID = productID {
return !(sID == siteID && pID == filterProductID)
} else {
return !(sID == siteID && pID == nil)
}
}
}
all.append(contentsOf: statuses)
saveAllStatuses(all)
}
}
Loading