From d57bf6757eb3426bd7404edb4b0d1d51c46f6478 Mon Sep 17 00:00:00 2001 From: Ed Morales Date: Sun, 14 May 2017 19:55:26 -0700 Subject: [PATCH] feat(loading): support for async and boolean with [until] input. (closes #528) (#583) * feat(loading): support for async and boolean with [until] input introducing a new syntax to use the tdLoading directive which lets the users leverage the async angular pipe and assign a reference from the result to use within the context. also leveraging this, we can use booleans to show and hide annonymous loading directives. * chore(loading): specify that name is optional in the `until` input usage * chore(loading): separate description into 2 properties so it fits better on screen this will change later on when we move everything to the README.md * chore(docs): disable loading masks when using obs or timer --- .../components/loading/loading.component.html | 158 +++++++++++++++- .../components/loading/loading.component.ts | 44 ++++- .../directives/loading.directive.spec.ts | 168 ++++++++++++++++++ .../loading/directives/loading.directive.ts | 42 ++++- .../core/loading/services/loading.factory.ts | 7 +- .../loading/services/loading.service.spec.ts | 31 ++++ .../core/loading/services/loading.service.ts | 31 +++- 7 files changed, 462 insertions(+), 19 deletions(-) diff --git a/src/app/components/components/loading/loading.component.html b/src/app/components/components/loading/loading.component.html index f4e3092ec8..10b25fcc6a 100644 --- a/src/app/components/components/loading/loading.component.html +++ b/src/app/components/components/loading/loading.component.html @@ -5,7 +5,7 @@

[tdLoading] directive with (*) syntax

with indetederminate [tdLoadingMode], circular [tdLoadingType], overlay [tdLoadingStrategy], accent [tdLoadingColor]

- + Demo
@@ -80,7 +80,7 @@

with indetederminate [tdLoadingMode], circular [tdLoading

[tdLoading] directive with template syntax

with determinate [tdLoadingMode], linear [tdLoadingType], replace [tdLoadingStrategy], warn [tdLoadingColor]

- + Demo @@ -98,7 +98,7 @@

with determinate [tdLoadingMode], linear [tdLoadingType],

- +
@@ -160,11 +160,138 @@

with determinate [tdLoadingMode], linear [tdLoadingType], + + +

[tdLoading] until star syntax with variable reference and observables

+

with accent [tdLoadingColor]

+ + + Demo +
+ + + + + {{item.label}} + + + + + +
+
+ +
+
+ + Code +

HTML:

+ + + + + + + { {item.label} } + + + + + + +
+ +
+ ]]> +
+

Typescript:

+ + ; + + createObservableList(): void { + this.listObservable = new Observable((subscriber: Subscriber) => { + setTimeout(() => { + subscriber.next([{label: 'Light', value: true}, {label: 'Console', value: false}, {label: 'T.V.', value: true}]); + }, 3000); + }); + } + } + ]]> + +
+
+
+
+ + +

[tdLoading] until template syntax with booleans

+

with overlay [tdLoadingStrategy]

+ + + Demo + +
+ + + + + + +
+
+
+ Loading Mask +
+
+ + Code +

HTML:

+ + +
+ + + + + + +
+ +
+ Loading Mask +
+ ]]> +
+

Typescript:

+ + + +
+
+
+

Preloaded [TdLoading] fullscreen mask

with indeterminate [mode], circular [type], primary [color] by default

- + Demo
@@ -210,7 +337,7 @@

with indeterminate [mode], circular [type], primary [colo

Custom [TdLoading] fullscreen mask

with indeterminate [mode], linear [type], accent [color]

- + Demo
@@ -271,6 +398,7 @@

Properties:

{{attr.name}}: {{attr.type}}

{{attr.description}}

+

{{attr.additionalDescription}}

@@ -283,7 +411,15 @@

Example(after setup):

...
]]> - + +

HTML (*) until async syntax:

+ + + { {item} } +

+ ]]> +

HTML ]]> syntax

Example(after setup):

]]> +

HTML ]]> until syntax

+ + + ... + + ]]> +

Typescript:

