diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index def30da7bb4..fdabba330cc 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -10,6 +10,7 @@ - [*] POS: a POS tab in the tab bar is now available in the app for stores in countries eligible for Point of Sale, instead of the tab is only shown when the store is eligible for POS. [https://github.com/woocommerce/woocommerce-ios/pull/15918] - [*] Shipping Labels: Display base rate on selected shipping service cards [https://github.com/woocommerce/woocommerce-ios/pull/15916] - [*] Shipping Labels: Update mark order completed toggle on purchase form [https://github.com/woocommerce/woocommerce-ios/pull/15917] +- [*] Shipping Labels: Allow confirming destination addresses when validation fails. [https://github.com/woocommerce/woocommerce-ios/pull/15928] - [*] Shipping Labels: Validate custom package dimensions [https://github.com/woocommerce/woocommerce-ios/pull/15925] - [*] Shipping Labels: Show UPS TOS modal in full length for better accessibility. [https://github.com/woocommerce/woocommerce-ios/pull/15926] - [*] Shipping Labels: Optimize data loading on purchase form [https://github.com/woocommerce/woocommerce-ios/pull/15919] diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooShipping.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooShipping.swift index a9eb343871c..0186160cd0e 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooShipping.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooShipping.swift @@ -18,6 +18,7 @@ extension WooAnalyticsEvent { case validationFailed = "validation_failed" case validationSuccess = "validation_success" case confirmed + case confirmedWithoutVerification = "confirmed_without_verification" } enum PackageSelectionStep: String { 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 4a2a1e1f15d..7f5fca69fd0 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 @@ -24,6 +24,7 @@ struct WooShippingEditAddressView: View { @State private var isPresentingCountrySelector: Bool = false @State private var isPresentingStateSelector: Bool = false + @State private var actionType: ActionType? var body: some View { ScrollView { @@ -207,7 +208,9 @@ struct WooShippingEditAddressView: View { } .font(.subheadline) .foregroundStyle(viewModel.status == .verified ? Constants.green : Constants.red) + Button(Localization.Button.label(for: viewModel.status)) { + actionType = .validateOrConfirm switch viewModel.status { case .verified: dismiss() @@ -219,8 +222,18 @@ struct WooShippingEditAddressView: View { break } } - .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading)) + .buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading && + actionType == .validateOrConfirm)) .disabled(viewModel.status == .missingInformation) + .renderedIf(!viewModel.canConfirmWithoutVerification) + + Button(Localization.useAddressAsEntered) { + actionType = .proceedWithoutValidation + viewModel.proceedWithInputAddress() + } + .buttonStyle(SecondaryLoadingButtonStyle(isLoading: viewModel.isLoading && + actionType == .proceedWithoutValidation)) + .renderedIf(viewModel.canConfirmWithoutVerification) } .padding(isScrollViewEmbedded ? .vertical : .all) } @@ -364,6 +377,11 @@ extension WooShippingEditAddressView { } private extension WooShippingEditAddressView { + enum ActionType { + case validateOrConfirm + case proceedWithoutValidation + } + enum Constants { static let verticalSpacing: CGFloat = 16 static let defaultPadding: CGFloat = 16 @@ -441,6 +459,11 @@ private extension WooShippingEditAddressView { static let done = NSLocalizedString("wooShipping.createLabels.editAddress.done", value: "Done", comment: "Button to dismiss the keyboard") + static let useAddressAsEntered = NSLocalizedString( + "wooShipping.createLabels.editAddress.useAddressAsEntered", + value: "Use address as entered", + comment: "Button to proceed with the input address even when validation fails" + ) enum Button { static func label(for status: WooShippingAddressStatus) -> String { 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 842b8661a04..191c3eb1677 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 @@ -86,12 +86,14 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { /// Status of the address, based on local validation and remote verification. var status: WooShippingAddressStatus { let isRemotelyVerified = originalAddressIsVerified && !hasChanges - switch (isRemotelyVerified, isValid) { - case (true, true): // Is a valid, remotely verified address. + switch (isRemotelyVerified, isValid, canConfirmWithoutVerification) { + case (true, true, _): // Is a valid, remotely verified address. return .verified - case (false, true): // Is a valid, unverified address. + case (false, true, _): // Is a valid, unverified address. return .unverified - case (_, false): // Is an invalid address. + case (_, false, true): // Validation fails but user can proceed. + return .unverified + case (_, false, false): // Is an invalid address. return .missingInformation } } @@ -120,6 +122,9 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { /// Selected state. We observe this to update the `state` property. @Published private(set) var selectedState: StateOfACountry? + /// Whether user can proceed with their input address even when validation fails. + @Published private(set) var canConfirmWithoutVerification = false + /// View model for selecting a country from a list. var countrySelectorVM: CountrySelectorViewModel { let selectedCountryBinding = Binding( @@ -275,6 +280,7 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { return self.isPhoneNumberValid ? nil : Localization.Validation.phone } + observeFieldValues() observeNameAndCompany() observeSelectedCountry() observeSelectedState() @@ -343,6 +349,19 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { onDestinationAddressEdited: onAddressEdited) } + func proceedWithInputAddress() { + let address = WooShippingAddress(company: company.value, + name: name.value, + phone: phone.value, + country: country.value, + state: state.value, + address1: address.value, + address2: "", + city: city.value, + postcode: postalCode.value) + updateConfirmedAddress(address, withoutVerification: true) + } + /// Validates the address remotely. @MainActor func remotelyValidateAddress() async { @@ -368,6 +387,10 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { updateConfirmedAddress(confirmedAddress) }) } catch let error as WooShippingAddressValidationError { + /// Enables proceeding for destination addresses even when validation fails + if case .destination = addressType { + canConfirmWithoutVerification = true + } if let nameError = error.nameError { name.setError(nameError) } @@ -397,15 +420,15 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { } /// Update confirmed address remotely. - @MainActor - func updateConfirmedAddress(_ address: WooShippingAddress) { + func updateConfirmedAddress(_ address: WooShippingAddress, withoutVerification: Bool = false) { switch addressType { case .origin: updateConfirmedOriginAddress(address) case .destination(let orderID): updateConfirmedDestinationAddress( for: orderID, - with: address + with: address, + withoutVerification: withoutVerification ) } } @@ -454,7 +477,8 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { /// Updates the destination address remotely with the provided (normalized) address and other edits. private func updateConfirmedDestinationAddress(for orderID: Int64, - with address: WooShippingAddress) { + with address: WooShippingAddress, + withoutVerification: Bool) { // Merge the provided (normalized) address with the edited address fields. let destinationAddress = WooShippingDestinationAddress(company: address.company, address1: address.address1, @@ -474,7 +498,10 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { let updatedDestinationAddress = try await updateDestinationAddress(for: orderID, with: destinationAddress) onDestinationAddressEdited?(updatedDestinationAddress.toWooShippingAddress(), email.value) - analytics.track(event: .WooShipping.editingAddressStep(type: .destination, state: .confirmed)) + analytics.track(event: .WooShipping.editingAddressStep( + type: .destination, + state: withoutVerification ? .confirmedWithoutVerification : .confirmed + )) } catch { DDLogError("⛔️ Error updating destination address for Woo Shipping label: \(error)") @@ -483,7 +510,7 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable { analytics.track(event: .WooShipping.editingAddressStep( type: .destination, - state: .confirmed, + state: withoutVerification ? .confirmedWithoutVerification : .confirmed, error: error )) } @@ -623,6 +650,13 @@ private extension WooShippingEditAddressViewModel { } .store(in: &cancellables) } + + func observeFieldValues() { + allFields.map { $0.$value.removeDuplicates() } + .combineLatest() + .map { _ in false } + .assign(to: &$canConfirmWithoutVerification) + } } // MARK: Remote 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 8eb60786312..0dc2964481e 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 @@ -1446,6 +1446,147 @@ final class WooShippingEditAddressViewModelTests: XCTestCase { XCTAssertEqual(viewModel.address.errorMessage, expectedAddressError) XCTAssertEqual(viewModel.statusLabel, expectedGeneralError) } + + // MARK: - canConfirmWithoutVerification Tests + + func test_canConfirmWithoutVerification_is_false_initially() { + // Given & When + let viewModel = WooShippingEditAddressViewModel(type: .destination(orderID: sampleOrderID), + id: "", + name: "JANE DOE", + company: "HEADQUARTERS", + country: "US", + address: "15 ALGONKIN ST", + city: "TICONDEROGA", + state: "NY", + postalCode: "12883-1487", + email: "test@example.com", + phone: "123-456-7890", + isDefaultAddress: false, + showCompanyField: true, + isVerified: false) + + // Then + XCTAssertFalse(viewModel.canConfirmWithoutVerification) + } + + @MainActor + func test_canConfirmWithoutVerification_is_enabled_for_destination_addresses_when_validation_fails() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingEditAddressViewModel(type: .destination(orderID: sampleOrderID), + id: "", + name: "", + company: "", + country: "US", + address: "ALGONKIN ST", + city: "TICONDEROGA", + state: "NY", + postalCode: "12883-1487", + email: "test@example.com", + phone: "123-456-7890", + isDefaultAddress: false, + showCompanyField: true, + isVerified: false, + stores: stores) + + // Initial state + XCTAssertFalse(viewModel.canConfirmWithoutVerification) + + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + if case let .validateAddress(_, _, completion) = action { + completion(.failure(WooShippingAddressValidationError(addressError: "House number is missing", + generalError: "Address not found", + nameError: nil))) + } + } + + // When + await viewModel.remotelyValidateAddress() + + // Then + XCTAssertTrue(viewModel.canConfirmWithoutVerification) + XCTAssertEqual(viewModel.status, .unverified) + } + + @MainActor + func test_canConfirmWithoutVerification_remains_false_for_origin_addresses_even_when_validation_fails() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingEditAddressViewModel(type: .origin, + id: "", + name: "", + company: "", + country: "US", + address: "ALGONKIN ST", + city: "TICONDEROGA", + state: "NY", + postalCode: "12883-1487", + email: "test@example.com", + phone: "123-456-7890", + isDefaultAddress: true, + showCompanyField: true, + isVerified: false, + stores: stores) + + // Initial state + XCTAssertFalse(viewModel.canConfirmWithoutVerification) + + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + if case let .validateAddress(_, _, completion) = action { + completion(.failure(WooShippingAddressValidationError(addressError: "House number is missing", + generalError: "Address not found", + nameError: nil))) + } + } + + // When + await viewModel.remotelyValidateAddress() + + // Then + XCTAssertFalse(viewModel.canConfirmWithoutVerification) + XCTAssertEqual(viewModel.status, .missingInformation) + } + + @MainActor + func test_canConfirmWithoutVerification_resets_to_false_when_address_fields_change() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingEditAddressViewModel(type: .destination(orderID: sampleOrderID), + id: "", + name: "", + company: "", + country: "US", + address: "ALGONKIN ST", + city: "TICONDEROGA", + state: "NY", + postalCode: "12883-1487", + email: "test@example.com", + phone: "123-456-7890", + isDefaultAddress: false, + showCompanyField: true, + isVerified: false, + stores: stores) + + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + if case let .validateAddress(_, _, completion) = action { + completion(.failure(WooShippingAddressValidationError(addressError: "House number is missing", + generalError: "Address not found", + nameError: "Either Name or Company is required"))) + } + } + + await viewModel.remotelyValidateAddress() + XCTAssertTrue(viewModel.canConfirmWithoutVerification) + XCTAssertEqual(viewModel.status, .unverified) + + // When any field value changes + viewModel.name.value = "JANE DOE" + + // Then + XCTAssertFalse(viewModel.canConfirmWithoutVerification) + XCTAssertEqual(viewModel.status, .missingInformation) + } } private extension WooShippingEditAddressViewModel {