Просмотр исходного кода

feat(server): Ensure root category exists for each node

Michael Bromley 7 лет назад
Родитель
Сommit
ecf392835d

+ 1 - 1
server/src/api/resolvers/product-category.resolver.ts

@@ -40,7 +40,7 @@ export class ProductCategoryResolver {
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @Decode('assetIds', 'featuredAssetId')
+    @Decode('assetIds', 'featuredAssetId', 'parentId')
     async createProductCategory(
         @Ctx() ctx: RequestContext,
         @Args() args: CreateProductCategoryMutationArgs,

+ 3 - 0
server/src/entity/product-category/product-category.entity.ts

@@ -28,6 +28,9 @@ export class ProductCategory extends VendureEntity implements Translatable, HasC
         super(input);
     }
 
+    @Column()
+    isRoot: boolean;
+
     name: LocaleString;
 
     description: LocaleString;

+ 3 - 1
server/src/entity/product-category/product-category.graphql

@@ -7,7 +7,7 @@ type ProductCategory implements Node {
     description: String!
     featuredAsset: Asset
     assets: [Asset!]!
-    parent: ProductCategory
+    parent: ProductCategory!
     children: [ProductCategory!]
     facetValues: [FacetValue!]!
     translations: [ProductCategoryTranslation!]!
@@ -32,12 +32,14 @@ input ProductCategoryTranslationInput {
 input CreateProductCategoryInput {
     featuredAssetId: ID
     assetIds: [ID!]
+    parentId: ID
     translations: [ProductCategoryTranslationInput!]!
 }
 
 input UpdateProductCategoryInput {
     id: ID!
     featuredAssetId: ID
+    parentId: ID
     assetIds: [ID!]
     translations: [ProductCategoryTranslationInput!]!
 }

+ 3 - 1
server/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import { ID, Type } from 'shared/shared-types';
-import { Connection, FindManyOptions, SelectQueryBuilder } from 'typeorm';
+import { Connection, FindConditions, FindManyOptions, SelectQueryBuilder } from 'typeorm';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
 import { ListQueryOptions } from '../../../common/types/common-types';
@@ -23,6 +23,7 @@ export class ListQueryBuilder {
         options: ListQueryOptions<T> = {},
         relations?: string[],
         channelId?: ID,
+        findConditions?: FindConditions<T>,
     ): SelectQueryBuilder<T> {
         const skip = options.skip;
         let take = options.take;
@@ -37,6 +38,7 @@ export class ListQueryBuilder {
             relations,
             take,
             skip,
+            where: findConditions || {},
         } as FindManyOptions<T>);
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);

+ 58 - 1
server/src/service/helpers/utils/translate-entity.spec.ts

@@ -1,6 +1,8 @@
 import { LanguageCode } from 'shared/generated-types';
 
 import { Translatable, Translation } from '../../../common/types/locale-types';
+import { ProductCategoryTranslation } from '../../../entity/product-category/product-category-translation.entity';
+import { ProductCategory } from '../../../entity/product-category/product-category.entity';
 import { ProductOptionTranslation } from '../../../entity/product-option/product-option-translation.entity';
 import { ProductOption } from '../../../entity/product-option/product-option.entity';
 import { ProductVariantTranslation } from '../../../entity/product-variant/product-variant-translation.entity';
@@ -8,7 +10,7 @@ import { ProductVariant } from '../../../entity/product-variant/product-variant.
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { Product } from '../../../entity/product/product.entity';
 
-import { translateDeep, translateEntity } from './translate-entity';
+import { translateDeep, translateEntity, translateTree } from './translate-entity';
 
 const LANGUAGE_CODE = LanguageCode.en;
 const PRODUCT_NAME_EN = 'English Name';
@@ -255,3 +257,58 @@ describe('translateDeep()', () => {
         expect(result.variants[0].options[0]).toHaveProperty('name', OPTION_NAME_EN);
     });
 });
