Browse Source

refactor(admin-ui): Create NavBuilderService for main nav menu

Relates to #55. This new service will allow dynamic modification of the nav menu by UI plugins.
Michael Bromley 6 years ago
parent
commit
0d869270a7

+ 24 - 156
packages/admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -1,161 +1,29 @@
 <nav class="sidenav" [clr-nav-level]="2">
     <section class="sidenav-content">
-        <section class="nav-group" *vdrIfPermissions="'ReadCatalog'">
-            <input id="catalog" type="checkbox" />
-            <label for="catalog">{{ 'nav.catalog' | translate }}</label>
-            <ul class="nav-list">
-                <li>
-                    <a class="nav-link" [routerLink]="['/catalog', 'products']" routerLinkActive="active">
-                        <clr-icon shape="library" size="20"></clr-icon>
-                        {{ 'nav.products' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a class="nav-link" [routerLink]="['/catalog', 'facets']" routerLinkActive="active">
-                        <clr-icon shape="tag" size="20"></clr-icon>
-                        {{ 'nav.facets' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/catalog', 'collections']"
-                        [queryParams]="{ perPage: 25 }"
-                        [class.active]="isLinkActive('/catalog/collections')"
-                    >
-                        <clr-icon shape="folder-open" size="20"></clr-icon>
-                        {{ 'nav.collections' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/catalog', 'assets']"
-                        [queryParams]="{ perPage: 25 }"
-                        [class.active]="isLinkActive('/catalog/assets')"
-                    >
-                        <clr-icon shape="image-gallery" size="20"></clr-icon>
-                        {{ 'nav.assets' | translate }}
-                    </a>
-                </li>
-            </ul>
-        </section>
-        <section class="nav-group" *vdrIfPermissions="'ReadOrder'">
-            <input id="sales" type="checkbox" />
-            <label for="sales">{{ 'nav.sales' | translate }}</label>
-            <ul class="nav-list">
-                <li>
-                    <a class="nav-link" [routerLink]="['/orders']" routerLinkActive="active">
-                        <clr-icon shape="shopping-cart" size="20"></clr-icon>
-                        {{ 'nav.orders' | translate }}
-                    </a>
-                </li>
-            </ul>
-        </section>
-        <section class="nav-group" *vdrIfPermissions="'ReadCustomer'">
-            <input id="tabexample2" type="checkbox" />
-            <label for="tabexample2">{{ 'nav.customers' | translate }}</label>
-            <ul class="nav-list">
-                <li>
-                    <a class="nav-link" [routerLink]="['/customer', 'customers']" routerLinkActive="active">
-                        <clr-icon shape="user" size="20"></clr-icon>
-                        {{ 'nav.customers' | translate }}
-                    </a>
-                </li>
-            </ul>
-        </section>
-        <section class="nav-group" *vdrIfPermissions="'ReadPromotion'">
-            <input id="marketing" type="checkbox" />
-            <label for="marketing">{{ 'nav.marketing' | translate }}</label>
-            <ul class="nav-list">
-                <li>
-                    <a class="nav-link" [routerLink]="['/marketing', 'promotions']" routerLinkActive="active">
-                        <clr-icon shape="asterisk" size="20"></clr-icon>
-                        {{ 'nav.promotions' | translate }}
-                    </a>
-                </li>
-            </ul>
-        </section>
-        <section class="nav-group collapsible"  *vdrIfPermissions="'ReadSettings'">
-            <input id="settings" type="checkbox" checked="true" />
-            <label for="settings">{{ 'nav.settings' | translate }}</label>
-            <ul class="nav-list">
-                <li>
-                    <a class="nav-link" [routerLink]="['/settings', 'channels']" routerLinkActive="active">
-                        <clr-icon shape="layers" size="20"></clr-icon>
-                        {{ 'nav.channels' | translate }}
-                    </a>
-                </li>
-                <li *vdrIfPermissions="'ReadAdministrator'">
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/settings', 'administrators']"
-                        routerLinkActive="active"
-                    >
-                        <clr-icon shape="administrator" size="20"></clr-icon>
-                        {{ 'nav.administrators' | translate }}
-                    </a>
-                </li>
-                <li *vdrIfPermissions="'ReadAdministrator'">
-                    <a class="nav-link" [routerLink]="['/settings', 'roles']" routerLinkActive="active">
-                        <clr-icon shape="users" size="20"></clr-icon>
-                        {{ 'nav.roles' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/settings', 'shipping-methods']"
-                        routerLinkActive="active"
-                    >
-                        <clr-icon shape="truck" size="20"></clr-icon>
-                        {{ 'nav.shipping-methods' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/settings', 'payment-methods']"
-                        routerLinkActive="active"
-                    >
-                        <clr-icon shape="credit-card" size="20"></clr-icon>
-                        {{ 'nav.payment-methods' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/settings', 'tax-categories']"
-                        routerLinkActive="active"
-                    >
-                        <clr-icon shape="view-list" size="20"></clr-icon>
-                        {{ 'nav.tax-categories' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a class="nav-link" [routerLink]="['/settings', 'tax-rates']" routerLinkActive="active">
-                        <clr-icon shape="calculator" size="20"></clr-icon>
-                        {{ 'nav.tax-rates' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a class="nav-link" [routerLink]="['/settings', 'countries']" routerLinkActive="active">
-                        <clr-icon shape="world" size="20"></clr-icon>
-                        {{ 'nav.countries' | translate }}
-                    </a>
-                </li>
-                <li>
-                    <a
-                        class="nav-link"
-                        [routerLink]="['/settings', 'global-settings']"
-                        routerLinkActive="active"
-                    >
-                        <clr-icon shape="cog" size="20"></clr-icon>
-                        {{ 'nav.global-settings' | translate }}
-                    </a>
-                </li>
-            </ul>
-        </section>
+        <ng-container *ngFor="let section of menuBuilderService.navMenuConfig$ | async">
+            <section
+                class="nav-group"
+                [attr.data-section-name]="section.name"
+                [class.collapsible]="section.collapsible"
+                *vdrIfPermissions="section.requiresPermission"
+            >
+                <input [id]="section.name" type="checkbox" [checked]="section.collapsedByDefault" />
+                <label [for]="section.name">{{ section.label | translate }}</label>
+                <ul class="nav-list">
+                    <li *ngFor="let item of section.items">
+                        <a
+                            class="nav-link"
+                            [attr.data-item-name]="section.name"
+                            [routerLink]="item.routerLink"
+                            routerLinkActive="active"
+                        >
+                            <clr-icon [attr.shape]="item.icon" size="20"></clr-icon>
+                            {{ item.label | translate }}
+                        </a>
+                    </li>
+                </ul>
+            </section>
+        </ng-container>
         <section class="nav-group">
             <vdr-job-list></vdr-job-list>
         </section>

+ 148 - 2
packages/admin-ui/src/app/core/components/main-nav/main-nav.component.ts

@@ -1,13 +1,159 @@
 import { Component, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 
+import { NavBuilderService } from '../../providers/nav-builder/nav-builder.service';
+
 @Component({
     selector: 'vdr-main-nav',
     templateUrl: './main-nav.component.html',
     styleUrls: ['./main-nav.component.scss'],
 })
-export class MainNavComponent {
-    constructor(private route: ActivatedRoute, private router: Router) {}
+export class MainNavComponent implements OnInit {
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        public menuBuilderService: NavBuilderService,
+    ) {}
+
+    ngOnInit(): void {
+        this.menuBuilderService.defineNavMenuSections([
+            {
+                requiresPermission: 'ReadCatalog',
+                name: 'catalog',
+                label: 'nav.catalog',
+                items: [
+                    {
+                        name: 'products',
+                        label: 'nav.products',
+                        icon: 'library',
+                        routerLink: ['/catalog', 'products'],
+                    },
+                    {
+                        name: 'facets',
+                        label: 'nav.facets',
+                        icon: 'tag',
+                        routerLink: ['/catalog', 'facets'],
+                    },
+                    {
+                        name: 'collections',
+                        label: 'nav.collections',
+                        icon: 'folder-open',
+                        routerLink: ['/catalog', 'collections'],
+                    },
+                    {
+                        name: 'assets',
+                        label: 'nav.assets',
+                        icon: 'image-gallery',
+                        routerLink: ['/catalog', 'assets'],
+                    },
+                ],
+            },
+            {
+                name: 'sales',
+                label: 'nav.sales',
+                requiresPermission: 'ReadOrder',
+                items: [
+                    {
+                        name: 'orders',
+                        label: 'nav.orders',
+                        routerLink: ['/orders'],
+                        icon: 'shopping-cart',
+                    },
+                ],
+            },
+            {
+                name: 'customers',
+                label: 'nav.customers',
+                requiresPermission: 'ReadCustomer',
+                items: [
+                    {
+                        name: 'customers',
+                        label: 'nav.customers',
+                        routerLink: ['/customer', 'customers'],
+                        icon: 'user',
+                    },
+                ],
+            },
+            {
+                name: 'marketing',
+                label: 'nav.marketing',
+                requiresPermission: 'ReadPromotion',
+                items: [
+                    {
+                        name: 'promotions',
+                        label: 'nav.promotions',
+                        routerLink: ['/marketing', 'promotions'],
+                        icon: 'asterisk',
+                    },
+                ],
+            },
+            {
+                name: 'settings',
+                label: 'nav.settings',
+                requiresPermission: 'ReadSettings',
+                collapsible: true,
+                collapsedByDefault: true,
+                items: [
+                    {
+                        name: 'channels',
+                        label: 'nav.channels',
+                        routerLink: ['/settings', 'channels'],
+                        icon: 'layers',
+                    },
+                    {
+                        name: 'administrators',
+                        label: 'nav.administrators',
+                        requiresPermission: 'ReadAdministrator',
+                        routerLink: ['/settings', 'administrators'],
+                        icon: 'administrator',
+                    },
+                    {
+                        name: 'roles',
+                        label: 'nav.roles',
+                        requiresPermission: 'ReadAdministrator',
+                        routerLink: ['/settings', 'roles'],
+                        icon: 'users',
+                    },
+                    {
+                        name: 'shipping-methods',
+                        label: 'nav.shipping-methods',
+                        routerLink: ['/settings', 'shipping-methods'],
+                        icon: 'truck',
+                    },
+                    {
+                        name: 'payment-methods',
+                        label: 'nav.payment-methods',
+                        routerLink: ['/settings', 'payment-methods'],
+                        icon: 'credit-card',
+                    },
+                    {
+                        name: 'tax-categories',
+                        label: 'nav.tax-categories',
+                        routerLink: ['/settings', 'tax-categories'],
+                        icon: 'view-list',
+                    },
+                    {
+                        name: 'tax-rates',
+                        label: 'nav.tax-rates',
+                        routerLink: ['/settings', 'tax-rates'],
+                        icon: 'calculator',
+                    },
+                    {
+                        name: 'countries',
+                        label: 'nav.countries',
+                        routerLink: ['/settings', 'countries'],
+                        icon: 'world',
+                    },
+                    {
+                        name: 'global-settings',
+                        label: 'nav.global-settings',
+                        routerLink: ['/settings', 'global-settings'],
+                        icon: 'cog',
+                    },
+                ],
+            },
+        ]);
+    }
 
     /**
      * Work-around for routerLinkActive on links which include queryParams.

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

@@ -17,6 +17,7 @@ import { AuthGuard } from './providers/guard/auth.guard';
 import { I18nService } from './providers/i18n/i18n.service';
 import { JobQueueService } from './providers/job-queue/job-queue.service';
 import { LocalStorageService } from './providers/local-storage/local-storage.service';
+import { NavBuilderService } from './providers/nav-builder/nav-builder.service';
 import { NotificationService } from './providers/notification/notification.service';
 import { OverlayHostService } from './providers/overlay-host/overlay-host.service';
 
@@ -31,6 +32,7 @@ import { OverlayHostService } from './providers/overlay-host/overlay-host.servic
         OverlayHostService,
         NotificationService,
         JobQueueService,
+        NavBuilderService,
     ],
     declarations: [
         AppShellComponent,

+ 131 - 0
packages/admin-ui/src/app/core/providers/nav-builder/nav-builder.service.ts

@@ -0,0 +1,131 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
+import { map, scan, shareReplay } from 'rxjs/operators';
+
+/**
+ * A NavMenuItem is a menu item in the main (left-hand side) nav
+ * bar.
+ */
+export interface NavMenuItem {
+    name: string;
+    label: string;
+    routerLink: any[];
+    icon?: string;
+    requiresPermission?: string;
+}
+
+/**
+ * A NavMenuSection is a grouping of links in the main
+ * (left-hand side) nav bar.
+ */
+export interface NavMenuSection {
+    name: string;
+    label: string;
+    items: NavMenuItem[];
+    requiresPermission?: string;
+    collapsible?: boolean;
+    collapsedByDefault?: boolean;
+}
+
+/**
+ * This service is used to define the contents of configurable menus in the application.
+ */
+@Injectable()
+export class NavBuilderService {
+    navMenuConfig$: Observable<NavMenuSection[]>;
+
+    private initialNavMenuConfig$ = new BehaviorSubject<NavMenuSection[]>([]);
+    private addNavMenuSection$ = new BehaviorSubject<{ config: NavMenuSection; before?: string } | null>(
+        null,
+    );
+    private addNavMenuItem$ = new BehaviorSubject<{
+        config: NavMenuItem;
+        section: string;
+        before?: string;
+    } | null>(null);
+
+    constructor() {
+        const sectionAdditions$ = this.addNavMenuSection$.pipe(
+            scan(
+                (acc, value) => {
+                    return value ? [...acc, value] : acc;
+                },
+                [] as Array<{ config: NavMenuSection; before?: string }>,
+            ),
+        );
+
+        const itemAdditions$ = this.addNavMenuItem$.pipe(
+            scan(
+                (acc, value) => {
+                    return value ? [...acc, value] : acc;
+                },
+                [] as Array<{ config: NavMenuItem; section: string; before?: string }>,
+            ),
+        );
+
+        const combinedConfig$ = combineLatest(this.initialNavMenuConfig$, sectionAdditions$).pipe(
+            map(([initalConfig, additions]) => {
+                for (const { config, before } of additions) {
+                    const index = initalConfig.findIndex(c => c.name === before);
+                    if (-1 < index) {
+                        initalConfig.splice(index, 0, config);
+                    } else {
+                        initalConfig.push(config);
+                    }
+                }
+                return initalConfig;
+            }),
+            shareReplay(1),
+        );
+
+        this.navMenuConfig$ = combineLatest(combinedConfig$, itemAdditions$).pipe(
+            map(([sections, additionalItems]) => {
+                for (const item of additionalItems) {
+                    const section = sections.find(s => s.name === item.section);
+                    if (!section) {
+                        // tslint:disable-next-line:no-console
+                        console.error(
+                            `Could not add menu item "${item.config.name}", section "${item.section}" does not exist`,
+                        );
+                    } else {
+                        const index = section.items.findIndex(i => i.name === item.before);
+                        if (-1 < index) {
+                            section.items.splice(index, 0, item.config);
+                        } else {
+                            section.items.push(item.config);
+                        }
+                    }
+                }
+                return sections;
+            }),
+        );
+    }
+
+    /**
+     * Used to define the initial sections and items of the main nav menu.
+     */
+    defineNavMenuSections(config: NavMenuSection[]) {
+        this.initialNavMenuConfig$.next(config);
+    }
+
+    /**
+     * Add a section to the main nav menu. Providing the `before` argument will
+     * move the section before any existing section with the specified name. If
+     * omitted (or if the name is not found) the section will be appended to the
+     * existing set of sections.
+     */
+    addNavMenuSection(config: NavMenuSection, before?: string) {
+        this.addNavMenuSection$.next({ config, before });
+    }
+
+    /**
+     * Add a menu item to an existing section specified by `section`. The name of the section
+     * can be found by inspecting the DOM and finding the `data-section-name` attribute.
+     * Providing the `before` argument will move the item before any existing item with the specified name.
+     * If omitted (or if the name is not found) the item will be appended to the
+     * end of the section.
+     */
+    addNavMenuItem(config: NavMenuItem, section: string, before?: string) {
+        this.addNavMenuItem$.next({ config, section, before });
+    }
+}

+ 29 - 17
packages/admin-ui/src/app/shared/directives/if-permissions.directive.ts

@@ -19,7 +19,7 @@ export class IfPermissionsDirective {
     private _elseTemplateRef: TemplateRef<any> | null = null;
     private _thenViewRef: EmbeddedViewRef<any> | null = null;
     private _elseViewRef: EmbeddedViewRef<any> | null = null;
-    private permissionToCheck = '__initial_value__';
+    private permissionToCheck: string | null = '__initial_value__';
 
     constructor(
         private _viewContainer: ViewContainerRef,
@@ -33,7 +33,7 @@ export class IfPermissionsDirective {
      * The permission to check to determine whether to show the template.
      */
     @Input()
-    set vdrIfPermissions(permission: string) {
+    set vdrIfPermissions(permission: string | null) {
         this.permissionToCheck = permission;
         this._updateView(permission);
     }
@@ -49,27 +49,39 @@ export class IfPermissionsDirective {
         this._updateView(this.permissionToCheck);
     }
 
-    private _updateView(permission: string) {
+    private _updateView(permission: string | null) {
+        if (!permission) {
+            this.showThen();
+            return;
+        }
         this.dataService.client.userStatus().single$.subscribe(({ userStatus }) => {
             if (userStatus.permissions.includes(permission)) {
-                if (!this._thenViewRef) {
-                    this._viewContainer.clear();
-                    this._elseViewRef = null;
-                    if (this._thenTemplateRef) {
-                        this._thenViewRef = this._viewContainer.createEmbeddedView(this._thenTemplateRef);
-                    }
-                }
+                this.showThen();
             } else {
-                if (!this._elseViewRef) {
-                    this._viewContainer.clear();
-                    this._thenViewRef = null;
-                    if (this._elseTemplateRef) {
-                        this._elseViewRef = this._viewContainer.createEmbeddedView(this._elseTemplateRef);
-                    }
-                }
+                this.showElse();
             }
         });
     }
+
+    private showThen() {
+        if (!this._thenViewRef) {
+            this._viewContainer.clear();
+            this._elseViewRef = null;
+            if (this._thenTemplateRef) {
+                this._thenViewRef = this._viewContainer.createEmbeddedView(this._thenTemplateRef);
+            }
+        }
+    }
+
+    private showElse() {
+        if (!this._elseViewRef) {
+            this._viewContainer.clear();
+            this._thenViewRef = null;
+            if (this._elseTemplateRef) {
+                this._elseViewRef = this._viewContainer.createEmbeddedView(this._elseTemplateRef);
+            }
+        }
+    }
 }
 
 function assertTemplate(property: string, templateRef: TemplateRef<any> | null): void {