Browse Source

feat(server): Implement generating of ProductVariants

Michael Bromley 7 years ago
parent
commit
2d7dd7d399

+ 50 - 50
admin-ui/src/app/data/types/gql-generated-types.ts

@@ -293,56 +293,6 @@ export interface RemoveOptionGroupFromProductVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
-// ====================================================
-// GraphQL query operation: GetNetworkStatus
-// ====================================================
-
-export interface GetNetworkStatus_networkStatus {
-    __typename: 'NetworkStatus';
-    inFlightRequests: number;
-}
-
-export interface GetNetworkStatus {
-    networkStatus: GetNetworkStatus_networkStatus;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetUserStatus
-// ====================================================
-
-export interface GetUserStatus_userStatus {
-    __typename: 'UserStatus';
-    username: string;
-    isLoggedIn: boolean;
-    loginTime: string;
-}
-
-export interface GetUserStatus {
-    userStatus: GetUserStatus_userStatus;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetUiState
-// ====================================================
-
-export interface GetUiState_uiState {
-    __typename: 'UiState';
-    language: LanguageCode;
-}
-
-export interface GetUiState {
-    uiState: GetUiState_uiState;
-}
-
-/* tslint:disable */
-// This file was automatically generated and should not be edited.
-
 // ====================================================
 // GraphQL query operation: GetProductWithVariants
 // ====================================================
@@ -480,6 +430,56 @@ export interface GetProductOptionGroupsVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL query operation: GetNetworkStatus
+// ====================================================
+
+export interface GetNetworkStatus_networkStatus {
+    __typename: 'NetworkStatus';
+    inFlightRequests: number;
+}
+
+export interface GetNetworkStatus {
+    networkStatus: GetNetworkStatus_networkStatus;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: GetUserStatus
+// ====================================================
+
+export interface GetUserStatus_userStatus {
+    __typename: 'UserStatus';
+    username: string;
+    isLoggedIn: boolean;
+    loginTime: string;
+}
+
+export interface GetUserStatus {
+    userStatus: GetUserStatus_userStatus;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: GetUiState
+// ====================================================
+
+export interface GetUiState_uiState {
+    __typename: 'UiState';
+    language: LanguageCode;
+}
+
+export interface GetUiState {
+    uiState: GetUiState_uiState;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL fragment: ProductWithVariants
 // ====================================================

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 22 - 16
server/mock-data/mock-data-client.service.ts

@@ -1,6 +1,7 @@
 import * as faker from 'faker/locale/en_GB';
 import { request } from 'graphql-request';
 
+import { ID } from '../../shared/shared-types';
 import { PasswordService } from '../src/auth/password.service';
 import { VendureConfig } from '../src/config/vendure-config';
 import { CreateAddressDto } from '../src/entity/address/address.dto';
@@ -159,17 +160,17 @@ export class MockDataClientService {
                     translations: languageCodes.map(code =>
                         this.makeProductTranslation(code, name, slug, description),
                     ),
-                    variants: [
-                        this.makeProductVariant(`${name} Variant 1`, languageCodes),
-                        this.makeProductVariant(`${name} Variant 2`, languageCodes),
-                    ],
                 } as CreateProductDto,
             };
 
-            await request(this.apiUrl, query, variables).then(
-                data => console.log('Created Product:', data),
+            const product = await request<any>(this.apiUrl, query, variables).then(
+                data => {
+                    console.log('Created Product:', data);
+                    return data;
+                },
                 err => console.log(err),
             );
+            await this.makeProductVariant(product.createProduct.id);
         }
     }
 
@@ -187,15 +188,20 @@ export class MockDataClientService {
         };
     }
 
-    private makeProductVariant(variantName: string, languageCodes: LanguageCode[]): CreateProductVariantDto {
-        return {
-            price: faker.random.number({ min: 100, max: 5000 }),
-            optionCodes: ['small'],
-            sku: faker.random.alphaNumeric(8).toUpperCase(),
-            translations: languageCodes.map(code => ({
-                languageCode: code,
-                name: `${variantName} ${code}`,
-            })),
-        };
+    private async makeProductVariant(productId: ID): Promise<any> {
+        console.log('generating variants for', productId);
+        const query = `mutation GenerateVariants($productId: ID!) {
+            generateVariantsForProduct(productId: $productId) {
+                id
+                name
+            }
+         }`;
+        await request(this.apiUrl, query, { productId }).then(
+            data => {
+                console.log('Created Variants:', data);
+                return data;
+            },
+            err => console.log(err),
+        );
     }
 }

+ 2 - 0
server/src/api/product/product.api.graphql

@@ -12,6 +12,8 @@ type Mutation {
     addOptionGroupToProduct(productId: ID!, optionGroupId: ID!): Product!
     "Remove an OptionGroup from a Product"
     removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): Product!
+    "Create a set of ProductVariants based on the OptionGroups assigned to the given Product"
+    generateVariantsForProduct(productId: ID!): [ProductVariant!]!
     "Update existing ProductVariants"
     updateProductVariants(input: [UpdateProductVariantInput!]!): [ProductVariant]!
 }

+ 8 - 9
server/src/api/product/product.resolver.ts

@@ -31,15 +31,7 @@ export class ProductResolver {
     @ApplyIdCodec()
     async createProduct(_, args): Promise<Translated<Product>> {
         const { input } = args;
-        const product = await this.productService.create(input);
-
-        if (input.variants && input.variants.length) {
-            for (const variant of input.variants) {
-                await this.productVariantService.create(product, variant);
-            }
-        }
-
-        return product;
+        return this.productService.create(input);
     }
 
     @Mutation()
@@ -63,6 +55,13 @@ export class ProductResolver {
         return this.productService.removeOptionGroupFromProduct(productId, optionGroupId);
     }
 
+    @Mutation()
+    @ApplyIdCodec()
+    async generateVariantsForProduct(_, args): Promise<Array<Translated<ProductVariant>>> {
+        const { productId } = args;
+        return this.productVariantService.generateVariantsForProduct(productId);
+    }
+
     @Mutation()
     @ApplyIdCodec()
     async updateProductVariants(_, args): Promise<Array<Translated<ProductVariant>>> {

+ 0 - 1
server/src/entity/product/product.dto.ts

@@ -6,7 +6,6 @@ import { Product } from './product.entity';
 export interface CreateProductDto extends TranslatedInput<Product> {
     image?: string;
     optionGroupCodes?: [string];
-    variants?: CreateProductVariantDto[];
 }
 
 export interface UpdateProductDto extends TranslatedInput<Product> {

+ 0 - 1
server/src/entity/product/product.graphql

@@ -29,7 +29,6 @@ input ProductTranslationInput {
 input CreateProductInput {
     image: String
     translations: [ProductTranslationInput]!
-    variants: [CreateProductVariantInput]
     optionGroupCodes: [String]
 }
 

+ 116 - 0
server/src/service/product-variant.service.spec.ts

@@ -1,6 +1,8 @@
 import { Test } from '@nestjs/testing';
 import { Connection } from 'typeorm';
 
+import { DeepPartial } from '../../../shared/shared-types';
+import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
@@ -100,4 +102,118 @@ describe('ProductVariantService', () => {
             expect(savedProductVariant.options).toEqual([mockOptions[1]]);
         });
     });
+
+    describe('generateVariantsForProduct()', () => {
+        const mockSizeOptions: Array<DeepPartial<ProductOption>> = [
+            { code: 'small', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Small' }] },
+            { code: 'medium', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Medium' }] },
+            { code: 'large', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Large' }] },
+        ];
+
+        const mockColorOptions: Array<DeepPartial<ProductOption>> = [
+            { code: 'red', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Red' }] },
+            { code: 'green', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Green' }] },
+            { code: 'blue', translations: [{ languageCode: DEFAULT_LANGUAGE_CODE, name: 'Blue' }] },
+        ];
+
+        it('generates default variant for a product with no optionGroup', async () => {
+            const mockProduct: DeepPartial<Product> = {
+                id: 123,
+                translations: [
+                    {
+                        languageCode: DEFAULT_LANGUAGE_CODE,
+                        name: 'Mock Product',
+                    },
+                ],
+                optionGroups: [],
+            };
+            const productVariantRepository = connection
+                .registerMockRepository(Product)
+                .findOne.mockReturnValue(mockProduct);
+            const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
+            await productVariantService.generateVariantsForProduct(123);
+
+            const saveCalls = mockCreate.mock.calls;
+            expect(saveCalls.length).toBe(1);
+            expect(saveCalls[0][0]).toBe(mockProduct);
+            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product');
+            expect(saveCalls[0][1].optionCodes).toEqual([]);
+        });
+
+        it('generates variants for a product with a single optionGroup', async () => {
+            const mockProduct: DeepPartial<Product> = {
+                id: 123,
+                translations: [
+                    {
+                        languageCode: DEFAULT_LANGUAGE_CODE,
+                        name: 'Mock Product',
+                    },
+                ],
+                optionGroups: [
+                    {
+                        name: 'Size',
+                        code: 'size',
+                        options: mockSizeOptions,
+                    },
+                ],
+            };
+            const productVariantRepository = connection
+                .registerMockRepository(Product)
+                .findOne.mockReturnValue(mockProduct);
+            const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
+            await productVariantService.generateVariantsForProduct(123);
+
+            const saveCalls = mockCreate.mock.calls;
+            expect(saveCalls.length).toBe(3);
+            expect(saveCalls[0][0]).toBe(mockProduct);
+            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product Small');
+            expect(saveCalls[0][1].optionCodes).toEqual(['small']);
+            expect(saveCalls[1][1].optionCodes).toEqual(['medium']);
+            expect(saveCalls[2][1].optionCodes).toEqual(['large']);
+        });
+
+        it('generates variants for a product multiples optionGroups', async () => {
+            const mockProduct: DeepPartial<Product> = {
+                id: 123,
+                translations: [
+                    {
+                        languageCode: DEFAULT_LANGUAGE_CODE,
+                        name: 'Mock Product',
+                    },
+                ],
+                optionGroups: [
+                    {
+                        name: 'Size',
+                        code: 'size',
+                        options: mockSizeOptions,
+                    },
+                    {
+                        name: 'Color',
+                        code: 'color',
+                        options: mockColorOptions,
+                    },
+                ],
+            };
+            const productVariantRepository = connection
+                .registerMockRepository(Product)
+                .findOne.mockReturnValue(mockProduct);
+            const mockCreate = jest.spyOn(productVariantService, 'create').mockReturnValue(Promise.resolve());
+
+            await productVariantService.generateVariantsForProduct(123);
+
+            const saveCalls = mockCreate.mock.calls;
+            expect(saveCalls.length).toBe(9);
+            expect(saveCalls[0][0]).toBe(mockProduct);
+            expect(saveCalls[0][1].translations[0].name).toBe('Mock Product Small Red');
+            expect(saveCalls[0][1].optionCodes).toEqual(['small', 'red']);
+            expect(saveCalls[1][1].optionCodes).toEqual(['small', 'green']);
+            expect(saveCalls[2][1].optionCodes).toEqual(['small', 'blue']);
+            expect(saveCalls[3][1].optionCodes).toEqual(['medium', 'red']);
+            expect(saveCalls[4][1].optionCodes).toEqual(['medium', 'green']);
+            expect(saveCalls[5][1].optionCodes).toEqual(['medium', 'blue']);
+            expect(saveCalls[6][1].optionCodes).toEqual(['large', 'red']);
+            expect(saveCalls[7][1].optionCodes).toEqual(['large', 'green']);
+            expect(saveCalls[8][1].optionCodes).toEqual(['large', 'blue']);
+        });
+    });
 });

+ 82 - 0
server/src/service/product-variant.service.ts

@@ -2,11 +2,16 @@ import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
+import { ID } from '../../../shared/shared-types';
+import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { ProductOption } from '../entity/product-option/product-option.entity';
 import { CreateProductVariantDto } from '../entity/product-variant/create-product-variant.dto';
 import { ProductVariantTranslation } from '../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../entity/product-variant/product-variant.entity';
 import { Product } from '../entity/product/product.entity';
+import { I18nError } from '../i18n/i18n-error';
+import { Translated } from '../locale/locale-types';
+import { translateDeep } from '../locale/translate-entity';
 import { TranslationUpdaterService } from '../locale/translation-updater.service';
 
 @Injectable()
@@ -42,4 +47,81 @@ export class ProductVariantService {
 
         return createdVariant;
     }
+
+    async generateVariantsForProduct(productId: ID): Promise<Array<Translated<ProductVariant>>> {
+        const product = await this.connection.getRepository(Product).findOne(productId, {
+            relations: ['optionGroups', 'optionGroups.options'],
+        });
+
+        if (!product) {
+            throw new I18nError('error.product-with-id-not-found', { productId });
+        }
+        const defaultTranslation = product.translations.find(t => t.languageCode === DEFAULT_LANGUAGE_CODE);
+
+        const productName = defaultTranslation ? defaultTranslation.name : `product_${productId}`;
+        const optionCombinations = product.optionGroups.length
+            ? this.optionCombinations(product.optionGroups.map(g => g.options))
+            : [[]];
+        const createVariants = optionCombinations.map(options => {
+            const name = this.createVariantName(productName, options);
+            return this.create(product, {
+                sku: 'sku-not-set',
+                price: 0,
+                optionCodes: options.map(o => o.code),
+                translations: [
+                    {
+                        languageCode: DEFAULT_LANGUAGE_CODE,
+                        name,
+                    },
+                ],
+            });
+        });
+
+        return await Promise.all(createVariants).then(variants =>
+            variants.map(v => translateDeep(v, DEFAULT_LANGUAGE_CODE)),
+        );
+    }
+
+    private createVariantName(productName: string, options: ProductOption[]): string {
+        const optionsSuffix = options
+            .map(option => {
+                const defaultTranslation = option.translations.find(
+                    t => t.languageCode === DEFAULT_LANGUAGE_CODE,
+                );
+                return defaultTranslation ? defaultTranslation.name : option.code;
+            })
+            .join(' ');
+
+        return options.length ? `${productName} ${optionsSuffix}` : productName;
+    }
+
+    /**
+     * Given an array of option arrays `[['red, 'blue'], ['small', 'large']]`, this method returns a new array
+     * containing all the combinations of those options:
+     *
+     * [
+     *  ['red', 'small'],
+     *  ['red', 'large'],
+     *  ['blue', 'small'],
+     *  ['blue', 'large'],
+     * ]
+     */
+    private optionCombinations<T>(
+        optionGroups: T[][],
+        combination: T[] = [],
+        k: number = 0,
+        output: T[][] = [],
+    ): T[][] {
+        if (k === optionGroups.length) {
+            output.push(combination);
+            return [];
+        } else {
+            // tslint:disable:prefer-for-of
+            for (let i = 0; i < optionGroups[k].length; i++) {
+                this.optionCombinations(optionGroups, combination.concat(optionGroups[k][i]), k + 1, output);
+            }
+            // tslint:enable:prefer-for-of
+            return output;
+        }
+    }
 }

+ 1 - 1
server/src/service/product.service.ts

@@ -56,7 +56,7 @@ export class ProductService {
     }
 
     async create(createProductDto: CreateProductDto): Promise<Translated<Product>> {
-        const { variants, optionGroupCodes, image, translations } = createProductDto;
+        const { optionGroupCodes, image, translations } = createProductDto;
         const product = new Product(createProductDto);
         const productTranslations: ProductTranslation[] = [];
 

Some files were not shown because too many files changed in this diff