瀏覽代碼

feat(admin-ui): Create OverlayHost & NotificationService

Michael Bromley 7 年之前
父節點
當前提交
c2d29e8617

+ 17 - 7
admin-ui/src/_variables.scss

@@ -1,9 +1,19 @@
+@import "~@clr/ui/src/color/utils/colors.clarity";
+@import "~@clr/ui/src/color/utils/contrast-cache.clarity";
+@import "~@clr/ui/src/color/utils/helpers.clarity";
+@import "~@clr/ui/src/color/variables.color";
+
 // colors
 $color-brand: #13b7f3;
-$grey-1: #FAFAFA;
-$grey-2: #EEEEEE;
-$grey-3: #CCCCCC;
-$grey-4: #9A9A9A;
-$grey-5: #565656;
-$grey-6: #313131;
-$grey-7: #111111;
+$color-success: $clr-green-accessible;
+$color-error: $clr-red;
+$color-info: $clr-action-blue;
+$color-warning: $clr-yellow-light-midtone;
+$color-grey-1: #FAFAFA;
+$color-grey-2: #EEEEEE;
+$color-grey-3: #CCCCCC;
+$color-grey-4: #9A9A9A;
+$color-grey-5: #565656;
+$color-grey-6: #313131;
+$color-grey-7: #111111;
+

+ 3 - 1
admin-ui/src/app/core/components/app-shell/app-shell.component.html

@@ -1,5 +1,5 @@
 <clr-main-container>
-    <!--<div class="alert alert-app-level">
+    <!--<div class="error error-app-level">
         ALERT
     </div>-->
     <clr-header>
@@ -19,8 +19,10 @@
 
     <div class="content-container">
         <div class="content-area">
+            <vdr-breadcrumb></vdr-breadcrumb>
             <router-outlet></router-outlet>
         </div>
         <vdr-main-nav></vdr-main-nav>
     </div>
 </clr-main-container>
+<vdr-overlay-host></vdr-overlay-host>

+ 1 - 1
admin-ui/src/app/core/components/main-nav/main-nav.component.scss

@@ -3,7 +3,7 @@
 :host {
     flex: 0 0 auto;
     order: -1;
-    background-color: $grey-2;
+    background-color: $color-grey-2;
 }
 
 nav.sidenav {

+ 13 - 0
admin-ui/src/app/core/components/notification/notification.component.html

@@ -0,0 +1,13 @@
+<div class="notification-wrapper" #wrapper
+     [style.top.px]="offsetTop"
+     [ngClass]="{
+        'visible': isVisible,
+        'info': type === 'info',
+        'success': type === 'success',
+        'error': type === 'error',
+        'warning': type === 'warning'
+     }">
+    <clr-icon [attr.shape]="getIcon()" size="24"></clr-icon>
+    {{ message }}
+</div>
+

+ 43 - 0
admin-ui/src/app/core/components/notification/notification.component.scss

@@ -0,0 +1,43 @@
+@import "variables";
+
+@keyframes fadeIn {
+    0% { opacity: 0; }
+    100% { opacity: 0.95; }
+}
+
+:host {
+    > .notification-wrapper {
+        display: block;
+        position: fixed;
+        z-index: 1001;
+        top: 0;
+        right: 10px;
+        border-radius: 3px;
+        max-width: 98vw;
+        word-wrap: break-word;
+        padding: 10px;
+        background-color: $color-grey-5;
+        color: white;
+        transition: opacity 1s, top 0.3s;
+        opacity: 0;
+
+        &.success {
+            background-color: $color-success;
+        }
+        &.error {
+            background-color: $color-error;
+        }
+        &.warning {
+            background-color: $color-warning;
+        }
+        &.info {
+            background-color: $color-info;
+        }
+
+        &.visible {
+            opacity: 0.95;
+            animation: fadeIn 0.3s 0.3s backwards;
+        }
+        white-space: pre-line;
+    }
+}

+ 61 - 0
admin-ui/src/app/core/components/notification/notification.component.ts

