Browse Source

feat(admin-ui): Implement creation of ProductOptionGroups

Michael Bromley 7 years ago
parent
commit
98615e8b38
23 changed files with 585 additions and 19 deletions
  1. 1 0
      admin-ui/src/app/app.config.ts
  2. 3 1
      admin-ui/src/app/catalog/catalog.module.ts
  3. 36 0
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.html
  4. 14 0
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.scss
  5. 45 0
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.spec.ts
  6. 95 0
      admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.ts
  7. 19 0
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  8. 12 0
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.scss
  9. 24 3
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  10. 33 0
      admin-ui/src/app/common/utilities/normalize-string.spec.ts
  11. 13 0
      admin-ui/src/app/common/utilities/normalize-string.ts
  12. 26 0
      admin-ui/src/app/data/fragments/product-fragments.ts
  13. 28 2
      admin-ui/src/app/data/mutations/product-mutations.ts
  14. 3 0
      admin-ui/src/app/data/providers/data.service.mock.ts
  15. 22 11
      admin-ui/src/app/data/providers/product-data.service.ts
  16. 164 1
      admin-ui/src/app/data/types/gql-generated-types.ts
  17. 8 1
      admin-ui/src/app/shared/components/form-field/form-field.component.html
  18. 1 0
      admin-ui/src/app/shared/components/form-field/form-field.component.ts
  19. 6 0
      admin-ui/src/app/shared/components/form-item/form-item.component.html
  20. 7 0
      admin-ui/src/app/shared/components/form-item/form-item.component.scss
  21. 15 0
      admin-ui/src/app/shared/components/form-item/form-item.component.ts
  22. 10 0
      admin-ui/src/i18n-messages/en.json
  23. 0 0
      schema.json

+ 1 - 0
admin-ui/src/app/app.config.ts

@@ -1,4 +1,5 @@
 import { API_PORT } from '../../../shared/shared-constants';
+import { LanguageCode } from './data/types/gql-generated-types';
 
 export const API_URL = `http://localhost:${API_PORT}`;
 export const DEFAULT_LANGUAGE: LanguageCode = LanguageCode.en;

+ 3 - 1
admin-ui/src/app/catalog/catalog.module.ts

@@ -4,6 +4,7 @@ import { RouterModule } from '@angular/router';
 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 { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductResolver } from './providers/routing/product-resolver';
@@ -14,7 +15,8 @@ import { ProductResolver } from './providers/routing/product-resolver';
         RouterModule.forChild(catalogRoutes),
     ],
     exports: [],
