Skip to content

Commit 4ebd4d1

Browse files
committed
Add AddProductVariationToOrderViewModel to add a variation to an order
1 parent 5063d71 commit 4ebd4d1

File tree

3 files changed

+352
-0
lines changed

3 files changed

+352
-0
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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: SyncStatus?
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: "product.siteID == %lld AND product.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+
lazy var productVariationsResultsController: ResultsController<StorageProductVariation> = {
56+
let predicate = NSPredicate(format: "siteID == %lld AND productID == %lld", siteID, product.productID)
57+
let descriptor = NSSortDescriptor(keyPath: \StorageProductVariation.menuOrder, ascending: true)
58+
let resultsController = ResultsController<StorageProductVariation>(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
59+
return resultsController
60+
}()
61+
62+
init(siteID: Int64,
63+
product: Product,
64+
storageManager: StorageManagerType = ServiceLocator.storageManager,
65+
stores: StoresManager = ServiceLocator.stores) {
66+
self.siteID = siteID
67+
self.product = product
68+
self.storageManager = storageManager
69+
self.stores = stores
70+
71+
configureSyncingCoordinator()
72+
configureProductVariationsResultsController()
73+
}
74+
75+
/// Select a product variation to add to the order
76+
///
77+
func selectProductOrVariation(_ productID: Int64) {
78+
// TODO: Add the selected product variation to the order
79+
}
80+
}
81+
82+
// MARK: - SyncingCoordinatorDelegate & Sync Methods
83+
extension AddProductVariationToOrderViewModel: SyncingCoordinatorDelegate {
84+
/// Sync product variations from remote.
85+
///
86+
func sync(pageNumber: Int, pageSize: Int, reason: String? = nil, onCompletion: ((Bool) -> Void)?) {
87+
transitionToSyncingState()
88+
let action = ProductVariationAction.synchronizeProductVariations(siteID: siteID,
89+
productID: product.productID,
90+
pageNumber: pageNumber,
91+
pageSize: pageSize) { [weak self] error in
92+
guard let self = self else { return }
93+
94+
if let error = error {
95+
DDLogError("⛔️ Error synchronizing product variations during order creation: \(error)")
96+
} else {
97+
self.updateProductVariationsResultsController()
98+
}
99+
100+
self.transitionToResultsUpdatedState()
101+
onCompletion?(error == nil)
102+
}
103+
stores.dispatch(action)
104+
}
105+
106+
/// Sync first page of product variations from remote if needed.
107+
///
108+
func syncFirstPage() {
109+
syncingCoordinator.synchronizeFirstPage()
110+
}
111+
112+
/// Sync next page of product variations from remote.
113+
///
114+
func syncNextPage() {
115+
let lastIndex = productVariationsResultsController.numberOfObjects - 1
116+
syncingCoordinator.ensureNextPageIsSynchronized(lastVisibleIndex: lastIndex)
117+
}
118+
}
119+
120+
// MARK: - Finite State Machine Management
121+
private extension AddProductVariationToOrderViewModel {
122+
/// Update state for sync from remote.
123+
///
124+
func transitionToSyncingState() {
125+
shouldShowScrollIndicator = true
126+
if productVariations.isEmpty {
127+
syncStatus = .firstPageSync
128+
}
129+
}
130+
131+
/// Update state after sync is complete.
132+
///
133+
func transitionToResultsUpdatedState() {
134+
shouldShowScrollIndicator = false
135+
syncStatus = productVariations.isNotEmpty ? .results: .empty
136+
}
137+
}
138+
139+
// MARK: - Configuration
140+
private extension AddProductVariationToOrderViewModel {
141+
/// Performs initial fetch from storage and updates sync status accordingly.
142+
///
143+
func configureProductVariationsResultsController() {
144+
updateProductVariationsResultsController()
145+
transitionToResultsUpdatedState()
146+
}
147+
148+
/// Fetches product variations from storage.
149+
///
150+
func updateProductVariationsResultsController() {
151+
do {
152+
try productVariationsResultsController.performFetch()
153+
} catch {
154+
DDLogError("⛔️ Error fetching product variations for new order: \(error)")
155+
}
156+
}
157+
158+
/// Setup: Syncing Coordinator
159+
///
160+
func configureSyncingCoordinator() {
161+
syncingCoordinator.delegate = self
162+
}
163+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,8 @@
11351135
CC078531266E706300BA9AC1 /* ErrorTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */; };
11361136
CC07860526736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */; };
11371137
CC13C0C9278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */; };
1138+
CC13C0CB278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */; };
1139+
CC13C0CD278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */; };
11381140
CC200BB127847DE300EC5884 /* OrderPaymentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC200BB027847DE300EC5884 /* OrderPaymentSection.swift */; };
11391141
CC254F2D26C17AB5005F3C82 /* BottomButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC254F2C26C17AB5005F3C82 /* BottomButtonView.swift */; };
11401142
CC254F3026C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC254F2F26C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift */; };
@@ -2720,6 +2722,8 @@
27202722
CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactory.swift; sourceTree = "<group>"; };
27212723
CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactoryTests.swift; sourceTree = "<group>"; };
27222724
CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductToOrderViewModelProtocol.swift; sourceTree = "<group>"; };
2725+
CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductVariationToOrderViewModel.swift; sourceTree = "<group>"; };
2726+
CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductVariationToOrderViewModelTests.swift; sourceTree = "<group>"; };
27232727
CC200BB027847DE300EC5884 /* OrderPaymentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderPaymentSection.swift; sourceTree = "<group>"; };
27242728
CC254F2C26C17AB5005F3C82 /* BottomButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomButtonView.swift; sourceTree = "<group>"; };
27252729
CC254F2F26C2A53D005F3C82 /* ShippingLabelAddNewPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddNewPackage.swift; sourceTree = "<group>"; };
@@ -6126,6 +6130,7 @@
61266130
children = (
61276131
CC53FB372755213900C4CA4F /* AddProductToOrder.swift */,
61286132
CC53FB3B2757EC7200C4CA4F /* AddProductToOrderViewModel.swift */,
6133+
CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */,
61296134
CC13C0C8278DE76A00C0B5B5 /* AddProductToOrderViewModelProtocol.swift */,
61306135
CC53FB3427551A6E00C4CA4F /* ProductRow.swift */,
61316136
CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */,
@@ -6142,6 +6147,7 @@
61426147
AEA622B627468790002A9B57 /* AddOrderCoordinatorTests.swift */,
61436148
CC53FB3D2758E2D500C4CA4F /* ProductRowViewModelTests.swift */,
61446149
CC53FB3F2759042600C4CA4F /* AddProductToOrderViewModelTests.swift */,
6150+
CC13C0CC278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift */,
61456151
);
61466152
path = "Order Creation";
61476153
sourceTree = "<group>";
@@ -8105,6 +8111,7 @@
81058111
02A9BCD62737F73C00159C79 /* JetpackBenefitItem.swift in Sources */,
81068112
CE0F17D222A8308900964A63 /* FancyAlertController+PurchaseNote.swift in Sources */,
81078113
E1BAAEA026BBECEF00F2C037 /* ButtonStyles.swift in Sources */,
8114+
CC13C0CB278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift in Sources */,
81088115
B59D1EEA2190AE96009D1978 /* StorageNote+Woo.swift in Sources */,
81098116
024DF3072372C18D006658FE /* AztecUIConfigurator.swift in Sources */,
81108117
0217399E2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift in Sources */,
@@ -9128,6 +9135,7 @@
91289135
A650BE872578E76600C655E0 /* MockStorageManager.swift in Sources */,
91299136
029700EF24FE38F000D242F8 /* ScrollWatcherTests.swift in Sources */,
91309137
0236BCA425087B660043EB43 /* ProductFormRemoteActionUseCaseTests.swift in Sources */,
9138+
CC13C0CD278E086D00C0B5B5 /* AddProductVariationToOrderViewModelTests.swift in Sources */,
91319139
DE26B5292775C76C00A2EA0A /* MockSyncingCoordinator.swift in Sources */,
91329140
DE126D0F26CA71E8007F901D /* ShippingLabelCustomsFormInputViewModelTests.swift in Sources */,
91339141
0277AE9B256CA8A200F45C4A /* AggregatedShippingLabelOrderItemsTests.swift in Sources */,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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 `Product` into storage
155+
func insert(_ readOnlyProduct: Yosemite.Product) {
156+
let product = storage.insertNewObject(ofType: StorageProduct.self)
157+
product.update(with: readOnlyProduct)
158+
}
159+
160+
/// Insert an array of `Product`s into storage
161+
func insert(_ readOnlyProducts: [Yosemite.Product]) {
162+
for readOnlyProduct in readOnlyProducts {
163+
let product = storage.insertNewObject(ofType: StorageProduct.self)
164+
product.update(with: readOnlyProduct)
165+
}
166+
}
167+
168+
/// Insert a `ProductVariation` into storage
169+
func insert(_ readOnlyProduct: Yosemite.ProductVariation) {
170+
let product = storage.insertNewObject(ofType: StorageProductVariation.self)
171+
product.update(with: readOnlyProduct)
172+
}
173+
174+
/// Insert an array of `ProductVariation`s into storage
175+
func insert(_ readOnlyProducts: [Yosemite.ProductVariation]) {
176+
for readOnlyProduct in readOnlyProducts {
177+
let product = storage.insertNewObject(ofType: StorageProductVariation.self)
178+
product.update(with: readOnlyProduct)
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)