Skip to content

Commit 40e9d26

Browse files
authored
Merge pull request #8563 from woocommerce/issue/8517-selection-state
Bulk Editing: Add selection state to products list
2 parents f05c872 + 09db3ad commit 40e9d26

File tree

4 files changed

+135
-11
lines changed

4 files changed

+135
-11
lines changed

Networking/Networking/Model/Product/Product.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,12 @@ private extension Product {
662662
enum ProductDecodingError: Error {
663663
case missingSiteID
664664
}
665+
666+
// MARK: - Hashable Conformance
667+
//
668+
extension Product: Hashable {
669+
public func hash(into hasher: inout Hasher) {
670+
hasher.combine(siteID)
671+
hasher.combine(productID)
672+
}
673+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
import Yosemite
3+
4+
/// View model for `ProductsViewController`. Only stores logic related to Bulk Editing.
5+
///
6+
class ProductListViewModel {
7+
private var selectedProducts: Set<Product> = .init()
8+
9+
var selectedProductsCount: Int {
10+
selectedProducts.count
11+
}
12+
13+
func productIsSelected(_ productToCheck: Product) -> Bool {
14+
return selectedProducts.contains(productToCheck)
15+
}
16+
17+
func selectProduct(_ selectedProduct: Product) {
18+
selectedProducts.insert(selectedProduct)
19+
}
20+
21+
func deselectProduct(_ selectedProduct: Product) {
22+
selectedProducts.remove(selectedProduct)
23+
}
24+
25+
func deselectAll() {
26+
selectedProducts.removeAll()
27+
}
28+
}

WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import class AutomatticTracks.CrashLogging
99
///
1010
final class ProductsViewController: UIViewController, GhostableViewController {
1111

12+
let viewModel: ProductListViewModel = .init()
13+
1214
/// Main TableView
1315
///
1416
@IBOutlet weak var tableView: UITableView!
@@ -264,7 +266,24 @@ private extension ProductsViewController {
264266
}
265267

266268
@objc func startBulkEditing() {
267-
// TODO-8517: implement selection state
269+
tableView.setEditing(true, animated: true)
270+
271+
// Disable pull-to-refresh while editing
272+
refreshControl.removeFromSuperview()
273+
274+
configureNavigationBarForEditing()
275+
showOrHideToolbar()
276+
}
277+
278+
@objc func finishBulkEditing() {
279+
viewModel.deselectAll()
280+
tableView.setEditing(false, animated: true)
281+
282+
// Enable pull-to-refresh
283+
tableView.addSubview(refreshControl)
284+
285+
configureNavigationBar()
286+
showOrHideToolbar()
268287
}
269288
}
270289

@@ -330,12 +349,8 @@ private extension ProductsViewController {
330349
target: self,
331350
action: #selector(startBulkEditing))
332351
button.accessibilityTraits = .button
333-
button.accessibilityLabel = NSLocalizedString("Edit products",
334-
comment: "Action to start bulk editing of products")
335-
button.accessibilityHint = NSLocalizedString(
336-
"Edit status or price for multiple products at once",
337-
comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products"
338-
)
352+
button.accessibilityLabel = Localization.bulkEditingNavBarButtonTitle
353+
button.accessibilityHint = Localization.bulkEditingNavBarButtonHint
339354

340355
return button
341356
}()
@@ -345,6 +360,26 @@ private extension ProductsViewController {
345360
navigationItem.rightBarButtonItems = rightBarButtonItems
346361
}
347362

363+
func configureNavigationBarForEditing() {
364+
configureNavigationBarTitleForEditing()
365+
configureNavigationBarRightButtonItemsForEditing()
366+
}
367+
368+
func configureNavigationBarTitleForEditing() {
369+
let selectedProducts = viewModel.selectedProductsCount
370+
if selectedProducts == 0 {
371+
navigationItem.title = Localization.bulkEditingTitle
372+
} else {
373+
navigationItem.title = String.localizedStringWithFormat(Localization.bulkEditingItemsTitle, String(selectedProducts))
374+
}
375+
}
376+
377+
func configureNavigationBarRightButtonItemsForEditing() {
378+
navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .cancel,
379+
target: self,
380+
action: #selector(finishBulkEditing))]
381+
}
382+
348383
/// Apply Woo styles.
349384
///
350385
func configureMainView() {
@@ -371,6 +406,8 @@ private extension ProductsViewController {
371406
tableView.tableFooterView = footerSpinnerView
372407
tableView.separatorStyle = .none
373408

409+
tableView.allowsMultipleSelectionDuringEditing = true
410+
374411
// Adds the refresh control to table view manually so that the refresh control always appears below the navigation bar title in
375412
// large or normal size to be consistent with Dashboard and Orders tab with large titles workaround.
376413
// If we do `tableView.refreshControl = refreshControl`, the refresh control appears in the navigation bar when large title is shown.
@@ -447,6 +484,11 @@ private extension ProductsViewController {
447484
/// if there is 1 or more products, toolbar will be visible
448485
///
449486
func showOrHideToolbar() {
487+
guard !tableView.isEditing else {
488+
toolbar.isHidden = true
489+
return
490+
}
491+
450492
toolbar.isHidden = filters.numberOfActiveFilters == 0 ? isEmpty : false
451493
}
452494
}
@@ -627,13 +669,28 @@ extension ProductsViewController: UITableViewDelegate {
627669
}
628670

629671
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
630-
tableView.deselectRow(at: indexPath, animated: true)
672+
let product = resultsController.object(at: indexPath)
631673

632-
ServiceLocator.analytics.track(.productListProductTapped)
674+
if tableView.isEditing {
675+
viewModel.selectProduct(product)
676+
configureNavigationBarTitleForEditing()
677+
} else {
678+
tableView.deselectRow(at: indexPath, animated: true)
633679

634-
let product = resultsController.object(at: indexPath)
680+
ServiceLocator.analytics.track(.productListProductTapped)
681+
682+
didSelectProduct(product: product)
683+
}
684+
}
685+
686+
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
687+
guard tableView.isEditing else {
688+
return
689+
}
635690

636-
didSelectProduct(product: product)
691+
let product = resultsController.object(at: indexPath)
692+
viewModel.deselectProduct(product)
693+
configureNavigationBarTitleForEditing()
637694
}
638695

639696
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
@@ -646,6 +703,14 @@ extension ProductsViewController: UITableViewDelegate {
646703
// the actual value. AKA no flicker!
647704
//
648705
estimatedRowHeights[indexPath] = cell.frame.height
706+
707+
// Restore cell selection state
708+
let product = resultsController.object(at: indexPath)
709+
if self.viewModel.productIsSelected(product) {
710+
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
711+
} else {
712+
tableView.deselectRow(at: indexPath, animated: false)
713+
}
649714
}
650715

651716
func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -1046,4 +1111,22 @@ private extension ProductsViewController {
10461111
static let headerContainerInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
10471112
static let toolbarButtonInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
10481113
}
1114+
1115+
enum Localization {
1116+
1117+
static let bulkEditingNavBarButtonTitle = NSLocalizedString("Edit products", comment: "Action to start bulk editing of products")
1118+
static let bulkEditingNavBarButtonHint = NSLocalizedString(
1119+
"Edit status or price for multiple products at once",
1120+
comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products"
1121+
)
1122+
1123+
static let bulkEditingTitle = NSLocalizedString(
1124+
"Select items",
1125+
comment: "Title that appears on top of the Product List screen when bulk editing starts."
1126+
)
1127+
static let bulkEditingItemsTitle = NSLocalizedString(
1128+
"%1$@ selected",
1129+
comment: "Title that appears on top of the Product List screen during bulk editing. Reads like: 2 selected"
1130+
)
1131+
}
10491132
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,7 @@
12241224
ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */; };
12251225
ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35055F8AC8C8EB649F421 /* InPersonPaymentsUnavailableView.swift */; };
12261226
AE1CC33829129A010021C8EF /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1CC33729129A010021C8EF /* LinkBehavior.swift */; };
1227+
AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */; };
12271228
AE3AA889290C303B00BE422D /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA888290C303B00BE422D /* WebKitViewController.swift */; };
12281229
AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */; };
12291230
AE3AA88D290C30E800BE422D /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88C290C30E800BE422D /* WebProgressView.swift */; };
@@ -3271,6 +3272,7 @@
32713272
ABC353433EABC5F0EC796222 /* CardReaderSettingsSearchingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardReaderSettingsSearchingViewController.swift; sourceTree = "<group>"; };
32723273
ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsOnboardingError.swift; sourceTree = "<group>"; };
32733274
AE1CC33729129A010021C8EF /* LinkBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBehavior.swift; sourceTree = "<group>"; };
3275+
AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsListViewModel.swift; sourceTree = "<group>"; };
32743276
AE3AA888290C303B00BE422D /* WebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitViewController.swift; sourceTree = "<group>"; };
32753277
AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerConfiguration.swift; sourceTree = "<group>"; };
32763278
AE3AA88C290C30E800BE422D /* WebProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProgressView.swift; sourceTree = "<group>"; };
@@ -6798,6 +6800,7 @@
67986800
02A65300246AA63600755A01 /* ProductDetailsFactory.swift */,
67996801
B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */,
68006802
0260F40023224E8100EDA10A /* ProductsViewController.swift */,
6803+
AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */,
68016804
020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */,
68026805
02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */,
68036806
0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */,
@@ -10374,6 +10377,7 @@
1037410377
CE1CCB402056F21C000EE3AC /* Style.swift in Sources */,
1037510378
45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */,
1037610379
02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */,
10380+
AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */,
1037710381
CC770C8A27B1497700CE6ABC /* SearchHeader.swift in Sources */,
1037810382
02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */,
1037910383
45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */,

0 commit comments

Comments
 (0)