Просмотр исходного кода

feat(admin-ui): Create Breadcrumb component

Michael Bromley 7 лет назад
Родитель
Сommit
19fa0b8287

+ 3 - 0
admin-ui/src/app/app.routes.ts

@@ -8,6 +8,9 @@ export const routes: Route[] = [
         path: '',
         canActivate: [AuthGuard],
         component: AppShellComponent,
+        data: {
+            breadcrumb: 'Dashboard',
+        },
         children: [
             {
                 path: '',

+ 8 - 0
admin-ui/src/app/core/components/breadcrumb/breadcrumb.component.html

@@ -0,0 +1,8 @@
+<nav aria-label="You are here:" role="navigation">
+    <ul class="breadcrumbs">
+        <li *ngFor="let breadcrumb of breadcrumbs$ | async; let isLast = last">
+            <a [routerLink]="breadcrumb.link" *ngIf="!isLast">{{ breadcrumb.label }}</a>
+            <ng-container *ngIf="isLast">{{ breadcrumb.label }}</ng-container>
+        </li>
+    </ul>
+</nav>

+ 20 - 0
admin-ui/src/app/core/components/breadcrumb/breadcrumb.component.scss

@@ -0,0 +1,20 @@
+@import "variables";
+
+:host {
+    display: block;
+    height: 45px;
+}
+.breadcrumbs {
+    list-style-type: none;
+    li {
+        font-size: 16px;
+        display: inline-block;
+        margin-right: 10px;
+    }
+    li:not(:last-child)::after {
+        content: '›';
+        top: 0;
+        color: #7e7e7e;
+        margin-left: 10px;
+    }
+}

+ 427 - 0
admin-ui/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts

@@ -0,0 +1,427 @@
+import { Component, DebugElement } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { Resolve, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+import { Observable, of as observableOf } from 'rxjs';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+
+import { StateStore } from '../../../state/state-store.service';
+import { BreadcrumbComponent, BreadcrumbLabelLinkPair } from './breadcrumb.component';
+
+describe('BeadcrumbsComponent', () => {
+
+    let baseRouteConfig: Routes;
+    let router: Router;
+    let breadcrumbSubject: BehaviorSubject<string>;
+
+    beforeEach(() => {
+        breadcrumbSubject = new BehaviorSubject<string>('Initial Value');
+
+        const leafRoute = {
+            path: 'string-grandchild',
+            data: { breadcrumb: 'Grandchild' },
+            component: TestChildComponent,
+        };
+
+        baseRouteConfig = [
+            {
+                path: '',
+                component: TestParentComponent,
+                data: { breadcrumb: 'Root' },
+                children: [
+                    {
+                        path: 'string-child',
+                        component: TestParentComponent,
+                        data: { breadcrumb: 'Child' },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'no-breadcrumb-child',
+                        component: TestParentComponent,
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'simple-function-child',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: () => 'String From Function',
+                        },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'resolved-function-child',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: data => data.foo,
+                        },
+                        resolve: { foo: FooResolver },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'params-child/:name',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: (data, params) => params['name'],
+                        },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'single-pair-child',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: {
+                                label: 'Pair',
+                                link: ['foo', 'bar', { p: 1 }],
+                            } as BreadcrumbLabelLinkPair,
+                        },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'array-pair-child',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: [
+                                {
+                                    label: 'PairA',
+                                    link: ['foo', 'bar'],
+                                },
+                                {
+                                    label: 'PairB',
+                                    link: ['baz', 'quux'],
+                                },
+                            ] as BreadcrumbLabelLinkPair[],
+                        },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'pair-function-child',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: () => [
+                                {
+                                    label: 'PairA',
+                                    link: ['foo', 'bar'],
+                                },
+                                {
+                                    label: 'PairB',
+                                    link: ['baz', 'quux'],
+                                },
+                            ] as BreadcrumbLabelLinkPair[],
+                        },
+                        children: [leafRoute],
+                    },
+                    {
+                        path: 'relative-parent',
+                        component: TestParentComponent,
+                        data: { breadcrumb: 'Parent' },
+                        children: [
+                            {
+                                path: 'relative-child',
+                                component: TestParentComponent,
+                                data: {
+                                    breadcrumb: {
+                                        label: 'Child',
+                                        link: ['./', 'foo', { p: 1 }],
+                                    } as BreadcrumbLabelLinkPair,
+                                },
+                                children: [leafRoute],
+                            },
+                            {
+                                path: 'relative-sibling',
+                                component: TestParentComponent,
+                                data: {
+                                    breadcrumb: {
+                                        label: 'Sibling',
+                                        link: ['../', 'foo', { p: 1 }],
+                                    } as BreadcrumbLabelLinkPair,
+                                },
+                                children: [leafRoute],
+                            },
+                        ],
+                    },
+                    {
+                        path: 'deep-pair-child-1',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: 'Child 1',
+                        },
+                        children: [
+                            {
+                                path: 'deep-pair-child-2',
+                                component: TestParentComponent,
+                                data: {
+                                    breadcrumb: () => [
+                                        {
+                                            label: 'PairA',
+                                            link: ['./', 'child', 'path'],
+                                        },
+                                        {
+                                            label: 'PairB',
+                                            link: ['../', 'sibling', 'path'],
+                                        },
+                                        {
+                                            label: 'PairC',
+                                            link: ['absolute', 'path'],
+                                        },
+                                    ] as BreadcrumbLabelLinkPair[],
+                                },
+                                children: [leafRoute],
+                            },
+                        ],
+                    },
+                    {
+                        path: 'observable-string',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: observableOf('Observable String'),
+                        },
+                    },
+                    {
+                        path: 'observable-pair',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: observableOf({
+                                label: 'Observable Pair',
+                                link: ['foo', 'bar', { p: 1 }],
+                            } as BreadcrumbLabelLinkPair),
+                        },
+                    },
+                    {
+                        path: 'observable-array-pair',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: [
+                                {
+                                    label: 'Observable PairA',
+                                    link: ['foo', 'bar'],
+                                },
+                                {
+                                    label: 'Observable PairB',
+                                    link: ['baz', 'quux'],
+                                },
+                            ] as BreadcrumbLabelLinkPair[],
+                        },
+                    },
+                    {
+                        path: 'function-observable',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: () => observableOf('Observable String From Function'),
+                        },
+                    },
+                    {
+                        path: 'observable-string-subject',
+                        component: TestParentComponent,
+                        data: {
+                            breadcrumb: breadcrumbSubject.asObservable(),
+                        },
+                        children: [leafRoute],
+                    },
+                ],
+            },
+        ];
+
+        TestBed.configureTestingModule({
+            imports: [
+                RouterTestingModule.withRoutes(baseRouteConfig),
+            ],
+            declarations: [
+                BreadcrumbComponent,
+                TestParentComponent,
+                TestChildComponent,
+            ],
+            providers: [
+                FooResolver,
+                { provide: StateStore, useClass: class {} },
+            ],
+        }).compileComponents();
+
+        router = TestBed.get(Router);
+    });
+
+    /**
+     * Navigates to the provided route and returns the fixture for the TestChildComponent at that route.
+     */
+    function getFixtureForRoute(route: string[], testFn: (fixture: ComponentFixture<TestComponent>) => void): () => void {
+        return fakeAsync(() => {
+            const fixture = TestBed.createComponent(TestChildComponent);
+            router.navigate(route);
+            fixture.detectChanges();
+            tick();
+            fixture.detectChanges();
+            testFn(fixture);
+        });
+    }
+
+    it('shows correct labels for string breadcrumbs',
+        getFixtureForRoute(['', 'string-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            expect(labels).toEqual(['Root', 'Child', 'Grandchild']);
+        }));
+
+    it('links have correct href',
+        getFixtureForRoute(['', 'string-child', 'string-grandchild'], fixture => {
+            const links = getBreadcrumbLinks(fixture);
+            expect(links).toEqual(['/', '/string-child']);
+        }));
+
+    it('skips a route with no breadcrumbs configured',
+        getFixtureForRoute(['', 'no-breadcrumb-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            expect(labels).toEqual(['Root', 'Grandchild']);
+        }));
+
+    it('shows correct label for function breadcrumb',
+        getFixtureForRoute(['', 'simple-function-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            expect(labels).toEqual(['Root', 'String From Function', 'Grandchild']);
+        }));
+
+    it('works with resolved data',
+        getFixtureForRoute(['', 'resolved-function-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            expect(labels).toEqual(['Root', 'Foo', 'Grandchild']);
+        }));
+
+    it('works with data from parameters',
+        getFixtureForRoute(['', 'params-child', 'Bar', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            expect(labels).toEqual(['Root', 'Bar', 'Grandchild']);
+        }));
+
+    it('works with a BreadcrumbLabelLinkPair',
+        getFixtureForRoute(['', 'single-pair-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Pair', 'Grandchild']);
+            expect(links).toEqual(['/', '/foo/bar;p=1']);
+        }));
+
+    it('works with array of BreadcrumbLabelLinkPairs',
+        getFixtureForRoute(['', 'array-pair-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'PairA', 'PairB', 'Grandchild']);
+            expect(links).toEqual(['/', '/foo/bar', '/baz/quux']);
+        }));
+
+    it('works with function returning BreadcrumbLabelLinkPairs',
+        getFixtureForRoute(['', 'pair-function-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'PairA', 'PairB', 'Grandchild']);
+            expect(links).toEqual(['/', '/foo/bar', '/baz/quux']);
+        }));
+
+    it('works with relative child paths in a BreadcrumbLabelLinkPair',
+        getFixtureForRoute(['', 'relative-parent', 'relative-child', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Parent', 'Child', 'Grandchild']);
+            expect(links).toEqual(['/', '/relative-parent', '/relative-parent/relative-child/foo;p=1']);
+        }));
+
+    it('works with relative sibling paths in a BreadcrumbLabelLinkPair',
+        getFixtureForRoute(['', 'relative-parent', 'relative-sibling', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Parent', 'Sibling', 'Grandchild']);
+            expect(links).toEqual(['/', '/relative-parent', '/relative-parent/foo;p=1']);
+        }));
+
+    it('array of BreadcrumbLabelLinkPairs paths compose correctly',
+        getFixtureForRoute(['', 'deep-pair-child-1', 'deep-pair-child-2', 'string-grandchild'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Child 1', 'PairA', 'PairB', 'PairC', 'Grandchild']);
+            expect(links).toEqual([
+                '/',
+                '/deep-pair-child-1',
+                '/deep-pair-child-1/deep-pair-child-2/child/path',
+                '/deep-pair-child-1/sibling/path',
+                '/absolute/path',
+            ]);
+        }));
+
+    it('shows correct labels for observable of string',
+        getFixtureForRoute(['', 'observable-string'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Observable String']);
+        }));
+
+    it('shows correct labels for observable of BreadcrumbLabelLinkPair',
+        getFixtureForRoute(['', 'observable-pair'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Observable Pair']);
+        }));
+
+    it('shows correct labels for observable of BreadcrumbLabelLinkPair array',
+        getFixtureForRoute(['', 'observable-array-pair'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Observable PairA', 'Observable PairB']);
+            expect(links).toEqual(['/', '/foo/bar']);
+        }));
+
+    it('shows correct labels for function returning observable string',
+        getFixtureForRoute(['', 'function-observable'], fixture => {
+            const labels = getBreadcrumbLabels(fixture);
+            const links = getBreadcrumbLinks(fixture);
+            expect(labels).toEqual(['Root', 'Observable String From Function']);
+        }));
+
+    it('labels update when observables emit new values',
+        getFixtureForRoute(['', 'observable-string-subject', 'string-grandchild'], fixture => {
+            expect(getBreadcrumbLabels(fixture)).toEqual(['Root', 'Initial Value', 'Grandchild']);
+            breadcrumbSubject.next('New Value');
+            fixture.detectChanges();
+            expect(getBreadcrumbLabels(fixture)).toEqual(['Root', 'New Value', 'Grandchild']);
+        }));
+});
+
+function getBreadcrumbsElement(fixture: ComponentFixture<TestComponent>): DebugElement {
+    return fixture.debugElement.query(By.directive(BreadcrumbComponent));
+}
+
+function getBreadcrumbListItems(fixture: ComponentFixture<TestComponent>): HTMLLIElement[] {
+    return fixture.debugElement.queryAll(By.css('.breadcrumbs li'))
+        .map(de => de.nativeElement);
+}
+
+function getBreadcrumbLabels(fixture: ComponentFixture<TestComponent>): string[] {
+    return getBreadcrumbListItems(fixture).map(item => item.innerText.trim());
+}
+
+function getBreadcrumbLinks(fixture: ComponentFixture<TestComponent>): string[] {
+    return getBreadcrumbListItems(fixture)
+        .map(el => el.querySelector('a'))
+        .filter(el => !!el)
+        .map(a => a.getAttribute('href'));
+}
+
+// tslint:disable component-selector
+@Component({
+    selector: 'test-root-component',
+    template: `
+        <vdr-breadcrumb></vdr-breadcrumb>
+        <router-outlet></router-outlet>`,
+})
+class TestParentComponent {}
+
+@Component({
+    selector: 'test-child-component',
+    template: `
+        <vdr-breadcrumb></vdr-breadcrumb>`,
+})
+class TestChildComponent {}
+
+type TestComponent = TestParentComponent | TestChildComponent;
+
+class FooResolver implements Resolve<string> {
+    resolve(): Observable<string> {
+        return observableOf('Foo');
+    }
+}

