diff --git a/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationGenerator.swift b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationGenerator.swift new file mode 100644 index 00000000000..4245c06c6c5 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationGenerator.swift @@ -0,0 +1,76 @@ +import Foundation +import Yosemite + +/// Generates all possible variations from a product attributes +/// +struct ProductVariationGenerator { + + /// Group a colection of attribute options. + /// EG: [Size: Large, Color: Black, Fabric: Cotton] + /// + private struct Combination: Hashable { + let options: [Option] + } + + /// Represents an attribute option. + /// EG: Size: Large + /// + private struct Option: Hashable { + let attributeID: Int64 + let attributeName: String + let value: String + } + + /// Generates all possible variations from a product attributes. + /// Additionally it excludes variations that already exists in the `variations` parameter. + /// + static func generateVariations(for product: Product, excluding variations: [ProductVariation]) -> [CreateProductVariation] { + let allCombinations = getCombinations(from: product) + let uniqueCombinations = filterExistingCombinations(allCombinations, existing: variations) + return buildVariations(from: uniqueCombinations, for: product) + } + + /// Generates all posible combination for a product attributes. + /// + private static func getCombinations(from product: Product) -> [Combination] { + // Iterates through attributes while eceiving the previous combinations list. + product.attributes.reduce([Combination(options: [])]) { combinations, attribute in + combinations.flatMap { combination in + // When receiving a previous combination list, we add each attribute to each previous combination util we finish with them. + attribute.options.map { option in + Combination(options: combination.options + [Option(attributeID: attribute.attributeID, attributeName: attribute.name, value: option)]) + } + } + } + } + + /// Removes the provided variations from the given combinations array. + /// + private static func filterExistingCombinations(_ combinations: [Combination], existing variations: [ProductVariation]) -> [Combination] { + // Convert variations into combinations + let existingCombinations = variations.map { existingVariation in + let options = existingVariation.attributes.map { attibute in + Option(attributeID: attibute.id, attributeName: attibute.name, value: attibute.option) + } + return Combination(options: options) + } + + // Filter existing combinations. + let existingSet = Set(existingCombinations) + return combinations.filter { combination in + !existingSet.contains(combination) + } + } + + /// Convert the provided combinations into `[CreateProductVariation]` types that are consumed by our Yosemite stores. + /// + private static func buildVariations(from combinations: [Combination], for product: Product) -> [CreateProductVariation] { + combinations.map { combination in + let attributes = combination.options.map { option in + ProductVariationAttribute(id: option.attributeID, name: option.attributeName, option: option.value) + } + // Setting a regular price is not required when creating a variation. + return CreateProductVariation(regularPrice: "", attributes: attributes) + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationsViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationsViewModel.swift index 1bc146b176b..c2d828ec67a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Variations/ProductVariationsViewModel.swift @@ -29,15 +29,20 @@ final class ProductVariationsViewModel { /// func generateAllVariations(for product: Product) { let action = ProductVariationAction.synchronizeAllProductVariations(siteID: product.siteID, productID: product.productID) { result in - // Temp - let fetched = ServiceLocator.storageManager.viewStorage.loadProductVariations(siteID: product.siteID, productID: product.productID) - print("Synchronized \(fetched?.count ?? 0) variations") + // TODO: Fetch this via a results controller + let existingVariations = ServiceLocator.storageManager.viewStorage.loadProductVariations(siteID: product.siteID, productID: product.productID)? + .map { + $0.toReadOnly() + } ?? [] + + // TEMP + let variationsToGenerate = ProductVariationGenerator.generateVariations(for: product, excluding: existingVariations) + print("Variations to Generate: \(variationsToGenerate.count)") + } stores.dispatch(action) // TODO: - // - Generate all variations locally - // - Substract already created variations // - Alert if there are more than 100 variations to create // - Create variations remotely } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 495f9834394..fad2fa3d77b 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -596,6 +596,7 @@ 262C921F26EEF8B100011F92 /* Binding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262C921E26EEF8B100011F92 /* Binding.swift */; }; 262C922126F1370000011F92 /* StorePickerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262C922026F1370000011F92 /* StorePickerError.swift */; }; 26309F17277D0AEA0012797F /* SafeAreaInsetsKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26309F16277D0AEA0012797F /* SafeAreaInsetsKey.swift */; }; + 263C4CC02963784900CA7E05 /* ProductVariationGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263C4CBF2963784900CA7E05 /* ProductVariationGenerator.swift */; }; 263E37E12641AD8300260D3B /* Codegen in Frameworks */ = {isa = PBXBuildFile; productRef = 263E37E02641AD8300260D3B /* Codegen */; }; 263E37E22641AD8300260D3B /* Codegen in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 263E37E02641AD8300260D3B /* Codegen */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 263E38462641FF3400260D3B /* Codegen in Frameworks */ = {isa = PBXBuildFile; productRef = 263E38452641FF3400260D3B /* Codegen */; }; @@ -632,6 +633,7 @@ 2676F4CC2908284800C7A15B /* ProductCreationTypeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2676F4CB2908284800C7A15B /* ProductCreationTypeCommand.swift */; }; 26771A14256FFA8700EE030E /* IssueRefundCoordinatingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26771A13256FFA8700EE030E /* IssueRefundCoordinatingController.swift */; }; 2678897C270E6E8B00BD249E /* SimplePaymentsAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2678897B270E6E8B00BD249E /* SimplePaymentsAmount.swift */; }; + 267D6882296485850072ED0C /* ProductVariationGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267D6881296485850072ED0C /* ProductVariationGeneratorTests.swift */; }; 2687165524D21BC80042F6AE /* SurveySubmittedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2687165324D21BC80042F6AE /* SurveySubmittedViewController.swift */; }; 2687165624D21BC80042F6AE /* SurveySubmittedViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2687165424D21BC80042F6AE /* SurveySubmittedViewController.xib */; }; 2687165A24D350C20042F6AE /* SurveyCoordinatingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2687165924D350C20042F6AE /* SurveyCoordinatingController.swift */; }; @@ -2649,6 +2651,7 @@ 262C921E26EEF8B100011F92 /* Binding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.swift; sourceTree = ""; }; 262C922026F1370000011F92 /* StorePickerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePickerError.swift; sourceTree = ""; }; 26309F16277D0AEA0012797F /* SafeAreaInsetsKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeAreaInsetsKey.swift; sourceTree = ""; }; + 263C4CBF2963784900CA7E05 /* ProductVariationGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationGenerator.swift; sourceTree = ""; }; 263EB408242C58EA00F3A15F /* ProductFormActionsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormActionsFactoryTests.swift; sourceTree = ""; }; 2647F7B429280A7F00D59FDF /* AnalyticsHubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHubView.swift; sourceTree = ""; }; 2647F7B9292BE2F900D59FDF /* AnalyticsReportCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsReportCard.swift; sourceTree = ""; }; @@ -2681,6 +2684,7 @@ 26771A13256FFA8700EE030E /* IssueRefundCoordinatingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueRefundCoordinatingController.swift; sourceTree = ""; }; 2678897B270E6E8B00BD249E /* SimplePaymentsAmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePaymentsAmount.swift; sourceTree = ""; }; 267CFE1824435A5500AF3A13 /* ProductCategoryViewModelBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryViewModelBuilderTests.swift; sourceTree = ""; }; + 267D6881296485850072ED0C /* ProductVariationGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationGeneratorTests.swift; sourceTree = ""; }; 2687165324D21BC80042F6AE /* SurveySubmittedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveySubmittedViewController.swift; sourceTree = ""; }; 2687165424D21BC80042F6AE /* SurveySubmittedViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SurveySubmittedViewController.xib; sourceTree = ""; }; 2687165924D350C20042F6AE /* SurveyCoordinatingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyCoordinatingController.swift; sourceTree = ""; }; @@ -4768,6 +4772,7 @@ 0202B68C23876BC100F3EBE0 /* ProductsTabProductViewModel+ProductVariation.swift */, 26F65C9725DEDAF0008FAE29 /* GenerateVariationUseCase.swift */, 269A2F46295CC683000828A8 /* GenerateVariationsSelectorCommand.swift */, + 263C4CBF2963784900CA7E05 /* ProductVariationGenerator.swift */, 4515262B2577D48D0076B03C /* Add Attributes */, AEDDDA0825CA9C0A0077F9B2 /* Edit Attributes */, ); @@ -7811,6 +7816,7 @@ 09C6A26027C01151001FAD73 /* Bulk Update */, CCD2E68825DD52C100BD975D /* ProductVariationsViewModelTests.swift */, 26F65C9D25DEDE67008FAE29 /* GenerateVariationUseCaseTests.swift */, + 267D6881296485850072ED0C /* ProductVariationGeneratorTests.swift */, ); path = Variations; sourceTree = ""; @@ -10134,6 +10140,7 @@ 028FA466257E021100F88A48 /* RefundShippingLabelViewModel.swift in Sources */, CCC284112768C18500F6CC8B /* ProductInOrder.swift in Sources */, DE2FE5882925DD950018040A /* JetpackInstallHeaderView.swift in Sources */, + 263C4CC02963784900CA7E05 /* ProductVariationGenerator.swift in Sources */, B59D49CD219B587E006BF0AD /* UILabel+OrderStatus.swift in Sources */, 265BCA0C2430E741004E53EE /* ProductCategoryTableViewCell.swift in Sources */, 02ACD25A2852E11700EC928E /* CloseAccountCoordinator.swift in Sources */, @@ -11277,6 +11284,7 @@ CE4DA5C821DD759400074607 /* CurrencyFormatterTests.swift in Sources */, DE61979528A25842005E4362 /* StorePickerViewModelTests.swift in Sources */, B57C745120F56EE900EEFC87 /* UITableViewCellHelpersTests.swift in Sources */, + 267D6882296485850072ED0C /* ProductVariationGeneratorTests.swift in Sources */, 0225C42824768A4C00C5B4F0 /* FilterProductListViewModelTests.swift in Sources */, D85136C9231E12B600DD0539 /* ReviewViewModelTests.swift in Sources */, 57C5FF7C25091DE50074EC26 /* OrderListSyncActionUseCaseTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Variations/ProductVariationGeneratorTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Variations/ProductVariationGeneratorTests.swift new file mode 100644 index 00000000000..fd3d867d9e2 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Variations/ProductVariationGeneratorTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import WooCommerce +@testable import Yosemite + +final class ProductVariationGeneratorTests: XCTestCase { + + func test_all_variations_are_generated_correctly() { + // Given + let product = Product.fake().copy(attributes: [ + ProductAttribute.fake().copy(attributeID: 1, name: "Size", options: ["S", "M"]), + ProductAttribute.fake().copy(attributeID: 2, name: "Color", options: ["Red", "Green"]), + ProductAttribute.fake().copy(attributeID: 3, name: "Fabric", options: ["Cotton", "Nylon"]), + ]) + + // When + let variations = ProductVariationGenerator.generateVariations(for: product, excluding: []) + + // Then + XCTAssertEqual(variations, [ + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + ]) + } + + func test_existing_variations_are_excluded_correctly() { + // Given + let product = Product.fake().copy(attributes: [ + ProductAttribute.fake().copy(attributeID: 1, name: "Size", options: ["S", "M"]), + ProductAttribute.fake().copy(attributeID: 2, name: "Color", options: ["Red", "Green"]), + ProductAttribute.fake().copy(attributeID: 3, name: "Fabric", options: ["Cotton", "Nylon"]), + ]) + + let existingVariations = [ + ProductVariation.fake().copy(attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Cotton"), + ]), + ProductVariation.fake().copy(attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Nylon"), + ]) + ] + + // When + let variations = ProductVariationGenerator.generateVariations(for: product, excluding: existingVariations) + + // Then + XCTAssertEqual(variations, [ + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "S"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Cotton") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Red"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + CreateProductVariation(regularPrice: "", attributes: [ + .init(id: 1, name: "Size", option: "M"), + .init(id: 2, name: "Color", option: "Green"), + .init(id: 3, name: "Fabric", option: "Nylon") + ]), + ]) + } +}