Parcourir la source

refactor: Implement first draft of Collection filters.

Relates to #71. Still to do: collection filter cascading, code clean up, more tests, UI polish
Michael Bromley il y a 6 ans
Parent
commit
f2a6076ee8

+ 30 - 12
admin-ui/src/app/catalog/components/collection-detail/collection-detail.component.html

@@ -61,20 +61,38 @@
             ></vdr-product-assets>
         </div>
     </div>
-    <div class="clr-row">
+    <div class="clr-row" formArrayName="filters">
         <div class="clr-col">
-            <h4>{{ 'catalog.facets' | translate }}</h4>
-            <div class="facets">
-                <vdr-facet-value-chip
-                    *ngFor="let facetValue of (facetValues$ | async)"
-                    [facetValue]="facetValue"
-                    (remove)="removeValueFacet(facetValue.id)"
-                ></vdr-facet-value-chip>
+            <label>{{ 'catalog.filters' | translate }}</label>
+            <ng-container *ngFor="let filter of filters; index as i">
+                <vdr-adjustment-operation-input
+                    (remove)="removeFilter($event)"
+                    [facets]="facets$ | async"
+                    [operation]="filter"
+                    [formControlName]="i"
+                ></vdr-adjustment-operation-input>
+            </ng-container>
+
+            <div>
+                <clr-dropdown>
+                    <div clrDropdownTrigger>
+                        <button class="btn btn-outline">
+                            <clr-icon shape="plus"></clr-icon>
+                            {{ 'marketing.add-condition' | translate }}
+                        </button>
+                    </div>
+                    <clr-dropdown-menu clrPosition="top-right" *clrIfOpen>
+                        <button
+                            *ngFor="let filter of allFilters"
+                            type="button"
+                            clrDropdownItem
+                            (click)="addFilter(filter)"
+                        >
+                            {{ filter.code }}
+                        </button>
+                    </clr-dropdown-menu>
+                </clr-dropdown>
             </div>
-            <button class="btn btn-secondary" (click)="addFacetValue()">
-                <clr-icon shape="plus"></clr-icon>
-                {{ 'catalog.add-facet' | translate }}
-            </button>
         </div>
     </div>
 </form>

+ 64 - 68
admin-ui/src/app/catalog/components/collection-detail/collection-detail.component.ts

@@ -1,37 +1,26 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
-import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { combineLatest, Observable } from 'rxjs';
+import { mergeMap, shareReplay, take } from 'rxjs/operators';
 import {
-    filter,
-    map,
-    mergeMap,
-    shareReplay,
-    startWith,
-    switchMap,
-    take,
-    withLatestFrom,
-} from 'rxjs/operators';
-import {
+    AdjustmentOperation,
+    AdjustmentOperationInput,
     Collection,
     CreateCollectionInput,
-    FacetValue,
     FacetWithValues,
     LanguageCode,
     UpdateCollectionInput,
 } from 'shared/generated-types';
 import { CustomFieldConfig } from 'shared/shared-types';
-import { unique } from 'shared/unique';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
 import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
-import { flattenFacetValues } from '../../../common/utilities/flatten-facet-values';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';
 import { ServerConfigService } from '../../../data/server-config';
 import { ModalService } from '../../../shared/providers/modal/modal.service';
