Skip to content

Commit b880467

Browse files
authored
Merge pull request #8691 from woocommerce/issue/8520-price-picker
Bulk Editing: Add price picker
2 parents b0c4a24 + 02eb646 commit b880467

File tree

8 files changed

+626
-4
lines changed

8 files changed

+626
-4
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import UIKit
2+
import Yosemite
3+
import Combine
4+
5+
final class PriceInputViewController: UIViewController {
6+
7+
let tableView: UITableView = UITableView(frame: .zero, style: .grouped)
8+
9+
private var viewModel: PriceInputViewModel
10+
private var subscriptions = Set<AnyCancellable>()
11+
12+
private lazy var noticePresenter: NoticePresenter = {
13+
let noticePresenter = DefaultNoticePresenter()
14+
noticePresenter.presentingViewController = self
15+
return noticePresenter
16+
}()
17+
18+
init(viewModel: PriceInputViewModel, noticePresenter: NoticePresenter? = nil) {
19+
self.viewModel = viewModel
20+
super.init(nibName: nil, bundle: nil)
21+
22+
if let noticePresenter {
23+
self.noticePresenter = noticePresenter
24+
}
25+
}
26+
required init?(coder: NSCoder) {
27+
fatalError("init(coder:) has not been implemented")
28+
}
29+
30+
override func viewDidLoad() {
31+
super.viewDidLoad()
32+
33+
configureTitleAndBackground()
34+
configureTableView()
35+
configureViewModel()
36+
}
37+
}
38+
39+
private extension PriceInputViewController {
40+
func configureTitleAndBackground() {
41+
title = viewModel.screenTitle()
42+
view.backgroundColor = .listBackground
43+
44+
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
45+
target: self,
46+
action: #selector(cancelButtonTapped))
47+
navigationItem.rightBarButtonItem = UIBarButtonItem(title: Localization.bulkEditingApply,
48+
style: .plain,
49+
target: self,
50+
action: #selector(applyButtonTapped))
51+
}
52+
53+
func configureViewModel() {
54+
viewModel.$applyButtonEnabled
55+
.sink { [weak self] enabled in
56+
self?.navigationItem.rightBarButtonItem?.isEnabled = enabled
57+
}.store(in: &subscriptions)
58+
59+
viewModel.$inputValidationError.sink { [weak self] error in
60+
guard let error = error else {
61+
return
62+
}
63+
self?.displayNoticeForError(error)
64+
}.store(in: &subscriptions)
65+
}
66+
67+
func configureTableView() {
68+
tableView.translatesAutoresizingMaskIntoConstraints = false
69+
view.addSubview(tableView)
70+
view.pinSubviewToAllEdges(tableView)
71+
72+
tableView.rowHeight = UITableView.automaticDimension
73+
tableView.backgroundColor = .listBackground
74+
75+
tableView.registerNib(for: UnitInputTableViewCell.self)
76+
77+
tableView.dataSource = self
78+
}
79+
80+
/// Called when the cancel button is tapped
81+
///
82+
@objc func cancelButtonTapped() {
83+
viewModel.cancelButtonTapped()
84+
}
85+
86+
/// Called when the save button is tapped to update the price for all products
87+
///
88+
@objc func applyButtonTapped() {
89+
// Dismiss the keyboard before triggering the update
90+
view.endEditing(true)
91+
viewModel.applyButtonTapped()
92+
}
93+
94+
func displayNoticeForError(_ productPriceSettingsError: ProductPriceSettingsError) {
95+
switch productPriceSettingsError {
96+
case .salePriceWithoutRegularPrice:
97+
displayNotice(for: Localization.salePriceWithoutRegularPriceError)
98+
case .salePriceHigherThanRegularPrice:
99+
displayNotice(for: Localization.displaySalePriceError)
100+
case .newSaleWithEmptySalePrice:
101+
displayNotice(for: Localization.displayMissingSalePriceError)
102+
}
103+
}
104+
105+
/// Displays a Notice onscreen for a given message
106+
///
107+
func displayNotice(for message: String) {
108+
view.endEditing(true)
109+
let notice = Notice(title: message, feedbackType: .error)
110+
noticePresenter.enqueue(notice: notice)
111+
}
112+
}
113+
114+
// MARK: - UITableViewDataSource Conformance
115+
//
116+
extension PriceInputViewController: UITableViewDataSource {
117+
118+
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
119+
return 1
120+
}
121+
122+
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
123+
let cell = tableView.dequeueReusableCell(withIdentifier: UnitInputTableViewCell.self.reuseIdentifier, for: indexPath)
124+
configure(cell, at: indexPath)
125+
126+
return cell
127+
}
128+
129+
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
130+
return UITableView.automaticDimension
131+
}
132+
133+
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
134+
return viewModel.footerText
135+
}
136+
137+
private func configure(_ cell: UITableViewCell, at indexPath: IndexPath) {
138+
switch cell {
139+
case let cell as UnitInputTableViewCell:
140+
let cellViewModel = UnitInputViewModel.createBulkPriceViewModel(using: ServiceLocator.currencySettings) { [weak self] value in
141+
self?.viewModel.handlePriceChange(value)
142+
}
143+
cell.selectionStyle = .none
144+
cell.configure(viewModel: cellViewModel)
145+
default:
146+
fatalError("Unidentified bulk update row type")
147+
break
148+
}
149+
}
150+
}
151+
152+
private extension PriceInputViewController {
153+
enum Localization {
154+
static let bulkEditingApply = NSLocalizedString("Apply", comment: "Title for the button to apply bulk editing changes to selected products.")
155+
156+
static let salePriceWithoutRegularPriceError = NSLocalizedString("The sale price can't be added without the regular price.",
157+
comment: "Bulk price update error message, when the sale price is added but the"
158+
+ " regular price is not")
159+
static let displaySalePriceError = NSLocalizedString("The sale price should be lower than the regular price.",
160+
comment: "Bulk price update error, when the sale price is higher than the regular"
161+
+ " price")
162+
static let displayMissingSalePriceError = NSLocalizedString("Please enter a sale price for the scheduled sale",
163+
comment: "Bulk price update error, when the sale price is empty")
164+
}
165+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import Foundation
2+
import Yosemite
3+
import WooFoundation
4+
5+
/// View Model logic for the bulk price setting screen
6+
///
7+
final class PriceInputViewModel {
8+
9+
@Published private(set) var applyButtonEnabled: Bool = false
10+
11+
@Published private(set) var inputValidationError: ProductPriceSettingsError? = nil
12+
13+
/// This holds the latest entered price. It is used to perform validations when the user taps the apply button
14+
/// and for creating a products array with the new price for the bulk update Action
15+
private var currentPrice: String = ""
16+
17+
private let productListViewModel: ProductListViewModel
18+
19+
private let priceSettingsValidator: ProductPriceSettingsValidator
20+
private let currencySettings: CurrencySettings
21+
private let currencyFormatter: CurrencyFormatter
22+
23+
var cancelClosure: () -> Void = {}
24+
var applyClosure: (String) -> Void = { _ in }
25+
26+
init(productListViewModel: ProductListViewModel,
27+
currencySettings: CurrencySettings = ServiceLocator.currencySettings) {
28+
self.productListViewModel = productListViewModel
29+
self.priceSettingsValidator = ProductPriceSettingsValidator(currencySettings: currencySettings)
30+
self.currencySettings = currencySettings
31+
self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings)
32+
}
33+
34+
/// Called when the cancel button is tapped
35+
///
36+
func cancelButtonTapped() {
37+
cancelClosure()
38+
}
39+
40+
/// Called when the save button is tapped
41+
///
42+
func applyButtonTapped() {
43+
inputValidationError = validatePrice()
44+
guard inputValidationError == nil else {
45+
return
46+
}
47+
48+
applyClosure(currentPrice)
49+
}
50+
51+
/// Called when price changes
52+
///
53+
func handlePriceChange(_ price: String?) {
54+
currentPrice = price ?? ""
55+
updateButtonStateBasedOnCurrentPrice()
56+
}
57+
58+
/// Update the button state to enable/disable based on price value
59+
///
60+
private func updateButtonStateBasedOnCurrentPrice() {
61+
if currentPrice.isNotEmpty {
62+
applyButtonEnabled = true
63+
} else {
64+
applyButtonEnabled = false
65+
}
66+
}
67+
68+
/// Validates if the currently selected price is valid for all products
69+
///
70+
private func validatePrice() -> ProductPriceSettingsError? {
71+
for product in productListViewModel.selectedProducts {
72+
let regularPrice = currentPrice
73+
let salePrice = product.salePrice
74+
75+
if let error = priceSettingsValidator.validate(regularPrice: regularPrice,
76+
salePrice: salePrice,
77+
dateOnSaleStart: product.dateOnSaleStart,
78+
dateOnSaleEnd: product.dateOnSaleEnd) {
79+
return error
80+
}
81+
}
82+
83+
return nil
84+
}
85+
86+
/// Returns the footer text to be displayed with information about the current bulk price and how many products will be updated.
87+
///
88+
var footerText: String {
89+
let numberOfProducts = productListViewModel.selectedProductsCount
90+
let numberOfProductsText = String.pluralize(numberOfProducts,
91+
singular: Localization.productsNumberSingularFooter,
92+
plural: Localization.productsNumberPluralFooter)
93+
94+
switch productListViewModel.commonPriceForSelectedProducts {
95+
case .none:
96+
return [Localization.currentPriceNoneFooter, numberOfProductsText].joined(separator: " ")
97+
case .mixed:
98+
return [Localization.currentPriceMixedFooter, numberOfProductsText].joined(separator: " ")
99+
case let .value(price):
100+
let currentPriceText = String.localizedStringWithFormat(Localization.currentPriceFooter, formatPriceString(price))
101+
return [currentPriceText, numberOfProductsText].joined(separator: " ")
102+
}
103+
}
104+
105+
/// It formats a price `String` according to the current price settings.
106+
///
107+
private func formatPriceString(_ price: String) -> String {
108+
let currencyCode = currencySettings.currencyCode
109+
let currency = currencySettings.symbol(from: currencyCode)
110+
111+
return currencyFormatter.formatAmount(price, with: currency) ?? ""
112+
}
113+
114+
/// Returns the title to be displayed in the top of bulk update screen
115+
///
116+
func screenTitle() -> String {
117+
return Localization.screenTitle
118+
}
119+
}
120+
121+
private extension PriceInputViewModel {
122+
enum Localization {
123+
static let screenTitle = NSLocalizedString("Update Regular Price", comment: "Title that appears on top of the of bulk price setting screen")
124+
static let productsNumberSingularFooter = NSLocalizedString("The price will be updated for %d product.",
125+
comment: "Message in the footer of bulk price setting screen (singular).")
126+
static let productsNumberPluralFooter = NSLocalizedString("The price will be updated for %d products.",
127+
comment: "Message in the footer of bulk price setting screen (plurar).")
128+
static let currentPriceFooter = NSLocalizedString("Current price is %@.",
129+
comment: "Message in the footer of bulk price setting screen"
130+
+ " with the current price, when it is the same for all products")
131+
static let currentPriceMixedFooter = NSLocalizedString("Current prices are mixed.",
132+
comment: "Message in the footer of bulk price setting screen, when products have"
133+
+ " different price values.")
134+
static let currentPriceNoneFooter = NSLocalizedString("Current price is not set.",
135+
comment: "Message in the footer of bulk price setting screen, when none of the"
136+
+ " products have price value.")
137+
}
138+
}

WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class ProductListViewModel {
1212
let siteID: Int64
1313
private let stores: StoresManager
1414

15-
private var selectedProducts: Set<Product> = .init()
15+
private(set) var selectedProducts: Set<Product> = .init()
1616

1717
init(siteID: Int64, stores: StoresManager) {
1818
self.siteID = siteID
@@ -43,6 +43,17 @@ class ProductListViewModel {
4343
selectedProducts.removeAll()
4444
}
4545

46+
/// Represents if a property in a collection of `Product` has the same value or different values or is missing.
47+
///
48+
enum BulkValue: Equatable {
49+
/// All variations have the same value
50+
case value(String)
51+
/// When variations have mixed values.
52+
case mixed
53+
/// None of the variation has a value
54+
case none
55+
}
56+
4657
/// Check if selected products share the same common ProductStatus. Returns `nil` otherwise.
4758
///
4859
var commonStatusForSelectedProducts: ProductStatus? {
@@ -54,6 +65,18 @@ class ProductListViewModel {
5465
}
5566
}
5667

68+
/// Check if selected products share the same common ProductStatus. Returns `nil` otherwise.
69+
///
70+
var commonPriceForSelectedProducts: BulkValue {
71+
if selectedProducts.allSatisfy({ $0.regularPrice?.isEmpty != false }) {
72+
return .none
73+
} else if let price = selectedProducts.first?.regularPrice, selectedProducts.allSatisfy({ $0.regularPrice == price }) {
74+
return .value(price)
75+
} else {
76+
return .mixed
77+
}
78+
}
79+
5780
/// Update selected products with new ProductStatus and trigger Network action to save the change remotely.
5881
///
5982
func updateSelectedProducts(with newStatus: ProductStatus, completion: @escaping (Result<Void, Error>) -> Void ) {
@@ -73,4 +96,24 @@ class ProductListViewModel {
7396
}
7497
stores.dispatch(batchAction)
7598
}
99+
100+
/// Update selected products with new price and trigger Network action to save the change remotely.
101+
///
102+
func updateSelectedProducts(with newPrice: String, completion: @escaping (Result<Void, Error>) -> Void ) {
103+
guard selectedProductsCount > 0 else {
104+
completion(.failure(BulkEditError.noProductsSelected))
105+
return
106+
}
107+
108+
let updatedProducts = selectedProducts.map({ $0.copy(regularPrice: newPrice) })
109+
let batchAction = ProductAction.updateProducts(siteID: siteID, products: updatedProducts) { result in
110+
switch result {
111+
case .success:
112+
completion(.success(()))
113+
case .failure(let error):
114+
completion(.failure(error))
115+
}
116+
}
117+
stores.dispatch(batchAction)
118+
}
76119
}

0 commit comments

Comments
 (0)