diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingAddressField.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingAddressField.swift new file mode 100644 index 00000000000..cc1211de4f9 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingAddressField.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Represents a field in a WooCommerce Shipping address. +final class WooShippingAddressField: ObservableObject { + let type: WooShippingAddressFieldType + + /// The value for the field. + @Published var value: String + + /// An optional display value for the field. + /// + /// Set the display value with `setDisplayValue(_:)` for fields where the value is not suited for display. + @Published private(set) var displayValue: String? + + /// Whether the field is required. + @Published var required: Bool + + /// The error message to display; set if the value is invalid. + @Published private(set) var errorMessage: String? = nil + + /// Closure used to validate a new value for the field. + /// Returns an error message if the value is invalid. + var validate: (String) -> String? + + init(type: WooShippingAddressFieldType, + value: String, + required: Bool, + validate: @escaping (String) -> String?) { + self.type = type + self.value = value + self.required = required + self.validate = validate + + observeValue() + } + + private func observeValue() { + $value + .map { [weak self] newValue in + guard let self else { return nil } + return validate(newValue) + } + .assign(to: &$errorMessage) + } + + /// Sets the display value to the provided value. + func setDisplayValue(_ value: String) { + displayValue = value + } + + /// Validates the field with the current value. + func validateField() { + errorMessage = validate(value) + } +} + +/// Represents the types of fields in a WooCommerce Shipping address. +enum WooShippingAddressFieldType: CaseIterable { + case name + case company + case country + case address + case city + case state + case postalCode + case email + case phone +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressView.swift index 5453a818478..0c87c4a8cd1 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressView.swift @@ -12,7 +12,12 @@ struct WooShippingEditAddressView: View { @ObservedObject var viewModel: WooShippingEditAddressViewModel /// Tracks the focused address field. - @FocusState private var focusedField: AddressField? + @FocusState private var focusedField: WooShippingAddressFieldType? + + /// Tracks the previously focused address field. + /// + /// Used to validate the field when the focus changes. + @State private var previousFocusedField: WooShippingAddressFieldType? @Environment(\.dismiss) private var dismiss @@ -22,9 +27,11 @@ struct WooShippingEditAddressView: View { var body: some View { ScrollView { VStack(spacing: Constants.verticalSpacing) { - AddressTextField(field: .name, text: $viewModel.name, required: viewModel.isRequired(.name), focused: $focusedField) + AddressTextField(field: viewModel.name, + focused: $focusedField) if viewModel.showCompanyField { - AddressTextField(field: .company, text: $viewModel.company, required: viewModel.isRequired(.company), focused: $focusedField) + AddressTextField(field: viewModel.company, + focused: $focusedField) } else { Button { withAnimation { @@ -37,26 +44,32 @@ struct WooShippingEditAddressView: View { .font(.subheadline) .bold() } - AddressSelection(field: .country, selected: viewModel.selectedCountry?.name ?? "", required: viewModel.isRequired(.country)) { + AddressSelection(field: viewModel.country) { isPresentingCountrySelector = true } .padding(.top, Constants.extraPadding) - AddressTextField(field: .address, text: $viewModel.address, required: viewModel.isRequired(.address), focused: $focusedField) - AddressTextField(field: .city, text: $viewModel.city, required: viewModel.isRequired(.city), focused: $focusedField) + AddressTextField(field: viewModel.address, + focused: $focusedField) + AddressTextField(field: viewModel.city, + focused: $focusedField) AdaptiveStack(horizontalAlignment: .leading, verticalAlignment: .top, spacing: Constants.innerSpacing) { if viewModel.statesOfSelectedCountry.isNotEmpty { - AddressSelection(field: .state, selected: viewModel.selectedState?.name ?? " ", required: viewModel.isRequired(.state)) { + AddressSelection(field: viewModel.state) { isPresentingStateSelector = true } } else { - AddressTextField(field: .state, text: $viewModel.state, required: viewModel.isRequired(.state), focused: $focusedField) + AddressTextField(field: viewModel.state, + focused: $focusedField) } - AddressTextField(field: .postalCode, text: $viewModel.postalCode, required: viewModel.isRequired(.postalCode), focused: $focusedField) + AddressTextField(field: viewModel.postalCode, + focused: $focusedField) } .padding(.bottom, Constants.extraPadding) - AddressTextField(field: .email, text: $viewModel.email, required: viewModel.isRequired(.email), focused: $focusedField) - AddressTextField(field: .phone, text: $viewModel.phone, required: viewModel.isRequired(.phone), focused: $focusedField) - .padding(.bottom, Constants.extraPadding) + AddressTextField(field: viewModel.email, + focused: $focusedField) + AddressTextField(field: viewModel.phone, + focused: $focusedField) + .padding(.bottom, Constants.extraPadding) if viewModel.showSaveAsDefault { Toggle(Localization.defaultAddress, isOn: $viewModel.isDefaultAddress) .font(.subheadline) @@ -76,13 +89,13 @@ struct WooShippingEditAddressView: View { }, label: { Image(systemName: "chevron.backward") }) - .disabled(focusedField == AddressField.allCases.first) + .disabled(focusedField == WooShippingAddressFieldType.allCases.first) Button(action: { focusNextField() }, label: { Image(systemName: "chevron.forward") }) - .disabled(focusedField == AddressField.allCases.last) + .disabled(focusedField == WooShippingAddressFieldType.allCases.last) Spacer() Button { dismissKeyboard() @@ -152,48 +165,35 @@ struct WooShippingEditAddressView: View { } private struct AddressTextField: View { - /// Which address field to display. - let field: AddressField - - /// The text to display in the text field. - @Binding var text: String - - /// Whether the field is required. - let required: Bool + @ObservedObject var field: WooShippingAddressField /// The focused state of the field. - @FocusState.Binding var focused: AddressField? + @FocusState.Binding var focused: WooShippingAddressFieldType? var body: some View { VStack(spacing: Constants.innerSpacing) { HStack(spacing: Constants.requiredLabelSpacing) { - Text(field.title) - if required { + Text(Localization.title(for: field.type)) + if field.required { Text("*") } Spacer() } .font(.subheadline) .foregroundStyle(Color(.text)) - TextField(field.title, text: $text, prompt: Text(required ? "" : Localization.optional)) - .focused($focused, equals: field) + TextField(Localization.title(for: field.type), text: $field.value, prompt: Text(field.required ? "" : Localization.optional)) + .focused($focused, equals: field.type) .padding() .roundedBorder(cornerRadius: Constants.cornerRadius, - lineColor: focused == field ? Color(.accent) : Constants.defaultBorderColor, - lineWidth: focused == field ? 2 : Constants.defaultBorderWidth) + lineColor: focused == field.type ? Color(.accent) : Constants.defaultBorderColor, + lineWidth: focused == field.type ? 2 : Constants.defaultBorderWidth) } } } private struct AddressSelection: View { /// Which address field to display. - let field: AddressField - - /// The text to display for the selection. - let selected: String - - /// Whether the field is required. - let required: Bool + @ObservedObject var field: WooShippingAddressField /// The action to perform when the button is tapped. var action: () -> Void @@ -201,8 +201,9 @@ struct WooShippingEditAddressView: View { var body: some View { VStack(spacing: Constants.innerSpacing) { HStack(spacing: Constants.requiredLabelSpacing) { - Text(field.title) - if required { + + Text(Localization.title(for: field.type)) + if field.required { Text("*") } Spacer() @@ -213,7 +214,7 @@ struct WooShippingEditAddressView: View { action() } label: { HStack { - Text(selected) + Text(field.displayValue ?? field.value) .bodyStyle() Spacer() Image(systemName: "chevron.up.chevron.down") @@ -230,31 +231,6 @@ struct WooShippingEditAddressView: View { } extension WooShippingEditAddressView { - enum AddressField: CaseIterable { - case name - case company - case country - case address - case city - case state - case postalCode - case email - case phone - - var title: String { - switch self { - case .name: return Localization.name - case .company: return Localization.company - case .country: return Localization.country - case .address: return Localization.address - case .city: return Localization.city - case .state: return Localization.state - case .postalCode: return Localization.postalCode - case .email: return Localization.email - case .phone: return Localization.phone - } - } - } /// Navigates to the next address field in the form. private func focusNextField() { @@ -322,6 +298,19 @@ private extension WooShippingEditAddressView { } enum Localization { + static func title(for type: WooShippingAddressFieldType) -> String { + switch type { + case .name: return Localization.name + case .company: return Localization.company + case .country: return Localization.country + case .address: return Localization.address + case .city: return Localization.city + case .state: return Localization.state + case .postalCode: return Localization.postalCode + case .email: return Localization.email + case .phone: return Localization.phone + } + } static let name = NSLocalizedString("wooShipping.createLabels.editAddress.name", value: "Name", comment: "Label for the name field when editing an address in the Woo Shipping label creation flow") diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressViewModel.swift index f46795048f1..4aefe91c06a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingEditAddressViewModel.swift @@ -22,15 +22,15 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { // MARK: Address properties let id: String - @Published var name: String - @Published var company: String - private(set) var country: String - @Published var address: String - @Published var city: String - @Published var state: String - @Published var postalCode: String - @Published var email: String - @Published var phone: String + @Published var name: WooShippingAddressField + @Published var company: WooShippingAddressField + @Published var country: WooShippingAddressField + @Published var address: WooShippingAddressField + @Published var city: WooShippingAddressField + @Published var state: WooShippingAddressField + @Published var postalCode: WooShippingAddressField + @Published var email: WooShippingAddressField + @Published var phone: WooShippingAddressField /// Whether the address is the default address for shipping labels; this is only used for origin addresses. @Published var isDefaultAddress: Bool @@ -48,8 +48,14 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { /// Whether the address has been remotely verified. private var isVerified: Bool - /// Fields that are invalid based on local validation. - @Published private(set) var invalidFields: [WooShippingEditAddressView.AddressField] = [] + var allFields: [WooShippingAddressField] { + [name, company, country, address, city, state, postalCode, email, phone] + } + + /// Fields with validation errors based on local validation. + var invalidFields: [WooShippingAddressField] { + allFields.filter { $0.errorMessage != nil } + } /// Whether the phone number is required. private let phoneNumberRequired: Bool @@ -112,12 +118,12 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { /// Whether the address is in the US. var isUSAddress: Bool { - country == "US" + country.value == "US" } /// States of the selected country. var statesOfSelectedCountry: [StateOfACountry] { - countries.first { $0.code == country }?.states.sorted { $0.name < $1.name } ?? [] + countries.first { $0.code == country.value }?.states.sorted { $0.name < $1.name } ?? [] } /// Whether the state is required for the selected country. @@ -144,15 +150,25 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { storageManager: StorageManagerType = ServiceLocator.storageManager) { self.addressType = type self.id = id - self.name = name - self.company = company - self.country = country - self.address = address - self.city = city - self.state = state - self.postalCode = postalCode - self.email = email - self.phone = phone + self.name = WooShippingAddressField(type: .name, value: name, required: company.isEmpty, validate: { _ in return nil }) + self.company = WooShippingAddressField(type: .company, value: company, required: name.isEmpty, validate: { _ in return nil }) + self.country = WooShippingAddressField(type: .country, value: country, required: true, validate: { newCountry in + newCountry.isEmpty ? Localization.Validation.country : nil + }) + self.address = WooShippingAddressField(type: .address, value: address, required: true, validate: { newAddress in + newAddress.isEmpty ? Localization.Validation.address : nil + }) + self.city = WooShippingAddressField(type: .city, value: city, required: true, validate: { newCity in + newCity.isEmpty ? Localization.Validation.city : nil + }) + self.state = WooShippingAddressField(type: .state, value: state, required: false, validate: { _ in return nil }) + self.postalCode = WooShippingAddressField(type: .postalCode, value: postalCode, required: true, validate: { newPostalCode in + newPostalCode.isEmpty ? Localization.Validation.postalCode : nil + }) + self.email = WooShippingAddressField(type: .email, value: email, required: true, validate: { newEmail in + newEmail.isEmpty ? Localization.Validation.email : nil + }) + self.phone = WooShippingAddressField(type: .phone, value: phone, required: phoneNumberRequired, validate: { _ in return nil}) self.isDefaultAddress = isDefaultAddress self.showCompanyField = showCompanyField self.isVerified = isVerified @@ -161,6 +177,33 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { self.siteID = stores.sessionManager.defaultStoreID ?? Int64.min self.storageManager = storageManager + // Set validation rules for fields that rely on instance properties. + self.name.validate = { [weak self] newName in + guard let self, self.company.value.isEmpty else { + return nil + } + return newName.isEmpty ? Localization.Validation.nameOrCompany : nil + } + self.company.validate = { [weak self] newCompany in + guard let self, self.name.value.isEmpty else { + return nil + } + return newCompany.isEmpty ? Localization.Validation.nameOrCompany : nil + } + self.state.validate = { [weak self] newState in + guard let self, stateRequired else { + return nil + } + return newState.isEmpty ? Localization.Validation.state : nil + } + self.phone.validate = { [weak self] newPhone in + guard let self else { + return nil + } + return self.isPhoneNumberValid ? nil : Localization.Validation.phone + } + + observeNameAndCompany() observeSelectedCountry() observeSelectedState() fetchCountries() @@ -187,64 +230,19 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { stores: stores, storageManager: storageManager) } - - func isRequired(_ field: WooShippingEditAddressView.AddressField) -> Bool { - switch field { - case .name: - return company.isEmpty - case .company: - return name.isEmpty - case .country, .address, .city, .postalCode, .email: - return true - case .state: - return stateRequired - case .phone: - return phoneNumberRequired - } - } } // MARK: Validation extension WooShippingEditAddressViewModel { - /// Locally validates all fields in the address at once. + /// Validate all fields in the address. func validateAddress() { - for field in WooShippingEditAddressView.AddressField.allCases { - validate(field) - } - } - - /// Locally validates the given field and appends/removes it from the list of invalid fields. - func validate(_ field: WooShippingEditAddressView.AddressField) { - if isValid(field) { - invalidFields.removeAll { $0 == field } - } else if !invalidFields.contains(field) { - invalidFields.append(field) - } + allFields.forEach { $0.validateField() } } - /// Checks if the field is valid based on local validation. - private func isValid(_ field: WooShippingEditAddressView.AddressField) -> Bool { - switch field { - case .name: - return !isRequired(.name) || name.isNotEmpty - case .company: - return !isRequired(.company) || company.isNotEmpty - case .country: - return !isRequired(.country) || country.isNotEmpty - case .address: - return !isRequired(.address) || address.isNotEmpty - case .city: - return !isRequired(.city) || city.isNotEmpty - case .state: - return !isRequired(.state) || state.isNotEmpty - case .postalCode: - return !isRequired(.postalCode) || postalCode.isNotEmpty - case .email: - return !isRequired(.email) || email.isNotEmpty - case .phone: - return isPhoneNumberValid - } + /// Validate the address field with the given type. + func validate(_ field: WooShippingAddressFieldType) { + allFields.first { $0.type == field }?.validateField() } /// Validates phone number for the address. @@ -252,13 +250,13 @@ extension WooShippingEditAddressViewModel { /// has length 10 with additional "1" area code for US. /// private var isPhoneNumberValid: Bool { - guard phone.isNotEmpty else { + guard phone.value.isNotEmpty else { return !phoneNumberRequired } guard isUSAddress else { return true } - let phoneDigits = phone.components(separatedBy: .decimalDigits.inverted).joined() + let phoneDigits = phone.value.components(separatedBy: .decimalDigits.inverted).joined() if phoneDigits.hasPrefix("1") { return phoneDigits.count == 11 } else { @@ -268,13 +266,27 @@ extension WooShippingEditAddressViewModel { } private extension WooShippingEditAddressViewModel { + func observeNameAndCompany() { + (name.$value.removeDuplicates()).combineLatest(company.$value.removeDuplicates()) + .sink { [weak self] name, company in + guard let self else { return } + self.name.required = company.isEmpty + self.company.required = name.isEmpty + self.name.validateField() + self.company.validateField() + } + .store(in: &cancellables) + } + func observeSelectedCountry() { $selectedCountry .dropFirst() .sink { [weak self] selectedCountry in guard let self, let selectedCountry, self.selectedCountry != selectedCountry else { return } - country = selectedCountry.code + country.value = selectedCountry.code + country.setDisplayValue(selectedCountry.name) selectedState = nil + state.required = stateRequired } .store(in: &cancellables) } @@ -284,7 +296,8 @@ private extension WooShippingEditAddressViewModel { .dropFirst() .sink { [weak self] selectedState in guard let self else { return } - state = selectedState?.code ?? "" + state.value = selectedState?.code ?? "" + state.setDisplayValue(selectedState?.name ?? "") } .store(in: &cancellables) } @@ -311,8 +324,8 @@ private extension WooShippingEditAddressViewModel { try? resultsController.performFetch() // Updating the selected country clears the selected state. // We track the initial state code so we can set the correct selected state. - let stateCode = state - selectedCountry = countries.first { $0.code == country } + let stateCode = state.value + selectedCountry = countries.first { $0.code == country.value } selectedState = statesOfSelectedCountry.first { $0.code == stateCode } } } @@ -336,3 +349,35 @@ private extension WooShippingEditAddressViewModel { ] } } + +private extension WooShippingEditAddressViewModel { + enum Localization { + enum Validation { + static let nameOrCompany = NSLocalizedString("wooShipping.createLabels.editAddress.validation.nameOrCompany", + value: "Please provide a valid name or company name.", + comment: "Validation message when the name and company fields are empty " + + "in the Woo Shipping label creation flow") + static let email = NSLocalizedString("wooShipping.createLabels.editAddress.validation.email", + value: "Please provide a valid email address.", + comment: "Validation message when the email field is empty in the Woo Shipping label creation flow") + static let phone = NSLocalizedString("wooShipping.createLabels.editAddress.validation.phone", + value: "Please provide a valid phone number.", + comment: "Validation message when the phone field is empty in the Woo Shipping label creation flow") + static let country = NSLocalizedString("wooShipping.createLabels.editAddress.validation.country", + value: "Please select a country.", + comment: "Validation message when the country field is empty in the Woo Shipping label creation flow") + static let address = NSLocalizedString("wooShipping.createLabels.editAddress.validation.address", + value: "Please provide a valid address.", + comment: "Validation message when the address field is empty in the Woo Shipping label creation flow") + static let city = NSLocalizedString("wooShipping.createLabels.editAddress.validation.city", + value: "Please provide a valid city.", + comment: "Validation message when the city field is empty in the Woo Shipping label creation flow") + static let state = NSLocalizedString("wooShipping.createLabels.editAddress.validation.state", + value: "Please provide a valid state.", + comment: "Validation message when the state field is empty in the Woo Shipping label creation flow") + static let postalCode = NSLocalizedString("wooShipping.createLabels.editAddress.validation.postalCode", + value: "Please provide a valid postal code.", + comment: "Validation message when the postal code field is empty in the Woo Shipping label creation flow") + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7d17ddff488..6a9694859b8 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2382,6 +2382,7 @@ CECC759923D6160000486676 /* AggregateDataHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECC759823D6160000486676 /* AggregateDataHelperTests.swift */; }; CECC759C23D61C1400486676 /* AggregateDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECC759B23D61C1400486676 /* AggregateDataHelper.swift */; }; CECEFA6D2CA2CEB50071C7DB /* WooShippingPackageAndRatePlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECEFA6C2CA2CEB50071C7DB /* WooShippingPackageAndRatePlaceholder.swift */; }; + CED9BCD12D412E2400C063B8 /* WooShippingAddressField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED9BCD02D412E1E00C063B8 /* WooShippingAddressField.swift */; }; CEDBDA472B6BEF2E002047D4 /* AnalyticsWebReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDBDA462B6BEF2E002047D4 /* AnalyticsWebReport.swift */; }; CEE006052077D1280079161F /* SummaryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE006032077D1280079161F /* SummaryTableViewCell.swift */; }; CEE006062077D1280079161F /* SummaryTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEE006042077D1280079161F /* SummaryTableViewCell.xib */; }; @@ -5533,6 +5534,7 @@ CECC759823D6160000486676 /* AggregateDataHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateDataHelperTests.swift; sourceTree = ""; }; CECC759B23D61C1400486676 /* AggregateDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateDataHelper.swift; sourceTree = ""; }; CECEFA6C2CA2CEB50071C7DB /* WooShippingPackageAndRatePlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingPackageAndRatePlaceholder.swift; sourceTree = ""; }; + CED9BCD02D412E1E00C063B8 /* WooShippingAddressField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAddressField.swift; sourceTree = ""; }; CEDBDA462B6BEF2E002047D4 /* AnalyticsWebReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsWebReport.swift; sourceTree = ""; }; CEE006032077D1280079161F /* SummaryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryTableViewCell.swift; sourceTree = ""; }; CEE006042077D1280079161F /* SummaryTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SummaryTableViewCell.xib; sourceTree = ""; }; @@ -12136,6 +12138,7 @@ CECAE70B2D22EF9B000AE10B /* WooShippingOriginAddressListViewModel.swift */, CE852E1E2D2EDC4C00C7DBB6 /* WooShippingEditAddressView.swift */, CE5A9BBA2D3137ED00FBADDF /* WooShippingEditAddressViewModel.swift */, + CED9BCD02D412E1E00C063B8 /* WooShippingAddressField.swift */, ); path = WooShippingAddresses; sourceTree = ""; @@ -15900,6 +15903,7 @@ 0206483A23FA4160008441BB /* OrdersRootViewController.swift in Sources */, 022BF7FD23B9D708000A1DFB /* InProgressViewController.swift in Sources */, EEB4E2D629B2063800371C3C /* StoreOnboardingTaskViewModel.swift in Sources */, + CED9BCD12D412E2400C063B8 /* WooShippingAddressField.swift in Sources */, 867644A62B55121A0044ACAA /* BlazeCampaignCreationCoordinator.swift in Sources */, 204C20482D354C6A00E6D9CF /* POSTertiaryButtonStyle.swift in Sources */, 45527A412472C6160078D609 /* SwitchStoreUseCase.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingEditAddressViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingEditAddressViewModelTests.swift index 41ca4c9e1cb..e3d0e9f9f94 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingEditAddressViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingEditAddressViewModelTests.swift @@ -44,15 +44,15 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { // Then XCTAssertEqual(viewModel.id, id) - XCTAssertEqual(viewModel.name, name) - XCTAssertEqual(viewModel.company, company) - XCTAssertEqual(viewModel.country, country) - XCTAssertEqual(viewModel.address, address) - XCTAssertEqual(viewModel.city, city) - XCTAssertEqual(viewModel.state, state) - XCTAssertEqual(viewModel.postalCode, postalCode) - XCTAssertEqual(viewModel.email, email) - XCTAssertEqual(viewModel.phone, phone) + XCTAssertEqual(viewModel.name.value, name) + XCTAssertEqual(viewModel.company.value, company) + XCTAssertEqual(viewModel.country.value, country) + XCTAssertEqual(viewModel.address.value, address) + XCTAssertEqual(viewModel.city.value, city) + XCTAssertEqual(viewModel.state.value, state) + XCTAssertEqual(viewModel.postalCode.value, postalCode) + XCTAssertEqual(viewModel.email.value, email) + XCTAssertEqual(viewModel.phone.value, phone) XCTAssertEqual(viewModel.isDefaultAddress, saveAsDefault) XCTAssertEqual(viewModel.showCompanyField, showCompanyField) XCTAssertEqual(viewModel.status, .verified) @@ -84,15 +84,15 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { // Then XCTAssertEqual(viewModel.id, address.id) - XCTAssertEqual(viewModel.name, address.fullName) - XCTAssertEqual(viewModel.country, address.country) - XCTAssertEqual(viewModel.company, address.company) - XCTAssertEqual(viewModel.address, address.combinedAddress) - XCTAssertEqual(viewModel.city, address.city) - XCTAssertEqual(viewModel.state, address.state) - XCTAssertEqual(viewModel.postalCode, address.postcode) - XCTAssertEqual(viewModel.phone, address.phone) - XCTAssertEqual(viewModel.email, address.email) + XCTAssertEqual(viewModel.name.value, address.fullName) + XCTAssertEqual(viewModel.country.value, address.country) + XCTAssertEqual(viewModel.company.value, address.company) + XCTAssertEqual(viewModel.address.value, address.combinedAddress) + XCTAssertEqual(viewModel.city.value, address.city) + XCTAssertEqual(viewModel.state.value, address.state) + XCTAssertEqual(viewModel.postalCode.value, address.postcode) + XCTAssertEqual(viewModel.phone.value, address.phone) + XCTAssertEqual(viewModel.email.value, address.email) XCTAssertTrue(viewModel.isDefaultAddress) XCTAssertTrue(viewModel.showCompanyField) XCTAssertEqual(viewModel.status, .verified) @@ -100,7 +100,7 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { XCTAssertEqual(viewModel.countries.count, 1, "Should only include USPS-supported countries for origin addresses") } - func test_isRequired_returns_expected_values() { + func test_expected_fields_are_required() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let storageManager = MockStorageManager() @@ -122,25 +122,19 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { stores: stores, storageManager: storageManager) - // When - var requirements: [WooShippingEditAddressView.AddressField: Bool] = [:] - for field in WooShippingEditAddressView.AddressField.allCases { - requirements[field] = viewModel.isRequired(field) - } - // Then - XCTAssertEqual(requirements[.name], true) - XCTAssertEqual(requirements[.company], true) - XCTAssertEqual(requirements[.country], true) - XCTAssertEqual(requirements[.address], true) - XCTAssertEqual(requirements[.city], true) - XCTAssertEqual(requirements[.state], false) - XCTAssertEqual(requirements[.postalCode], true) - XCTAssertEqual(requirements[.email], true) - XCTAssertEqual(requirements[.phone], true) + XCTAssertEqual(viewModel.name.required, true) + XCTAssertEqual(viewModel.company.required, true) + XCTAssertEqual(viewModel.country.required, true) + XCTAssertEqual(viewModel.address.required, true) + XCTAssertEqual(viewModel.city.required, true) + XCTAssertEqual(viewModel.state.required, false) + XCTAssertEqual(viewModel.postalCode.required, true) + XCTAssertEqual(viewModel.email.required, true) + XCTAssertEqual(viewModel.phone.required, true) } - func test_isRequired_returns_false_for_company_when_name_is_not_empty() { + func test_company_not_required_when_name_is_not_empty() { // Given let viewModel = WooShippingEditAddressViewModel(type: .origin, id: "", @@ -158,14 +152,11 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { isVerified: true, phoneNumberRequired: false) - // When - let isCompanyRequired = viewModel.isRequired(.company) - // Then - XCTAssertFalse(isCompanyRequired) + XCTAssertFalse(viewModel.company.required) } - func test_isRequired_returns_false_for_name_when_company_is_not_empty() { + func test_name_not_required_when_company_is_not_empty() { // Given let viewModel = WooShippingEditAddressViewModel(type: .origin, id: "", @@ -183,14 +174,11 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { isVerified: true, phoneNumberRequired: false) - // When - let isNameRequired = viewModel.isRequired(.name) - // Then - XCTAssertFalse(isNameRequired) + XCTAssertFalse(viewModel.name.required) } - func test_isRequired_returns_false_when_phone_number_not_required() { + func test_phone_number_not_required_when_phoneNumberRequired_set_to_false() { // Given let viewModel = WooShippingEditAddressViewModel(type: .origin, id: "", @@ -208,11 +196,8 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { isVerified: true, phoneNumberRequired: false) - // When - let isPhoneRequired = viewModel.isRequired(.phone) - // Then - XCTAssertFalse(isPhoneRequired) + XCTAssertFalse(viewModel.phone.required) } func test_it_inits_with_expected_values_for_origin_address_type() { @@ -310,7 +295,7 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { XCTAssertTrue(stores.receivedActions.first is DataAction) } - func test_isRequired_returns_true_when_selected_country_contains_states() { + func test_state_required_when_selected_country_contains_states() { // Given let storageManager = MockStorageManager() let country = Country(code: "US", name: "United States", states: [.init(code: "NY", name: "New York")]) @@ -332,11 +317,8 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { phoneNumberRequired: true, storageManager: storageManager) - // When - let isStateRequired = viewModel.isRequired(.state) - // Then - XCTAssertTrue(isStateRequired) + XCTAssertTrue(viewModel.state.required) } func test_selected_country_and_state_properies_set_when_address_contains_country_and_state_in_countries() { @@ -367,8 +349,8 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { // Then XCTAssertEqual(viewModel.selectedCountry, country) XCTAssertEqual(viewModel.selectedState, state) - XCTAssertEqual(viewModel.country, country.code) - XCTAssertEqual(viewModel.state, state.code) + XCTAssertEqual(viewModel.country.value, country.code) + XCTAssertEqual(viewModel.state.value, state.code) } func test_selectedState_cleared_when_new_country_is_selected() { @@ -488,8 +470,8 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { // Then // Note that empty state is valid when country is empty (has no states). - let expectedInvalidFields = WooShippingEditAddressView.AddressField.allCases.filter { $0 != .state } - XCTAssertEqual(viewModel.invalidFields, expectedInvalidFields) + let expectedInvalidFieldTypes = WooShippingAddressFieldType.allCases.filter { $0 != .state } + XCTAssertEqual(viewModel.invalidFields.map { $0.type }, expectedInvalidFieldTypes) XCTAssertEqual(viewModel.status, .missingInformation) } @@ -516,7 +498,7 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { storageManager: storageManager) // When - for field in WooShippingEditAddressView.AddressField.allCases { + for field in WooShippingAddressFieldType.allCases { viewModel.validate(field) } @@ -544,14 +526,14 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { phoneNumberRequired: true) // When - for field in WooShippingEditAddressView.AddressField.allCases { + for field in WooShippingAddressFieldType.allCases { viewModel.validate(field) } // Then // Note that empty state is valid when country is empty (has no states). - let expectedInvalidFields = WooShippingEditAddressView.AddressField.allCases.filter { $0 != .state } - XCTAssertEqual(viewModel.invalidFields, expectedInvalidFields) + let expectedInvalidFields = WooShippingAddressFieldType.allCases.filter { $0 != .state } + XCTAssertEqual(viewModel.invalidFields.map { $0.type }, expectedInvalidFields) XCTAssertEqual(viewModel.status, .missingInformation) } @@ -581,7 +563,7 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { viewModel.validate(.state) // Then - XCTAssertEqual(viewModel.invalidFields, [.state]) + XCTAssertTrue(viewModel.invalidFields.map { $0.type }.contains(.state)) } func test_validate_sets_phone_as_invalid_field_when_invalid_for_US() { @@ -610,7 +592,7 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { viewModel.validate(.phone) // Then - XCTAssertEqual(viewModel.invalidFields, [.phone]) + XCTAssertTrue(viewModel.invalidFields.map { $0.type }.contains(.phone)) } func test_validate_removes_valid_field_from_invalidFields() { @@ -632,13 +614,13 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { phoneNumberRequired: true) // Precondition check viewModel.validate(.name) - XCTAssertTrue(viewModel.invalidFields.contains(.name)) + XCTAssertTrue(viewModel.invalidFields.map { $0.type }.contains(.name)) // When - viewModel.name = "JANE DOE" + viewModel.name.value = "JANE DOE" viewModel.validate(.name) // Then - XCTAssertFalse(viewModel.invalidFields.contains(.name)) + XCTAssertFalse(viewModel.invalidFields.map { $0.type }.contains(.name)) } }