Skip to content

Commit e95aadd

Browse files
authored
Merge pull request #8552 from woocommerce/issue/8490-create-variations
Variations: Generate All Variations Remotely
2 parents 3611a9d + 187d31b commit e95aadd

File tree

11 files changed

+318
-4
lines changed

11 files changed

+318
-4
lines changed

Networking/Networking.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@
197197
26B2F74924C55ACE0065CCC8 /* LeaderboardsRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2F74824C55ACE0065CCC8 /* LeaderboardsRemoteTests.swift */; };
198198
26B2F74B24C696C00065CCC8 /* LeaderboardRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2F74A24C696C00065CCC8 /* LeaderboardRow.swift */; };
199199
26B2F74D24C696E70065CCC8 /* LeaderboardRowContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2F74C24C696E70065CCC8 /* LeaderboardRowContent.swift */; };
200+
26BD9FCD2965EC3C004E0D15 /* product-variations-bulk-create.json in Resources */ = {isa = PBXBuildFile; fileRef = 26BD9FCC2965EC3C004E0D15 /* product-variations-bulk-create.json */; };
201+
26BD9FCF2965EE71004E0D15 /* ProductVariationsBulkCreateMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BD9FCE2965EE71004E0D15 /* ProductVariationsBulkCreateMapper.swift */; };
200202
26FB056C25F6CB9100A40B26 /* Fakes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26FB056B25F6CB9100A40B26 /* Fakes.framework */; };
201203
31054702262E04F700C5C02B /* RemotePaymentIntentMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31054701262E04F700C5C02B /* RemotePaymentIntentMapper.swift */; };
202204
31054706262E278100C5C02B /* RemotePaymentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31054705262E278100C5C02B /* RemotePaymentIntent.swift */; };
@@ -985,6 +987,8 @@
985987
26B6453F259BCDFE00EF3FB3 /* ProductAttributeTermMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductAttributeTermMapper.swift; sourceTree = "<group>"; };
986988
26B64543259BCE0F00EF3FB3 /* ProductAttributeTermListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductAttributeTermListMapper.swift; sourceTree = "<group>"; };
987989
26B6454D259BF81400EF3FB3 /* product-attribute-terms.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "product-attribute-terms.json"; sourceTree = "<group>"; };
990+
26BD9FCC2965EC3C004E0D15 /* product-variations-bulk-create.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-variations-bulk-create.json"; sourceTree = "<group>"; };
991+
26BD9FCE2965EE71004E0D15 /* ProductVariationsBulkCreateMapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductVariationsBulkCreateMapper.swift; sourceTree = "<group>"; };
988992
26E5A08725A66AFC000DF8F6 /* ProductAttributeTermRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductAttributeTermRemote.swift; sourceTree = "<group>"; };
989993
26E5A08B25A66FD3000DF8F6 /* ProductAttributeTermRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductAttributeTermRemoteTests.swift; sourceTree = "<group>"; };
990994
26FB056B25F6CB9100A40B26 /* Fakes.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Fakes.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -2233,6 +2237,7 @@
22332237
02C54CC524D3E937007D658F /* product-variations-load-all-manage-stock-two-states.json */,
22342238
020C907A24C6E108001E2BEB /* product-variation-update.json */,
22352239
09885C7F27C3FFD200910A62 /* product-variations-bulk-update.json */,
2240+
26BD9FCC2965EC3C004E0D15 /* product-variations-bulk-create.json */,
22362241
451274A525276C82009911FF /* product-variation.json */,
22372242
CE0A0F1E223998A00075ED8D /* products-load-all.json */,
22382243
2676F4CF290B0EC700C7A15B /* product-id-only.json */,
@@ -2434,6 +2439,7 @@
24342439
026CF61D237D6985009563D4 /* ProductVariationListMapper.swift */,
24352440
02C1CEF324C6A02B00703EBA /* ProductVariationMapper.swift */,
24362441
09EA564A27C75FCE00407D40 /* ProductVariationsBulkUpdateMapper.swift */,
2442+
26BD9FCE2965EE71004E0D15 /* ProductVariationsBulkCreateMapper.swift */,
24372443
451A9831260B9D2D0059D135 /* ShippingLabelPackagesMapper.swift */,
24382444
029BA53A255DFABD006171FD /* ShippingLabelPrintDataMapper.swift */,
24392445
021A84D9257DF92800BC71D1 /* ShippingLabelRefundMapper.swift */,
@@ -2887,6 +2893,7 @@
28872893
028CB71F2902589E00331C09 /* create-account-error-invalid-email.json in Resources */,
28882894
E137619929151C7400FD098F /* error-wp-rest-forbidden.json in Resources */,
28892895
31A451CE27863A2E00FE81AA /* stripe-account-wrong-json.json in Resources */,
2896+
26BD9FCD2965EC3C004E0D15 /* product-variations-bulk-create.json in Resources */,
28902897
028CB718290223CB00331C09 /* account-username-suggestions.json in Resources */,
28912898
0282DD91233A120A006A5FDB /* products-search-photo.json in Resources */,
28922899
45152825257A8B740076B03C /* product-attribute-update.json in Resources */,
@@ -3347,6 +3354,7 @@
33473354
B554FA912180BCFC00C54DFF /* NoteHash.swift in Sources */,
33483355
68F48B0D28E3B2E80045C15B /* WCAnalyticsCustomerMapper.swift in Sources */,
33493356
CE0A0F19223987DF0075ED8D /* ProductListMapper.swift in Sources */,
3357+
26BD9FCF2965EE71004E0D15 /* ProductVariationsBulkCreateMapper.swift in Sources */,
33503358
021C7BF723863D1800A3BCBD /* Encodable+Serialization.swift in Sources */,
33513359
0219B03923964BB3007DCD5E /* ProductShippingClassMapper.swift in Sources */,
33523360
7452387321124B7700A973CD /* AnyCodable.swift in Sources */,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Foundation
2+
3+
/// Mapper: ProductVariationsBulkCreateMapper
4+
///
5+
struct ProductVariationsBulkCreateMapper: Mapper {
6+
/// Site Identifier associated to the product variation that will be parsed.
7+
///
8+
/// We're injecting this field via `JSONDecoder.userInfo` because SiteID is not returned in any of the Product Variation Endpoints.
9+
///
10+
let siteID: Int64
11+
12+
/// Product Identifier associated to the product variation that will be parsed.
13+
///
14+
/// We're injecting this field via `JSONDecoder.userInfo` because ProductID is not returned in any of the Product Variation Endpoints.
15+
///
16+
let productID: Int64
17+
18+
/// (Attempts) to convert a dictionary into ProductVariations.
19+
///
20+
func map(response: Data) throws -> [ProductVariation] {
21+
let decoder = JSONDecoder()
22+
decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter)
23+
decoder.userInfo = [
24+
.siteID: siteID,
25+
.productID: productID
26+
]
27+
return try decoder.decode(ProductVariationsEnvelope.self, from: response).createdProductVariations
28+
}
29+
}
30+
31+
/// ProductVariationsEnvelope Disposable Entity
32+
///
33+
/// `Variations/batch` endpoint returns the requested create product variations document in a `create` key, nested in a `data` key.
34+
/// This entity allows us to do parse all the things with JSONDecoder.
35+
///
36+
private struct ProductVariationsEnvelope: Decodable {
37+
let createdProductVariations: [ProductVariation]
38+
39+
private enum CodingKeys: String, CodingKey {
40+
case data
41+
case create
42+
}
43+
44+
public init(from decoder: Decoder) throws {
45+
let container = try decoder.container(keyedBy: CodingKeys.self)
46+
47+
let nestedContainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .data)
48+
createdProductVariations = try nestedContainer.decode([ProductVariation].self, forKey: .create)
49+
}
50+
}