-    declarations: [ProductListComponent, ProductDetailComponent],
+    declarations: [ProductListComponent, ProductDetailComponent, CreateOptionGroupDialogComponent],
+    entryComponents: [CreateOptionGroupDialogComponent],
     providers: [ProductResolver],
 })
 export class CatalogModule {

+ 36 - 0
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.html

@@ -0,0 +1,36 @@
+<ng-template vdrDialogTitle>{{ 'catalog.create-new-option-group' | translate }}</ng-template>
+
+<form class="form" [formGroup]="optionGroupForm">
+    <section class="form-block">
+        <vdr-form-field [label]="'catalog.option-group-name' | translate" for="name">
+            <input id="name" type="text" formControlName="name" (input)="updateCode($event.target.value)">
+        </vdr-form-field>
+        <vdr-form-field [label]="'catalog.option-group-code' | translate" for="code">
+            <div class="code-input">
+                <input id="code" type="text" formControlName="code" [readonly]="!editCode">
+                <button type="button" class="btn btn-icon btn-sm"
+                        (click)="editCode = true">
+                    <clr-icon shape="edit"></clr-icon>
+                </button>
+            </div>
+        </vdr-form-field>
+        <vdr-form-field [label]="'catalog.option-group-options-label' | translate"
+                        [tooltip]="'catalog.option-group-options-tooltip' | translate: { defaultLanguage: defaultLanguage }"
+                        for="options">
+            <textarea id="options" type="text" formControlName="options" rows="10"></textarea>
+        </vdr-form-field>
+    </section>
+</form>
+
+<ng-template vdrDialogButtons>
+    <button type="button"
+            class="btn"
+            (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit"
+            (click)="createOptionGroup()"
+            class="btn btn-primary"
+            [disabled]="optionGroupForm.invalid || optionGroupForm.pristine">
+        {{ 'catalog.create-group' | translate }}
+    </button>
+</ng-template>
+

+ 14 - 0
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.scss

@@ -0,0 +1,14 @@
+@import "variables";
+
+.code-input {
+    display: flex;
+    input {
+        flex: 1;
+        &[readonly] {
+            background-color: $color-grey-1;
+        }
+    }
+    button {
+        margin: 0;
+    }
+}

+ 45 - 0
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.spec.ts

@@ -0,0 +1,45 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ReactiveFormsModule } from '@angular/forms';
+import { IconCustomTag, Tooltip, TooltipContent, TooltipTrigger } from '@clr/angular';
+import { MockTranslatePipe } from '../../../../testing/translate.pipe.mock';
+import { DataService } from '../../../data/providers/data.service';
+import { MockDataService } from '../../../data/providers/data.service.mock';
+import { FormFieldControlDirective } from '../../../shared/components/form-field/form-field-control.directive';
+import { FormFieldComponent } from '../../../shared/components/form-field/form-field.component';
+import { CreateOptionGroupDialogComponent } from './create-option-group-dialog.component';
+
+describe('CreateOptionGroupDialogComponent', () => {
+    let component: CreateOptionGroupDialogComponent;
+    let fixture: ComponentFixture<CreateOptionGroupDialogComponent>;
+
+    beforeEach(async(() => {
+        TestBed.configureTestingModule({
+            imports: [ReactiveFormsModule],
+            declarations: [
+                CreateOptionGroupDialogComponent,
+                FormFieldComponent,
+                FormFieldControlDirective,
+                MockTranslatePipe,
+                IconCustomTag,
+                TooltipContent,
+                TooltipTrigger,
+                Tooltip,
+            ],
+            providers: [
+                { provide: DataService, useClass: MockDataService },
+            ],
+        }).compileComponents();
+    }));
+
+    beforeEach(() => {
+        fixture = TestBed.createComponent(CreateOptionGroupDialogComponent);
+        component = fixture.componentInstance;
+
+    });
+
+    it('should create', () => {
+        fixture.detectChanges();
+        expect(component).toBeTruthy();
+    });
+});

+ 95 - 0
admin-ui/src/app/catalog/components/create-option-group-dialog/create-option-group-dialog.component.ts

@@ -0,0 +1,95 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormBuilder, FormGroup } from '@angular/forms';
+
+import { mergeMap } from 'rxjs/operators';
+import { ID } from '../../../../../../shared/shared-types';
+import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
+import { normalizeString } from '../../../common/utilities/normalize-string';
+import { DataService } from '../../../data/providers/data.service';
+import { CreateProductOptionGroupInput, CreateProductOptionInput, LanguageCode } from '../../../data/types/gql-generated-types';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-create-option-group-dialog',
+    templateUrl: './create-option-group-dialog.component.html',
+    styleUrls: ['./create-option-group-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CreateOptionGroupDialogComponent implements Dialog {
+    resolveWith: (result?: any) => void;
+    optionGroupForm: FormGroup;
+    productName = '';
+    productId: string;
+    editCode = false;
+    readonly defaultLanguage = getDefaultLanguage();
+
+    constructor(private formBuilder: FormBuilder,
+                private dataService: DataService) {}
+
+    ngOnInit() {
+        this.optionGroupForm = this.formBuilder.group({
+            name: '',
+            code: '',
+            options: '',
+        });
+    }
+
+    updateCode(nameValue: string) {
+        const codeControl = this.optionGroupForm.get('code');
+        if (codeControl && codeControl.pristine) {
+            codeControl.setValue(normalizeString(`${this.productName} ${nameValue}`, '-'));
+        }
+    }
+
+    createOptionGroup() {
+        this.dataService.product.createProductOptionGroups(this.createGroupFromForm()).pipe(
+            mergeMap(data => {
+                const optionGroup = data.createProductOptionGroup;
+                if (optionGroup) {
+                    return this.dataService.product.addOptionGroupToProduct({
+                        productId: this.productId,
+                        optionGroupId: optionGroup.id,
+                    });
+                }
+                return [];
+            }),
+        )
+            .subscribe(product => this.resolveWith(product));
+    }
+
+    cancel() {
+        this.resolveWith('cancelled!');
+    }
+
+    private createGroupFromForm(): CreateProductOptionGroupInput {
+        const name = this.optionGroupForm.value.name;
+        const code = this.optionGroupForm.value.code;
+        const rawOptions = this.optionGroupForm.value.options;
+        return {
+            code,
+            translations: [
+                {
+                    languageCode: getDefaultLanguage(),
+                    name,
+                },
+            ],
+            options: this.createGroupOptions(rawOptions),
+        };
+    }
+
+    private createGroupOptions(rawOptions: string): CreateProductOptionInput[] {
+        return rawOptions.split('\n')
+            .map(line => line.trim())
+            .map(name => {
+                return {
+                    code: normalizeString(name, '-'),
+                    translations: [
+                        {
+                            languageCode: getDefaultLanguage(),
+                            name,
+                        },
+                    ],
+                };
+            });
+    }
+}

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

