Skip to content
Merged
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: Optimize data loading on purchase form [https://github.com/woocommerce/woocommerce-ios/pull/15919]
- [internal] Optimized assets for app size reduction [https://github.com/woocommerce/woocommerce-ios/pull/15881]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension WooAnalyticsEvent {
case validationFailed = "validation_failed"
case validationSuccess = "validation_success"
case confirmed
case confirmedWithoutVerification = "confirmed_without_verification"
}

enum PackageSelectionStep: String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,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<AreaSelectorCommandProtocol?>(
Expand Down Expand Up @@ -275,6 +278,7 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable {
return self.isPhoneNumberValid ? nil : Localization.Validation.phone
}

observeFieldValues()
observeNameAndCompany()
observeSelectedCountry()
observeSelectedState()
Expand Down Expand Up @@ -343,6 +347,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 {
Expand All @@ -368,6 +385,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)
}
Expand Down Expand Up @@ -397,15 +418,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
)
}
}
Expand Down Expand Up @@ -454,7 +475,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,
Expand All @@ -474,7 +496,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)")

Expand All @@ -483,7 +508,7 @@ final class WooShippingEditAddressViewModel: ObservableObject, Identifiable {

analytics.track(event: .WooShipping.editingAddressStep(
type: .destination,
state: .confirmed,
state: withoutVerification ? .confirmedWithoutVerification : .confirmed,
error: error
))
}
Expand Down Expand Up @@ -623,6 +648,13 @@ private extension WooShippingEditAddressViewModel {
}
.store(in: &cancellables)
}

func observeFieldValues() {
allFields.map { $0.$value.removeDuplicates() }
.combineLatest()
.map { _ in false }
.assign(to: &$canConfirmWithoutVerification)
}
}

// MARK: Remote
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,143 @@ 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: "[email protected]",
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: "[email protected]",
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)
}

@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: "[email protected]",
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)
}

@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: "[email protected]",
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)

// When any field value changes
viewModel.name.value = "JANE DOE"

// Then
XCTAssertFalse(viewModel.canConfirmWithoutVerification)
}
}

private extension WooShippingEditAddressViewModel {
Expand Down