Skip to content

Commit 645dbbf

Browse files
authored
Merge pull request #5895 from woocommerce/issue/5846-variable-products
Order Creation: Include variable products in product list on Add Product screen
2 parents 732afde + 4597798 commit 645dbbf

File tree

9 files changed

+134
-90
lines changed

9 files changed

+134
-90
lines changed

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductToOrder.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ struct AddProductToOrder: View {
1919
InfiniteScrollList(isLoading: viewModel.shouldShowScrollIndicator,
2020
loadAction: viewModel.syncNextPage) {
2121
ForEach(viewModel.productRows) { rowViewModel in
22-
ProductRow(viewModel: rowViewModel)
23-
.onTapGesture {
24-
viewModel.selectProduct(rowViewModel.productOrVariationID)
25-
isPresented.toggle()
26-
}
22+
createProductRow(rowViewModel: rowViewModel)
2723
}
2824
}
2925
case .empty:
@@ -57,6 +53,23 @@ struct AddProductToOrder: View {
5753
}
5854
.wooNavigationBarStyle()
5955
}
56+
57+
/// Creates the `ProductRow` for a product, depending on whether the product is variable.
58+
///
59+
@ViewBuilder private func createProductRow(rowViewModel: ProductRowViewModel) -> some View {
60+
if rowViewModel.numberOfVariations > 0,
61+
let addVariationToOrderVM = viewModel.getVariationsViewModel(for: rowViewModel.productOrVariationID) {
62+
LazyNavigationLink(destination: AddProductVariationToOrder(isPresented: $isPresented, viewModel: addVariationToOrderVM)) {
63+
ProductRow(viewModel: rowViewModel)
64+
}
65+
} else {
66+
ProductRow(viewModel: rowViewModel)
67+
.onTapGesture {
68+
viewModel.selectProduct(rowViewModel.productOrVariationID)
69+
isPresented.toggle()
70+
}
71+
}
72+
}
6073
}
6174

6275
private extension AddProductToOrder {

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductToOrderViewModel.swift

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,10 @@ final class AddProductToOrderViewModel: ObservableObject {
1414
///
1515
private let stores: StoresManager
1616

17-
/// Product types excluded from the product list.
18-
/// For now, only non-variable product types are supported.
19-
///
20-
private let excludedProductTypes: [ProductType] = [ProductType.variable]
21-
22-
/// Product statuses included in the product list.
23-
/// Only published or private products can be added to an order.
24-
///
25-
private let includedProductStatuses: [ProductStatus] = [ProductStatus.publish, ProductStatus.privateStatus]
26-
2717
/// All products that can be added to an order.
2818
///
2919
private var products: [Product] {
30-
return productsResultsController.fetchedObjects.filter {
31-
let hasValidProductType = !excludedProductTypes.contains( $0.productType )
32-
let hasValidProductStatus = includedProductStatuses.contains( $0.productStatus )
33-
return hasValidProductType && hasValidProductStatus
34-
}
20+
productsResultsController.fetchedObjects.filter { $0.purchasable }
3521
}
3622

3723
/// View models for each product row
@@ -96,6 +82,15 @@ final class AddProductToOrderViewModel: ObservableObject {
9682
}
9783
onProductSelected?(selectedProduct)
9884
}
85+
86+
/// Get the view model for a list of product variations to add to the order
87+
///
88+
func getVariationsViewModel(for productID: Int64) -> AddProductVariationToOrderViewModel? {
89+
guard let variableProduct = products.first(where: { $0.productID == productID }) else {
90+
return nil
91+
}
92+
return AddProductVariationToOrderViewModel(siteID: siteID, product: variableProduct)
93+
}
9994
}
10095

