Browse Source

refactor(admin-ui): Create shared base impls for entity details

Michael Bromley 7 years ago
parent
commit
673c0ab204

+ 21 - 50
admin-ui/src/app/catalog/catalog.routes.ts

@@ -1,8 +1,9 @@
 import { Route } from '@angular/router';
-import { map } from 'rxjs/operators';
+import { ProductWithVariants } from 'shared/generated-types';
 
+import { createResolveData } from '../common/base-entity-resolver';
+import { detailBreadcrumb } from '../common/detail-breadcrumb';
 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';
@@ -22,9 +23,7 @@ export const catalogRoutes: Route[] = [
     {
         path: 'products/:id',
         component: ProductDetailComponent,
-        resolve: {
-            product: ProductResolver,
-        },
+        resolve: createResolveData(ProductResolver),
         data: {
             breadcrumb: productBreadcrumb,
         },
@@ -39,57 +38,29 @@ export const catalogRoutes: Route[] = [
     {
         path: 'facets/:id',
         component: FacetDetailComponent,
-        resolve: {
-            facet: FacetResolver,
-        },
+        resolve: createResolveData(FacetResolver),
         data: {
             breadcrumb: facetBreadcrumb,
         },
     },
 ];
 
-export function productBreadcrumb(data: any, params: any, dataService: DataService) {
-    return dataService.product.getProduct(params.id).stream$.pipe(
-        map(productData => {
-            let productLabel = '';
-            if (params.id === 'create') {
-                productLabel = 'common.create';
-            } else {
-                productLabel = `#${params.id} (${productData.product && productData.product.name})`;
-            }
-            return [
-                {
-                    label: _('breadcrumb.products'),
-                    link: ['../', 'products'],
-                },
-                {
-                    label: productLabel,
-                    link: [params.id],
-                },
-            ];
-        }),
-    );
+export function productBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<ProductWithVariants>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.products',
+        getName: product => product.name,
+        route: 'products',
+    });
 }
 
-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],
-                },
-            ];
-        }),
-    );
+export function facetBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<ProductWithVariants>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.facets',
+        getName: facet => facet.name,
+        route: 'facets',
+    });
 }

+ 15 - 38
admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.ts

