Skip to content

Commit 9f98010

Browse files
authored
Merge pull request #2139 from Ecarrion/ec/categories-indentation
[Manage Categories] Build and Render Category Selector tree view
2 parents 43c8c2a + 6f691fd commit 9f98010

File tree

12 files changed

+370
-70
lines changed

12 files changed

+370
-70
lines changed

Networking/Networking/Model/Product/ProductCategory.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ extension ProductCategory: Comparable {
7474
}
7575
}
7676

77+
// MARK: - Constants
78+
//
79+
public extension ProductCategory {
80+
/// Value the API sends on the `parentID` field when a category does not have a parent.
81+
static let noParentID: Int64 = 0
82+
}
83+
7784
// MARK: - Decoding Errors
7885
//
7986
enum ProductCategoryDecodingError: Error {

WooCommerce/Classes/ViewRelated/Products/Edit Product/EditCategories/Cell/ProductCategoryTableViewCell.swift

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ final class ProductCategoryTableViewCell: UITableViewCell {
88
///
99
@IBOutlet private var nameLabel: UILabel!
1010

11+
/// Leading constraint of the category name label
12+
///
13+
@IBOutlet private var leadingNameLabelConstraint: NSLayoutConstraint!
14+
1115
override func awakeFromNib() {
1216
super.awakeFromNib()
1317
applyDefaultBackgroundStyle()
@@ -19,6 +23,7 @@ final class ProductCategoryTableViewCell: UITableViewCell {
1923
super.prepareForReuse()
2024
nameLabel.text = nil
2125
accessoryType = .none
26+
leadingNameLabelConstraint.constant = Constants.baseNameLabelMargin
2227
}
2328

2429
private func styleLabels() {
@@ -29,12 +34,20 @@ final class ProductCategoryTableViewCell: UITableViewCell {
2934
tintColor = .primary
3035
}
3136

32-
/// Configure the cell with the given content
33-
/// - Parameters:
34-
/// - name: Product Category name
35-
/// - selected: `true` renders a chekmark, `false` renders nothing.
36-
func configure(name: String, selected: Bool) {
37-
nameLabel.text = name
38-
accessoryType = selected ? .checkmark : .none
37+
/// Configure the cell with the given ViewModel
38+
///
39+
func configure(with viewModel: ProductCategoryCellViewModel) {
40+
nameLabel.text = viewModel.name
41+
accessoryType = viewModel.isSelected ? .checkmark : .none
42+
leadingNameLabelConstraint.constant = Constants.baseNameLabelMargin + (Constants.nameLabelIndentationFactor * CGFloat(viewModel.indentationLevel))
43+
}
44+
}
45+
46+
// MARK: - Constants!
47+
//
48+
private extension ProductCategoryTableViewCell {
49+
enum Constants {
50+
static let baseNameLabelMargin: CGFloat = 16.0
51+
static let nameLabelIndentationFactor: CGFloat = 20.0
3952
}
4053
}

WooCommerce/Classes/ViewRelated/Products/Edit Product/EditCategories/Cell/ProductCategoryTableViewCell.xib

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
<rect key="frame" x="0.0" y="0.0" width="462" height="53"/>
1717
<autoresizingMask key="autoresizingMask"/>
1818
<subviews>
19-
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iPu-uq-Yz5">
19+
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iPu-uq-Yz5">
2020
<rect key="frame" x="16" y="13" width="42" height="27"/>
2121
<fontDescription key="fontDescription" type="system" pointSize="17"/>
2222
<nil key="textColor"/>
@@ -31,6 +31,7 @@
3131
</constraints>
3232
</tableViewCellContentView>
3333
<connections>
34+
<outlet property="leadingNameLabelConstraint" destination="3hZ-f9-3Hj" id="G4V-Oj-W8q"/>
3435
<outlet property="nameLabel" destination="iPu-uq-Yz5" id="D22-2I-Vyu"/>
3536
</connections>
3637
<point key="canvasLocation" x="-5.7971014492753632" y="-122.87946428571428"/>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
3+
/// Represents a row in the ProductCategoryList screen
4+
///
5+
struct ProductCategoryCellViewModel {
6+
/// Category name
7+
///
8+
let name: String
9+
10+
/// Category selected status
11+
///
12+
let isSelected: Bool
13+
14+
/// Level of indentation as a subcategory
15+
///
16+
let indentationLevel: Int
17+
}

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,8 @@ private extension ProductCategoryListViewController {
144144
//
145145
extension ProductCategoryListViewController: UITableViewDataSource, UITableViewDelegate {
146146

147-
func numberOfSections(in tableView: UITableView) -> Int {
148-
return viewModel.numberOfSections()
149-
}
150-
151147
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
152-
return viewModel.numberOfRowsInSection(section: section)
148+
return viewModel.categoryViewModels.count
153149
}
154150

155151
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
@@ -158,9 +154,9 @@ extension ProductCategoryListViewController: UITableViewDataSource, UITableViewD
158154
fatalError()
159155
}
160156

161-
let category = viewModel.item(at: indexPath)
162-
let isSelected = viewModel.isCategorySelected(category)
163-
cell.configure(name: category.name, selected: isSelected)
157+
if let categoryViewModel = viewModel.categoryViewModels[safe: indexPath.row] {
158+
cell.configure(with: categoryViewModel)
159+
}
164160
return cell
165161
}
166162

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

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ final class ProductCategoryListViewModel {
2626
///
2727
private let product: Product
2828

29+
/// Array of view models to be rendered by the View Controller.
30+
///
31+
private(set) var categoryViewModels: [ProductCategoryCellViewModel] = []
32+
2933
/// Closure to be invoked when `synchronizeCategories` state changes
3034
///
3135
private var onSyncStateChange: ((SyncingState) -> Void)?
@@ -53,24 +57,6 @@ final class ProductCategoryListViewModel {
5357
self.product = product
5458
}
5559

56-
/// Returns the number sections.
57-
///
58-
func numberOfSections() -> Int {
59-
return resultController.sections.count
60-
}
61-
62-
/// Returns the number of items for a given `section` that should be displayed
63-
///
64-
func numberOfRowsInSection(section: Int) -> Int {
65-
return resultController.sections[section].numberOfObjects
66-
}
67-
68-
/// Returns a product category for a given `indexPath`
69-
///
70-
func item(at indexPath: IndexPath) -> ProductCategory {
71-
return resultController.object(at: indexPath)
72-
}
73-
7460
/// Load existing categories from storage and fire the synchronize all categories action.
7561
///
7662
func performFetch() {
@@ -94,12 +80,6 @@ final class ProductCategoryListViewModel {
9480
onSyncStateChange = onStateChanges
9581
onSyncStateChange?(syncCategoriesState)
9682
}
97-
98-
/// Returns `true` if the receiver's product contains the given category. Otherwise returns `false`
99-
///
100-
func isCategorySelected(_ category: ProductCategory) -> Bool {
101-
return product.categories.contains(category)
102-
}
10383
}
10484

10585
// MARK: - Synchronize Categories
@@ -110,11 +90,14 @@ private extension ProductCategoryListViewModel {
11090
func synchronizeAllCategories(fromPageNumber: Int = Default.firstPageNumber) {
11191
self.syncCategoriesState = .syncing
11292
let action = ProductCategoryAction.synchronizeProductCategories(siteID: product.siteID, fromPageNumber: fromPageNumber) { [weak self] error in
93+
// Make sure we always have view models to display
94+
self?.updateViewModelsArray()
95+
11396
if let error = error {
11497
self?.handleSychronizeAllCategoriesError(error)
115-
return
98+
} else {
99+
self?.syncCategoriesState = .synced
116100
}
117-
self?.syncCategoriesState = .synced
118101
}
119102
storesManager.dispatch(action)
120103
}
@@ -129,6 +112,13 @@ private extension ProductCategoryListViewModel {
129112
DDLogError("⛔️ Error fetching product categories: \(rawError.localizedDescription)")
130113
}
131114
}
115+
116+
/// Updates `categoryViewModels` from the resultController's fetched objects.
117+
///
118+
func updateViewModelsArray() {
119+
let fetchedCategories = resultController.fetchedObjects
120+
categoryViewModels = CellViewModelBuilder.viewModels(from: fetchedCategories, selectedCategories: product.categories)
121+
}
132122
}
133123

134124
// MARK: - Constants
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Foundation
2+
import Yosemite
3+
4+
// MARK: ViewModel Builder
5+
extension ProductCategoryListViewModel {
6+
7+
/// Creates `ProductCategoryCellViewModel` types
8+
///
9+
struct CellViewModelBuilder {
10+
11+
/// Represents Categories -> SubCategories relatioships
12+
///
13+
private struct CategoryTree {
14+
15+
/// Stores categories by holding a reference to it's `parentID`
16+
///
17+
private let nodes: [Int64: [ProductCategory]]
18+
19+
init(categories: [ProductCategory]) {
20+
nodes = Self.nodesFromCategories(categories)
21+
}
22+
23+
/// Returns a dictionary where each key holds a category `parentID` each value an array of subcategories.
24+
///
25+
private static func nodesFromCategories(_ productCategories: [ProductCategory]) -> [Int64: [ProductCategory]] {
26+
return productCategories.reduce(into: [Int64: [ProductCategory]]()) { (result, category) in
27+
var children = result[category.parentID] ?? []
28+
children.append(category)
29+
result[category.parentID] = children
30+
}
31+
}
32+
33+
/// Returns categories that don't have a `parentID`
34+
///
35+
var rootCategories: [ProductCategory] {
36+
return nodes[ProductCategory.noParentID] ?? []
37+
}
38+
39+
/// Returns the inmediate subCategories of a given category or `nil` if there aren't any.
40+
///
41+
func outterSubCategories(of category: ProductCategory) -> [ProductCategory]? {
42+
return nodes[category.categoryID]
43+
}
44+
}
45+
46+
/// Returns an array of `ProductCategoryCellViewModel` by sorting the provided `categories` following a `Category -> SubCategory` order.
47+
/// Provide an array of `selectedCategories` to properly reflect the selected state in the returned view model array.
48+
///
49+
static func viewModels(from categories: [ProductCategory], selectedCategories: [ProductCategory]) -> [ProductCategoryCellViewModel] {
50+
// Create tree structure
51+
let tree = CategoryTree(categories: categories)
52+
53+
// For each root category, get all sub-categories and return a flattened array of view models
54+
let viewModels = tree.rootCategories.flatMap { category -> [ProductCategoryCellViewModel] in
55+
return flattenViewModels(of: category, in: tree, selectedCategories: selectedCategories)
56+
}
57+
58+
return viewModels
59+
}
60+
61+
/// Recursively return all sub-categories view models of a given category in a given tree.
62+
/// Provide an array of `selectedCategories` to properly reflect the selected state in the returned view model array.
63+
///
64+
private static func flattenViewModels(of category: ProductCategory,
65+
in tree: CategoryTree,
66+
selectedCategories: [ProductCategory],
67+
depthLevel: Int = 0) -> [ProductCategoryCellViewModel] {
68+
69+
// View model for the main category
70+
let categoryViewModel = viewModel(for: category, selectedCategories: selectedCategories, indentationLevel: depthLevel)
71+
72+
// Base case, return the single view model when a category doesn't have any sub-categories
73+
guard let outterSubCategories = tree.outterSubCategories(of: category) else {
74+
return [categoryViewModel]
75+
}
76+
77+
// Return the main categoryViewModel + all possible sub-categories VMs by calling this function recursively
78+
return [categoryViewModel] + outterSubCategories.flatMap { outterSubCategory -> [ProductCategoryCellViewModel] in
79+
80+
// Increase the `depthLevel` to properly track the view model indentation level
81+
return flattenViewModels(of: outterSubCategory, in: tree, selectedCategories: selectedCategories, depthLevel: depthLevel + 1)
82+
}
83+
}
84+
85+
/// Return a view model for an specific category, indentation level and `selectedCategories` array
86+
///
87+
private static func viewModel(for category: ProductCategory,
88+
selectedCategories: [ProductCategory],
89+
indentationLevel: Int) -> ProductCategoryCellViewModel {
90+
let isSelected = selectedCategories.contains(category)
91+
return ProductCategoryCellViewModel(name: category.name, isSelected: isSelected, indentationLevel: indentationLevel)
92+
}
93+
}
94+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@
230230
265BCA092430E6E0004E53EE /* ProductCategoryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265BCA082430E6E0004E53EE /* ProductCategoryListViewModel.swift */; };
231231
265BCA0C2430E741004E53EE /* ProductCategoryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265BCA0B2430E741004E53EE /* ProductCategoryTableViewCell.swift */; };
232232
265BCA0E2430E771004E53EE /* ProductCategoryTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 265BCA0D2430E771004E53EE /* ProductCategoryTableViewCell.xib */; };
233+
265D909B2446657B00D66F0F /* ProductCategoryViewModelBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265D909A2446657A00D66F0F /* ProductCategoryViewModelBuilder.swift */; };
234+
265D909D2446688C00D66F0F /* ProductCategoryViewModelBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267CFE1824435A5500AF3A13 /* ProductCategoryViewModelBuilderTests.swift */; };
235+
267CFE1C2443A54200AF3A13 /* ProductCategoryCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267CFE1A2443740900AF3A13 /* ProductCategoryCellViewModel.swift */; };
233236
451750B224470CD5004FDA65 /* EnhancedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451750B124470CD5004FDA65 /* EnhancedTextView.swift */; };
234237
451A04E62386CE8700E368C9 /* ProductImagesHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A04E42386CE8700E368C9 /* ProductImagesHeaderTableViewCell.swift */; };
235238
451A04E72386CE8700E368C9 /* ProductImagesHeaderTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 451A04E52386CE8700E368C9 /* ProductImagesHeaderTableViewCell.xib */; };
@@ -1024,6 +1027,9 @@
10241027
265BCA082430E6E0004E53EE /* ProductCategoryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryListViewModel.swift; sourceTree = "<group>"; };
10251028
265BCA0B2430E741004E53EE /* ProductCategoryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryTableViewCell.swift; sourceTree = "<group>"; };
10261029
265BCA0D2430E771004E53EE /* ProductCategoryTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProductCategoryTableViewCell.xib; sourceTree = "<group>"; };
1030+
265D909A2446657A00D66F0F /* ProductCategoryViewModelBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryViewModelBuilder.swift; sourceTree = "<group>"; };
1031+
267CFE1824435A5500AF3A13 /* ProductCategoryViewModelBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryViewModelBuilderTests.swift; sourceTree = "<group>"; };
1032+
267CFE1A2443740900AF3A13 /* ProductCategoryCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryCellViewModel.swift; sourceTree = "<group>"; };
10271033
2719B6FD1E6FE78A76B6AC74 /* Pods-WooCommerceTests.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WooCommerceTests.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WooCommerceTests/Pods-WooCommerceTests.release-alpha.xcconfig"; sourceTree = "<group>"; };
10281034
33035144757869DE5E4DC88A /* Pods-WooCommerce.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WooCommerce.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WooCommerce/Pods-WooCommerce.release.xcconfig"; sourceTree = "<group>"; };
10291035
451750B124470CD5004FDA65 /* EnhancedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTextView.swift; sourceTree = "<group>"; };
@@ -2076,6 +2082,7 @@
20762082
isa = PBXGroup;
20772083
children = (
20782084
2611EE58243A473300A74490 /* ProductCategoryListViewModelTests.swift */,
2085+
267CFE1824435A5500AF3A13 /* ProductCategoryViewModelBuilderTests.swift */,
20792086
);
20802087
path = Categories;
20812088
sourceTree = "<group>";
@@ -2086,6 +2093,8 @@
20862093
265BCA042430E611004E53EE /* ProductCategoryListViewController.swift */,
20872094
265BCA062430E62E004E53EE /* ProductCategoryListViewController.xib */,
20882095
265BCA082430E6E0004E53EE /* ProductCategoryListViewModel.swift */,
2096+
267CFE1A2443740900AF3A13 /* ProductCategoryCellViewModel.swift */,
2097+
265D909A2446657A00D66F0F /* ProductCategoryViewModelBuilder.swift */,
20892098
265BCA0A2430E719004E53EE /* Cell */,
20902099
);
20912100
path = EditCategories;
@@ -4168,6 +4177,7 @@
41684177
B5F571A421BEC90D0010D1B8 /* NoteDetailsHeaderPlainTableViewCell.swift in Sources */,
41694178
D817585E22BB5E8700289CFE /* OrderEmailComposer.swift in Sources */,
41704179
024DF3092372CA00006658FE /* EditorViewProperties.swift in Sources */,
4180+
267CFE1C2443A54200AF3A13 /* ProductCategoryCellViewModel.swift in Sources */,
41714181
CE85535D209B5BB700938BDC /* OrderDetailsViewModel.swift in Sources */,
41724182
CE21B3E020FFC59700A259D5 /* ProductDetailsTableViewCell.swift in Sources */,
41734183
B509FED121C041DF000076A9 /* Locale+Woo.swift in Sources */,
@@ -4232,6 +4242,7 @@
42324242
0262DA5823A23AC80029AF30 /* ProductShippingSettingsViewController.swift in Sources */,
42334243
748C7782211E294000814F2C /* Double+Woo.swift in Sources */,
42344244
451C77732404534000413F73 /* ProductSettingsSections.swift in Sources */,
4245+
265D909B2446657B00D66F0F /* ProductCategoryViewModelBuilder.swift in Sources */,
42354246
576F92222423C3C0003E5FEF /* OrdersViewModel.swift in Sources */,
42364247
D8915DC32372C9EF00F63762 /* UIColor+ColorStudio.swift in Sources */,
42374248
024DF31623742BB6006658FE /* AztecStrikethroughFormatBarCommand.swift in Sources */,
@@ -4583,6 +4594,7 @@
45834594
02153211242376B5003F2BBD /* ProductPriceSettingsViewModelTests.swift in Sources */,
45844595
45C8B25D231529410002FA77 /* CustomerInfoTableViewCellTests.swift in Sources */,
45854596
D85B8336222FCDA1002168F3 /* StatusListTableViewCellTests.swift in Sources */,
4597+
265D909D2446688C00D66F0F /* ProductCategoryViewModelBuilderTests.swift in Sources */,
45864598
B555531321B57E8800449E71 /* MockupUserNotificationsCenterAdapter.swift in Sources */,
45874599
D8C11A6022E2479800D4A88D /* OrderPaymentDetailsViewModelTests.swift in Sources */,
45884600
D83F593D225B4B5000626E75 /* ManualTrackingViewControllerTests.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>classNames</key>
6+
<dict>
7+
<key>ProductCategoryViewModelBuilderTests</key>
8+
<dict>
9+
<key>testViewModelGenerationTimeWithFromBigSetOfCategories()</key>
10+
<dict>
11+
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
12+
<dict>
13+
<key>baselineAverage</key>
14+
<real>0.0793</real>
15+
<key>baselineIntegrationDisplayName</key>
16+
<string>Local Baseline</string>
17+
</dict>
18+
</dict>
19+
</dict>
20+
</dict>
21+
</dict>
22+
</plist>

0 commit comments

Comments
 (0)