Browse Source

feat(admin-ui): Initial implementation of customFields in prod detail

Michael Bromley 7 years ago
parent
commit
7230a2ff3a

+ 1 - 2
admin-ui/package.json

@@ -30,7 +30,6 @@
     "@ngx-translate/http-loader": "^3.0.1",
     "@webcomponents/custom-elements": "1.0.0",
     "apollo-angular": "^1.1.1",
-    "apollo-angular-cache-ngrx": "^1.0.0-beta.0",
     "apollo-angular-link-http": "^1.1.0",
     "apollo-cache-inmemory": "^1.2.7",
     "apollo-client": "^2.3.8",
@@ -48,7 +47,7 @@
   },
   "devDependencies": {
     "@angular-devkit/build-angular": "~0.6.6",
-    "@angular/cli": "~6.0.7",
+    "@angular/cli": "^6.1.4",
     "@angular/compiler-cli": "^6.0.3",
     "@angular/language-service": "^6.0.3",
     "@biesbjerg/ngx-translate-extract": "^2.3.4",

+ 9 - 0
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -41,6 +41,15 @@
         <vdr-form-field [label]="'catalog.description' | translate" for="description">
             <textarea id="description" formControlName="description"></textarea>
         </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]="productForm.get(['product', 'customFields'])"
+                                          [customField]="customField"></vdr-custom-field-control>
+            </ng-container>
+        </section>
     </section>
 
     <section class="form-block" *ngIf="!(isNew$ | async)">

