Skip to content
9 changes: 9 additions & 0 deletions Networking/Networking/Model/Product/Product.swift
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,12 @@ private extension Product {
enum ProductDecodingError: Error {
case missingSiteID
}

// MARK: - Hashable Conformance
//
extension Product: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(siteID)
hasher.combine(productID)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import Yosemite

/// View model for `ProductsViewController`. Only stores logic related to Bulk Editing.
///
class ProductListViewModel {
private var selectedProducts: Set<Product> = .init()

var selectedProductsCount: Int {
selectedProducts.count
}

func productIsSelected(_ productToCheck: Product) -> Bool {
return selectedProducts.contains(productToCheck)
}

func selectProduct(_ selectedProduct: Product) {
selectedProducts.insert(selectedProduct)
}

func deselectProduct(_ selectedProduct: Product) {
selectedProducts.remove(selectedProduct)
}

func deselectAll() {
selectedProducts.removeAll()
}
}
105 changes: 94 additions & 11 deletions WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import class AutomatticTracks.CrashLogging
///
final class ProductsViewController: UIViewController, GhostableViewController {

let viewModel: ProductListViewModel = .init()

/// Main TableView
///
@IBOutlet weak var tableView: UITableView!
Expand Down Expand Up @@ -264,7 +266,24 @@ private extension ProductsViewController {
}

@objc func startBulkEditing() {
// TODO-8517: implement selection state
tableView.setEditing(true, animated: true)

// Disable pull-to-refresh while editing
refreshControl.removeFromSuperview()

configureNavigationBarForEditing()
showOrHideToolbar()
}

@objc func finishBulkEditing() {
viewModel.deselectAll()
tableView.setEditing(false, animated: true)

// Enable pull-to-refresh
tableView.addSubview(refreshControl)

configureNavigationBar()
showOrHideToolbar()
}
}

Expand Down Expand Up @@ -330,12 +349,8 @@ private extension ProductsViewController {
target: self,
action: #selector(startBulkEditing))
button.accessibilityTraits = .button
button.accessibilityLabel = NSLocalizedString("Edit products",
comment: "Action to start bulk editing of products")
button.accessibilityHint = NSLocalizedString(
"Edit status or price for multiple products at once",
comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products"
)
button.accessibilityLabel = Localization.bulkEditingNavBarButtonTitle
button.accessibilityHint = Localization.bulkEditingNavBarButtonHint

return button
}()
Expand All @@ -345,6 +360,26 @@ private extension ProductsViewController {
navigationItem.rightBarButtonItems = rightBarButtonItems
}

func configureNavigationBarForEditing() {
configureNavigationBarTitleForEditing()
configureNavigationBarRightButtonItemsForEditing()
}

func configureNavigationBarTitleForEditing() {
let selectedProducts = viewModel.selectedProductsCount
if selectedProducts == 0 {
navigationItem.title = Localization.bulkEditingTitle
} else {
navigationItem.title = String.localizedStringWithFormat(Localization.bulkEditingItemsTitle, String(selectedProducts))
}
}

func configureNavigationBarRightButtonItemsForEditing() {
navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .cancel,
target: self,
action: #selector(finishBulkEditing))]
}

/// Apply Woo styles.
///
func configureMainView() {
Expand All @@ -371,6 +406,8 @@ private extension ProductsViewController {
tableView.tableFooterView = footerSpinnerView
tableView.separatorStyle = .none

tableView.allowsMultipleSelectionDuringEditing = true

// Adds the refresh control to table view manually so that the refresh control always appears below the navigation bar title in
// large or normal size to be consistent with Dashboard and Orders tab with large titles workaround.
// If we do `tableView.refreshControl = refreshControl`, the refresh control appears in the navigation bar when large title is shown.
Expand Down Expand Up @@ -447,6 +484,11 @@ private extension ProductsViewController {
/// if there is 1 or more products, toolbar will be visible
///
func showOrHideToolbar() {
guard !tableView.isEditing else {
toolbar.isHidden = true
return
}

toolbar.isHidden = filters.numberOfActiveFilters == 0 ? isEmpty : false
}
}
Expand Down Expand Up @@ -627,13 +669,28 @@ extension ProductsViewController: UITableViewDelegate {
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let product = resultsController.object(at: indexPath)

ServiceLocator.analytics.track(.productListProductTapped)
if tableView.isEditing {
viewModel.selectProduct(product)
configureNavigationBarTitleForEditing()
} else {
tableView.deselectRow(at: indexPath, animated: true)

let product = resultsController.object(at: indexPath)
ServiceLocator.analytics.track(.productListProductTapped)

didSelectProduct(product: product)
}
}

func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
guard tableView.isEditing else {
return
}

didSelectProduct(product: product)
let product = resultsController.object(at: indexPath)
viewModel.deselectProduct(product)
configureNavigationBarTitleForEditing()
}

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Expand All @@ -646,6 +703,14 @@ extension ProductsViewController: UITableViewDelegate {
// the actual value. AKA no flicker!
//
estimatedRowHeights[indexPath] = cell.frame.height

// Restore cell selection state
let product = resultsController.object(at: indexPath)
if self.viewModel.productIsSelected(product) {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
} else {
tableView.deselectRow(at: indexPath, animated: false)
}
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
Expand Down Expand Up @@ -1046,4 +1111,22 @@ private extension ProductsViewController {
static let headerContainerInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
static let toolbarButtonInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
}

enum Localization {

static let bulkEditingNavBarButtonTitle = NSLocalizedString("Edit products", comment: "Action to start bulk editing of products")
static let bulkEditingNavBarButtonHint = NSLocalizedString(
"Edit status or price for multiple products at once",
comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products"
)

static let bulkEditingTitle = NSLocalizedString(
"Select items",
comment: "Title that appears on top of the Product List screen when bulk editing starts."
)
static let bulkEditingItemsTitle = NSLocalizedString(
"%1$@ selected",
comment: "Title that appears on top of the Product List screen during bulk editing. Reads like: 2 selected"
)
}
}
4 changes: 4 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,7 @@
ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */; };
ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35055F8AC8C8EB649F421 /* InPersonPaymentsUnavailableView.swift */; };
AE1CC33829129A010021C8EF /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1CC33729129A010021C8EF /* LinkBehavior.swift */; };
AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */; };
AE3AA889290C303B00BE422D /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA888290C303B00BE422D /* WebKitViewController.swift */; };
AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */; };
AE3AA88D290C30E800BE422D /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88C290C30E800BE422D /* WebProgressView.swift */; };
Expand Down Expand Up @@ -3259,6 +3260,7 @@
ABC353433EABC5F0EC796222 /* CardReaderSettingsSearchingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardReaderSettingsSearchingViewController.swift; sourceTree = "<group>"; };
ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsOnboardingError.swift; sourceTree = "<group>"; };
AE1CC33729129A010021C8EF /* LinkBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBehavior.swift; sourceTree = "<group>"; };
AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsListViewModel.swift; sourceTree = "<group>"; };
AE3AA888290C303B00BE422D /* WebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitViewController.swift; sourceTree = "<group>"; };
AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerConfiguration.swift; sourceTree = "<group>"; };
AE3AA88C290C30E800BE422D /* WebProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProgressView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6783,6 +6785,7 @@
02A65300246AA63600755A01 /* ProductDetailsFactory.swift */,
B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */,
0260F40023224E8100EDA10A /* ProductsViewController.swift */,
AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */,
020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */,
02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */,
0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */,
Expand Down Expand Up @@ -10355,6 +10358,7 @@
CE1CCB402056F21C000EE3AC /* Style.swift in Sources */,
45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */,
02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */,
AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */,
CC770C8A27B1497700CE6ABC /* SearchHeader.swift in Sources */,
02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */,
45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */,
Expand Down