Networking/Networking/Remote/ProductVariationsRemote.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ public protocol ProductVariationsRemoteProtocol {
1616
productID: Int64,
1717
newVariation: CreateProductVariation,
1818
completion: @escaping (Result<ProductVariation, Error>) -> Void)
19+
func createProductVariations(siteID: Int64,
20+
productID: Int64,
21+
productVariations: [CreateProductVariation],
22+
completion: @escaping (Result<[ProductVariation], Error>) -> Void)
1923
func updateProductVariation(productVariation: ProductVariation, completion: @escaping (Result<ProductVariation, Error>) -> Void)
2024
func updateProductVariationImage(siteID: Int64,
2125
productID: Int64,
@@ -102,6 +106,31 @@ public class ProductVariationsRemote: Remote, ProductVariationsRemoteProtocol {
102106
}
103107
}
104108

109+
/// Creates the provided `ProductVariations`.
110+
///
111+
/// - Parameters:
112+
/// - siteID: Site which hosts the ProductVariations.
113+
/// - productID: Identifier of the Product.
114+
/// - productVariations: the ProductVariations to created remotely.
115+
/// - completion: Closure to be executed upon completion.
116+
///
117+
public func createProductVariations(siteID: Int64,
118+
productID: Int64,
119+
productVariations: [CreateProductVariation],
120+
completion: @escaping (Result<[ProductVariation], Error>) -> Void) {
121+
122+
do {
123+
let parameters = try productVariations.map { try $0.toDictionary() }
124+
let path = "\(Path.products)/\(productID)/variations/batch"
125+
let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: ["create": parameters])
126+
let mapper = ProductVariationsBulkCreateMapper(siteID: siteID, productID: productID)
127+
128+
enqueue(request, mapper: mapper, completion: completion)
129+
} catch {
130+
completion(.failure(error))
131+
}
132+
}
133+
105134
/// Updates a specific `ProductVariation`.
106135
///
107136
/// - Parameters:

