Skip to content

Commit cab93a3

Browse files
authored
Merge pull request #8616 from woocommerce/issue/8534-generate-from-attributes
Variations: Create all variations from no attributes empty state screen.
2 parents 80bd8a6 + 7af704a commit cab93a3

File tree

9 files changed

+449
-361
lines changed

9 files changed

+449
-361
lines changed

WooCommerce/Classes/ViewRelated/Products/Variations/Edit Attributes/EditAttributesViewController.swift

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ final class EditAttributesViewController: UIViewController {
1616

1717
/// Assign this closure to be notified after a variation is created.
1818
///
19-
var onVariationCreation: ((Product, ProductVariation) -> Void)?
19+
var onVariationCreation: ((Product) -> Void)?
2020

2121
/// Assign this closure to be notified after an attribute is created or updated.
2222
///
@@ -110,13 +110,27 @@ extension EditAttributesViewController {
110110
details: Localization.attributesAddedInfo,
111111
buttonTitle: Localization.generateButtonTitle,
112112
onTap: { [weak self] _ in
113-
self?.createVariation()
113+
self?.presentGenerateVariationOptions()
114114
}
115115
))
116116
createVariationViewController.title = Localization.generateTitle
117117
show(createVariationViewController, sender: self)
118118
}
119119

120+
/// Displays a bottom sheet allowing the merchant to choose whether to generate one variation or to generate all variations.
121+
///
122+
private func presentGenerateVariationOptions() {
123+
let presenter = GenerateVariationsOptionsPresenter(baseViewController: self)
124+
presenter.presentGenerationOptions(sourceView: self.view) { [weak self] selectedOption in
125+
switch selectedOption {
126+
case .single:
127+
self?.createVariation()
128+
case .all:
129+
self?.generateAllVariations()
130+
}
131+
}
132+
}
133+
120134
/// Creates a variation and presents a loading screen while it is created.
121135
///
122136
private func createVariation() {
@@ -126,12 +140,12 @@ extension EditAttributesViewController {
126140
viewModel.generateVariation { [onVariationCreation, noticePresenter] result in
127141
progressViewController.dismiss(animated: true)
128142

129-
guard let (product, variation) = try? result.get() else {
143+
guard let (product, _) = try? result.get() else {
130144
noticePresenter.enqueue(notice: .init(title: Localization.generateVariationError, feedbackType: .error))
131145
return
132146
}
133147

134-
onVariationCreation?(product, variation)
148+
onVariationCreation?(product)
135149
}
136150
}
137151

@@ -161,6 +175,25 @@ extension EditAttributesViewController {
161175
}
162176
show(editViewController, sender: true)
163177
}
178+
179+
/// Generates all possible variations for the product attributes.
180+
///
181+
private func generateAllVariations() {
182+
let presenter = GenerateAllVariationsPresenter(baseViewController: self)
183+
viewModel.generateAllVariations() { [weak self, presenter] currentState in
184+
// Perform Presentation Actions
185+
presenter.handleStateChanges(state: currentState)
186+
187+
// Perform other side effects
188+
switch currentState {
189+
case .finished(let variationsCreated, let updatedProduct):
190+
if variationsCreated {
191+
self?.onVariationCreation?(updatedProduct)
192+
}
193+
default: break
194+
}
195+
}
196+
}
164197
}
165198

166199
// MARK: - UITableView conformance

WooCommerce/Classes/ViewRelated/Products/Variations/Edit Attributes/EditAttributesViewModel.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ extension EditAttributesViewModel {
5858
func productAttributeAtIndex(_ index: Int) -> ProductAttribute {
5959
return product.attributesForVariations[index]
6060
}
61+
62+
/// Generates all missing variations for a product. Up to 100 variations.
63+
/// Parameters:
64+
/// - `onStateChanged`: Closure invoked every time there is a significant state change in the generation process.
65+
///
66+
func generateAllVariations(onStateChanged: @escaping (GenerateAllVariationsUseCase.State) -> Void) {
67+
let useCase = GenerateAllVariationsUseCase(stores: stores)
68+
useCase.generateAllVariations(for: product, onStateChanged: onStateChanged)
69+
}
6170
}
6271

