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

[Woo POS][Products Search] Add POSProduct in Networking #15441

Merged
merged 6 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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 Fakes/Fakes/Networking.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,26 @@ extension Networking.OrderTaxLine {
)
}
}
extension Networking.POSProduct {
/// Returns a "ready to use" type filled with fake values.
///
public static func fake() -> Networking.POSProduct {
.init(
siteID: .fake(),
productID: .fake(),
name: .fake(),
productTypeKey: .fake(),
sku: .fake(),
globalUniqueID: .fake(),
price: .fake(),
regularPrice: .fake(),
salePrice: .fake(),
onSale: .fake(),
images: .fake(),
attributes: .fake()
)
}
}
extension Networking.PaymentGateway {
/// Returns a "ready to use" type filled with fake values.
///
Expand Down
8 changes: 8 additions & 0 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@
207D816C2D4A30A30097012E /* products-load-simple-products-empty-price.json in Resources */ = {isa = PBXBuildFile; fileRef = 207D816B2D4A30A30097012E /* products-load-simple-products-empty-price.json */; };
209AD3C32AC196E300825D76 /* WooPaymentsPayoutsOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209AD3C22AC196E300825D76 /* WooPaymentsPayoutsOverview.swift */; };
209AD3C52AC19E7500825D76 /* WooPaymentsDepositsOverviewMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209AD3C42AC19E7500825D76 /* WooPaymentsDepositsOverviewMapper.swift */; };
20ABC0632D95632D0000EADD /* POSProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20ABC0622D95632D0000EADD /* POSProduct.swift */; };
20ABC0672D95995C0000EADD /* ListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20ABC0662D95995C0000EADD /* ListMapper.swift */; };
20D210C32B1780CE0099E517 /* deposits-overview-all.json in Resources */ = {isa = PBXBuildFile; fileRef = 20D210C22B1780CE0099E517 /* deposits-overview-all.json */; };
20D210C52B1788E60099E517 /* deposits-overview-all-no-default-currency.json in Resources */ = {isa = PBXBuildFile; fileRef = 20D210C42B1788E60099E517 /* deposits-overview-all-no-default-currency.json */; };
20F616482CF4B74600F9FA2A /* POSOrdersRemoteProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F616472CF4B74600F9FA2A /* POSOrdersRemoteProtocol.swift */; };
Expand Down Expand Up @@ -1522,6 +1524,8 @@
207D816B2D4A30A30097012E /* products-load-simple-products-empty-price.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "products-load-simple-products-empty-price.json"; sourceTree = "<group>"; };
209AD3C22AC196E300825D76 /* WooPaymentsPayoutsOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutsOverview.swift; sourceTree = "<group>"; };
209AD3C42AC19E7500825D76 /* WooPaymentsDepositsOverviewMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsOverviewMapper.swift; sourceTree = "<group>"; };
20ABC0622D95632D0000EADD /* POSProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProduct.swift; sourceTree = "<group>"; };
20ABC0662D95995C0000EADD /* ListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMapper.swift; sourceTree = "<group>"; };
20D210C22B1780CE0099E517 /* deposits-overview-all.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "deposits-overview-all.json"; sourceTree = "<group>"; };
20D210C42B1788E60099E517 /* deposits-overview-all-no-default-currency.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "deposits-overview-all-no-default-currency.json"; sourceTree = "<group>"; };
20F616472CF4B74600F9FA2A /* POSOrdersRemoteProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSOrdersRemoteProtocol.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3211,6 +3215,7 @@
EE078D8E2AD2E65400C1199E /* JWToken.swift */,
DEDA8DA02B182E850076BF0F /* WordPressTheme.swift */,
DE78DE452B2AE880002E58DE /* WordPressPage.swift */,
20ABC0622D95632D0000EADD /* POSProduct.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -3744,6 +3749,7 @@
CEB9BF422BB199600007978A /* ProductBundleStatsMapper.swift */,
CCF434632906BD7200B4475A /* ProductIDMapper.swift */,
CE0A0F18223987DF0075ED8D /* ProductListMapper.swift */,
20ABC0662D95995C0000EADD /* ListMapper.swift */,
45B204B72489095100FE6526 /* ProductCategoryMapper.swift */,
26615474242D7C9500A31661 /* ProductCategoryListMapper.swift */,
D88D5A4A230BCF0A007B6E01 /* ProductReviewListMapper.swift */,
Expand Down Expand Up @@ -5427,6 +5433,7 @@
B572F69A21AC475C003EEFF0 /* DevicesRemote.swift in Sources */,
3192F220260D33BB0067FEF9 /* WCPayAccount.swift in Sources */,
68CB800E28D8901B00E169F8 /* CustomerMapper.swift in Sources */,
20ABC0632D95632D0000EADD /* POSProduct.swift in Sources */,
45CCFCE227A2C9BF0012E8CB /* InboxNote.swift in Sources */,
311D412C2783BF7400052F64 /* StripeAccount.swift in Sources */,
B518662420A099BF00037A38 /* AlamofireNetwork.swift in Sources */,
Expand Down Expand Up @@ -5527,6 +5534,7 @@
264541B72CA64522006C13A2 /* WooShippingRemote.swift in Sources */,
EE105F452D671F57005AB07F /* WooShippingDestinationAddress.swift in Sources */,
B96158FC2BF63B4F0080E52A /* String+MinMaxQuantities.swift in Sources */,
20ABC0672D95995C0000EADD /* ListMapper.swift in Sources */,
DE50295D28C6068B00551736 /* JetpackUserMapper.swift in Sources */,
B524194121AC60A700D6FC0A /* DotcomDevice.swift in Sources */,
D8EDFE2225EE88C9003D2213 /* ReaderConnectionToken.swift in Sources */,
Expand Down
39 changes: 39 additions & 0 deletions Networking/Networking/Mapper/ListMapper.swift
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 {
Comment on lines +3 to +5
Copy link
Contributor

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.

/// 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
Expand Up @@ -1875,6 +1875,51 @@ extension Networking.OrderTaxLine {
}
}