-import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
 
 @Component({
     selector: 'vdr-collection-detail',
@@ -44,8 +33,9 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
     customFields: CustomFieldConfig[];
     detailForm: FormGroup;
     assetChanges: { assetIds?: string[]; featuredAssetId?: string } = {};
-    facetValues$: Observable<FacetValue.Fragment[]>;
-    private facets$: Observable<FacetWithValues.Fragment[]>;
+    filters: AdjustmentOperation[] = [];
+    allFilters: AdjustmentOperation[] = [];
+    facets$: Observable<FacetWithValues.Fragment[]>;
 
     constructor(
         router: Router,
@@ -62,7 +52,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
         this.detailForm = this.formBuilder.group({
             name: ['', Validators.required],
             description: '',
-            facetValueIds: [[]],
+            filters: this.formBuilder.array([]),
             customFields: this.formBuilder.group(
                 this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
             ),
@@ -76,18 +66,9 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
             .mapSingle(data => data.facets.items)
             .pipe(shareReplay(1));
 
-        const facetValues$ = this.facets$.pipe(map(facets => flattenFacetValues(facets)));
-        const facetValueIds$ = this.entity$.pipe(
-            filter(category => !!(category && category.facetValues)),
-            take(1),
-            switchMap(category => this.detailForm.valueChanges),
-            startWith(this.detailForm.value),
-            map(formValue => formValue.facetValueIds),
-        );
-
-        this.facetValues$ = combineLatest(facetValueIds$, facetValues$).pipe(
-            map(([ids, facetValues]) => ids.map(id => facetValues.find(fv => fv.id === id))),
-        );
+        this.dataService.product.getCollectionFilters().single$.subscribe(res => {
+            this.allFilters = res.collectionFilters;
+        });
     }
 
     ngOnDestroy() {
@@ -102,37 +83,34 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
         return !!Object.values(this.assetChanges).length;
     }
 
-    addFacetValue() {
-        this.facets$
-            .pipe(
-                take(1),
-                mergeMap(facets =>
-                    this.modalService.fromComponent(ApplyFacetDialogComponent, {
-                        size: 'md',
-                        locals: { facets },
-                    }),
-                ),
-                map(facetValues => facetValues && facetValues.map(v => v.id)),
-                withLatestFrom(this.entity$),
-            )
-            .subscribe(([facetValueIds, category]) => {
-                if (facetValueIds) {
-                    const existingFacetValueIds = this.detailForm.value.facetValueIds;
-                    this.detailForm.patchValue({
-                        facetValueIds: unique([...existingFacetValueIds, ...facetValueIds]),
-                    });
-                    this.detailForm.markAsDirty();
-                    this.changeDetector.markForCheck();
-                }
-            });
+    addFilter(collectionFilter: AdjustmentOperation) {
+        const filtersArray = this.detailForm.get('filters') as FormArray;
+        const index = filtersArray.value.findIndex(o => o.code === collectionFilter.code);
+        if (index === -1) {
+            const argsHash = collectionFilter.args.reduce(
+                (output, arg) => ({
+                    ...output,
+                    [arg.name]: arg.value,
+                }),
+                {},
+            );
+            filtersArray.push(
+                this.formBuilder.control({
+                    code: collectionFilter.code,
+                    args: argsHash,
+                }),
+            );
+            this.filters.push(collectionFilter);
+        }
     }
 
-    removeValueFacet(id: string) {
-        const facetValueIds = this.detailForm.value.facetValueIds.filter(fvid => fvid !== id);
-        this.detailForm.patchValue({
-            facetValueIds,
-        });
-        this.detailForm.markAsDirty();
+    removeFilter(collectionFilter: AdjustmentOperation) {
+        const filtersArray = this.detailForm.get('filters') as FormArray;
+        const index = filtersArray.value.findIndex(o => o.code === collectionFilter.code);
+        if (index !== -1) {
+            filtersArray.removeAt(index);
+            this.filters.splice(index, 1);
+        }
     }
 
     create() {
@@ -143,7 +121,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
             .pipe(
                 take(1),
                 mergeMap(([category, languageCode]) => {
-                    const input = this.getUpdatedCategory(category, this.detailForm, languageCode);
+                    const input = this.getUpdatedCollection(category, this.detailForm, languageCode);
                     return this.dataService.product.createCollection(input);
                 }),
             )
@@ -169,8 +147,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
             .pipe(
                 take(1),
                 mergeMap(([category, languageCode]) => {
-                    const updateOperations: Array<Observable<any>> = [];
-                    const input = this.getUpdatedCategory(
+                    const input = this.getUpdatedCollection(
                         category,
                         this.detailForm,
                         languageCode,
@@ -197,15 +174,16 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
     /**
      * Sets the values of the form on changes to the category or current language.
      */
-    protected setFormValues(category: Collection.Fragment, languageCode: LanguageCode) {
-        const currentTranslation = category.translations.find(t => t.languageCode === languageCode);
+    protected setFormValues(entity: Collection.Fragment, languageCode: LanguageCode) {
+        const currentTranslation = entity.translations.find(t => t.languageCode === languageCode);
 
         this.detailForm.patchValue({
             name: currentTranslation ? currentTranslation.name : '',
             description: currentTranslation ? currentTranslation.description : '',
-            facetValueIds: category.facetValues.map(fv => fv.id),
         });
 
+        entity.filters.forEach(f => this.addFilter(f));
+
         if (this.customFields.length) {
             const customFieldsGroup = this.detailForm.get(['customFields']) as FormGroup;
 
@@ -214,7 +192,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
                 const value =
                     fieldDef.type === 'localeString'
                         ? (currentTranslation as any).customFields[key]
-                        : (category as any).customFields[key];
+                        : (entity as any).customFields[key];
                 const control = customFieldsGroup.get(key);
                 if (control) {
                     control.patchValue(value);
@@ -227,7 +205,7 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
      * Given a category and the value of the form, this method creates an updated copy of the category which
      * can then be persisted to the API.
      */
-    private getUpdatedCategory(
+    private getUpdatedCollection(
         category: Collection.Fragment,
         form: FormGroup,
         languageCode: LanguageCode,
@@ -246,7 +224,25 @@ export class CollectionDetailComponent extends BaseDetailComponent<Collection.Fr
         return {
             ...updatedCategory,
             ...this.assetChanges,
-            facetValueIds: this.detailForm.value.facetValueIds,
+            filters: this.mapOperationsToInputs(this.filters, this.detailForm.value.filters),
         };
     }
+
+    /**
+     * Maps an array of conditions or actions to the input format expected by the GraphQL API.
+     */
+    private mapOperationsToInputs(
+        operations: AdjustmentOperation[],
+        formValueOperations: any,
+    ): AdjustmentOperationInput[] {
+        return operations.map((o, i) => {
+            return {
+                code: o.code,
+                arguments: Object.values(formValueOperations[i].args).map((value, j) => ({
+                    name: o.args[j].name,
+                    value: value.toString(),
+                })),
+            };
+        });
+    }
 }

+ 1 - 1
admin-ui/src/app/catalog/providers/routing/collection-resolver.ts

@@ -18,7 +18,7 @@ export class CollectionResolver extends BaseEntityResolver<Collection.Fragment>
                 featuredAsset: null,
                 assets: [],
                 translations: [],
-                facetValues: [],
+                filters: [],
                 parent: {} as any,
                 children: null,
             },

+ 14 - 13
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -1,5 +1,7 @@
 import gql from 'graphql-tag';
 
+import { ADJUSTMENT_OPERATION_FRAGMENT } from './promotion-definitions';
+
 export const ASSET_FRAGMENT = gql`
     fragment Asset on Asset {
         id
@@ -285,6 +287,15 @@ export const CREATE_ASSETS = gql`
     ${ASSET_FRAGMENT}
 `;
 
+export const GET_COLLECTION_FILTERS = gql`
+    query GetCollectionFilters {
+        collectionFilters {
+            ...AdjustmentOperation
+        }
+    }
+    ${ADJUSTMENT_OPERATION_FRAGMENT}
+`;
+
 export const COLLECTION_FRAGMENT = gql`
     fragment Collection on Collection {
         id
@@ -297,10 +308,8 @@ export const COLLECTION_FRAGMENT = gql`
         assets {
             ...Asset
         }
-        facetValues {
-            id
-            name
-            code
+        filters {
+            ...AdjustmentOperation
         }
         translations {
             id
@@ -318,6 +327,7 @@ export const COLLECTION_FRAGMENT = gql`
         }
     }
     ${ASSET_FRAGMENT}
+    ${ADJUSTMENT_OPERATION_FRAGMENT}
 `;
 
 export const GET_COLLECTION_LIST = gql`
@@ -330,15 +340,6 @@ export const GET_COLLECTION_LIST = gql`
                 featuredAsset {
                     ...Asset
                 }
-                facetValues {
-                    id
-                    code
-                    name
-                    facet {
-                        id
-                        name
-                    }
-                }
                 parent {
                     id
                 }

+ 8 - 2
admin-ui/src/app/data/providers/product-data.service.ts

@@ -13,6 +13,7 @@ import {
     GenerateProductVariants,
     GetAssetList,
     GetCollection,
+    GetCollectionFilters,
     GetCollectionList,
     GetProductList,
     GetProductOptionGroups,
@@ -41,6 +42,7 @@ import {
     GENERATE_PRODUCT_VARIANTS,
     GET_ASSET_LIST,
     GET_COLLECTION,
+    GET_COLLECTION_FILTERS,
     GET_COLLECTION_LIST,
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUPS,
@@ -205,6 +207,10 @@ export class ProductDataService {
         });
     }
 
+    getCollectionFilters() {
+        return this.baseDataService.query<GetCollectionFilters.Query>(GET_COLLECTION_FILTERS);
+    }
+
     getCollections(take: number = 10, skip: number = 0) {
         return this.baseDataService.query<GetCollectionList.Query, GetCollectionList.Variables>(
             GET_COLLECTION_LIST,
@@ -233,7 +239,7 @@ export class ProductDataService {
                     'translations',
                     'assetIds',
                     'featuredAssetId',
-                    'facetValueIds',
+                    'filters',
                     'customFields',
                 ]),
             },
@@ -249,7 +255,7 @@ export class ProductDataService {
                     'translations',
                     'assetIds',
                     'featuredAssetId',
-                    'facetValueIds',
+                    'filters',
                     'customFields',
                 ]),
             },

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-admin.json


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-shop.json


Fichier diff supprimé car celui-ci est trop grand
+ 211 - 534
schema.json


+ 16 - 16
server/e2e/__snapshots__/collection.e2e-spec.ts.snap

@@ -24,13 +24,6 @@ Object {
   ],
   "children": null,
   "description": "",
-  "facetValues": Array [
-    Object {
-      "code": "electronics",
-      "id": "T_1",
-      "name": "electronics",
-    },
-  ],
   "featuredAsset": Object {
     "fileSize": 4,
     "id": "T_1",
@@ -40,6 +33,19 @@ Object {
     "source": "test-url/test-assets/derick-david-409858-unsplash.jpg",
     "type": "IMAGE",
   },
+  "filters": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "facetValueIds",
+          "type": "facetValueIds",
+          "value": "[\\"T_1\\"]",
+        },
+      ],
+      "code": "facet-value-filter",
+      "description": "Filter by FacetValues",
+    },
+  ],
   "id": "T_2",
   "languageCode": "en",
   "name": "Electronics",
@@ -58,7 +64,7 @@ Object {
 }
 `;
 
-exports[`Collection resolver updateCollection updates the details 1`] = `
+exports[`Collection resolver updateCollection 1`] = `
 Object {
   "assets": Array [
     Object {
@@ -73,13 +79,6 @@ Object {
   ],
   "children": null,
   "description": "Apple stuff ",
-  "facetValues": Array [
-    Object {
-      "code": "photo",
-      "id": "T_3",
-      "name": "photo",
-    },
-  ],
   "featuredAsset": Object {
     "fileSize": 4,
     "id": "T_1",
@@ -89,12 +88,13 @@ Object {
     "source": "test-url/test-assets/derick-david-409858-unsplash.jpg",
     "type": "IMAGE",
   },
+  "filters": Array [],
   "id": "T_4",
   "languageCode": "en",
   "name": "Apple",
   "parent": Object {
     "id": "T_3",
-    "name": "Laptops",
+    "name": "Computers",
   },
   "translations": Array [
     Object {

+ 69 - 104
server/e2e/collection.e2e-spec.ts

@@ -19,18 +19,21 @@ import {
     UpdateCollection,
 } from '../../shared/generated-types';
 import { ROOT_CATEGORY_NAME } from '../../shared/shared-constants';
+import { facetValueCollectionFilter } from '../src/config/collection/collection-filter';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './test-utils';
 
+// TODO: test collection without filters has no ProductVariants
+
 describe('Collection resolver', () => {
     const client = new TestAdminClient();
     const server = new TestServer();
     let assets: GetAssetList.Items[];
     let electronicsCategory: Collection.Fragment;
-    let laptopsCategory: Collection.Fragment;
+    let computersCategory: Collection.Fragment;
     let appleCategory: Collection.Fragment;
 
     beforeAll(async () => {
@@ -61,7 +64,12 @@ describe('Collection resolver', () => {
                     input: {
                         assetIds: [assets[0].id, assets[1].id],
                         featuredAssetId: assets[1].id,
-                        facetValueIds: ['T_1'],
+                        filters: [
+                            {
+                                code: facetValueCollectionFilter.code,
+                                arguments: [{ name: 'facetValueIds', value: `["T_1"]` }],
+                            },
+                        ],
                         translations: [
                             { languageCode: LanguageCode.en, name: 'Electronics', description: '' },
                         ],
@@ -80,13 +88,18 @@ describe('Collection resolver', () => {
                 {
                     input: {
                         parentId: electronicsCategory.id,
-                        translations: [{ languageCode: LanguageCode.en, name: 'Laptops', description: '' }],
-                        facetValueIds: ['T_2'],
+                        translations: [{ languageCode: LanguageCode.en, name: 'Computers', description: '' }],
+                        filters: [
+                            {
+                                code: facetValueCollectionFilter.code,
+                                arguments: [{ name: 'facetValueIds', value: `["T_2"]` }],
+                            },
+                        ],
                     },
                 },
             );
-            laptopsCategory = result.createCollection;
-            expect(laptopsCategory.parent.name).toBe(electronicsCategory.name);
+            computersCategory = result.createCollection;
+            expect(computersCategory.parent.name).toBe(electronicsCategory.name);
         });
 
         it('creates a 2nd level nested category', async () => {
@@ -94,92 +107,43 @@ describe('Collection resolver', () => {
                 CREATE_COLLECTION,
                 {
                     input: {
-                        parentId: laptopsCategory.id,
+                        parentId: computersCategory.id,
                         translations: [{ languageCode: LanguageCode.en, name: 'Apple', description: '' }],
-                        facetValueIds: ['T_3', 'T_4'],
+                        filters: [],
                     },
                 },
             );
             appleCategory = result.createCollection;
-            expect(appleCategory.parent.name).toBe(laptopsCategory.name);
+            expect(appleCategory.parent.name).toBe(computersCategory.name);
         });
     });
 
-    describe('collection query', () => {
-        it('returns a category', async () => {
-            const result = await client.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-                id: laptopsCategory.id,
-            });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.id).toBe(laptopsCategory.id);
-        });
-
-        it('resolves descendantFacetValues 1 level deep', async () => {
-            const result = await client.query(GET_DECENDANT_FACET_VALUES, { id: laptopsCategory.id });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.descendantFacetValues.map(v => v.id)).toEqual(['T_3', 'T_4']);
-        });
-
-        it('resolves descendantFacetValues 2 levels deep', async () => {
-            const result = await client.query(GET_DECENDANT_FACET_VALUES, { id: electronicsCategory.id });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.descendantFacetValues.map(v => v.id)).toEqual(['T_2', 'T_3', 'T_4']);
-        });
-
-        it('resolves ancestorFacetValues at root', async () => {
-            const result = await client.query(GET_ANCESTOR_FACET_VALUES, { id: electronicsCategory.id });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.ancestorFacetValues.map(v => v.id)).toEqual([]);
-        });
-
-        it('resolves ancestorFacetValues 1 level deep', async () => {
-            const result = await client.query(GET_ANCESTOR_FACET_VALUES, { id: laptopsCategory.id });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.ancestorFacetValues.map(v => v.id)).toEqual(['T_1']);
-        });
-
-        it('resolves ancestorFacetValues 2 levels deep', async () => {
-            const result = await client.query(GET_ANCESTOR_FACET_VALUES, { id: appleCategory.id });
-            if (!result.collection) {
-                fail(`did not return the category`);
-                return;
-            }
-            expect(result.collection.ancestorFacetValues.map(v => v.id)).toEqual(['T_1', 'T_2']);
+    it('collection query', async () => {
+        const result = await client.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+            id: computersCategory.id,
         });
+        if (!result.collection) {
+            fail(`did not return the category`);
+            return;
+        }
+        expect(result.collection.id).toBe(computersCategory.id);
     });
 
-    describe('updateCollection', () => {
-        it('updates the details', async () => {
-            const result = await client.query<UpdateCollection.Mutation, UpdateCollection.Variables>(
-                UPDATE_COLLECTION,
-                {
-                    input: {
-                        id: appleCategory.id,
-                        assetIds: [assets[1].id],
-                        featuredAssetId: assets[1].id,
-                        facetValueIds: ['T_3'],
-                        translations: [{ languageCode: LanguageCode.en, description: 'Apple stuff ' }],
-                    },
+    it('updateCollection', async () => {
+        const result = await client.query<UpdateCollection.Mutation, UpdateCollection.Variables>(
+            UPDATE_COLLECTION,
+            {
+                input: {
+                    id: appleCategory.id,
+                    assetIds: [assets[1].id],
+                    featuredAssetId: assets[1].id,
+                    filters: [],
+                    translations: [{ languageCode: LanguageCode.en, description: 'Apple stuff ' }],
                 },
-            );
+            },
+        );
 
-            expect(result.updateCollection).toMatchSnapshot();
-        });
+        expect(result.updateCollection).toMatchSnapshot();
     });
 
     describe('moveCollection', () => {
@@ -198,7 +162,7 @@ describe('Collection resolver', () => {
             expect(result.moveCollection.parent.id).toBe(electronicsCategory.id);
 
             const positions = await getChildrenOf(electronicsCategory.id);
-            expect(positions.map(i => i.id)).toEqual([appleCategory.id, laptopsCategory.id]);
+            expect(positions.map(i => i.id)).toEqual([appleCategory.id, computersCategory.id]);
         });
 
         it('alters the position in the current parent', async () => {
@@ -211,7 +175,7 @@ describe('Collection resolver', () => {
             });
 
             const afterResult = await getChildrenOf(electronicsCategory.id);
-            expect(afterResult.map(i => i.id)).toEqual([laptopsCategory.id, appleCategory.id]);
+            expect(afterResult.map(i => i.id)).toEqual([computersCategory.id, appleCategory.id]);
         });
 
         it('corrects an out-of-bounds negative index value', async () => {
@@ -224,7 +188,7 @@ describe('Collection resolver', () => {
             });
 
             const afterResult = await getChildrenOf(electronicsCategory.id);
-            expect(afterResult.map(i => i.id)).toEqual([appleCategory.id, laptopsCategory.id]);
+            expect(afterResult.map(i => i.id)).toEqual([appleCategory.id, computersCategory.id]);
         });
 
         it('corrects an out-of-bounds positive index value', async () => {
@@ -237,7 +201,7 @@ describe('Collection resolver', () => {
             });
 
             const afterResult = await getChildrenOf(electronicsCategory.id);
-            expect(afterResult.map(i => i.id)).toEqual([laptopsCategory.id, appleCategory.id]);
+            expect(afterResult.map(i => i.id)).toEqual([computersCategory.id, appleCategory.id]);
         });
 
         it(
@@ -271,14 +235,23 @@ describe('Collection resolver', () => {
         );
 
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
-            const result = await client.query(GET_CATEGORIES);
+            const result = await client.query(GET_COLLECTIONS);
             return result.collections.items.filter(i => i.parent.id === parentId);
         }
     });
+
+    /*describe('filters', () => {
+        it('facetValue filter', async () => {
+            const result = await client.query(GET_COLLECTION_PRODUCT_VARIANTS, { id: electronicsCategory.id });
+            expect(result.collection.productVariants.items.map(i => i.name)).toEqual([
+                '',
+            ]);
+        });
+    });*/
 });
 
-const GET_CATEGORIES = gql`
-    query GetCategories {
+const GET_COLLECTIONS = gql`
+    query GetCollections {
         collections(languageCode: en) {
             items {
                 id
@@ -293,25 +266,17 @@ const GET_CATEGORIES = gql`
     }
 `;
 
-const GET_DECENDANT_FACET_VALUES = gql`
-    query GetDescendantFacetValues($id: ID!) {
-        collection(id: $id) {
-            id
-            descendantFacetValues {
-                id
-                name
-            }
-        }
-    }
-`;
-
-const GET_ANCESTOR_FACET_VALUES = gql`
-    query GetAncestorFacetValues($id: ID!) {
+const GET_COLLECTION_PRODUCT_VARIANTS = gql`
+    query GetCollectionProducts($id: ID!) {
         collection(id: $id) {
-            id
-            ancestorFacetValues {
-                id
-                name
+            productVariants {
+                items {
+                    id
+                    name
+                    facetValues {
+                        code
+                    }
+                }
             }
         }
     }

+ 19 - 0
server/src/api/resolvers/admin/collection.resolver.ts

@@ -1,6 +1,7 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
 import {
+    AdjustmentOperation,
     CollectionQueryArgs,
     CollectionsQueryArgs,
     CreateCollectionMutationArgs,
@@ -10,6 +11,7 @@ import {
 } from '../../../../../shared/generated-types';
 import { PaginatedList } from '../../../../../shared/shared-types';
 import { Translated } from '../../../common/types/locale-types';
+import { CollectionFilter } from '../../../config/collection/collection-filter';
 import { Collection } from '../../../entity/collection/collection.entity';
 import { CollectionService } from '../../../service/services/collection.service';
 import { FacetValueService } from '../../../service/services/facet-value.service';
@@ -22,6 +24,23 @@ import { Ctx } from '../../decorators/request-context.decorator';
 export class CollectionResolver {
     constructor(private collectionService: CollectionService, private facetValueService: FacetValueService) {}
 
+    @Query()
+    @Allow(Permission.ReadCatalog)
+    async collectionFilters(
+        @Ctx() ctx: RequestContext,
+        @Args() args: CollectionsQueryArgs,
+    ): Promise<AdjustmentOperation[]> {
+        // TODO: extract to common util bc it is used in at least 3 places.
+        const toAdjustmentOperation = (source: CollectionFilter<any>) => {
+            return {
+                code: source.code,
+                description: source.description,
+                args: Object.entries(source.args).map(([name, type]) => ({ name, type })),
+            };
+        };
+        return this.collectionService.getAvailableFilters().map(toAdjustmentOperation);
+    }
+
     @Query()
     @Allow(Permission.ReadCatalog)
     async collections(

+ 11 - 20
server/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -1,32 +1,23 @@
-import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
 
+import { ProductVariantListOptions } from '../../../../../shared/generated-types';
+import { PaginatedList } from '../../../../../shared/shared-types';
 import { Translated } from '../../../common/types/locale-types';
-import { Collection } from '../../../entity/collection/collection.entity';
-import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
-import { CollectionService } from '../../../service/services/collection.service';
-import { FacetValueService } from '../../../service/services/facet-value.service';
+import { Collection, ProductVariant } from '../../../entity';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Collection')
 export class CollectionEntityResolver {
-    constructor(private collectionService: CollectionService, private facetValueService: FacetValueService) {}
+    constructor(private productVariantService: ProductVariantService) {}
 
     @ResolveProperty()
-    async descendantFacetValues(
+    async productVariants(
         @Ctx() ctx: RequestContext,
-        @Parent() category: Collection,
-    ): Promise<Array<Translated<FacetValue>>> {
-        const descendants = await this.collectionService.getDescendants(ctx, category.id);
-        return this.facetValueService.findByCategoryIds(ctx, descendants.map(d => d.id));
-    }
-
-    @ResolveProperty()
-    async ancestorFacetValues(
-        @Ctx() ctx: RequestContext,
-        @Parent() category: Collection,
-    ): Promise<Array<Translated<FacetValue>>> {
-        const ancestors = await this.collectionService.getAncestors(category.id, ctx);
-        return this.facetValueService.findByCategoryIds(ctx, ancestors.map(d => d.id));
+        @Parent() collection: Collection,
+        @Args() args: { options: ProductVariantListOptions },
+    ): Promise<PaginatedList<Translated<ProductVariant>>> {
+        return this.productVariantService.getVariantsByCollectionId(ctx, collection.id, args.options);
     }
 }

+ 3 - 2
server/src/api/schema/admin-api/collection.api.graphql

@@ -1,6 +1,7 @@
 type Query {
     collections(languageCode: LanguageCode, options: CollectionListOptions): CollectionList!
     collection(id: ID!, languageCode: LanguageCode): Collection
+    collectionFilters: [AdjustmentOperation!]!
 }
 
 type Mutation {
@@ -34,7 +35,7 @@ input CreateCollectionInput {
     featuredAssetId: ID
     assetIds: [ID!]
     parentId: ID
-    facetValueIds: [ID!]
+    filters: [AdjustmentOperationInput!]!
     translations: [CollectionTranslationInput!]!
 }
 
@@ -43,6 +44,6 @@ input UpdateCollectionInput {
     featuredAssetId: ID
     parentId: ID
     assetIds: [ID!]
-    facetValueIds: [ID!]
+    filters: [AdjustmentOperationInput!]!
     translations: [CollectionTranslationInput!]!
 }

+ 1 - 3
server/src/api/schema/admin-api/product.api.graphql

@@ -27,9 +27,7 @@ type Mutation {
 }
 
 # generated by generateListOptions function
-input ProductListOptions {
-    categoryId: ID
-}
+input ProductListOptions
 
 input ProductTranslationInput {
     id: ID

+ 10 - 3
server/src/api/schema/type/collection.type.graphql

@@ -10,10 +10,9 @@ type Collection implements Node {
     assets: [Asset!]!
     parent: Collection!
     children: [Collection!]
-    facetValues: [FacetValue!]!
-    descendantFacetValues: [FacetValue!]!
-    ancestorFacetValues: [FacetValue!]!
+    filters: [AdjustmentOperation!]!
     translations: [CollectionTranslation!]!
+    productVariants(options: ProductVariantListOptions): ProductVariantList!
 }
 
 type CollectionTranslation {
@@ -29,3 +28,11 @@ type CollectionList implements PaginatedList {
     items: [Collection!]!
     totalItems: Int!
 }
+
+
+type ProductVariantList implements PaginatedList {
+    items: [ProductVariant!]!
+    totalItems: Int!
+}
+
+input ProductVariantListOptions

+ 62 - 0
server/src/config/collection/collection-filter.ts

@@ -0,0 +1,62 @@
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+import { ConfigArg } from '../../../../shared/generated-types';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+import { argsArrayToHash, ConfigArgs, ConfigArgValues } from '../common/config-args';
+
+export type CollectionFilterArgType = 'facetValueIds';
+export type CollectionFilterArgs = ConfigArgs<CollectionFilterArgType>;
+
+export type ApplyCollectionFilterFn<T extends CollectionFilterArgs> = (
+    qb: SelectQueryBuilder<ProductVariant>,
+    args: ConfigArgValues<T>,
+) => SelectQueryBuilder<ProductVariant>;
+
+export interface CollectionFilterConfig<T extends CollectionFilterArgs> {
+    args: T;
+    code: string;
+    description: string;
+    apply: ApplyCollectionFilterFn<T>;
+}
+
+export class CollectionFilter<T extends CollectionFilterArgs = {}> {
+    readonly code: string;
+    readonly args: CollectionFilterArgs;
+    readonly description: string;
+    private readonly applyFn: ApplyCollectionFilterFn<T>;
+
+    constructor(config: CollectionFilterConfig<T>) {
+        this.code = config.code;
+        this.description = config.description;
+        this.args = config.args;
+        this.applyFn = config.apply;
+    }
+
+    apply(qb: SelectQueryBuilder<ProductVariant>, args: ConfigArg[]) {
+        return this.applyFn(qb, argsArrayToHash(args));
+    }
+}
+
+export const facetValueCollectionFilter = new CollectionFilter({
+    args: {
+        facetValueIds: 'facetValueIds',
+    },
+    code: 'facet-value-filter',
+    description: 'Filter by FacetValues',
+    apply: (qb, args) => {
+        qb.leftJoin('productVariant.product', 'product')
+            .leftJoin('product.facetValues', 'productFacetValues')
+            .leftJoin('productVariant.facetValues', 'variantFacetValues')
+            .andWhere(
+                new Brackets(qb1 => {
+                    const ids = args.facetValueIds;
+                    return qb1
+                        .where(`productFacetValues.id IN (:...ids)`, { ids })
+                        .orWhere(`variantFacetValues.id IN (:...ids)`, { ids });
+                }),
+            )
+            .groupBy('productVariant.id')
+            .having(`COUNT(1) = :count`, { count: args.facetValueIds.length });
+        return qb;
+    },
+});

+ 7 - 5
server/src/entity/collection/collection.entity.ts

@@ -5,11 +5,11 @@ import {
     ManyToMany,
     ManyToOne,
     OneToMany,
-    Tree,
     TreeChildren,
     TreeParent,
 } from 'typeorm';
 
+import { AdjustmentOperation } from '../../../../shared/generated-types';
 import { DeepPartial, HasCustomFields } from '../../../../shared/shared-types';
 import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
@@ -17,13 +17,13 @@ import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { CustomCollectionFields } from '../custom-entity-fields';
-import { FacetValue } from '../facet-value/facet-value.entity';
+import { ProductVariant } from '../product-variant/product-variant.entity';
 
 import { CollectionTranslation } from './collection-translation.entity';
 
 /**
  * @description
- * A Collection is a grouping of {@link Product}s based on {@link FacetValue}s.
+ * A Collection is a grouping of {@link Product}s based on various configurable criteria.
  *
  * @docsCategory entities
  */
@@ -58,9 +58,11 @@ export class Collection extends VendureEntity implements Translatable, HasCustom
     @JoinTable()
     assets: Asset[];
 
-    @ManyToMany(type => FacetValue)
+    @Column('simple-json') filters: AdjustmentOperation[];
+
+    @ManyToMany(type => ProductVariant, productVariant => productVariant.collections)
     @JoinTable()
-    facetValues: FacetValue[];
+    productVariants: ProductVariant[];
 
     @Column(type => CustomCollectionFields)
     customFields: CustomCollectionFields;

+ 4 - 0
server/src/entity/product-variant/product-variant.entity.ts

@@ -5,6 +5,7 @@ import { DeepPartial, HasCustomFields } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
+import { Collection } from '../collection/collection.entity';
 import { CustomProductVariantFields } from '../custom-entity-fields';
 import { FacetValue } from '../facet-value/facet-value.entity';
 import { ProductOption } from '../product-option/product-option.entity';
@@ -93,4 +94,7 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
 
     @Column(type => CustomProductVariantFields)
     customFields: CustomProductVariantFields;
+
+    @ManyToMany(type => Collection, collection => collection.productVariants)
+    collections: Collection[];
 }

+ 74 - 44
server/src/service/services/collection.service.ts

@@ -1,16 +1,24 @@
 import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
+import {
+    AdjustmentOperation,
+    CreateCollectionInput,
+    MoveCollectionInput,
+    UpdateCollectionInput,
+} from '../../../../shared/generated-types';
 import { ROOT_CATEGORY_NAME } from '../../../../shared/shared-constants';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { RequestContext } from '../../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
-import { IllegalOperationError } from '../../common/error/errors';
+import { IllegalOperationError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
+import { CollectionFilter, facetValueCollectionFilter } from '../../config/collection/collection-filter';
 import { CollectionTranslation } from '../../entity/collection/collection-translation.entity';
 import { Collection } from '../../entity/collection/collection.entity';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
@@ -22,6 +30,7 @@ import { FacetValueService } from './facet-value.service';
 
 export class CollectionService {
     private rootCategories: { [channelCode: string]: Collection } = {};
+    private availableFilters: Array<CollectionFilter<any>> = [facetValueCollectionFilter];
 
     constructor(
         @InjectConnection() private connection: Connection,
@@ -36,7 +45,7 @@ export class CollectionService {
         ctx: RequestContext,
         options?: ListQueryOptions<Collection>,
     ): Promise<PaginatedList<Translated<Collection>>> {
-        const relations = ['featuredAsset', 'facetValues', 'facetValues.facet', 'parent', 'channels'];
+        const relations = ['featuredAsset', 'parent', 'channels'];
 
         return this.listQueryBuilder
             .build(Collection, options, {
@@ -48,11 +57,7 @@ export class CollectionService {
             .getManyAndCount()
             .then(async ([collections, totalItems]) => {
                 const items = collections.map(collection =>
-                    translateDeep(collection, ctx.languageCode, [
-                        'facetValues',
-                        'parent',
-                        ['facetValues', 'facet'],
-                    ]),
+                    translateDeep(collection, ctx.languageCode, ['parent']),
                 );
                 return {
                     items,
@@ -62,34 +67,18 @@ export class CollectionService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Collection> | undefined> {
-        const relations = ['featuredAsset', 'assets', 'facetValues', 'channels', 'parent'];
+        const relations = ['featuredAsset', 'assets', 'channels', 'parent'];
         const collection = await this.connection.getRepository(Collection).findOne(productId, {
             relations,
         });
         if (!collection) {
             return;
         }
-        return translateDeep(collection, ctx.languageCode, ['facetValues', 'parent']);
+        return translateDeep(collection, ctx.languageCode, ['parent']);
     }
 
-    /**
-     * Given a categoryId, returns an array of all the facetValueIds assigned to that
-     * category and its ancestors. A Product is considered to be "in" a category when it has *all*
-     * of these facetValues assigned to it.
-     */
-    async getFacetValueIdsForCategory(categoryId: ID): Promise<ID[]> {
-        const category = await this.connection
-            .getRepository(Collection)
-            .findOne(categoryId, { relations: ['facetValues'] });
-        if (!category) {
-            return [];
-        }
-        const ancestors = await this.getAncestors(categoryId);
-        const facetValueIds = [category, ...ancestors].reduce(
-            (flat, c) => [...flat, ...c.facetValues.map(fv => fv.id)],
-            [] as ID[],
-        );
-        return facetValueIds;
+    getAvailableFilters(): Array<CollectionFilter<any>> {
+        return this.availableFilters;
     }
 
     /**
@@ -149,43 +138,41 @@ export class CollectionService {
             });
     }
 
-    async create(ctx: RequestContext, input: any): Promise<Translated<Collection>> {
+    async create(ctx: RequestContext, input: CreateCollectionInput): Promise<Translated<Collection>> {
         const collection = await this.translatableSaver.create({
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async category => {
-                await this.channelService.assignToChannels(category, ctx);
+            beforeSave: async coll => {
+                await this.channelService.assignToChannels(coll, ctx);
                 const parent = await this.getParentCategory(ctx, input.parentId);
                 if (parent) {
-                    category.parent = parent;
+                    coll.parent = parent;
                 }
-                category.position = await this.getNextPositionInParent(ctx, input.parentId || undefined);
-                if (input.facetValueIds) {
-                    category.facetValues = await this.facetValueService.findByIds(input.facetValueIds);
-                }
-                await this.assetUpdater.updateEntityAssets(category, input);
+                coll.position = await this.getNextPositionInParent(ctx, input.parentId || undefined);
+                coll.filters = this.getCollectionFiltersFromInput(input);
+                coll.productVariants = await this.applyCollectionFilters(coll.filters);
+                await this.assetUpdater.updateEntityAssets(coll, input);
             },
         });
         return assertFound(this.findOne(ctx, collection.id));
     }
 
-    async update(ctx: RequestContext, input: any): Promise<Translated<Collection>> {
+    async update(ctx: RequestContext, input: UpdateCollectionInput): Promise<Translated<Collection>> {
         const collection = await this.translatableSaver.update({
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async category => {
-                if (input.facetValueIds) {
-                    category.facetValues = await this.facetValueService.findByIds(input.facetValueIds);
-                }
-                await this.assetUpdater.updateEntityAssets(category, input);
+            beforeSave: async coll => {
+                coll.filters = this.getCollectionFiltersFromInput(input);
+                coll.productVariants = await this.applyCollectionFilters(coll.filters);
+                await this.assetUpdater.updateEntityAssets(coll, input);
             },
         });
         return assertFound(this.findOne(ctx, collection.id));
     }
 
-    async move(ctx: RequestContext, input: any): Promise<Translated<Collection>> {
+    async move(ctx: RequestContext, input: MoveCollectionInput): Promise<Translated<Collection>> {
         const target = await getEntityOrThrow(this.connection, Collection, input.categoryId, {
             relations: ['parent'],
         });
@@ -227,10 +214,44 @@ export class CollectionService {
         return assertFound(this.findOne(ctx, input.categoryId));
     }
 
+    private getCollectionFiltersFromInput(
+        input: CreateCollectionInput | UpdateCollectionInput,
+    ): AdjustmentOperation[] {
+        const filters: AdjustmentOperation[] = [];
+        if (input.filters) {
+            for (const filter of input.filters) {
+                const match = this.getFilterByCode(filter.code);
+                const output = {
+                    code: filter.code,
+                    description: match.description,
+                    args: filter.arguments.map((inputArg, i) => {
+                        return {
+                            name: inputArg.name,
+                            type: match.args[inputArg.name],
+                            value: inputArg.value,
+                        };
+                    }),
+                };
+                filters.push(output);
+            }
+        }
+        return filters;
+    }
+
+    private async applyCollectionFilters(filters: AdjustmentOperation[]): Promise<ProductVariant[]> {
+        let qb = this.connection.getRepository(ProductVariant).createQueryBuilder('productVariant');
+        for (const filter of filters) {
+            if (filter.code === facetValueCollectionFilter.code) {
+                qb = facetValueCollectionFilter.apply(qb, filter.args);
+            }
+        }
+        return qb.getMany();
+    }
+
     /**
      * Returns the next position value in the given parent category.
      */
-    async getNextPositionInParent(ctx: RequestContext, maybeParentId?: ID): Promise<number> {
+    private async getNextPositionInParent(ctx: RequestContext, maybeParentId?: ID): Promise<number> {
         const parentId = maybeParentId || (await this.getRootCategory(ctx)).id;
         const result = await this.connection
             .getRepository(Collection)
@@ -292,10 +313,19 @@ export class CollectionService {
             position: 0,
             translations: [rootTranslation],
             channels: [ctx.channel],
+            filters: [],
         });
 
         await this.connection.getRepository(Collection).save(newRoot);
         this.rootCategories[ctx.channel.code] = newRoot;
         return newRoot;
     }
+
+    private getFilterByCode(code: string): CollectionFilter<any> {
+        const match = this.availableFilters.find(a => a.code === code);
+        if (!match) {
+            throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
+        }
+        return match;
+    }
 }

+ 35 - 2
server/src/service/services/product-variant.service.ts

@@ -3,16 +3,16 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
 import { CreateProductVariantInput, UpdateProductVariantInput } from '../../../../shared/generated-types';
-import { ID } from '../../../../shared/shared-types';
+import { ID, PaginatedList } from '../../../../shared/shared-types';
 import { generateAllCombinations } from '../../../../shared/shared-utils';
 import { RequestContext } from '../../api/common/request-context';
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
+import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { TaxCategory } from '../../entity';
-import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 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';
@@ -20,6 +20,7 @@ import { Product } from '../../entity/product/product.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
+import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TaxCalculator } from '../helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -43,6 +44,7 @@ export class ProductVariantService {
         private zoneService: ZoneService,
         private translatableSaver: TranslatableSaver,
         private eventBus: EventBus,
+        private listQueryBuilder: ListQueryBuilder,
     ) {}
 
     findOne(ctx: RequestContext, productVariantId: ID): Promise<Translated<ProductVariant> | undefined> {
@@ -85,6 +87,37 @@ export class ProductVariantService {
             );
     }
 
+    getVariantsByCollectionId(
+        ctx: RequestContext,
+        collectionId: ID,
+        options: ListQueryOptions<ProductVariant>,
+    ): Promise<PaginatedList<Translated<ProductVariant>>> {
+        const relations = ['product', 'product.featuredAsset', 'taxCategory'];
+
+        return this.listQueryBuilder
+            .build(ProductVariant, options, {
+                relations,
+                channelId: ctx.channelId,
+            })
+            .leftJoin('productvariant.collections', 'collection')
+            .where('collection.id = :collectionId', { collectionId })
+            .getManyAndCount()
+            .then(async ([variants, totalItems]) => {
+                const items = variants.map(variant => {
+                    const variantWithPrices = this.applyChannelPriceAndTax(variant, ctx);
+                    return translateDeep(variantWithPrices, ctx.languageCode, [
+                        'options',
+                        'facetValues',
+                        ['facetValues', 'facet'],
+                    ]);
+                });
+                return {
+                    items,
+                    totalItems,
+                };
+            });
+    }
+
     getOptionsForVariant(ctx: RequestContext, variantId: ID): Promise<Array<Translated<ProductOption>>> {
         return this.connection
             .getRepository(ProductVariant)

+ 3 - 27
server/src/service/services/product.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { Connection, FindConditions, In } from 'typeorm';
+import { Connection } from 'typeorm';
 
 import {
     CreateProductInput,
@@ -26,7 +26,6 @@ import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ChannelService } from './channel.service';
-import { CollectionService } from './collection.service';
 import { FacetValueService } from './facet-value.service';
 import { ProductVariantService } from './product-variant.service';
 import { TaxRateService } from './tax-rate.service';
@@ -47,7 +46,6 @@ export class ProductService {
         private channelService: ChannelService,
         private assetUpdater: AssetUpdater,
         private productVariantService: ProductVariantService,
-        private collectionService: CollectionService,
         private facetValueService: FacetValueService,
         private taxRateService: TaxRateService,
         private listQueryBuilder: ListQueryBuilder,
@@ -57,19 +55,13 @@ export class ProductService {
 
     async findAll(
         ctx: RequestContext,
-        options?: ListQueryOptions<Product> & { categoryId?: string | null },
+        options?: ListQueryOptions<Product>,
     ): Promise<PaginatedList<Translated<Product>>> {
-        let where: FindConditions<Product> | undefined;
-        if (options && options.categoryId) {
-            where = {
-                id: In(await this.getProductIdsInCategory(options.categoryId)),
-            };
-        }
         return this.listQueryBuilder
             .build(Product, options, {
                 relations: this.relations,
                 channelId: ctx.channelId,
-                where: { ...where, deletedAt: null },
+                where: { deletedAt: null },
             })
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
@@ -180,22 +172,6 @@ export class ProductService {
         return assertFound(this.findOne(ctx, productId));
     }
 
-    private async getProductIdsInCategory(categoryId: ID): Promise<ID[]> {
-        const facetValueIds = await this.collectionService.getFacetValueIdsForCategory(categoryId);
-        const qb = this.connection
-            .getRepository(Product)
-            .createQueryBuilder('product')
-            .select(['product.id'])
-            .innerJoin('product.facetValues', 'facetValue', 'facetValue.id IN (:...facetValueIds)', {
-                facetValueIds,
-            })
-            .groupBy('product.id')
-            .having('count(distinct facetValue.id) = :idCount', { idCount: facetValueIds.length });
-
-        const productIds = await qb.getRawMany().then(rows => rows.map(r => r.product_id));
-        return productIds;
-    }
-
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)

+ 59 - 6
shared/generated-shop-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-03-04T11:47:05+01:00
+// Generated in 2019-03-05T13:49:42+01:00
 export type Maybe<T> = T | null;
 
 export interface OrderListOptions {
@@ -142,6 +142,52 @@ export interface CollectionFilterParameter {
     description?: Maybe<StringOperators>;
 }
 
+export interface ProductVariantListOptions {
+    skip?: Maybe<number>;
+
+    take?: Maybe<number>;
+
+    sort?: Maybe<ProductVariantSortParameter>;
+
+    filter?: Maybe<ProductVariantFilterParameter>;
+}
+
+export interface ProductVariantSortParameter {
+    id?: Maybe<SortOrder>;
+
+    createdAt?: Maybe<SortOrder>;
+
+    updatedAt?: Maybe<SortOrder>;
+
+    sku?: Maybe<SortOrder>;
+
+    name?: Maybe<SortOrder>;
+
+    price?: Maybe<SortOrder>;
+
+    priceWithTax?: Maybe<SortOrder>;
+}
+
+export interface ProductVariantFilterParameter {
+    createdAt?: Maybe<DateOperators>;
+
+    updatedAt?: Maybe<DateOperators>;
+
+    languageCode?: Maybe<StringOperators>;
+
+    sku?: Maybe<StringOperators>;
+
+    name?: Maybe<StringOperators>;
+
+    price?: Maybe<NumberOperators>;
+
+    currencyCode?: Maybe<StringOperators>;
+
+    priceIncludesTax?: Maybe<BooleanOperators>;
+
+    priceWithTax?: Maybe<NumberOperators>;
+}
+
 export interface ProductListOptions {
     skip?: Maybe<number>;
 
@@ -1326,14 +1372,12 @@ export interface Collection extends Node {
 
     children?: Maybe<Collection[]>;
 
-    facetValues: FacetValue[];
-
-    descendantFacetValues: FacetValue[];
-
-    ancestorFacetValues: FacetValue[];
+    filters: AdjustmentOperation[];
 
     translations: CollectionTranslation[];
 
+    productVariants: ProductVariantList;
+
     customFields?: Maybe<Json>;
 }
 
@@ -1351,6 +1395,12 @@ export interface CollectionTranslation {
     description: string;
 }
 
+export interface ProductVariantList extends PaginatedList {
+    items: ProductVariant[];
+
+    totalItems: number;
+}
+
 export interface ShippingMethodQuote {
     id: string;
 
@@ -1717,6 +1767,9 @@ export interface SearchQueryArgs {
 export interface OrdersCustomerArgs {
     options?: Maybe<OrderListOptions>;
 }
+export interface ProductVariantsCollectionArgs {
+    options?: Maybe<ProductVariantListOptions>;
+}
 export interface AddItemToOrderMutationArgs {
     productVariantId: string;
 

+ 258 - 216
shared/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-03-04T11:47:06+01:00
+// Generated in 2019-03-05T13:49:43+01:00
 export type Maybe<T> = T | null;
 
 
@@ -171,6 +171,60 @@ export interface CollectionFilterParameter {
   description?: Maybe<StringOperators>;
 }
 
+export interface ProductVariantListOptions {
+  
+  skip?: Maybe<number>;
+  
+  take?: Maybe<number>;
+  
+  sort?: Maybe<ProductVariantSortParameter>;
+  
+  filter?: Maybe<ProductVariantFilterParameter>;
+}
+
+export interface ProductVariantSortParameter {
+  
+  id?: Maybe<SortOrder>;
+  
+  createdAt?: Maybe<SortOrder>;
+  
+  updatedAt?: Maybe<SortOrder>;
+  
+  sku?: Maybe<SortOrder>;
+  
+  name?: Maybe<SortOrder>;
+  
+  price?: Maybe<SortOrder>;
+  
+  priceWithTax?: Maybe<SortOrder>;
+}
+
+export interface ProductVariantFilterParameter {
+  
+  createdAt?: Maybe<DateOperators>;
+  
+  updatedAt?: Maybe<DateOperators>;
+  
+  languageCode?: Maybe<StringOperators>;
+  
+  sku?: Maybe<StringOperators>;
+  
+  name?: Maybe<StringOperators>;
+  
+  price?: Maybe<NumberOperators>;
+  
+  currencyCode?: Maybe<StringOperators>;
+  
+  priceIncludesTax?: Maybe<BooleanOperators>;
+  
+  priceWithTax?: Maybe<NumberOperators>;
+}
+
+export interface BooleanOperators {
+  
+  eq?: Maybe<boolean>;
+}
+
 export interface CountryListOptions {
   
   skip?: Maybe<number>;
@@ -202,11 +256,6 @@ export interface CountryFilterParameter {
   enabled?: Maybe<BooleanOperators>;
 }
 
-export interface BooleanOperators {
-  
-  eq?: Maybe<boolean>;
-}
-
 export interface CustomerListOptions {
   
   skip?: Maybe<number>;
@@ -414,8 +463,6 @@ export interface ProductListOptions {
   sort?: Maybe<ProductSortParameter>;
   
   filter?: Maybe<ProductFilterParameter>;
-  
-  categoryId?: Maybe<string>;
 }
 
 export interface ProductSortParameter {
@@ -665,13 +712,27 @@ export interface CreateCollectionInput {
   
   parentId?: Maybe<string>;
   
-  facetValueIds?: Maybe<string[]>;
+  filters: AdjustmentOperationInput[];
   
   translations: CollectionTranslationInput[];
   
   customFields?: Maybe<Json>;
 }
 
+export interface AdjustmentOperationInput {
+  
+  code: string;
+  
+  arguments: ConfigArgInput[];
+}
+
+export interface ConfigArgInput {
+  
+  name: string;
+  
+  value: string;
+}
+
 export interface CollectionTranslationInput {
   
   id?: Maybe<string>;
@@ -695,7 +756,7 @@ export interface UpdateCollectionInput {
   
   assetIds?: Maybe<string[]>;
   
-  facetValueIds?: Maybe<string[]>;
+  filters: AdjustmentOperationInput[];
   
   translations: CollectionTranslationInput[];
   
@@ -933,13 +994,6 @@ export interface UpdatePaymentMethodInput {
   configArgs?: Maybe<ConfigArgInput[]>;
 }
 
-export interface ConfigArgInput {
-  
-  name: string;
-  
-  value: string;
-}
-
 export interface CreateProductOptionGroupInput {
   
   code: string;
@@ -1068,13 +1122,6 @@ export interface CreatePromotionInput {
   actions: AdjustmentOperationInput[];
 }
 
-export interface AdjustmentOperationInput {
-  
-  code: string;
-  
-  arguments: ConfigArgInput[];
-}
-
 export interface UpdatePromotionInput {
   
   id: string;
@@ -2581,6 +2628,19 @@ export namespace CreateAssets {
   export type CreateAssets = Asset.Fragment
 }
 
+export namespace GetCollectionFilters {
+  export type Variables = {
+  }
+
+  export type Query = {
+    __typename?: "Query";
+    
+    collectionFilters: CollectionFilters[];
+  }
+
+  export type CollectionFilters = AdjustmentOperation.Fragment
+}
+
 export namespace GetCollectionList {
   export type Variables = {
     options?: Maybe<CollectionListOptions>;
@@ -2612,33 +2672,11 @@ export namespace GetCollectionList {
     
     featuredAsset: Maybe<FeaturedAsset>;
     
-    facetValues: FacetValues[];
-    
     parent: Parent;
   } 
 
   export type FeaturedAsset = Asset.Fragment
 
-  export type FacetValues = {
-    __typename?: "FacetValue";
-    
-    id: string;
-    
-    code: string;
-    
-    name: string;
-    
-    facet: Facet;
-  } 
-
-  export type Facet = {
-    __typename?: "Facet";
-    
-    id: string;
-    
-    name: string;
-  } 
-
   export type Parent = {
     __typename?: "Collection";
     
@@ -4102,7 +4140,7 @@ export namespace Collection {
     
     assets: Assets[];
     
-    facetValues: FacetValues[];
+    filters: Filters[];
     
     translations: Translations[];
     
@@ -4115,15 +4153,7 @@ export namespace Collection {
 
   export type Assets =Asset.Fragment
 
-  export type FacetValues = {
-    __typename?: "FacetValue";
-    
-    id: string;
-    
-    name: string;
-    
-    code: string;
-  }
+  export type Filters =AdjustmentOperation.Fragment
 
   export type Translations = {
     __typename?: "CollectionTranslation";
@@ -4449,6 +4479,8 @@ export interface Query {
   
   collection?: Maybe<Collection>;
   
+  collectionFilters: AdjustmentOperation[];
+  
   config: Config;
   
   countries: CountryList;
@@ -4727,18 +4759,180 @@ export interface Collection extends Node {
   
   children?: Maybe<Collection[]>;
   
+  filters: AdjustmentOperation[];
+  
+  translations: CollectionTranslation[];
+  
+  productVariants: ProductVariantList;
+  
+  customFields?: Maybe<Json>;
+}
+
+
+export interface AdjustmentOperation {
+  
+  code: string;
+  
+  args: ConfigArg[];
+  
+  description: string;
+}
+
+
+export interface ConfigArg {
+  
+  name: string;
+  
+  type: string;
+  
+  value?: Maybe<string>;
+}
+
+
+export interface CollectionTranslation {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode: LanguageCode;
+  
+  name: string;
+  
+  description: string;
+}
+
+
+export interface ProductVariantList extends PaginatedList {
+  
+  items: ProductVariant[];
+  
+  totalItems: number;
+}
+
+
+export interface ProductVariant extends Node {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode: LanguageCode;
+  
+  sku: string;
+  
+  name: string;
+  
+  featuredAsset?: Maybe<Asset>;
+  
+  assets: Asset[];
+  
+  price: number;
+  
+  currencyCode: CurrencyCode;
+  
+  priceIncludesTax: boolean;
+  
+  priceWithTax: number;
+  
+  taxRateApplied: TaxRate;
+  
+  taxCategory: TaxCategory;
+  
+  options: ProductOption[];
+  
   facetValues: FacetValue[];
   
-  descendantFacetValues: FacetValue[];
+  translations: ProductVariantTranslation[];
   
-  ancestorFacetValues: FacetValue[];
+  customFields?: Maybe<Json>;
+}
+
+
+export interface TaxRate extends Node {
   
-  translations: CollectionTranslation[];
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  name: string;
+  
+  enabled: boolean;
+  
+  value: number;
+  
+  category: TaxCategory;
+  
+  zone: Zone;
+  
+  customerGroup?: Maybe<CustomerGroup>;
+}
+
+
+export interface TaxCategory extends Node {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  name: string;
+}
+
+
+export interface CustomerGroup extends Node {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  name: string;
+}
+
+
+export interface ProductOption extends Node {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode?: Maybe<LanguageCode>;
+  
+  code?: Maybe<string>;
+  
+  name?: Maybe<string>;
+  
+  translations: ProductOptionTranslation[];
   
   customFields?: Maybe<Json>;
 }
 
 
+export interface ProductOptionTranslation {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode: LanguageCode;
+  
+  name: string;
+}
+
+
 export interface FacetValue extends Node {
   
   id: string;
@@ -4811,7 +5005,7 @@ export interface FacetValueTranslation {
 }
 
 
-export interface CollectionTranslation {
+export interface ProductVariantTranslation {
   
   id: string;
   
@@ -4822,8 +5016,6 @@ export interface CollectionTranslation {
   languageCode: LanguageCode;
   
   name: string;
-  
-  description: string;
 }
 
 
@@ -4841,18 +5033,6 @@ export interface CountryList extends PaginatedList {
 }
 
 
-export interface CustomerGroup extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  name: string;
-}
-
-
 export interface CustomerList extends PaginatedList {
   
   items: Customer[];
@@ -5025,128 +5205,6 @@ export interface OrderLine extends Node {
 }
 
 
-export interface ProductVariant extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode: LanguageCode;
-  
-  sku: string;
-  
-  name: string;
-  
-  featuredAsset?: Maybe<Asset>;
-  
-  assets: Asset[];
-  
-  price: number;
-  
-  currencyCode: CurrencyCode;
-  
-  priceIncludesTax: boolean;
-  
-  priceWithTax: number;
-  
-  taxRateApplied: TaxRate;
-  
-  taxCategory: TaxCategory;
-  
-  options: ProductOption[];
-  
-  facetValues: FacetValue[];
-  
-  translations: ProductVariantTranslation[];
-  
-  customFields?: Maybe<Json>;
-}
-
-
-export interface TaxRate extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  name: string;
-  
-  enabled: boolean;
-  
-  value: number;
-  
-  category: TaxCategory;
-  
-  zone: Zone;
-  
-  customerGroup?: Maybe<CustomerGroup>;
-}
-
-
-export interface TaxCategory extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  name: string;
-}
-
-
-export interface ProductOption extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode?: Maybe<LanguageCode>;
-  
-  code?: Maybe<string>;
-  
-  name?: Maybe<string>;
-  
-  translations: ProductOptionTranslation[];
-  
-  customFields?: Maybe<Json>;
-}
-
-
-export interface ProductOptionTranslation {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode: LanguageCode;
-  
-  name: string;
-}
-
-
-export interface ProductVariantTranslation {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode: LanguageCode;
-  
-  name: string;
-}
-
-
 export interface OrderItem extends Node {
   
   id: string;
@@ -5217,26 +5275,6 @@ export interface ShippingMethod extends Node {
 }
 
 
-export interface AdjustmentOperation {
-  
-  code: string;
-  
-  args: ConfigArg[];
-  
-  description: string;
-}
-
-
-export interface ConfigArg {
-  
-  name: string;
-  
-  type: string;
-  
-  value?: Maybe<string>;
-}
-
-
 export interface FacetList extends PaginatedList {
   
   items: Facet[];
@@ -5836,6 +5874,10 @@ export interface ZoneQueryArgs {
   
   id: string;
 }
+export interface ProductVariantsCollectionArgs {
+  
+  options?: Maybe<ProductVariantListOptions>;
+}
 export interface OrdersCustomerArgs {
   
   options?: Maybe<OrderListOptions>;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff