diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift index ea217926e8a..802b368539c 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift @@ -199,6 +199,34 @@ final class ProductFormViewController: saveProduct(status: .draft) } + // MARK: Product preview action handling + + @objc private func saveDraftAndDisplayProductPreview() { + guard viewModel.canSaveAsDraft() || viewModel.hasUnsavedChanges() else { + displayProductPreview() + return + } + + saveProduct(status: .draft) { [weak self] result in + if result.isSuccess { + self?.displayProductPreview() + } + } + } + + private func displayProductPreview() { + var permalink = URLComponents(string: product.permalink) + var updatedQueryItems = permalink?.queryItems ?? [] + updatedQueryItems.append(.init(name: "preview", value: "true")) + permalink?.queryItems = updatedQueryItems + guard let url = permalink?.url else { + return + } + + // TODO: Show authenticated WebView + WebviewHelper.launch(url, with: self) + } + // MARK: Navigation actions @objc func closeNavigationBarButtonTapped() { @@ -716,14 +744,14 @@ private extension ProductFormViewController { // MARK: Navigation actions // private extension ProductFormViewController { - func saveProduct(status: ProductStatus? = nil) { + func saveProduct(status: ProductStatus? = nil, onCompletion: @escaping (Result) -> Void = { _ in }) { let productStatus = status ?? product.status let messageType = viewModel.saveMessageType(for: productStatus) showSavingProgress(messageType) - saveProductRemotely(status: status) + saveProductRemotely(status: status, onCompletion: onCompletion) } - func saveProductRemotely(status: ProductStatus?) { + func saveProductRemotely(status: ProductStatus?, onCompletion: @escaping (Result) -> Void = { _ in }) { viewModel.saveProductRemotely(status: status) { [weak self] result in switch result { case .failure(let error): @@ -732,6 +760,7 @@ private extension ProductFormViewController { // Dismisses the in-progress UI then presents the error alert. self?.navigationController?.dismiss(animated: true) { self?.displayError(error: error) + onCompletion(.failure(error)) } case .success: // Dismisses the in-progress UI, then presents the confirmation alert. @@ -741,6 +770,7 @@ private extension ProductFormViewController { // Show linked products promo banner after product save (self?.viewModel as? ProductFormViewModel)?.isLinkedProductsPromoEnabled = true self?.reloadLinkedPromoCellAnimated() + onCompletion(.success(())) } } } @@ -908,6 +938,8 @@ private extension ProductFormViewController { // Create action buttons based on view model let rightBarButtonItems: [UIBarButtonItem] = viewModel.actionButtons.reversed().map { buttonType in switch buttonType { + case .preview: + return createPreviewBarButtonItem() case .publish: return createPublishBarButtonItem() case .save: @@ -934,6 +966,10 @@ private extension ProductFormViewController { return UIBarButtonItem(title: Localization.saveTitle, style: .done, target: self, action: #selector(saveProductAndLogEvent)) } + func createPreviewBarButtonItem() -> UIBarButtonItem { + return UIBarButtonItem(title: Localization.previewTitle, style: .done, target: self, action: #selector(saveDraftAndDisplayProductPreview)) + } + func createMoreOptionsBarButtonItem() -> UIBarButtonItem { let moreButton = UIBarButtonItem(image: .moreImage, style: .plain, @@ -1535,6 +1571,7 @@ private extension ProductFormViewController { private enum Localization { static let publishTitle = NSLocalizedString("Publish", comment: "Action for creating a new product remotely with a published status") static let saveTitle = NSLocalizedString("Save", comment: "Action for saving a Product remotely") + static let previewTitle = NSLocalizedString("Preview", comment: "Action for previewing draft Product changes in the webview") static let groupedProductsViewTitle = NSLocalizedString("Grouped Products", comment: "Navigation bar title for editing linked products for a grouped product") static let unnamedProduct = NSLocalizedString("Unnamed product", diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift index 40ea9a5ea3f..ab317307cf7 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift @@ -1,4 +1,5 @@ import Combine +import protocol Experiments.FeatureFlagService import Yosemite import protocol Storage.StorageManagerType @@ -151,6 +152,14 @@ final class ProductFormViewModel: ProductFormViewModelProtocol { } }() + if featureFlagService.isFeatureFlagEnabled(.productsOnboarding), + // Preview existing drafts or new products, that can be saved as a draft + (canSaveAsDraft() || originalProductModel.status == .draft), + // Do not preview new blank products without any changes + !(formType == .add && !hasUnsavedChanges()) { + buttons.insert(.preview, at: 0) + } + // Add more button if needed if shouldShowMoreOptionsMenu() { buttons.append(.more) @@ -170,13 +179,16 @@ final class ProductFormViewModel: ProductFormViewModelProtocol { private let analytics: Analytics + private let featureFlagService: FeatureFlagService + init(product: EditableProductModel, formType: ProductFormType, productImageActionHandler: ProductImageActionHandler, stores: StoresManager = ServiceLocator.stores, storageManager: StorageManagerType = ServiceLocator.storageManager, productImagesUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, - analytics: Analytics = ServiceLocator.analytics) { + analytics: Analytics = ServiceLocator.analytics, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.formType = formType self.productImageActionHandler = productImageActionHandler self.originalProduct = product @@ -186,6 +198,7 @@ final class ProductFormViewModel: ProductFormViewModelProtocol { self.storageManager = storageManager self.productImagesUploader = productImagesUploader self.analytics = analytics + self.featureFlagService = featureFlagService self.cancellable = productImageActionHandler.addUpdateObserver(self) { [weak self] allStatuses in guard let self = self else { return } diff --git a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModelProtocol.swift b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModelProtocol.swift index 1d8b3e7f6dd..d70563fd392 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModelProtocol.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModelProtocol.swift @@ -10,6 +10,7 @@ enum ProductFormType { /// The type of action that can be performed in the product. enum ActionButtonType { + case preview case publish case save case more diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index d79320568bb..ae86bd8103a 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -9,6 +9,7 @@ struct MockFeatureFlagService: FeatureFlagService { private let isLoginPrologueOnboardingEnabled: Bool private let isSimplifiedLoginFlowI1Enabled: Bool private let isStoreCreationMVPEnabled: Bool + private let isProductsOnboardingEnabled: Bool init(isInboxOn: Bool = false, isSplitViewInOrdersTabOn: Bool = false, @@ -16,7 +17,8 @@ struct MockFeatureFlagService: FeatureFlagService { shippingLabelsOnboardingM1: Bool = false, isLoginPrologueOnboardingEnabled: Bool = false, isSimplifiedLoginFlowI1Enabled: Bool = false, - isStoreCreationMVPEnabled: Bool = false) { + isStoreCreationMVPEnabled: Bool = false, + isProductsOnboardingEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isSplitViewInOrdersTabOn = isSplitViewInOrdersTabOn self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -24,6 +26,7 @@ struct MockFeatureFlagService: FeatureFlagService { self.isLoginPrologueOnboardingEnabled = isLoginPrologueOnboardingEnabled self.isSimplifiedLoginFlowI1Enabled = isSimplifiedLoginFlowI1Enabled self.isStoreCreationMVPEnabled = isStoreCreationMVPEnabled + self.isProductsOnboardingEnabled = isProductsOnboardingEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -42,6 +45,8 @@ struct MockFeatureFlagService: FeatureFlagService { return isSimplifiedLoginFlowI1Enabled case .storeCreationMVP: return isStoreCreationMVPEnabled + case .productsOnboarding: + return isProductsOnboardingEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift index 304db3e22e9..5131a180196 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModelTests.swift @@ -3,6 +3,7 @@ import XCTest @testable import WooCommerce import Yosemite import TestKit +import protocol Experiments.FeatureFlagService final class ProductFormViewModelTests: XCTestCase { @@ -532,19 +533,136 @@ final class ProductFormViewModelTests: XCTestCase { let hasLinkedProducts = try XCTUnwrap(analyticsProvider.receivedProperties.first?["has_linked_products"] as? Bool) XCTAssertTrue(hasLinkedProducts) } + + // MARK: Preview button tests (with enabled Product Onboarding feature flag) + + func test_no_preview_button_for_new_blank_product_without_any_changes() { + // Given + let product = Product.fake().copy(statusKey: ProductStatus.published.rawValue) + let viewModel = createViewModel(product: product, formType: .add, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.publish, .more]) + } + + func test_preview_button_for_new_product_with_pending_changes() { + // Given + let product = Product.fake().copy(statusKey: ProductStatus.published.rawValue) + let viewModel = createViewModel(product: product, formType: .add, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + viewModel.updateName("new name") + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.preview, .publish, .more]) + } + + func test_no_preview_button_for_existing_published_product_without_any_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + viewModel.updateName("new name") + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.save, .more]) + } + + func test_no_preview_button_for_existing_published_product_with_pending_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.more]) + } + + func test_preview_button_for_existing_draft_product_without_any_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.preview, .publish, .more]) + } + + func test_preview_button_for_existing_draft_product_with_pending_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.draft.rawValue) + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + viewModel.updateName("new name") + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.preview, .save, .more]) + } + + func test_no_preview_button_for_existing_product_with_other_status_and_without_any_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: "other") + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.publish, .more]) + } + + func test_no_preview_button_for_existing_product_with_other_status_and_pending_changes() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: "other") + let viewModel = createViewModel(product: product, formType: .edit, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + viewModel.updateName("new name") + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.save, .more]) + } + + func test_no_preview_button_for_any_product_in_read_only_mode() { + // Given + let product = Product.fake().copy(productID: 123, statusKey: ProductStatus.published.rawValue) + let viewModel = createViewModel(product: product, formType: .readonly, featureFlagService: MockFeatureFlagService(isProductsOnboardingEnabled: true)) + viewModel.updateName("new name") + + // When + let actionButtons = viewModel.actionButtons + + // Then + XCTAssertEqual(actionButtons, [.more]) + } } private extension ProductFormViewModelTests { func createViewModel(product: Product, formType: ProductFormType, stores: StoresManager = ServiceLocator.stores, - analytics: Analytics = ServiceLocator.analytics) -> ProductFormViewModel { + analytics: Analytics = ServiceLocator.analytics, + featureFlagService: FeatureFlagService = MockFeatureFlagService()) -> ProductFormViewModel { let model = EditableProductModel(product: product) let productImageActionHandler = ProductImageActionHandler(siteID: 0, product: model) return ProductFormViewModel(product: model, formType: formType, productImageActionHandler: productImageActionHandler, stores: stores, - analytics: analytics) + analytics: analytics, + featureFlagService: featureFlagService) } }