Skip to content

Commit 684362f

Browse files
authored
Merge pull request #2130 from Ecarrion/ec/product-categories-complete-pagination
[Manage Categories] Category selector screen pagination strategy
2 parents cdc9f3d + bf0ce08 commit 684362f

File tree

9 files changed

+411
-56
lines changed

9 files changed

+411
-56
lines changed

Networking/Networking.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
26615475242D7C9500A31661 /* ProductCategoryListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26615474242D7C9500A31661 /* ProductCategoryListMapper.swift */; };
4343
26615479242DA54D00A31661 /* ProductCategoyListMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26615478242DA54D00A31661 /* ProductCategoyListMapperTests.swift */; };
4444
2661547B242DAC1C00A31661 /* ProductCategoriesRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2661547A242DAC1C00A31661 /* ProductCategoriesRemoteTests.swift */; };
45+
2683D70E24456DB8002A1589 /* categories-empty.json in Resources */ = {isa = PBXBuildFile; fileRef = 2683D70D24456DB7002A1589 /* categories-empty.json */; };
46+
2683D71024456EE4002A1589 /* categories-extra.json in Resources */ = {isa = PBXBuildFile; fileRef = 2683D70F24456EE4002A1589 /* categories-extra.json */; };
4547
450106852399A7CB00E24722 /* TaxClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450106842399A7CB00E24722 /* TaxClass.swift */; };
4648
4501068F2399B19500E24722 /* TaxClassRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4501068E2399B19500E24722 /* TaxClassRemote.swift */; };
4749
450106912399B2C800E24722 /* TaxClassListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450106902399B2C800E24722 /* TaxClassListMapper.swift */; };
@@ -361,6 +363,8 @@
361363
26615474242D7C9500A31661 /* ProductCategoryListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryListMapper.swift; sourceTree = "<group>"; };
362364
26615478242DA54D00A31661 /* ProductCategoyListMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoyListMapperTests.swift; sourceTree = "<group>"; };
363365
2661547A242DAC1C00A31661 /* ProductCategoriesRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoriesRemoteTests.swift; sourceTree = "<group>"; };
366+
2683D70D24456DB7002A1589 /* categories-empty.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "categories-empty.json"; sourceTree = "<group>"; };
367+
2683D70F24456EE4002A1589 /* categories-extra.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "categories-extra.json"; sourceTree = "<group>"; };
364368
450106842399A7CB00E24722 /* TaxClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxClass.swift; sourceTree = "<group>"; };
365369
4501068E2399B19500E24722 /* TaxClassRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxClassRemote.swift; sourceTree = "<group>"; };
366370
450106902399B2C800E24722 /* TaxClassListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxClassListMapper.swift; sourceTree = "<group>"; };
@@ -973,6 +977,8 @@
973977
B57B1E6B21C94C9F0046E764 /* timeout_error.json */,
974978
743E84FB22174CE000FAC9D7 /* restnoroute_error.json */,
975979
265BCA01243056E3004E53EE /* categories-all.json */,
980+
2683D70F24456EE4002A1589 /* categories-extra.json */,
981+
2683D70D24456DB7002A1589 /* categories-empty.json */,
976982
74C9477F2193A6C60024CB60 /* comment-moderate-approved.json */,
977983
74C9477E2193A6C60024CB60 /* comment-moderate-trash.json */,
978984
74C947812193A6C70024CB60 /* comment-moderate-unapproved.json */,
@@ -1354,6 +1360,7 @@
13541360
265BCA02243056E3004E53EE /* categories-all.json in Resources */,
13551361
D8FBFF2422D52815006E3336 /* order-stats-v4-daily.json in Resources */,
13561362
CEC4BF91234E40B5008D9195 /* refund-single.json in Resources */,
1363+
2683D70E24456DB8002A1589 /* categories-empty.json in Resources */,
13571364
D8736B6222F089E200A14A29 /* orders-count.json in Resources */,
13581365
D823D90B22376EFE00C90817 /* shipment_tracking_delete.json in Resources */,
13591366
74C947832193A6C70024CB60 /* comment-moderate-trash.json in Resources */,
@@ -1402,6 +1409,7 @@
14021409
CE0A0F1F223998A10075ED8D /* products-load-all.json in Resources */,
14031410
74E30951216E8DCE00ABCE4C /* site-visits-alt.json in Resources */,
14041411
74ABA1C5213F17AA00FFAD30 /* top-performers-day.json in Resources */,
1412+
2683D71024456EE4002A1589 /* categories-extra.json in Resources */,
14051413
CECC759E23D6231A00486676 /* order-560-all-refunds.json in Resources */,
14061414
74ABA1CB213F19FE00FFAD30 /* top-performers-week.json in Resources */,
14071415
B524194721AC643900D6FC0A /* device-settings.json in Resources */,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"data": []
3+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"data": [
3+
{
4+
"id": 130,
5+
"name": "Shoes",
6+
"slug": "Shoes",
7+
"parent": 0,
8+
"description": "",
9+
"display": "default",
10+
"image": {
11+
"id": 1310,
12+
"date_created": "2018-08-28T13:09:22",
13+
"date_created_gmt": "2018-08-28T17:09:22",
14+
"date_modified": "2018-08-28T13:09:22",
15+
"date_modified_gmt": "2018-08-28T17:09:22",
16+
"src": "https://some-website.com/2018.jpg",
17+
"name": "2018",
18+
"alt": ""
19+
},
20+
"menu_order": 0,
21+
"count": 1,
22+
"_links": {
23+
"self": [
24+
{
25+
"href": "https://some-website.com/products/categories/104"
26+
}
27+
],
28+
"collection": [
29+
{
30+
"href": "https://some-website.com/products/categories"
31+
}
32+
]
33+
}
34+
}
35+
]
36+
}