@@ -0,0 +1,61 @@
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, ViewChild } from '@angular/core';
+import { NotificationType } from '../../providers/notification/notification.service';
+
+@Component({
+    selector: 'vdr-notification',
+    templateUrl: './notification.component.html',
+    styleUrls: ['./notification.component.scss'],
+})
+export class NotificationComponent {
+
+    @ViewChild('wrapper') wrapper: ElementRef;
+    offsetTop = 0;
+    message = '';
+    type: NotificationType = 'info';
+    isVisible = true;
+    private onClickFn: () => void = () => { /* */ };
+
+    registerOnClickFn(fn: () => void): void {
+        this.onClickFn = fn;
+    }
+
+    @HostListener('click')
+    onClick(): void {
+        if (this.isVisible) {
+            this.onClickFn();
+        }
+    }
+
+    /**
+     * Fade out the toast. When promise resolves, toast is invisible and
+     * can be removed.
+     */
+    fadeOut(): Promise<any> {
+        this.isVisible = false;
+        return new Promise(resolve => setTimeout(resolve, 1000));
+    }
+
+    /**
+     * Returns the height of the toast element in px.
+     */
+    getHeight(): number {
+        if (!this.wrapper) {
+            return 0;
+        }
+        const el: HTMLElement = this.wrapper.nativeElement;
+        return el.getBoundingClientRect().height;
+    }
+
+    getIcon(): string {
+        switch (this.type) {
+            case 'info':
+                return 'info-circle';
+            case 'success':
+                return 'check-circle';
+            case 'error':
+                return 'exclamation-circle';
+            case 'warning':
+                return 'exclamation-triangle';
+        }
+    }
+}

+ 17 - 0
admin-ui/src/app/core/components/overlay-host/overlay-host.component.ts

@@ -0,0 +1,17 @@
+import {Component, ViewContainerRef} from '@angular/core';
+
+import {OverlayHostService} from '../../providers/overlay-host/overlay-host.service';
+
+/**
+ * The OverlayHostComponent is a placeholder component which provides a location in the DOM into which overlay
+ * elements (modals, notify notifications etc) may be injected dynamically.
+ */
+@Component({
+    selector: 'vdr-overlay-host',
+    template: '<!-- -->',
+})
+export class OverlayHostComponent {
+    constructor(viewContainerRef: ViewContainerRef, overlayHostService: OverlayHostService) {
+        overlayHostService.registerHostView(viewContainerRef);
+    }
+}

+ 15 - 7
admin-ui/src/app/core/core.module.ts

@@ -4,21 +4,26 @@ import { Apollo, APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
 import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 
+import { API_PATH } from '../../../../shared/shared-constants';
+import { API_URL } from '../app.config';
 import { SharedModule } from '../shared/shared.module';
 import { APOLLO_NGRX_CACHE, StateModule } from '../state/state.module';
 import { AppShellComponent } from './components/app-shell/app-shell.component';
+import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
+import { MainNavComponent } from './components/main-nav/main-nav.component';
+import { UserMenuComponent } from './components/user-menu/user-menu.component';
 import { BaseDataService } from './providers/data/base-data.service';
-import { API_URL } from '../app.config';
-import { LocalStorageService } from './providers/local-storage/local-storage.service';
 import { DataService } from './providers/data/data.service';
 import { AuthGuard } from './providers/guard/auth.guard';
-import { UserMenuComponent } from './components/user-menu/user-menu.component';
-import { MainNavComponent } from './components/main-nav/main-nav.component';
-import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
+import { LocalStorageService } from './providers/local-storage/local-storage.service';
+import { OverlayHostComponent } from './components/overlay-host/overlay-host.component';
+import { OverlayHostService } from './providers/overlay-host/overlay-host.service';
+import { NotificationService } from './providers/notification/notification.service';
+import { NotificationComponent } from './components/notification/notification.component';
 
 export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
   return {
-    link: httpLink.create({ uri: `${API_URL}/api` }),
+    link: httpLink.create({ uri: `${API_URL}/${API_PATH}` }),
     cache: ngrxCache,
   };
 }
@@ -44,7 +49,10 @@ export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
         LocalStorageService,
         DataService,
         AuthGuard,
+        OverlayHostService,
+        NotificationService,
     ],
-    declarations: [AppShellComponent, UserMenuComponent, MainNavComponent, BreadcrumbComponent],
+    declarations: [AppShellComponent, UserMenuComponent, MainNavComponent, BreadcrumbComponent, OverlayHostComponent, NotificationComponent],
+    entryComponents: [NotificationComponent],
 })
 export class CoreModule {}

+ 98 - 0
admin-ui/src/app/core/providers/notification/notification.service.spec.ts

