diff --git a/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift index 0a8ebed7a68..c00fc5974bd 100644 --- a/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift +++ b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift @@ -123,6 +123,11 @@ final class AuthenticatedWebViewController: UIViewController { viewModel.handleDismissal() } } + + override func viewDidDisappear(_ animated: Bool) { + viewModel.handleDisappear() + super.viewDidDisappear(animated) + } } private extension AuthenticatedWebViewController { diff --git a/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift b/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift index 41864638dc6..48f982b7a0b 100644 --- a/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift +++ b/WooCommerce/Classes/Authentication/AuthenticatedWebViewModel.swift @@ -22,6 +22,9 @@ protocol AuthenticatedWebViewModel { /// Triggered when the web view is dismissed func handleDismissal() + /// Triggered when the web view is disappeared + func handleDisappear() + /// Triggered when the web view redirects to a new URL func handleRedirect(for url: URL?) @@ -53,6 +56,10 @@ extension AuthenticatedWebViewModel { func didFailProvisionalNavigation(with error: Error) { // NO-OP } + + func handleDisappear () { + // NO-OP + } } // MARK: - Helper methods diff --git a/WooCommerce/Classes/Authentication/WPAdminWebViewModel.swift b/WooCommerce/Classes/Authentication/WPAdminWebViewModel.swift index df727133515..658388b8696 100644 --- a/WooCommerce/Classes/Authentication/WPAdminWebViewModel.swift +++ b/WooCommerce/Classes/Authentication/WPAdminWebViewModel.swift @@ -20,6 +20,10 @@ class WPAdminWebViewModel: AuthenticatedWebViewModel, WebviewReloadable { // no-op } + func handleDisappear() { + // no-op + } + func handleRedirect(for url: URL?) { guard let url else { return diff --git a/WooCommerce/Classes/CIAB/CIABEligibilityChecker.swift b/WooCommerce/Classes/CIAB/CIABEligibilityChecker.swift index b3627647e1c..955af2262b0 100644 --- a/WooCommerce/Classes/CIAB/CIABEligibilityChecker.swift +++ b/WooCommerce/Classes/CIAB/CIABEligibilityChecker.swift @@ -1,5 +1,3 @@ -/// periphery: ignore:all - Will be used in upcoming PRs - import Foundation import Yosemite diff --git a/WooCommerce/Classes/CIAB/CIABEligibilityCheckerProtocol.swift b/WooCommerce/Classes/CIAB/CIABEligibilityCheckerProtocol.swift index 6c7cd0aac92..54c82d7f844 100644 --- a/WooCommerce/Classes/CIAB/CIABEligibilityCheckerProtocol.swift +++ b/WooCommerce/Classes/CIAB/CIABEligibilityCheckerProtocol.swift @@ -1,7 +1,6 @@ import Foundation import Yosemite -/// periphery: ignore - Will be used in upcoming changes for app feature gating protocol CIABEligibilityCheckerProtocol { var isCurrentSiteCIAB: Bool { get } diff --git a/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailNativeCoordinator.swift b/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailNativeCoordinator.swift new file mode 100644 index 00000000000..ba1a1977c86 --- /dev/null +++ b/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailNativeCoordinator.swift @@ -0,0 +1,18 @@ +import Yosemite +import UIKit + +/// Coordinator for the **native** product detail/editor flow. +/// Delegates VC construction to `ProductDetailsFactory` and applies the requested presentation style. +class ProductDetailNativeCoordinator { + + func viewController( + product: Product, + presentationStyle: ProductDetailNavigator.Presentation, + isReadOnly: Bool, + onDelete: (() -> Void)? = nil) -> UIViewController { + return ProductDetailsFactory.productDetails(product: product, + presentationStyle: presentationStyle.asProductFormPresentationStyle, + forceReadOnly: isReadOnly, + onDeleteCompletion: onDelete ?? {}) + } +} diff --git a/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailWebCoordinator.swift b/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailWebCoordinator.swift new file mode 100644 index 00000000000..78ced3128ff --- /dev/null +++ b/WooCommerce/Classes/Routing/ProductDetail/Coordinator/ProductDetailWebCoordinator.swift @@ -0,0 +1,37 @@ +import UIKit +import Yosemite + +/// Coordinator for the **admin web** product detail/editor flow. +class ProductDetailWebCoordinator: NSObject { + private let site: Site? + + init(site: Site?) { + self.site = site + } + + func viewController(product: Product, onDismiss: @escaping () -> Void) -> UIViewController { + guard let url = ProductAdminURLProvider.editURL(for: product, site: site) else { + return UIViewController() + } + + let viewModel = AdminWebViewModel(title: product.name, initialURL: url) { [onDismiss] in + onDismiss() + } + let webViewController = AuthenticatedWebViewController(viewModel: viewModel) + return webViewController + } +} + +fileprivate class AdminWebViewModel: WPAdminWebViewModel { + let onDismiss: (() -> Void) + + init(title: String, initialURL: URL, onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init(title: title, initialURL: initialURL) + } + + override func handleDisappear() { + onDismiss() + super.handleDisappear() + } +} diff --git a/WooCommerce/Classes/Routing/ProductDetail/ProductAdminURLProvider.swift b/WooCommerce/Classes/Routing/ProductDetail/ProductAdminURLProvider.swift new file mode 100644 index 00000000000..dad8a14588a --- /dev/null +++ b/WooCommerce/Classes/Routing/ProductDetail/ProductAdminURLProvider.swift @@ -0,0 +1,18 @@ +import Yosemite +import Foundation + +/// Builds canonical admin edit URLs for products. +enum ProductAdminURLProvider { + + static func editURL(for product: Product, site: Site?) -> URL? { + guard let base = site?.adminURLWithFallback() else { return nil } + + var components = URLComponents(url: base, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "page", value: "next-admin"), + URLQueryItem(name: "p", value: "/woocommerce/products/edit/\(product.productID)") + ] + + return components.url + } +} diff --git a/WooCommerce/Classes/Routing/ProductDetail/ProductDetailCoordinatorFactoryProtocol.swift b/WooCommerce/Classes/Routing/ProductDetail/ProductDetailCoordinatorFactoryProtocol.swift new file mode 100644 index 00000000000..81ccf02ed29 --- /dev/null +++ b/WooCommerce/Classes/Routing/ProductDetail/ProductDetailCoordinatorFactoryProtocol.swift @@ -0,0 +1,20 @@ +import Yosemite + +/// Factory for producing coordinators used by the navigator. +protocol ProductDetailCoordinatorFactoryProtocol { + func webCoordinator(site: Site?) -> ProductDetailWebCoordinator + func nativeCoordinator() -> ProductDetailNativeCoordinator +} + +/// Default coordinator factory that wires production dependencies. +class ProductDetailCoordinatorFactory: ProductDetailCoordinatorFactoryProtocol { + static let `default` = ProductDetailCoordinatorFactory() + + func webCoordinator(site: Site?) -> ProductDetailWebCoordinator { + return ProductDetailWebCoordinator(site: site) + } + + func nativeCoordinator() -> ProductDetailNativeCoordinator { + return ProductDetailNativeCoordinator() + } +} diff --git a/WooCommerce/Classes/Routing/ProductDetail/ProductDetailNavigator.swift b/WooCommerce/Classes/Routing/ProductDetail/ProductDetailNavigator.swift new file mode 100644 index 00000000000..26360d94587 --- /dev/null +++ b/WooCommerce/Classes/Routing/ProductDetail/ProductDetailNavigator.swift @@ -0,0 +1,70 @@ +import UIKit +import Yosemite + +/// Decides between **native** and **admin web** product detail flows and builds the destination VC. +final class ProductDetailNavigator { + /// Describes how the **native** destination should be presented. + enum Presentation { + case push + case contained(in: () -> UIViewController?) + + var asProductFormPresentationStyle: ProductFormPresentationStyle { + switch self { + case .contained(let inVC): + .contained(containerViewController: inVC) + case .push: + .navigationStack + } + } + } + + static var shared = ProductDetailNavigator() + + private let ciabChecker: CIABEligibilityCheckerProtocol + private let coordinatorFactory: ProductDetailCoordinatorFactoryProtocol + private let stores: StoresManager + + init(ciabChecker: CIABEligibilityCheckerProtocol = CIABEligibilityChecker(), + coordinatorFactory: ProductDetailCoordinatorFactoryProtocol = ProductDetailCoordinatorFactory.default, + stores: StoresManager = ServiceLocator.stores, + ) { + self.ciabChecker = ciabChecker + self.coordinatorFactory = coordinatorFactory + self.stores = stores + } + + /// Builds the destination `UIViewController` for the given product. + /// - Parameters: + /// - product: The product to display. + /// - presentationStyle: How to present **native** detail (ignored for web). + /// - isReadOnly: Whether the native screen should be read-only. + /// - onDelete: Optional callback invoked after a successful product delete. + /// - Returns: A ready-to-present view controller (native or web). + func makeDestination(product: Product, + presentationStyle: Presentation = .push, + isReadOnly: Bool, + onDismissWeb: (() -> Void)? = nil, + onDelete: (() -> Void)? = nil) -> UIViewController { + + let viewController: UIViewController + if shouldOpenInWeb(product: product) { + let coordinator = coordinatorFactory.webCoordinator(site: stores.sessionManager.defaultSite) + viewController = coordinator.viewController(product: product) { + onDismissWeb?() + } + + } else { + let coordinator = coordinatorFactory.nativeCoordinator() + viewController = coordinator.viewController(product: product, + presentationStyle: presentationStyle, + isReadOnly: isReadOnly, + onDelete: onDelete) + } + + return viewController + } + + private func shouldOpenInWeb(product: Product) -> Bool { + return ciabChecker.isCurrentSiteCIAB && product.productType == .booking + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/Product Loader/ProductLoaderViewController.swift b/WooCommerce/Classes/ViewRelated/Products/Product Loader/ProductLoaderViewController.swift index b8e43e60ff9..6a1ba22e9bb 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Product Loader/ProductLoaderViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Product Loader/ProductLoaderViewController.swift @@ -227,11 +227,10 @@ private extension ProductLoaderViewController { /// Presents the ProductFormViewController, as a childViewController, for a given Product. /// func presentProductDetails(for product: Product) { - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .contained(containerViewController: { [weak self] in self }), - forceReadOnly: forceReadOnly) { [weak self] viewController in - self?.attachProductDetailsChildViewController(viewController) - } + let viewController = ProductDetailNavigator.shared.makeDestination(product: product, + presentationStyle: .contained(in: { [weak self] in self }), + isReadOnly: forceReadOnly) + attachProductDetailsChildViewController(viewController) } /// Presents the product variation details for a given ProductVariation and its parent Product. diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductDetailsFactory.swift b/WooCommerce/Classes/ViewRelated/Products/ProductDetailsFactory.swift index 743bc080594..420c834e05f 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductDetailsFactory.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductDetailsFactory.swift @@ -10,21 +10,19 @@ struct ProductDetailsFactory { /// - currencySettings: site currency settings. /// - forceReadOnly: force the product detail to be presented in read only mode /// - onDeleteCompletion: called when the product deletion completes in the product form. - /// - onCompletion: called when the view controller is created and ready for display. static func productDetails(product: Product, presentationStyle: ProductFormPresentationStyle, currencySettings: CurrencySettings = ServiceLocator.currencySettings, forceReadOnly: Bool, productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, - onDeleteCompletion: @escaping () -> Void = {}, - onCompletion: @escaping (UIViewController) -> Void) { + onDeleteCompletion: @escaping () -> Void = {}) -> UIViewController { let vc = productDetails(product: product, presentationStyle: presentationStyle, currencySettings: currencySettings, isEditProductsEnabled: forceReadOnly ? false: true, productImageUploader: productImageUploader, onDeleteCompletion: onDeleteCompletion) - onCompletion(vc) + return vc } } diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsSplitViewCoordinator.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsSplitViewCoordinator.swift index a8373b4f59b..3662500245f 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsSplitViewCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsSplitViewCoordinator.swift @@ -122,14 +122,19 @@ private extension ProductsSplitViewCoordinator { } func showProductForm(product: Product) { - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false, - onDeleteCompletion: { [weak self] in - self?.onSecondaryProductFormDeletion() - }) { [weak self] viewController in - self?.showSecondaryView(contentType: .productForm(product: product), viewController: viewController, replacesNavigationStack: true) - } + let viewController = ProductDetailNavigator.shared.makeDestination( + product: product, + isReadOnly: false, + onDismissWeb: { [weak self] in + self?.resyncProducts() + }, + onDelete: { [weak self] in + self?.onSecondaryProductFormDeletion() + }) + + showSecondaryView(contentType: .productForm(product: product), + viewController: viewController, + replacesNavigationStack: true) } func startProductCreationIfNoUnsavedChanges(sourceView: AddProductCoordinator.SourceView, isFirstProduct: Bool) { @@ -194,6 +199,11 @@ private extension ProductsSplitViewCoordinator { } } + func resyncProducts() { + guard let productsViewController = primaryNavigationController.topViewController as? ProductsViewController else { return } + productsViewController.resync() + } + func showEmptyViewOrFirstProduct() { showEmptyView() switch primaryNavigationController.topViewController { diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index d27b02315bb..c7884b1a2c7 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -303,6 +303,11 @@ final class ProductsViewController: UIViewController, GhostableViewController { func startProductCreation() { addProduct(sourceBarButtonItem: addProductButton, isFirstProduct: false) } + + func resync() { + tableView.reloadData() + paginationTracker.resync() + } } // MARK: - Navigation Bar Actions @@ -1214,11 +1219,9 @@ extension ProductsViewController: UITableViewDelegate { private extension ProductsViewController { func didSelectProduct(product: Product) { guard isSplitViewEnabled else { - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { [weak self] viewController in - self?.navigationController?.pushViewController(viewController, animated: true) - } + let viewController = ProductDetailNavigator.shared.makeDestination(product: product, + isReadOnly: false) + navigationController?.pushViewController(viewController, animated: true) return } navigateToContent(.productForm(product: product)) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 75f6968ef19..c22239ec58d 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1338,6 +1338,8 @@ 57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */; }; 57F42E40253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */; }; 640DA3482E97DE4F00317FB2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */; }; + 6489DFFF2EA78E0D00D96802 /* MockProductDetailCoordinatorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6489DFFE2EA78E0D00D96802 /* MockProductDetailCoordinatorFactory.swift */; }; + 6489E0012EA78E2D00D96802 /* MockProductDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6489E0002EA78E2D00D96802 /* MockProductDetailCoordinator.swift */; }; 64D355A52E99048E005F53F7 /* TestingSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D355A42E99048E005F53F7 /* TestingSceneDelegate.swift */; }; 68051E1E2E9DFE5500228196 /* POSNotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */; }; 680E36B52BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 680E36B42BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib */; }; @@ -4227,6 +4229,8 @@ 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryTableViewCellViewModelTests.swift; sourceTree = ""; }; 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleAndEditableValueTableViewCellViewModelTests.swift; sourceTree = ""; }; 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 6489DFFE2EA78E0D00D96802 /* MockProductDetailCoordinatorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProductDetailCoordinatorFactory.swift; sourceTree = ""; }; + 6489E0002EA78E2D00D96802 /* MockProductDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProductDetailCoordinator.swift; sourceTree = ""; }; 64D355A42E99048E005F53F7 /* TestingSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingSceneDelegate.swift; sourceTree = ""; }; 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSNotificationSchedulerTests.swift; sourceTree = ""; }; 680BA5992A4C377900F5559D /* UpgradeViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeViewState.swift; sourceTree = ""; }; @@ -5777,6 +5781,8 @@ 3F0904022D26A40800D8ACCE /* WordPressAuthenticator */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3F09041C2D26A40800D8ACCE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticator; sourceTree = ""; }; 3F09040E2D26A40800D8ACCE /* WordPressAuthenticatorTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3FD28D5E2D271391002EBB3D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticatorTests; sourceTree = ""; }; 3FD9BFBE2E0A2533004A8DC8 /* WooCommerceScreenshots */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3FD9BFC42E0A2534004A8DC8 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WooCommerceScreenshots; sourceTree = ""; }; + 646A2C682E9FCD7E003A32A1 /* Routing */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Routing; sourceTree = ""; }; + 6489D8522EA667AC00D96802 /* Routing */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Routing; sourceTree = ""; }; DEDB5D342E7A68950022E5A1 /* Bookings */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Bookings; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -8939,6 +8945,8 @@ 746791642108D853007CF1DC /* Mocks */ = { isa = PBXGroup; children = ( + 6489E0002EA78E2D00D96802 /* MockProductDetailCoordinator.swift */, + 6489DFFE2EA78E0D00D96802 /* MockProductDetailCoordinatorFactory.swift */, 02F5DE602E852909002DEE24 /* MockPOSTabVisibilityChecker.swift */, 2DE9DDFA2E6EF4A300155408 /* MockCIABEligibilityChecker.swift */, 022900892E3019020028F6D7 /* MockPluginsService.swift */, @@ -9647,6 +9655,7 @@ B56DB3E02049BFAA00D4AA8E /* WooCommerceTests */ = { isa = PBXGroup; children = ( + 6489D8522EA667AC00D96802 /* Routing */, DEB387962C2E80060025256E /* GoogleAds */, DABF35242C11B40C006AF826 /* POS */, B958A7CF28B527FB00823EEF /* Universal Links */, @@ -9727,6 +9736,7 @@ B56DB3F12049C0B800D4AA8E /* Classes */ = { isa = PBXGroup; children = ( + 646A2C682E9FCD7E003A32A1 /* Routing */, 640DA3472E97DE4F00317FB2 /* SceneDelegate.swift */, DEDB5D342E7A68950022E5A1 /* Bookings */, 2DCB54F82E6AE8C900621F90 /* CIAB */, @@ -13248,6 +13258,7 @@ ); fileSystemSynchronizedGroups = ( 2D33E6B02DD1453E000C7198 /* WooShippingPaymentMethod */, + 646A2C682E9FCD7E003A32A1 /* Routing */, DEDB5D342E7A68950022E5A1 /* Bookings */, ); name = WooCommerce; @@ -13272,6 +13283,9 @@ dependencies = ( B56DB3DF2049BFAA00D4AA8E /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 6489D8522EA667AC00D96802 /* Routing */, + ); name = WooCommerceTests; packageProductDependencies = ( 3F2B4AEB2DDC319800E5E49C /* XcodeTarget_WooCommerceTests */, @@ -15911,6 +15925,7 @@ 02038C612AF222D600CD36D9 /* ConfigurableVariableBundleAttributePickerViewModelTests.swift in Sources */, CE56C01E2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift in Sources */, DA3F99BA2C92F6D30034BDA5 /* MarkOrderAsReadUseCaseTests.swift in Sources */, + 6489E0012EA78E2D00D96802 /* MockProductDetailCoordinator.swift in Sources */, 26C0D1E32B460E5700F6EDA5 /* OrderNotificationViewModel.swift in Sources */, 86E40AED2B597DEC00990365 /* BlazeCampaignCreationCoordinatorTests.swift in Sources */, 20A3AFE12B0F750B0033AF2D /* MockInPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */, @@ -16164,6 +16179,7 @@ 03F8D87D2A7A76DE00DD6D2F /* MockCardPresentPaymentPreflightController.swift in Sources */, 02524A5D252ED5C60033E7BD /* ProductVariationLoadUseCaseTests.swift in Sources */, 028AFFB62484EDA000693C09 /* Dictionary+LoggingTests.swift in Sources */, + 6489DFFF2EA78E0D00D96802 /* MockProductDetailCoordinatorFactory.swift in Sources */, AEA3F91527BEC96B00B9F555 /* PriceFieldFormatterTests.swift in Sources */, 6879B8DB287AFFA100A0F9A8 /* CardReaderManualsViewModelTests.swift in Sources */, DE58D0042D8A6F85005914DF /* NotificationSettingsViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinator.swift b/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinator.swift new file mode 100644 index 00000000000..6e30b82c7ad --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinator.swift @@ -0,0 +1,18 @@ +import UIKit +import Yosemite +@testable import WooCommerce + +class MockProductDetailWebCoordinator: ProductDetailWebCoordinator { + override func viewController(product: Product, onDismiss: @escaping () -> Void) -> UIViewController { + UIViewController() + } +} + +class MockProductDetailNativeCoordinator: ProductDetailNativeCoordinator { + override func viewController(product: Product, + presentationStyle: ProductDetailNavigator.Presentation, + isReadOnly: Bool, + onDelete: (() -> Void)?) -> UIViewController { + UIViewController() + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinatorFactory.swift b/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinatorFactory.swift new file mode 100644 index 00000000000..bc7dc336ada --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockProductDetailCoordinatorFactory.swift @@ -0,0 +1,17 @@ +@testable import WooCommerce +import Yosemite + +class MockProductDetailCoordinatorFactory: ProductDetailCoordinatorFactoryProtocol { + private(set) var createdWebCoordiantor = false + private(set) var createdNativeCoordiantor = false + + func webCoordinator(site: Site?) -> ProductDetailWebCoordinator { + createdWebCoordiantor = true + return MockProductDetailWebCoordinator(site: Site.fake()) + } + + func nativeCoordinator() -> ProductDetailNativeCoordinator { + createdNativeCoordiantor = true + return MockProductDetailNativeCoordinator() + } +} diff --git a/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductAdminURLProviderTests.swift b/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductAdminURLProviderTests.swift new file mode 100644 index 00000000000..702a7a9e1a2 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductAdminURLProviderTests.swift @@ -0,0 +1,18 @@ +@testable import WooCommerce +import Testing +import Yosemite + +final class ProductAdminURLProviderTests { + + private let storeURL = "https://nicestore.com" + private let productID: Int64 = 1234567 + private lazy var aProduct = Product.fake().copy(productID: productID) + private lazy var aSite = Site.fake().copy(url: storeURL) + + @Test + private func validateEditAdminURL() async throws { + let url = try #require(ProductAdminURLProvider.editURL(for: aProduct, site: aSite)) + #expect(url.absoluteString == + "\(storeURL)/wp-admin?page=next-admin&p=/woocommerce/products/edit/\(productID)") + } +} diff --git a/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductDetailNavigatorTests.swift b/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductDetailNavigatorTests.swift new file mode 100644 index 00000000000..38136918dbc --- /dev/null +++ b/WooCommerce/WooCommerceTests/Routing/ProductDetail/ProductDetailNavigatorTests.swift @@ -0,0 +1,36 @@ +@testable import WooCommerce +import Testing +import Yosemite + +@MainActor +final class ProductDetailNavigatorTests { + private static let aNonBookingProduct = Product.fake().copy(productTypeKey: "simple") + private static let aBookingProduct = Product.fake().copy(productTypeKey: "booking") + private lazy var coordinatorFactory = MockProductDetailCoordinatorFactory() + + @Test(arguments: [ + (isCIABSite: true, product: aNonBookingProduct), + (isCIABSite: false, product: aBookingProduct), + (isCIABSite: false, product: aNonBookingProduct), + ]) + private func regardlessOfCIABSiteWeShouldDirectToNative(isCIABSite: Bool, product: Product) { + let navigator = ProductDetailNavigator( + ciabChecker: MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: isCIABSite), + coordinatorFactory: coordinatorFactory + ) + _ = navigator.makeDestination(product: product, isReadOnly: false) + #expect(coordinatorFactory.createdNativeCoordiantor) + #expect(!coordinatorFactory.createdWebCoordiantor) + } + + @Test + private func bookableProductOnCIABSiteWeShouldDirectToWeb() { + let navigator = ProductDetailNavigator( + ciabChecker: MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: true), + coordinatorFactory: coordinatorFactory + ) + _ = navigator.makeDestination(product: Self.aBookingProduct, isReadOnly: false) + #expect(coordinatorFactory.createdWebCoordiantor) + #expect(!coordinatorFactory.createdNativeCoordiantor) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductDetailsFactoryTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductDetailsFactoryTests.swift index c035dd480b4..29ede5711dc 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductDetailsFactoryTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductDetailsFactoryTests.swift @@ -10,16 +10,13 @@ final class ProductDetailsFactoryTests: XCTestCase { // Arrange let product = Product.fake().copy(productTypeKey: ProductType.simple.rawValue) - let exp = expectation(description: #function) // Action - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - exp.fulfill() - } - waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: false) + + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } // MARK: External/affiliate product type @@ -27,17 +24,14 @@ final class ProductDetailsFactoryTests: XCTestCase { func test_factory_creates_product_form_for_affiliate_product() { // Arrange let product = Product.fake().copy(productTypeKey: ProductType.affiliate.rawValue) - let exp = expectation(description: #function) // Action - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - exp.fulfill() - } - waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: false) + + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } // MARK: Grouped product type @@ -45,17 +39,13 @@ final class ProductDetailsFactoryTests: XCTestCase { func test_factory_creates_product_form_for_grouped_product() { // Arrange let product = Product.fake().copy(productTypeKey: ProductType.grouped.rawValue) - let exp = expectation(description: #function) // Action - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - exp.fulfill() - } - waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: false) + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } // MARK: Variable product type @@ -63,17 +53,14 @@ final class ProductDetailsFactoryTests: XCTestCase { func test_factory_creates_product_form_for_variable_product() { // Arrange let product = Product.fake().copy(productTypeKey: ProductType.variable.rawValue) - let exp = expectation(description: #function) // Action - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - exp.fulfill() - } - waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: false) + + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } // MARK: Non-core product type @@ -83,15 +70,11 @@ final class ProductDetailsFactoryTests: XCTestCase { let product = Product.fake().copy(productTypeKey: "other") // Action - waitForExpectation { expectation in - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: false) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - expectation.fulfill() - } - } + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: false) + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } func test_factory_creates_readonly_product_details_for_product_when_forceReadOnly_is_on() { @@ -99,14 +82,10 @@ final class ProductDetailsFactoryTests: XCTestCase { let product = Product.fake().copy(productTypeKey: ProductType.simple.rawValue) // Action - waitForExpectation { expectation in - ProductDetailsFactory.productDetails(product: product, - presentationStyle: .navigationStack, - forceReadOnly: true) { viewController in - // Assert - XCTAssertTrue(viewController is ProductFormViewController) - expectation.fulfill() - } - } + let viewController = ProductDetailsFactory.productDetails(product: product, + presentationStyle: .navigationStack, + forceReadOnly: true) + // Assert + XCTAssertTrue(viewController is ProductFormViewController) } }