WooCommerce/Classes/ViewRelated/Products/Edit Product/EditCategories/ProductCategoryListViewController.swift

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import UIKit
22
import Yosemite
3+
import WordPressUI
34

45
/// ProductCategoryListViewController: Displays the list of ProductCategories associated to the active Account.
56
///
67
final class ProductCategoryListViewController: UIViewController {
78

89
@IBOutlet private var tableView: UITableView!
10+
private let ghostTableView = UITableView()
911

1012
private let viewModel: ProductCategoryListViewModel
1113

@@ -22,6 +24,7 @@ final class ProductCategoryListViewController: UIViewController {
2224
super.viewDidLoad()
2325
registerTableViewCells()
2426
configureTableView()
27+
configureGhostTableView()
2528
configureNavigationBar()
2629
configureViewModel()
2730
}
@@ -32,6 +35,7 @@ final class ProductCategoryListViewController: UIViewController {
3235
private extension ProductCategoryListViewController {
3336
func registerTableViewCells() {
3437
tableView.register(ProductCategoryTableViewCell.loadNib(), forCellReuseIdentifier: ProductCategoryTableViewCell.reuseIdentifier)
38+
ghostTableView.register(ProductCategoryTableViewCell.loadNib(), forCellReuseIdentifier: ProductCategoryTableViewCell.reuseIdentifier)
3539
}
3640

3741
func configureTableView() {
@@ -42,6 +46,15 @@ private extension ProductCategoryListViewController {
4246
tableView.removeLastCellSeparator()
4347
}
4448

49+
func configureGhostTableView() {
50+
view.addSubview(ghostTableView)
51+
ghostTableView.isHidden = true
52+
ghostTableView.translatesAutoresizingMaskIntoConstraints = false
53+
ghostTableView.pinSubviewToAllEdges(view)
54+
ghostTableView.backgroundColor = .listBackground
55+
ghostTableView.removeLastCellSeparator()
56+
}
57+
4558
func configureNavigationBar() {
4659
configureTitle()
4760
configureRightButton()
@@ -66,9 +79,19 @@ private extension ProductCategoryListViewController {
6679
//
6780
private extension ProductCategoryListViewController {
6881
func configureViewModel() {
69-
viewModel.performInitialFetch()
70-
viewModel.observeCategoryListChanges { [weak self] in
71-
self?.tableView.reloadData()
82+
viewModel.performFetch()
83+
viewModel.observeCategoryListStateChanges { [weak self] syncState in
84+
switch syncState {
85+
case .initialized:
86+
break
87+
case .syncing:
88+
self?.displayGhostTableView()
89+
case let .failed(retryToken):
90+
self?.removeGhostTableView()
91+
self?.displaySyncingErrorNotice(retryToken: retryToken)
92+
case .synced:
93+
self?.removeGhostTableView()
94+
}
7295
}
7396
}
7497
}
@@ -81,6 +104,42 @@ private extension ProductCategoryListViewController {
81104
}
82105
}
83106

107+
// MARK: - Placeholders & Errors
108+
//
109+
private extension ProductCategoryListViewController {
110+
111+
/// Renders ghost placeholder categories.
112+
///
113+
func displayGhostTableView() {
114+
let placeholderCategoriesPerSection = [3]
115+
let options = GhostOptions(displaysSectionHeader: false,
116+
reuseIdentifier: ProductCategoryTableViewCell.reuseIdentifier,
117+
rowsPerSection: placeholderCategoriesPerSection)
118+
ghostTableView.displayGhostContent(options: options)
119+
ghostTableView.isHidden = false
120+
}
121+
122+
/// Removes ghost placeholder categories.
123+
///
124+
func removeGhostTableView() {
125+
tableView.reloadData()
126+
ghostTableView.removeGhostContent()
127+
ghostTableView.isHidden = true
128+
}
129+
130+
/// Displays the Sync Error Notice.
131+
///
132+
func displaySyncingErrorNotice(retryToken: ProductCategoryListViewModel.RetryToken) {
133+
let message = NSLocalizedString("Unable to load categories", comment: "Load Product Categories Action Failed")
134+
let actionTitle = NSLocalizedString("Retry", comment: "Retry Action")
135+
let notice = Notice(title: message, feedbackType: .error, actionTitle: actionTitle) { [weak self] in
136+
self?.viewModel.retryCategorySynchronization(retryToken: retryToken)
137+
}
138+
139+
ServiceLocator.noticePresenter.enqueue(notice: notice)
140+
}
141+
}
142+
84143
// MARK: - UITableViewConformace conformance
85144
//
86145
extension ProductCategoryListViewController: UITableViewDataSource, UITableViewDelegate {

WooCommerce/Classes/ViewRelated/Products/Edit Product/EditCategories/ProductCategoryListViewModel.swift

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,53 @@ import Yosemite
33

44
final class ProductCategoryListViewModel {
55

6+
/// Obscure token that allows the view model to retry the synchronizeCategories operation
7+
///
8+
struct RetryToken: Equatable {
9+
fileprivate let fromPageNumber: Int
10+
}
11+
12+
/// Represents the current state of `synchronizeCategories` action. Useful for the consumer to update it's UI upon changes
13+
///
14+
enum SyncingState: Equatable {
15+
case initialized
16+
case syncing
17+
case failed(RetryToken)
18+
case synced
19+
}
20+
21+
/// Reference to the StoresManager to dispatch Yosemite Actions.
22+
///
23+
private let storesManager: StoresManager
24+
25+
/// Product the user is editiing
26+
///
627
private let product: Product
728

29+
/// Closure to be invoked when `synchronizeCategories` state changes
30+
///
31+
private var onSyncStateChange: ((SyncingState) -> Void)?
32+
33+
/// Current category synchronization state
34+
///
35+
private var syncCategoriesState: SyncingState = .initialized {
36+
didSet {
37+
guard syncCategoriesState != oldValue else {
38+
return
39+
}
40+
onSyncStateChange?(syncCategoriesState)
41+
}
42+
}
43+
844
private lazy var resultController: ResultsController<StorageProductCategory> = {
945
let storageManager = ServiceLocator.storageManager
1046
let predicate = NSPredicate(format: "siteID = %ld", self.product.siteID)
1147
let descriptor = NSSortDescriptor(keyPath: \StorageProductCategory.name, ascending: true)
1248
return ResultsController<StorageProductCategory>(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
1349
}()
1450

15-
init(product: Product) {
51+
init(storesManager: StoresManager = ServiceLocator.stores, product: Product) {
52+
self.storesManager = storesManager
1653
self.product = product
1754
}
1855

@@ -34,17 +71,28 @@ final class ProductCategoryListViewModel {
3471
return resultController.object(at: indexPath)
3572
}
3673

37-
/// Load existing categories from storage and fire the synchronize product categories action
74+
/// Load existing categories from storage and fire the synchronize all categories action.
3875
///
39-
func performInitialFetch() {
40-
syncronizeCategories()
76+
func performFetch() {
77+
synchronizeAllCategories()
4178
try? resultController.performFetch()
4279
}
4380

44-
/// Observes and notifies of changes made to product categories
81+
/// Retry product categories synchronization when `syncCategoriesState` is on a `.failed` state.
4582
///
46-
func observeCategoryListChanges(onReload: @escaping () -> (Void)) {
47-
observeResultControllerChanges(onReload: onReload)
83+
func retryCategorySynchronization(retryToken: RetryToken) {
84+
guard syncCategoriesState == .failed(retryToken) else {
85+
return
86+
}
87+
synchronizeAllCategories(fromPageNumber: retryToken.fromPageNumber)
88+
}
89+
90+
/// Observes and notifies of changes made to product categories. the current state will be dispatched upon subscription.
91+
/// Calling this method will remove any other previous observer.
92+
///
93+
func observeCategoryListStateChanges(onStateChanges: @escaping (SyncingState) -> Void) {
94+
onSyncStateChange = onStateChanges
95+
onSyncStateChange?(syncCategoriesState)
4896
}
4997

5098
/// Returns `true` if the receiver's product contains the given category. Otherwise returns `false`
@@ -57,19 +105,36 @@ final class ProductCategoryListViewModel {
57105
// MARK: - Synchronize Categories
58106
//
59107
private extension ProductCategoryListViewModel {
60-
func syncronizeCategories() {
61-
/// TODO-2020: Page Number and PageSized to be updated when `SyncingCoordinator` is implemented.
62-
let action = ProductCategoryAction.synchronizeProductCategories(siteID: product.siteID, pageNumber: 1, pageSize: 30) { error in
108+
/// Synchronizes all product categories starting at a specific page number. Default initial page number is set on `Default.firstPageNumber`
109+
///
110+
func synchronizeAllCategories(fromPageNumber: Int = Default.firstPageNumber) {
111+
self.syncCategoriesState = .syncing
112+
let action = ProductCategoryAction.synchronizeProductCategories(siteID: product.siteID, fromPageNumber: fromPageNumber) { [weak self] error in
63113
if let error = error {
64-
DDLogError("⛔️ Error fetching product categories: \(error.localizedDescription)")
114+
self?.handleSychronizeAllCategoriesError(error)
115+
return
65116
}
117+
self?.syncCategoriesState = .synced
66118
}
67-
ServiceLocator.stores.dispatch(action)
119+
storesManager.dispatch(action)
68120
}
69121

70-
func observeResultControllerChanges(onReload: @escaping () -> (Void)) {
71-
resultController.onDidChangeContent = {
72-
onReload()
122+
/// Update `syncCategoriesState` with the proper retryToken
123+
///
124+
func handleSychronizeAllCategoriesError(_ error: ProductCategoryActionError) {
125+
switch error {
126+
case let .categoriesSynchronization(pageNumber, rawError):
127+
let retryToken = RetryToken(fromPageNumber: pageNumber)
128+
syncCategoriesState = .failed(retryToken)
129+
DDLogError("⛔️ Error fetching product categories: \(rawError.localizedDescription)")
73130
}
74131
}
75132
}
133+
134+
// MARK: - Constants
135+
//
136+
private extension ProductCategoryListViewModel {
137+
enum Default {
138+
public static let firstPageNumber = 1
139+
}
140+
}

0 commit comments

Comments
 (0)