Skip to content

Commit c6f4f07

Browse files
authored
Merge pull request #4970 from woocommerce/issue/4599-new-package-details-validation
Shipping Labels: New package details validation
2 parents 28be859 + a6718e5 commit c6f4f07

File tree

11 files changed

+393
-18
lines changed

11 files changed

+393
-18
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
extension NumberFormatter {
4+
/// Get a double from a string value, with locale taken into consideration.
5+
///
6+
static func double(from string: String, locale: Locale = .current) -> Double? {
7+
let formatter = NumberFormatter()
8+
formatter.locale = locale
9+
let number = formatter.number(from: string)
10+
return number?.doubleValue
11+
}
12+
13+
/// Get a string from a number with locale taken into consideration.
14+
///
15+
static func localizedString(from number: NSNumber, locale: Locale = .current) -> String? {
16+
let formatter = NumberFormatter()
17+
formatter.locale = locale
18+
formatter.usesGroupingSeparator = true
19+
formatter.groupingSize = 3
20+
formatter.formatterBehavior = .behavior10_4
21+
formatter.numberStyle = .decimal
22+
formatter.generatesDecimalNumbers = true
23+
formatter.roundingMode = .halfUp
24+
return formatter.string(from: number)
25+
}
26+
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Package Details/Multi-package/ShippingLabelPackageItem.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct ShippingLabelPackageItem: View {
2323

2424
var body: some View {
2525
CollapsibleView(isCollapsible: isCollapsible, isCollapsed: $isCollapsed, safeAreaInsets: safeAreaInsets) {
26-
ShippingLabelPackageNumberRow(packageNumber: packageNumber, numberOfItems: viewModel.itemsRows.count)
26+
ShippingLabelPackageNumberRow(packageNumber: packageNumber, numberOfItems: viewModel.itemsRows.count, isValid: viewModel.isValidTotalWeight)
2727
} content: {
2828
ListHeaderView(text: Localization.itemsToFulfillHeader, alignment: .left)
2929
.padding(.horizontal, insets: safeAreaInsets)
@@ -66,8 +66,13 @@ struct ShippingLabelPackageItem: View {
6666
}
6767
.background(Color(.systemBackground))
6868

69-
ListHeaderView(text: Localization.footer, alignment: .left)
70-
.padding(.horizontal, insets: safeAreaInsets)
69+
if viewModel.isValidTotalWeight {
70+
ListHeaderView(text: Localization.footer, alignment: .left)
71+
.padding(.horizontal, insets: safeAreaInsets)
72+
} else {
73+
ValidationErrorRow(errorMessage: Localization.invalidWeight)
74+
.padding(.horizontal, insets: safeAreaInsets)
75+
}
7176
}
7277
}
7378
}
@@ -82,6 +87,7 @@ private extension ShippingLabelPackageItem {
8287
comment: "Title of the row for adding the package weight in Shipping Label Package Detail screen")
8388
static let footer = NSLocalizedString("Sum of products and package weight",
8489
comment: "Title of the footer in Shipping Label Package Detail screen")
90+
static let invalidWeight = NSLocalizedString("Invalid weight", comment: "Error message when total weight is invalid in Package Detail screen")
8591
}
8692