6372
// MARK: Helpers

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ final class GenerateAllVariationsPresenter {
2020

2121
/// Respond to necessary presentation changes.
2222
///
23-
func handleStateChanges(state: ProductVariationsViewModel.GenerationState) {
23+
func handleStateChanges(state: GenerateAllVariationsUseCase.State) {
2424
switch state {
2525
case .fetching:
2626
presentFetchingIndicator()
@@ -30,7 +30,7 @@ final class GenerateAllVariationsPresenter {
3030
presentCreatingIndicator()
3131
case .canceled:
3232
dismissBlockingIndicator()
33-
case .finished(let variationsCreated):
33+
case .finished(let variationsCreated, _):
3434
dismissBlockingIndicator()
3535
if variationsCreated {
3636
presentVariationsCreatedNotice()
@@ -50,7 +50,7 @@ final class GenerateAllVariationsPresenter {
5050
private extension GenerateAllVariationsPresenter {
5151
/// Informs the merchant about errors that happen during the variation generation
5252
///
53-
private func presentGenerationError(_ error: ProductVariationsViewModel.GenerationError) {
53+
private func presentGenerationError(_ error: GenerateAllVariationsUseCase.GenerationError) {
5454
let notice = Notice(title: error.errorTitle, message: error.errorDescription)
5555
noticePresenter.enqueue(notice: notice)
5656
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import Foundation
2+
import Yosemite
3+
4+
/// Generates and creates all variations needed for a product.
5+
///
6+
final class GenerateAllVariationsUseCase {
7+
8+
/// Stores dependency. Needed to generate variations
9+
///
10+
private let stores: StoresManager
11+
12+
init(stores: StoresManager) {
13+
self.stores = stores
14+
}
15+
16+
/// Generates all missing variations for a product. Up to 100 variations.
17+
/// Parameters:
18+
/// - `Product`: Product on which we will be creating the variations
19+
/// - `onStateChanged`: Closure invoked every time there is a significant state change in the generation process.
20+
///
21+
func generateAllVariations(for product: Product, onStateChanged: @escaping (State) -> Void) {
22+
23+
// Fetch Previous variations
24+
onStateChanged(.fetching)
25+
fetchAllVariations(of: product) { result in
26+
switch result {
27+
case .success(let existingVariations):
28+
29+
// Generate variations locally
30+
let variationsToGenerate = ProductVariationGenerator.generateVariations(for: product, excluding: existingVariations)
31+
32+
// Guard for 100 variation limit
33+
guard variationsToGenerate.count <= 100 else {
34+
return onStateChanged(.error(.tooManyVariations(variationCount: variationsToGenerate.count)))
35+
}
36+
37+
// Guard for no variations to generate
38+
guard variationsToGenerate.count > 0 else {
39+
return onStateChanged(.finished(false, product))
40+
}
41+
42+
// Confirm generation with merchant
43+
onStateChanged(.confirmation(variationsToGenerate.count, { confirmed in
44+
45+
guard confirmed else {
46+
return onStateChanged(.canceled)
47+
}
48+
49+
// Create variations remotely
50+
onStateChanged(.creating)
51+
self.createVariationsRemotely(for: product, variations: variationsToGenerate) { result in
52+
switch result {
53+
case .success(let generatedVariations):
54+
55+
// Updates the current product with the up-to-date list of variations IDs.
56+
// This is needed in order to reflect variations count changes back to other screens.
57+
let updatedProduct = product.copy(variations: product.variations + generatedVariations.map { $0.productVariationID })
58+
onStateChanged(.finished(true, updatedProduct))
59+
60+
case .failure(let error):
61+
onStateChanged(.error(error))
62+
}
63+
}
64+
}))
65+
66+
case .failure(let error):
67+
onStateChanged(.error(.unableToFetchVariations))
68+
DDLogError("⛔️ Failed to create variations: \(error)")
69+
}
70+
}
71+
}
72+
}
73+
74+
// MARK: Helper Methods
75+
//
76+
private extension GenerateAllVariationsUseCase {
77+
/// Fetches all remote variations.
78+
///
79+
private func fetchAllVariations(of product: Product, onCompletion: @escaping (Result<[ProductVariation], Error>) -> Void) {
80+
let action = ProductVariationAction.synchronizeAllProductVariations(siteID: product.siteID, productID: product.productID, onCompletion: onCompletion)
81+
stores.dispatch(action)
82+
}
83+
84+
/// Creates the provided variations remotely.
85+
///
86+
private func createVariationsRemotely(for product: Product,
87+
variations: [CreateProductVariation],
88+
onCompletion: @escaping (Result<[ProductVariation], GenerationError>) -> Void) {
89+
let action = ProductVariationAction.createProductVariations(siteID: product.siteID,
90+
productID: product.productID,
91+
productVariations: variations, onCompletion: { result in
92+
switch result {
93+
case .success(let variations):
94+
onCompletion(.success(variations))
95+
case .failure(let error):
96+
onCompletion(.failure(.unableToCreateVariations))
97+
DDLogError("⛔️ Failed to create variations: \(error)")
98+
}
99+
})
100+
stores.dispatch(action)
101+
}
102+
}
103+
104+
// MARK: Definitions
105+
///
106+
extension GenerateAllVariationsUseCase {
107+
/// Type that represents the possible states while all variations are being created.
108+
///
109+
enum State {
110+
/// State while previous variations are being fetched
111+
///
112+
case fetching
113+
114+
/// State to allow merchant to confirm the variation generation
115+
///
116+
case confirmation(_ numberOfVariations: Int, _ onCompletion: (_ confirmed: Bool) -> Void)
117+
118+
/// State while the variations are being created remotely
119+
///
120+
case creating
121+
122+
///State when the merchant decides to not continue with the generation process.
123+
///
124+
case canceled
125+
126+
/// State when the the process is finished. `variationsCreated` indicates if variations were created or not.
127+
/// `updatedProduct` contains the original product with the new generated variation ids in it's variations array.
128+
///
129+
case finished(_ variationsCreated: Bool, _ updatedProduct: Product)
130+
131+
/// Error state in any part of the process.
132+
///
133+
case error(GenerationError)
134+
}
135+
136+
/// Type to represent known generation errors
137+
///
138+
enum GenerationError: LocalizedError, Equatable {
139+
case unableToFetchVariations
140+
case unableToCreateVariations
141+
case tooManyVariations(variationCount: Int)
142+
143+
var errorTitle: String {
144+
switch self {
145+
case .unableToFetchVariations:
146+
return NSLocalizedString("Unable to fetch variations", comment: "Error title for when we can't fetch existing variations.")
147+
case .unableToCreateVariations:
148+
return NSLocalizedString("Unable to create variations", comment: "Error title for when we can't create variations remotely.")
149+
case .tooManyVariations:
150+
return NSLocalizedString("Generation limit exceeded", comment: "Error title for when there are too many variations to generate.")
151+
}
152+
}
153+
154+
var errorDescription: String? {
155+
switch self {
156+
case .unableToFetchVariations:
157+
return NSLocalizedString("Something went wrong, please try again later.", comment: "Error message for when we can't fetch existing variations.")
158+
case .unableToCreateVariations:
159+
return NSLocalizedString("Something went wrong, please try again later.", comment: "Error message for when we can't create variations remotely")
160+
case .tooManyVariations(let variationCount):
161+
let format = NSLocalizedString(
162+
"Currently creation is supported for 100 variations maximum. Generating variations for this product would create %d variations.",
163+
comment: "Error description for when there are too many variations to generate."
164+
)
165+
return String.localizedStringWithFormat(format, variationCount)
166+
}
167+
}
168+
}
169+
}

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ private extension ProductVariationsViewController {
475475

476476
let editAttributesViewModel = EditAttributesViewModel(product: product, allowVariationCreation: allowVariationCreation)
477477
let editAttributeViewController = EditAttributesViewController(viewModel: editAttributesViewModel)
478-
editAttributeViewController.onVariationCreation = { [weak self] (updatedProduct, _) in
478+
editAttributeViewController.onVariationCreation = { [weak self] updatedProduct in
479479
self?.product = updatedProduct
480480
self?.onFirstVariationCreated()
481481
}
@@ -508,7 +508,11 @@ private extension ProductVariationsViewController {
508508
/// Presents a notice alerting that the variation was created and navigates back to the `initialViewController` if possible.
509509
///
510510
private func onFirstVariationCreated() {
511-
noticePresenter.enqueue(notice: .init(title: Localization.variationCreated, feedbackType: .success))
511+
// Only show a notice when one variation is created.
512+
// When creating multiple variations, the notice presentation is handled on `GenerateAllVariationsPresenter`
513+
if product.variations.count == 1 {
514+
noticePresenter.enqueue(notice: .init(title: Localization.variationCreated, feedbackType: .success))
515+
}
512516

513517
guard let initialViewController = initialViewController else {
514518
navigationController?.popViewController(animated: true)
@@ -610,13 +614,6 @@ private extension ProductVariationsViewController {
610614
}
611615
}
612616
}
613-
614-
/// Updates the current product with the up-to-date list of variations IDs.
615-
/// This is needed in order to reflect variations count changes back to this and to other screens.
616-
///
617-
private func updateProductVariationCount() {
618-
self.product = product.copy(variations: resultsController.fetchedObjects.map { $0.productVariationID })
619-
}
620617
}
621618

622619
// MARK: - Placeholders
@@ -700,9 +697,9 @@ extension ProductVariationsViewController: SyncingCoordinatorDelegate {
700697

701698
// Perform other side effects
702699
switch currentState {
703-
case .finished(let variationsCreated):
700+
case .finished(let variationsCreated, let updatedProduct):
704701
if variationsCreated {
705-
self?.updateProductVariationCount()
702+
self?.product = updatedProduct
706703
}
707704
default: break
708705
}

0 commit comments

Comments
 (0)