+
+describe('translateTree()', () => {
+    let cat1: ProductCategory;
+    let cat11: ProductCategory;
+    let cat12: ProductCategory;
+    let cat111: ProductCategory;
+
+    beforeEach(() => {
+        cat1 = new ProductCategory({
+            translations: [
+                new ProductCategoryTranslation({
+                    languageCode: LanguageCode.en,
+                    name: 'cat1 en',
+                }),
+            ],
+        });
+        cat11 = new ProductCategory({
+            translations: [
+                new ProductCategoryTranslation({
+                    languageCode: LanguageCode.en,
+                    name: 'cat11 en',
+                }),
+            ],
+        });
+        cat12 = new ProductCategory({
+            translations: [
+                new ProductCategoryTranslation({
+                    languageCode: LanguageCode.en,
+                    name: 'cat12 en',
+                }),
+            ],
+        });
+        cat111 = new ProductCategory({
+            translations: [
+                new ProductCategoryTranslation({
+                    languageCode: LanguageCode.en,
+                    name: 'cat111 en',
+                }),
+            ],
+        });
+
+        cat1.children = [cat11, cat12];
+        cat11.children = [cat111];
+    });
+
+    it('translates all entities in the tree', () => {
+        const result = translateTree(cat1, LanguageCode.en, []);
+
+        expect(result.languageCode).toBe(LanguageCode.en);
+        expect(result.name).toBe('cat1 en');
+        expect(result.children[0].name).toBe('cat11 en');
+        expect(result.children[1].name).toBe('cat12 en');
+        expect(result.children[0].children[0].name).toBe('cat111 en');
+    });
+});

+ 19 - 0
server/src/service/helpers/utils/translate-entity.ts

@@ -121,3 +121,22 @@ function translateLeaf(object: object | undefined, property: string, languageCod
         }
     }
 }
+
+export type TreeNode = { children: TreeNode[] } & Translatable;
+
+/**
+ * Translates a tree structure of Translatable entities
+ */
+export function translateTree<T extends TreeNode>(
+    node: T,
+    languageCode: LanguageCode,
+    translatableRelations: DeepTranslatableRelations<T> = [],
+): Translated<T> {
+    const output = translateDeep(node, languageCode, translatableRelations);
+    if (Array.isArray(output.children)) {
+        output.children = output.children.map(child =>
+            translateTree(child, languageCode, translatableRelations as any),
+        );
+    }
+    return output;
+}

+ 83 - 15
server/src/service/services/product-category.service.ts

@@ -1,14 +1,10 @@
 import { InjectConnection } from '@nestjs/typeorm';
-import {
-    CreateProductCategoryInput,
-    CreateProductInput,
-    UpdateProductCategoryInput,
-    UpdateProductInput,
-} from 'shared/generated-types';
+import { CreateProductCategoryInput, UpdateProductCategoryInput } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
@@ -16,12 +12,14 @@ import { ProductCategoryTranslation } from '../../entity/product-category/produc
 import { ProductCategory } from '../../entity/product-category/product-category.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