@@ -28,6 +28,25 @@
         <vdr-form-field [label]="'catalog.description' | translate" for="description">
             <textarea id="description" formControlName="description"></textarea>
         </vdr-form-field>
+        <vdr-form-item [label]="'catalog.product-option-groups' | translate">
+            <div class="option-groups-list">
+                <div *ngFor="let optionGroup of (product$ | async)?.optionGroups"
+                     class="option-group">{{ optionGroup.name }}</div>
+                <clr-dropdown>
+                    <button type="button" class="btn btn-outline-primary btn-sm" clrDropdownTrigger>
+                        <clr-icon shape="add"></clr-icon>
+                        {{ 'catalog.add-option-group' | translate }}
+                        <clr-icon shape="caret down"></clr-icon>
+                    </button>
+                    <clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
+                        <button type="button" clrDropdownItem>{{ 'catalog.add-existing-option-group' | translate }}</button>
+                        <button type="button"
+                                clrDropdownItem
+                                (click)="createNewOptionGroup()">{{ 'catalog.create-new-option-group' | translate }}</button>
+                    </clr-dropdown-menu>
+                </clr-dropdown>
+            </div>
+        </vdr-form-item>
     </section>
 
     <section class="form-block">

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

@@ -0,0 +1,12 @@
+@import "variables";
+
+.option-groups-list {
+
+}
+.option-group {
+    border: 1px solid $color-grey-4;
+    border-radius: 3px;
+    display: inline-block;
+    padding: 0 6px;
+    margin-right: 6px;
+}

+ 24 - 3
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -1,12 +1,14 @@
-import { Component, OnInit } from '@angular/core';
+import { Component } from '@angular/core';
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { combineLatest, Observable, Subject } from 'rxjs';
-import { map, take, takeUntil, tap } from 'rxjs/operators';
+import { map, mergeMap, take, takeUntil } from 'rxjs/operators';
 
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { DataService } from '../../../data/providers/data.service';
 import { GetProductWithVariants_product, LanguageCode } from '../../../data/types/gql-generated-types';