+ 146 - 0
admin-ui/src/app/core/components/breadcrumb/breadcrumb.component.ts

@@ -0,0 +1,146 @@
+import { Component, OnDestroy } from '@angular/core';
+import { ActivatedRoute, Data, NavigationEnd, Params, PRIMARY_OUTLET, Router } from '@angular/router';
+import { flatten } from 'lodash';
+import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subject } from 'rxjs';
+import { filter, map, takeUntil } from 'rxjs/operators';
+import { StateStore } from '../../../state/state-store.service';
+
+export type BreadcrumbString = string;
+export interface BreadcrumbLabelLinkPair {
+    label: string;
+    link: any[];
+}
+export type BreadcrumbValue = BreadcrumbString | BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[];
+export type BreadcrumbFunction = (data: Data, params: Params, store: StateStore) => BreadcrumbValue | Observable<BreadcrumbValue>;
+export type BreadcrumbDefinition = BreadcrumbValue | BreadcrumbFunction | Observable<BreadcrumbValue>;
+
+/**
+ * A breadcrumbs component which reads the route config and any route that has a `data.breadcrumb` property will
+ * be displayed in the breadcrumb trail.
+ *
+ * The `breadcrumb` property can be a string or a function. If a function, it will be passed the route's `data`
+ * object (which will include all resolved keys) and any route params, and should return a BreadcrumbValue.
+ *
+ * See the test config to get an idea of allowable configs for breadcrumbs.
+ */
+@Component({
+    selector: 'vdr-breadcrumb',
+    templateUrl: './breadcrumb.component.html',
+    styleUrls: ['./breadcrumb.component.scss'],
+})
+export class BreadcrumbComponent implements OnDestroy {
+
+    breadcrumbs$: Observable<Array<{ link: string | any[]; label: string; }>>;
+    private destroy$ = new Subject<void>();
+
+    constructor(private router: Router,
+                private route: ActivatedRoute,
+                private store: StateStore) {
+        this.router.events.pipe(
+            filter(event => event instanceof NavigationEnd),
+            takeUntil(this.destroy$))
+            .subscribe(event => {
+                this.breadcrumbs$ = this.generateBreadcrumbs(this.route.root);
+            });
+    }
+
+    ngOnDestroy(): void {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    private generateBreadcrumbs(rootRoute: ActivatedRoute): Observable<Array<{ link: Array<string | any>; label: string; }>> {
+        const breadcrumbParts = this.assembleBreadcrumbParts(rootRoute);
+        const breadcrumbObservables$ = breadcrumbParts
+            .map(({ value$, path }) => {
+                return value$.pipe(
+                    map(value => {
+                        if (isBreadcrumbLabelLinkPair(value)) {
+                            return {
+                                label: value.label,
+                                link: this.normalizeRelativeLinks(value.link, path),
+                            };
+                        } else if (isBreadcrumbPairArray(value)) {
+                            return value.map(val => ({
+                                label: val.label,
+                                link: this.normalizeRelativeLinks(val.link, path),
+                            }));
+                        } else {
+                            return {
+                                label: value,
+                                link: '/' + path.join('/'),
+                            };
+                        }
+                    }),
+                ) as Observable<BreadcrumbLabelLinkPair | BreadcrumbLabelLinkPair[]>;
+            });
+
+        return observableCombineLatest(breadcrumbObservables$).pipe(map(links => flatten(links)));
+    }
+
+    /**
+     * Walks the route definition tree to assemble an array from which the breadcrumbs can be derived.
+     */
+    private assembleBreadcrumbParts(rootRoute: ActivatedRoute): Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> {
+        const breadcrumbParts: Array<{ value$: Observable<BreadcrumbValue>; path: string[] }> = [];
+        const inferredUrl = '';
+        const segmentPaths: string[] = [];
+        let currentRoute: ActivatedRoute | null = rootRoute;
+        do {
+            const childRoutes = currentRoute.children;
+            currentRoute = null;
+            childRoutes.forEach((route: ActivatedRoute) => {
+                if (route.outlet === PRIMARY_OUTLET) {
+                    const routeSnapshot = route.snapshot;
+                    let breadcrumbDef: BreadcrumbDefinition | undefined =
+                        route.routeConfig && route.routeConfig.data && route.routeConfig.data['breadcrumb'];
+                    segmentPaths.push(routeSnapshot.url.map(segment => segment.path).join('/'));
+
+                    if (breadcrumbDef) {
+                        if (isBreadcrumbFunction(breadcrumbDef)) {
+                            breadcrumbDef = breadcrumbDef(routeSnapshot.data, routeSnapshot.params, this.store);
+                        }
+                        const observableValue = isObservable(breadcrumbDef) ? breadcrumbDef : observableOf(breadcrumbDef);
+                        breadcrumbParts.push({ value$: observableValue, path: segmentPaths.slice() });
+                    }
+                    currentRoute = route;
+                }
+            });
+        } while (currentRoute);
+
+        return breadcrumbParts;
+    }
+
+    /**
+     * Accounts for relative routes in the link array, i.e. arrays whose first element is either:
+     * * `./`   - this appends the rest of the link segments to the current active route
+     * * `../`  - this removes the last segment of the current active route, and appends the link segments
+     *            to the parent route.
+     */
+    private normalizeRelativeLinks(link: any[], segmentPaths: string[]): any[] {
+        const clone = link.slice();
+        if (clone[0] === './') {
+            clone[0] = segmentPaths.join('/');
+        }
+        if (clone[0] === '../') {
+            clone[0] = segmentPaths.slice(0, -1).join('/');
+        }
+        return clone;
+    }
+}
+
+function isBreadcrumbFunction(value: BreadcrumbDefinition): value is BreadcrumbFunction {
+    return typeof value === 'function';
+}
+
+function isObservable(value: BreadcrumbDefinition): value is Observable<BreadcrumbValue> {
+    return value instanceof Observable;
+}
+
+function isBreadcrumbLabelLinkPair(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair {
+    return value.hasOwnProperty('label') && value.hasOwnProperty('link');
+}
+
+function isBreadcrumbPairArray(value: BreadcrumbValue): value is BreadcrumbLabelLinkPair[] {
+    return Array.isArray(value) && isBreadcrumbLabelLinkPair(value[0]);
+}

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

@@ -14,6 +14,7 @@ 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';
 
 export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
   return {
@@ -44,6 +45,6 @@ export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
         DataService,
         AuthGuard,
     ],
-    declarations: [AppShellComponent, UserMenuComponent, MainNavComponent],
+    declarations: [AppShellComponent, UserMenuComponent, MainNavComponent, BreadcrumbComponent],
 })
 export class CoreModule {}

+ 5 - 0
admin-ui/src/styles.scss

@@ -1 +1,6 @@
 /* You can add global styles to this file, and also import other style files */
+@import "variables";
+
+a:link, a:visited {
+    color: darken($color-brand, 20%);
+}