From e5e1f8e1ddc27b6a4f43c81e2f0926011d482d87 Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 18:58:00 +0300 Subject: [PATCH 1/7] Add PriceInputViewController --- .../Products/PriceInputViewController.swift | 120 ++++++++++++++++++ .../Products/PriceInputViewModel.swift | 115 +++++++++++++++++ .../Products/ProductsListViewModel.swift | 25 +++- .../Products/ProductsViewController.swift | 16 ++- .../WooCommerce.xcodeproj/project.pbxproj | 8 ++ 5 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift create mode 100644 WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift new file mode 100644 index 00000000000..f63ffacba76 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift @@ -0,0 +1,120 @@ +import UIKit +import Yosemite +import Combine + +final class PriceInputViewController: UIViewController { + + let tableView: UITableView = UITableView(frame: .zero, style: .grouped) + + private var viewModel: PriceInputViewModel + private var subscriptions = Set() + + init(viewModel: PriceInputViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureTitleAndBackground() + configureTableView() + configureViewModel() + } +} + +private extension PriceInputViewController { + func configureTitleAndBackground() { + title = viewModel.screenTitle() + view.backgroundColor = .listBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelButtonTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem(title: Localization.bulkEditingApply, + style: .plain, + target: self, + action: #selector(applyButtonTapped)) + } + + func configureViewModel() { + viewModel.$applyButtonEnabled + .sink { [weak self] enabled in + self?.navigationItem.rightBarButtonItem?.isEnabled = enabled + }.store(in: &subscriptions) + } + + func configureTableView() { + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + view.pinSubviewToAllEdges(tableView) + + tableView.rowHeight = UITableView.automaticDimension + tableView.backgroundColor = .listBackground + + tableView.registerNib(for: UnitInputTableViewCell.self) + + tableView.dataSource = self + } + + /// Called when the cancel button is tapped + /// + @objc func cancelButtonTapped() { + viewModel.cancelButtonTapped() + } + + /// Called when the save button is tapped to update the price for all products + /// + @objc func applyButtonTapped() { + // Dismiss the keyboard before triggering the update + view.endEditing(true) + viewModel.applyButtonTapped() + } +} + +// MARK: - UITableViewDataSource Conformance +// +extension PriceInputViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: UnitInputTableViewCell.self.reuseIdentifier, for: indexPath) + configure(cell, at: indexPath) + + return cell + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return viewModel.footerText + } + + private func configure(_ cell: UITableViewCell, at indexPath: IndexPath) { + switch cell { + case let cell as UnitInputTableViewCell: + let cellViewModel = UnitInputViewModel.createBulkPriceViewModel(using: ServiceLocator.currencySettings) { [weak self] value in + self?.viewModel.handlePriceChange(value) + } + cell.selectionStyle = .none + cell.configure(viewModel: cellViewModel) + default: + fatalError("Unidentified bulk update row type") + break + } + } +} + +private extension PriceInputViewController { + enum Localization { + static let bulkEditingApply = NSLocalizedString("Apply", comment: "Title for the button to apply bulk editing changes to selected products.") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift new file mode 100644 index 00000000000..da5478e8b34 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift @@ -0,0 +1,115 @@ +import Foundation +import Yosemite +import WooFoundation + +/// View Model logic for the bulk price setting screen +/// +final class PriceInputViewModel { + + @Published private(set) var applyButtonEnabled: Bool = false + + /// This holds the latest entered price. It is used to perform validations when the user taps the apply button + /// and for creating a products array with the new price for the bulk update Action + private(set) var currentPrice: String = "" + + private let productListViewModel: ProductListViewModel + + private let currencySettings: CurrencySettings + private let currencyFormatter: CurrencyFormatter + + private let cancelClosure: () -> Void + private let applyClosure: (String) -> Void + + init(productListViewModel: ProductListViewModel, + currencySettings: CurrencySettings = ServiceLocator.currencySettings, + cancelClosure: @escaping () -> Void, + applyClosure: @escaping (String) -> Void) { + self.productListViewModel = productListViewModel + self.currencySettings = currencySettings + self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) + self.cancelClosure = cancelClosure + self.applyClosure = applyClosure + } + + /// Called when the cancel button is tapped + /// + func cancelButtonTapped() { + cancelClosure() + } + + /// Called when the save button is tapped + /// + func applyButtonTapped() { + applyClosure(currentPrice) + } + + /// Called when price changes + /// + func handlePriceChange(_ price: String?) { + currentPrice = price ?? "" + updateButtonStateBasedOnCurrentPrice() + } + + /// Update the button state to enable/disable based on price value + /// + private func updateButtonStateBasedOnCurrentPrice() { + if currentPrice.isNotEmpty { + applyButtonEnabled = true + } else { + applyButtonEnabled = false + } + } + + /// Returns the footer text to be displayed with information about the current bulk price and how many products will be updated. + /// + var footerText: String { + let numberOfProducts = productListViewModel.selectedProductsCount + let numberOfProductsText = String.pluralize(numberOfProducts, + singular: Localization.productsNumberSingularFooter, + plural: Localization.productsNumberPluralFooter) + + switch productListViewModel.commonPriceForSelectedProducts { + case .none: + return [Localization.currentPriceNoneFooter, numberOfProductsText].joined(separator: " ") + case .mixed: + return [Localization.currentPriceMixedFooter, numberOfProductsText].joined(separator: " ") + case let .value(price): + let currentPriceText = String.localizedStringWithFormat(Localization.currentPriceFooter, formatPriceString(price)) + return [currentPriceText, numberOfProductsText].joined(separator: " ") + } + } + + /// It formats a price `String` according to the current price settings. + /// + private func formatPriceString(_ price: String) -> String { + let currencyCode = currencySettings.currencyCode + let currency = currencySettings.symbol(from: currencyCode) + + return currencyFormatter.formatAmount(price, with: currency) ?? "" + } + + /// Returns the title to be displayed in the top of bulk update screen + /// + func screenTitle() -> String { + return Localization.screenTitle + } +} + +private extension PriceInputViewModel { + enum Localization { + static let screenTitle = NSLocalizedString("Update Regular Price", comment: "Title that appears on top of the of bulk price setting screen") + static let productsNumberSingularFooter = NSLocalizedString("The price will be updated for %d product.", + comment: "Message in the footer of bulk price setting screen (singular).") + static let productsNumberPluralFooter = NSLocalizedString("The price will be updated for %d products.", + comment: "Message in the footer of bulk price setting screen (plurar).") + static let currentPriceFooter = NSLocalizedString("Current price is %@.", + comment: "Message in the footer of bulk price setting screen" + + " with the current price, when it is the same for all products") + static let currentPriceMixedFooter = NSLocalizedString("Current prices are mixed.", + comment: "Message in the footer of bulk price setting screen, when products have" + + " different price values.") + static let currentPriceNoneFooter = NSLocalizedString("Current price is not set.", + comment: "Message in the footer of bulk price setting screen, when none of the" + + " products have price value.") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift index f7ef2fa94a7..c5ba5d54ccf 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift @@ -12,7 +12,7 @@ class ProductListViewModel { let siteID: Int64 private let stores: StoresManager - private var selectedProducts: Set = .init() + private(set) var selectedProducts: Set = .init() init(siteID: Int64, stores: StoresManager) { self.siteID = siteID @@ -43,6 +43,17 @@ class ProductListViewModel { selectedProducts.removeAll() } + /// Represents if a property in a collection of `Product` has the same value or different values or is missing. + /// + enum BulkValue: Equatable { + /// All variations have the same value + case value(String) + /// When variations have mixed values. + case mixed + /// None of the variation has a value + case none + } + /// Check if selected products share the same common ProductStatus. Returns `nil` otherwise. /// var commonStatusForSelectedProducts: ProductStatus? { @@ -54,6 +65,18 @@ class ProductListViewModel { } } + /// Check if selected products share the same common ProductStatus. Returns `nil` otherwise. + /// + var commonPriceForSelectedProducts: BulkValue { + if selectedProducts.allSatisfy({ $0.regularPrice?.isEmpty != false }) { + return .none + } else if let price = selectedProducts.first?.regularPrice, selectedProducts.allSatisfy({ $0.regularPrice == price }) { + return .value(price) + } else { + return .mixed + } + } + /// Update selected products with new ProductStatus and trigger Network action to save the change remotely. /// func updateSelectedProducts(with newStatus: ProductStatus, completion: @escaping (Result) -> Void ) { diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index af5292bb664..6f24c0706c6 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -339,8 +339,8 @@ private extension ProductsViewController { let updateStatus = UIAlertAction(title: Localization.bulkEditingStatusOption, style: .default) { [weak self] _ in self?.showStatusBulkEditingModal() } - let updatePrice = UIAlertAction(title: Localization.bulkEditingPriceOption, style: .default) { _ in - // TODO-8520: show UI for price update + let updatePrice = UIAlertAction(title: Localization.bulkEditingPriceOption, style: .default) { [weak self] _ in + self?.showPriceBulkEditingModal() } let cancelAction = UIAlertAction(title: Localization.cancel, style: .cancel) @@ -379,7 +379,7 @@ private extension ProductsViewController { }.store(in: &subscriptions) listSelectorViewController.navigationItem.rightBarButtonItem = applyButton - self.present(WooNavigationController(rootViewController: listSelectorViewController), animated: true) + present(WooNavigationController(rootViewController: listSelectorViewController), animated: true) } @objc func dismissModal() { @@ -404,6 +404,16 @@ private extension ProductsViewController { } } + func showPriceBulkEditingModal() { + let priceInputViewModel = PriceInputViewModel(productListViewModel: viewModel) { [weak self] in + self?.dismissModal() + } applyClosure: { newPrice in + // + } + let priceInputViewController = PriceInputViewController(viewModel: priceInputViewModel) + present(WooNavigationController(rootViewController: priceInputViewController), animated: true) + } + func displayProductsSavingInProgressView(on vc: UIViewController) { let viewProperties = InProgressViewProperties(title: Localization.productsSavingTitle, message: Localization.productsSavingMessage) let inProgressViewController = InProgressViewController(viewProperties: viewProperties) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 951a5b20426..69a1ec5274c 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1275,6 +1275,8 @@ AEE2610F26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE2610E26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift */; }; AEE2611126E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE2611026E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift */; }; AEE9A880293A3E5500227C92 /* RefreshablePlainList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE9A87F293A3E5500227C92 /* RefreshablePlainList.swift */; }; + AEFF77A42978389400667F7A /* PriceInputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A32978389400667F7A /* PriceInputViewController.swift */; }; + AEFF77A629783CA600667F7A /* PriceInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */; }; B50911302049E27A007D25DC /* DashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509112D2049E27A007D25DC /* DashboardViewController.swift */; }; B509FED121C041DF000076A9 /* Locale+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED021C041DF000076A9 /* Locale+Woo.swift */; }; B509FED321C05121000076A9 /* SupportManagerAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED221C05121000076A9 /* SupportManagerAdapter.swift */; }; @@ -3329,6 +3331,8 @@ AEE2610E26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOrderAddressFormViewModel.swift; sourceTree = ""; }; AEE2611026E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOrderAddressFormViewModelTests.swift; sourceTree = ""; }; AEE9A87F293A3E5500227C92 /* RefreshablePlainList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshablePlainList.swift; sourceTree = ""; }; + AEFF77A32978389400667F7A /* PriceInputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewController.swift; sourceTree = ""; }; + AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewModel.swift; sourceTree = ""; }; B509112D2049E27A007D25DC /* DashboardViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardViewController.swift; sourceTree = ""; }; B509FED021C041DF000076A9 /* Locale+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Woo.swift"; sourceTree = ""; }; B509FED221C05121000076A9 /* SupportManagerAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerAdapter.swift; sourceTree = ""; }; @@ -6822,6 +6826,8 @@ 020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */, 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */, 0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */, + AEFF77A32978389400667F7A /* PriceInputViewController.swift */, + AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */, ); path = Products; sourceTree = ""; @@ -10477,6 +10483,7 @@ 26AC0DD92941081500859074 /* AnalyticsHubCustomRangeData.swift in Sources */, CECC759723D607C900486676 /* OrderItemRefund+Woo.swift in Sources */, 03EF250228C615A5006A033E /* InPersonPaymentsMenuViewModel.swift in Sources */, + AEFF77A629783CA600667F7A /* PriceInputViewModel.swift in Sources */, 0212275C244972660042161F /* BottomSheetListSelectorSectionHeaderView.swift in Sources */, DEC6C51A2747758D006832D3 /* JetpackInstallView.swift in Sources */, DE37517C28DC5FC6000163CB /* Authenticator.swift in Sources */, @@ -10750,6 +10757,7 @@ 024DF31923742C3F006658FE /* AztecFormatBarFactory.swift in Sources */, 02291737270BEFF200449FA0 /* ProcessConfiguration.swift in Sources */, 45CE2D322625AA9A00E3CA00 /* ShippingLabelPackageList.swift in Sources */, + AEFF77A42978389400667F7A /* PriceInputViewController.swift in Sources */, 02535CBB25823F7A00E137BB /* ShippingLabelPaperSize+UI.swift in Sources */, CCD2E67E25DD4DC900BD975D /* ProductVariationsViewModel.swift in Sources */, 02C2756824F4E77F00286C04 /* ProductShippingSettingsViewModel.swift in Sources */, From 29a5370e67b6d93ac657fabea5c6cc9100e5193b Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 18:58:24 +0300 Subject: [PATCH 2/7] Add price validation --- .../Products/PriceInputViewController.swift | 41 +++++++++++++++++++ .../Products/PriceInputViewModel.swift | 27 ++++++++++++ 2 files changed, 68 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift index f63ffacba76..3b085f8c16a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift @@ -9,6 +9,12 @@ final class PriceInputViewController: UIViewController { private var viewModel: PriceInputViewModel private var subscriptions = Set() + private lazy var noticePresenter: NoticePresenter = { + let noticePresenter = DefaultNoticePresenter() + noticePresenter.presentingViewController = self + return noticePresenter + }() + init(viewModel: PriceInputViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -45,6 +51,13 @@ private extension PriceInputViewController { .sink { [weak self] enabled in self?.navigationItem.rightBarButtonItem?.isEnabled = enabled }.store(in: &subscriptions) + + viewModel.$inputValidationError.sink { [weak self] error in + guard let error = error else { + return + } + self?.displayNoticeForError(error) + }.store(in: &subscriptions) } func configureTableView() { @@ -73,6 +86,25 @@ private extension PriceInputViewController { view.endEditing(true) viewModel.applyButtonTapped() } + + func displayNoticeForError(_ productPriceSettingsError: ProductPriceSettingsError) { + switch productPriceSettingsError { + case .salePriceWithoutRegularPrice: + displayNotice(for: Localization.salePriceWithoutRegularPriceError) + case .salePriceHigherThanRegularPrice: + displayNotice(for: Localization.displaySalePriceError) + case .newSaleWithEmptySalePrice: + displayNotice(for: Localization.displayMissingSalePriceError) + } + } + + /// Displays a Notice onscreen for a given message + /// + func displayNotice(for message: String) { + view.endEditing(true) + let notice = Notice(title: message, feedbackType: .error) + noticePresenter.enqueue(notice: notice) + } } // MARK: - UITableViewDataSource Conformance @@ -116,5 +148,14 @@ extension PriceInputViewController: UITableViewDataSource { private extension PriceInputViewController { enum Localization { static let bulkEditingApply = NSLocalizedString("Apply", comment: "Title for the button to apply bulk editing changes to selected products.") + + static let salePriceWithoutRegularPriceError = NSLocalizedString("The sale price can't be added without the regular price.", + comment: "Bulk price update error message, when the sale price is added but the" + + " regular price is not") + static let displaySalePriceError = NSLocalizedString("The sale price should be lower than the regular price.", + comment: "Bulk price update error, when the sale price is higher than the regular" + + " price") + static let displayMissingSalePriceError = NSLocalizedString("Please enter a sale price for the scheduled sale", + comment: "Bulk price update error, when the sale price is empty") } } diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift index da5478e8b34..70096e02e07 100644 --- a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift @@ -8,12 +8,15 @@ final class PriceInputViewModel { @Published private(set) var applyButtonEnabled: Bool = false + @Published private(set) var inputValidationError: ProductPriceSettingsError? = nil + /// This holds the latest entered price. It is used to perform validations when the user taps the apply button /// and for creating a products array with the new price for the bulk update Action private(set) var currentPrice: String = "" private let productListViewModel: ProductListViewModel + private let priceSettingsValidator: ProductPriceSettingsValidator private let currencySettings: CurrencySettings private let currencyFormatter: CurrencyFormatter @@ -25,6 +28,7 @@ final class PriceInputViewModel { cancelClosure: @escaping () -> Void, applyClosure: @escaping (String) -> Void) { self.productListViewModel = productListViewModel + self.priceSettingsValidator = ProductPriceSettingsValidator(currencySettings: currencySettings) self.currencySettings = currencySettings self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) self.cancelClosure = cancelClosure @@ -40,6 +44,11 @@ final class PriceInputViewModel { /// Called when the save button is tapped /// func applyButtonTapped() { + inputValidationError = validatePrice() + guard inputValidationError == nil else { + return + } + applyClosure(currentPrice) } @@ -60,6 +69,24 @@ final class PriceInputViewModel { } } + /// Validates if the currently selected price is valid for all products + /// + private func validatePrice() -> ProductPriceSettingsError? { + for product in productListViewModel.selectedProducts { + let regularPrice = currentPrice + let salePrice = product.salePrice + + if let error = priceSettingsValidator.validate(regularPrice: regularPrice, + salePrice: salePrice, + dateOnSaleStart: product.dateOnSaleStart, + dateOnSaleEnd: product.dateOnSaleEnd) { + return error + } + } + + return nil + } + /// Returns the footer text to be displayed with information about the current bulk price and how many products will be updated. /// var footerText: String { From e9e26e8f7a8b31c4af63fea7d1b545319d455ca0 Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 19:20:35 +0300 Subject: [PATCH 3/7] Trigger price action --- .../Products/PriceInputViewModel.swift | 10 ++----- .../Products/ProductsListViewModel.swift | 20 +++++++++++++ .../Products/ProductsViewController.swift | 30 ++++++++++++++++--- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift index 70096e02e07..a81ea48a303 100644 --- a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift @@ -20,19 +20,15 @@ final class PriceInputViewModel { private let currencySettings: CurrencySettings private let currencyFormatter: CurrencyFormatter - private let cancelClosure: () -> Void - private let applyClosure: (String) -> Void + var cancelClosure: () -> Void = {} + var applyClosure: (String) -> Void = { _ in } init(productListViewModel: ProductListViewModel, - currencySettings: CurrencySettings = ServiceLocator.currencySettings, - cancelClosure: @escaping () -> Void, - applyClosure: @escaping (String) -> Void) { + currencySettings: CurrencySettings = ServiceLocator.currencySettings) { self.productListViewModel = productListViewModel self.priceSettingsValidator = ProductPriceSettingsValidator(currencySettings: currencySettings) self.currencySettings = currencySettings self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) - self.cancelClosure = cancelClosure - self.applyClosure = applyClosure } /// Called when the cancel button is tapped diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift index c5ba5d54ccf..3aa5a39fbae 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift @@ -96,4 +96,24 @@ class ProductListViewModel { } stores.dispatch(batchAction) } + + /// Update selected products with new price and trigger Network action to save the change remotely. + /// + func updateSelectedProducts(with newPrice: String, completion: @escaping (Result) -> Void ) { + guard selectedProductsCount > 0 else { + completion(.failure(BulkEditError.noProductsSelected)) + return + } + + let updatedProducts = selectedProducts.map({ $0.copy(regularPrice: newPrice) }) + let batchAction = ProductAction.updateProducts(siteID: siteID, products: updatedProducts) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + stores.dispatch(batchAction) + } } diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index 6f24c0706c6..f0206c967da 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -405,15 +405,35 @@ private extension ProductsViewController { } func showPriceBulkEditingModal() { - let priceInputViewModel = PriceInputViewModel(productListViewModel: viewModel) { [weak self] in + let priceInputViewModel = PriceInputViewModel(productListViewModel: viewModel) + let priceInputViewController = PriceInputViewController(viewModel: priceInputViewModel) + priceInputViewModel.cancelClosure = { [weak self] in self?.dismissModal() - } applyClosure: { newPrice in - // } - let priceInputViewController = PriceInputViewController(viewModel: priceInputViewModel) + priceInputViewModel.applyClosure = { [weak self] newPrice in + self?.applyBulkEditingPrice(newPrice: newPrice, modalVC: priceInputViewController) + } present(WooNavigationController(rootViewController: priceInputViewController), animated: true) } + func applyBulkEditingPrice(newPrice: String?, modalVC: UIViewController) { + guard let newPrice else { return } + + displayProductsSavingInProgressView(on: modalVC) + viewModel.updateSelectedProducts(with: newPrice) { [weak self] result in + guard let self else { return } + + self.dismiss(animated: true, completion: nil) + switch result { + case .success: + self.finishBulkEditing() + self.presentNotice(title: Localization.priceUpdatedNotice) + case .failure: + self.presentNotice(title: Localization.updateErrorNotice) + } + } + } + func displayProductsSavingInProgressView(on vc: UIViewController) { let viewProperties = InProgressViewProperties(title: Localization.productsSavingTitle, message: Localization.productsSavingMessage) let inProgressViewController = InProgressViewController(viewProperties: viewProperties) @@ -1311,6 +1331,8 @@ private extension ProductsViewController { static let statusUpdatedNotice = NSLocalizedString("Status updated", comment: "Title of the notice when a user updated status for selected products") + static let priceUpdatedNotice = NSLocalizedString("Price updated", + comment: "Title of the notice when a user updated price for selected products") static let updateErrorNotice = NSLocalizedString("Cannot update products", comment: "Title of the notice when there is an error updating selected products") } From 3cd456591e561106849339cf209b94f0e4df1d6b Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 21:25:39 +0300 Subject: [PATCH 4/7] Update ProductListViewModelTests --- .../Products/ProductListViewModelTests.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift index bf3af0fb589..a8075c0144b 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift @@ -142,6 +142,28 @@ final class ProductListViewModelTests: XCTestCase { XCTAssertNil(viewModel.commonStatusForSelectedProducts) } + func test_common_price_works_correctly() { + // Given + let viewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + let sampleProduct2 = Product.fake().copy(productID: 2, regularPrice: "100") + let sampleProduct3 = Product.fake().copy(productID: 3, regularPrice: "200") + XCTAssertEqual(viewModel.commonPriceForSelectedProducts, .none) + + // When + viewModel.selectProduct(sampleProduct1) + viewModel.selectProduct(sampleProduct2) + + // Then + XCTAssertEqual(viewModel.commonPriceForSelectedProducts, .value("100")) + + // When + viewModel.selectProduct(sampleProduct3) + + // Then + XCTAssertEqual(viewModel.commonPriceForSelectedProducts, .mixed) + } + func test_updating_products_with_status_sets_correct_status() throws { // Given let viewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) @@ -172,4 +194,35 @@ final class ProductListViewModelTests: XCTestCase { // Then XCTAssertTrue(result.isSuccess) } + + func test_updating_products_with_price_sets_correct_price() throws { + // Given + let viewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + let sampleProduct2 = Product.fake().copy(productID: 2, regularPrice: "100") + let sampleProduct3 = Product.fake().copy(productID: 3, regularPrice: "200") + + storesManager.whenReceivingAction(ofType: ProductAction.self) { action in + switch action { + case let .updateProducts(_, products, completion): + XCTAssertTrue(products.allSatisfy { $0.regularPrice == "150" }) + completion(.success(products)) + default: + break + } + } + + // When + viewModel.selectProduct(sampleProduct1) + viewModel.selectProduct(sampleProduct2) + viewModel.selectProduct(sampleProduct3) + let result = waitFor { promise in + viewModel.updateSelectedProducts(with: "150") { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result.isSuccess) + } } From ab71a38930a803141f566a59c6f072a44032eef2 Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 21:25:39 +0300 Subject: [PATCH 5/7] Add PriceInputViewControllerTests --- .../Products/PriceInputViewController.swift | 6 +- .../WooCommerce.xcodeproj/project.pbxproj | 4 ++ .../PriceInputViewControllerTests.swift | 70 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewControllerTests.swift diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift index 3b085f8c16a..5c8ae142184 100644 --- a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift @@ -15,9 +15,13 @@ final class PriceInputViewController: UIViewController { return noticePresenter }() - init(viewModel: PriceInputViewModel) { + init(viewModel: PriceInputViewModel, noticePresenter: NoticePresenter? = nil) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) + + if let noticePresenter { + self.noticePresenter = noticePresenter + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 69a1ec5274c..83747816930 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1277,6 +1277,7 @@ AEE9A880293A3E5500227C92 /* RefreshablePlainList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE9A87F293A3E5500227C92 /* RefreshablePlainList.swift */; }; AEFF77A42978389400667F7A /* PriceInputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A32978389400667F7A /* PriceInputViewController.swift */; }; AEFF77A629783CA600667F7A /* PriceInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */; }; + AEFF77A829786A2900667F7A /* PriceInputViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */; }; B50911302049E27A007D25DC /* DashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509112D2049E27A007D25DC /* DashboardViewController.swift */; }; B509FED121C041DF000076A9 /* Locale+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED021C041DF000076A9 /* Locale+Woo.swift */; }; B509FED321C05121000076A9 /* SupportManagerAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED221C05121000076A9 /* SupportManagerAdapter.swift */; }; @@ -3333,6 +3334,7 @@ AEE9A87F293A3E5500227C92 /* RefreshablePlainList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshablePlainList.swift; sourceTree = ""; }; AEFF77A32978389400667F7A /* PriceInputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewController.swift; sourceTree = ""; }; AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewModel.swift; sourceTree = ""; }; + AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewControllerTests.swift; sourceTree = ""; }; B509112D2049E27A007D25DC /* DashboardViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardViewController.swift; sourceTree = ""; }; B509FED021C041DF000076A9 /* Locale+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Woo.swift"; sourceTree = ""; }; B509FED221C05121000076A9 /* SupportManagerAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerAdapter.swift; sourceTree = ""; }; @@ -5433,6 +5435,7 @@ children = ( 093B265827DF15100026F92D /* BulkUpdatePriceViewControllerTests.swift */, 09BE3A9027C921A70070B69D /* BulkUpdatePriceSettingsViewModelTests.swift */, + AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */, ); path = "Bulk Edit Price"; sourceTree = ""; @@ -11642,6 +11645,7 @@ 57A5D8D92534FEBB00AA54D6 /* TotalRefundedCalculationUseCaseTests.swift in Sources */, B5718D6521B56B400026C9F0 /* PushNotificationsManagerTests.swift in Sources */, D8A8C4F32268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift in Sources */, + AEFF77A829786A2900667F7A /* PriceInputViewControllerTests.swift in Sources */, 02C2756F24F5F5EE00286C04 /* ProductShippingSettingsViewModel+ProductVariationTests.swift in Sources */, 571FDDAE24C768DC00D486A5 /* MockZendeskManager.swift in Sources */, 45FBDF3C238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewControllerTests.swift new file mode 100644 index 00000000000..3d4c4a04bd2 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewControllerTests.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest +import Yosemite + +@testable import WooCommerce + +/// Tests for `PriceInputViewController`. +/// +final class PriceInputViewControllerTests: XCTestCase { + private let sampleSiteID: Int64 = 123 + private var storesManager: MockStoresManager! + + override func setUp() { + super.setUp() + storesManager = MockStoresManager(sessionManager: .makeForTesting()) + } + + override func tearDown() { + storesManager = nil + super.tearDown() + } + + func test_view_controller_displays_notice_on_selected_regular_price_is_less_than_sale_price_validation_error() throws { + // Given + let noticePresenter = MockNoticePresenter() + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, dateOnSaleStart: Date(), dateOnSaleEnd: Date(), regularPrice: "100", salePrice: "42") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + let viewController = PriceInputViewController(viewModel: viewModel, noticePresenter: noticePresenter) + + // When + _ = try XCTUnwrap(viewController.view) + viewModel.handlePriceChange("24") + viewModel.applyButtonTapped() + + waitUntil { + noticePresenter.queuedNotices.count == 1 + } + + // Then + let notice = try XCTUnwrap(noticePresenter.queuedNotices.first) + XCTAssertEqual(notice.feedbackType, .error) + } + + func test_view_controller_displays_notice_on_no_regular_price_validation_error() throws { + // Given + let noticePresenter = MockNoticePresenter() + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, dateOnSaleStart: Date(), dateOnSaleEnd: Date(), regularPrice: "100", salePrice: "42") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + let viewController = PriceInputViewController(viewModel: viewModel, noticePresenter: noticePresenter) + + // When + _ = try XCTUnwrap(viewController.view) + viewModel.handlePriceChange("") + viewModel.applyButtonTapped() + + waitUntil { + noticePresenter.queuedNotices.count == 1 + } + + // Then + let notice = try XCTUnwrap(noticePresenter.queuedNotices.first) + XCTAssertEqual(notice.feedbackType, .error) + } +} From 0e8bae1784a200e9921161fa7a80263a0b43ada6 Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 21:25:39 +0300 Subject: [PATCH 6/7] Make currentPrice private --- .../Classes/ViewRelated/Products/PriceInputViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift index a81ea48a303..5ad0a4a1e37 100644 --- a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift @@ -12,7 +12,7 @@ final class PriceInputViewModel { /// This holds the latest entered price. It is used to perform validations when the user taps the apply button /// and for creating a products array with the new price for the bulk update Action - private(set) var currentPrice: String = "" + private var currentPrice: String = "" private let productListViewModel: ProductListViewModel From 02eb6463305001adbc74ace2428e455eb1420387 Mon Sep 17 00:00:00 2001 From: Evgeny Aleksandrov Date: Wed, 18 Jan 2023 21:25:40 +0300 Subject: [PATCH 7/7] Add PriceInputViewModelTests --- .../WooCommerce.xcodeproj/project.pbxproj | 4 + .../PriceInputViewModelTests.swift | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewModelTests.swift diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 83747816930..375ddbc9b62 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1278,6 +1278,7 @@ AEFF77A42978389400667F7A /* PriceInputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A32978389400667F7A /* PriceInputViewController.swift */; }; AEFF77A629783CA600667F7A /* PriceInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */; }; AEFF77A829786A2900667F7A /* PriceInputViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */; }; + AEFF77AA29786DAA00667F7A /* PriceInputViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFF77A929786DAA00667F7A /* PriceInputViewModelTests.swift */; }; B50911302049E27A007D25DC /* DashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509112D2049E27A007D25DC /* DashboardViewController.swift */; }; B509FED121C041DF000076A9 /* Locale+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED021C041DF000076A9 /* Locale+Woo.swift */; }; B509FED321C05121000076A9 /* SupportManagerAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B509FED221C05121000076A9 /* SupportManagerAdapter.swift */; }; @@ -3335,6 +3336,7 @@ AEFF77A32978389400667F7A /* PriceInputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewController.swift; sourceTree = ""; }; AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewModel.swift; sourceTree = ""; }; AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewControllerTests.swift; sourceTree = ""; }; + AEFF77A929786DAA00667F7A /* PriceInputViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceInputViewModelTests.swift; sourceTree = ""; }; B509112D2049E27A007D25DC /* DashboardViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardViewController.swift; sourceTree = ""; }; B509FED021C041DF000076A9 /* Locale+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Woo.swift"; sourceTree = ""; }; B509FED221C05121000076A9 /* SupportManagerAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerAdapter.swift; sourceTree = ""; }; @@ -5436,6 +5438,7 @@ 093B265827DF15100026F92D /* BulkUpdatePriceViewControllerTests.swift */, 09BE3A9027C921A70070B69D /* BulkUpdatePriceSettingsViewModelTests.swift */, AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */, + AEFF77A929786DAA00667F7A /* PriceInputViewModelTests.swift */, ); path = "Bulk Edit Price"; sourceTree = ""; @@ -11517,6 +11520,7 @@ 0257285C230ACC7E00A288C4 /* StoreStatsV4ChartAxisHelperTests.swift in Sources */, FEED57FA2686544D00E47FD9 /* RoleErrorViewModelTests.swift in Sources */, 03CF78D127C3DBC000523706 /* WCPayCardBrand+IconsTests.swift in Sources */, + AEFF77AA29786DAA00667F7A /* PriceInputViewModelTests.swift in Sources */, EEC2D27F292CF60E0072132E /* LoginJetpackSetupHostingControllerTests.swift in Sources */, 4535EE82281BE726004212B4 /* CouponCodeInputFormatterTests.swift in Sources */, DEF36DEC2898D64600178AC2 /* JetpackSetupWebViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewModelTests.swift new file mode 100644 index 00000000000..2f2e1cc5c1f --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/Edit Price/Bulk Edit Price/PriceInputViewModelTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import WooCommerce +@testable import Yosemite + +/// Tests for `PriceInputViewModel` +/// +final class PriceInputViewModelTests: XCTestCase { + private let sampleSiteID: Int64 = 123 + private var storesManager: MockStoresManager! + + override func setUp() { + super.setUp() + storesManager = MockStoresManager(sessionManager: .makeForTesting()) + } + + override func tearDown() { + storesManager = nil + super.tearDown() + } + + func test_initial_viewModel_state() throws { + // Given + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + + // Then + XCTAssertEqual(viewModel.applyButtonEnabled, false) + XCTAssertNil(viewModel.inputValidationError) + XCTAssertTrue(viewModel.footerText.isNotEmpty) + } + + func test_state_when_price_is_changed_from_empty_to_a_value() { + // Given + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + + // When + viewModel.handlePriceChange("42") + + // Then + XCTAssertEqual(viewModel.applyButtonEnabled, true) + XCTAssertNil(viewModel.inputValidationError) + } + + func test_state_when_price_is_changed_from_a_value_to_empty() { + // Given + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + + // When + viewModel.handlePriceChange("") + + // Then + XCTAssertEqual(viewModel.applyButtonEnabled, false) + XCTAssertNil(viewModel.inputValidationError) + } + + func test_state_when_selected_regular_price_is_less_than_sale_price() { + // Given + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, dateOnSaleStart: Date(), dateOnSaleEnd: Date(), regularPrice: "100", salePrice: "42") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + + // When + viewModel.handlePriceChange("24") + viewModel.applyButtonTapped() + + // Then + XCTAssertEqual(viewModel.applyButtonEnabled, true) + XCTAssertEqual(viewModel.inputValidationError, .salePriceHigherThanRegularPrice) + } + + func test_state_when_selected_valid_price_is_valid_and_action_is_dispatched() { + // Given + var callbackValue: String? + let listViewModel = ProductListViewModel(siteID: sampleSiteID, stores: storesManager) + let sampleProduct1 = Product.fake().copy(productID: 1, regularPrice: "100") + listViewModel.selectProduct(sampleProduct1) + + let viewModel = PriceInputViewModel(productListViewModel: listViewModel) + viewModel.applyClosure = { result in + callbackValue = result + } + + // When + viewModel.handlePriceChange("42") + viewModel.applyButtonTapped() + + // Then + XCTAssertEqual(viewModel.applyButtonEnabled, true) + XCTAssertNil(viewModel.inputValidationError) + XCTAssertEqual(callbackValue, "42") + } +}