Skip to content

Commit 5524af8

Browse files
authored
Merge pull request #5871 from woocommerce/issue/5847-variation-list
Order Creation: Add support for displaying a list of variations to select for a new order
2 parents 7aa229b + eb4d2ac commit 5524af8

File tree

3 files changed

+332
-0
lines changed

3 files changed

+332
-0
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import Yosemite
2+
import protocol Storage.StorageManagerType
3+
4+
/// View model for `AddProductToOrder` with a list of product variations for a product.
5+
///
6+
final class AddProductVariationToOrderViewModel: AddProductToOrderViewModelProtocol {
7+
private let siteID: Int64
8+
9+
/// Storage to fetch product variation list
10+
///
11+
private let storageManager: StorageManagerType
12+
13+
/// Stores to sync product variation list
14+
///
15+
private let stores: StoresManager
16+
17+
/// The product whose variations are listed
18+
///
19+
private var product: Product
20+
21+
/// All purchasable product variations for the product.
22+
///
23+
private var productVariations: [ProductVariation] {
24+
productVariationsResultsController.fetchedObjects.filter { $0.purchasable }
25+
}
26+
27+
/// View models for each product row
28+
///
29+
var productRows: [ProductRowViewModel] {
30+
productVariations.map { .init(productVariation: $0, allAttributes: product.attributesForVariations, canChangeQuantity: false) }
31+
}
32+
33+
// MARK: Sync & Storage properties
34+
35+
/// Current sync status; used to determine what list view to display.
36+
///
37+
@Published private(set) var syncStatus: AddProductToOrderSyncStatus?
38+
39+
/// SyncCoordinator: Keeps tracks of which pages have been refreshed, and encapsulates the "What should we sync now" logic.
40+
///
41+
private let syncingCoordinator = SyncingCoordinator()
42+
43+
/// Tracks if the infinite scroll indicator should be displayed
44+
///
45+
@Published private(set) var shouldShowScrollIndicator = false
46+
47+
/// Product Variations Results Controller.
48+
///
49+
private lazy var productVariationsResultsController: ResultsController<StorageProductVariation> = {
50+
let predicate = NSPredicate(format: "siteID == %lld AND productID == %lld", siteID, product.productID)
51+
let descriptor = NSSortDescriptor(keyPath: \StorageProductVariation.menuOrder, ascending: true)
52+
let resultsController = ResultsController<StorageProductVariation>(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
53+
return resultsController
54+
}()
55+
56+
init(siteID: Int64,
57+
product: Product,
58+
storageManager: StorageManagerType = ServiceLocator.storageManager,
59+
stores: StoresManager = ServiceLocator.stores) {
60+
self.siteID = siteID
61+
self.product = product
62+
self.storageManager = storageManager
63+
self.stores = stores
64+
65+
configureSyncingCoordinator()
66+
configureProductVariationsResultsController()
67+
}
68+
69+
/// Select a product variation to add to the order
70+
///
71+
func selectProductOrVariation(_ productID: Int64) {
72+
// TODO: Add the selected product variation to the order
73+
}
74+
}
75+
76+
// MARK: - SyncingCoordinatorDelegate & Sync Methods
77+
extension AddProductVariationToOrderViewModel: SyncingCoordinatorDelegate {
78+
/// Sync product variations from remote.
79+
///
80+
func sync(pageNumber: Int, pageSize: Int, reason: String? = nil, onCompletion: ((Bool) -> Void)?) {
81+
transitionToSyncingState()
82+
let action = ProductVariationAction.synchronizeProductVariations(siteID: siteID,
83+
productID: product.productID,
84+
pageNumber: pageNumber,
85+
pageSize: pageSize) { [weak self] error in
86+
guard let self = self else { return }
87+
88+
if let error = error {
89+
DDLogError("⛔️ Error synchronizing product variations during order creation: \(error)")
90+
} else {
91+
self.updateProductVariationsResultsController()
92+
}
93+
94+
self.transitionToResultsUpdatedState()
95+
onCompletion?(error == nil)
96+
}
97+
stores.dispatch(action)
98+
}
99+
100+
/// Sync first page of product variations from remote if needed.
101+
///
102+
func syncFirstPage() {
103+
syncingCoordinator.synchronizeFirstPage()
104+
}
105+
106+
/// Sync next page of product variations from remote.
107+
///
108+
func syncNextPage() {
109+
let lastIndex = productVariationsResultsController.numberOfObjects - 1
110+
syncingCoordinator.ensureNextPageIsSynchronized(lastVisibleIndex: lastIndex)
111+
}
112+
}
113+
114+
// MARK: - Finite State Machine Management
115+
private extension AddProductVariationToOrderViewModel {
116+
/// Update state for sync from remote.
117+
///
118+
func transitionToSyncingState() {
119+
shouldShowScrollIndicator = true
120+
if productVariations.isEmpty {
121+
syncStatus = .firstPageSync
122+
}
123+
}
124+
125+
/// Update state after sync is complete.
126+
///
127+
func transitionToResultsUpdatedState() {
128+
shouldShowScrollIndicator = false
129+
syncStatus = productVariations.isNotEmpty ? .results: .empty
130+
}
131+
}
132+
133+
// MARK: - Configuration
134+
private extension AddProductVariationToOrderViewModel {
135+
/// Performs initial fetch from storage and updates sync status accordingly.
136+
///
137+
func configureProductVariationsResultsController() {
138+
updateProductVariationsResultsController()
139+
transitionToResultsUpdatedState()
140+
}
141+
142+
/// Fetches product variations from storage.
143+
///
144+
func updateProductVariationsResultsController() {
145+
do {
146+
try productVariationsResultsController.performFetch()
147+
} catch {
148+
DDLogError("⛔️ Error fetching product variations for new order: \(error)")
149+
}
150+
}
151+
152+
/// Setup: Syncing Coordinator
153+
///
154+
func configureSyncingCoordinator() {
155+
syncingCoordinator.delegate = self
156+
}
157+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,8 @@
11401140
CC078531266E706300BA9AC1 /* ErrorTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */; };
11411141
CC07860526736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */; };
11421142
CC13C0C9278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */; };
1143+
CC13C0CB278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */; };
1144+
CC13C0CD278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */; };
11431145
CC200BB127847DE300EC5884 /* OrderPaymentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC200BB027847DE300EC5884 /* OrderPaymentSection.swift */; };
11441146
CC254F2D26C17AB5005F3C82 /* BottomButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC254F2C26C17AB5005F3C82 /* BottomButtonView.swift */; };
11451147
CC254F3026C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC254F2F26C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift */; };
@@ -2730,6 +2732,8 @@
27302732
CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactory.swift; sourceTree = "<group>"; };
27312733
CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactoryTests.swift; sourceTree = "<group>"; };
27322734
CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductToOrderViewModelProtocol.swift; sourceTree = "<group>"; };
2735+
CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductVariationToOrderViewModel.swift; sourceTree = "<group>"; };
2736+
CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductVariationToOrderViewModelTests.swift; sourceTree = "<group>"; };
27332737
CC200BB027847DE300EC5884 /* OrderPaymentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderPaymentSection.swift; sourceTree = "<group>"; };
27342738
CC254F2C26C17AB5005F3C82 /* BottomButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomButtonView.swift; sourceTree = "<group>"; };
27352739
CC254F2F26C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddNewPackage.swift; sourceTree = "<group>"; };
@@ -6137,6 +6141,7 @@
61376141
children = (
61386142
CC53FB372755213900C4CA4F /* AddProductToOrder.swift */,
61396143
CC53FB3B2757EC7200C4CA4F /* AddProductToOrderViewModel.swift */,
6144+
CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */,
61406145
CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */,
61416146
CC53FB3427551A6E00C4CA4F /* ProductRow.swift */,
61426147
CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */,
@@ -6153,6 +6158,7 @@
61536158
AEA622B627468790002A9B57 /* AddOrderCoordinatorTests.swift */,
61546159
CC53FB3D2758E2D500C4CA4F /* ProductRowViewModelTests.swift */,
61556160
CC53FB3F2759042600C4CA4F /* AddProductToOrderViewModelTests.swift */,
6161+
CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */,
61566162
);
61576163
path = "Order Creation";
61586164
sourceTree = "<group>";
@@ -8129,6 +8135,7 @@
81298135
02A9BCD62737F73C00159C79 /* JetpackBenefitItem.swift in Sources */,
81308136
CE0F17D222A8308900964A63 /* FancyAlertController+PurchaseNote.swift in Sources */,
81318137
E1BAAEA026BBECEF00F2C037 /* ButtonStyles.swift in Sources */,
8138+
CC13C0CB278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift in Sources */,
81328139
B59D1EEA2190AE96009D1978 /* StorageNote+Woo.swift in Sources */,
81338140
024DF3072372C18D006658FE /* AztecUIConfigurator.swift in Sources */,
81348141
0217399E2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift in Sources */,
@@ -9153,6 +9160,7 @@
91539160
A650BE872578E76600C655E0 /* MockStorageManager.swift in Sources */,
91549161
029700EF24FE38F000D242F8 /* ScrollWatcherTests.swift in Sources */,
91559162
0236BCA425087B660043EB43 /* ProductFormRemoteActionUseCaseTests.swift in Sources */,
9163+
CC13C0CD278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift in Sources */,
91569164
DE26B5292775C76C00A2EA0A /* MockSyncingCoordinator.swift in Sources */,
91579165
DE126D0F26CA71E8007F901D /* ShippingLabelCustomsFormInputViewModelTests.swift in Sources */,
91589166
0277AE9B256CA8A200F45C4A /* AggregatedShippingLabelOrderItemsTests.swift in Sources */,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import XCTest
2+
import Yosemite
3+
@testable import WooCommerce
4+
@testable import Storage
5+
6+
class AddProductVariationToOrderViewModelTests: XCTestCase {
7+
8+
private let sampleSiteID: Int64 = 123
9+
private let sampleProductID: Int64 = 12
10+
private var storageManager: StorageManagerType!
11+
private var storage: StorageType {
12+
storageManager.viewStorage
13+
}
14+
private let stores = MockStoresManager(sessionManager: .testingInstance)
15+
16+
override func setUp() {
17+
super.setUp()
18+
storageManager = MockStorageManager()
19+
stores.reset()
20+
}
21+
22+
override func tearDown() {
23+
storageManager = nil
24+
super.tearDown()
25+
}
26+
27+
func test_view_model_adds_product_rows_with_unchangeable_quantity() {
28+
// Given
29+
let product = Product.fake().copy(productID: sampleProductID)
30+
let productVariation = ProductVariation.fake().copy(siteID: sampleSiteID, productID: sampleProductID, purchasable: true)
31+
insert(productVariation)
32+
33+
// When
34+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager)
35+
36+
// Then
37+
XCTAssertEqual(viewModel.productRows.count, 1)
38+
39+
let productRow = viewModel.productRows[0]
40+
XCTAssertFalse(productRow.canChangeQuantity, "Product row canChangeQuantity property should be false but is true instead")
41+
}
42+
43+
func test_product_rows_only_include_purchasable_product_variations() {
44+
// Given
45+
let product = Product.fake().copy(productID: sampleProductID)
46+
let purchasableProductVariation = ProductVariation.fake().copy(siteID: sampleSiteID,
47+
productID: sampleProductID,
48+
productVariationID: 1,
49+
purchasable: true)
50+
let nonPurchasableProductVariation = ProductVariation.fake().copy(siteID: sampleSiteID, productVariationID: 2, purchasable: false)
51+
insert([purchasableProductVariation, nonPurchasableProductVariation])
52+
53+
// When
54+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager)
55+
56+
// Then
57+
XCTAssertTrue(viewModel.productRows.contains(where: { $0.productOrVariationID == 1 }), "Product rows do not include purchasable product variation")
58+
XCTAssertFalse(viewModel.productRows.contains(where: { $0.productOrVariationID == 2 }), "Product rows include non-purchasable product variation")
59+
}
60+
61+
func test_scrolling_indicator_appears_only_during_sync() {
62+
// Given
63+
let product = Product.fake()
64+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager, stores: stores)
65+
XCTAssertFalse(viewModel.shouldShowScrollIndicator, "Scroll indicator is not disabled at start")
66+
stores.whenReceivingAction(ofType: ProductVariationAction.self) { action in
67+
switch action {
68+
case let .synchronizeProductVariations(_, _, _, _, onCompletion):
69+
XCTAssertTrue(viewModel.shouldShowScrollIndicator, "Scroll indicator is not enabled during sync")
70+
onCompletion(nil)
71+
default:
72+
XCTFail("Unsupported Action")
73+
}
74+
}
75+
76+
// When
77+
viewModel.sync(pageNumber: 1, pageSize: 25, onCompletion: { _ in })
78+
79+
// Then
80+
XCTAssertFalse(viewModel.shouldShowScrollIndicator, "Scroll indicator is not disabled after sync ends")
81+
}
82+
83+
func test_sync_status_updates_as_expected_for_empty_product_variation_list() {
84+
// Given
85+
let product = Product.fake().copy(productID: sampleProductID)
86+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager, stores: stores)
87+
stores.whenReceivingAction(ofType: ProductVariationAction.self) { action in
88+
switch action {
89+
case let .synchronizeProductVariations(_, _, _, _, onCompletion):
90+
XCTAssertEqual(viewModel.syncStatus, .firstPageSync)
91+
onCompletion(nil)
92+
default:
93+
XCTFail("Unsupported Action")
94+
}
95+
}
96+
97+
// When
98+
viewModel.sync(pageNumber: 1, pageSize: 25, onCompletion: { _ in })
99+
100+
// Then
101+
XCTAssertEqual(viewModel.syncStatus, .empty)
102+
}
103+
104+
func test_sync_status_updates_as_expected_when_product_variations_are_synced() {
105+
// Given
106+
let product = Product.fake().copy(productID: sampleProductID)
107+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager, stores: stores)
108+
stores.whenReceivingAction(ofType: ProductVariationAction.self) { action in
109+
switch action {
110+
case let .synchronizeProductVariations(_, _, _, _, onCompletion):
111+
XCTAssertEqual(viewModel.syncStatus, .firstPageSync)
112+
let productVariation = ProductVariation.fake().copy(siteID: self.sampleSiteID, productID: self.sampleProductID, purchasable: true)
113+
self.insert(productVariation)
114+
onCompletion(nil)
115+
default:
116+
XCTFail("Unsupported Action")
117+
}
118+
}
119+
120+
// When
121+
viewModel.sync(pageNumber: 1, pageSize: 25, onCompletion: { _ in })
122+
123+
// Then
124+
XCTAssertEqual(viewModel.syncStatus, .results)
125+
}
126+
127+
func test_sync_status_does_not_change_while_syncing_when_storage_contains_product_variations() {
128+
// Given
129+
let product = Product.fake().copy(productID: sampleProductID)
130+
let productVariation = ProductVariation.fake().copy(siteID: sampleSiteID, productID: sampleProductID, purchasable: true)
131+
insert(productVariation)
132+
133+
let viewModel = AddProductVariationToOrderViewModel(siteID: sampleSiteID, product: product, storageManager: storageManager, stores: stores)
134+
stores.whenReceivingAction(ofType: ProductVariationAction.self) { action in
135+
switch action {
136+
case let .synchronizeProductVariations(_, _, _, _, onCompletion):
137+
XCTAssertEqual(viewModel.syncStatus, .results)
138+
onCompletion(nil)
139+
default:
140+
XCTFail("Unsupported Action")
141+
}
142+
}
143+
144+
// When
145+
viewModel.sync(pageNumber: 1, pageSize: 25, onCompletion: { _ in })
146+
147+
// Then
148+
XCTAssertEqual(viewModel.syncStatus, .results)
149+
}
150+
}
151+
152+
// MARK: - Utils
153+
private extension AddProductVariationToOrderViewModelTests {
154+
/// Insert a `ProductVariation` into storage
155+
func insert(_ readOnlyProduct: Yosemite.ProductVariation) {
156+
let product = storage.insertNewObject(ofType: StorageProductVariation.self)
157+
product.update(with: readOnlyProduct)
158+
}
159+
160+
/// Insert an array of `ProductVariation`s into storage
161+
func insert(_ readOnlyProducts: [Yosemite.ProductVariation]) {
162+
for readOnlyProduct in readOnlyProducts {
163+
let product = storage.insertNewObject(ofType: StorageProductVariation.self)
164+
product.update(with: readOnlyProduct)
165+
}
166+
}
167+
}

0 commit comments

Comments
 (0)