Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -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<AnyCancellable>()

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")
}
}
138 changes: 138 additions & 0 deletions WooCommerce/Classes/ViewRelated/Products/PriceInputViewModel.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ProductListViewModel {
let siteID: Int64
private let stores: StoresManager

private var selectedProducts: Set<Product> = .init()
private(set) var selectedProducts: Set<Product> = .init()

init(siteID: Int64, stores: StoresManager) {
self.siteID = siteID
Expand Down Expand Up @@ -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? {
Expand All @@ -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, Error>) -> Void ) {
Expand All @@ -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, Error>) -> 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)
}
}
Loading