+ 68 - 34
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -4,11 +4,13 @@ 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 { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { normalizeString } from '../../../common/utilities/normalize-string';
 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 {
     GetProductWithVariants_product_variants,
     LanguageCode,
@@ -27,6 +29,8 @@ export class ProductDetailComponent implements OnDestroy {
     product$: Observable<ProductWithVariants>;
     variants$: Observable<GetProductWithVariants_product_variants[]>;
     availableLanguages$: Observable<LanguageCode[]>;
+    customFields: CustomFieldConfig[];
+    customVariantFields: CustomFieldConfig[];
     languageCode$: Observable<LanguageCode>;
     isNew$: Observable<boolean>;
     productForm: FormGroup;
@@ -42,6 +46,8 @@ export class ProductDetailComponent implements OnDestroy {
         private notificationService: NotificationService,
         private productUpdaterService: ProductUpdaterService,
     ) {
+        this.customFields = getServerConfig().customFields.Product || [];
+        this.customVariantFields = getServerConfig().customFields.ProductVariant || [];
         this.product$ = this.route.data.pipe(switchMap(data => data.product));
         this.variants$ = this.product$.pipe(map(product => product.variants));
         this.productForm = this.formBuilder.group({
@@ -49,6 +55,9 @@ export class ProductDetailComponent implements OnDestroy {
                 name: ['', Validators.required],
                 slug: '',
                 description: '',
+                customFields: this.formBuilder.group(
+                    this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
+                ),
             }),
             variants: this.formBuilder.array([]),
         });
@@ -62,38 +71,7 @@ export class ProductDetailComponent implements OnDestroy {
 
         combineLatest(this.product$, this.languageCode$)
             .pipe(takeUntil(this.destroy$))
-            .subscribe(([product, languageCode]) => {
-                const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
-                if (currentTranslation) {
-                    this.productForm.patchValue({
-                        product: {
-                            name: currentTranslation.name,
-                            slug: currentTranslation.slug,
-                            description: currentTranslation.description,
-                        },
-                    });
-
-                    const variantsFormArray = this.productForm.get('variants') as FormArray;
-                    product.variants.forEach((variant, i) => {
-                        const variantTranslation = variant.translations.find(
-                            t => t.languageCode === languageCode,
-                        );
-
-                        const group = {
-                            sku: variant.sku,
-                            name: variantTranslation ? variantTranslation.name : '',
-                            price: variant.price,
-                        };
-
-                        const existing = variantsFormArray.at(i);
-                        if (existing) {
-                            existing.setValue(group);
-                        } else {
-                            variantsFormArray.insert(i, this.formBuilder.group(group));
-                        }
-                    });
-                }
-            });
+            .subscribe(([product, languageCode]) => this.setFormValues(product, languageCode));
     }
 
     ngOnDestroy() {
@@ -105,14 +83,17 @@ export class ProductDetailComponent implements OnDestroy {
         this.setQueryParam('lang', code);
     }
 
+    customFieldIsSet(name: string): boolean {
+        return !!this.productForm.get(['product', 'customFields', name]);
+    }
+
     /**
      * If creating a new product, automatically generate the slug based on the product name.
      */
     updateSlug(nameValue: string) {
         this.isNew$.pipe(take(1)).subscribe(isNew => {
             if (isNew) {
-                const productForm = this.productForm.get('product');
-                const slugControl = productForm && productForm.get('slug');
+                const slugControl = this.productForm.get(['product', 'slug']);
                 if (slugControl && slugControl.pristine) {
                     slugControl.setValue(normalizeString(`${nameValue}`, '-'));
                 }
@@ -132,6 +113,7 @@ export class ProductDetailComponent implements OnDestroy {
                     const newProduct = this.productUpdaterService.getUpdatedProduct(
                         product,
                         productGroup.value,
+                        this.customFields,
                         languageCode,
                     );
                     return this.dataService.product.createProduct(newProduct);
@@ -161,6 +143,7 @@ export class ProductDetailComponent implements OnDestroy {
                         const newProduct = this.productUpdaterService.getUpdatedProduct(
                             product,
                             productGroup.value,
+                            this.customFields,
                             languageCode,
                         );
                         if (newProduct) {
@@ -179,6 +162,7 @@ export class ProductDetailComponent implements OnDestroy {
                         const newVariants = this.productUpdaterService.getUpdatedProductVariants(
                             dirtyVariants,
                             dirtyVariantValues,
+                            this.customVariantFields,
                             languageCode,
                         );
                         updateOperations.push(this.dataService.product.updateProductVariants(newVariants));
@@ -222,6 +206,56 @@ export class ProductDetailComponent implements OnDestroy {
             .subscribe();
     }
 
+    /**
+     * Sets the values of the form on changes to the product or current language.
+     */
+    private setFormValues(product: ProductWithVariants, languageCode: LanguageCode) {
+        const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
+        if (currentTranslation) {
+            this.productForm.patchValue({
+                product: {
+                    name: currentTranslation.name,
+                    slug: currentTranslation.slug,
+                    description: currentTranslation.description,
+                },
+            });
+
+            if (this.customFields.length) {
+                const customFieldsGroup = this.productForm.get(['product', 'customFields']) as FormGroup;
+
+                for (const fieldDef of this.customFields) {
+                    const key = fieldDef.name;
+                    const value =
+                        fieldDef.type === 'localeString'
+                            ? (currentTranslation as any).customFields[key]
+                            : (product as any).customFields[key];
+                    const control = customFieldsGroup.get(key);
+                    if (control) {
+                        control.patchValue(value);
+                    }
+                }
+            }
+
+            const variantsFormArray = this.productForm.get('variants') as FormArray;
+            product.variants.forEach((variant, i) => {
+                const variantTranslation = variant.translations.find(t => t.languageCode === languageCode);
+
+                const group = {
+                    sku: variant.sku,
+                    name: variantTranslation ? variantTranslation.name : '',
+                    price: variant.price,
+                };
+
+                const existing = variantsFormArray.at(i);
+                if (existing) {
+                    existing.setValue(group);
+                } else {
+                    variantsFormArray.insert(i, this.formBuilder.group(group));
+                }
+            });
+        }
+    }
+
     private setQueryParam(key: string, value: any) {
         this.router.navigate(['./'], {
             queryParams: { [key]: value },

+ 85 - 5
admin-ui/src/app/catalog/providers/product-updater/product-updater.service.spec.ts

@@ -1,5 +1,6 @@
 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';
@@ -54,7 +55,7 @@ describe('ProductUpdaterService', () => {
     describe('getUpdatedProduct()', () => {
         it('returns a clone', () => {
             const formValue = {};
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, LanguageCode.en);
+            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
 
             expect(result).not.toBe(product);
         });
@@ -63,7 +64,7 @@ describe('ProductUpdaterService', () => {
             const formValue = {
                 name: 'New Name AA',
             };
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, LanguageCode.aa);
+            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.aa);
 
             expect(result.translations[2]).toEqual({
                 languageCode: LanguageCode.aa,
@@ -78,7 +79,7 @@ describe('ProductUpdaterService', () => {
                 image: 'new-image.jpg',
             };
 
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, LanguageCode.en);
+            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
 
             if (!result) {
                 fail('Expected result to be truthy');
@@ -93,7 +94,7 @@ describe('ProductUpdaterService', () => {
                 name: 'New Name EN',
             };
 
-            const result = productUpdaterService.getUpdatedProduct(product, formValue, LanguageCode.en);
+            const result = productUpdaterService.getUpdatedProduct(product, formValue, [], LanguageCode.en);
 
             if (!result) {
                 fail('Expected result to be truthy');
@@ -104,6 +105,77 @@ describe('ProductUpdaterService', () => {
             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()', () => {
@@ -112,6 +184,7 @@ describe('ProductUpdaterService', () => {
             const result = productUpdaterService.getUpdatedProductVariants(
                 product.variants,
                 formValue,
+                [],
                 LanguageCode.en,
             );
 
@@ -123,7 +196,12 @@ describe('ProductUpdaterService', () => {
         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);
+                productUpdaterService.getUpdatedProductVariants(
+                    product.variants,
+                    formValue,
+                    [],
+                    LanguageCode.en,
+                );
 
             expect(invoke).toThrowError('error.product-variant-form-values-do-not-match');
         });
@@ -134,6 +212,7 @@ describe('ProductUpdaterService', () => {
             const result = productUpdaterService.getUpdatedProductVariants(
                 product.variants,
                 formValue,
+                [],
                 LanguageCode.en,
             );
 
@@ -149,6 +228,7 @@ describe('ProductUpdaterService', () => {
             const result = productUpdaterService.getUpdatedProductVariants(
                 product.variants,
                 formValue,
+                [],
                 LanguageCode.en,
             );
 

+ 26 - 3
admin-ui/src/app/catalog/providers/product-updater/product-updater.service.ts

@@ -1,5 +1,10 @@
 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 {
@@ -23,9 +28,10 @@ export class ProductUpdaterService {
     getUpdatedProduct(
         product: GetProductWithVariants_product,
         formValue: { [key: string]: any },
+        customFieldConfig: CustomFieldConfig[],
         languageCode: LanguageCode,
     ): UpdateProductInput {
-        return this.createUpdatedTranslatable(product, formValue, languageCode, {
+        return this.createUpdatedTranslatable(product, formValue, customFieldConfig, languageCode, {
             languageCode,
             name: product.name || '',
             slug: product.slug || '',
@@ -40,6 +46,7 @@ export class ProductUpdaterService {
     getUpdatedProductVariants(
         variants: GetProductWithVariants_product_variants[],
         formValue: Array<{ [key: string]: any }>,
+        customFieldConfig: CustomFieldConfig[],
         languageCode: LanguageCode,
     ): UpdateProductVariantInput[] {
         if (variants.length !== formValue.length) {
@@ -47,14 +54,15 @@ export class ProductUpdaterService {
         }
         return variants
             .map((variant, i) => {
-                return this.createUpdatedTranslatable(variant, formValue[i], languageCode);
+                return this.createUpdatedTranslatable(variant, formValue[i], customFieldConfig, languageCode);
             })
             .filter(notNullOrUndefined);
     }
 
-    private createUpdatedTranslatable<T extends { translations: any[] }>(
+    private createUpdatedTranslatable<T extends { translations: any[] } & MayHaveCustomFields>(
         translatable: T,
         updatedFields: { [key: string]: any },
+        customFieldConfig: CustomFieldConfig[],
         languageCode: LanguageCode,
         defaultTranslation?: Partial<T['translations'][number]>,
     ): T {
@@ -62,9 +70,24 @@ export class ProductUpdaterService {
             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);

+ 1 - 0
admin-ui/src/app/core/providers/i18n/i18n.service.ts

@@ -11,6 +11,7 @@ export class I18nService {
         // produces a stack overflow in some cases, wheras assigning an intermediate
         // var does not. ¯\_(ツ)_/¯
         const defaultLang = getDefaultLanguage();
+        // TODO: this constructor is called many times on bootstrap. Investigate why and fix.
         ngxTranslate.setDefaultLang(defaultLang);
     }
 

+ 210 - 0
admin-ui/src/app/data/add-custom-fields.spec.ts

@@ -0,0 +1,210 @@
+import { DocumentNode, FieldNode, FragmentDefinitionNode } from 'graphql';
+
+import { CustomFields } from '../../../../shared/shared-types';
+
+import { addCustomFields } from './add-custom-fields';
+
+// tslint:disable:no-non-null-assertion
+describe('addCustomFields()', () => {
+    let documentNode: DocumentNode;
+
+    function generateFragmentDefinitionFor(type: keyof CustomFields): FragmentDefinitionNode {
+        return {
+            kind: 'FragmentDefinition',
+            name: {
+                kind: 'Name',
+                value: type,
+            },
+            typeCondition: {
+                kind: 'NamedType',
+                name: {
+                    kind: 'Name',
+                    value: type,
+                },
+            },
+            directives: [],
+            selectionSet: {
+                kind: 'SelectionSet',
+                selections: [],
+            },
+        };
+    }
+
+    beforeEach(() => {
+        documentNode = {
+            kind: 'Document',
+            definitions: [
+                {
+                    kind: 'OperationDefinition',
+                    operation: 'query',
+                    name: {
+                        kind: 'Name',
+                        value: 'GetProductWithVariants',
+                    },
+                    variableDefinitions: [],
+                    directives: [],
+                    selectionSet: {
+                        kind: 'SelectionSet',
+                        selections: [
+                            {
+                                kind: 'Field',
+                                name: {
+                                    kind: 'Name',
+                                    value: 'product',
+                                },
+                                arguments: [],
+                                directives: [],
+                                selectionSet: {
+                                    kind: 'SelectionSet',
+                                    selections: [
+                                        {
+                                            kind: 'FragmentSpread',
+                                            name: {
+                                                kind: 'Name',
+                                                value: 'ProductWithVariants',
+                                            },
+                                            directives: [],
+                                        },
+                                    ],
+                                },
+                            },
+                        ],
+                    },
+                },
+                {
+                    kind: 'FragmentDefinition',
+                    name: {
+                        kind: 'Name',
+                        value: 'ProductWithVariants',
+                    },
+                    typeCondition: {
+                        kind: 'NamedType',
+                        name: {
+                            kind: 'Name',
+                            value: 'Product',
+                        },
+                    },
+                    directives: [],
+                    selectionSet: {
+                        kind: 'SelectionSet',
+                        selections: [
+                            {
+                                kind: 'Field',
+                                name: {
+                                    kind: 'Name',
+                                    value: 'id',
+                                },
+                                arguments: [],
+                                directives: [],
+                            },
+                            {
+                                kind: 'Field',
+                                name: {
+                                    kind: 'Name',
+                                    value: 'translations',
+                                },
+                                arguments: [],
+                                directives: [],
+                                selectionSet: {
+                                    kind: 'SelectionSet',
+                                    selections: [
+                                        {
+                                            kind: 'Field',
+                                            name: {
+                                                kind: 'Name',
+                                                value: 'languageCode',
+                                            },
+                                            arguments: [],
+                                            directives: [],
+                                        },
+                                        {
+                                            kind: 'Field',
+                                            name: {
+                                                kind: 'Name',
+                                                value: 'name',
+                                            },
+                                            arguments: [],
+                                            directives: [],
+                                        },
+                                    ],
+                                },
+                            },
+                        ],
+                    },
+                },
+                generateFragmentDefinitionFor('ProductVariant'),
+                generateFragmentDefinitionFor('ProductOptionGroup'),
+                generateFragmentDefinitionFor('ProductOption'),
+                generateFragmentDefinitionFor('User'),
+                generateFragmentDefinitionFor('Customer'),
+                generateFragmentDefinitionFor('Address'),
+            ],
+        };
+    });
+
+    it('Adds customFields to Product fragment', () => {
+        const customFieldsConfig: CustomFields = {
+            Product: [{ name: 'custom1', type: 'string' }, { name: 'custom2', type: 'boolean' }],
+        };
+
+        const result = addCustomFields(documentNode, customFieldsConfig);
+        const productFragmentDef = result.definitions[1] as FragmentDefinitionNode;
+        const customFieldsDef = productFragmentDef.selectionSet.selections[2] as FieldNode;
+        expect(productFragmentDef.selectionSet.selections.length).toBe(3);
+        expect(customFieldsDef.selectionSet!.selections.length).toBe(2);
+        expect((customFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe('custom1');
+        expect((customFieldsDef.selectionSet!.selections[1] as FieldNode).name.value).toBe('custom2');
+    });
+
+    it('Adds customFields to Product translations', () => {
+        const customFieldsConfig: CustomFields = {
+            Product: [{ name: 'customLocaleString', type: 'localeString' }],
+        };
+
+        const result = addCustomFields(documentNode, customFieldsConfig);
+        const productFragmentDef = result.definitions[1] as FragmentDefinitionNode;
+        const translationsField = productFragmentDef.selectionSet.selections[1] as FieldNode;
+        const customTranslationFieldsDef = translationsField.selectionSet!.selections[2] as FieldNode;
+        expect(translationsField.selectionSet!.selections.length).toBe(3);
+        expect((customTranslationFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe(
+            'customLocaleString',
+        );
+    });
+
+    function addsCustomFieldsToType(type: keyof CustomFields, indexOfDefinition: number) {
+        const customFieldsConfig: CustomFields = {
+            [type]: [{ name: 'custom', type: 'boolean' }],
+        };
+
+        const result = addCustomFields(documentNode, customFieldsConfig);
+        const fragmentDef = result.definitions[indexOfDefinition] as FragmentDefinitionNode;
+        const customFieldsDef = fragmentDef.selectionSet.selections[0] as FieldNode;
+        expect(fragmentDef.selectionSet.selections.length).toBe(1);
+        expect(customFieldsDef.selectionSet!.selections.length).toBe(1);
+        expect((customFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe('custom');
+    }
+
+    it('Adds customFields to ProductVariant fragment', () => {
+        addsCustomFieldsToType('ProductVariant', 2);
+    });
+
+    it('Adds customFields to ProductOptionGroup fragment', () => {
+        addsCustomFieldsToType('ProductOptionGroup', 3);
+    });
+
+    it('Adds customFields to ProductOption fragment', () => {
+        addsCustomFieldsToType('ProductOption', 4);
+    });
+
+    it('Adds customFields to User fragment', () => {
+        addsCustomFieldsToType('User', 5);
+    });
+
+    it('Adds customFields to Customer fragment', () => {
+        addsCustomFieldsToType('Customer', 6);
+    });
+
+    it('Adds customFields to Address fragment', () => {
+        addsCustomFieldsToType('Address', 7);
+    });
+});

+ 89 - 0
admin-ui/src/app/data/add-custom-fields.ts

@@ -0,0 +1,89 @@
+import {
+    DefinitionNode,
+    DocumentNode,
+    FieldNode,
+    FragmentDefinitionNode,
+    Kind,
+    SelectionNode,
+} from 'graphql';
+
+import { CustomFields } from '../../../../shared/shared-types';
+
+import { getServerConfig } from './server-config';
+
+/**
+ * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
+ * custom fields to those fragments.
+ */
+export function addCustomFields(
+    documentNode: DocumentNode,
+    providedCustomFields?: CustomFields,
+): DocumentNode {
+    const customFields = providedCustomFields || getServerConfig().customFields;
+
+    const fragmentDefs = documentNode.definitions.filter(isFragmentDefinition);
+
+    for (const fragmentDef of fragmentDefs) {
+        const entityType = fragmentDef.typeCondition.name.value as keyof CustomFields;
+        const customFieldsForType = customFields[entityType];
+        if (customFieldsForType && customFieldsForType.length) {
+            fragmentDef.selectionSet.selections.push({
+                name: {
+                    kind: Kind.NAME,
+                    value: 'customFields',
+                },
+                kind: Kind.FIELD,
+                selectionSet: {
+                    kind: Kind.SELECTION_SET,
+                    selections: customFieldsForType.map(customField => {
+                        return {
+                            kind: Kind.FIELD,
+                            name: {
+                                kind: Kind.NAME,
+                                value: customField.name,
+                            },
+                        } as FieldNode;
+                    }),
+                },
+            });
+
+            const localeStrings = customFieldsForType.filter(field => field.type === 'localeString');
+
+            const translationsField = fragmentDef.selectionSet.selections
+                .filter(isFieldNode)
+                .find(field => field.name.value === 'translations');
+
+            if (localeStrings.length && translationsField && translationsField.selectionSet) {
+                translationsField.selectionSet.selections.push({
+                    name: {
+                        kind: Kind.NAME,
+                        value: 'customFields',
+                    },
+                    kind: Kind.FIELD,
+                    selectionSet: {
+                        kind: Kind.SELECTION_SET,
+                        selections: localeStrings.map(customField => {
+                            return {
+                                kind: Kind.FIELD,
+                                name: {
+                                    kind: Kind.NAME,
+                                    value: customField.name,
+                                },
+                            } as FieldNode;
+                        }),
+                    },
+                });
+            }
+        }
+    }
+
+    return documentNode;
+}
+
+function isFragmentDefinition(value: DefinitionNode): value is FragmentDefinitionNode {
+    return value.kind === Kind.FRAGMENT_DEFINITION;
+}
+
+function isFieldNode(value: SelectionNode): value is FieldNode {
+    return value.kind === Kind.FIELD;
+}

+ 9 - 2
admin-ui/src/app/data/data.module.ts

@@ -1,6 +1,6 @@
 import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
-import { NgModule } from '@angular/core';
-import { APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
+import { APP_INITIALIZER, NgModule } from '@angular/core';
+import { Apollo, APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
 import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 import { ApolloLink } from 'apollo-link';
@@ -16,6 +16,7 @@ import { OmitTypenameLink } from './omit-typename-link';
 import { BaseDataService } from './providers/base-data.service';
 import { DataService } from './providers/data.service';
 import { DefaultInterceptor } from './providers/interceptor';
+import { loadServerConfigFactory } from './server-config';
 
 const apolloCache = new InMemoryCache();
 
@@ -58,6 +59,12 @@ export function createApollo(httpLink: HttpLink) {
             deps: [HttpLink],
         },
         { provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
+        {
+            provide: APP_INITIALIZER,
+            multi: true,
+            useFactory: loadServerConfigFactory,
+            deps: [Apollo],
+        },
     ],
 })
 export class DataModule {}

+ 12 - 3
admin-ui/src/app/data/providers/product-data.service.ts

@@ -1,6 +1,7 @@
 import { Observable } from 'rxjs';
 
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
+import { addCustomFields } from '../add-custom-fields';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     CREATE_PRODUCT,
@@ -58,7 +59,7 @@ export class ProductDataService {
 
     getProduct(id: string): QueryResult<GetProductWithVariants, GetProductWithVariantsVariables> {
         return this.baseDataService.query<GetProductWithVariants, GetProductWithVariantsVariables>(
-            GET_PRODUCT_WITH_VARIANTS,
+            addCustomFields(GET_PRODUCT_WITH_VARIANTS),
             {
                 id,
                 languageCode: getDefaultLanguage(),
@@ -72,9 +73,13 @@ export class ProductDataService {
                 image: product.image,
                 translations: product.translations,
                 optionGroupCodes: product.optionGroupCodes,
+                customFields: product.customFields,
             },
         };
-        return this.baseDataService.mutate<CreateProduct, CreateProductVariables>(CREATE_PRODUCT, input);
+        return this.baseDataService.mutate<CreateProduct, CreateProductVariables>(
+            addCustomFields(CREATE_PRODUCT),
+            input,
+        );
     }
 
     updateProduct(product: UpdateProductInput): Observable<UpdateProduct> {
@@ -83,9 +88,13 @@ export class ProductDataService {
                 id: product.id,
                 image: product.image,
                 translations: product.translations,
+                customFields: product.customFields,
             },
         };
-        return this.baseDataService.mutate<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, input);
+        return this.baseDataService.mutate<UpdateProduct, UpdateProductVariables>(
+            addCustomFields(UPDATE_PRODUCT),
+            input,
+        );
     }
 
     generateProductVariants(

+ 45 - 0
admin-ui/src/app/data/server-config.ts

@@ -0,0 +1,45 @@
+import { Apollo } from 'apollo-angular';
+import gql from 'graphql-tag';
+
+import { CustomFields } from '../../../../shared/shared-types';
+
+export interface ServerConfig {
+    customFields: CustomFields;
+}
+
+export interface GetConfigResponse {
+    config: ServerConfig;
+}
+
+let serverConfig: ServerConfig;
+
+/**
+ * Fetches the ServerConfig. Should be run as part of the app bootstrap process by attaching
+ * to the Angular APP_INITIALIZER token.
+ */
+export function loadServerConfigFactory(apollo: Apollo): () => Promise<ServerConfig> {
+    return () => {
+        // TODO: usethe gql tag function once graphql-js 14.0.0 is released
+        // issue: https://github.com/graphql/graphql-js/issues/1344
+        // note: the rc of 14.0.0 does not work with the apollo-cli used for codegen.
+        // Test this when upgrading.
+        const query = gql`
+            query GetConfig {
+                config {
+                    customFields
+                }
+            }
+        `;
+        return apollo
+            .query<GetConfigResponse>({ query })
+            .toPromise()
+            .then(result => {
+                serverConfig = result.data.config;
+                return serverConfig;
+            });
+    };
+}
+
+export function getServerConfig(): ServerConfig {
+    return serverConfig;
+}

+ 2 - 2
admin-ui/src/app/data/types/client-types.graphql

@@ -1,10 +1,10 @@
-type Query {
+extend type Query {
     networkStatus: NetworkStatus!
     userStatus: UserStatus!
     uiState: UiState!
 }
 
-type Mutation {
+extend type Mutation {
     requestStarted: Int!
     requestCompleted: Int!
     logIn(username: String!, loginTime: String!): UserStatus

+ 53 - 16
admin-ui/src/app/data/types/gql-generated-types.ts

@@ -140,7 +140,10 @@ export interface UpdateProduct_updateProduct {
 }
 
 export interface UpdateProduct {
-    updateProduct: UpdateProduct_updateProduct; // Update an existing Product
+    /**
+     * Update an existing Product
+     */
+    updateProduct: UpdateProduct_updateProduct;
 }
 
 export interface UpdateProductVariables {
@@ -211,7 +214,10 @@ export interface CreateProduct_createProduct {
 }
 
 export interface CreateProduct {
-    createProduct: CreateProduct_createProduct; // Create a new Product
+    /**
+     * Create a new Product
+     */
+    createProduct: CreateProduct_createProduct;
 }
 
 export interface CreateProductVariables {
@@ -282,7 +288,10 @@ export interface GenerateProductVariants_generateVariantsForProduct {
 }
 
 export interface GenerateProductVariants {
-    generateVariantsForProduct: GenerateProductVariants_generateVariantsForProduct; // Create a set of ProductVariants based on the OptionGroups assigned to the given Product
+    /**
+     * Create a set of ProductVariants based on the OptionGroups assigned to the given Product
+     */
+    generateVariantsForProduct: GenerateProductVariants_generateVariantsForProduct;
 }
 
 export interface GenerateProductVariantsVariables {
@@ -326,7 +335,10 @@ export interface UpdateProductVariants_updateProductVariants {
 }
 
 export interface UpdateProductVariants {
-    updateProductVariants: (UpdateProductVariants_updateProductVariants | null)[]; // Update existing ProductVariants
+    /**
+     * Update existing ProductVariants
+     */
+    updateProductVariants: (UpdateProductVariants_updateProductVariants | null)[];
 }
 
 export interface UpdateProductVariantsVariables {
@@ -370,7 +382,10 @@ export interface CreateProductOptionGroup_createProductOptionGroup {
 }
 
 export interface CreateProductOptionGroup {
-    createProductOptionGroup: CreateProductOptionGroup_createProductOptionGroup; // Create a new ProductOptionGroup
+    /**
+     * Create a new ProductOptionGroup
+     */
+    createProductOptionGroup: CreateProductOptionGroup_createProductOptionGroup;
 }
 
 export interface CreateProductOptionGroupVariables {
@@ -404,7 +419,10 @@ export interface AddOptionGroupToProduct_addOptionGroupToProduct {
 }
 
 export interface AddOptionGroupToProduct {
-    addOptionGroupToProduct: AddOptionGroupToProduct_addOptionGroupToProduct; // Add an OptionGroup to a Product
+    /**
+     * Add an OptionGroup to a Product
+     */
+    addOptionGroupToProduct: AddOptionGroupToProduct_addOptionGroupToProduct;
 }
 
 export interface AddOptionGroupToProductVariables {
@@ -439,7 +457,10 @@ export interface RemoveOptionGroupFromProduct_removeOptionGroupFromProduct {
 }
 
 export interface RemoveOptionGroupFromProduct {
-    removeOptionGroupFromProduct: RemoveOptionGroupFromProduct_removeOptionGroupFromProduct; // Remove an OptionGroup from a Product
+    /**
+     * Remove an OptionGroup from a Product
+     */
+    removeOptionGroupFromProduct: RemoveOptionGroupFromProduct_removeOptionGroupFromProduct;
 }
 
 export interface RemoveOptionGroupFromProductVariables {
@@ -774,7 +795,9 @@ export interface ProductOptionGroup {
 // START Enums and Input Objects
 //==============================================================
 
-// ISO 639-1 language code
+/**
+ * ISO 639-1 language code
+ */
 export enum LanguageCode {
     aa = 'aa',
     ab = 'ab',
@@ -962,64 +985,78 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
-//
 export interface UpdateProductInput {
     id: string;
     image?: string | null;
     translations: (ProductTranslationInput | null)[];
     optionGroupCodes?: (string | null)[] | null;
+    customFields?: UpdateProductCustomFieldsInput | null;
 }
 
-//
 export interface ProductTranslationInput {
     id?: string | null;
     languageCode: LanguageCode;
     name: string;
     slug?: string | null;
     description?: string | null;
+    customFields?: ProductTranslationCustomFieldsInput | null;
+}
+
+export interface ProductTranslationCustomFieldsInput {
+    nickname?: string | null;
+}
+
+export interface UpdateProductCustomFieldsInput {
+    infoUrl?: string | null;
+    downloadable?: boolean | null;
 }
 
-//
 export interface CreateProductInput {
     image?: string | null;
     translations: (ProductTranslationInput | null)[];
     optionGroupCodes?: (string | null)[] | null;
+    customFields?: CreateProductCustomFieldsInput | null;
+}
+
+export interface CreateProductCustomFieldsInput {
+    infoUrl?: string | null;
+    downloadable?: boolean | null;
 }
 
-//
 export interface UpdateProductVariantInput {
     id: string;
     translations: ProductVariantTranslationInput[];
     sku: string;
     image?: string | null;
     price: number;
+    customFields?: any | null;
 }
 
-//
 export interface ProductVariantTranslationInput {
     id?: string | null;
     languageCode: LanguageCode;
     name: string;
+    customFields?: any | null;
 }
 
-//
 export interface CreateProductOptionGroupInput {
     code: string;
     translations: ProductOptionGroupTranslationInput[];
     options: CreateProductOptionInput[];
+    customFields?: any | null;
 }
 
-//
 export interface ProductOptionGroupTranslationInput {
     id?: string | null;
     languageCode: LanguageCode;
     name: string;
+    customFields?: any | null;
 }
 
-//
 export interface CreateProductOptionInput {
     code: string;
     translations: ProductOptionGroupTranslationInput[];
+    customFields?: any | null;
 }
 
 //==============================================================

+ 22 - 0
admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.html

@@ -0,0 +1,22 @@
+<vdr-form-field [label]="customField.name"
+                [for]="customField.name">
+    <input *ngIf="customField.type === 'string' || customField.type === 'localeString'"
+           type="text"
+           [id]="customField.name"
+           [formControl]="formGroup.get(customField.name)">
+    <input *ngIf="customField.type === 'int' || customField.type === 'float'"
+           type="number"
+           [id]="customField.name"
+           [formControl]="formGroup.get(customField.name)">
+    <div *ngIf="customField.type === 'boolean'"
+         class="toggle-switch">
+        <input type="checkbox"
+               [id]="customField.name"
+               [formControl]="formGroup.get(customField.name)">
+        <label [for]="customField.name"></label></div>
+    <input *ngIf="customField.type === 'datetime'"
+           type="date"
+           [id]="customField.name"
+           [formControl]="formGroup.get(customField.name)"
+           clrDate>
+</vdr-form-field>

+ 6 - 0
admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.scss

@@ -0,0 +1,6 @@
+:host {
+    .toggle-switch {
+        margin-top: 0;
+        margin-bottom: 0;
+    }
+}

+ 18 - 0
admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.ts

@@ -0,0 +1,18 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { CustomFieldConfig } from '../../../../../../shared/shared-types';
+
+/**
+ * This component renders the appropriate type of form input control based
+ * on the "type" property of the provided CustomFieldConfig.
+ */
+@Component({
+    selector: 'vdr-custom-field-control',
+    templateUrl: './custom-field-control.component.html',
+    styleUrls: ['./custom-field-control.component.scss'],
+})
+export class CustomFieldControlComponent {
+    @Input('customFieldsFormGroup') formGroup: FormGroup;
+    @Input() customField: CustomFieldConfig;
+}

+ 2 - 2
admin-ui/src/app/shared/components/form-field/form-field-control.directive.ts

@@ -16,10 +16,10 @@ export class FormFieldControlDirective {
     constructor(@Optional() private formControlName: NgControl) {}
 
     get valid(): boolean {
-        return !!this.formControlName.valid;
+        return !!this.formControlName && !!this.formControlName.valid;
     }
 
     get touched(): boolean {
-        return !!this.formControlName.touched;
+        return !!this.formControlName && !!this.formControlName.touched;
     }
 }

+ 2 - 0
admin-ui/src/app/shared/shared.module.ts

@@ -13,6 +13,7 @@ import {
 } from './components/action-bar/action-bar.component';
 import { ChipComponent } from './components/chip/chip.component';
 import { CurrencyInputComponent } from './components/currency-input/currency-input.component';
+import { CustomFieldControlComponent } from './components/custom-field-control/custom-field-control.component';
 import { DataTableColumnComponent } from './components/data-table/data-table-column.component';
 import { DataTableComponent } from './components/data-table/data-table.component';
 import { FormFieldControlDirective } from './components/form-field/form-field-control.directive';
@@ -43,6 +44,7 @@ const DECLARATIONS = [
     ActionBarRightComponent,
     ChipComponent,
     CurrencyInputComponent,
+    CustomFieldControlComponent,
     DataTableComponent,
     DataTableColumnComponent,
     PaginationControlsComponent,

+ 18 - 1
admin-ui/src/karma.conf.js

@@ -34,6 +34,23 @@ module.exports = function (config) {
                 flags: ['--no-sandbox']
             }
         },
-        singleRun: false
+        singleRun: false,
+        // TODO: this whole "webpack" section is a hack to get around this issue with
+        // the graphql-js lib: https://github.com/aws-amplify/amplify-js/issues/686#issuecomment-387710340
+        // also: https://github.com/graphql/graphql-js/issues/1272
+        // Once v14.0.0 is out it may be possible to remove it. Check that the apollo codegen
+        // script still works (may need to upgrade apollo package too at that point)
+        webpack: {
+            module: {
+                rules: [
+                    ...config.buildWebpack.webpackConfig.module.rules,
+                    {
+                      test: /\.mjs$/,
+                      include: /node_modules/,
+                      type: "javascript/auto",
+                    }
+                ]
+            }
+        }
     });
 };

+ 5 - 0
admin-ui/src/polyfills.ts

@@ -79,3 +79,8 @@ import '@clr/icons';
 import '@clr/icons/shapes/commerce-shapes';
 import '@clr/icons/shapes/essential-shapes';
 import '@webcomponents/custom-elements/custom-elements.min.js';
+// TODO: remove this shim once the newer version of graphql-js is released (14.0.0)
+// and check that the codegen still works (may need to upgrade apollo package).
+// See: https://github.com/graphql/graphql-js/issues/1344
+// and: https://github.com/kripken/emscripten/issues/6653
+(window as any).process = {};

+ 1 - 1
admin-ui/tslint.json

@@ -22,7 +22,7 @@
     "use-input-property-decorator": true,
     "use-output-property-decorator": true,
     "use-host-property-decorator": true,
-    "no-input-rename": true,
+    "no-input-rename": false,
     "no-output-rename": true,
     "use-life-cycle-interface": true,
     "use-pipe-transform-interface": true,

File diff suppressed because it is too large
+ 234 - 279
admin-ui/yarn.lock


Some files were not shown because too many files changed in this diff