Răsfoiți Sursa

feat(core): Allow custom CollectionFilters in config

Closes #325
Michael Bromley 5 ani în urmă
părinte
comite
87edc9b6ef

+ 6 - 7
packages/core/e2e/shop-order.e2e-spec.ts

@@ -96,7 +96,7 @@ describe('Shop orders', () => {
     it('availableCountries returns enabled countries', async () => {
         // disable Austria
         const { countries } = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
-        const AT = countries.items.find(c => c.code === 'AT')!;
+        const AT = countries.items.find((c) => c.code === 'AT')!;
         await adminClient.query<UpdateCountry.Mutation, UpdateCountry.Variables>(UPDATE_COUNTRY, {
             input: {
                 id: AT.id,
@@ -106,7 +106,7 @@ describe('Shop orders', () => {
 
         const result = await shopClient.query<GetAvailableCountries.Query>(GET_AVAILABLE_COUNTRIES);
         expect(result.availableCountries.length).toBe(countries.items.length - 1);
-        expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined();
+        expect(result.availableCountries.find((c) => c.id === AT.id)).toBeUndefined();
     });
 
     describe('ordering as anonymous user', () => {
@@ -256,7 +256,7 @@ describe('Shop orders', () => {
                 quantity: 3,
             });
             expect(addItemToOrder!.lines.length).toBe(2);
-            expect(addItemToOrder!.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(addItemToOrder!.lines.map((i) => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const { removeOrderLine } = await shopClient.query<
                 RemoveItemFromOrder.Mutation,
@@ -265,7 +265,7 @@ describe('Shop orders', () => {
                 orderLineId: firstOrderLineId,
             });
             expect(removeOrderLine!.lines.length).toBe(1);
-            expect(removeOrderLine!.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(removeOrderLine!.lines.map((i) => i.productVariant.id)).toEqual(['T_3']);
         });
 
         it(
@@ -427,7 +427,6 @@ describe('Shop orders', () => {
         });
 
         it('customer default Addresses are updated after payment', async () => {
-            // TODO: will need to be reworked for https://github.com/vendure-ecommerce/vendure/issues/98
             const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
                 id: createdCustomerId,
             });
@@ -533,7 +532,7 @@ describe('Shop orders', () => {
                 quantity: 3,
             });
             expect(addItemToOrder!.lines.length).toBe(2);
-            expect(addItemToOrder!.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(addItemToOrder!.lines.map((i) => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const { removeOrderLine } = await shopClient.query<
                 RemoveItemFromOrder.Mutation,
@@ -542,7 +541,7 @@ describe('Shop orders', () => {
                 orderLineId: firstOrderLineId,
             });
             expect(removeOrderLine!.lines.length).toBe(1);
-            expect(removeOrderLine!.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(removeOrderLine!.lines.map((i) => i.productVariant.id)).toEqual(['T_3']);
         });
 
         it('nextOrderStates returns next valid states', async () => {

+ 2 - 1
packages/core/src/app.module.ts

@@ -139,11 +139,12 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
 
     private getConfigurableOperations(): Array<ConfigurableOperationDef<any>> {
         const { paymentMethodHandlers } = this.configService.paymentOptions;
-        // TODO: add CollectionFilters once #325 is fixed
+        const { collectionFilters } = this.configService.catalogOptions;
         const { promotionActions, promotionConditions } = this.configService.promotionOptions;
         const { shippingCalculators, shippingEligibilityCheckers } = this.configService.shippingOptions;
         return [
             ...paymentMethodHandlers,
+            ...collectionFilters,
             ...(promotionActions || []),
             ...(promotionConditions || []),
             ...(shippingCalculators || []),

+ 11 - 0
packages/core/src/config/collection/collection-filter.ts

@@ -24,6 +24,17 @@ export interface CollectionFilterConfig<T extends CollectionFilterArgs>
     apply: ApplyCollectionFilterFn<T>;
 }
 
+/**
+ * @description
+ * A CollectionFilter defines a rule which can be used to associate ProductVariants with a Collection.
+ * The filtering is done by defining the `apply()` function, which receives a TypeORM
+ * [`QueryBuilder`](https://typeorm.io/#/select-query-builder) object to which clauses may be added.
+ *
+ * Creating a CollectionFilter is considered an advanced Vendure topic. For more insight into how
+ * they work, study the [default collection filters](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/src/config/collection/default-collection-filters.ts)
+ *
+ * @docsCategory configuration
+ */
 export class CollectionFilter<T extends CollectionFilterArgs = {}> extends ConfigurableOperationDef<T> {
     private readonly applyFn: ApplyCollectionFilterFn<T>;
 

+ 2 - 0
packages/core/src/config/collection/default-collection-filters.ts

@@ -97,3 +97,5 @@ export const variantNameCollectionFilter = new CollectionFilter({
         }
     },
 });
+
+export const defaultCollectionFilters = [facetValueCollectionFilter, variantNameCollectionFilter];

+ 1 - 0
packages/core/src/config/config.service.mock.ts

@@ -20,6 +20,7 @@ export class MockConfigService implements MockClass<ConfigService> {
         assetStorageStrategy: {} as any,
         assetPreviewStrategy: {} as any,
     };
+    catalogOptions: {};
     uploadMaxFileSize = 1024;
     dbConnectionOptions = {};
     shippingOptions = {};

+ 5 - 1
packages/core/src/config/config.service.ts

@@ -11,7 +11,7 @@ import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { Logger, VendureLogger } from './logger/vendure-logger';
 import {
     AssetOptions,
-    AuthOptions,
+    AuthOptions, CatalogOptions,
     ImportExportOptions,
     JobQueueOptions,
     OrderOptions,
@@ -40,6 +40,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.authOptions;
     }
 
+    get catalogOptions(): Required<CatalogOptions> {
+        return this.activeConfig.catalogOptions;
+    }
+
     get defaultChannelToken(): string | null {
         return this.activeConfig.defaultChannelToken;
     }

+ 4 - 0
packages/core/src/config/default-config.ts

@@ -8,6 +8,7 @@ import { InMemoryJobQueueStrategy } from '../job-queue/in-memory-job-queue-strat
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
+import { defaultCollectionFilters } from './collection/default-collection-filters';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { DefaultLogger } from './logger/default-logger';
 import { TypeOrmLogger } from './logger/typeorm-logger';
@@ -47,6 +48,9 @@ export const defaultConfig: RuntimeVendureConfig = {
         requireVerification: true,
         verificationTokenDuration: '7d',
     },
+    catalogOptions: {
+        collectionFilters: defaultCollectionFilters,
+    },
     adminApiPath: 'admin-api',
     shopApiPath: 'shop-api',
     entityIdStrategy: new AutoIncrementIdStrategy(),

+ 23 - 2
packages/core/src/config/vendure-config.ts

@@ -15,6 +15,7 @@ import { OrderState } from '../service/helpers/order-state-machine/order-state';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
+import { CollectionFilter } from './collection/collection-filter';
 import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
@@ -244,9 +245,24 @@ export interface AssetOptions {
 }
 
 /**
- * @docsCategory promotions
+ * @description
+ * Options related to products and collections.
  *
- * */
+ * @docsCategory configuration
+ */
+export interface CatalogOptions {
+    /**
+     * @description
+     * Allows custom {@link CollectionFilter}s to be defined.
+     *
+     * @default defaultCollectionFilters
+     */
+    collectionFilters: Array<CollectionFilter<any>>;
+}
+
+/**
+ * @docsCategory promotions
+ */
 export interface PromotionOptions {
     /**
      * @description
@@ -437,6 +453,11 @@ export interface VendureConfig {
      * Configuration for authorization.
      */
     authOptions: AuthOptions;
+    /**
+     * @description
+     * Configuration for Products and Collections.
+     */
+    catalogOptions?: CatalogOptions;
     /**
      * @description
      * The name of the property which contains the token of the

+ 14 - 22
packages/core/src/service/controllers/collection.controller.ts

@@ -7,10 +7,7 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { Observable } from 'rxjs';
 import { Connection } from 'typeorm';
 
-import {
-    facetValueCollectionFilter,
-    variantNameCollectionFilter,
-} from '../../config/collection/default-collection-filters';
+import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
 import { Collection } from '../../entity/collection/collection.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
@@ -27,13 +24,14 @@ export class CollectionController {
     constructor(
         @InjectConnection() private connection: Connection,
         private collectionService: CollectionService,
+        private configService: ConfigService,
     ) {}
 
     @MessagePattern(ApplyCollectionFiltersMessage.pattern)
     applyCollectionFilters({
         collectionIds,
     }: ApplyCollectionFiltersMessage['data']): Observable<ApplyCollectionFiltersMessage['response']> {
-        return asyncObservable(async observer => {
+        return asyncObservable(async (observer) => {
             Logger.verbose(`Processing ${collectionIds.length} Collections`);
             const timeStart = Date.now();
             const collections = await this.connection.getRepository(Collection).findByIds(collectionIds, {
@@ -60,7 +58,7 @@ export class CollectionController {
     private async applyCollectionFiltersInternal(collection: Collection): Promise<ID[]> {
         const ancestorFilters = await this.collectionService
             .getAncestors(collection.id)
-            .then(ancestors =>
+            .then((ancestors) =>
                 ancestors.reduce(
                     (filters, c) => [...filters, ...(c.filters || [])],
                     [] as ConfigurableOperation[],
@@ -71,7 +69,7 @@ export class CollectionController {
             ...ancestorFilters,
             ...(collection.filters || []),
         ]);
-        const postIds = collection.productVariants.map(v => v.id);
+        const postIds = collection.productVariants.map((v) => v.id);
         try {
             await this.connection
                 .getRepository(Collection)
@@ -90,8 +88,8 @@ export class CollectionController {
         const preIdsSet = new Set(preIds);
         const postIdsSet = new Set(postIds);
         const difference = [
-            ...preIds.filter(id => !postIdsSet.has(id)),
-            ...postIds.filter(id => !preIdsSet.has(id)),
+            ...preIds.filter((id) => !postIdsSet.has(id)),
+            ...postIds.filter((id) => !preIdsSet.has(id)),
         ];
         return difference;
     }
@@ -103,21 +101,15 @@ export class CollectionController {
         if (filters.length === 0) {
             return [];
         }
-        const facetFilters = filters.filter(f => f.code === facetValueCollectionFilter.code);
-        const variantNameFilters = filters.filter(f => f.code === variantNameCollectionFilter.code);
+        const { collectionFilters } = this.configService.catalogOptions;
         let qb = this.connection.getRepository(ProductVariant).createQueryBuilder('productVariant');
 
-        // Apply any facetValue-based filters
-        if (facetFilters.length) {
-            for (const filter of facetFilters) {
-                qb = facetValueCollectionFilter.apply(qb, filter.args);
-            }
-        }
-
-        // Apply any variant name-based filters
-        if (variantNameFilters.length) {
-            for (const filter of variantNameFilters) {
-                qb = variantNameCollectionFilter.apply(qb, filter.args);
+        for (const filterType of collectionFilters) {
+            const filtersOfType = filters.filter((f) => f.code === filterType.code);
+            if (filtersOfType.length) {
+                for (const filter of filtersOfType) {
+                    qb = filterType.apply(qb, filter.args);
+                }
             }
         }
 

+ 1 - 1
packages/core/src/service/service.module.ts

@@ -192,7 +192,7 @@ export class ServiceModule {
         }
         return {
             module: ServiceModule,
-            imports: [workerTypeOrmModule],
+            imports: [workerTypeOrmModule, ConfigModule],
             controllers: workerControllers,
         };
     }

+ 22 - 28
packages/core/src/service/services/collection.service.ts

@@ -23,10 +23,6 @@ import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { CollectionFilter } from '../../config/collection/collection-filter';
-import {
-    facetValueCollectionFilter,
-    variantNameCollectionFilter,
-} from '../../config/collection/default-collection-filters';
 import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
 import { CollectionTranslation } from '../../entity/collection/collection-translation.entity';
@@ -54,10 +50,6 @@ import { FacetValueService } from './facet-value.service';
 
 export class CollectionService implements OnModuleInit {
     private rootCollection: Collection | undefined;
-    private availableFilters: Array<CollectionFilter<any>> = [
-        facetValueCollectionFilter,
-        variantNameCollectionFilter,
-    ];
     private applyFiltersQueue: JobQueue<ApplyCollectionFiletersJobData>;
 
     constructor(
@@ -79,18 +71,18 @@ export class CollectionService implements OnModuleInit {
 
         merge(productEvents$, variantEvents$)
             .pipe(debounceTime(50))
-            .subscribe(async event => {
+            .subscribe(async (event) => {
                 const collections = await this.connection.getRepository(Collection).find();
                 this.applyFiltersQueue.add({
                     ctx: event.ctx.serialize(),
-                    collectionIds: collections.map(c => c.id),
+                    collectionIds: collections.map((c) => c.id),
                 });
             });
 
         this.applyFiltersQueue = this.jobQueueService.createQueue({
             name: 'apply-collection-filters',
             concurrency: 1,
-            process: async job => {
+            process: async (job) => {
                 const collections = await this.connection
                     .getRepository(Collection)
                     .findByIds(job.data.collectionIds);
@@ -114,7 +106,7 @@ export class CollectionService implements OnModuleInit {
             })
             .getManyAndCount()
             .then(async ([collections, totalItems]) => {
-                const items = collections.map(collection =>
+                const items = collections.map((collection) =>
                     translateDeep(collection, ctx.languageCode, ['parent']),
                 );
                 return {
@@ -136,7 +128,9 @@ export class CollectionService implements OnModuleInit {
     }
 
     getAvailableFilters(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.availableFilters.map(x => configurableDefToOperation(ctx, x));
+        return this.configService.catalogOptions.collectionFilters.map((x) =>
+            configurableDefToOperation(ctx, x),
+        );
     }
 
     async getParent(ctx: RequestContext, collectionId: ID): Promise<Collection | undefined> {
@@ -145,7 +139,7 @@ export class CollectionService implements OnModuleInit {
             .createQueryBuilder('collection')
             .leftJoinAndSelect('collection.translations', 'translation')
             .where(
-                qb =>
+                (qb) =>
                     `collection.id = ${qb
                         .subQuery()
                         .select('child.parentId')
@@ -194,7 +188,7 @@ export class CollectionService implements OnModuleInit {
         }
         const result = await qb.getMany();
 
-        return result.map(collection => translateDeep(collection, ctx.languageCode));
+        return result.map((collection) => translateDeep(collection, ctx.languageCode));
     }
 
     /**
@@ -220,7 +214,7 @@ export class CollectionService implements OnModuleInit {
         };
 
         const descendants = await getChildren(rootId);
-        return descendants.map(c => translateDeep(c, ctx.languageCode));
+        return descendants.map((c) => translateDeep(c, ctx.languageCode));
     }
 
     /**
@@ -252,9 +246,9 @@ export class CollectionService implements OnModuleInit {
 
         return this.connection
             .getRepository(Collection)
-            .findByIds(ancestors.map(c => c.id))
-            .then(categories => {
-                return ctx ? categories.map(c => translateDeep(c, ctx.languageCode)) : categories;
+            .findByIds(ancestors.map((c) => c.id))
+            .then((categories) => {
+                return ctx ? categories.map((c) => translateDeep(c, ctx.languageCode)) : categories;
             });
     }
 
@@ -263,7 +257,7 @@ export class CollectionService implements OnModuleInit {
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async coll => {
+            beforeSave: async (coll) => {
                 await this.channelService.assignToCurrentChannel(coll, ctx);
                 const parent = await this.getParentCollection(ctx, input.parentId);
                 if (parent) {
@@ -287,7 +281,7 @@ export class CollectionService implements OnModuleInit {
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async coll => {
+            beforeSave: async (coll) => {
                 if (input.filters) {
                     coll.filters = this.getCollectionFiltersFromInput(input);
                 }
@@ -331,7 +325,7 @@ export class CollectionService implements OnModuleInit {
 
         if (
             idsAreEqual(input.parentId, target.id) ||
-            descendants.some(cat => idsAreEqual(input.parentId, cat.id))
+            descendants.some((cat) => idsAreEqual(input.parentId, cat.id))
         ) {
             throw new IllegalOperationError(`error.cannot-move-collection-into-self`);
         }
@@ -388,13 +382,13 @@ export class CollectionService implements OnModuleInit {
         collections: Collection[],
         job: Job<ApplyCollectionFiletersJobData>,
     ): Promise<void> {
-        const collectionIds = collections.map(c => c.id);
+        const collectionIds = collections.map((c) => c.id);
         const requestContext = RequestContext.deserialize(ctx);
 
         this.workerService.send(new ApplyCollectionFiltersMessage({ collectionIds })).subscribe({
             next: ({ total, completed, duration, collectionId, affectedVariantIds }) => {
                 const progress = Math.ceil((completed / total) * 100);
-                const collection = collections.find(c => idsAreEqual(c.id, collectionId));
+                const collection = collections.find((c) => idsAreEqual(c.id, collectionId));
                 if (collection) {
                     this.eventBus.publish(
                         new CollectionModificationEvent(requestContext, collection, affectedVariantIds),
@@ -405,7 +399,7 @@ export class CollectionService implements OnModuleInit {
             complete: () => {
                 job.complete();
             },
-            error: err => {
+            error: (err) => {
                 Logger.error(err);
                 job.fail(err);
             },
@@ -417,14 +411,14 @@ export class CollectionService implements OnModuleInit {
      */
     async getCollectionProductVariantIds(collection: Collection): Promise<ID[]> {
         if (collection.productVariants) {
-            return collection.productVariants.map(v => v.id);
+            return collection.productVariants.map((v) => v.id);
         } else {
             const productVariants = await this.connection
                 .getRepository(ProductVariant)
                 .createQueryBuilder('variant')
                 .innerJoin('variant.collections', 'collection', 'collection.id = :id', { id: collection.id })
                 .getMany();
-            return productVariants.map(v => v.id);
+            return productVariants.map((v) => v.id);
         }
     }
 
@@ -503,7 +497,7 @@ export class CollectionService implements OnModuleInit {
     }
 
     private getFilterByCode(code: string): CollectionFilter<any> {
-        const match = this.availableFilters.find(a => a.code === code);
+        const match = this.configService.catalogOptions.collectionFilters.find((a) => a.code === code);
         if (!match) {
             throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
         }