Skip to content

Commit b66b823

Browse files
authored
Merge pull request #5588 from woocommerce/issue/5408-product-row-data
Order Creation: Add view model to display product data in ProductRow
2 parents d6ab1f2 + 45ed09f commit b66b823

File tree

6 files changed

+309
-14
lines changed

6 files changed

+309
-14
lines changed

WooCommerce/Classes/ViewRelated/Orders/Order Creation/NewOrder.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ private struct ProductsSection: View {
9797
.headlineStyle()
9898

9999
// TODO: Add a product row for each product added to the order
100-
ProductRow(canChangeQuantity: true)
100+
let viewModel = ProductRowViewModel(id: 1,
101+
name: "Love Ficus",
102+
sku: "123456",
103+
price: "20",
104+
stockStatusKey: "instock",
105+
stockQuantity: 7,
106+
manageStock: true,
107+
canChangeQuantity: true) // Temporary view model with fake data
108+
ProductRow(viewModel: viewModel)
101109

102110
Button(NewOrder.Localization.addProduct) {
103111
showAddProduct.toggle()

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ struct AddProduct: View {
1313
// TODO: Make the product list searchable
1414
LazyVStack {
1515
// TODO: Add a product row for each non-variable product in the store
16-
ProductRow(canChangeQuantity: false)
16+
let viewModel = ProductRowViewModel(id: 1,
17+
name: "Love Ficus",
18+
sku: "123456",
19+
price: "20",
20+
stockStatusKey: "instock",
21+
stockQuantity: 7,
22+
manageStock: true,
23+
canChangeQuantity: false) // Temporary view model with fake data
24+
ProductRow(viewModel: viewModel)
1725
}
1826
.padding()
1927
}

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import SwiftUI
33
/// Represent a single product row in the Product section of a New Order
44
///
55
struct ProductRow: View {
6-
/// Whether the product quantity can be changed.
7-
/// Controls whether the stepper is rendered.
6+
/// View model to drive the view.
87
///
9-
let canChangeQuantity: Bool
8+
@ObservedObject var viewModel: ProductRowViewModel
109

1110
// Tracks the scale of the view due to accessibility changes
1211
@ScaledMetric private var scale: CGFloat = 1
@@ -26,19 +25,19 @@ struct ProductRow: View {
2625

2726
// Product details
2827
VStack(alignment: .leading) {
29-
Text("Love Ficus") // Fake data - product name
30-
Text("7 in stock • $20.00") // Fake data - stock / price
28+
Text(viewModel.name)
29+
Text(viewModel.stockAndPriceLabel)
3130
.subheadlineStyle()
32-
Text("SKU: 123456") // Fake data - SKU
31+
Text(viewModel.skuLabel)
3332
.subheadlineStyle()
3433
}
3534
.accessibilityElement(children: .combine)
3635
}
3736

3837
Spacer()
3938

40-
ProductStepper()
41-
.renderedIf(canChangeQuantity)
39+
ProductStepper(viewModel: viewModel)
40+
.renderedIf(viewModel.canChangeQuantity)
4241
}
4342

4443
Divider()
@@ -51,6 +50,10 @@ struct ProductRow: View {
5150
///
5251
private struct ProductStepper: View {
5352

53+
/// View model to drive the view.
54+
///
55+
@ObservedObject var viewModel: ProductRowViewModel
56+
5457
// Tracks the scale of the view due to accessibility changes
5558
@ScaledMetric private var scale: CGFloat = 1
5659

@@ -65,7 +68,7 @@ private struct ProductStepper: View {
6568
.frame(height: Layout.stepperButtonSize * scale)
6669
}
6770

68-
Text("1") // Fake data - quantity
71+
Text("\(viewModel.quantity)")
6972

7073
Button {
7174
// TODO: Increment the product quantity
@@ -83,7 +86,7 @@ private struct ProductStepper: View {
8386
)
8487
.accessibilityElement(children: .ignore)
8588
.accessibility(label: Text(Localization.quantityLabel))
86-
.accessibility(value: Text("1")) // Fake static data - quantity
89+
.accessibility(value: Text("\(viewModel.quantity)"))
8790
.accessibilityAdjustableAction { direction in
8891
switch direction {
8992
case .decrement:
@@ -111,11 +114,28 @@ private enum Localization {
111114

112115
struct ProductRow_Previews: PreviewProvider {
113116
static var previews: some View {
114-
ProductRow(canChangeQuantity: true)
117+
let viewModel = ProductRowViewModel(id: 1,
118+
name: "Love Ficus",
119+
sku: "123456",
120+
price: "20",
121+
stockStatusKey: "instock",
122+
stockQuantity: 7,
123+
manageStock: true,
124+
canChangeQuantity: true)
125+
let viewModelWithoutStepper = ProductRowViewModel(id: 1,
126+
name: "Love Ficus",
127+
sku: "123456",
128+
price: "20",
129+
stockStatusKey: "instock",
130+
stockQuantity: 7,
131+
manageStock: true,
132+
canChangeQuantity: false)
133+
134+
ProductRow(viewModel: viewModel)
115135
.previewDisplayName("ProductRow with stepper")
116136
.previewLayout(.sizeThatFits)
117137

118-
ProductRow(canChangeQuantity: false)
138+
ProductRow(viewModel: viewModelWithoutStepper)
119139
.previewDisplayName("ProductRow without stepper")
120140
.previewLayout(.sizeThatFits)
121141
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import Foundation
2+
import Yosemite
3+
4+
/// View model for `ProductRow`.
5+
///
6+
final class ProductRowViewModel: ObservableObject, Identifiable {
7+
private let currencyFormatter: CurrencyFormatter
8+
9+
/// Whether the product quantity can be changed.
10+
/// Controls whether the stepper is rendered.
11+
///
12+
let canChangeQuantity: Bool
13+
14+
// MARK: Product properties
15+
16+
/// Product ID
17+
/// Required by SwiftUI as a unique identifier
18+
///
19+
let id: Int64
20+
21+
/// Product name
22+
///
23+
let name: String
24+
25+
/// Product SKU
26+
///
27+
private let sku: String?
28+
29+
/// Product price
30+
///
31+
private let price: String
32+
33+
/// Product stock status
34+
///
35+
private let stockStatus: ProductStockStatus
36+
37+
/// Product stock quantity
38+
///
39+
private let stockQuantity: Decimal?
40+
41+
/// Whether the product's stock quantity is managed
42+
///
43+
private let manageStock: Bool
44+
45+
/// Label showing product stock status and price.
46+
///
47+
lazy var stockAndPriceLabel: String = {
48+
let stockLabel = createStockText()
49+
let priceLabel = createPriceText()
50+
51+
return [stockLabel, priceLabel]
52+
.compactMap({ $0 })
53+
.joined(separator: "")
54+
}()
55+
56+
/// Label showing product SKU
57+
///
58+
lazy var skuLabel: String = {
59+
guard let sku = sku, sku.isNotEmpty else {
60+
return ""
61+
}
62+
return String.localizedStringWithFormat(Localization.skuFormat, sku)
63+
}()
64+
65+
/// Quantity of product in the order
66+
///
67+
@Published var quantity: Int64 = 1
68+
69+
init(id: Int64,
70+
name: String,
71+
sku: String?,
72+
price: String,
73+
stockStatusKey: String,
74+
stockQuantity: Decimal?,
75+
manageStock: Bool,
76+
canChangeQuantity: Bool,
77+
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) {
78+
self.id = id
79+
self.name = name
80+
self.sku = sku
81+
self.price = price
82+
self.stockStatus = .init(rawValue: stockStatusKey)
83+
self.stockQuantity = stockQuantity
84+
self.manageStock = manageStock
85+
self.canChangeQuantity = canChangeQuantity
86+
self.currencyFormatter = currencyFormatter
87+
}
88+
89+
convenience init(product: Product,
90+
canChangeQuantity: Bool,
91+
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) {
92+
self.init(id: product.productID,
93+
name: product.name,
94+
sku: product.sku,
95+
price: product.price,
96+
stockStatusKey: product.stockStatusKey,
97+
stockQuantity: product.stockQuantity,
98+
manageStock: product.manageStock,
99+
canChangeQuantity: canChangeQuantity,
100+
currencyFormatter: currencyFormatter)
101+
}
102+
103+
/// Create the stock text based on a product's stock status/quantity.
104+
///
105+
private func createStockText() -> String {
106+
switch stockStatus {
107+
case .inStock:
108+
if let stockQuantity = stockQuantity, manageStock {
109+
let localizedStockQuantity = NumberFormatter.localizedString(from: stockQuantity as NSDecimalNumber, number: .decimal)
110+
return String.localizedStringWithFormat(Localization.stockFormat, localizedStockQuantity)
111+
} else {
112+
return stockStatus.description
113+
}
114+
default:
115+
return stockStatus.description
116+
}
117+
}
118+
119+
/// Create the price text based on a product's price.
120+
///
121+
private func createPriceText() -> String? {
122+
let unformattedPrice = price.isNotEmpty ? price : "0"
123+
return currencyFormatter.formatAmount(unformattedPrice)
124+
}
125+
}
126+
127+
private extension ProductRowViewModel {
128+
enum Localization {
129+
static let stockFormat = NSLocalizedString("%1$@ in stock", comment: "Label about product's inventory stock status shown during order creation")
130+
static let skuFormat = NSLocalizedString("SKU: %1$@", comment: "SKU label in order details > product row. The variable shows the SKU of the product.")
131+
}
132+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,8 @@
10671067
CC4D1E7925EE415D00B6E4E7 /* RenameAttributesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4D1E7825EE415D00B6E4E7 /* RenameAttributesViewModelTests.swift */; };
10681068
CC53FB3527551A6E00C4CA4F /* ProductRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC53FB3427551A6E00C4CA4F /* ProductRow.swift */; };
10691069
CC53FB382755213900C4CA4F /* AddProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC53FB372755213900C4CA4F /* AddProduct.swift */; };
1070+
CC53FB3A275697B000C4CA4F /* ProductRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */; };
1071+
CC53FB3E2758E2D500C4CA4F /* ProductRowViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC53FB3D2758E2D500C4CA4F /* ProductRowViewModelTests.swift */; };
10701072
CC593A6726EA116300EF0E04 /* ShippingLabelAddNewPackageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC593A6626EA116300EF0E04 /* ShippingLabelAddNewPackageViewModelTests.swift */; };
10711073
CC593A6B26EA640800EF0E04 /* PackageCreationError+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC593A6A26EA640800EF0E04 /* PackageCreationError+UI.swift */; };
10721074
CC69236226010946002FB669 /* LoginProloguePages.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC69236126010946002FB669 /* LoginProloguePages.swift */; };
@@ -2565,6 +2567,8 @@
25652567
CC4D1E7825EE415D00B6E4E7 /* RenameAttributesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameAttributesViewModelTests.swift; sourceTree = "<group>"; };
25662568
CC53FB3427551A6E00C4CA4F /* ProductRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductRow.swift; sourceTree = "<group>"; };
25672569
CC53FB372755213900C4CA4F /* AddProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProduct.swift; sourceTree = "<group>"; };
2570+
CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductRowViewModel.swift; sourceTree = "<group>"; };
2571+
CC53FB3D2758E2D500C4CA4F /* ProductRowViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductRowViewModelTests.swift; sourceTree = "<group>"; };
25682572
CC593A6626EA116300EF0E04 /* ShippingLabelAddNewPackageViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddNewPackageViewModelTests.swift; sourceTree = "<group>"; };
25692573
CC593A6A26EA640800EF0E04 /* PackageCreationError+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PackageCreationError+UI.swift"; sourceTree = "<group>"; };
25702574
CC69236126010946002FB669 /* LoginProloguePages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginProloguePages.swift; sourceTree = "<group>"; };
@@ -5803,6 +5807,7 @@
58035807
children = (
58045808
CC53FB372755213900C4CA4F /* AddProduct.swift */,
58055809
CC53FB3427551A6E00C4CA4F /* ProductRow.swift */,
5810+
CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */,
58065811
);
58075812
path = ProductsSection;
58085813
sourceTree = "<group>";
@@ -5812,6 +5817,7 @@
58125817
children = (
58135818
CCB366AE274518EC007D437A /* NewOrderViewModelTests.swift */,
58145819
AEA622B627468790002A9B57 /* AddOrderCoordinatorTests.swift */,
5820+
CC53FB3D2758E2D500C4CA4F /* ProductRowViewModelTests.swift */,
58155821
);
58165822
path = "Order Creation";
58175823
sourceTree = "<group>";
@@ -8182,6 +8188,7 @@
81828188
028BAC3D22F2DECE008BB4AF /* StoreStatsAndTopPerformersViewController.swift in Sources */,
81838189
D85A3C5626C1911600C0E026 /* InPersonPaymentsPluginNotInstalledView.swift in Sources */,
81848190
B59D1EDF219072CC009D1978 /* ProductReviewTableViewCell.swift in Sources */,
8191+
CC53FB3A275697B000C4CA4F /* ProductRowViewModel.swift in Sources */,
81858192
AEE1D4F525D14F88006A490B /* AttributeOptionListSelectorCommand.swift in Sources */,
81868193
020DD48A23229495005822B1 /* ProductsTabProductTableViewCell.swift in Sources */,
81878194
74AAF6A5212A04A900C612B0 /* ChartMarker.swift in Sources */,
@@ -8587,6 +8594,7 @@
85878594
26FE09E424DCFE5200B9BDF5 /* InAppFeedbackCardViewControllerTests.swift in Sources */,
85888595
0215320D2423309B003F2BBD /* UIStackView+SubviewsTests.swift in Sources */,
85898596
027B8BBD23FE0DE10040944E /* ProductImageActionHandlerTests.swift in Sources */,
8597+
CC53FB3E2758E2D500C4CA4F /* ProductRowViewModelTests.swift in Sources */,
85908598
B5980A6521AC905C00EBF596 /* UIDeviceWooTests.swift in Sources */,
85918599
FEEB2F61268A215E0075A6E0 /* StorageEligibilityErrorInfoWooTests.swift in Sources */,
85928600
31F21B02263C8E150035B50A /* CardReaderSettingsSearchingViewModelTests.swift in Sources */,

0 commit comments

Comments
 (0)