diff --git a/Modules/Sources/Yosemite/Tools/ResultsController.swift b/Modules/Sources/Yosemite/Tools/ResultsController.swift index 7a1a25a761b..cb6c5d77c64 100644 --- a/Modules/Sources/Yosemite/Tools/ResultsController.swift +++ b/Modules/Sources/Yosemite/Tools/ResultsController.swift @@ -51,7 +51,7 @@ public class GenericResultsController { /// Internal NSFetchedResultsController Instance. /// - private lazy var controller: NSFetchedResultsController = { + public private(set) lazy var controller: NSFetchedResultsController = { viewStorage.createFetchedResultsController( fetchRequest: fetchRequest, sectionNameKeyPath: sectionNameKeyPath, @@ -65,10 +65,6 @@ public class GenericResultsController { // swiftlint:disable:next weak_delegate private let internalDelegate = FetchedResultsControllerDelegateWrapper() - /// NotificationCenter ObserverBlock Token - /// - private var notificationCenterToken: Any? - /// Closure to be executed before the results are changed. /// public var onWillChangeContent: (() -> Void)? diff --git a/Modules/Tests/YosemiteTests/Tools/ResultsControllerTests.swift b/Modules/Tests/YosemiteTests/Tools/ResultsControllerTests.swift index 26d8937947c..7aa85e476e8 100644 --- a/Modules/Tests/YosemiteTests/Tools/ResultsControllerTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/ResultsControllerTests.swift @@ -56,7 +56,6 @@ final class ResultsControllerTests: XCTestCase { /// func testResultsControllerPicksUpEntitiesAvailablePriorToInstantiation() { storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() let resultsController = ResultsController(viewStorage: viewStorage, sortedBy: [sampleSortDescriptor]) try? resultsController.performFetch() @@ -70,12 +69,11 @@ final class ResultsControllerTests: XCTestCase { /// Verifies that ResultsController does pick up entities inserted after being instantiated. /// func testResultsControllerPicksUpEntitiesInsertedAfterInstantiation() { + storageManager.insertSampleAccount() + let resultsController = ResultsController(viewStorage: viewStorage, sortedBy: [sampleSortDescriptor]) try? resultsController.performFetch() - storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() - XCTAssertEqual(resultsController.sections.count, 1) XCTAssertEqual(resultsController.sections.first?.objects.count, 1) XCTAssertEqual(resultsController.sections.first?.numberOfObjects, 1) @@ -85,18 +83,16 @@ final class ResultsControllerTests: XCTestCase { /// Verifies that `sectionNameKeyPath` effectively causes the ResultsController to produce multiple sections, based on the grouping parameter. /// func testResultsControllerGroupSectionsBySectionNameKeypath() { - let sectionNameKeyPath = "userID" - let resultsController = ResultsController(viewStorage: viewStorage, - sectionNameKeyPath: sectionNameKeyPath, - sortedBy: [sampleSortDescriptor]) - try? resultsController.performFetch() - let numberOfAccounts = 100 for _ in 0 ..< numberOfAccounts { storageManager.insertSampleAccount() } - viewStorage.saveIfNeeded() + let sectionNameKeyPath = "userID" + let resultsController = ResultsController(viewStorage: viewStorage, + sectionNameKeyPath: sectionNameKeyPath, + sortedBy: [sampleSortDescriptor]) + try? resultsController.performFetch() XCTAssertEqual(resultsController.sections.count, numberOfAccounts) @@ -109,15 +105,13 @@ final class ResultsControllerTests: XCTestCase { /// Verifies that `object(at indexPath:)` effectively returns the expected (ReadOnly) Entity. /// func testObjectAtIndexPathReturnsExpectedEntity() { + let mutableAccount = storageManager.insertSampleAccount() let sectionNameKeyPath = "userID" let resultsController = ResultsController(viewStorage: viewStorage, sectionNameKeyPath: sectionNameKeyPath, sortedBy: [sampleSortDescriptor]) try? resultsController.performFetch() - let mutableAccount = storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() - let indexPath = IndexPath(row: 0, section: 0) let readOnlyAccount = resultsController.object(at: indexPath) @@ -144,7 +138,6 @@ final class ResultsControllerTests: XCTestCase { } storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) } @@ -167,7 +160,6 @@ final class ResultsControllerTests: XCTestCase { } storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) } @@ -189,7 +181,6 @@ final class ResultsControllerTests: XCTestCase { } storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) } @@ -211,7 +202,6 @@ final class ResultsControllerTests: XCTestCase { } storageManager.insertSampleAccount() - viewStorage.saveIfNeeded() waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) } @@ -228,7 +218,6 @@ final class ResultsControllerTests: XCTestCase { let second = storageManager.insertSampleAccount().toReadOnly() let expected = [first.userID: first, second.userID: second] - viewStorage.saveIfNeeded() for retrieved in resultsController.fetchedObjects { XCTAssertEqual(retrieved.username, expected[retrieved.userID]?.username) @@ -239,14 +228,13 @@ final class ResultsControllerTests: XCTestCase { /// Verifies that `fetchedObjects` effectively returns all of the (readOnly) objects that are expected to be available. /// func testResettingStorageIsMappedIntoOnResetClosure() { + storageManager.insertSampleAccount() + storageManager.insertSampleAccount() + let sortDescriptor = NSSortDescriptor(key: #selector(getter: StorageAccount.userID).description, ascending: true) let resultsController = ResultsController(viewStorage: viewStorage, sortedBy: [sortDescriptor]) try? resultsController.performFetch() - storageManager.insertSampleAccount() - storageManager.insertSampleAccount() - - viewStorage.saveIfNeeded() XCTAssertEqual(resultsController.fetchedObjects.count, 2) let expectation = self.expectation(description: "OnDidReset") @@ -296,8 +284,6 @@ final class ResultsControllerTests: XCTestCase { } } - viewStorage.saveIfNeeded() - for (sectionNumber, sectionObject) in resultsController.sections.enumerated() { for (row, object) in sectionObject.objects.enumerated() { let indexPath = IndexPath(row: row, section: sectionNumber) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index f7dc6cc697a..7b04887e003 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -7,6 +7,7 @@ - [*] Fix initialization of authenticator to avoid crashes during login [https://github.com/woocommerce/woocommerce-ios/pull/15953] - [*] Shipping Labels: Made HS tariff number field required in customs form for EU destinations [https://github.com/woocommerce/woocommerce-ios/pull/15946] - [*] Order Details: Attempt to improve performance by using a simplified version of product objects. [https://github.com/woocommerce/woocommerce-ios/pull/15959] +- [*] Product List: Load list with simplified product objects to improve performance. [https://teamkiwip2.wordpress.com/2025/08/01/hack-week-improving-performance-when-loading-cached-products/] - [*] Order Details > Edit Shipping/Billing Address: Added map-based address lookup support for iOS 17+. [https://github.com/woocommerce/woocommerce-ios/pull/15964] - [*] Order Creation: Prevent subscription products to be added to an order [https://github.com/woocommerce/woocommerce-ios/pull/15960] - [internal] Replace COTS_DEVICE reader model name with TAP_TO_PAY_DEVICE. [https://github.com/woocommerce/woocommerce-ios/pull/15961] diff --git a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift index 3c940134eff..001ab321f83 100644 --- a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift +++ b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift @@ -50,6 +50,54 @@ extension WooCommerce.AggregateOrderItem { } } +extension WooCommerce.ProductListItem { + func copy( + siteID: CopiableProp = .copy, + productID: CopiableProp = .copy, + name: CopiableProp = .copy, + productTypeKey: CopiableProp = .copy, + statusKey: CopiableProp = .copy, + sku: NullableCopiableProp = .copy, + manageStock: CopiableProp = .copy, + stockQuantity: NullableCopiableProp = .copy, + stockStatusKey: CopiableProp = .copy, + imageURL: NullableCopiableProp = .copy, + variations: CopiableProp<[Int64]> = .copy, + bundleStockStatus: NullableCopiableProp = .copy, + bundleStockQuantity: NullableCopiableProp = .copy + ) -> WooCommerce.ProductListItem { + let siteID = siteID ?? self.siteID + let productID = productID ?? self.productID + let name = name ?? self.name + let productTypeKey = productTypeKey ?? self.productTypeKey + let statusKey = statusKey ?? self.statusKey + let sku = sku ?? self.sku + let manageStock = manageStock ?? self.manageStock + let stockQuantity = stockQuantity ?? self.stockQuantity + let stockStatusKey = stockStatusKey ?? self.stockStatusKey + let imageURL = imageURL ?? self.imageURL + let variations = variations ?? self.variations + let bundleStockStatus = bundleStockStatus ?? self.bundleStockStatus + let bundleStockQuantity = bundleStockQuantity ?? self.bundleStockQuantity + + return WooCommerce.ProductListItem( + siteID: siteID, + productID: productID, + name: name, + productTypeKey: productTypeKey, + statusKey: statusKey, + sku: sku, + manageStock: manageStock, + stockQuantity: stockQuantity, + stockStatusKey: stockStatusKey, + imageURL: imageURL, + variations: variations, + bundleStockStatus: bundleStockStatus, + bundleStockQuantity: bundleStockQuantity + ) + } +} + extension WooCommerce.ShippingLabelSelectedRate { func copy( packageID: CopiableProp = .copy, diff --git a/WooCommerce/Classes/Extensions/Product+ListItem.swift b/WooCommerce/Classes/Extensions/Product+ListItem.swift new file mode 100644 index 00000000000..b520e9ca10b --- /dev/null +++ b/WooCommerce/Classes/Extensions/Product+ListItem.swift @@ -0,0 +1,20 @@ +import Foundation +import Yosemite + +extension Product { + func toListItem() -> ProductListItem { + ProductListItem(siteID: siteID, + productID: productID, + name: name, + productTypeKey: productTypeKey, + statusKey: statusKey, + sku: sku, + manageStock: manageStock, + stockQuantity: stockQuantity, + stockStatusKey: stockStatusKey, + imageURL: imageURL, + variations: variations, + bundleStockStatus: bundleStockStatus, + bundleStockQuantity: bundleStockQuantity) + } +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsProduct.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsProduct.swift index 9378f915cb9..986cea75a2c 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsProduct.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsProduct.swift @@ -4,65 +4,45 @@ import Yosemite /// Represents a Product Entity with basic details to display in the product section of order details screen. /// struct OrderDetailsProduct: Equatable { - let siteID: Int64 let productID: Int64 - let name: String - let productTypeKey: String let sku: String? let price: String let virtual: Bool - let stockQuantity: Decimal? // Core API reports Int or null; some extensions allow decimal values as well - let imageURL: URL? - let addOns: [Yosemite.ProductAddOn] //TODO: migrate AddOns to MetaData + let addOns: [Yosemite.ProductAddOn] var productType: ProductType { return ProductType(rawValue: productTypeKey) } - init(siteID: Int64, - productID: Int64, - name: String, + /// periphery: ignore - used in test module + init(productID: Int64, productTypeKey: String, sku: String?, price: String, virtual: Bool, - stockQuantity: Decimal?, imageURL: URL?, addOns: [Yosemite.ProductAddOn]) { - self.siteID = siteID self.productID = productID - self.name = name self.productTypeKey = productTypeKey self.sku = sku self.price = price self.virtual = virtual - self.stockQuantity = stockQuantity self.imageURL = imageURL self.addOns = addOns } init(storageProduct: StorageProduct) { - self.siteID = storageProduct.siteID self.productID = storageProduct.productID - self.name = storageProduct.name self.productTypeKey = storageProduct.productTypeKey self.sku = storageProduct.sku self.price = storageProduct.price self.virtual = storageProduct.virtual - self.stockQuantity = { - var quantity: Decimal? - if let stockQuantity = storageProduct.stockQuantity { - quantity = Decimal(string: stockQuantity) - } - return quantity - }() - self.imageURL = storageProduct.imagesArray.first?.toReadOnly().imageURL let addOnsArray: [StorageProductAddOn] = storageProduct.addOns?.toArray() ?? [] diff --git a/WooCommerce/Classes/ViewModels/Order Details/Refund Details/RefundDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/Refund Details/RefundDetailsViewModel.swift index 227d9875a44..3af7ee687eb 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/Refund Details/RefundDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/Refund Details/RefundDetailsViewModel.swift @@ -25,12 +25,6 @@ final class RefundDetailsViewModel { self.refund = refund } - /// Products from a Refund - /// - var products: [OrderDetailsProduct] { - return dataSource.products - } - /// Subtotal from all refunded products /// var itemSubtotal: String { diff --git a/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift b/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift index 2ce31339865..308b922a7f7 100644 --- a/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift +++ b/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift @@ -92,6 +92,7 @@ struct ProductDetailsCellViewModel { self.isChildProduct = isChildProduct } + /// periphery: ignore - used in test module /// Order Item initializer /// init(item: OrderItem, diff --git a/WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift b/WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift index fd61f42c2e3..3b69ba8ceec 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift @@ -305,9 +305,16 @@ private struct ProductsTabProductTableViewCellRepresentable: UIViewRepresentable } struct ProductsTabProductTableViewCell_Previews: PreviewProvider { - private static var nonSelectedViewModel = ProductsTabProductViewModel(product: Product.swiftUIPreviewSample(), isSelected: false) - private static var selectedViewModel = ProductsTabProductViewModel(product: Product.swiftUIPreviewSample().copy(statusKey: ProductStatus.pending.rawValue), - isSelected: true) + private static var nonSelectedViewModel = ProductsTabProductViewModel( + product: Product.swiftUIPreviewSample().toListItem(), + isSelected: false + ) + private static var selectedViewModel = ProductsTabProductViewModel( + product: Product.swiftUIPreviewSample() + .toListItem() + .copy(statusKey: ProductStatus.pending.rawValue), + isSelected: true + ) private static func makeStack() -> some View { VStack { diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/EditableProductModel.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/EditableProductModel.swift index e3f7a4887b8..38b1f4e7337 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/EditableProductModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/EditableProductModel.swift @@ -164,14 +164,6 @@ extension EditableProductModel: ProductFormDataModel, TaxClassRequestable { product.bundledItems } - var bundleStockStatus: ProductStockStatus? { - product.bundleStockStatus - } - - var bundleStockQuantity: Int64? { - product.bundleStockQuantity - } - var password: String? { product.password } diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/LinkedProductListSelectorDataSource.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/LinkedProductListSelectorDataSource.swift index 18f816bb164..7d83f198206 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/LinkedProductListSelectorDataSource.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/LinkedProductListSelectorDataSource.swift @@ -74,7 +74,7 @@ final class LinkedProductListSelectorDataSource: PaginatedListSelectorDataSource func configureCell(cell: ProductsTabProductTableViewCell, model: Product) { cell.selectionStyle = .none - let viewModel = ProductsTabProductViewModel(product: model, isDraggable: true) + let viewModel = ProductsTabProductViewModel(product: model.toListItem(), isDraggable: true) cell.update(viewModel: viewModel, imageService: imageService) cell.configureAccessoryDeleteButton { [weak self] in diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorDataSource.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorDataSource.swift index c18305d8bb9..f82e068556c 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorDataSource.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorDataSource.swift @@ -55,7 +55,7 @@ final class ProductListMultiSelectorDataSource: PaginatedListSelectorDataSource func configureCell(cell: ProductsTabProductTableViewCell, model: Product) { cell.selectionStyle = .default - let viewModel = ProductsTabProductViewModel(product: model, isSelected: isSelected(model: model)) + let viewModel = ProductsTabProductViewModel(product: model.toListItem(), isSelected: isSelected(model: model)) cell.update(viewModel: viewModel, imageService: imageService) } diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorSearchUICommand.swift index 4840eed4124..d4193faa8e9 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorSearchUICommand.swift @@ -71,7 +71,7 @@ final class ProductListMultiSelectorSearchUICommand: NSObject, SearchUICommand { } func createCellViewModel(model: Product) -> ProductsTabProductViewModel { - return ProductsTabProductViewModel(product: model, isSelected: isProductSelected(model)) + return ProductsTabProductViewModel(product: model.toListItem(), isSelected: isProductSelected(model)) } /// Synchronizes the Products matching a given Keyword diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormDataModel.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormDataModel.swift index bb1f07b76b4..2a2e5301e13 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormDataModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormDataModel.swift @@ -71,8 +71,6 @@ protocol ProductFormDataModel { // Product Bundles var bundledItems: [ProductBundleItem] { get } - var bundleStockStatus: ProductStockStatus? { get } - var bundleStockQuantity: Int64? { get } // Password var password: String? { get } diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductListItem.swift b/WooCommerce/Classes/ViewRelated/Products/ProductListItem.swift new file mode 100644 index 00000000000..bd2f1df10ae --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/ProductListItem.swift @@ -0,0 +1,107 @@ +import Codegen +import Foundation +import Yosemite + +/// Represents a Product Entity with basic details to display in the product list of Products tab. +/// +struct ProductListItem: Equatable, GeneratedCopiable { + let siteID: Int64 + let productID: Int64 + let name: String + + let productTypeKey: String + let statusKey: String // draft, pending, private, published + let sku: String? + + let manageStock: Bool + let stockQuantity: Decimal? // Core API reports Int or null; some extensions allow decimal values as well + let stockStatusKey: String // instock, outofstock, backorder + + let imageURL: URL? + + let variations: [Int64] + + // MARK: Product Bundle properties + + /// Stock status of this bundle, taking bundled product quantity requirements and limitations into account. Applicable for bundle-type products only. + let bundleStockStatus: ProductStockStatus? + + /// Quantity of bundles left in stock, taking bundled product quantity requirements into account. Applicable for bundle-type products only. + let bundleStockQuantity: Int64? + + /// Computed Properties + /// + var productStatus: ProductStatus { + return ProductStatus(rawValue: statusKey) + } + var productStockStatus: ProductStockStatus { + return ProductStockStatus(rawValue: stockStatusKey) + } + var productType: ProductType { + return ProductType(rawValue: productTypeKey) + } + /// Product struct initializer. + /// + init(siteID: Int64, + productID: Int64, + name: String, + productTypeKey: String, + statusKey: String, + sku: String?, + manageStock: Bool, + stockQuantity: Decimal?, + stockStatusKey: String, + imageURL: URL?, + variations: [Int64], + bundleStockStatus: ProductStockStatus?, + bundleStockQuantity: Int64?) { + self.siteID = siteID + self.productID = productID + self.name = name + self.productTypeKey = productTypeKey + self.statusKey = statusKey + self.sku = sku + self.manageStock = manageStock + self.stockQuantity = stockQuantity + self.stockStatusKey = stockStatusKey + self.imageURL = imageURL + self.variations = variations + self.bundleStockStatus = bundleStockStatus + self.bundleStockQuantity = bundleStockQuantity + } + + init(storageProduct: StorageProduct) { + self.siteID = storageProduct.siteID + self.productID = storageProduct.productID + self.name = storageProduct.name + self.productTypeKey = storageProduct.productTypeKey + self.statusKey = storageProduct.statusKey + self.sku = storageProduct.sku + self.manageStock = storageProduct.manageStock + self.stockQuantity = { + var quantity: Decimal? + if let stockQuantity = storageProduct.stockQuantity { + quantity = Decimal(string: stockQuantity) + } + return quantity + }() + self.stockStatusKey = storageProduct.stockStatusKey + self.imageURL = storageProduct.imagesArray.first?.toReadOnly().imageURL + self.variations = storageProduct.variations ?? [] + self.bundleStockStatus = { + var productBundleStockStatus: ProductStockStatus? + if let bundleStockStatus = storageProduct.bundleStockStatus { + productBundleStockStatus = ProductStockStatus(rawValue: bundleStockStatus) + } + return productBundleStockStatus + }() + self.bundleStockQuantity = storageProduct.bundleStockQuantity as? Int64 + } +} + +extension ProductListItem: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(siteID) + hasher.combine(productID) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductRowViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductRowViewModel.swift index 520f70987b0..914ff3810b9 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductRowViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductRowViewModel.swift @@ -178,6 +178,7 @@ final class ProductRowViewModel: ObservableObject, Identifiable { case .attributes(let attributes): return createAttributesText(from: attributes) default: + // TODO: use `String.createStockText(...)` to display bundle products if necessary return createStockText() } } diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index 502a836dc19..29a81edcc17 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -299,7 +299,7 @@ final class ProductsViewController: UIViewController, GhostableViewController { /// Selects the first product if one is available. Invoked when no product is selected when data is loaded in split view expanded mode. func selectFirstProductIfAvailable() { - guard let firstProduct = resultsController.fetchedObjects.first else { + guard let firstProduct = resultsController.safeObject(at: IndexPath(row: 0, section: 0)) else { return } didSelectProduct(product: firstProduct) @@ -1026,7 +1026,7 @@ private extension ProductsViewController { .map { $0.productOrVariationID.id } var indexPathsToReload: [IndexPath] = [] - for (index, object) in resultsController.fetchedObjects.enumerated() { + for (index, object) in resultsController.listItems.enumerated() { if activeUploadIds.contains(object.productID) != oldIDs.contains(object.productID) { indexPathsToReload.append(IndexPath(row: index, section: 0)) } @@ -1114,8 +1114,7 @@ extension ProductsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(ProductsTabProductTableViewCell.self, for: indexPath) - let product = resultsController.object(at: indexPath) - + let product = resultsController.listItem(at: indexPath) let hasPendingUploads = activeUploadIds.contains(where: { $0 == product.productID }) let viewModel = ProductsTabProductViewModel(product: product, hasPendingUploads: hasPendingUploads) cell.update(viewModel: viewModel, imageService: imageService) @@ -1709,3 +1708,20 @@ private extension ProductsViewController { ) } } + +/// This is a workaround since the batch product update feature makes it hard +/// to convert the results controller to GenericResultsController. +/// We should consider updating batch update to accept product IDs and clean this up. +/// +extension ResultsController { + var listItems: [ProductListItem] { + controller.fetchedObjects?.compactMap { mutableObject in + ProductListItem(storageProduct: mutableObject) + } ?? [] + } + + func listItem(at indexPath: IndexPath) -> ProductListItem { + let mutableObject = controller.object(at: indexPath) + return ProductListItem(storageProduct: mutableObject) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/Variations/ProductsTabProductViewModel+ProductVariation.swift b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductsTabProductViewModel+ProductVariation.swift index b09570e8863..43899e232a6 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Variations/ProductsTabProductViewModel+ProductVariation.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductsTabProductViewModel+ProductVariation.swift @@ -32,7 +32,12 @@ private extension EditableProductVariationModel { } func createStockStatusAttributedString() -> NSAttributedString { - let stockText = createStockText() + let stockText = String.createStockText(productType: productType, + manageStock: manageStock, + stockStatus: stockStatus, + stockQuantity: stockQuantity, + bundleStockStatus: bundleStockStatus, + bundleStockQuantity: bundleStockQuantity) return NSAttributedString(string: stockText, attributes: [ .foregroundColor: UIColor.textSubtle, diff --git a/WooCommerce/Classes/ViewRelated/Products/View Models/ProductFormDataModel+ProductsTabProductViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/View Models/ProductFormDataModel+ProductsTabProductViewModel.swift deleted file mode 100644 index 4617ae282b1..00000000000 --- a/WooCommerce/Classes/ViewRelated/Products/View Models/ProductFormDataModel+ProductsTabProductViewModel.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Helpers for `ProductsTabProductViewModel` from `ProductFormDataModel`. -extension ProductFormDataModel { - /// Create a description text based on a product data model's stock status/quantity. - func createStockText() -> String { - if productType == .bundle { - return createProductBundleStockText() - } - - switch stockStatus { - case .inStock: - if let stockQuantity = stockQuantity, manageStock { - let localizedStockQuantity = NumberFormatter.localizedString(from: stockQuantity as NSDecimalNumber, number: .decimal) - let format = NSLocalizedString("%1$@ in stock", comment: "Label about product's inventory stock status shown on Products tab") - return String.localizedStringWithFormat(format, localizedStockQuantity) - } else { - return NSLocalizedString("In stock", comment: "Label about product's inventory stock status shown on Products tab") - } - default: - return stockStatus.description - } - } - - /// Create a description text based on a product bundle data model's stock status/quantity and bundle stock status/quantity. - private func createProductBundleStockText() -> String { - // Use bundle stock status if it is insufficent stock - if let bundleStockStatus, bundleStockStatus == .insufficientStock { - return bundleStockStatus.description - } - - switch stockStatus { - case .inStock: - let quantityFormat = NSLocalizedString("%1$@ in stock", comment: "Label about product's inventory stock status shown on Products tab") - if let bundleStockQuantity { // Use bundle stock quantity, if set - let localizedStockQuantity = NumberFormatter.localizedString(from: NSDecimalNumber(value: bundleStockQuantity), number: .decimal) - return String.localizedStringWithFormat(quantityFormat, localizedStockQuantity) - } else if let stockQuantity, manageStock { // Otherwise, use product stock quantity if set and product manages stock - let localizedStockQuantity = NumberFormatter.localizedString(from: stockQuantity as NSDecimalNumber, number: .decimal) - return String.localizedStringWithFormat(quantityFormat, localizedStockQuantity) - } else { - return NSLocalizedString("In stock", comment: "Label about product's inventory stock status shown on Products tab") - } - default: - return stockStatus.description - } - } -} diff --git a/WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift index a44042fdb2d..9fd5ed41319 100644 --- a/WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift @@ -31,7 +31,7 @@ struct ProductsTabProductViewModel { // Dependency for configuring the view. let imageService: ImageService - init(product: Product, + init(product: ProductListItem, hasPendingUploads: Bool = false, productVariation: ProductVariation? = nil, isSelected: Bool = false, @@ -39,13 +39,13 @@ struct ProductsTabProductViewModel { isSKUShown: Bool = false, imageService: ImageService = ServiceLocator.imageService) { - imageUrl = product.images.first?.src + imageUrl = product.imageURL?.absoluteString name = product.name.isEmpty ? Localization.noTitle : product.name self.productVariation = productVariation self.isSelected = isSelected self.isDraggable = isDraggable self.hasPendingUploads = hasPendingUploads - detailsAttributedString = EditableProductModel(product: product).createDetailsAttributedString(isSKUShown: isSKUShown) + detailsAttributedString = product.createDetailsAttributedString(isSKUShown: isSKUShown) self.imageService = imageService } @@ -63,10 +63,15 @@ struct ProductsTabProductViewModel { } } -private extension EditableProductModel { +private extension ProductListItem { func createDetailsAttributedString(isSKUShown: Bool) -> NSAttributedString { let statusText = createStatusText() - let stockText = createStockText() + let stockText = String.createStockText(productType: productType, + manageStock: manageStock, + stockStatus: productStockStatus, + stockQuantity: stockQuantity, + bundleStockStatus: bundleStockStatus, + bundleStockQuantity: bundleStockQuantity) let variationsText = createVariationsText() let detailsText = [statusText, stockText, variationsText] @@ -81,37 +86,37 @@ private extension EditableProductModel { .font: StyleManager.footerLabelFont ]) if let statusText = statusText { - attributedString.addAttributes([.foregroundColor: status.descriptionColor], + attributedString.addAttributes([.foregroundColor: productStatus.descriptionColor], range: NSRange(location: 0, length: statusText.count)) } return attributedString } func createStatusText() -> String? { - switch status { + switch productStatus { case .pending, .draft, .privateStatus: - return status.description + return productStatus.description default: return nil } } func createVariationsText() -> String? { - guard !product.variations.isEmpty else { + guard !variations.isEmpty else { return nil } - let numberOfVariations = product.variations.count + let numberOfVariations = variations.count let format = String.pluralize(numberOfVariations, - singular: Localization.VariationCount.singular, - plural: Localization.VariationCount.plural) + singular: EditableProductModel.Localization.VariationCount.singular, + plural: EditableProductModel.Localization.VariationCount.plural) return String.localizedStringWithFormat(format, numberOfVariations) } func createSKUText() -> String? { - guard let sku = product.sku, sku.isNotEmpty else { + guard let sku, sku.isNotEmpty else { return nil } - return String.localizedStringWithFormat(Localization.skuFormat, sku) + return String.localizedStringWithFormat(EditableProductModel.Localization.skuFormat, sku) } } diff --git a/WooCommerce/Classes/ViewRelated/Products/View Models/String+ProductStock.swift b/WooCommerce/Classes/ViewRelated/Products/View Models/String+ProductStock.swift new file mode 100644 index 00000000000..049bcbb5518 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/View Models/String+ProductStock.swift @@ -0,0 +1,76 @@ +import Foundation +import UIKit +import Yosemite + +extension String { + /// Create a description text based on a product data model's stock status/quantity. + static func createStockText(productType: ProductType, + manageStock: Bool, + stockStatus: ProductStockStatus, + stockQuantity: Decimal?, + bundleStockStatus: ProductStockStatus?, + bundleStockQuantity: Int64?) -> String { + if productType == .bundle { + return createProductBundleStockText(manageStock: manageStock, + productStockStatus: stockStatus, + stockQuantity: stockQuantity, + bundleStockStatus: bundleStockStatus, + bundleStockQuantity: bundleStockQuantity) + } + + switch stockStatus { + case .inStock: + if let stockQuantity = stockQuantity, manageStock { + let localizedStockQuantity = NumberFormatter.localizedString(from: stockQuantity as NSDecimalNumber, number: .decimal) + return String.localizedStringWithFormat(Localization.stockQuantity, localizedStockQuantity) + } else { + return Localization.inStock + } + default: + return stockStatus.description + } + } + + /// Create a description text based on a product bundle data model's stock status/quantity and bundle stock status/quantity. + private static func createProductBundleStockText(manageStock: Bool, + productStockStatus: ProductStockStatus, + stockQuantity: Decimal?, + bundleStockStatus: ProductStockStatus?, + bundleStockQuantity: Int64?) -> String { + // Use bundle stock status if it is insufficent stock + if let bundleStockStatus, bundleStockStatus == .insufficientStock { + return bundleStockStatus.description + } + + switch productStockStatus { + case .inStock: + let quantityFormat = Localization.stockQuantity + if let bundleStockQuantity { // Use bundle stock quantity, if set + let localizedStockQuantity = NumberFormatter.localizedString(from: NSDecimalNumber(value: bundleStockQuantity), number: .decimal) + return String.localizedStringWithFormat(quantityFormat, localizedStockQuantity) + } else if let stockQuantity, manageStock { // Otherwise, use product stock quantity if set and product manages stock + let localizedStockQuantity = NumberFormatter.localizedString(from: stockQuantity as NSDecimalNumber, number: .decimal) + return String.localizedStringWithFormat(quantityFormat, localizedStockQuantity) + } else { + return Localization.inStock + } + default: + return productStockStatus.description + } + } +} + +private enum Localization { + static let inStock = NSLocalizedString( + "string.createStockText.inStock", + value: "In stock", + comment: "Label about product's inventory stock status shown on Products tab" + ) + + static let stockQuantity = NSLocalizedString( + "string.createStockText.count", + value: "%1$@ in stock", + comment: "Label about product's inventory stock status shown on Products tab " + + "Placeholder is the stock quantity. Reads as: '20 in stock'." + ) +} diff --git a/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift index 728f31aeb83..cafc53072c6 100644 --- a/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift @@ -114,7 +114,7 @@ final class ProductSearchUICommand: SearchUICommand { func createCellViewModel(model: Product) -> ProductsTabProductViewModel { let hasPendingUploads = activeUploadIds.contains(where: { $0 == model.productID }) - return ProductsTabProductViewModel(product: model, hasPendingUploads: hasPendingUploads, isSKUShown: true) + return ProductsTabProductViewModel(product: model.toListItem(), hasPendingUploads: hasPendingUploads, isSKUShown: true) } /// Synchronizes the Products matching a given Keyword diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d4d7db1f1b4..0291363adcb 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -329,7 +329,6 @@ 025C00BA25514A7100FAC222 /* BarcodeScannerFrameScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025C00B925514A7100FAC222 /* BarcodeScannerFrameScaler.swift */; }; 025C00CC2551524300FAC222 /* BarcodeScannerFrameScalerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025C00CB2551524300FAC222 /* BarcodeScannerFrameScalerTests.swift */; }; 025CA1A62887D17A00CCBB25 /* LoggedOutAppSettingsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA1A52887D17A00CCBB25 /* LoggedOutAppSettingsProtocol.swift */; }; - 025E32BC251D8FEF00685C4A /* ProductFormDataModel+ProductsTabProductViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025E32BB251D8FEF00685C4A /* ProductFormDataModel+ProductsTabProductViewModel.swift */; }; 025FA38B2522CB4D0054CA57 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025FA38A2522CB4D0054CA57 /* AppCoordinator.swift */; }; 025FDD3223717D2900824006 /* EditorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025FDD3123717D2900824006 /* EditorFactory.swift */; }; 025FDD3423717D4900824006 /* AztecEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025FDD3323717D4900824006 /* AztecEditorViewController.swift */; }; @@ -2755,12 +2754,15 @@ DE96844B2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */; }; DE96844D2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844C2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift */; }; DE971219290A9615000C0BD3 /* AddStoreFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE971218290A9615000C0BD3 /* AddStoreFooterView.swift */; }; + DE972D422E3C7238008C2EAE /* Product+ListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE972D412E3C7231008C2EAE /* Product+ListItem.swift */; }; + DE9734B42E3CA163008C2EAE /* String+ProductStock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE9734B32E3CA155008C2EAE /* String+ProductStock.swift */; }; DE9A02A32A44441200193ABF /* RequirementsCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE9A02A22A44441200193ABF /* RequirementsCheckerTests.swift */; }; DE9F2D292A1B1AB2004E5957 /* FirstProductCreatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE9F2D282A1B1AB2004E5957 /* FirstProductCreatedView.swift */; }; DEA0D0682BA82EA2007786F2 /* StatsGranularityV4+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA0D0672BA82EA2007786F2 /* StatsGranularityV4+UI.swift */; }; DEA357132ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA357122ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift */; }; DEA64C532E40B04700791018 /* OrderDetailsProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA64C522E40B04000791018 /* OrderDetailsProduct.swift */; }; DEA64C552E41A2D000791018 /* Product+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA64C542E41A2CA00791018 /* Product+Helpers.swift */; }; + DEA65B372E41A65600791018 /* ProductListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA65B362E41A65100791018 /* ProductListItem.swift */; }; DEA6BCAF2BC6A9B10017D671 /* StoreStatsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA6BCAE2BC6A9B10017D671 /* StoreStatsChart.swift */; }; DEA6BCB12BC6AA040017D671 /* StoreStatsChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA6BCB02BC6AA040017D671 /* StoreStatsChartViewModel.swift */; }; DEA88F502AA9D0100037273B /* AddEditProductCategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA88F4F2AA9D0100037273B /* AddEditProductCategoryViewModel.swift */; }; @@ -3517,7 +3519,6 @@ 025C00B925514A7100FAC222 /* BarcodeScannerFrameScaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerFrameScaler.swift; sourceTree = ""; }; 025C00CB2551524300FAC222 /* BarcodeScannerFrameScalerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerFrameScalerTests.swift; sourceTree = ""; }; 025CA1A52887D17A00CCBB25 /* LoggedOutAppSettingsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedOutAppSettingsProtocol.swift; sourceTree = ""; }; - 025E32BB251D8FEF00685C4A /* ProductFormDataModel+ProductsTabProductViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductFormDataModel+ProductsTabProductViewModel.swift"; sourceTree = ""; }; 025FA38A2522CB4D0054CA57 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 025FDD3123717D2900824006 /* EditorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFactory.swift; sourceTree = ""; }; 025FDD3323717D4900824006 /* AztecEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecEditorViewController.swift; sourceTree = ""; }; @@ -5954,12 +5955,15 @@ DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductSharingAI.swift"; sourceTree = ""; }; DE96844C2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareProductCoordinatorTests.swift; sourceTree = ""; }; DE971218290A9615000C0BD3 /* AddStoreFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddStoreFooterView.swift; sourceTree = ""; }; + DE972D412E3C7231008C2EAE /* Product+ListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+ListItem.swift"; sourceTree = ""; }; + DE9734B32E3CA155008C2EAE /* String+ProductStock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ProductStock.swift"; sourceTree = ""; }; DE9A02A22A44441200193ABF /* RequirementsCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequirementsCheckerTests.swift; sourceTree = ""; }; DE9F2D282A1B1AB2004E5957 /* FirstProductCreatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstProductCreatedView.swift; sourceTree = ""; }; DEA0D0672BA82EA2007786F2 /* StatsGranularityV4+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsGranularityV4+UI.swift"; sourceTree = ""; }; DEA357122ADCC4C9006380BA /* BlazeCampaignListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignListViewModelTests.swift; sourceTree = ""; }; DEA64C522E40B04000791018 /* OrderDetailsProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsProduct.swift; sourceTree = ""; }; DEA64C542E41A2CA00791018 /* Product+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+Helpers.swift"; sourceTree = ""; }; + DEA65B362E41A65100791018 /* ProductListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItem.swift; sourceTree = ""; }; DEA6BCAE2BC6A9B10017D671 /* StoreStatsChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsChart.swift; sourceTree = ""; }; DEA6BCB02BC6AA040017D671 /* StoreStatsChartViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsChartViewModel.swift; sourceTree = ""; }; DEA88F4F2AA9D0100037273B /* AddEditProductCategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditProductCategoryViewModel.swift; sourceTree = ""; }; @@ -6625,7 +6629,7 @@ isa = PBXGroup; children = ( 020DD48C2322A617005822B1 /* ProductsTabProductViewModel.swift */, - 025E32BB251D8FEF00685C4A /* ProductFormDataModel+ProductsTabProductViewModel.swift */, + DE9734B32E3CA155008C2EAE /* String+ProductStock.swift */, ); path = "View Models"; sourceTree = ""; @@ -10168,6 +10172,7 @@ B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */, 0260F40023224E8100EDA10A /* ProductsViewController.swift */, AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */, + DEA65B362E41A65100791018 /* ProductListItem.swift */, 020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */, 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */, 0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */, @@ -12071,6 +12076,7 @@ 57612988245888E2007BB2D9 /* NumberFormatter+LocalizedOrNinetyNinePlus.swift */, 02BA12842461674B008D8325 /* Optional+String.swift */, 02B8650E24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift */, + DE972D412E3C7231008C2EAE /* Product+ListItem.swift */, 571CDD59250ACC470076B8CC /* UITableViewDiffableDataSource+Helpers.swift */, 025C00B925514A7100FAC222 /* BarcodeScannerFrameScaler.swift */, 023D877825EC8BCB00625963 /* UIScrollView+LargeTitleWorkaround.swift */, @@ -16145,7 +16151,6 @@ 022266BC2AE7707000614F34 /* ConfigurableBundleItemViewModel.swift in Sources */, D449C52926DFBCCC00D75B02 /* WhatsNewHostingController.swift in Sources */, B626C71B287659D60083820C /* CustomFieldsListView.swift in Sources */, - 025E32BC251D8FEF00685C4A /* ProductFormDataModel+ProductsTabProductViewModel.swift in Sources */, 02ECD1E624FFB4E900735BE5 /* ProductFactory.swift in Sources */, 260520F42B87BA23005D5D59 /* WooAnalyticsEvent+ConnectivityTool.swift in Sources */, 579CDEFF274D7E7900E8903D /* StoreStatsUsageTracksEventEmitter.swift in Sources */, @@ -16299,6 +16304,7 @@ DE3650662B512889001569A7 /* BlazeTargetLocationPickerView.swift in Sources */, 020DD48A23229495005822B1 /* ProductsTabProductTableViewCell.swift in Sources */, CCA0EF8829CCA89300A44E6F /* CompositeComponentOptionType+UI.swift in Sources */, + DE9734B42E3CA163008C2EAE /* String+ProductStock.swift in Sources */, 26E7EE7429365F0700793045 /* TopPerformersView.swift in Sources */, CE32B11520BF8779006FBCF4 /* ButtonTableViewCell.swift in Sources */, DE74A45B2BD9048E0009C415 /* DashboardCardErrorView.swift in Sources */, @@ -16455,6 +16461,7 @@ B9F3DAAF29BB73CD00DDD545 /* CreateOrderAppIntent.swift in Sources */, 0290C25A2D2C0C5C0090C55C /* InfiniteScrollView.swift in Sources */, AEDDDA0A25CA9C980077F9B2 /* AttributePickerViewController.swift in Sources */, + DE972D422E3C7238008C2EAE /* Product+ListItem.swift in Sources */, 7E7C5F862719A93C00315B61 /* ProductCategoryViewModelBuilder.swift in Sources */, 204D1D622C5A50840064A6BE /* POSModalViewModifier.swift in Sources */, 8601C29A2B9769C200C59D93 /* FancyAlertViewController+Stats.swift in Sources */, @@ -16948,6 +16955,7 @@ 0365986729AFAEFC00F297D3 /* SetUpTapToPayViewModelsOrderedList.swift in Sources */, DEDA8D992B04643E0076BF0F /* ProductSubscription+Empty.swift in Sources */, 6856D31F941A33BAE66F394D /* KeyboardFrameAdjustmentProvider.swift in Sources */, + DEA65B372E41A65600791018 /* ProductListItem.swift in Sources */, DE57462F2B43EB0B0034B10D /* BlazeCampaignCreationForm.swift in Sources */, CCFC50592743E021001E505F /* EditableOrderViewModel.swift in Sources */, CCFBBCF429C4B8AF0081B595 /* ComponentsList.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Testing/Product+Helpers.swift b/WooCommerce/WooCommerceTests/Testing/Product+Helpers.swift index fa6af36778a..30bf106b2ea 100644 --- a/WooCommerce/WooCommerceTests/Testing/Product+Helpers.swift +++ b/WooCommerce/WooCommerceTests/Testing/Product+Helpers.swift @@ -1,16 +1,14 @@ +import Foundation @testable import WooCommerce import Yosemite extension Product { func toOrderDetailsProduct() -> OrderDetailsProduct { - OrderDetailsProduct(siteID: siteID, - productID: productID, - name: name, + OrderDetailsProduct(productID: productID, productTypeKey: productTypeKey, sku: sku, price: price, virtual: virtual, - stockQuantity: stockQuantity, imageURL: imageURL, addOns: addOns) } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductsTabProductViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductsTabProductViewModelTests.swift index 06b386218d8..6c19f055c65 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductsTabProductViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductsTabProductViewModelTests.swift @@ -128,7 +128,7 @@ final class ProductsTabProductViewModelTests: XCTestCase { stockQuantity: 5, stockStatusKey: "instock", bundleStockStatus: .insufficientStock, - bundleStockQuantity: 0) + bundleStockQuantity: 0).toListItem() // When let viewModel = ProductsTabProductViewModel(product: product) @@ -147,7 +147,7 @@ final class ProductsTabProductViewModelTests: XCTestCase { stockQuantity: 5, stockStatusKey: "onbackorder", bundleStockStatus: .inStock, - bundleStockQuantity: 0) + bundleStockQuantity: 0).toListItem() // When let viewModel = ProductsTabProductViewModel(product: product) @@ -166,7 +166,7 @@ final class ProductsTabProductViewModelTests: XCTestCase { stockQuantity: 5, stockStatusKey: "instock", bundleStockStatus: .inStock, - bundleStockQuantity: 1) + bundleStockQuantity: 1).toListItem() // Action let viewModel = ProductsTabProductViewModel(product: product) @@ -186,7 +186,7 @@ final class ProductsTabProductViewModelTests: XCTestCase { stockQuantity: 5, stockStatusKey: "instock", bundleStockStatus: .inStock, - bundleStockQuantity: 1) + bundleStockQuantity: 1).toListItem() // Action let viewModel = ProductsTabProductViewModel(product: product) @@ -205,13 +205,13 @@ extension ProductsTabProductViewModelTests { stockQuantity: Decimal? = nil, stockStatus: ProductStockStatus = .inStock, variations: [Int64] = [], - images: [ProductImage] = []) -> Product { + images: [ProductImage] = []) -> ProductListItem { return Product.fake().copy(name: name, stockQuantity: stockQuantity, stockStatusKey: stockStatus.rawValue, images: images, - variations: variations) + variations: variations).toListItem() } }