From b7b113f984a77bd7c85b860cc85b3cdbe08e2aff Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 1 Jul 2025 12:55:52 +0300 Subject: [PATCH 1/5] Trigger customs form callback on data pre-fill --- .../WooShippingCustomsFormViewModel.swift | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift index f4f50a07023..01b57f57656 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift @@ -17,7 +17,7 @@ final class WooShippingCustomsFormViewModel: ObservableObject { @Published var returnToSenderIfNotDelivered = false @Published var requiredInformationIsEntered = false - @Published var itemsRequiredInformationIsEntered = false + @Published private var itemsRequiredInformationIsEntered = false @Published var contentExplanation = "" @Published var restrictionDetails = "" @@ -30,15 +30,22 @@ final class WooShippingCustomsFormViewModel: ObservableObject { @Published private(set) var destinationCountryCode: String? private var cancellables = Set() - private let onCompletion: (ShippingLabelCustomsForm) -> () + + /// The callback that passes the `ShippingLabelCustomsForm` to outer environment + /// Called when: + /// - The customs form is closed + /// - The customs form is pre-filled with data and all required fields are completed. + private let onFormReady: (ShippingLabelCustomsForm) -> () + + @Published private(set) var itemsViewModels: [WooShippingCustomsItemViewModel] = [] init(order: Order, shipment: Shipment, originCountryCode: AnyPublisher? = nil, stores: StoresManager = ServiceLocator.stores, storageManager: StorageManagerType = ServiceLocator.storageManager, - onCompletion: @escaping (ShippingLabelCustomsForm) -> ()) { - self.onCompletion = onCompletion + onFormReady: @escaping (ShippingLabelCustomsForm) -> ()) { + self.onFormReady = onFormReady itemsViewModels = shipment.items.map { WooShippingCustomsItemViewModel(itemName: $0.name, @@ -55,31 +62,27 @@ final class WooShippingCustomsFormViewModel: ObservableObject { listenToItemsRequiredInformationValues() listenForRequiredInformation() listenForInternationalTransactionNumberIsRequired() + listenForRequiredInformationCompletedUponPreFill() } - @Published private(set) var itemsViewModels: [WooShippingCustomsItemViewModel] = [] + /// WOOMOB-734 + /// Solves the issue where a pre-filled form becomes complete without a manual submission + /// + /// Listens for the `requiredInformationIsEntered` state + /// As soon as all required info is entered, calls the `emitForm` just once + func listenForRequiredInformationCompletedUponPreFill() { + $requiredInformationIsEntered + .first { $0 == true } + .sink { [weak self] _ in + DispatchQueue.main.async { + self?.emitForm() + } + } + .store(in: &cancellables) + } func onDismiss() { - /// Ignoring `packageID` and `packageName` as these are not needed in WooShipping plugin, only in WCS&T - let form = ShippingLabelCustomsForm(packageID: "", - packageName: "", - contentsType: contentType.toFormContentsType(), - contentExplanation: contentType == .other ? contentExplanation : "", - restrictionType: restrictionType.toFormRestrictionType(), - restrictionComments: restrictionType == .other ? restrictionDetails : "", - nonDeliveryOption: returnToSenderIfNotDelivered ? .return : .abandon, - itn: internationalTransactionNumber.isValidITN ? internationalTransactionNumber : "", - items: itemsViewModels.map { - ShippingLabelCustomsForm.Item(description: $0.description, - quantity: $0.itemQuantity, - value: Double($0.valuePerUnit) ?? 0, - weight: Double($0.weightPerUnit) ?? 0, - hsTariffNumber: $0.isValidTariffNumber ? $0.hsTariffNumber : "", - originCountry: $0.selectedCountry?.code ?? "", - productID: $0.itemProductID) - } - ) - onCompletion(form) + emitForm() } func updateDestinationCountry(code: String) { @@ -192,6 +195,33 @@ private extension WooShippingCustomsFormViewModel { } return ServiceLocator.currencySettings.symbol(from: currencyCode) } + + private func emitForm() { + /// Ignoring `packageID` and `packageName` as these are not needed in WooShipping plugin, only in WCS&T + let form = ShippingLabelCustomsForm( + packageID: "", + packageName: "", + contentsType: contentType.toFormContentsType(), + contentExplanation: contentType == .other ? contentExplanation : "", + restrictionType: restrictionType.toFormRestrictionType(), + restrictionComments: restrictionType == .other ? restrictionDetails : "", + nonDeliveryOption: returnToSenderIfNotDelivered ? .return : .abandon, + itn: internationalTransactionNumber.isValidITN ? internationalTransactionNumber : "", + items: itemsViewModels.map { + ShippingLabelCustomsForm.Item( + description: $0.description, + quantity: $0.itemQuantity, + value: Double($0.valuePerUnit) ?? 0, + weight: Double($0.weightPerUnit) ?? 0, + hsTariffNumber: $0.isValidTariffNumber ? $0.hsTariffNumber : "", + originCountry: $0.selectedCountry?.code ?? "", + productID: $0.itemProductID + ) + } + ) + + onFormReady(form) + } } private extension WooShippingCustomsFormViewModel { From fcc0c1839961c589dbda9c0b8339ed0dc9022f4c Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 1 Jul 2025 18:21:24 +0300 Subject: [PATCH 2/5] Update tests --- .../WooShippingShipmentDetailsViewModel.swift | 2 +- ...WooShippingCustomsFormViewModelTests.swift | 32 +++++++++---------- ...hippingShipmentDetailsViewModelTests.swift | 4 +++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift index 00b3088a142..0dc3b6d329a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift @@ -63,7 +63,7 @@ final class WooShippingShipmentDetailsViewModel: ObservableObject { /// Selected shipping rate when creating a shipping label. @Published private(set) var selectedRate: WooShippingSelectedRate? - @Published private var customsForm: ShippingLabelCustomsForm? + @Published private(set) var customsForm: ShippingLabelCustomsForm? lazy private(set) var customsFormViewModel: WooShippingCustomsFormViewModel = { return WooShippingCustomsFormViewModel( diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift index 7e4df1a5360..d63f529a55e 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModelTests.swift @@ -10,7 +10,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) } override func tearDown() { @@ -25,7 +25,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { var passedForm: ShippingLabelCustomsForm? viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: shipment, - onCompletion: { form in + onFormReady: { form in passedForm = form }) @@ -66,7 +66,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { var passedForm: ShippingLabelCustomsForm? viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { form in + onFormReady: { form in passedForm = form }) @@ -84,7 +84,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { var passedForm: ShippingLabelCustomsForm? viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { form in + onFormReady: { form in passedForm = form }) @@ -109,7 +109,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { var passedForm: ShippingLabelCustomsForm? viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { form in + onFormReady: { form in passedForm = form }) @@ -127,7 +127,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { let order = Order.fake().copy(currency: "USD") viewModel = WooShippingCustomsFormViewModel(order: order, shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // Then XCTAssertEqual(viewModel.itemsViewModels.first?.currencySymbol, "$") @@ -153,7 +153,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake().copy(currency: "USD"), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.forEach { item in @@ -176,7 +176,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.first?.hsTariffNumberTotalValue = ("123456", 1000) @@ -189,7 +189,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels[0].requiredInformationIsEntered = true @@ -216,7 +216,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { let requiredDestinations = ["IR", "SY", "KP", "CU", "SD"] viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) viewModel.itemsViewModels.forEach { item in item.hsTariffNumber = "" item.valuePerUnit = "1000" @@ -241,7 +241,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.first?.requiredInformationIsEntered = true @@ -255,7 +255,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.internationalTransactionNumber = "NOEEI 30.37(a)" @@ -269,7 +269,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.first?.requiredInformationIsEntered = true @@ -285,7 +285,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.first?.requiredInformationIsEntered = true @@ -301,7 +301,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // Given viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: sampleShipment, - onCompletion: { _ in }) + onFormReady: { _ in }) // When viewModel.itemsViewModels.first?.requiredInformationIsEntered = true @@ -324,7 +324,7 @@ final class WooShippingCustomsFormViewModelTests: XCTestCase { // When viewModel = WooShippingCustomsFormViewModel(order: Order.fake(), shipment: shipment, - onCompletion: { _ in }) + onFormReady: { _ in }) let firstItemViewModel = viewModel.itemsViewModels.first // Then diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift index 7024e27d002..28d84bddf5f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift @@ -816,6 +816,10 @@ final class WooShippingShipmentDetailsViewModelTests: XCTestCase { // Then XCTAssertEqual(customsItemViewModel.selectedCountry?.code, expectedCountry) + + waitUntil { + viewModel.customsForm?.items.first?.originCountry == expectedCountry + } } } From 80eab35222fa0b0eca78eb26e84c683a3caa6969 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 2 Jul 2025 14:28:20 +0300 Subject: [PATCH 3/5] Make customsForm private back --- .../ShipmentDetails/WooShippingShipmentDetailsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift index 0dc3b6d329a..00b3088a142 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/ShipmentDetails/WooShippingShipmentDetailsViewModel.swift @@ -63,7 +63,7 @@ final class WooShippingShipmentDetailsViewModel: ObservableObject { /// Selected shipping rate when creating a shipping label. @Published private(set) var selectedRate: WooShippingSelectedRate? - @Published private(set) var customsForm: ShippingLabelCustomsForm? + @Published private var customsForm: ShippingLabelCustomsForm? lazy private(set) var customsFormViewModel: WooShippingCustomsFormViewModel = { return WooShippingCustomsFormViewModel( From 3c44f1622a671a8db1eaca5c9584da55c913b8ca Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 2 Jul 2025 14:45:11 +0300 Subject: [PATCH 4/5] Additionally filter out non nil empty values for country code --- .../WooShipping Customs/WooShippingCustomsItemViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift index f5f5b8ffbd0..3a5a6199822 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift @@ -103,6 +103,7 @@ private extension WooShippingCustomsItemViewModel { func combineToPreselectCountry() { $originCountryCode .compactMap { $0 } + .filter { !$0.isEmpty } .first() /// Make sure to only handle the initial value .combineLatest($countries) .map { code, countries in From 5bc1a765dcd5924513f2feb22ffe1b85b86518a9 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 2 Jul 2025 14:45:42 +0300 Subject: [PATCH 5/5] Add test for a customs form presence in package after a data prefill --- ...hippingShipmentDetailsViewModelTests.swift | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift index 28d84bddf5f..22b634458bf 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingShipmentDetailsViewModelTests.swift @@ -816,9 +816,65 @@ final class WooShippingShipmentDetailsViewModelTests: XCTestCase { // Then XCTAssertEqual(customsItemViewModel.selectedCountry?.code, expectedCountry) + } + + func test_package_contains_complete_customs_form_when_required_data_is_prefilled() throws { + // Setup + let originAddressSubject = PassthroughSubject() + let destinationAddressSubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + let storageManager = MockStorageManager() + + // Given + let originCountry = Country(code: "US", name: "United States", states: []) + let destinationCountry = Country(code: "CA", name: "Canada", states: []) + + let countries = [ + originCountry, + destinationCountry + ] + + stores.whenReceivingAction(ofType: DataAction.self) { action in + switch action { + case let .synchronizeCountries(_, onCompletion): + storageManager.insertSampleCountries(readOnlyCountries: countries) + onCompletion(.success(countries)) + } + } + + let shipment = sampleShipment + + let viewModel = WooShippingShipmentDetailsViewModel( + order: Order.fake(), + shipment: shipment, + shippingLabel: nil, + originAddress: originAddressSubject.eraseToAnyPublisher(), + destinationAddress: destinationAddressSubject.eraseToAnyPublisher(), + stores: stores, + storageManager: storageManager + ) + + // When + destinationAddressSubject.send(sampleDestinationAddress(country: destinationCountry.code, state: "")) + originAddressSubject.send(sampleOriginAddress(country: originCountry.code, state: "")) + + viewModel.selectPackage(samplePackageData()) + + // Then + XCTAssertTrue(viewModel.customsInformationIsCompleted) waitUntil { - viewModel.customsForm?.items.first?.originCountry == expectedCountry + guard let customsForm = viewModel.currentPackage?.customsForm else { + return false + } + + let customsFormItem = customsForm.items[0] + let shipmentItem = shipment.items[0] + + return customsFormItem.description == shipmentItem.name && + customsFormItem.value == shipmentItem.value && + customsFormItem.weight == shipmentItem.weight && + customsFormItem.originCountry == originCountry.code } } }