8793
enum Constants {

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Package Details/Multi-package/ShippingLabelPackageItemViewModel.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ final class ShippingLabelPackageItemViewModel: ObservableObject {
2424
///
2525
@Published private(set) var itemsRows: [ItemToFulfillRow] = []
2626

27+
/// Whether totalWeight is valid
28+
///
29+
@Published private(set) var isValidTotalWeight: Bool = false
30+
2731
/// The title of the selected package, if any.
2832
///
2933
var selectedPackageName: String {
@@ -36,6 +40,17 @@ final class ShippingLabelPackageItemViewModel: ObservableObject {
3640
}
3741
}
3842

43+
/// Attributes of the package if validated.
44+
///
45+
var validatedPackageAttributes: ShippingLabelPackageAttributes? {
46+
guard validateTotalWeight(totalWeight) else {
47+
return nil
48+
}
49+
return ShippingLabelPackageAttributes(packageID: selectedPackageID,
50+
totalWeight: totalWeight,
51+
productIDs: orderItems.map { $0.productOrVariationID })
52+
}
53+
3954
private let order: Order
4055
private let orderItems: [OrderItem]
4156
private let currency: String
@@ -92,19 +107,23 @@ final class ShippingLabelPackageItemViewModel: ObservableObject {
92107
let calculatedWeight = calculateTotalWeight(products: products,
93108
productVariations: productVariations,
94109
customPackage: packageListViewModel.selectedCustomPackage)
95-
110+
let localizedCalculatedWeight = NumberFormatter.localizedString(from: NSNumber(value: calculatedWeight)) ?? String(calculatedWeight)
96111
// Set total weight to initialTotalWeight if it's different from the calculated weight.
97112
// Otherwise use the calculated weight.
98113
if initialTotalWeight.isNotEmpty, initialTotalWeight != String(calculatedWeight) {
99114
isPackageWeightEdited = true
100115
totalWeight = initialTotalWeight
101116
} else {
102-
totalWeight = String(calculatedWeight)
117+
totalWeight = localizedCalculatedWeight
103118
}
104119

105120
$totalWeight
106-
.map { $0 != String(calculatedWeight) }
121+
.map { $0 != localizedCalculatedWeight }
107122
.assign(to: &$isPackageWeightEdited)
123+
124+
$totalWeight
125+
.map { [weak self] in self?.validateTotalWeight($0) ?? false }
126+
.assign(to: &$isValidTotalWeight)
108127
}
109128
}
110129

@@ -199,6 +218,16 @@ private extension ShippingLabelPackageItemViewModel {
199218
}
200219
return tempTotalWeight
201220
}
221+
222+
/// Validate that total weight is a valid floating point number.
223+
///
224+
private func validateTotalWeight(_ totalWeight: String) -> Bool {
225+
guard totalWeight.isNotEmpty,
226+
let value = NumberFormatter.double(from: totalWeight) else {
227+
return false
228+
}
229+
return value > 0
230+
}
202231
}
203232

204233
private extension ShippingLabelPackageItemViewModel {

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Package Details/Multi-package/ShippingLabelPackagesForm.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ struct ShippingLabelPackagesForm: View {
2727
.navigationBarItems(trailing: Button(action: {
2828
ServiceLocator.analytics.track(.shippingLabelPurchaseFlow,
2929
withProperties: ["state": "packages_selected"])
30-
// TODO-4599: Update selection
30+
viewModel.confirmPackageSelection()
3131
presentation.wrappedValue.dismiss()
3232
}, label: {
3333
Text(Localization.doneButton)
34-
}))
34+
})
35+
.disabled(!viewModel.doneButtonEnabled))
3536
}
3637
}
3738