Example(after setup): } resolveLoading(): void { - this._loadingService.resolve('stringName'); + this._loadingService.resolve('stringName'); // or this._loadingService.resolveAll('stringName'); } } ]]> diff --git a/src/app/components/components/loading/loading.component.ts b/src/app/components/components/loading/loading.component.ts index a1531eb834..1663647ebf 100644 --- a/src/app/components/components/loading/loading.component.ts +++ b/src/app/components/components/loading/loading.component.ts @@ -1,5 +1,8 @@ import { Component, ViewContainerRef, OnInit, HostBinding, ChangeDetectionStrategy } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; + import { slideInDownAnimation } from '../../../app.animations'; import { TdLoadingService, ITdLoadingConfig, LoadingType, LoadingMode } from '../../../../platform/core'; @@ -39,18 +42,31 @@ export class LoadingDemoComponent implements OnInit { Defaults to "primary"`, name: 'tdLoadingColor?', type: '"primary" | "accent" | "warn"', + }, { + description: `If its null, undefined or false it will be used to register requests to the mask. + Else if its any value that can be resolved as true, it will resolve the mask.`, + additionalDescription: `[name] is optional when using [until], but can still be used to register/resolve it manually.`, + name: 'tdLoadingUtil?', + type: 'any', }]; loadingServiceMethods: Object[] = [{ description: `Registers a request for the loading mask referenced by the name parameter. - Can optionally pass registers argument to set a number of register calls.`, + Can optionally pass registers argument to set a number of register calls. + If no paramemeters are used, then default main mask will be used.`, name: 'register', type: 'function(name?: string, registers: number = 1)', }, { description: `Resolves a request for the loading mask referenced by the name parameter. - Can optionally pass resolves argument to set a number of resolve calls.`, + Can optionally pass resolves argument to set a number of resolve calls. + If no paramemeters are used, then default main mask will be used.`, name: 'resolve', type: 'function(name?: string, resolves: number = 1)', + }, { + description: `Resolves all requests for the loading mask referenced by the name parameter. + If no paramemeters are used, then default main mask will be used.`, + name: 'resolveAll', + type: 'function(name?: string)', }, { description: `Set value on a loading mask referenced by the name parameter. Usage only available if its mode is 'determinate'.`, @@ -63,6 +79,12 @@ export class LoadingDemoComponent implements OnInit { type: 'function(options: ITdLoadingConfig)', }]; + loading: boolean = false; + listObservable: Observable; + + replaceTemplateSyntaxDisabled: boolean = false; + listObservableDisabled: boolean = false; + overlayStarSyntax: boolean = false; overlayDemo: any = { @@ -76,6 +98,11 @@ export class LoadingDemoComponent implements OnInit { description: '', }; + untilOverlayDemo: any = { + name: '', + description: '', + }; + constructor(private _loadingService: TdLoadingService) { this._loadingService.create({ name: 'configFullscreenDemo', @@ -114,6 +141,7 @@ export class LoadingDemoComponent implements OnInit { } toggleReplaceTemplateSyntax(): void { + this.replaceTemplateSyntaxDisabled = true; this._loadingService.register('replaceTemplateSyntax'); let value: number = 0; let interval: number = setInterval(() => { @@ -125,10 +153,22 @@ export class LoadingDemoComponent implements OnInit { }, 250); setTimeout(() => { this._loadingService.resolve('replaceTemplateSyntax'); + this.replaceTemplateSyntaxDisabled = false; }, 3000); } startDirectives(): void { this._loadingService.register('overlayStarSyntax'); + this.createObservableList(); + } + + createObservableList(): void { + this.listObservableDisabled = true; + this.listObservable = new Observable((subscriber: Subscriber) => { + setTimeout(() => { + subscriber.next([{label: 'Light', value: true}, {label: 'Console', value: false}, {label: 'T.V.', value: true}]); + this.listObservableDisabled = false; + }, 3000); + }); } } diff --git a/src/platform/core/loading/directives/loading.directive.spec.ts b/src/platform/core/loading/directives/loading.directive.spec.ts index 8411fc267c..507501e8ab 100644 --- a/src/platform/core/loading/directives/loading.directive.spec.ts +++ b/src/platform/core/loading/directives/loading.directive.spec.ts @@ -5,6 +5,8 @@ import { ComponentFixture, } from '@angular/core/testing'; import { Component } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { CovalentLoadingModule, LoadingMode, LoadingType, LoadingStrategy, TdLoadingService } from '../loading.module'; @@ -17,6 +19,9 @@ describe('Directive: Loading', () => { TdLoadingDefaultTestComponent, TdLoadingBasicTestComponent, TdLoadingDuplicationTestComponent, + TdLoadingStarUntilAsyncTestComponent, + TdLoadingNamedErrorStarUntilAsyncTestComponent, + TdLoadingBooleanTemplateUntilTestComponent, ], imports: [ BrowserAnimationsModule, @@ -267,6 +272,109 @@ describe('Directive: Loading', () => { done(); })(); }); + + it('should render a circle loading while the observable returns a value using until syntax and async pipe and display it', (done: DoneFn) => { + inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdLoadingStarUntilAsyncTestComponent); + let component: TdLoadingStarUntilAsyncTestComponent = fixture.debugElement.componentInstance; + component.createObservable(); + component.color = 'accent'; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + expect((fixture.debugElement.query(By.css('.content')).nativeElement).textContent).not.toContain('success'); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('md-progress-spinner'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.mat-accent'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.content'))).toBeFalsy(); + component.sendResult('success'); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + setTimeout(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + expect((fixture.debugElement.query(By.css('.content')).nativeElement).textContent).toContain('success'); + fixture.detectChanges(); + done(); + }); + }, 200); + }); + }); + })(); + }); + + it('should render a circle loading while the observable and resolve it in the catch by calling the service', (done: DoneFn) => { + inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdLoadingNamedErrorStarUntilAsyncTestComponent); + let component: TdLoadingNamedErrorStarUntilAsyncTestComponent = fixture.debugElement.componentInstance; + component.createObservable(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('md-progress-spinner'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.mat-primary'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.content'))).toBeFalsy(); + component.sendError('error'); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + setTimeout(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + expect((fixture.debugElement.query(By.css('.content')).nativeElement).textContent.trim()).toBeFalsy(); + fixture.detectChanges(); + done(); + }); + }, 200); + }); + }); + })(); + }); + + it('should render a circle loading when false and remove it when true with boolean until syntax', (done: DoneFn) => { + inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdLoadingBooleanTemplateUntilTestComponent); + let component: TdLoadingBooleanTemplateUntilTestComponent = fixture.debugElement.componentInstance; + component.loading = true; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('md-progress-spinner'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.mat-primary'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.content'))).toBeFalsy(); + component.loading = false; + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + setTimeout(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('td-loading'))).toBeFalsy(); + fixture.detectChanges(); + done(); + }); + }, 200); + }); + }); + })(); + }); }); @Component({ @@ -312,3 +420,63 @@ class TdLoadingBasicTestComponent { class TdLoadingDuplicationTestComponent { } + +@Component({ + selector: 'td-loading-star-until-async-test', + template: ` +
+
{{item}}
+
+ `, +}) +class TdLoadingStarUntilAsyncTestComponent { + private _subject: Subject = new Subject(); + observable: Observable; + color: string; + + createObservable(): void { + this.observable = this._subject.asObservable(); + } + + sendResult(result: any): void { + this._subject.next(result); + } +} + +@Component({ + selector: 'td-loading-named-error-star-until-async-test', + template: ` +
+
{{item}}
+
+ `, +}) +class TdLoadingNamedErrorStarUntilAsyncTestComponent { + private _subject: Subject = new Subject(); + observable: Observable; + + constructor(private _loadingService: TdLoadingService) {} + + createObservable(): void { + this.observable = this._subject.asObservable().catch(() => { + this._loadingService.resolveAll('name1'); + return Observable.of(undefined); + }); + } + + sendError(error: any): void { + this._subject.error(error); + } +} + +@Component({ + selector: 'td-loading-boolean-template-until-test', + template: ` + +
+
+ `, +}) +class TdLoadingBooleanTemplateUntilTestComponent { + loading: boolean = false; +} diff --git a/src/platform/core/loading/directives/loading.directive.ts b/src/platform/core/loading/directives/loading.directive.ts index 603bcc34b9..92c0c27f14 100644 --- a/src/platform/core/loading/directives/loading.directive.ts +++ b/src/platform/core/loading/directives/loading.directive.ts @@ -5,11 +5,23 @@ import { LoadingType, LoadingMode, LoadingStrategy, TdLoadingComponent } from '. import { TdLoadingService } from '../services/loading.service'; import { ILoadingRef } from '../services/loading.factory'; +/** + * Context class for variable reference + */ +export class TdLoadingContext { + public $implicit: any = undefined; + public tdLoading: any = undefined; +} + +// Constant for generation of the id for the next component +let TD_LOADING_NEXT_ID: number = 0; + @Directive({ selector: '[tdLoading]', }) export class TdLoadingDirective implements OnInit, OnDestroy { + private _context: TdLoadingContext = new TdLoadingContext(); private _type: LoadingType; private _mode: LoadingMode; private _strategy: LoadingStrategy; @@ -17,12 +29,35 @@ export class TdLoadingDirective implements OnInit, OnDestroy { private _loadingRef: ILoadingRef; /** - * tdLoading?: string + * tdLoading: string * Name reference of the loading mask, used to register/resolve requests to the mask. */ @Input('tdLoading') set name(name: string) { - this._name = name; + if (!this._name) { + if (name) { + this._name = name; + } + } + } + + /** + * tdLoadingUntil?: any + * If its null, undefined or false it will be used to register requests to the mask. + * Else if its any value that can be resolved as true, it will resolve the mask. + * [name] is optional when using [until], but can still be used to register/resolve it manually. + */ + @Input('tdLoadingUntil') + set until(until: any) { + if (!this._name) { + this._name = 'td-loading-until-' + TD_LOADING_NEXT_ID++; + } + this._context.$implicit = this._context.tdLoading = until; + if (!until) { + this._loadingService.register(this._name); + } else { + this._loadingService.resolveAll(this._name); + } } /** @@ -112,14 +147,13 @@ export class TdLoadingDirective implements OnInit, OnDestroy { // Check if `TdLoadingComponent` has been created before trying to add one again. // There is a weird edge case when using `[routerLinkActive]` that calls the `ngOnInit` twice in a row if (!this._loadingRef) { - this._viewContainerRef.createEmbeddedView(this._templateRef); this._loadingRef = this._loadingService.createComponent({ name: this._name, type: this._type, mode: this._mode, color: this.color, strategy: this._strategy, - }, this._viewContainerRef, this._templateRef); + }, this._viewContainerRef, this._templateRef, this._context); } } } diff --git a/src/platform/core/loading/services/loading.factory.ts b/src/platform/core/loading/services/loading.factory.ts index 9a93a81eff..e867ebfde1 100644 --- a/src/platform/core/loading/services/loading.factory.ts +++ b/src/platform/core/loading/services/loading.factory.ts @@ -5,6 +5,7 @@ import { Subject } from 'rxjs/Subject'; import { Observable } from 'rxjs/Observable'; import { Subscription } from 'rxjs/Subscription'; +import { TdLoadingContext } from '../directives/loading.directive'; import { TdLoadingComponent, LoadingType, LoadingMode, LoadingStrategy, LoadingStyle } from '../loading.component'; import { ITdLoadingConfig} from './loading.service'; @@ -100,13 +101,14 @@ export class TdLoadingFactory { * Saves a reference in context to be called when registering/resolving the loading element. */ public createReplaceComponent(options: ITdLoadingConfig, viewContainerRef: ViewContainerRef, - templateRef: TemplateRef): ILoadingRef { + templateRef: TemplateRef, context: TdLoadingContext): ILoadingRef { let nativeElement: HTMLElement = templateRef.elementRef.nativeElement; (options).height = nativeElement.nextElementSibling ? nativeElement.nextElementSibling.scrollHeight : undefined; (options).style = LoadingStyle.None; let loadingRef: ILoadingRef = this._createComponent(options); let loading: boolean = false; + viewContainerRef.createEmbeddedView(templateRef, context); loadingRef.observable .subscribe((registered: number) => { if (registered > 0 && !loading) { @@ -121,7 +123,8 @@ export class TdLoadingFactory { loading = false; let subs: Subscription = loadingRef.componentRef.instance.startOutAnimation().subscribe(() => { subs.unsubscribe(); - let cdr: ChangeDetectorRef = viewContainerRef.createEmbeddedView(templateRef); + // passing context so when the template is re-attached, we can keep the reference of the variables + let cdr: ChangeDetectorRef = viewContainerRef.createEmbeddedView(templateRef, context); viewContainerRef.detach(viewContainerRef.indexOf(loadingRef.componentRef.hostView)); /** * Need to call "markForCheck" and "detectChanges" on attached template, so its detected by parent component when attached diff --git a/src/platform/core/loading/services/loading.service.spec.ts b/src/platform/core/loading/services/loading.service.spec.ts index f1e8b3be9f..c69bcfbc0a 100644 --- a/src/platform/core/loading/services/loading.service.spec.ts +++ b/src/platform/core/loading/services/loading.service.spec.ts @@ -178,6 +178,37 @@ describe('Service: Loading', () => { done(); })(); }); + + it('should render default fullscreen by registering 3 times and then resolve by calling resolveAll', (done: DoneFn) => { + inject([TdLoadingService], (loadingService: TdLoadingService) => { + let fixture: ComponentFixture = TestBed.createComponent(TdLoadingWrapperTestComponent); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.content'))).toBeTruthy(); + loadingService.register(); + loadingService.register(); + loadingService.register(); + fixture.detectChanges(); + setTimeout(() => { + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(overlayContainerElement.querySelector('td-loading')).toBeTruthy(); + expect(overlayContainerElement.querySelector('md-progress-spinner')).toBeTruthy(); + expect(overlayContainerElement.querySelector('.mat-primary')).toBeTruthy(); + expect(overlayContainerElement.querySelector('.td-overlay')).toBeTruthy(); + expect(overlayContainerElement.querySelector('.td-fullscreen')).toBeTruthy(); + loadingService.resolveAll(); + fixture.detectChanges(); + setTimeout(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(overlayContainerElement.querySelector('td-loading')).toBeFalsy(); + done(); + }); + }, 200); + }); + }, 200); + })(); + }); }); @Component({ diff --git a/src/platform/core/loading/services/loading.service.ts b/src/platform/core/loading/services/loading.service.ts index 5cfa473417..ac71493683 100644 --- a/src/platform/core/loading/services/loading.service.ts +++ b/src/platform/core/loading/services/loading.service.ts @@ -4,6 +4,7 @@ import { Subject } from 'rxjs/Subject'; import { Observable } from 'rxjs/Observable'; import { Subscription } from 'rxjs/Subscription'; +import { TdLoadingContext } from '../directives/loading.directive'; import { TdLoadingComponent, LoadingMode, LoadingStrategy, LoadingType } from '../loading.component'; import { TdLoadingFactory, ILoadingRef } from './loading.factory'; @@ -71,7 +72,7 @@ export class TdLoadingService { * NOTE: @internal usage only. */ createComponent(config: ITdLoadingDirectiveConfig, viewContainerRef: ViewContainerRef, - templateRef: TemplateRef): ILoadingRef { + templateRef: TemplateRef, context: TdLoadingContext): ILoadingRef { let directiveConfig: TdLoadingDirectiveConfig = new TdLoadingDirectiveConfig(config); if (this._context[directiveConfig.name]) { throw Error(`Name duplication: [TdLoading] directive has a name conflict with ${directiveConfig.name}.`); @@ -79,7 +80,7 @@ export class TdLoadingService { if (directiveConfig.strategy === LoadingStrategy.Overlay) { this._context[directiveConfig.name] = this._loadingFactory.createOverlayComponent(directiveConfig, viewContainerRef, templateRef); } else { - this._context[directiveConfig.name] = this._loadingFactory.createReplaceComponent(directiveConfig, viewContainerRef, templateRef); + this._context[directiveConfig.name] = this._loadingFactory.createReplaceComponent(directiveConfig, viewContainerRef, templateRef, context); } return this._context[directiveConfig.name]; } @@ -155,12 +156,12 @@ export class TdLoadingService { * - resolves?: number * returns: true if successful * - * Registers a request for the loading mask referenced by the name parameter. + * Resolves a request for the loading mask referenced by the name parameter. * Can optionally pass resolves argument to set a number of resolve calls. * * If no paramemeters are used, then default main mask will be used. * - * e.g. loadingService.register() + * e.g. loadingService.resolve() */ public resolve(name: string = 'td-loading-main', resolves: number = 1): boolean { // clear timeout if the loading component is "resolved" before its "registered" @@ -178,6 +179,28 @@ export class TdLoadingService { return false; } + /** + * params: + * - name: string + * returns: true if successful + * + * Resolves all request for the loading mask referenced by the name parameter. + * + * If no paramemeters are used, then default main mask will be used. + * + * e.g. loadingService.resolveAll() + */ + public resolveAll(name: string = 'td-loading-main'): boolean { + // clear timeout if the loading component is "resolved" before its "registered" + this._clearTimeout(name); + if (this._context[name]) { + this._context[name].times = 0; + this._context[name].subject.next(this._context[name].times); + return true; + } + return false; + } + /** * params: * - name: string