diff --git a/packages/core/e2e/shop-order.e2e-spec.ts b/packages/core/e2e/shop-order.e2e-spec.ts index 21d330c742..71f8bbcd00 100644 --- a/packages/core/e2e/shop-order.e2e-spec.ts +++ b/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(GET_COUNTRY_LIST, {}); - const AT = countries.items.find(c => c.code === 'AT')!; + const AT = countries.items.find((c) => c.code === 'AT')!; await adminClient.query(UPDATE_COUNTRY, { input: { id: AT.id, @@ -106,7 +106,7 @@ describe('Shop orders', () => { const result = await shopClient.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(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 () => { diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index a7df032163..1539c62774 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -139,11 +139,12 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat private getConfigurableOperations(): Array> { 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 || []), diff --git a/packages/core/src/config/collection/collection-filter.ts b/packages/core/src/config/collection/collection-filter.ts index ab83a95f61..06d5def86a 100644 --- a/packages/core/src/config/collection/collection-filter.ts +++ b/packages/core/src/config/collection/collection-filter.ts @@ -24,6 +24,17 @@ export interface CollectionFilterConfig apply: ApplyCollectionFilterFn; } +/** + * @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 extends ConfigurableOperationDef { private readonly applyFn: ApplyCollectionFilterFn; diff --git a/packages/core/src/config/collection/default-collection-filters.ts b/packages/core/src/config/collection/default-collection-filters.ts index d182743ea2..270de1f7fa 100644 --- a/packages/core/src/config/collection/default-collection-filters.ts +++ b/packages/core/src/config/collection/default-collection-filters.ts @@ -97,3 +97,5 @@ export const variantNameCollectionFilter = new CollectionFilter({ } }, }); + +export const defaultCollectionFilters = [facetValueCollectionFilter, variantNameCollectionFilter]; diff --git a/packages/core/src/config/config.service.mock.ts b/packages/core/src/config/config.service.mock.ts index ec31a96e7c..6bdb402690 100644 --- a/packages/core/src/config/config.service.mock.ts +++ b/packages/core/src/config/config.service.mock.ts @@ -20,6 +20,7 @@ export class MockConfigService implements MockClass { assetStorageStrategy: {} as any, assetPreviewStrategy: {} as any, }; + catalogOptions: {}; uploadMaxFileSize = 1024; dbConnectionOptions = {}; shippingOptions = {}; diff --git a/packages/core/src/config/config.service.ts b/packages/core/src/config/config.service.ts index 79a6c656a3..0a6acc9001 100644 --- a/packages/core/src/config/config.service.ts +++ b/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 { + return this.activeConfig.catalogOptions; + } + get defaultChannelToken(): string | null { return this.activeConfig.defaultChannelToken; } diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 5675352bf2..5c9ca59438 100644 --- a/packages/core/src/config/default-config.ts +++ b/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(), diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index f0bbc76066..92816b2aeb 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/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>; +} + +/** + * @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 diff --git a/packages/core/src/service/controllers/collection.controller.ts b/packages/core/src/service/controllers/collection.controller.ts index fbe8e579a3..8465ed57c0 100644 --- a/packages/core/src/service/controllers/collection.controller.ts +++ b/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 { - 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 { 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); + } } } diff --git a/packages/core/src/service/service.module.ts b/packages/core/src/service/service.module.ts index 86298343d3..9befa24c01 100644 --- a/packages/core/src/service/service.module.ts +++ b/packages/core/src/service/service.module.ts @@ -192,7 +192,7 @@ export class ServiceModule { } return { module: ServiceModule, - imports: [workerTypeOrmModule], + imports: [workerTypeOrmModule, ConfigModule], controllers: workerControllers, }; } diff --git a/packages/core/src/service/services/collection.service.ts b/packages/core/src/service/services/collection.service.ts index e060f02200..ccac51b980 100644 --- a/packages/core/src/service/services/collection.service.ts +++ b/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> = [ - facetValueCollectionFilter, - variantNameCollectionFilter, - ]; private applyFiltersQueue: JobQueue; 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 { @@ -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, ): Promise { - 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 { 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 { - 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 }); }