@@ -1,8 +1,8 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormArray, FormBuilder, FormControl, 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 { combineLatest, forkJoin, Observable } from 'rxjs';
+import { map, mergeMap, take } from 'rxjs/operators';
 import {
     CreateFacetValueInput,
     FacetWithValues,
@@ -13,13 +13,12 @@ import {
 import { CustomFieldConfig } from 'shared/shared-types';
 import { notNullOrUndefined } from 'shared/shared-utils';
 
+import { BaseDetailComponent } from '../../../common/base-detail.component';
 import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
-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';
 
 @Component({
     selector: 'vdr-facet-detail',
@@ -27,30 +26,29 @@ import { getServerConfig } from '../../../data/server-config';
     styleUrls: ['./facet-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class FacetDetailComponent implements OnInit, OnDestroy {
+export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues> implements OnInit, OnDestroy {
     facet$: Observable<FacetWithValues>;
     values$: Observable<FacetWithValues_values[]>;
-    availableLanguages$: Observable<LanguageCode[]>;
     customFields: CustomFieldConfig[];
     customValueFields: CustomFieldConfig[];
-    languageCode$: Observable<LanguageCode>;
-    isNew$: Observable<boolean>;
     facetForm: FormGroup;
-    private destroy$ = new Subject<void>();
 
     constructor(
+        router: Router,
+        route: ActivatedRoute,
         private changeDetector: ChangeDetectorRef,
         private dataService: DataService,
-        private router: Router,
-        private route: ActivatedRoute,
         private formBuilder: FormBuilder,
         private notificationService: NotificationService,
-    ) {}
+    ) {
+        super(route, router);
+    }
 
     ngOnInit() {
-        this.customFields = getServerConfig().customFields.Facet || [];
-        this.customValueFields = getServerConfig().customFields.FacetValue || [];
-        this.facet$ = this.route.data.pipe(switchMap(data => data.facet));
+        this.init();
+        this.customFields = this.getCustomFieldConfig('Facet');
+        this.customValueFields = this.getCustomFieldConfig('FacetValue');
+        this.facet$ = this.entity$;
         this.values$ = this.facet$.pipe(map(facet => facet.values));
         this.facetForm = this.formBuilder.group({
             facet: this.formBuilder.group({
@@ -62,23 +60,10 @@ export class FacetDetailComponent implements OnInit, OnDestroy {
             }),
             values: this.formBuilder.array([]),
         });
-
-        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();
+        this.destroy();
     }
 
     updateCode(nameValue: string) {
@@ -200,7 +185,7 @@ export class FacetDetailComponent implements OnInit, OnDestroy {
     /**
      * Sets the values of the form on changes to the facet or current language.
      */
-    private setFormValues(facet: FacetWithValues, languageCode: LanguageCode) {
+    protected setFormValues(facet: FacetWithValues, languageCode: LanguageCode) {
         const currentTranslation = facet.translations.find(t => t.languageCode === languageCode);
         if (currentTranslation) {
             this.facetForm.patchValue({
@@ -320,12 +305,4 @@ export class FacetDetailComponent implements OnInit, OnDestroy {
             })
             .filter(notNullOrUndefined);
     }
-
-    private setQueryParam(key: string, value: any) {
-        this.router.navigate(['./'], {
-            queryParams: { [key]: value },
-            relativeTo: this.route,
-            queryParamsHandling: 'merge',
-        });
-    }
 }

+ 16 - 37
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -1,8 +1,8 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { combineLatest, EMPTY, forkJoin, Observable, Subject } from 'rxjs';
-import { map, mergeMap, switchMap, take, takeUntil } from 'rxjs/operators';
+import { combineLatest, EMPTY, forkJoin, Observable } from 'rxjs';
+import { map, mergeMap, take } from 'rxjs/operators';
 import {
     LanguageCode,
     ProductWithVariants,
@@ -13,13 +13,12 @@ import {
 import { CustomFieldConfig } from 'shared/shared-types';
 import { notNullOrUndefined } from 'shared/shared-utils';
 
+import { BaseDetailComponent } from '../../../common/base-detail.component';
 import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
-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 { ModalService } from '../../../shared/providers/modal/modal.service';
 import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dialog.component';
 
@@ -29,30 +28,30 @@ import { ApplyFacetDialogComponent } from '../apply-facet-dialog/apply-facet-dia
     styleUrls: ['./product-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ProductDetailComponent implements OnInit, OnDestroy {
+export class ProductDetailComponent extends BaseDetailComponent<ProductWithVariants>
+    implements OnInit, OnDestroy {
     product$: Observable<ProductWithVariants>;
     variants$: Observable<ProductWithVariants_variants[]>;
-    availableLanguages$: Observable<LanguageCode[]>;
     customFields: CustomFieldConfig[];
     customVariantFields: CustomFieldConfig[];
-    languageCode$: Observable<LanguageCode>;
-    isNew$: Observable<boolean>;
     productForm: FormGroup;
-    private destroy$ = new Subject<void>();
 
     constructor(
+        route: ActivatedRoute,
+        router: Router,
         private dataService: DataService,
-        private router: Router,
-        private route: ActivatedRoute,
         private formBuilder: FormBuilder,
         private modalService: ModalService,
         private notificationService: NotificationService,
-    ) {}
+    ) {
+        super(route, router);
+    }
 
     ngOnInit() {
-        this.customFields = getServerConfig().customFields.Product || [];
-        this.customVariantFields = getServerConfig().customFields.ProductVariant || [];
-        this.product$ = this.route.data.pipe(switchMap(data => data.product));
+        this.init();
+        this.customFields = this.getCustomFieldConfig('Product');
+        this.customVariantFields = this.getCustomFieldConfig('ProductVariant');
+        this.product$ = this.entity$;
         this.variants$ = this.product$.pipe(map(product => product.variants));
         this.productForm = this.formBuilder.group({
             product: this.formBuilder.group({
@@ -65,22 +64,10 @@ export class ProductDetailComponent implements OnInit, OnDestroy {
             }),
             variants: this.formBuilder.array([]),
         });
-        this.isNew$ = this.product$.pipe(map(product => product.id === ''));
-        this.languageCode$ = this.route.queryParamMap.pipe(
-            map(qpm => qpm.get('lang')),
-            map(lang => (!lang ? getDefaultLanguage() : (lang as LanguageCode))),
-        );
-
-        this.availableLanguages$ = this.product$.pipe(map(p => p.translations.map(t => t.languageCode)));
-
-        combineLatest(this.product$, this.languageCode$)
-            .pipe(takeUntil(this.destroy$))
-            .subscribe(([product, languageCode]) => this.setFormValues(product, languageCode));
     }
 
     ngOnDestroy() {
-        this.destroy$.next();
-        this.destroy$.complete();
+        this.destroy();
     }
 
     setLanguage(code: LanguageCode) {
@@ -203,7 +190,7 @@ export class ProductDetailComponent implements OnInit, OnDestroy {
     /**
      * Sets the values of the form on changes to the product or current language.
      */
-    private setFormValues(product: ProductWithVariants, languageCode: LanguageCode) {
+    protected setFormValues(product: ProductWithVariants, languageCode: LanguageCode) {
         const currentTranslation = product.translations.find(t => t.languageCode === languageCode);
         if (currentTranslation) {
             this.productForm.patchValue({
@@ -296,12 +283,4 @@ export class ProductDetailComponent implements OnInit, OnDestroy {
             })
             .filter(notNullOrUndefined);
     }
-
-    private setQueryParam(key: string, value: any) {
-        this.router.navigate(['./'], {
-            queryParams: { [key]: value },
-            relativeTo: this.route,
-            queryParamsHandling: 'merge',
-        });
-    }
 }

+ 15 - 39
admin-ui/src/app/catalog/providers/routing/facet-resolver.ts

@@ -1,51 +1,27 @@
 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 { FacetWithValues } from 'shared/generated-types';
-import { notNullOrUndefined } from 'shared/shared-utils';
 
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { DataService } from '../../../data/providers/data.service';
 
 /**
  * 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;
-        }
+export class FacetResolver extends BaseEntityResolver<FacetWithValues> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'Facet' as 'Facet',
+                id: '',
+                languageCode: getDefaultLanguage(),
+                name: '',
+                code: '',
+                translations: [],
+                values: [],
+            },
+            id => this.dataService.facet.getFacet(id).mapStream(data => data.facet),
+        );
     }
 }

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

@@ -1,10 +1,7 @@
 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 { ProductWithVariants } from 'shared/generated-types';
-import { notNullOrUndefined } from 'shared/shared-utils';
 
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { DataService } from '../../../data/providers/data.service';
 
@@ -13,42 +10,22 @@ import { DataService } from '../../../data/providers/data.service';
  */
 
 @Injectable()
-export class ProductResolver implements Resolve<Observable<ProductWithVariants>> {
-    constructor(private dataService: DataService) {}
-
-    resolve(
-        route: ActivatedRouteSnapshot,
-        state: RouterStateSnapshot,
-    ): Observable<Observable<ProductWithVariants>> {
-        const id = route.paramMap.get('id');
-
-        if (id === 'create') {
-            return of(
-                of({
-                    __typename: 'Product' as 'Product',
-                    id: '',
-                    languageCode: getDefaultLanguage(),
-                    name: '',
-                    slug: '',
-                    image: '',
-                    description: '',
-                    translations: [],
-                    optionGroups: [],
-                    variants: [],
-                }),
-            );
-        } else if (id) {
-            const stream = this.dataService.product
-                .getProduct(id)
-                .mapStream(data => data.product)
-                .pipe(filter(notNullOrUndefined));
-
-            return stream.pipe(
-                take(1),
-                map(() => stream),
-            );
-        } else {
-            return {} as any;
-        }
+export class ProductResolver extends BaseEntityResolver<ProductWithVariants> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'Product' as 'Product',
+                id: '',
+                languageCode: getDefaultLanguage(),
+                name: '',
+                slug: '',
+                image: '',
+                description: '',
+                translations: [],
+                optionGroups: [],
+                variants: [],
+            },
+            id => this.dataService.product.getProduct(id).mapStream(data => data.product),
+        );
     }
 }

+ 57 - 0
admin-ui/src/app/common/base-detail.component.ts

@@ -0,0 +1,57 @@
+import { ChangeDetectorRef, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { combineLatest, Observable, of, Subject } from 'rxjs';
+import { map, switchMap, takeUntil } from 'rxjs/operators';
+import { LanguageCode } from 'shared/generated-types';
+import { CustomFieldConfig, CustomFields } from 'shared/shared-types';
+
+import { NotificationService } from '../core/providers/notification/notification.service';
+import { DataService } from '../data/providers/data.service';
+import { getServerConfig } from '../data/server-config';
+
+import { getDefaultLanguage } from './utilities/get-default-language';
+
+export abstract class BaseDetailComponent<Entity extends { id: string }> {
+    entity$: Observable<Entity>;
+    availableLanguages$: Observable<LanguageCode[]>;
+    languageCode$: Observable<LanguageCode>;
+    isNew$: Observable<boolean>;
+    protected destroy$ = new Subject<void>();
+
+    protected constructor(protected route: ActivatedRoute, protected router: Router) {}
+
+    init() {
+        this.entity$ = this.route.data.pipe(switchMap(data => data.entity));
+        this.isNew$ = this.entity$.pipe(map(entity => entity.id === ''));
+        this.languageCode$ = this.route.queryParamMap.pipe(
+            map(qpm => qpm.get('lang')),
+            map(lang => (!lang ? getDefaultLanguage() : (lang as LanguageCode))),
+        );
+
+        this.availableLanguages$ = of([LanguageCode.en]);
+
+        combineLatest(this.entity$, this.languageCode$)
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(([facet, languageCode]) => this.setFormValues(facet, languageCode));
+    }
+
+    destroy() {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    protected abstract setFormValues(entity: Entity, languageCode: LanguageCode): void;
+
+    protected getCustomFieldConfig(key: keyof CustomFields): CustomFieldConfig[] {
+        return getServerConfig().customFields[key] || [];
+    }
+
+    protected setQueryParam(key: string, value: any) {
+        this.router.navigate(['./'], {
+            queryParams: { [key]: value },
+            relativeTo: this.route,
+            queryParamsHandling: 'merge',
+        });
+    }
+}

+ 45 - 0
admin-ui/src/app/common/base-entity-resolver.ts

@@ -0,0 +1,45 @@
+import { ActivatedRouteSnapshot, Resolve, ResolveData, RouterStateSnapshot } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { filter, map, take } from 'rxjs/operators';
+import { Type } from 'shared/shared-types';
+import { notNullOrUndefined } from 'shared/shared-utils';
+
+export interface EntityResolveData<R> extends ResolveData {
+    entity: Type<BaseEntityResolver<R>>;
+}
+
+export function createResolveData<T extends BaseEntityResolver<R>, R>(
+    resolver: Type<T>,
+): EntityResolveData<R> {
+    return {
+        entity: resolver,
+    };
+}
+
+/**
+ * A base resolver for an entity detail route. Resolves to an observable of the given entity, or a "blank"
+ * version if the route id equals "create".
+ */
+export class BaseEntityResolver<T> implements Resolve<Observable<T>> {
+    constructor(
+        private readonly emptyEntity: T,
+        private entityStream: (id: string) => Observable<T | null>,
+    ) {}
+
+    resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Observable<T>> {
+        const id = route.paramMap.get('id');
+
+        if (id === 'create') {
+            return of(of(this.emptyEntity));
+        } else if (id) {
+            const stream = this.entityStream(id).pipe(filter(notNullOrUndefined));
+
+            return stream.pipe(
+                take(1),
+                map(() => stream),
+            );
+        } else {
+            return {} as any;
+        }
+    }
+}

+ 37 - 0
admin-ui/src/app/common/detail-breadcrumb.ts

@@ -0,0 +1,37 @@
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { BreadcrumbValue } from '../core/components/breadcrumb/breadcrumb.component';
+import { _ } from '../core/providers/i18n/mark-for-extraction';
+
+/**
+ * Creates an observable of breadcrumb links for use in the route config of a detail route.
+ */
+export function detailBreadcrumb<T>(options: {
+    entity: Observable<T>;
+    id: string;
+    breadcrumbKey: string;
+    getName: (entity: T) => string;
+    route: string;
+}): Observable<BreadcrumbValue> {
+    return options.entity.pipe(
+        map(entity => {
+            let label = '';
+            if (options.id === 'create') {
+                label = 'common.create';
+            } else {
+                label = `#${options.id} (${options.getName(entity)})`;
+            }
+            return [
+                {
+                    label: _('breadcrumb.products'),
+                    link: ['../', options.route],
+                },
+                {
+                    label,
+                    link: [options.id],
+                },
+            ];
+        }),
+    );
+}