+import { ModalService } from '../../../shared/providers/modal/modal.service';
+import { CreateOptionGroupDialogComponent } from '../create-option-group-dialog/create-option-group-dialog.component';
 
 @Component({
     selector: 'vdr-product-detail',
@@ -24,7 +26,8 @@ export class ProductDetailComponent {
     constructor(private dataService: DataService,
                 private router: Router,
                 private route: ActivatedRoute,
-                private formBuilder: FormBuilder) {
+                private formBuilder: FormBuilder,
+                private modalService: ModalService) {
         this.product$ = this.route.snapshot.data.product;
         this.productForm = this.formBuilder.group({
             name: ['', Validators.required],
@@ -65,6 +68,24 @@ export class ProductDetailComponent {
         this.setQueryParam('lang', code);
     }
 
+    createNewOptionGroup() {
+        this.product$.pipe(
+            take(1),
+            mergeMap(product => {
+                const nameControl = this.productForm.get('name');
+                const productName = nameControl ? nameControl.value : '';
+                return this.modalService.fromComponent(CreateOptionGroupDialogComponent, {
+                    closable: true,
+                    size: 'lg',
+                    locals: {
+                        productName,
+                        productId: product.id,
+                    },
+                });
+            }),
+        ).subscribe();
+    }
+
     save() {
         combineLatest(this.product$, this.languageCode$).pipe(take(1))
             .subscribe(([product, languageCode]) => {

+ 33 - 0
admin-ui/src/app/common/utilities/normalize-string.spec.ts

@@ -0,0 +1,33 @@
+import { normalizeString } from './normalize-string';
+
+describe('normalizeString()', () => {
+
+    it('lowercases the string', () => {
+        expect(normalizeString('FOO')).toBe('foo');
+        expect(normalizeString('Foo Bar')).toBe('foo bar');
+    });
+
+    it('replaces diacritical marks with plain equivalents', () => {
+        expect(normalizeString('thé')).toBe('the');
+        expect(normalizeString('curaçao')).toBe('curacao');
+        expect(normalizeString('dấu hỏi')).toBe('dau hoi');
+        expect(normalizeString('el niño')).toBe('el nino');
+        expect(normalizeString('genkō yōshi')).toBe('genko yoshi');
+        expect(normalizeString('việt nam')).toBe('viet nam');
+    });
+
+    it('replaces spaces with the spaceReplacer', () => {
+        expect(normalizeString('old man', '_')).toBe('old_man');
+        expect(normalizeString('old  man', '_')).toBe('old_man');
+    });
+
+    it('strips non-alphanumeric characters', () => {
+        expect(normalizeString('hi!!!')).toBe('hi');
+        expect(normalizeString('who? me?')).toBe('who me');
+        expect(normalizeString('!"£$%^&*()+[]{};:@#~?/,|\\><`¬')).toBe('');
+    });
+
+    it('allows a subset of non-alphanumeric characters to pass through', () => {
+        expect(normalizeString('-_.')).toBe('-_.');
+    });
+});

+ 13 - 0
admin-ui/src/app/common/utilities/normalize-string.ts

@@ -0,0 +1,13 @@
+/**
+ * Normalizes a string to replace non-alphanumeric and diacritical marks with
+ * plain equivalents.
+ * Based on https://stackoverflow.com/a/37511463/772859
+ */
+export function normalizeString(input: string, spaceReplacer = ' '): string {
+    return input
+        .normalize('NFD')
+        .replace(/[\u0300-\u036f]/g, '')
+        .toLowerCase()
+        .replace(/[!"£$%^&*()+[\]{};:@#~?\\/,|><`¬]/g, '')
+        .replace(/\s+/g, spaceReplacer);
+}

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

@@ -14,6 +14,12 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
             slug
             description
         },
+        optionGroups {
+            id
+            languageCode
+            code
+            name
+        }
         variants {
             id
             languageCode
@@ -35,3 +41,23 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         }
     }
 `;
+
+export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
+    fragment ProductOptionGroup on ProductOptionGroup {
+        id
+        languageCode
+        code
+        name
+        translations {
+            name
+        }
+        options {
+            id
+            languageCode
+            code
+            translations {
+                name
+            }
+        }
+    }
+`;

+ 28 - 2
admin-ui/src/app/data/mutations/product-mutations.ts

@@ -1,11 +1,37 @@
 import gql from 'graphql-tag';
-import { PRODUCT_WITH_VARIANTS_FRAGMENT } from '../fragments/product-fragments';
+
+import { PRODUCT_OPTION_GROUP_FRAGMENT, PRODUCT_WITH_VARIANTS_FRAGMENT } from '../fragments/product-fragments';
 
 export const UPDATE_PRODUCT = gql`
-    mutation UpdateProduct ($input: UpdateProductInput) {
+    mutation UpdateProduct($input: UpdateProductInput!) {
     	updateProduct(input: $input) {
     		...ProductWithVariants
     	}
     },
     ${PRODUCT_WITH_VARIANTS_FRAGMENT}
 `;
+
+export const CREATE_PRODUCT_OPTION_GROUP = gql`
+    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            ...ProductOptionGroup
+        }
+    },
+    ${PRODUCT_OPTION_GROUP_FRAGMENT}
+`;
+
+export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            id
+            optionGroups {
+                id
+                code
+                options {
+                    id
+                    code
+                }
+            }
+        }
+    }
+`;

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

@@ -0,0 +1,3 @@
+export class MockDataService {
+
+}

+ 22 - 11
admin-ui/src/app/data/providers/product-data.service.ts

@@ -1,21 +1,23 @@
 import { Observable } from 'rxjs';
-
-import { ID } from '../../../../../shared/shared-types';
 import { getDefaultLanguage } from '../../common/utilities/get-default-language';
-import { CREATE_PRODUCT_OPTION_GROUP, UPDATE_PRODUCT } from '../mutations/product-mutations';
+import { ADD_OPTION_GROUP_TO_PRODUCT, CREATE_PRODUCT_OPTION_GROUP, UPDATE_PRODUCT } from '../mutations/product-mutations';
 import { GET_PRODUCT_LIST, GET_PRODUCT_WITH_VARIANTS } from '../queries/product-queries';
 import {
+    AddOptionGroupToProduct,
+    AddOptionGroupToProductVariables,
+    CreateProductOptionGroup,
+    CreateProductOptionGroupInput,
+    CreateProductOptionGroupVariables,
     GetProductList,
     GetProductListVariables,
-    GetProductWithVariants, GetProductWithVariants_product,
+    GetProductWithVariants,
     GetProductWithVariantsVariables,
-    LanguageCode,
-    ProductWithVariants,
     UpdateProduct,
     UpdateProductInput,
     UpdateProductVariables,
 } from '../types/gql-generated-types';
 import { QueryResult } from '../types/query-result';
+
 import { BaseDataService } from './base-data.service';
 
 export class ProductDataService {
@@ -24,18 +26,17 @@ export class ProductDataService {
 
     getProducts(take: number = 10, skip: number = 0): QueryResult<GetProductList, GetProductListVariables> {
         return this.baseDataService
-            .query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, { take, skip, languageCode: LanguageCode.en });
+            .query<GetProductList, GetProductListVariables>(GET_PRODUCT_LIST, { take, skip, languageCode: getDefaultLanguage() });
     }
 
-    getProduct(id: ID): QueryResult<GetProductWithVariants, GetProductWithVariantsVariables> {
-        const stringId = id.toString();
+    getProduct(id: string): QueryResult<GetProductWithVariants, GetProductWithVariantsVariables> {
         return this.baseDataService.query<GetProductWithVariants, GetProductWithVariantsVariables>(GET_PRODUCT_WITH_VARIANTS, {
-            id: stringId,
+            id,
             languageCode: getDefaultLanguage(),
         });
     }
 
-    updateProduct(product: GetProductWithVariants_product): Observable<UpdateProduct> {
+    updateProduct(product: UpdateProductInput): Observable<UpdateProduct> {
         const input: UpdateProductVariables = {
             input: {
                 id: product.id,
@@ -46,4 +47,14 @@ export class ProductDataService {
         return this.baseDataService.mutate<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, input);
     }
 
+    createProductOptionGroups(productOptionGroup: CreateProductOptionGroupInput): Observable<CreateProductOptionGroup> {
+        const input: CreateProductOptionGroupVariables = {
+            input: productOptionGroup,
+        };
+        return this.baseDataService.mutate<CreateProductOptionGroup, CreateProductOptionGroupVariables>(CREATE_PRODUCT_OPTION_GROUP, input);
+    }
+
+    addOptionGroupToProduct(variables: AddOptionGroupToProductVariables): Observable<AddOptionGroupToProduct> {
+        return this.baseDataService.mutate<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(ADD_OPTION_GROUP_TO_PRODUCT, variables);
+    }
 }

+ 164 - 1
admin-ui/src/app/data/types/gql-generated-types.ts

@@ -98,6 +98,14 @@ export interface UpdateProduct_updateProduct_translations {
   description: string | null;
 }
 
+export interface UpdateProduct_updateProduct_optionGroups {
+  __typename: "ProductOptionGroup";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  name: string | null;
+}
+
 export interface UpdateProduct_updateProduct_variants_options {
   __typename: "ProductOption";
   id: string;
@@ -134,6 +142,7 @@ export interface UpdateProduct_updateProduct {
   image: string | null;
   description: string | null;
   translations: UpdateProduct_updateProduct_translations[];
+  optionGroups: UpdateProduct_updateProduct_optionGroups[];
   variants: UpdateProduct_updateProduct_variants[];
 }
 
@@ -142,7 +151,87 @@ export interface UpdateProduct {
 }
 
 export interface UpdateProductVariables {
-  input?: UpdateProductInput | null;
+  input: UpdateProductInput;
+}
+
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: CreateProductOptionGroup
+// ====================================================
+
+export interface CreateProductOptionGroup_createProductOptionGroup_translations {
+  __typename: "ProductOptionGroupTranslation";
+  name: string;
+}
+
+export interface CreateProductOptionGroup_createProductOptionGroup_options_translations {
+  __typename: "ProductOptionTranslation";
+  name: string;
+}
+
+export interface CreateProductOptionGroup_createProductOptionGroup_options {
+  __typename: "ProductOption";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  translations: CreateProductOptionGroup_createProductOptionGroup_options_translations[];
+}
+
+export interface CreateProductOptionGroup_createProductOptionGroup {
+  __typename: "ProductOptionGroup";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  name: string | null;
+  translations: (CreateProductOptionGroup_createProductOptionGroup_translations | null)[] | null;
+  options: (CreateProductOptionGroup_createProductOptionGroup_options | null)[] | null;
+}
+
+export interface CreateProductOptionGroup {
+  createProductOptionGroup: CreateProductOptionGroup_createProductOptionGroup | null;  // Create a new ProductOptionGroup
+}
+
+export interface CreateProductOptionGroupVariables {
+  input: CreateProductOptionGroupInput;
+}
+
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: AddOptionGroupToProduct
+// ====================================================
+
+export interface AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups_options {
+  __typename: "ProductOption";
+  id: string;
+  code: string | null;
+}
+
+export interface AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups {
+  __typename: "ProductOptionGroup";
+  id: string;
+  code: string | null;
+  options: (AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups_options | null)[] | null;
+}
+
+export interface AddOptionGroupToProduct_addOptionGroupToProduct {
+  __typename: "Product";
+  id: string;
+  optionGroups: AddOptionGroupToProduct_addOptionGroupToProduct_optionGroups[];
+}
+
+export interface AddOptionGroupToProduct {
+  addOptionGroupToProduct: AddOptionGroupToProduct_addOptionGroupToProduct;  // Add an OptionGroup to a Product
+}
+
+export interface AddOptionGroupToProductVariables {
+  productId: string;
+  optionGroupId: string;
 }
 
 
@@ -214,6 +303,14 @@ export interface GetProductWithVariants_product_translations {
   description: string | null;
 }
 
+export interface GetProductWithVariants_product_optionGroups {
+  __typename: "ProductOptionGroup";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  name: string | null;
+}
+
 export interface GetProductWithVariants_product_variants_options {
   __typename: "ProductOption";
   id: string;
@@ -250,6 +347,7 @@ export interface GetProductWithVariants_product {
   image: string | null;
   description: string | null;
   translations: GetProductWithVariants_product_translations[];
+  optionGroups: GetProductWithVariants_product_optionGroups[];
   variants: GetProductWithVariants_product_variants[];
 }
 
@@ -311,6 +409,14 @@ export interface ProductWithVariants_translations {
   description: string | null;
 }
 
+export interface ProductWithVariants_optionGroups {
+  __typename: "ProductOptionGroup";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  name: string | null;
+}
+
 export interface ProductWithVariants_variants_options {
   __typename: "ProductOption";
   id: string;
@@ -347,9 +453,46 @@ export interface ProductWithVariants {
   image: string | null;
   description: string | null;
   translations: ProductWithVariants_translations[];
+  optionGroups: ProductWithVariants_optionGroups[];
   variants: ProductWithVariants_variants[];
 }
 
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL fragment: ProductOptionGroup
+// ====================================================
+
+export interface ProductOptionGroup_translations {
+  __typename: "ProductOptionGroupTranslation";
+  name: string;
+}
+
+export interface ProductOptionGroup_options_translations {
+  __typename: "ProductOptionTranslation";
+  name: string;
+}
+
+export interface ProductOptionGroup_options {
+  __typename: "ProductOption";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  translations: ProductOptionGroup_options_translations[];
+}
+
+export interface ProductOptionGroup {
+  __typename: "ProductOptionGroup";
+  id: string;
+  languageCode: LanguageCode | null;
+  code: string | null;
+  name: string | null;
+  translations: (ProductOptionGroup_translations | null)[] | null;
+  options: (ProductOptionGroup_options | null)[] | null;
+}
+
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
@@ -562,6 +705,26 @@ export interface ProductTranslationInput {
   description?: string | null;
 }
 
+// 
+export interface CreateProductOptionGroupInput {
+  code: string;
+  translations: (ProductOptionGroupTranslationInput | null)[];
+  options?: (CreateProductOptionInput | null)[] | null;
+}
+
+// 
+export interface ProductOptionGroupTranslationInput {
+  id?: string | null;
+  languageCode: LanguageCode;
+  name: string;
+}
+
+// 
+export interface CreateProductOptionInput {
+  code: string;
+  translations: (ProductOptionGroupTranslationInput | null)[];
+}
+
 //==============================================================
 // END Enums and Input Objects
 //==============================================================

+ 8 - 1
admin-ui/src/app/shared/components/form-field/form-field.component.html

@@ -1,5 +1,12 @@
 <div class="form-group">
-    <label [for]="for">{{ label }}</label>
+    <label [for]="for">{{ label }}
+        <clr-tooltip *ngIf="tooltip">
+            <clr-icon clrTooltipTrigger shape="info-circle" size="24"></clr-icon>
+            <clr-tooltip-content clrPosition="top-right" clrSize="lg" *clrIfOpen>
+                <span>{{ tooltip }}</span>
+            </clr-tooltip-content>
+        </clr-tooltip>
+    </label>
     <label [for]="for"
            aria-haspopup="true"
            role="tooltip"

+ 1 - 0
admin-ui/src/app/shared/components/form-field/form-field.component.ts

@@ -13,5 +13,6 @@ import { FormFieldControlDirective } from './form-field-control.directive';
 export class FormFieldComponent {
     @Input() label: string;
     @Input() for: string;
+    @Input() tooltip: string;
     @ContentChild(FormFieldControlDirective) formFieldControl: FormFieldControlDirective;
 }

+ 6 - 0
admin-ui/src/app/shared/components/form-item/form-item.component.html

@@ -0,0 +1,6 @@
+<div class="form-group">
+    <label>{{ label }}</label>
+    <div class="content">
+        <ng-content></ng-content>
+    </div>
+</div>

+ 7 - 0
admin-ui/src/app/shared/components/form-item/form-item.component.scss

@@ -0,0 +1,7 @@
+:host {
+    display: block;
+    .form-group >.content {
+        flex: 1;
+        max-width: 20rem;
+    }
+}

+ 15 - 0
admin-ui/src/app/shared/components/form-item/form-item.component.ts

@@ -0,0 +1,15 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+/**
+ * Like the {@link FormFieldComponent} but for content which is not a form control. Used
+ * to keep a consistent layout with other form fields in the form.
+ */
+@Component({
+    selector: 'vdr-form-item',
+    templateUrl: './form-item.component.html',
+    styleUrls: ['./form-item.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FormItemComponent {
+    @Input() label: string;
+}

+ 10 - 0
admin-ui/src/i18n-messages/en.json

@@ -5,14 +5,24 @@
   },
   "catalog": {
     "ID": "ID",
+    "add-existing-option-group": "Add existing option group",
+    "add-option-group": "Add option group",
+    "create-group": "Create option group",
+    "create-new-option-group": "Create new option group",
     "description": "Description",
     "name": "Name",
+    "option-group-code": "Code",
+    "option-group-name": "Option group name",
+    "option-group-options-label": "Options",
+    "option-group-options-tooltip": "Enter each option on a new line in the default language ({ defaultLanguage })",
     "product": "Product",
     "product-name": "Product name",
+    "product-option-groups": "Option groups",
     "product-variants": "Product variants",
     "slug": "Slug"
   },
   "common": {
+    "cancel": "Cancel",
     "edit": "Edit",
     "language": "Language",
     "log-out": "Log out",

File diff suppressed because it is too large
+ 0 - 0
schema.json


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