diff --git a/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift new file mode 100644 index 00000000000..5c8ae142184 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewController.swift @@ -0,0 +1,165 @@ +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() + + private lazy var noticePresenter: NoticePresenter = { + let noticePresenter = DefaultNoticePresenter() + noticePresenter.presentingViewController = self + return noticePresenter + }() + + 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") + } + + 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) + + viewModel.$inputValidationError.sink { [weak self] error in + guard let error = error else { + return + } + self?.displayNoticeForError(error) + }.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() + } + + 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 +// +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.") + + 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 new file mode 100644 index 00000000000..5ad0a4a1e37 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift @@ -0,0 +1,138 @@ +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 + + @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 var currentPrice: String = "" + + private let productListViewModel: ProductListViewModel + + private let priceSettingsValidator: ProductPriceSettingsValidator + private let currencySettings: CurrencySettings + private let currencyFormatter: CurrencyFormatter + + var cancelClosure: () -> Void = {} + var applyClosure: (String) -> Void = { _ in } + + init(productListViewModel: ProductListViewModel, + currencySettings: CurrencySettings = ServiceLocator.currencySettings) { + self.productListViewModel = productListViewModel + self.priceSettingsValidator = ProductPriceSettingsValidator(currencySettings: currencySettings) + self.currencySettings = currencySettings + self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) + } + + /// Called when the cancel button is tapped + /// + func cancelButtonTapped() { + cancelClosure() + } + + /// Called when the save button is tapped + /// + func applyButtonTapped() { + inputValidationError = validatePrice() + guard inputValidationError == nil else { + return + } + + 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 + } + } + + /// 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 { + 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..3aa5a39fbae 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 ) { @@ -73,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 af5292bb664..f0206c967da 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,36 @@ private extension ProductsViewController { } } + func showPriceBulkEditingModal() { + let priceInputViewModel = PriceInputViewModel(productListViewModel: viewModel) + let priceInputViewController = PriceInputViewController(viewModel: priceInputViewModel) + priceInputViewModel.cancelClosure = { [weak self] in + self?.dismissModal() + } + 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) @@ -1301,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") } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 951a5b20426..375ddbc9b62 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1275,6 +1275,10 @@ 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 */; }; + 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 */; }; @@ -3329,6 +3333,10 @@ 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 = ""; }; + 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 = ""; }; @@ -5429,6 +5437,8 @@ children = ( 093B265827DF15100026F92D /* BulkUpdatePriceViewControllerTests.swift */, 09BE3A9027C921A70070B69D /* BulkUpdatePriceSettingsViewModelTests.swift */, + AEFF77A729786A2900667F7A /* PriceInputViewControllerTests.swift */, + AEFF77A929786DAA00667F7A /* PriceInputViewModelTests.swift */, ); path = "Bulk Edit Price"; sourceTree = ""; @@ -6822,6 +6832,8 @@ 020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */, 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */, 0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */, + AEFF77A32978389400667F7A /* PriceInputViewController.swift */, + AEFF77A529783CA600667F7A /* PriceInputViewModel.swift */, ); path = Products; sourceTree = ""; @@ -10477,6 +10489,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 +10763,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 */, @@ -11506,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 */, @@ -11634,6 +11649,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) + } +} 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") + } +} 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) + } }