From fc3ae867d040f30d7d4ee9794de8d7dc96091822 Mon Sep 17 00:00:00 2001 From: klemenoslaj Date: Fri, 22 Jan 2021 13:40:17 +0100 Subject: [PATCH] feat: implement anchor action to open a browser link --- .../lib/action-abstract/action-abstract.ts | 2 +- .../lib/action-anchor/action-anchor.model.ts | 24 ++++ .../lib/action-anchor/action-anchor.spec.ts | 75 ++++++++++++ .../src/lib/action-anchor/action-anchor.ts | 109 ++++++++++++++++++ projects/core/src/public_api.ts | 3 + .../src/lib/action-mat-anchor.component.ts | 74 ++++++++++++ .../src/lib/action-mat-button.component.scss | 12 ++ .../src/lib/action-mat-button.component.ts | 32 +---- .../src/lib/action-mat-button.directive.ts | 18 +-- .../src/lib/action-mat-menu.component.ts | 13 ++- .../material/src/lib/action-mat.module.ts | 11 +- .../playground/src/app/app.component.html | 14 ++- .../playground/src/app/app.component.scss | 4 + projects/playground/src/app/app.component.ts | 33 +++++- projects/playground/src/app/app.module.ts | 33 +++++- 15 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 projects/core/src/lib/action-anchor/action-anchor.model.ts create mode 100644 projects/core/src/lib/action-anchor/action-anchor.spec.ts create mode 100644 projects/core/src/lib/action-anchor/action-anchor.ts create mode 100644 projects/material/src/lib/action-mat-anchor.component.ts create mode 100644 projects/material/src/lib/action-mat-button.component.scss diff --git a/projects/core/src/lib/action-abstract/action-abstract.ts b/projects/core/src/lib/action-abstract/action-abstract.ts index 3504115..f3e1280 100644 --- a/projects/core/src/lib/action-abstract/action-abstract.ts +++ b/projects/core/src/lib/action-abstract/action-abstract.ts @@ -82,7 +82,7 @@ export class ActionCustom extends ActionAbstract { +export abstract class ActionAbstract { /** * @internal * diff --git a/projects/core/src/lib/action-anchor/action-anchor.model.ts b/projects/core/src/lib/action-anchor/action-anchor.model.ts new file mode 100644 index 0000000..d93a567 --- /dev/null +++ b/projects/core/src/lib/action-anchor/action-anchor.model.ts @@ -0,0 +1,24 @@ +import { UrlTree } from '@angular/router'; +import { ActionAbstractComponentImpl, ActionAbstractOptions } from '../action-abstract/action-abstract.model'; +import { ActionAnchor } from './action-anchor'; + +/** + * Type that components used by `ActionAnchor` should implement + */ +export type ActionAnchorComponentImpl = ActionAbstractComponentImpl; + +/** + * _self: the current browsing context. (Default) + * _blank: usually a new tab, but users can configure browsers to open a new window instead. + * _parent: the parent browsing context of the current one. If no parent, behaves as _self. + * _top: the topmost browsing context (the "highest" context that’s an ancestor of the current one). If no ancestors, behaves as _self. + */ +export type AnchorTarget = '_self' | '_blank' | '_parent' | '_top'; + +/** + * `ActionAnchor` specific options, extending abstract options with it's specific properties + */ +export interface ActionAnchorOptions extends ActionAbstractOptions { + readonly href?: UrlTree | string | string[]; + readonly target?: AnchorTarget; +} diff --git a/projects/core/src/lib/action-anchor/action-anchor.spec.ts b/projects/core/src/lib/action-anchor/action-anchor.spec.ts new file mode 100644 index 0000000..6f24264 --- /dev/null +++ b/projects/core/src/lib/action-anchor/action-anchor.spec.ts @@ -0,0 +1,75 @@ +import { UrlTree } from '@angular/router'; +import { TestScheduler } from 'rxjs/testing'; + +import { ActionAnchor } from './action-anchor'; + +interface TestContext { + action: ActionAnchor; + testScheduler: TestScheduler; +} + +describe('Class: ActionAnchor', function (): void { + beforeEach(function (this: TestContext): void { + this.action = new ActionAnchor().activate(); + this.testScheduler = new TestScheduler( + (actual, e) => expect(actual).toEqual(e) + ); + }); + + it('should have public methods', function (this: TestContext): void { + expect(this.action.trigger).toEqual(jasmine.any(Function)); + expect(this.action.setHref).toEqual(jasmine.any(Function)); + expect(this.action.setTarget).toEqual(jasmine.any(Function)); + expect(this.action.isExternalLink).toEqual(jasmine.any(Function)); + }); + + it('should use provided href and target', function (this: TestContext): void { + this.action = new ActionAnchor({ href: '/home', target: '_self' }); + this.action.href$.subscribe(href => expect(href).toBe('/home')); + this.action.target$.subscribe(target => expect(target).toBe('_self')); + }); + + it('should properly differentiate external url and router links', function (this: TestContext): void { + this.action.setHref('/home'); + expect(this.action.isExternalLink()).toBeFalse(); + + this.action.setHref('home'); + expect(this.action.isExternalLink()).toBeFalse(); + + this.action.setHref(['user', '11111', 'overview']); + expect(this.action.isExternalLink()).toBeFalse(); + + this.action.setHref(new UrlTree()); + expect(this.action.isExternalLink()).toBeFalse(); + + this.action.setHref('www.external.com'); + expect(this.action.isExternalLink()).toBeTrue(); + + this.action.setHref('http://www.external.com'); + expect(this.action.isExternalLink()).toBeTrue(); + }); + + it('should notify every change with changes$', function (this: TestContext): void { + const marble = '(tivdlg)'; + const values = { + t: { title: 'Test Title' }, + i: { icon: 'test-icon' }, + v: { visible: true }, + d: { disabled: false }, + l: { link: '/home' }, + g: { target: '_blank' }, + }; + + this.action.setTitle('Test Title'); + this.action.setIcon('test-icon'); + this.action.show(); + this.action.trigger(); + this.action.enable(); + this.action.disable(); // Won't disable link + this.action.setHref('/home'); + this.action.setTarget('_blank'); + + this.testScheduler.expectObservable(this.action.changes$).toBe(marble, values); + this.testScheduler.flush(); + }); +}); diff --git a/projects/core/src/lib/action-anchor/action-anchor.ts b/projects/core/src/lib/action-anchor/action-anchor.ts new file mode 100644 index 0000000..fe3df50 --- /dev/null +++ b/projects/core/src/lib/action-anchor/action-anchor.ts @@ -0,0 +1,109 @@ +import { Type } from '@angular/core'; +import { UrlTree } from '@angular/router'; +import { Observable, merge, EMPTY, BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ActionAbstract } from '../action-abstract/action-abstract'; +import { ActionAnchorComponentImpl, ActionAnchorOptions, AnchorTarget } from './action-anchor.model'; + +/** + * Default options for `ActionAnchor` + * Extended by provided options in action `constructor` + */ +const defaultButtonOptions: ActionAnchorOptions = { }; + +/** + * `ActionAnchor` used to create basic link + * + * ## Example + * + * + * +```typescript +const button = new ActionAnchor({ title: 'Test', link: 'https://...' }); +``` + * + * **Or** + * + * +```typescript +const button = actionFactory.createButton({ title: 'Test', link: 'https://...' }); +``` + * + * **Or** + * +```typescript +const button = actionFactory.createButton().setTitle('Test'); +``` + */ +export class ActionAnchor extends ActionAbstract { + /** + * `EMPTY Observable` as link is handled by the browser + */ + readonly fire$ = EMPTY; + /** + * `Observable` notifies subscriptions on following changes: + * *title, icon, visibility, disabled* + */ + readonly changes$: Observable; + + readonly href$: Observable; + readonly target$: Observable; + + protected href: BehaviorSubject; + protected target: BehaviorSubject; + + /** + * Public `constructor` used to instantiate `ActionAnchor` + * + * @param options Options for `ActionAnchor` + * @param component Optional custom `Component` + */ + constructor(options: ActionAnchorOptions = defaultButtonOptions, + component?: Type) { + super({ ...defaultButtonOptions, ...options }, component); + + this.href = new BehaviorSubject(options.href ?? null); + this.target = new BehaviorSubject(options.target ?? null); + + this.href$ = this.handleLivecycleDistinct(this.href.asObservable(), false); + this.target$ = this.handleLivecycleDistinct(this.target.asObservable(), false); + this.changes$ = this.handleLivecycle(merge( + this.title$.pipe(map(title => ({ title }))), + this.icon$.pipe(map(icon => ({ icon }))), + this.visible$.pipe(map(visible => ({ visible }))), + this.disabled$.pipe(map(disabled => ({ disabled }))), + this.href$.pipe(map(href => ({ link: href }))), + this.target$.pipe(map(target => ({ target }))), + )); + } + + trigger(): this { + return this; + } + + setHref(link: UrlTree | string | string[] | null) { + this.href.next(link); + return this; + } + + setTarget(target: AnchorTarget) { + this.target.next(target); + return this; + } + + disable() { + // HTML Anchors cannot be disabled + return this; + } + + enable() { + // HTML Anchors cannot be disabled + return this; + } + + isExternalLink() { + const link = this.href.getValue(); + return typeof link === 'string' && (link.startsWith('http') || link.startsWith('www')); + } +} diff --git a/projects/core/src/public_api.ts b/projects/core/src/public_api.ts index d1ed9a4..63714af 100644 --- a/projects/core/src/public_api.ts +++ b/projects/core/src/public_api.ts @@ -18,3 +18,6 @@ export * from './lib/action-group/action-group.model'; export * from './lib/action-toggle/action-toggle'; export * from './lib/action-toggle/action-toggle.model'; + +export * from './lib/action-anchor/action-anchor'; +export * from './lib/action-anchor/action-anchor.model'; diff --git a/projects/material/src/lib/action-mat-anchor.component.ts b/projects/material/src/lib/action-mat-anchor.component.ts new file mode 100644 index 0000000..c531efe --- /dev/null +++ b/projects/material/src/lib/action-mat-anchor.component.ts @@ -0,0 +1,74 @@ +import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, HostBinding, Inject } from '@angular/core'; +import { ActionAnchor, ActionAnchorComponentImpl } from '@ng-action-outlet/core'; +import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; + +import { isMenuItem } from './common'; +import { actionMatButtonTemplate } from './action-mat-button.template'; +import { ICON_TYPE, ACTION_ICON_TYPE_TOKEN } from './action-icon-type-token'; + +export const AnchorType = { + Link: 0, + MenuLink: 1 +}; + +@Component({ + selector: 'action-mat-anchor', + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + ${actionMatButtonTemplate} + `, + styleUrls: ['./action-mat-button.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class ActionMatAnchorComponent implements ActionAnchorComponentImpl, FocusableOption { + @Input('action') + readonly _action?: ActionAnchor | null; + + @HostBinding('class') + readonly _classname = 'action-mat-button'; + + AnchorType = AnchorType; + + constructor( + @Inject(ACTION_ICON_TYPE_TOKEN) + readonly _iconType: ICON_TYPE, + private readonly _elementRef: ElementRef, + private readonly _focusMonitor?: FocusMonitor, + ) {} + + focus(origin: FocusOrigin = 'program', options?: FocusOptions): void { + if (this._focusMonitor) { + this._focusMonitor.focusVia(this._elementRef.nativeElement, origin, options); + } else { + this._elementRef.nativeElement.focus(options); + } + } + + _getAnchorType(action: ActionAnchor) { + return isMenuItem(action.getParent()) ? AnchorType.MenuLink : AnchorType.Link; + } +} diff --git a/projects/material/src/lib/action-mat-button.component.scss b/projects/material/src/lib/action-mat-button.component.scss new file mode 100644 index 0000000..16f80f8 --- /dev/null +++ b/projects/material/src/lib/action-mat-button.component.scss @@ -0,0 +1,12 @@ +.action-mat-button { + display: contents; +} + +.action-mat-button .mat-button, +.action-mat-button .mat-icon-button { + vertical-align: top; +} + +.action-mat-button a:hover { + text-decoration: underline; +} diff --git a/projects/material/src/lib/action-mat-button.component.ts b/projects/material/src/lib/action-mat-button.component.ts index fdd598c..0b61e75 100644 --- a/projects/material/src/lib/action-mat-button.component.ts +++ b/projects/material/src/lib/action-mat-button.component.ts @@ -1,7 +1,6 @@ import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, HostBinding, Inject } from '@angular/core'; import { ActionButton, ActionButtonComponentImpl } from '@ng-action-outlet/core'; import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; -import { MatMenu } from '@angular/material/menu'; import { isMenuItem } from './common'; import { actionMatButtonTemplate } from './action-mat-button.template'; @@ -9,9 +8,7 @@ import { ICON_TYPE, ACTION_ICON_TYPE_TOKEN } from './action-icon-type-token'; export const ButtonType = { Button: 0, - ButtonMenu: 1, - MenuItem: 2, - MenuItemMenu: 3, + MenuItem: 1 }; @Component({ @@ -22,31 +19,14 @@ export const ButtonType = { - - - - ${actionMatButtonTemplate} `, - styles: [` - .action-mat-button { - display: contents; - } - - .action-mat-button .mat-button, - .action-mat-button .mat-icon-button { - vertical-align: top; - } - `], + styleUrls: ['./action-mat-button.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) @@ -54,9 +34,6 @@ export class ActionMatButtonComponent implements ActionButtonComponentImpl, Focu @Input('action') readonly _action?: ActionButton | null; - @Input('forMatMenu') - readonly _forMatMenu?: MatMenu; - @HostBinding('class') readonly _classname = 'action-mat-button'; @@ -79,11 +56,6 @@ export class ActionMatButtonComponent implements ActionButtonComponentImpl, Focu _getButtonType(action: ActionButton) { const isButtonMenuItem = isMenuItem(action.getParent()); - - if (this._forMatMenu) { - return isButtonMenuItem ? ButtonType.MenuItemMenu : ButtonType.ButtonMenu; - } - return isButtonMenuItem ? ButtonType.MenuItem : ButtonType.Button; } } diff --git a/projects/material/src/lib/action-mat-button.directive.ts b/projects/material/src/lib/action-mat-button.directive.ts index c42fb10..5a6a67a 100644 --- a/projects/material/src/lib/action-mat-button.directive.ts +++ b/projects/material/src/lib/action-mat-button.directive.ts @@ -1,19 +1,19 @@ import { Directive, Input, OnDestroy, ElementRef, Renderer2, Optional, Inject, ChangeDetectorRef } from '@angular/core'; import { MatMenuTrigger, MatMenuItem } from '@angular/material/menu'; import { MatButton } from '@angular/material/button'; -import { ActionButton, ActionGroup } from '@ng-action-outlet/core'; -import { Subject, fromEvent, ReplaySubject } from 'rxjs'; +import { ActionButton, ActionGroup, ActionAnchor } from '@ng-action-outlet/core'; +import { Subject, fromEvent, ReplaySubject, EMPTY } from 'rxjs'; import { takeUntil, switchMap, filter } from 'rxjs/operators'; @Directive({ - selector: 'button[actionMatButton]', + selector: 'button[actionMatButton], a[actionMatButton]', }) export class ActionMatButtonDirective implements OnDestroy { - private _action$ = new ReplaySubject(1); + private _action$ = new ReplaySubject(1); private _unsubscribe$ = new Subject(); @Input('actionMatButton') - set _actionMatButton(action: ActionButton | ActionGroup) { + set _actionMatButton(action: ActionButton | ActionGroup | ActionAnchor) { this._action$.next(action); } @@ -29,16 +29,18 @@ export class ActionMatButtonDirective implements OnDestroy { cdRef: ChangeDetectorRef, ) { this._action$.pipe( - switchMap(action => action.disabled$), + switchMap(action => action instanceof ActionAnchor ? EMPTY : action.disabled$), takeUntil(this._unsubscribe$), ).subscribe(disabled => { // Either MatButton, either MatMenuItem should always be present. // tslint:disable-next-line: no-non-null-assertion - (matButton || matMenuItem)!.disabled = disabled; + (matButton ?? matMenuItem)!.disabled = disabled; cdRef.markForCheck(); }); - renderer.setAttribute(nativeElement, 'type', 'button'); + if (nativeElement.tagName === 'BUTTON') { + renderer.setAttribute(nativeElement, 'type', 'button'); + } const ariaLabel$ = this._action$.pipe(switchMap(action => action.ariaLabel$)); ariaLabel$.pipe( diff --git a/projects/material/src/lib/action-mat-menu.component.ts b/projects/material/src/lib/action-mat-menu.component.ts index 0f48f2e..8f5813a 100644 --- a/projects/material/src/lib/action-mat-menu.component.ts +++ b/projects/material/src/lib/action-mat-menu.component.ts @@ -1,5 +1,5 @@ import { Component, Input, ViewChild, HostBinding, ChangeDetectionStrategy, ViewEncapsulation, Inject } from '@angular/core'; -import { AnyAction, ActionGroup, ActionGroupComponentImpl } from '@ng-action-outlet/core'; +import { AnyAction, ActionGroup, ActionGroupComponentImpl, ActionAnchor } from '@ng-action-outlet/core'; import { MatMenu } from '@angular/material/menu'; import { trackByAction, TrackByAction } from './common'; @@ -22,9 +22,10 @@ import { ACTION_ICON_TYPE_TOKEN, ICON_TYPE } from './action-icon-type-token'; - + + + + @@ -79,6 +80,10 @@ export class ActionMatMenuComponent implements ActionGroupComponentImpl { return action instanceof ActionGroup && action.isDropdown(); } + _isAnchor(action?: AnyAction): action is ActionAnchor { + return action instanceof ActionAnchor; + } + _showDivider(action: ActionGroup) { const parent = action.getParent(); return !action.isDropdown() && parent && parent.isDropdown() && parent.getChild(0) !== action; diff --git a/projects/material/src/lib/action-mat.module.ts b/projects/material/src/lib/action-mat.module.ts index b248578..8e2124a 100644 --- a/projects/material/src/lib/action-mat.module.ts +++ b/projects/material/src/lib/action-mat.module.ts @@ -1,16 +1,18 @@ import { NgModule, ModuleWithProviders } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ActionOutletModule, ActionButton, ActionGroup } from '@ng-action-outlet/core'; +import { ActionOutletModule, ActionButton, ActionGroup, ActionAnchor } from '@ng-action-outlet/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { ActionMatButtonComponent } from './action-mat-button.component'; +import { ActionMatAnchorComponent } from './action-mat-anchor.component'; import { ActionMatGroupComponent } from './action-mat-group.component'; import { ActionMatMenuComponent } from './action-mat-menu.component'; import { ICON_TYPE, ACTION_ICON_TYPE_TOKEN } from './action-icon-type-token'; import { ActionMatButtonDirective } from './action-mat-button.directive'; +import { RouterModule } from '@angular/router'; @NgModule({ imports: [ @@ -19,13 +21,15 @@ import { ActionMatButtonDirective } from './action-mat-button.directive'; MatButtonModule, MatMenuModule, MatIconModule, - MatDividerModule + MatDividerModule, + RouterModule, ], declarations: [ ActionMatButtonComponent, ActionMatGroupComponent, ActionMatMenuComponent, ActionMatButtonDirective, + ActionMatAnchorComponent, ], entryComponents: [ ActionMatButtonComponent, @@ -38,6 +42,9 @@ import { ActionMatButtonDirective } from './action-mat-button.directive'; providers: [{ provide: ActionButton, useValue: ActionMatButtonComponent + }, { + provide: ActionAnchor, + useValue: ActionMatAnchorComponent }, { provide: ActionGroup, useValue: ActionMatGroupComponent diff --git a/projects/playground/src/app/app.component.html b/projects/playground/src/app/app.component.html index c8749fb..4021d2d 100644 --- a/projects/playground/src/app/app.component.html +++ b/projects/playground/src/app/app.component.html @@ -3,7 +3,7 @@
- + @@ -33,3 +33,15 @@
+ +
+
+ +
+ + + Current route: + + + + diff --git a/projects/playground/src/app/app.component.scss b/projects/playground/src/app/app.component.scss index 99bc8d1..185c8c6 100644 --- a/projects/playground/src/app/app.component.scss +++ b/projects/playground/src/app/app.component.scss @@ -23,4 +23,8 @@ .interactive-card--form-field { display: block; } + + .router-outlet { + font-weight: bold; + } } diff --git a/projects/playground/src/app/app.component.ts b/projects/playground/src/app/app.component.ts index 8d659c9..31bd05b 100644 --- a/projects/playground/src/app/app.component.ts +++ b/projects/playground/src/app/app.component.ts @@ -1,5 +1,5 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { ActionGroup, ActionButtonEvent, ActionButton, AnyAction } from '@ng-action-outlet/core'; +import { ActionGroup, ActionButtonEvent, ActionButton, AnyAction, ActionAnchor } from '@ng-action-outlet/core'; @Component({ selector: 'app-root', @@ -13,9 +13,9 @@ export class AppComponent { callback: this.callback, disabled: true }); - button2 = new ActionButton({ - title: 'Button 2', - callback: this.callback + link1 = new ActionAnchor({ + title: 'Hello route (this tab)', + href: ['hello'], }); menuItem1 = new ActionButton({ title: 'Menu item 1', @@ -53,13 +53,36 @@ export class AppComponent { ariaLabel: 'Menu for more actions', children: [ this.menuItem1, + new ActionAnchor({ + title: 'Hello route (new tab)', + href: ['hello'], + target: '_blank', + }), + new ActionAnchor({ + title: 'Home route (this tab)', + href: ['home'], + }), + new ActionAnchor({ + title: 'Home route (new tab)', + href: ['home'], + target: '_blank', + }), + new ActionAnchor({ + title: 'Google (this tab)', + href: 'http://www.google.com', + }), + new ActionAnchor({ + title: 'Google (new tab)', + href: 'http://www.google.com', + target: '_blank', + }), this.dropdown2, ] }); group1 = new ActionGroup({ children: [ this.button1, - this.button2, + this.link1, this.dropdown1 ] }); diff --git a/projects/playground/src/app/app.module.ts b/projects/playground/src/app/app.module.ts index 879cf62..2c81b32 100644 --- a/projects/playground/src/app/app.module.ts +++ b/projects/playground/src/app/app.module.ts @@ -1,5 +1,6 @@ import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ChangeDetectionStrategy, Component, NgModule } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -7,12 +8,27 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ActionOutletModule } from '@ng-action-outlet/core'; import { ActionMatModule, ICON_TYPE } from '@ng-action-outlet/material'; +import { MatDividerModule } from '@angular/material/divider'; import { AppComponent } from './app.component'; +@Component({ + template: 'home', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HomeComponent {} + +@Component({ + template: 'hello', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HelloComponent {} + @NgModule({ declarations: [ AppComponent, + HomeComponent, + HelloComponent, ], imports: [ BrowserModule, @@ -22,8 +38,21 @@ import { AppComponent } from './app.component'; MatFormFieldModule, MatInputModule, MatCheckboxModule, + MatDividerModule, + RouterModule.forRoot([ + { path: '', pathMatch: 'full', redirectTo: 'home' }, + { + path: 'home', + component: HomeComponent + }, + { + path: 'hello', + component: HelloComponent + }, + { path: '**', redirectTo: 'search' }, + ]), ActionMatModule.forRoot(ICON_TYPE.Font) ], - bootstrap: [AppComponent] + bootstrap: [AppComponent], }) export class AppModule { }