Browse Source

feat(admin-ui): Facet detail component with create / update of Facet

Michael Bromley 7 years ago
parent
commit
bb5d0bf827

+ 4 - 2
admin-ui/src/app/catalog/catalog.module.ts

@@ -6,13 +6,14 @@ import { SharedModule } from '../shared/shared.module';
 import { catalogRoutes } from './catalog.routes';
 import { CreateOptionGroupDialogComponent } from './components/create-option-group-dialog/create-option-group-dialog.component';
 import { CreateOptionGroupFormComponent } from './components/create-option-group-form/create-option-group-form.component';
+import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductVariantsWizardComponent } from './components/product-variants-wizard/product-variants-wizard.component';
 import { SelectOptionGroupDialogComponent } from './components/select-option-group-dialog/select-option-group-dialog.component';
 import { SelectOptionGroupComponent } from './components/select-option-group/select-option-group.component';
-import { ProductUpdaterService } from './providers/product-updater/product-updater.service';
+import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
 
 @NgModule({
@@ -27,8 +28,9 @@ import { ProductResolver } from './providers/routing/product-resolver';
         CreateOptionGroupFormComponent,
         SelectOptionGroupComponent,
         FacetListComponent,
+        FacetDetailComponent,
     ],
     entryComponents: [CreateOptionGroupDialogComponent, SelectOptionGroupDialogComponent],
-    providers: [ProductResolver, ProductUpdaterService],
+    providers: [ProductResolver, FacetResolver],
 })
 export class CatalogModule {}

+ 35 - 0
admin-ui/src/app/catalog/catalog.routes.ts

@@ -4,9 +4,11 @@ import { map } from 'rxjs/operators';
 import { _ } from '../core/providers/i18n/mark-for-extraction';
 import { DataService } from '../data/providers/data.service';
 
+import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
+import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
 
 export const catalogRoutes: Route[] = [
@@ -34,6 +36,16 @@ export const catalogRoutes: Route[] = [
             breadcrumb: _('breadcrumb.facets'),
         },
     },
+    {
+        path: 'facets/:id',
+        component: FacetDetailComponent,
+        resolve: {
+            facet: FacetResolver,
+        },
+        data: {
+            breadcrumb: facetBreadcrumb,
+        },
+    },
 ];
 
 export function productBreadcrumb(data: any, params: any, dataService: DataService) {
@@ -58,3 +70,26 @@ export function productBreadcrumb(data: any, params: any, dataService: DataServi
         }),
     );
 }
+
+export function facetBreadcrumb(data: any, params: any, dataService: DataService) {
+    return dataService.facet.getFacet(params.id).stream$.pipe(
+        map(facetData => {
+            let facetLabel = '';
+            if (params.id === 'create') {
+                facetLabel = 'common.create';
+            } else {
+                facetLabel = `#${params.id} (${facetData.facet && facetData.facet.name})`;
+            }
+            return [
+                {
+                    label: _('breadcrumb.facets'),
+                    link: ['../', 'facets'],
+                },
+                {
+                    label: facetLabel,
+                    link: [params.id],
+                },
+            ];
+        }),
+    );
+}

+ 51 - 0
admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.html

@@ -0,0 +1,51 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+        <clr-dropdown>
+            <button type="button" class="btn btn-outline-primary" clrDropdownTrigger>
+                <clr-icon shape="world"></clr-icon>
+                {{ 'common.language' | translate }}: {{ languageCode$ | async | uppercase }}
+                <clr-icon shape="caret down"></clr-icon>
+            </button>
+            <clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
+                <button type="button"
+                        *ngFor="let code of availableLanguages$ | async"
+                        (click)="setLanguage(code)"
+                        clrDropdownItem>{{ code }}</button>
+            </clr-dropdown-menu>
+        </clr-dropdown>
+    </vdr-ab-left>
+
+
+    <vdr-ab-right>
+        <button class="btn btn-primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="facetForm.invalid || facetForm.pristine">{{ 'common.create' | translate }}</button>
+        <ng-template #updateButton>
+            <button class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="facetForm.invalid || facetForm.pristine">{{ 'common.update' | translate }}</button>
+        </ng-template>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<form class="form" [formGroup]="facetForm" >
+    <section class="form-block" formGroupName="facet">
+        <label>{{ 'catalog.facet' | translate }}</label>
+        <vdr-form-field [label]="'catalog.code' | translate" for="code">
+            <input id="code" type="text" formControlName="code">
+        </vdr-form-field>
+        <vdr-form-field [label]="'catalog.name' | translate" for="name">
+            <input id="name" type="text" formControlName="name">
+        </vdr-form-field>
+
+        <section formGroupName="customFields" *ngIf="customFields.length">
+            <label>{{ 'catalog.custom-fields' }}</label>
+            <ng-container *ngFor="let customField of customFields">
+                <vdr-custom-field-control *ngIf="customFieldIsSet(customField.name)"
+                                          [customFieldsFormGroup]="facetForm.get(['facet', 'customFields'])"
+                                          [customField]="customField"></vdr-custom-field-control>
+            </ng-container>
+        </section>
+    </section>
+</form>

