Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,34 @@ final class ProductFormViewController<ViewModel: ProductFormViewModelProtocol>:
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this fail? if it can, should we show some kind of toast?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source string, product.permalink is non-optional property. But all URL manipulation tools in iOS SDK return optional URL result.
So I consider this one is safe and never reachable.

}

// TODO: Show authenticated WebView
WebviewHelper.launch(url, with: self)
}

// MARK: Navigation actions

@objc func closeNavigationBarButtonTapped() {
Expand Down Expand Up @@ -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, ProductUpdateError>) -> 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, ProductUpdateError>) -> Void = { _ in }) {
viewModel.saveProductRemotely(status: status) { [weak self] result in
switch result {
case .failure(let error):
Expand All @@ -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.
Expand All @@ -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(()))
}
}
}
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import protocol Experiments.FeatureFlagService
import Yosemite

import protocol Storage.StorageManagerType
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ 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,
isUpdateOrderOptimisticallyOn: Bool = false,
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
self.shippingLabelsOnboardingM1 = shippingLabelsOnboardingM1
self.isLoginPrologueOnboardingEnabled = isLoginPrologueOnboardingEnabled
self.isSimplifiedLoginFlowI1Enabled = isSimplifiedLoginFlowI1Enabled
self.isStoreCreationMVPEnabled = isStoreCreationMVPEnabled
self.isProductsOnboardingEnabled = isProductsOnboardingEnabled
}

func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool {
Expand All @@ -42,6 +45,8 @@ struct MockFeatureFlagService: FeatureFlagService {
return isSimplifiedLoginFlowI1Enabled
case .storeCreationMVP:
return isStoreCreationMVPEnabled
case .productsOnboarding:
return isProductsOnboardingEnabled
default:
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import XCTest
@testable import WooCommerce
import Yosemite
import TestKit
import protocol Experiments.FeatureFlagService

final class ProductFormViewModelTests: XCTestCase {

Expand Down Expand Up @@ -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)
}
}