Networking/NetworkingTests/Remote/ProductVariationsRemoteTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,25 @@ final class ProductVariationsRemoteTests: XCTestCase {
208208
XCTAssertTrue(try XCTUnwrap(result).isFailure)
209209
}
210210

211+
func test_create_product_variations_returns_parsed_variations() throws {
212+
// Given
213+
let remote = ProductVariationsRemote(network: network)
214+
network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations/batch", filename: "product-variations-bulk-create")
215+
216+
// When
217+
let result = waitFor { promise in
218+
remote.createProductVariations(siteID: self.sampleSiteID, productID: self.sampleProductID, productVariations: []) { result in
219+
promise(result)
220+
}
221+
}
222+
223+
// Then
224+
let sampleProductVariationID: Int64 = 2783
225+
let expectedVariations = [sampleProductVariation(siteID: sampleSiteID, productID: sampleProductID, id: sampleProductVariationID)]
226+
let createdVariations = try result.get()
227+
XCTAssertEqual(createdVariations, expectedVariations)
228+
}
229+
211230
// MARK: - Update ProductVariation
212231

213232
/// Verifies that updateProductVariation properly parses the `product-variation-update` sample response.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"data": {
3+
"create": [{
4+
"id": 2783,
5+
"date_created": "2020-06-12T22:36:02",
6+
"date_created_gmt": "2020-06-12T14:36:02",
7+
"date_modified": "2020-07-21T16:35:47",
8+
"date_modified_gmt": "2020-07-21T08:35:47",
9+
"description": "<p>Nutty chocolate marble, 99% and organic.</p>\n",
10+
"permalink": "https://chocolate.com/marble",
11+
"sku": "87%-strawberry-marble",
12+
"price": "14.99",
13+
"regular_price": "14.99",
14+
"sale_price": "",
15+
"date_on_sale_from": null,
16+
"date_on_sale_from_gmt": null,
17+
"date_on_sale_to": null,
18+
"date_on_sale_to_gmt": null,
19+
"on_sale": false,
20+
"status": "publish",
21+
"purchasable": true,
22+
"virtual": false,
23+
"downloadable": true,
24+
"downloads": [],
25+
"download_limit": -1,
26+
"download_expiry": 0,
27+
"tax_status": "taxable",
28+
"tax_class": "",
29+
"manage_stock": "parent",
30+
"stock_quantity": 16,
31+
"stock_status": "instock",
32+
"backorders": "notify",
33+
"backorders_allowed": true,
34+
"backordered": false,
35+
"weight": "2.5",
36+
"dimensions": {
37+
"length": "10",
38+
"width": "2.5",
39+
"height": ""
40+
},
41+
"shipping_class": "",
42+
"shipping_class_id": 0,
43+
"image": {
44+
"id": 2432,
45+
"date_created": "2020-03-13T19:13:57",
46+
"date_created_gmt": "2020-03-13T03:13:57",
47+
"date_modified": "2020-07-22T00:29:16",
48+
"date_modified_gmt": "2020-07-21T08:29:16",
49+
"src": "https://i0.wp.com/funtestingusa.wpcomstaging.com/wp-content/uploads/2019/11/img_0002-1.jpeg?fit=4288%2C2848&ssl=1",
50+
"name": "DSC_0010",
51+
"alt": ""
52+
},
53+
"attributes": [{
54+
"id": 0,
55+
"name": "Darkness",
56+
"option": "87%"
57+
},
58+
{
59+
"id": 0,
60+
"name": "Flavor",
61+
"option": "strawberry"
62+
},
63+
{
64+
"id": 0,
65+
"name": "Shape",
66+
"option": "marble"
67+
}
68+
],
69+
"menu_order": 1,
70+
"meta_data": [],
71+
"_links": {
72+
"self": [{
73+
"href": "https://example.com/wp-json/wc/v3/products/846/variations/2783"
74+
}],
75+
"collection": [{
76+
"href": "https://example.com/wp-json/wc/v3/products/846/variations"
77+
}],
78+
"up": [{
79+
"href": "https://example.com/wp-json/wc/v3/products/846"
80+
}]
81+
}
82+
}]
83+
}
84+
}

WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationsViewModel.swift

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,26 @@ final class ProductVariationsViewModel {
2828
/// Generates all missing variations for a product. Up to 100 variations.
2929
///
3030
func generateAllVariations(for product: Product, onCompletion: @escaping (Result<Void, GenerationError>) -> Void) {
31-
let action = ProductVariationAction.synchronizeAllProductVariations(siteID: product.siteID, productID: product.productID) { result in
31+
let action = ProductVariationAction.synchronizeAllProductVariations(siteID: product.siteID, productID: product.productID) { [weak self] result in
3232
// TODO: Fetch this via a results controller
3333
let existingVariations = ServiceLocator.storageManager.viewStorage.loadProductVariations(siteID: product.siteID, productID: product.productID)?
3434
.map {
3535
$0.toReadOnly()
3636
} ?? []
3737

38-
// TEMP
3938
let variationsToGenerate = ProductVariationGenerator.generateVariations(for: product, excluding: existingVariations)
40-
print("Variations to Generate: \(variationsToGenerate.count)")
4139

4240
// Guard for 100 variation limit
4341
guard variationsToGenerate.count <= 100 else {
4442
return onCompletion(.failure(.tooManyVariations(variationCount: variationsToGenerate.count)))
4543
}
4644

47-
onCompletion(.success(()))
45+
guard variationsToGenerate.count > 0 else {
46+
// TODO: Inform user that no variation will be created
47+
return onCompletion(.success(()))
48+
}
49+
50+
self?.createVariationsRemotely(for: product, variations: variationsToGenerate, onCompletion: onCompletion)
4851

4952
}
5053
stores.dispatch(action)
@@ -62,6 +65,25 @@ final class ProductVariationsViewModel {
6265
}
6366
formType = .edit
6467
}
68+
69+
/// Creates the provided variations remotely.
70+
///
71+
private func createVariationsRemotely(for product: Product,
72+
variations: [CreateProductVariation],
73+
onCompletion: @escaping (Result<Void, GenerationError>) -> Void) {
74+
let action = ProductVariationAction.createProductVariations(siteID: product.siteID,
75+
productID: product.productID,
76+
productVariations: variations, onCompletion: { result in
77+
switch result {
78+
case .success:
79+
onCompletion(.success(()))
80+
case .failure:
81+
// TODO: Log Error
82+
break
83+
}
84+
})
85+
stores.dispatch(action)
86+
}
6587
}
6688

6789
/// TODO: This functions need to be converted to computed variables, once the `ViewController` is refactored to use `MMVM`.

WooCommerce/WooCommerceTests/ViewRelated/Products/Variations/ProductVariationsViewModelTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,39 @@ final class ProductVariationsViewModelTests: XCTestCase {
146146

147147
// Then
148148
XCTAssertEqual(error, .tooManyVariations(variationCount: 125))
149+
}
150+
151+
func test_generating_less_than_100_variations_invokes_create_action() {
152+
// Given
153+
let product = Product.fake().copy(attributes: [
154+
ProductAttribute.fake().copy(attributeID: 1, name: "Size", options: ["XS", "S", "M", "L", "XL"]),
155+
ProductAttribute.fake().copy(attributeID: 2, name: "Color", options: ["Red", "Green", "Blue", "White", "Black"]),
156+
])
157+
158+
let stores = MockStoresManager(sessionManager: SessionManager.makeForTesting())
159+
stores.whenReceivingAction(ofType: ProductVariationAction.self) { action in
160+
switch action {
161+
case .synchronizeAllProductVariations(_, _, let onCompletion):
162+
onCompletion(.success(()))
163+
case .createProductVariations(_, _, _, let onCompletion):
164+
onCompletion(.success([]))
165+
default:
166+
break
167+
}
168+
}
149169

170+
let viewModel = ProductVariationsViewModel(stores: stores, formType: .edit)
171+
172+
// When
173+
let succeeded = waitFor { promise in
174+
viewModel.generateAllVariations(for: product) { result in
175+
if case .success = result {
176+
promise(true)
177+
}
178+
}
179+
}
180+
181+
// Then
182+
XCTAssertTrue(succeeded)
150183
}
151184
}

Yosemite/Yosemite/Actions/ProductVariationAction.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ public enum ProductVariationAction: Action {
2626
newVariation: CreateProductVariation,
2727
onCompletion: (Result<ProductVariation, Error>) -> Void)
2828

29+
/// Creates the provided ProductVariations.
30+
///
31+
case createProductVariations(siteID: Int64,
32+
productID: Int64,
33+
productVariations: [CreateProductVariation],
34+
onCompletion: (Result<[ProductVariation], Error>) -> Void)
35+
2936
/// Updates a specified ProductVariation.
3037
///
3138
case updateProductVariation(productVariation: ProductVariation, onCompletion: (Result<ProductVariation, ProductUpdateError>) -> Void)

0 commit comments

Comments
 (0)