@@ -0,0 +1,98 @@
+import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
+import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing';
+
+import {OverlayHostComponent} from '../../components/overlay-host/overlay-host.component';
+import {OverlayHostService} from '../overlay-host/overlay-host.service';
+
+import {NotificationComponent} from '../../components/notification/notification.component';
+import {NotificationService} from './notification.service';
+
+describe('NotificationService:', () => {
+
+    beforeEach(() => {
+        TestBed.configureTestingModule({
+            declarations: [
+                NotificationComponent,
+                OverlayHostComponent,
+                TestComponent,
+            ],
+            providers: [
+                NotificationService,
+                OverlayHostService,
+            ],
+            schemas: [CUSTOM_ELEMENTS_SCHEMA],
+        });
+        // TODO: it looks like there will be an easier way to declare the entryComponents,
+        // see https://github.com/angular/angular/issues/12079
+        TestBed.overrideModule(BrowserDynamicTestingModule, {
+            set: {
+                entryComponents: [NotificationComponent],
+            },
+        });
+    });
+
+    describe('notification():', () => {
+
+        // The ToastComponent relies heavily on async calls to schedule the dismissal of a notify.
+        function runDismissTimers(): void {
+            tick(5000); // duration timeout
+            tick(2000); // fadeOut timeout
+            tick(); // promise
+            tick();
+        }
+
+        let fixture: ComponentFixture<TestComponent>;
+
+        beforeEach(fakeAsync(() => {
+            fixture = TestBed.createComponent(TestComponent);
+            tick();
+            fixture.detectChanges();
+        }));
+
+        it('should insert notify next to OverlayHost', fakeAsync(() => {
+            const instance: TestComponent = fixture.componentInstance;
+
+            instance.notificationService.notify({ message: 'test'});
+            fixture.detectChanges();
+            tick();
+
+            expect(fixture.nativeElement.querySelector('vdr-notification')).not.toBeNull();
+            runDismissTimers();
+        }));
+
+        it('should bind the message', fakeAsync(() => {
+            const instance: TestComponent = fixture.componentInstance;
+
+            instance.notificationService.notify({ message: 'test'});
+            tick();
+            fixture.detectChanges();
+
+            expect(fixture.nativeElement.querySelector('.notification-wrapper').innerHTML).toContain('test');
+            runDismissTimers();
+        }));
+
+        it('should dismiss after duration elapses', fakeAsync(() => {
+            const instance: TestComponent = fixture.componentInstance;
+
+            instance.notificationService.notify({
+                message: 'test',
+                duration: 1000,
+            });
+            tick();
+            fixture.detectChanges();
+            expect(fixture.nativeElement.querySelector('vdr-notification')).not.toBeNull();
+
+            runDismissTimers();
+
+            expect(fixture.nativeElement.querySelector('vdr-notification')).toBeNull();
+        }));
+    });
+});
+
+@Component({
+    template: `<vdr-overlay-host></vdr-overlay-host>`,
+})
+class TestComponent {
+    constructor(public notificationService: NotificationService) {}
+}

+ 135 - 0
admin-ui/src/app/core/providers/notification/notification.service.ts

@@ -0,0 +1,135 @@
+import {ComponentFactoryResolver, ComponentRef, Injectable, ViewContainerRef} from '@angular/core';
+
+import {OverlayHostService} from '../overlay-host/overlay-host.service';
+
+import {NotificationComponent} from '../../components/notification/notification.component';
+
+export type NotificationType = 'info' | 'success' | 'error' | 'warning';
+export interface ToastConfig {
+    message: string;
+    type?: NotificationType;
+    duration?: number;
+}
+
+// How many ms before the toast is dismissed.
+const TOAST_DURATION = 3000;
+
+/**
+ * Provides toast notification functionality.
+ */
+@Injectable()
+export class NotificationService {
+
+    private hostView: ViewContainerRef;
+    private openToastRefs: { ref: ComponentRef<NotificationComponent>, timerId: any }[] = [];
+
+    constructor(private resolver: ComponentFactoryResolver, overlayHostService: OverlayHostService) {
+        overlayHostService.getHostView().then(view => {
+            this.hostView = view;
+        });
+    }
+
+    /**
+     * Display a success toast notification
+     */
+    success(message: string): void {
+        this.notify({
+            message,
+            type: 'success',
+        });
+    }
+
+    /**
+     * Display an info toast notification
+     */
+    info(message: string): void {
+        this.notify({
+            message,
+            type: 'info',
+        });
+    }
+
+    /**
+     * Display a warning toast notification
+     */
+    warning(message: string): void {
+        this.notify({
+            message,
+            type: 'warning',
+        });
+    }
+
+    /**
+     * Display an error toast notification
+     */
+    error(message: string): void {
+        this.notify({
+            message,
+            type: 'error',
+            duration: 20000,
+        });
+    }
+
+    /**
+     * Display a toast notification.
+     */
+    notify(config: ToastConfig): void {
+        this.createToast(config);
+    }
+
+    /**
+     * Load a ToastComponent into the DOM host location.
+     */
+    private createToast(config: ToastConfig): void {
+        const toastFactory = this.resolver.resolveComponentFactory(NotificationComponent);
+        const ref = this.hostView.createComponent<NotificationComponent>(toastFactory);
+        const toast: NotificationComponent = ref.instance;
+        const dismissFn = this.createDismissFunction(ref);
+        toast.type = config.type || 'info';
+        toast.message = config.message;
+        toast.registerOnClickFn(dismissFn);
+
+        let timerId;
+        if (!config.duration || 0 < config.duration) {
+            timerId = setTimeout(dismissFn, config.duration || TOAST_DURATION);
+        }
+
+        this.openToastRefs.unshift({ ref, timerId });
+        setTimeout(() => this.calculatePositions());
+    }
+
+    /**
+     * Returns a function which will destroy the toast component and
+     * remove it from the openToastRefs array.
+     */
+    private createDismissFunction(ref: ComponentRef<NotificationComponent>): () => void {
+        return () => {
+            const toast: NotificationComponent = ref.instance;
+            const index = this.openToastRefs.map(o => o.ref).indexOf(ref);
+
+            if (this.openToastRefs[index]) {
+                clearTimeout(this.openToastRefs[index].timerId);
+            }
+
+            toast.fadeOut()
+                .then(() => {
+                    ref.destroy();
+                    this.openToastRefs.splice(index, 1);
+                    this.calculatePositions();
+                });
+        };
+    }
+
+    /**
+     * Calculate and set the top offsets for each of the open toasts.
+     */
+    private calculatePositions(): void {
+        let cumulativeHeight = 10;
+
+        this.openToastRefs.forEach(obj => {
+            const toast: NotificationComponent = obj.ref.instance;
+            toast.offsetTop = cumulativeHeight;
+            cumulativeHeight += toast.getHeight() + 6;
+        });
+    }
+}