10196
// MARK: - SyncingCoordinatorDelegate & Sync Methods

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductVariationToOrder.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import SwiftUI
33
/// View showing a list of product variations to add to an order.
44
///
55
struct AddProductVariationToOrder: View {
6+
@Environment(\.presentationMode) private var presentation
7+
68
/// Defines whether the view is presented.
79
///
810
@Binding var isPresented: Bool
@@ -43,6 +45,17 @@ struct AddProductVariationToOrder: View {
4345
.ignoresSafeArea(.container, edges: .horizontal)
4446
.navigationTitle(viewModel.productName)
4547
.navigationBarTitleDisplayMode(.inline)
48+
.navigationBarBackButtonHidden(true)
49+
.toolbar {
50+
// Minimal back button
51+
ToolbarItem(placement: .navigationBarLeading) {
52+
Button {
53+
presentation.wrappedValue.dismiss()
54+
} label: {
55+
Image(uiImage: .chevronLeftImage.imageFlippedForRightToLeftLayoutDirection())
56+
}
57+
}
58+
}
4659
.onAppear {
4760
viewModel.syncFirstPage()
4861
}

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/ProductRow.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ struct ProductRow: View {
3030
// Product details
3131
VStack(alignment: .leading) {
3232
Text(viewModel.name)
33-
Text(viewModel.stockAndPriceLabel)
33+
Text(viewModel.productDetailsLabel)
3434
.subheadlineStyle()
3535
Text(viewModel.skuLabel)
3636
.subheadlineStyle()

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/ProductRowViewModel.swift

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
3535

3636
/// Product price
3737
///
38-
private let price: String
38+
private let price: String?
3939

4040
/// Product stock status
4141
///
@@ -49,13 +49,14 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
4949
///
5050
private let manageStock: Bool
5151

52-
/// Label showing product stock status and price.
52+
/// Label showing product details: stock status, price, and variations (if any).
5353
///
54-
lazy var stockAndPriceLabel: String = {
54+
lazy var productDetailsLabel: String = {
5555
let stockLabel = createStockText()
5656
let priceLabel = createPriceText()
57+
let variationsLabel = createVariationsText()
5758

58-
return [stockLabel, priceLabel]
59+
return [stockLabel, priceLabel, variationsLabel]
5960
.compactMap({ $0 })
6061
.joined(separator: "")
6162
}()
@@ -83,17 +84,22 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
8384
quantity <= minimumQuantity
8485
}
8586

87+
/// Number of variations in a variable product
88+
///
89+
let numberOfVariations: Int
90+
8691
init(id: String? = nil,
8792
productOrVariationID: Int64,
8893
name: String,
8994
sku: String?,
90-
price: String,
95+
price: String?,
9196
stockStatusKey: String,
9297
stockQuantity: Decimal?,
9398
manageStock: Bool,
9499
quantity: Decimal = 1,
95100
canChangeQuantity: Bool,
96101
imageURL: URL?,
102+
numberOfVariations: Int = 0,
97103
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) {
98104
self.id = id ?? productOrVariationID.description
99105
self.productOrVariationID = productOrVariationID
@@ -107,6 +113,7 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
107113
self.canChangeQuantity = canChangeQuantity
108114
self.imageURL = imageURL
109115
self.currencyFormatter = currencyFormatter
116+
self.numberOfVariations = numberOfVariations
110117
}
111118

112119
/// Initialize `ProductRowViewModel` with a `Product`
@@ -116,17 +123,26 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
116123
quantity: Decimal = 1,
117124
canChangeQuantity: Bool,
118125
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) {
126+
// Don't show any price for variable products; price will be shown for each product variation.
127+
let price: String?
128+
if product.productType == .variable {
129+
price = nil
130+
} else {
131+
price = product.price
132+
}
133+
119134
self.init(id: id,
120135
productOrVariationID: product.productID,
121136
name: product.name,
122137
sku: product.sku,
123-
price: product.price,
138+
price: price,
124139
stockStatusKey: product.stockStatusKey,
125140
stockQuantity: product.stockQuantity,
126141
manageStock: product.manageStock,
127142
quantity: quantity,
128143
canChangeQuantity: canChangeQuantity,
129144
imageURL: product.imageURL,
145+
numberOfVariations: product.variations.count,
130146
currencyFormatter: currencyFormatter)
131147
}
132148

@@ -186,10 +202,23 @@ final class ProductRowViewModel: ObservableObject, Identifiable {
186202
/// Create the price text based on a product's price.
187203
///
188204
private func createPriceText() -> String? {
205+
guard let price = price else {
206+
return nil
207+
}
189208
let unformattedPrice = price.isNotEmpty ? price : "0"
190209
return currencyFormatter.formatAmount(unformattedPrice)
191210
}
192211

212+
/// Create the variations text for a variable product.
213+
///
214+
private func createVariationsText() -> String? {
215+
guard numberOfVariations > 0 else {
216+
return nil
217+
}
218+
let format = String.pluralize(numberOfVariations, singular: Localization.singleVariation, plural: Localization.pluralVariations)
219+
return String.localizedStringWithFormat(format, numberOfVariations)
220+
}
221+
193222
/// Increment the product quantity.
194223
///
195224
func incrementQuantity() {
@@ -210,5 +239,9 @@ private extension ProductRowViewModel {
210239
enum Localization {
211240
static let stockFormat = NSLocalizedString("%1$@ in stock", comment: "Label about product's inventory stock status shown during order creation")
212241
static let skuFormat = NSLocalizedString("SKU: %1$@", comment: "SKU label in order details > product row. The variable shows the SKU of the product.")
242+
static let singleVariation = NSLocalizedString("%ld variation",
243+
comment: "Label for one product variation when showing details about a variable product")
244+
static let pluralVariations = NSLocalizedString("%ld variations",
245+
comment: "Label for multiple product variations when showing details about a variable product")
213246
}
214247
}

WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/LazyNavigationLink.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ struct LazyNavigationLink<Destination: View, Label: View>: View {
1010

1111
/// Set it to `true` to proceed with the desired navigation. Set it to `false` to remove the view from the navigation context.
1212
///
13-
@Binding var isActive: Bool
13+
var isActive: Binding<Bool>?
1414

1515
/// `NavigationLink` label
1616
///
@@ -19,15 +19,19 @@ struct LazyNavigationLink<Destination: View, Label: View>: View {
1919
/// Creates a navigation link that creates and presents the destination view when active.
2020
/// - Parameters:
2121
/// - destination: A view for the navigation link to present.
22-
/// - isActive: A binding to a Boolean value that indicates whether `destination` is currently presented.
22+
/// - isActive: An optional binding to a Boolean value that indicates whether `destination` is currently presented.
2323
/// - label: A view builder to produce a label describing the `destination` to present.
24-
init(destination: @autoclosure @escaping () -> Destination, isActive: Binding<Bool>, label: @escaping () -> Label) {
24+
init(destination: @autoclosure @escaping () -> Destination, isActive: Binding<Bool>? = nil, label: @escaping () -> Label) {
2525
self.destination = destination
26-
self._isActive = isActive
26+
self.isActive = isActive
2727
self.label = label
2828
}
2929

3030
var body: some View {
31-
NavigationLink(destination: LazyView(destination), isActive: $isActive, label: label)
31+
if let isActive = isActive {
32+
NavigationLink(destination: LazyView(destination), isActive: isActive, label: label)
33+
} else {
34+
NavigationLink(destination: LazyView(destination), label: label)
35+
}
3236
}
3337
}

WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/AddProductToOrderViewModelTests.swift

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class AddProductToOrderViewModelTests: XCTestCase {
2525

2626
func test_view_model_adds_product_rows_with_unchangeable_quantity() {
2727
// Given
28-
let product = Product.fake().copy(siteID: sampleSiteID, statusKey: "publish")
28+
let product = Product.fake().copy(siteID: sampleSiteID, purchasable: true)
2929
insert(product)
3030

3131
// When
@@ -38,44 +38,6 @@ class AddProductToOrderViewModelTests: XCTestCase {
3838
XCTAssertFalse(productRow.canChangeQuantity, "Product row canChangeQuantity property should be false but is true instead")
3939
}
4040

41-
func test_products_include_all_product_types_except_variable() {
42-
// Given
43-
let simpleProduct = Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: "simple", statusKey: "publish")
44-
let groupedProduct = Product.fake().copy(siteID: sampleSiteID, productID: 2, productTypeKey: "grouped", statusKey: "publish")
45-
let affiliateProduct = Product.fake().copy(siteID: sampleSiteID, productID: 3, productTypeKey: "external", statusKey: "publish")
46-
let variableProduct = Product.fake().copy(siteID: sampleSiteID, productID: 4, productTypeKey: "variable", statusKey: "publish")
47-
let subscriptionProduct = Product.fake().copy(siteID: sampleSiteID, productID: 5, productTypeKey: "subscription", statusKey: "publish")
48-
insert([simpleProduct, groupedProduct, affiliateProduct, variableProduct, subscriptionProduct])
49-
50-
// When
51-
let viewModel = AddProductToOrderViewModel(siteID: sampleSiteID, storageManager: storageManager)
52-
53-
// Then
54-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 1 }), "Products do not include simple product")
55-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 2 }), "Products do not include grouped product")
56-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 3 }), "Products do not include affiliate product")
57-
XCTAssertFalse(viewModel.productRows.contains(where: { $0.productOrVariationID == 4 }), "Products include variable product")
58-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 5 }), "Products do not include subscription product")
59-
}
60-
61-
func test_product_rows_only_contain_products_with_published_and_private_statuses() {
62-
// Given
63-
let publishedProduct = Product.fake().copy(siteID: sampleSiteID, productID: 1, statusKey: "publish")
64-
let draftProduct = Product.fake().copy(siteID: sampleSiteID, productID: 2, statusKey: "draft")
65-
let pendingProduct = Product.fake().copy(siteID: sampleSiteID, productID: 3, statusKey: "pending")
66-
let privateProduct = Product.fake().copy(siteID: sampleSiteID, productID: 4, statusKey: "private")
67-
insert([publishedProduct, draftProduct, pendingProduct, privateProduct])
68-
69-
// When
70-
let viewModel = AddProductToOrderViewModel(siteID: sampleSiteID, storageManager: storageManager)
71-
72-
// Then
73-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 1 }), "Product rows do not include published product")
74-
XCTAssertFalse(viewModel.productRows.contains(where: { $0.productOrVariationID == 2 }), "Product rows include draft product")
75-
XCTAssertFalse(viewModel.productRows.contains(where: { $0.productOrVariationID == 3 }), "Product rows include pending product")
76-
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 4 }), "Product rows do not include private product")
77-
}
78-
7941
func test_scrolling_indicator_appears_only_during_sync() {
8042
// Given
8143
let viewModel = AddProductToOrderViewModel(siteID: sampleSiteID, storageManager: storageManager, stores: stores)
@@ -124,7 +86,7 @@ class AddProductToOrderViewModelTests: XCTestCase {
12486
switch action {
12587
case let .synchronizeProducts(_, _, _, _, _, _, _, _, _, _, onCompletion):
12688
XCTAssertEqual(viewModel.syncStatus, .firstPageSync)
127-
let product = Product.fake().copy(siteID: self.sampleSiteID, statusKey: "publish")
89+
let product = Product.fake().copy(siteID: self.sampleSiteID, purchasable: true)
12890
self.insert(product)
12991
onCompletion(.success(true))
13092
default:
@@ -141,7 +103,7 @@ class AddProductToOrderViewModelTests: XCTestCase {
141103

142104
func test_sync_status_does_not_change_while_syncing_when_storage_contains_products() {
143105
// Given
144-
let product = Product.fake().copy(siteID: self.sampleSiteID, statusKey: "publish")
106+
let product = Product.fake().copy(siteID: self.sampleSiteID, purchasable: true)
145107
insert(product)
146108

147109
let viewModel = AddProductToOrderViewModel(siteID: sampleSiteID, storageManager: storageManager, stores: stores)

0 commit comments

Comments
 (0)