extension Networking.POSProduct {
public func copy(
siteID: CopiableProp<Int64> = .copy,
productID: CopiableProp<Int64> = .copy,
name: CopiableProp<String> = .copy,
productTypeKey: CopiableProp<String> = .copy,
sku: NullableCopiableProp<String> = .copy,
globalUniqueID: NullableCopiableProp<String> = .copy,
price: CopiableProp<String> = .copy,
regularPrice: NullableCopiableProp<String> = .copy,
salePrice: NullableCopiableProp<String> = .copy,
onSale: CopiableProp<Bool> = .copy,
images: CopiableProp<[ProductImage]> = .copy,
attributes: CopiableProp<[ProductAttribute]> = .copy
) -> Networking.POSProduct {
let siteID = siteID ?? self.siteID
let productID = productID ?? self.productID
let name = name ?? self.name
let productTypeKey = productTypeKey ?? self.productTypeKey
let sku = sku ?? self.sku
let globalUniqueID = globalUniqueID ?? self.globalUniqueID
let price = price ?? self.price
let regularPrice = regularPrice ?? self.regularPrice
let salePrice = salePrice ?? self.salePrice
let onSale = onSale ?? self.onSale
let images = images ?? self.images
let attributes = attributes ?? self.attributes

return Networking.POSProduct(
siteID: siteID,
productID: productID,
name: name,
productTypeKey: productTypeKey,
sku: sku,
globalUniqueID: globalUniqueID,
price: price,
regularPrice: regularPrice,
salePrice: salePrice,
onSale: onSale,
images: images,
attributes: attributes
)
}
}

extension Networking.PaymentGateway {
public func copy(
siteID: CopiableProp<Int64> = .copy,
Expand Down
154 changes: 154 additions & 0 deletions Networking/Networking/Model/POSProduct.swift
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
}
}
3 changes: 3 additions & 0 deletions Networking/Networking/Model/Product/Product.swift
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
throw ProductDecodingError.missingSiteID
}

/// If you make a change which improves the safety of this decoding,
/// consider applying it to `POSProduct` as well, if the field is included there..

let container = try decoder.container(keyedBy: CodingKeys.self)

let productID = try container.decode(Int64.self, forKey: .productID)
Expand Down
6 changes: 3 additions & 3 deletions Networking/Networking/Remote/ProductsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions Yosemite/Yosemite/Model/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public typealias OrderCreateField = Networking.OrdersRemote.CreateOrderField
public typealias PaymentGateway = Networking.PaymentGateway
public typealias PaymentGatewayAccount = Networking.PaymentGatewayAccount
public typealias Product = Networking.Product
public typealias POSProduct = Networking.POSProduct
public typealias ProductAddOn = Networking.ProductAddOn
public typealias ProductAddOnOption = Networking.ProductAddOnOption
public typealias ProductBackordersSetting = Networking.ProductBackordersSetting
Expand Down
2 changes: 1 addition & 1 deletion Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Expand Down
Loading