@@ -51,7 +52,9 @@ struct ShippingLabelPackagesForm_Previews: PreviewProvider {
5152
static var previews: some View {
5253
let viewModel = ShippingLabelPackagesFormViewModel(order: ShippingLabelPackagesFormViewModel.sampleOrder(),
5354
packagesResponse: ShippingLabelPackagesFormViewModel.samplePackageDetails(),
54-
selectedPackages: []) { _ in }
55+
selectedPackages: [],
56+
onSelectionCompletion: { _ in },
57+
onPackageSyncCompletion: { _ in })
5558

5659
ShippingLabelPackagesForm(viewModel: viewModel)
5760
.environment(\.colorScheme, .light)

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Package Details/Multi-package/ShippingLabelPackagesFormViewModel.swift

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,35 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
1717
///
1818
@Published private(set) var itemViewModels: [ShippingLabelPackageItemViewModel] = []
1919

20+
/// Whether Done button on Package Details screen should be enabled.
21+
///
22+
@Published private(set) var doneButtonEnabled: Bool = false
23+
2024
private let order: Order
2125
private let stores: StoresManager
2226
private let storageManager: StorageManagerType
2327
private var resultsControllers: ShippingLabelPackageDetailsResultsControllers?
28+
private let onSelectionCompletion: (_ selectedPackages: [ShippingLabelPackageAttributes]) -> Void
2429
private let onPackageSyncCompletion: (_ packagesResponse: ShippingLabelPackagesResponse?) -> Void
2530

2631
private var cancellables: Set<AnyCancellable> = []
2732

33+
/// Validation states of all items.
34+
///
35+
private var packagesValidation: [String: Bool] = [:] {
36+
didSet {
37+
configureDoneButton()
38+
}
39+
}
40+
41+
/// List of packages that are validated.
42+
///
43+
private var validatedPackages: [ShippingLabelPackageAttributes] {
44+
itemViewModels.compactMap {
45+
$0.validatedPackageAttributes
46+
}
47+
}
48+
2849
/// List of selected package with basic info.
2950
///
3051
@Published private var selectedPackages: [ShippingLabelPackageAttributes] = []
@@ -40,6 +61,7 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
4061
init(order: Order,
4162
packagesResponse: ShippingLabelPackagesResponse?,
4263
selectedPackages: [ShippingLabelPackageAttributes],
64+
onSelectionCompletion: @escaping (_ selectedPackages: [ShippingLabelPackageAttributes]) -> Void,
4365
onPackageSyncCompletion: @escaping (_ packagesResponse: ShippingLabelPackagesResponse?) -> Void,
4466
formatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings),
4567
stores: StoresManager = ServiceLocator.stores,
@@ -49,6 +71,7 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
4971
self.stores = stores
5072
self.storageManager = storageManager
5173
self.selectedPackages = selectedPackages
74+
self.onSelectionCompletion = onSelectionCompletion
5275
self.onPackageSyncCompletion = onPackageSyncCompletion
5376

5477
configureResultsControllers()
@@ -58,9 +81,17 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
5881
configureItemViewModels(order: order, packageResponse: packagesResponse)
5982
}
6083