+ 0 - 0
admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.scss


+ 191 - 0
admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.ts

@@ -0,0 +1,191 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { combineLatest, forkJoin, Observable, Subject } from 'rxjs';
+import { map, mergeMap, switchMap, take, takeUntil } from 'rxjs/operators';
+
+import { CustomFieldConfig } from '../../../../../../shared/shared-types';
+import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
+import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { getServerConfig } from '../../../data/server-config';
+import { FacetWithValues, LanguageCode } from '../../../data/types/gql-generated-types';
+
+@Component({
+    selector: 'vdr-facet-detail',
+    templateUrl: './facet-detail.component.html',
+    styleUrls: ['./facet-detail.component.scss'],
+})
+export class FacetDetailComponent implements OnInit, OnDestroy {
+    facet$: Observable<FacetWithValues>;
+    availableLanguages$: Observable<LanguageCode[]>;
+    customFields: CustomFieldConfig[];
+    languageCode$: Observable<LanguageCode>;
+    isNew$: Observable<boolean>;
+    facetForm: FormGroup;
+    private destroy$ = new Subject<void>();
+
+    constructor(
+        private dataService: DataService,
+        private router: Router,
+        private route: ActivatedRoute,
+        private formBuilder: FormBuilder,
+        private notificationService: NotificationService,
+    ) {}
+
+    ngOnInit() {
+        this.customFields = getServerConfig().customFields.Facet || [];
+        this.facet$ = this.route.data.pipe(switchMap(data => data.facet));
+        this.facetForm = this.formBuilder.group({
+            facet: this.formBuilder.group({
+                code: ['', Validators.required],
+                name: '',
+                customFields: this.formBuilder.group(
+                    this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+                ),
+            }),
+        });
+
+        this.isNew$ = this.facet$.pipe(map(facet => facet.id === ''));
+        this.languageCode$ = this.route.queryParamMap.pipe(
+            map(qpm => qpm.get('lang')),
+            map(lang => (!lang ? getDefaultLanguage() : (lang as LanguageCode))),
+        );
+
+        this.availableLanguages$ = this.facet$.pipe(map(p => p.translations.map(t => t.languageCode)));
+
+        combineLatest(this.facet$, this.languageCode$)
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(([facet, languageCode]) => this.setFormValues(facet, languageCode));
+    }
+
+    ngOnDestroy() {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    setLanguage(code: LanguageCode) {
+        this.setQueryParam('lang', code);
+    }
+
+    customFieldIsSet(name: string): boolean {
+        return !!this.facetForm.get(['facet', 'customFields', name]);
+    }
+
+    create() {
+        const facetForm = this.facetForm.get('facet');
+        if (!facetForm || !facetForm.dirty) {
+            return;
+        }
+        combineLatest(this.facet$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([facet, languageCode]) => {
+                    const newFacet = this.getUpdatedFacet(facet, facetForm as FormGroup, languageCode);
+                    return this.dataService.facet.createFacet(newFacet);
+                }),
+            )
+            .subscribe(
+                data => {
+                    this.notificationService.success(_('catalog.notify-create-facet-success'));
+                    this.facetForm.markAsPristine();
+                    this.router.navigate(['../', data.createFacet.id], { relativeTo: this.route });
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-create-facet-error'));
+                },
+            );
+    }
+
+    save() {
+        combineLatest(this.facet$, this.languageCode$)
+            .pipe(
+                take(1),
+                mergeMap(([facet, languageCode]) => {
+                    const facetGroup = this.facetForm.get('facet');
+                    const updateOperations: Array<Observable<any>> = [];
+
+                    if (facetGroup && facetGroup.dirty) {
+                        const newFacet = this.getUpdatedFacet(facet, facetGroup as FormGroup, languageCode);
+                        if (newFacet) {
+                            updateOperations.push(this.dataService.facet.updateFacet(newFacet));
+                        }
+                    }
+                    /* const variantsArray = this.facetForm.get('variants');
+                    if (variantsArray && variantsArray.dirty) {
+                        const newVariants = this.getUpdatedFacetValues(facet, variantsArray as FormArray, languageCode);
+                        updateOperations.push(this.dataService.facet.updateFacetVariants(newVariants));
+                    }*/
+
+                    return forkJoin(updateOperations);
+                }),
+            )
+            .subscribe(
+                () => {
+                    this.facetForm.markAsPristine();
+                    this.notificationService.success(_('catalog.notify-update-facet-success'));
+                },
+                err => {
+                    this.notificationService.error(_('catalog.notify-update-facet-error'), {
+                        error: err.message,
+                    });
+                },
+            );
+    }
+
+    /**
+     * Sets the values of the form on changes to the facet or current language.
+     */
+    private setFormValues(facet: FacetWithValues, languageCode: LanguageCode) {
+        const currentTranslation = facet.translations.find(t => t.languageCode === languageCode);
+        if (currentTranslation) {
+            this.facetForm.patchValue({
+                facet: {
+                    code: facet.code,
+                    name: currentTranslation.name,
+                },
+            });
+
+            if (this.customFields.length) {
+                const customFieldsGroup = this.facetForm.get(['facet', 'customFields']) as FormGroup;
+
+                for (const fieldDef of this.customFields) {
+                    const key = fieldDef.name;
+                    const value =
+                        fieldDef.type === 'localeString'
+                            ? (currentTranslation as any).customFields[key]
+                            : (facet as any).customFields[key];
+                    const control = customFieldsGroup.get(key);
+                    if (control) {
+                        control.patchValue(value);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Given a facet and the value of the facetForm, this method creates an updated copy of the facet which
+     * can then be persisted to the API.
+     */
+    private getUpdatedFacet(
+        facet: FacetWithValues,
+        facetFormGroup: FormGroup,
+        languageCode: LanguageCode,
+    ): any {
+        return createUpdatedTranslatable(facet, facetFormGroup.value, this.customFields, languageCode, {
+            languageCode,
+            name: facet.name || '',
+        });
+    }
+
+    private setQueryParam(key: string, value: any) {
+        this.router.navigate(['./'], {
+            queryParams: { [key]: value },
+            relativeTo: this.route,
+            queryParamsHandling: 'merge',
+        });
+    }
+}

+ 0 - 243
admin-ui/src/app/catalog/providers/product-updater/product-updater.service.spec.ts

@@ -1,243 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-
-import { CustomFieldConfig } from '../../../../../../shared/shared-types';
-import { GetProductWithVariants_product, LanguageCode } from '../../../data/types/gql-generated-types';
-
-import { ProductUpdaterService } from './product-updater.service';
-
-// tslint:disable:no-non-null-assertion
-describe('ProductUpdaterService', () => {
-    let productUpdaterService: ProductUpdaterService;
-    let product: any;
-
-    beforeEach(() => {
-        TestBed.configureTestingModule({
-            providers: [ProductUpdaterService],
-        });
-        productUpdaterService = TestBed.get(ProductUpdaterService);
-    });
-
-    beforeEach(() => {
-        product = {
-            id: '1',
-            languageCode: LanguageCode.en,
-            name: 'Old Name EN',
-            image: 'old-image.jpg',
-            translations: [
-                { languageCode: LanguageCode.en, name: 'Old Name EN' },
-                { languageCode: LanguageCode.de, name: 'Old Name DE' },
-            ],
-            variants: [
-                {
-                    id: '11',
-                    name: 'Old Variant 1 Name EN',
-                    sku: '11A',
-                    price: 11,
-                    translations: [
-                        { languageCode: LanguageCode.en, name: 'Old Variant 1 Name EN' },
-                        { languageCode: LanguageCode.de, name: 'Old Variant 1 Name DE' },
-                    ],
-                },
-                {
-                    id: '12',
-                    name: 'Old Variant 2 Name EN',
-                    sku: '12A',
-                    price: 12,
-                    translations: [
-                        { languageCode: LanguageCode.en, name: 'Old Variant 2 Name EN' },
-                        { languageCode: LanguageCode.de, name: 'Old Variant 2 Name DE' },
-                    ],
-                },
-            ],
-        } as Partial<GetProductWithVariants_product>;
-    });
-
-    describe('getUpdatedProduct()', () => {
-        it('returns a clone', () => {
-            const formValue = {};
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
-
-            expect(result).not.toBe(product);
-        });
-
-        it('creates new translation if the specified translation does not exist', () => {
-            const formValue = {
-                name: 'New Name AA',
-            };
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.aa);
-
-            expect(result.translations[2]).toEqual({
-                languageCode: LanguageCode.aa,
-                name: 'New Name AA',
-                description: '',
-                slug: '',
-            });
-        });
-
-        it('updates the non-translatable properties', () => {
-            const formValue = {
-                image: 'new-image.jpg',
-            };
-
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
-
-            if (!result) {
-                fail('Expected result to be truthy');
-                return;
-            }
-
-            expect(result.image).toBe('new-image.jpg');
-        });
-
-        it('updates only the specified translation', () => {
-            const formValue = {
-                name: 'New Name EN',
-            };
-
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
-
-            if (!result) {
-                fail('Expected result to be truthy');
-                return;
-            }
-
-            expect(result.translations).not.toBe(product.translations);
-            expect(result.translations[0]!.name).toBe('New Name EN');
-            expect(result.translations[1]!.name).toBe('Old Name DE');
-        });
-
-        it('updates custom fields correctly', () => {
-            const customFieldConfig: CustomFieldConfig[] = [
-                { name: 'available', type: 'boolean' },
-                { name: 'shortName', type: 'localeString' },
-            ];
-            product.customFields = {
-                available: true,
-                shortName: 'foo',
-            };
-            product.translations[0].customFields = { shortName: 'foo' };
-
-            const formValue = {
-                customFields: {
-                    available: false,
-                    shortName: 'bar',
-                },
-            };
-
-            const result = productUpdaterService.getUpdatedProduct(
-                product,
-                formValue,
-                customFieldConfig,
-                LanguageCode.en,
-            );
-
-            if (!result) {
-                fail('Expected result to be truthy');
-                return;
-            }
-
-            expect((result as any).customFields).toEqual({
-                available: false,
-            });
-            expect((result.translations[0] as any).customFields).toEqual({
-                shortName: 'bar',
-            });
-        });
-
-        it('updates custom fields when none initially exists', () => {
-            const customFieldConfig: CustomFieldConfig[] = [
-                { name: 'available', type: 'boolean' },
-                { name: 'shortName', type: 'localeString' },
-            ];
-
-            const formValue = {
-                customFields: {
-                    available: false,
-                    shortName: 'bar',
-                },
-            };
-
-            const result = productUpdaterService.getUpdatedProduct(
-                product,
-                formValue,
-                customFieldConfig,
-                LanguageCode.en,
-            );
-
-            if (!result) {
-                fail('Expected result to be truthy');
-                return;
-            }
-
-            expect((result as any).customFields).toEqual({
-                available: false,
-            });
-            expect((result.translations[0] as any).customFields).toEqual({
-                shortName: 'bar',
-            });
-        });
-    });
-
-    describe('getUpdatedProductVariants()', () => {
-        it('returns a new array and cloned objects within the array', () => {
-            const formValue = [{ name: 'New Variant 1 Name EN' }, { name: 'New Variant 2 Name EN' }];
-            const result = productUpdaterService.getUpdatedProductVariants(
-                product.variants,
-                formValue,
-                [],
-                LanguageCode.en,
-            );
-
-            expect(result).not.toBe(product.variants);
-            expect(result[0]).not.toBe(product.variants[0]);
-            expect(result[1]).not.toBe(product.variants[1]);
-        });
-
-        it('throws if the length of the formValues array does not match the number of variants', () => {
-            const formValue = [{ name: 'New Variant 1 Name EN' }];
-            const invoke = () =>
-                productUpdaterService.getUpdatedProductVariants(
-                    product.variants,
-                    formValue,
-                    [],
-                    LanguageCode.en,
-                );
-
-            expect(invoke).toThrowError('error.product-variant-form-values-do-not-match');
-        });
-
-        it('updates the non-translatable properties', () => {
-            const formValue = [{ price: 98, sku: '11B' }, { price: 99, sku: '12B' }];
-
-            const result = productUpdaterService.getUpdatedProductVariants(
-                product.variants,
-                formValue,
-                [],
-                LanguageCode.en,
-            );
-
-            expect(result[0].price).toBe(98);
-            expect(result[0].sku).toBe('11B');
-            expect(result[1].price).toBe(99);
-            expect(result[1].sku).toBe('12B');
-        });
-
-        it('updates only the specified translation', () => {
-            const formValue = [{ name: 'New Variant 1 Name EN' }, { name: 'New Variant 2 Name EN' }];
-
-            const result = productUpdaterService.getUpdatedProductVariants(
-                product.variants,
-                formValue,
-                [],
-                LanguageCode.en,
-            );
-
-            expect(result[0].translations).not.toBe(product.variants[0].translations);
-            expect(result[0].translations[0].name).toBe('New Variant 1 Name EN');
-            expect(result[0].translations[1].name).toBe('Old Variant 1 Name DE');
-            expect(result[1].translations).not.toBe(product.variants[1].translations);
-            expect(result[1].translations[0].name).toBe('New Variant 2 Name EN');
-            expect(result[1].translations[1].name).toBe('Old Variant 2 Name DE');
-        });
-    });
-});

+ 0 - 113
admin-ui/src/app/catalog/providers/product-updater/product-updater.service.ts

@@ -1,113 +0,0 @@
-import { Injectable } from '@angular/core';
-
-import {
-    CustomFieldConfig,
-    CustomFieldsObject,
-    MayHaveCustomFields,
-} from '../../../../../../shared/shared-types';
-import { notNullOrUndefined } from '../../../../../../shared/shared-utils';
-import { _ } from '../../../core/providers/i18n/mark-for-extraction';
-import {
-    GetProductWithVariants_product,
-    GetProductWithVariants_product_variants,
-    LanguageCode,
-    UpdateProductInput,
-    UpdateProductVariantInput,
-} from '../../../data/types/gql-generated-types';
-
-/**
- * A utility class containing method used to prepare product data from the ProductDetailComponent for
- * sending to the API.
- */
-@Injectable()
-export class ProductUpdaterService {
-    /**
-     * Given a product and the value of the productForm, this method creates an updated copy of the product which
-     * can then be persisted to the API.
-     */
-    getUpdatedProduct(
-        product: GetProductWithVariants_product,
-        formValue: { [key: string]: any },
-        customFieldConfig: CustomFieldConfig[],
-        languageCode: LanguageCode,
-    ): UpdateProductInput {
-        return this.createUpdatedTranslatable(product, formValue, customFieldConfig, languageCode, {
-            languageCode,
-            name: product.name || '',
-            slug: product.slug || '',
-            description: product.description || '',
-        });
-    }
-
-    /**
-     * Given an array of product variants and the values from the productForm, this method creates an new array
-     * which can be persisted to the API.
-     */
-    getUpdatedProductVariants(
-        variants: GetProductWithVariants_product_variants[],
-        formValue: Array<{ [key: string]: any }>,
-        customFieldConfig: CustomFieldConfig[],
-        languageCode: LanguageCode,
-    ): UpdateProductVariantInput[] {
-        if (variants.length !== formValue.length) {
-            throw new Error(_(`error.product-variant-form-values-do-not-match`));
-        }
-        return variants
-            .map((variant, i) => {
-                return this.createUpdatedTranslatable(variant, formValue[i], customFieldConfig, languageCode);
-            })
-            .filter(notNullOrUndefined);
-    }
-
-    private createUpdatedTranslatable<T extends { translations: any[] } & MayHaveCustomFields>(
-        translatable: T,
-        updatedFields: { [key: string]: any },
-        customFieldConfig: CustomFieldConfig[],
-        languageCode: LanguageCode,
-        defaultTranslation?: Partial<T['translations'][number]>,
-    ): T {
-        const currentTranslation =
-            translatable.translations.find(t => t.languageCode === languageCode) || defaultTranslation;
-        const index = translatable.translations.indexOf(currentTranslation);
-        const newTranslation = this.patchObject(currentTranslation, updatedFields);
-        const customFields = translatable.customFields;
-        const newCustomFields: CustomFieldsObject = {};
-        const newTranslatedCustomFields: CustomFieldsObject = {};
-        if (customFieldConfig && updatedFields.hasOwnProperty('customFields')) {
-            for (const field of customFieldConfig) {
-                const value = updatedFields.customFields[field.name];
-                if (field.type === 'localeString') {
-                    newTranslatedCustomFields[field.name] = value;
-                } else {
-                    newCustomFields[field.name] = value;
-                }
-            }
-            newTranslation.customFields = newTranslatedCustomFields;
-        }
-        const newTranslatable = {
-            ...(this.patchObject(translatable, updatedFields) as any),
-            ...{ translations: translatable.translations.slice() },
-            customFields: newCustomFields,
-        };
-        if (index !== -1) {
-            newTranslatable.translations.splice(index, 1, newTranslation);
-        } else {
-            newTranslatable.translations.push(newTranslation);
-        }
-        return newTranslatable;
-    }
-
-    /**
-     * Returns a shallow clone of `obj` with any properties contained in `patch` overwriting
-     * those of `obj`.
-     */
-    private patchObject<T extends { [key: string]: any }>(obj: T, patch: { [key: string]: any }): T {
-        const clone = Object.assign({}, obj);
-        Object.keys(clone).forEach(key => {
-            if (patch.hasOwnProperty(key)) {
-                clone[key] = patch[key];
-            }
-        });
-        return clone;
-    }
-}

+ 51 - 0
admin-ui/src/app/catalog/providers/routing/facet-resolver.ts

@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { filter, map, take } from 'rxjs/operators';
+
+import { notNullOrUndefined } from '../../../../../../shared/shared-utils';
+import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
+import { DataService } from '../../../data/providers/data.service';
+import { FacetWithValues } from '../../../data/types/gql-generated-types';
+
+/**
+ * Resolves the id from the path into a Customer entity.
+ */
+
+@Injectable()
+export class FacetResolver implements Resolve<Observable<FacetWithValues>> {
+    constructor(private dataService: DataService) {}
+
+    resolve(
+        route: ActivatedRouteSnapshot,
+        state: RouterStateSnapshot,
+    ): Observable<Observable<FacetWithValues>> {
+        const id = route.paramMap.get('id');
+
+        if (id === 'create') {
+            return of(
+                of({
+                    __typename: 'Facet' as 'Facet',
+                    id: '',
+                    languageCode: getDefaultLanguage(),
+                    name: '',
+                    code: '',
+                    translations: [],
+                    values: [],
+                }),
+            );
+        } else if (id) {
+            const stream = this.dataService.facet
+                .getFacet(id)
+                .mapStream(data => data.facet)
+                .pipe(filter(notNullOrUndefined));
+
+            return stream.pipe(
+                take(1),
+                map(() => stream),
+            );
+        } else {
+            return {} as any;
+        }
+    }
+}

+ 3 - 3
admin-ui/src/app/catalog/providers/routing/product-resolver.ts

@@ -6,20 +6,20 @@ import { filter, map, take } from 'rxjs/operators';
 import { notNullOrUndefined } from '../../../../../../shared/shared-utils';
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { DataService } from '../../../data/providers/data.service';
-import { GetProductWithVariants_product } from '../../../data/types/gql-generated-types';
+import { ProductWithVariants } from '../../../data/types/gql-generated-types';
 
 /**
  * Resolves the id from the path into a Customer entity.
  */
 
 @Injectable()
-export class ProductResolver implements Resolve<Observable<GetProductWithVariants_product>> {
+export class ProductResolver implements Resolve<Observable<ProductWithVariants>> {
     constructor(private dataService: DataService) {}
 
     resolve(
         route: ActivatedRouteSnapshot,
         state: RouterStateSnapshot,
-    ): Observable<Observable<GetProductWithVariants_product>> {
+    ): Observable<Observable<ProductWithVariants>> {
         const id = route.paramMap.get('id');
 
         if (id === 'create') {

+ 26 - 0
admin-ui/src/app/data/fragments/facet-fragments.ts

@@ -0,0 +1,26 @@
+import gql from 'graphql-tag';
+
+export const FACET_WITH_VALUES_FRAGMENT = gql`
+    fragment FacetWithValues on Facet {
+        id
+        languageCode
+        code
+        name
+        translations {
+            id
+            languageCode
+            name
+        }
+        values {
+            id
+            languageCode
+            code
+            name
+            translations {
+                id
+                languageCode
+                name
+            }
+        }
+    }
+`;

+ 21 - 0
admin-ui/src/app/data/mutations/facet-mutations.ts

@@ -0,0 +1,21 @@
+import gql from 'graphql-tag';
+
+import { FACET_WITH_VALUES_FRAGMENT } from '../fragments/facet-fragments';
+
+export const CREATE_FACET = gql`
+    mutation CreateFacet($input: CreateFacetInput) {
+        createFacet(input: $input) {
+            ...FacetWithValues
+        }
+    }
+    ${FACET_WITH_VALUES_FRAGMENT}
+`;
+
+export const UPDATE_FACET = gql`
+    mutation UpdateFacet($input: UpdateFacetInput) {
+        updateFacet(input: $input) {
+            ...FacetWithValues
+        }
+    }
+    ${FACET_WITH_VALUES_FRAGMENT}
+`;

+ 3 - 0
admin-ui/src/app/data/providers/data.service.mock.ts

@@ -58,5 +58,8 @@ export class MockDataService implements DataServiceMock {
     };
     facet = {
         getFacets: spyQueryResult('getFacets'),
+        getFacet: spyQueryResult('getFacet'),
+        createFacet: spyObservable('createFacet'),
+        updateFacet: spyObservable('updateFacet'),
     };
 }

+ 57 - 2
admin-ui/src/app/data/providers/facet-data.service.ts

@@ -1,6 +1,21 @@
+import { Observable } from 'rxjs';
+
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
-import { GET_FACET_LIST } from '../queries/facet-queries';
-import { GetFacetList, GetFacetListVariables } from '../types/gql-generated-types';
+import { addCustomFields } from '../add-custom-fields';
+import { CREATE_FACET, UPDATE_FACET } from '../mutations/facet-mutations';
+import { GET_FACET_LIST, GET_FACET_WITH_VALUES } from '../queries/facet-queries';
+import {
+    CreateFacet,
+    CreateFacetInput,
+    CreateFacetVariables,
+    GetFacetList,
+    GetFacetListVariables,
+    GetFacetWithValues,
+    GetFacetWithValuesVariables,
+    UpdateFacet,
+    UpdateFacetInput,
+    UpdateFacetVariables,
+} from '../types/gql-generated-types';
 import { QueryResult } from '../types/query-result';
 
 import { BaseDataService } from './base-data.service';
@@ -17,4 +32,44 @@ export class FacetDataService {
             languageCode: getDefaultLanguage(),
         });
     }
+
+    getFacet(id: string): QueryResult<GetFacetWithValues, GetFacetWithValuesVariables> {
+        return this.baseDataService.query<GetFacetWithValues, GetFacetWithValuesVariables>(
+            addCustomFields(GET_FACET_WITH_VALUES),
+            {
+                id,
+                languageCode: getDefaultLanguage(),
+            },
+        );
+    }
+
+    createFacet(facet: CreateFacetInput): Observable<CreateFacet> {
+        const input: CreateFacetVariables = {
+            input: {
+                code: facet.code,
+                translations: facet.translations,
+                values: facet.values,
+                customFields: facet.customFields,
+            },
+        };
+        return this.baseDataService.mutate<CreateFacet, CreateFacetVariables>(
+            addCustomFields(CREATE_FACET),
+            input,
+        );
+    }
+
+    updateFacet(facet: UpdateFacetInput): Observable<UpdateFacet> {
+        const input: UpdateFacetVariables = {
+            input: {
+                id: facet.id,
+                code: facet.code,
+                translations: facet.translations,
+                customFields: facet.customFields,
+            },
+        };
+        return this.baseDataService.mutate<UpdateFacet, UpdateFacetVariables>(
+            addCustomFields(UPDATE_FACET),
+            input,
+        );
+    }
 }

+ 11 - 0
admin-ui/src/app/data/queries/facet-queries.ts

@@ -1,5 +1,7 @@
 import gql from 'graphql-tag';
 
+import { FACET_WITH_VALUES_FRAGMENT } from '../fragments/facet-fragments';
+
 export const GET_FACET_LIST = gql`
     query GetFacetList($options: FacetListOptions, $languageCode: LanguageCode) {
         facets(languageCode: $languageCode, options: $options) {
@@ -13,3 +15,12 @@ export const GET_FACET_LIST = gql`
         }
     }
 `;
+
+export const GET_FACET_WITH_VALUES = gql`
+    query GetFacetWithValues($id: ID!, $languageCode: LanguageCode) {
+        facet(id: $id, languageCode: $languageCode) {
+            ...FacetWithValues
+        }
+    }
+    ${FACET_WITH_VALUES_FRAGMENT}
+`;

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

@@ -1,6 +1,108 @@
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL mutation operation: CreateFacet
+// ====================================================
+
+export interface CreateFacet_createFacet_translations {
+  __typename: "FacetTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface CreateFacet_createFacet_values_translations {
+  __typename: "FacetValueTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface CreateFacet_createFacet_values {
+  __typename: "FacetValue";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string;
+  name: string;
+  translations: CreateFacet_createFacet_values_translations[];
+}
+
+export interface CreateFacet_createFacet {
+  __typename: "Facet";
+  id: string;
+  languageCode: LanguageCode;
+  code: string;
+  name: string;
+  translations: CreateFacet_createFacet_translations[];
+  values: CreateFacet_createFacet_values[];
+}
+
+export interface CreateFacet {
+  /**
+   * Create a new Facet
+   */
+  createFacet: CreateFacet_createFacet;
+}
+
+export interface CreateFacetVariables {
+  input?: CreateFacetInput | null;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: UpdateFacet
+// ====================================================
+
+export interface UpdateFacet_updateFacet_translations {
+  __typename: "FacetTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface UpdateFacet_updateFacet_values_translations {
+  __typename: "FacetValueTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface UpdateFacet_updateFacet_values {
+  __typename: "FacetValue";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string;
+  name: string;
+  translations: UpdateFacet_updateFacet_values_translations[];
+}
+
+export interface UpdateFacet_updateFacet {
+  __typename: "Facet";
+  id: string;
+  languageCode: LanguageCode;
+  code: string;
+  name: string;
+  translations: UpdateFacet_updateFacet_translations[];
+  values: UpdateFacet_updateFacet_values[];
+}
+
+export interface UpdateFacet {
+  /**
+   * Update an existing Facet
+   */
+  updateFacet: UpdateFacet_updateFacet;
+}
+
+export interface UpdateFacetVariables {
+  input?: UpdateFacetInput | null;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL mutation operation: RequestStarted
 // ====================================================
@@ -501,6 +603,55 @@ export interface GetFacetListVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL query operation: GetFacetWithValues
+// ====================================================
+
+export interface GetFacetWithValues_facet_translations {
+  __typename: "FacetTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface GetFacetWithValues_facet_values_translations {
+  __typename: "FacetValueTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface GetFacetWithValues_facet_values {
+  __typename: "FacetValue";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string;
+  name: string;
+  translations: GetFacetWithValues_facet_values_translations[];
+}
+
+export interface GetFacetWithValues_facet {
+  __typename: "Facet";
+  id: string;
+  languageCode: LanguageCode;
+  code: string;
+  name: string;
+  translations: GetFacetWithValues_facet_translations[];
+  values: GetFacetWithValues_facet_values[];
+}
+
+export interface GetFacetWithValues {
+  facet: GetFacetWithValues_facet | null;
+}
+
+export interface GetFacetWithValuesVariables {
+  id: string;
+  languageCode?: LanguageCode | null;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL query operation: GetNetworkStatus
 // ====================================================
@@ -687,6 +838,46 @@ export interface GetProductOptionGroupsVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL fragment: FacetWithValues
+// ====================================================
+
+export interface FacetWithValues_translations {
+  __typename: "FacetTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface FacetWithValues_values_translations {
+  __typename: "FacetValueTranslation";
+  id: string;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface FacetWithValues_values {
+  __typename: "FacetValue";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string;
+  name: string;
+  translations: FacetWithValues_values_translations[];
+}
+
+export interface FacetWithValues {
+  __typename: "Facet";
+  id: string;
+  languageCode: LanguageCode;
+  code: string;
+  name: string;
+  translations: FacetWithValues_translations[];
+  values: FacetWithValues_values[];
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL fragment: ProductVariant
 // ====================================================
@@ -1019,6 +1210,46 @@ export enum SortOrder {
   DESC = "DESC",
 }
 
+export interface CreateFacetInput {
+  code: string;
+  translations: FacetTranslationInput[];
+  values?: CreateFacetValueInput[] | null;
+  customFields?: CreateFacetCustomFieldsInput | null;
+}
+
+export interface FacetTranslationInput {
+  id?: string | null;
+  languageCode: LanguageCode;
+  name: string;
+  customFields?: any | null;
+}
+
+export interface CreateFacetValueInput {
+  code: string;
+  translations: FacetValueTranslationInput[];
+}
+
+export interface FacetValueTranslationInput {
+  id?: string | null;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+export interface CreateFacetCustomFieldsInput {
+  searchable?: boolean | null;
+}
+
+export interface UpdateFacetInput {
+  id: string;
+  code: string;
+  translations: FacetTranslationInput[];
+  customFields?: UpdateFacetCustomFieldsInput | null;
+}
+
+export interface UpdateFacetCustomFieldsInput {
+  searchable?: boolean | null;
+}
+
 export interface UpdateProductInput {
   id: string;
   image?: string | null;
@@ -1106,6 +1337,7 @@ export interface FacetSortParameter {
   updatedAt?: SortOrder | null;
   name?: SortOrder | null;
   code?: SortOrder | null;
+  searchable?: SortOrder | null;
 }
 
 export interface FacetFilterParameter {
@@ -1113,6 +1345,7 @@ export interface FacetFilterParameter {
   code?: StringOperators | null;
   createdAt?: DateOperators | null;
   updatedAt?: DateOperators | null;
+  searchable?: BooleanOperators | null;
 }
 
 export interface StringOperators {
@@ -1132,6 +1365,10 @@ export interface DateRange {
   end: any;
 }
 
+export interface BooleanOperators {
+  eq?: boolean | null;
+}
+
 export interface ProductListOptions {
   take?: number | null;
   skip?: number | null;
@@ -1163,10 +1400,6 @@ export interface ProductFilterParameter {
   nickname?: StringOperators | null;
 }
 
-export interface BooleanOperators {
-  eq?: boolean | null;
-}
-
 //==============================================================
 // END Enums and Input Objects
 //==============================================================