-import { translateDeep } from '../helpers/utils/translate-entity';
+import { translateDeep, translateTree } from '../helpers/utils/translate-entity';
 
 import { AssetService } from './asset.service';
 import { ChannelService } from './channel.service';
 
 export class ProductCategoryService {
+    private rootCategories: { [channelCode: string]: ProductCategory } = {};
+
     constructor(
         @InjectConnection() private connection: Connection,
         private channelService: ChannelService,
@@ -30,18 +28,18 @@ export class ProductCategoryService {
         private translatableSaver: TranslatableSaver,
     ) {}
 
-    findAll(
+    async findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<ProductCategory>,
     ): Promise<PaginatedList<Translated<ProductCategory>>> {
-        const relations = ['featuredAsset', 'assets', 'facetValues', 'channels'];
+        const relations = ['featuredAsset', 'facetValues', 'parent', 'channels'];
 
         return this.listQueryBuilder
-            .build(ProductCategory, options, relations, ctx.channelId)
+            .build(ProductCategory, options, relations, ctx.channelId, { isRoot: false })
             .getManyAndCount()
             .then(async ([productCategories, totalItems]) => {
                 const items = productCategories.map(productCategory =>
-                    translateDeep(productCategory, ctx.languageCode, ['facetValues']),
+                    translateDeep(productCategory, ctx.languageCode, ['facetValues', 'parent']),
                 );
                 return {
                     items,
@@ -51,14 +49,22 @@ export class ProductCategoryService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<ProductCategory> | undefined> {
-        const relations = ['featuredAsset', 'assets', 'facetValues', 'channels'];
-        const productCategory = await this.connection.manager.findOne(ProductCategory, productId, {
+        const relations = ['featuredAsset', 'assets', 'facetValues', 'channels', 'parent'];
+        const productCategory = await this.connection.getRepository(ProductCategory).findOne(productId, {
             relations,
         });
         if (!productCategory) {
             return;
         }
-        return translateDeep(productCategory, ctx.languageCode, ['facetValues']);
+        return translateDeep(productCategory, ctx.languageCode, ['facetValues', 'parent']);
+    }
+
+    async getTree(ctx: RequestContext, rootId?: ID): Promise<Translated<ProductCategory> | undefined> {
+        const root = await this.getParentCategory(ctx, rootId);
+        if (root) {
+            const tree = await this.connection.getTreeRepository(ProductCategory).findDescendantsTree(root);
+            return translateTree(tree, ctx.languageCode);
+        }
     }
 
     async create(
@@ -69,7 +75,13 @@ export class ProductCategoryService {
             input,
             entityType: ProductCategory,
             translationType: ProductCategoryTranslation,
-            beforeSave: category => this.channelService.assignToChannels(category, ctx),
+            beforeSave: async category => {
+                await this.channelService.assignToChannels(category, ctx);
+                const parent = await this.getParentCategory(ctx, input.parentId);
+                if (parent) {
+                    category.parent = parent;
+                }
+            },
         });
         await this.saveAssetInputs(productCategory, input);
         return assertFound(this.findOne(ctx, productCategory.id));
@@ -103,4 +115,60 @@ export class ProductCategoryService {
             await this.connection.manager.save(productCategory);
         }
     }
+
+    private async getParentCategory(
+        ctx: RequestContext,
+        parentId?: ID | null,
+    ): Promise<ProductCategory | undefined> {
+        if (parentId) {
+            return this.connection
+                .getRepository(ProductCategory)
+                .createQueryBuilder('category')
+                .leftJoin('category.channels', 'channel')
+                .where('category.id = :id', { id: parentId })
+                .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+                .getOne();
+        } else {
+            return this.getRootCategory(ctx);
+        }
+    }
+
+    private async getRootCategory(ctx: RequestContext): Promise<ProductCategory> {
+        const cachedRoot = this.rootCategories[ctx.channel.code];
+
+        if (cachedRoot) {
+            return cachedRoot;
+        }
+
+        const existingRoot = await this.connection
+            .getRepository(ProductCategory)
+            .createQueryBuilder('category')
+            .leftJoin('category.channels', 'channel')
+            .where('category.isRoot = :isRoot', { isRoot: true })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .getOne();
+
+        if (existingRoot) {
+            this.rootCategories[ctx.channel.code] = existingRoot;
+            return existingRoot;
+        }
+
+        const rootTranslation = await this.connection.getRepository(ProductCategoryTranslation).save(
+            new ProductCategoryTranslation({
+                languageCode: DEFAULT_LANGUAGE_CODE,
+                name: '__root_category__',
+                description: 'The root of the ProductCategory tree.',
+            }),
+        );
+
+        const newRoot = new ProductCategory({
+            isRoot: true,
+            translations: [rootTranslation],
+            channels: [ctx.channel],
+        });
+
+        await this.connection.getRepository(ProductCategory).save(newRoot);
+        this.rootCategories[ctx.channel.code] = newRoot;
+        return newRoot;
+    }
 }