-
Notifications
You must be signed in to change notification settings - Fork 116
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
[Woo POS][Products Search] Add POSProduct in Networking #15441
Changes from 5 commits
fd9ab9d
2e5512f
9817166
2bc850b
1592a66
839b4be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import Foundation | ||
|
||
/// ListMapper: Maps generic WooCommerce REST API Lists | ||
/// | ||
struct ListMapper<Output: Decodable>: Mapper { | ||
/// Site Identifier associated to the items that will be parsed. | ||
/// | ||
/// We're injecting this field via `JSONDecoder.userInfo` because SiteID is not returned by our endpoints. | ||
/// | ||
let siteID: Int64 | ||
|
||
/// (Attempts) to convert a dictionary into [Product]. | ||
/// | ||
func map(response: Data) throws -> [Output] { | ||
let decoder = JSONDecoder() | ||
decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) | ||
decoder.userInfo = [ | ||
.siteID: siteID | ||
] | ||
|
||
if hasDataEnvelope(in: response) { | ||
return try decoder.decode(ListEnvelope<Output>.self, from: response).items | ||
} else { | ||
return try decoder.decode([Output].self, from: response) | ||
} | ||
} | ||
} | ||
|
||
/// ListEnvelope Disposable Entity: | ||
/// Our list endpoints return the items in the `data` key. | ||
/// This entity allows us to do parse all the things with JSONDecoder. | ||
/// | ||
private struct ListEnvelope<Output: Decodable>: Decodable { | ||
let items: [Output] | ||
|
||
private enum CodingKeys: String, CodingKey { | ||
case items = "data" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
import Foundation | ||
import Codegen | ||
|
||
/// Represents a Product Entity, for use in Point of Sale. | ||
/// This deliberately only includes a subset of the fields available for Product. | ||
/// It's likely that most or all of a store's products will be fetched and stored for POS, | ||
/// so we wanted a smaller representation and to reduce the risk of decoding issues | ||
/// caused by plugin incompatibilities. | ||
/// | ||
public struct POSProduct: Codable, Equatable, GeneratedCopiable, GeneratedFakeable { | ||
public let siteID: Int64 | ||
public let productID: Int64 | ||
public let name: String | ||
public let productTypeKey: String | ||
public let sku: String? | ||
public let globalUniqueID: String? | ||
|
||
public let price: String | ||
public let regularPrice: String? | ||
public let salePrice: String? | ||
public let onSale: Bool | ||
|
||
public let images: [ProductImage] | ||
|
||
public let attributes: [ProductAttribute] | ||
|
||
public var productType: ProductType { | ||
return ProductType(rawValue: productTypeKey) | ||
} | ||
|
||
/// Filtered product attributes available for variations | ||
/// (attributes with `variation == true`) | ||
/// | ||
public var attributesForVariations: [ProductAttribute] { | ||
attributes.filter { $0.variation } | ||
} | ||
|
||
public init(siteID: Int64, | ||
productID: Int64, | ||
name: String, | ||
productTypeKey: String, | ||
sku: String?, | ||
globalUniqueID: String?, | ||
price: String, | ||
regularPrice: String?, | ||
salePrice: String?, | ||
onSale: Bool, | ||
images: [ProductImage], | ||
attributes: [ProductAttribute]) { | ||
self.siteID = siteID | ||
self.productID = productID | ||
self.name = name | ||
self.productTypeKey = productTypeKey | ||
self.sku = sku | ||
self.globalUniqueID = globalUniqueID | ||
|
||
self.price = price | ||
self.regularPrice = regularPrice | ||
self.salePrice = salePrice | ||
self.onSale = onSale | ||
|
||
self.images = images | ||
|
||
self.attributes = attributes | ||
} | ||
|
||
public init(from decoder: any Decoder) throws { | ||
guard let siteID = decoder.userInfo[.siteID] as? Int64 else { | ||
throw POSProductDecodingError.missingSiteID | ||
} | ||
|
||
/// If you make a change which improves the safety of this decoding, | ||
/// consider applying it to `Product` as well. | ||
|
||
let container = try decoder.container(keyedBy: CodingKeys.self) | ||
let decimalString = AlternativeDecodingType.decimal { value in | ||
NSDecimalNumber(decimal: value).stringValue | ||
} | ||
|
||
let productID = try container.decode(Int64.self, forKey: .productID) | ||
let name = try container.decode(String.self, forKey: .name) | ||
let productTypeKey = try container.decode(String.self, forKey: .productTypeKey) | ||
let sku = container.failsafeDecodeIfPresent( | ||
targetType: String.self, | ||
forKey: .sku, | ||
alternativeTypes: [decimalString]) | ||
let globalUniqueID = try container.decodeIfPresent(String.self, forKey: .globalUniqueID) | ||
|
||
let price = container.failsafeDecodeIfPresent( | ||
targetType: String.self, | ||
forKey: .price, | ||
alternativeTypes: [decimalString]) ?? "" | ||
let regularPrice = container.failsafeDecodeIfPresent( | ||
targetType: String.self, | ||
forKey: .regularPrice, | ||
alternativeTypes: [decimalString]) | ||
let onSale = container.failsafeDecodeIfPresent( | ||
targetType: Bool.self, | ||
forKey: .onSale, | ||
alternativeTypes: [ .string(transform: { NSString(string: $0).boolValue })]) ?? false | ||
|
||
// Even though a plain install of WooCommerce Core provides string values, | ||
// some plugins alter the field value from String to Int or Decimal. | ||
let salePrice = container.failsafeDecodeIfPresent( | ||
targetType: String.self, | ||
forKey: .salePrice, | ||
shouldDecodeTargetTypeFirst: false, | ||
alternativeTypes: [ | ||
.string(transform: { (onSale && $0.isEmpty) ? "0" : $0 }), | ||
decimalString]) | ||
|
||
|
||
let images = try container.decode([ProductImage].self, forKey: .images) | ||
|
||
let attributes = try container.decode([ProductAttribute].self, forKey: .attributes) | ||
|
||
self.init(siteID: siteID, | ||
productID: productID, | ||
name: name, | ||
productTypeKey: productTypeKey, | ||
sku: sku, | ||
globalUniqueID: globalUniqueID, | ||
price: price, | ||
regularPrice: regularPrice, | ||
salePrice: salePrice, | ||
onSale: onSale, | ||
images: images, | ||
attributes: attributes) | ||
} | ||
} | ||
|
||
// MARK: - Decoding Errors | ||
// | ||
enum POSProductDecodingError: Error { | ||
case missingSiteID | ||
} | ||
|
||
// MARK: - Coding Keys | ||
// | ||
private extension POSProduct { | ||
enum CodingKeys: String, CodingKey { | ||
case productID = "id" | ||
case name | ||
case productTypeKey = "type" | ||
case sku | ||
case globalUniqueID = "global_unique_id" | ||
case price | ||
case regularPrice = "regular_price" | ||
case salePrice = "sale_price" | ||
case onSale = "on_sale" | ||
case images | ||
case attributes | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -202,11 +202,11 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { | |
/// - Parameters: | ||
/// - siteID: Site for which we'll fetch remote products. | ||
/// - productTypes: A list of product types to be included in the results. | ||
/// - pageNumber: Number of page that should be retrieved. | ||
/// - pageNumber: Number of pages that should be retrieved. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I think this parameter is the page index (starting with 1), not the number of pages (total page count)? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, you're absolutely right! I was obviously not thinking straight, I'll fix it. |
||
/// | ||
public func loadProductsForPointOfSale(for siteID: Int64, | ||
productTypes: [ProductType] = [.simple], | ||
pageNumber: Int = 1) async throws -> PagedItems<Product> { | ||
pageNumber: Int = 1) async throws -> PagedItems<POSProduct> { | ||
let parameters = [ | ||
ParameterKey.page: String(pageNumber), | ||
ParameterKey.perPage: POSConstants.productsPerPage, | ||
|
@@ -224,7 +224,7 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { | |
path: Path.products, | ||
parameters: parameters, | ||
availableAsRESTRequest: true) | ||
let mapper = ProductListMapper(siteID: siteID) | ||
let mapper = ListMapper<POSProduct>(siteID: siteID) | ||
|
||
let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -102,7 +102,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { | |
// Maps result to POSItem, and populate the output with: | ||
// - Formatted price based on store's currency settings. | ||
// - Product thumbnail, if any. | ||
private func mapProductsToPOSItems(products: [Product]) -> [POSItem] { | ||
private func mapProductsToPOSItems(products: [POSProduct]) -> [POSItem] { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice that this is the only change in POS 👍 |
||
return products.compactMap { product in | ||
let thumbnailSource = product.images.first?.src | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👏 no more copying mapper for new use case! We can create an issue to replace existing mappers and let the mobile team know.