84+
func confirmPackageSelection() {
85+
onSelectionCompletion(validatedPackages)
86+
}
87+
}
88+
89+
// MARK: - Helper methods
90+
//
91+
private extension ShippingLabelPackagesFormViewModel {
6192
/// If no initial packages was input, set up default package from last selected package ID and all order items.
6293
///
63-
private func configureDefaultPackage() {
94+
func configureDefaultPackage() {
6495
guard selectedPackages.isEmpty,
6596
let selectedPackageID = resultsControllers?.accountSettings?.lastSelectedPackageID else {
6697
return
@@ -72,7 +103,7 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
72103

73104
/// Set up item view models on change of products and product variations.
74105
///
75-
private func configureItemViewModels(order: Order, packageResponse: ShippingLabelPackagesResponse?) {
106+
func configureItemViewModels(order: Order, packageResponse: ShippingLabelPackagesResponse?) {
76107
$selectedPackages.combineLatest($products, $productVariations)
77108
.map { selectedPackages, products, variations -> [ShippingLabelPackageItemViewModel] in
78109
return selectedPackages.map { details in
@@ -94,13 +125,14 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
94125
}
95126
.sink { [weak self] viewModels in
96127
self?.itemViewModels = viewModels
128+
self?.observeItemViewModels()
97129
}
98130
.store(in: &cancellables)
99131
}
100132

101133
/// Update selected packages when user switch any package.
102134
///
103-
private func switchPackage(currentID: String, newPackage: ShippingLabelPackageAttributes) {
135+
func switchPackage(currentID: String, newPackage: ShippingLabelPackageAttributes) {
104136
selectedPackages = selectedPackages.map { package in
105137
if package.packageID == currentID {
106138
return newPackage
@@ -110,7 +142,25 @@ final class ShippingLabelPackagesFormViewModel: ObservableObject {
110142
}
111143
}
112144

113-
private func configureResultsControllers() {
145+
/// Observe validation state of each package and save it by package ID.
146+
///
147+
func observeItemViewModels() {
148+
itemViewModels.forEach { item in
149+
item.$isValidTotalWeight
150+
.sink { [weak self] isValid in
151+
self?.packagesValidation[item.selectedPackageID] = isValid
152+
}
153+
.store(in: &cancellables)
154+
}
155+
}
156+
157+
/// Disable Done button if any of the package validation fails.
158+
///
159+
func configureDoneButton() {
160+
doneButtonEnabled = packagesValidation.first(where: { $0.value == false }) == nil
161+
}
162+
163+
func configureResultsControllers() {
114164
resultsControllers = ShippingLabelPackageDetailsResultsControllers(siteID: order.siteID,
115165
orderItems: order.items,
116166
storageManager: storageManager,

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Package Details/ShippingLabelPackageNumberRow.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import Foundation
44
struct ShippingLabelPackageNumberRow: View {
55
let packageNumber: Int
66
let numberOfItems: Int
7+
let isValid: Bool
78

8-
init(packageNumber: Int, numberOfItems: Int) {
9+
init(packageNumber: Int, numberOfItems: Int, isValid: Bool = true) {
910
self.packageNumber = packageNumber
1011
self.numberOfItems = numberOfItems
12+
self.isValid = isValid
1113
}
1214

1315
var body: some View {
@@ -23,6 +25,9 @@ struct ShippingLabelPackageNumberRow: View {
2325
.font(.body)
2426
}
2527
Spacer()
28+
Image(uiImage: .noticeImage)
29+
.foregroundColor(Color(.error))
30+
.renderedIf(!isValid)
2631
}
2732
}
2833
}
@@ -45,5 +50,8 @@ struct ShippingLabelPackageNumberRow_Previews: PreviewProvider {
4550

4651
ShippingLabelPackageNumberRow(packageNumber: 7, numberOfItems: 1)
4752
.previewLayout(.fixed(width: 375, height: 50))
53+
54+
ShippingLabelPackageNumberRow(packageNumber: 7, numberOfItems: 1, isValid: false)
55+
.previewLayout(.fixed(width: 375, height: 50))
4856
}
4957
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewController.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,9 @@ private extension ShippingLabelFormViewController {
421421
let vm = ShippingLabelPackagesFormViewModel(order: viewModel.order,
422422
packagesResponse: viewModel.packagesResponse,
423423
selectedPackages: inputPackages,
424+
onSelectionCompletion: { [weak self] selectedPackages in
425+
self?.viewModel.handlePackageDetailsValueChanges(details: selectedPackages)
426+
},
424427
onPackageSyncCompletion: { [weak self] (packagesResponse) in
425428
self?.viewModel.handleNewPackagesResponse(packagesResponse: packagesResponse)
426429
})

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,8 @@
12941294
DE525499268C8B32007A5829 /* UIRefreshControl+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE525498268C8B32007A5829 /* UIRefreshControl+Woo.swift */; };
12951295
DE67D46726B98FD000EFE8DB /* Publisher+WithLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */; };
12961296
DE67D46926BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE67D46826BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift */; };
1297+
DE7842ED26F061650030C792 /* NumberFormatter+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */; };
1298+
DE7842EF26F079A60030C792 /* NumberFormatter+LocalizedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7842EE26F079A60030C792 /* NumberFormatter+LocalizedTests.swift */; };
12971299
DE8C94662646990000C94823 /* PluginListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C94652646990000C94823 /* PluginListViewController.swift */; };
12981300
DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C946D264699B600C94823 /* PluginListViewModel.swift */; };
12991301
DEC2961F26BD1605005A056B /* ShippingLabelCustomsFormListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC2961E26BD1605005A056B /* ShippingLabelCustomsFormListViewModel.swift */; };
@@ -2726,6 +2728,8 @@
27262728
DE525498268C8B32007A5829 /* UIRefreshControl+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Woo.swift"; sourceTree = "<group>"; };
27272729
DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithLatestFrom.swift"; sourceTree = "<group>"; };
27282730
DE67D46826BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithLatestFromTests.swift"; sourceTree = "<group>"; };
2731+
DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NumberFormatter+Localized.swift"; sourceTree = "<group>"; };
2732+
DE7842EE26F079A60030C792 /* NumberFormatter+LocalizedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NumberFormatter+LocalizedTests.swift"; sourceTree = "<group>"; };
27292733
DE8C94652646990000C94823 /* PluginListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewController.swift; sourceTree = "<group>"; };
27302734
DE8C946D264699B600C94823 /* PluginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = "<group>"; };
27312735
DEC2961E26BD1605005A056B /* ShippingLabelCustomsFormListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelCustomsFormListViewModel.swift; sourceTree = "<group>"; };
@@ -5233,6 +5237,7 @@
52335237
025C00CB2551524300FAC222 /* BarcodeScannerFrameScalerTests.swift */,
52345238
D88100D2257DD060008DE6F2 /* WordPressComSiteInfoWooTests.swift */,
52355239
DE67D46826BAA82600EFE8DB /* Publisher+WithLatestFromTests.swift */,
5240+
DE7842EE26F079A60030C792 /* NumberFormatter+LocalizedTests.swift */,
52365241
);
52375242
path = Extensions;
52385243
sourceTree = "<group>";
@@ -5730,6 +5735,7 @@
57305735
DE4B3B5726A7041800EEF2D8 /* EdgeInsets+Woo.swift */,
57315736
DE67D46626B98FD000EFE8DB /* Publisher+WithLatestFrom.swift */,
57325737
DEC2962626C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift */,
5738+
DE7842EC26F061650030C792 /* NumberFormatter+Localized.swift */,
57335739
);
57345740
path = Extensions;
57355741
sourceTree = "<group>";
@@ -7501,6 +7507,7 @@
75017507
CE35F11B2343F3B1007B2A6B /* TwoColumnHeadlineFootnoteTableViewCell.swift in Sources */,
75027508
D8C251DB230D288A00F49782 /* PushNotesManager.swift in Sources */,
75037509
0279F0DA252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift in Sources */,
7510+
DE7842ED26F061650030C792 /* NumberFormatter+Localized.swift in Sources */,
75047511
748D34E12148291E00E21A2F /* TopPerformerDataViewController.swift in Sources */,
75057512
02C887712450285100E4470F /* BottomButtonContainerView.swift in Sources */,
75067513
0235BFD9246E959500778909 /* ProductFormActionsFactory.swift in Sources */,
@@ -7952,6 +7959,7 @@
79527959
4574745F24EA9ADE00CF49BC /* ProductTypeBottomSheetListSelectorCommandTests.swift in Sources */,
79537960
AEE2611126E6785400B142A0 /* EditAddressFormViewModelTests.swift in Sources */,
79547961
0212683724C049F000F8A892 /* ProductListMultiSelectorDataSourceTests.swift in Sources */,
7962+
DE7842EF26F079A60030C792 /* NumberFormatter+LocalizedTests.swift in Sources */,
79557963
5768315126694ADC00FDFB6C /* AuthenticationManagerTests.swift in Sources */,
79567964
D82DFB4C225F303200EFE2CB /* EmptyListMessageWithActionTests.swift in Sources */,
79577965
E10DFC78267331590083AFF2 /* ApplicationLogViewModelTests.swift in Sources */,

0 commit comments

Comments
 (0)