+ 43 - 0
admin-ui/src/app/core/providers/overlay-host/overlay-host.service.ts

@@ -0,0 +1,43 @@
+import {Injectable, ViewContainerRef} from '@angular/core';
+
+/**
+ * The OverlayHostService is used to get a reference to the ViewConainerRef of the
+ * OverlayHost component, so that other components may insert components & elements
+ * into the DOM at that point.
+ */
+@Injectable()
+export class OverlayHostService {
+
+    private hostView: ViewContainerRef;
+    private promiseResolveFns: Array<(result: any) => void> = [];
+
+    /**
+     * Used to pass in the ViewContainerRed from the OverlayHost component.
+     * Should not be used by any other component.
+     */
+    registerHostView(viewContainerRef: ViewContainerRef): void {
+        this.hostView = viewContainerRef;
+        if (0 < this.promiseResolveFns.length) {
+            this.resolveHostView();
+        }
+    }
+
+    /**
+     * Returns a promise which resolves to the ViewContainerRef of the OverlayHost
+     * component. This can then be used to insert components and elements into the
+     * DOM at that point.
+     */
+    getHostView(): Promise<ViewContainerRef> {
+        return new Promise((resolve: (result: any) => void) => {
+            this.promiseResolveFns.push(resolve);
+            if (this.hostView !== undefined) {
+                this.resolveHostView();
+            }
+        });
+    }
+
+    private resolveHostView(): void {
+        this.promiseResolveFns.forEach(resolve => resolve(this.hostView));
+        this.promiseResolveFns = [];
+    }
+}

+ 5 - 0
server/src/common/common-types.ts

@@ -0,0 +1,5 @@
+/**
+ * Creates a type based on T, but with all properties non-optional
+ * and readonly.
+ */
+export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };

+ 2 - 1
server/src/config/vendure-config.ts

@@ -1,6 +1,7 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ConnectionOptions } from 'typeorm';
-import { DeepPartial, ReadOnlyRequired } from '../../../shared/shared-types';
+import { DeepPartial } from '../../../shared/shared-types';
+import { ReadOnlyRequired } from '../common/common-types';
 import { LanguageCode } from '../locale/language-code';
 import { AutoIncrementIdStrategy } from './auto-increment-id-strategy';
 import { EntityIdStrategy } from './entity-id-strategy';

+ 1 - 1
server/src/service/config.service.ts

@@ -1,7 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ConnectionOptions } from 'typeorm';
-import { ReadOnlyRequired } from '../../../shared/shared-types';
+import { ReadOnlyRequired } from '../common/common-types';
 import { EntityIdStrategy } from '../config/entity-id-strategy';
 import { getConfig, VendureConfig } from '../config/vendure-config';
 import { LanguageCode } from '../locale/language-code';

+ 0 - 6
shared/shared-types.ts

@@ -3,12 +3,6 @@
  */
 export type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };
 
-/**
- * Creates a type based on T, but with all properties non-optional
- * and readonly.
- */
-export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };
-
 // tslint:disable:ban-types
 /**
  * A type representing the type rather than instance of a class.