Skip to content

Commit

Permalink
refactor: rework lazy-loading for Angular 9
Browse files Browse the repository at this point in the history
  • Loading branch information
dhhyi committed May 18, 2020
1 parent f449ddd commit a2d7886
Show file tree
Hide file tree
Showing 16 changed files with 307 additions and 267 deletions.
29 changes: 0 additions & 29 deletions src/app/extensions/captcha/captcha.module.ts

This file was deleted.

29 changes: 4 additions & 25 deletions src/app/extensions/captcha/exports/captcha-exports.module.ts
@@ -1,35 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveComponentLoaderModule } from '@wishtack/reactive-component-loader';
import { take } from 'rxjs/operators';

import { whenTruthy } from 'ish-core/utils/operators';

import { CaptchaFacade } from '../facades/captcha.facade';
import { SitekeyProviderService } from '../services/sitekey-provider/sitekey-provider.service';

import { LazyCaptchaComponent } from './captcha/lazy-captcha/lazy-captcha.component';

@NgModule({
imports: [
CommonModule,
ReactiveComponentLoaderModule.withModule({
moduleId: 'ish-extensions-captcha',
loadChildren: '../captcha.module#CaptchaModule',
}),
],
imports: [CommonModule],
declarations: [LazyCaptchaComponent],
exports: [LazyCaptchaComponent],
providers: [SitekeyProviderService],
})
export class CaptchaExportsModule {
siteKey: string;

constructor(captchaFacade: CaptchaFacade) {
// synchronize asynchronous site key so we can provide it synchronously for the recaptcha service later on.
captchaFacade.captchaSiteKey$
.pipe(
whenTruthy(),
take(1)
)
.subscribe(siteKey => (this.siteKey = siteKey));
}
}
export class CaptchaExportsModule {}
@@ -1,8 +1 @@
<wt-lazy
*ngIf="captchaActive$ | async"
[location]="componentLocation"
[inputs]="{
form: form,
cssClass: cssClass
}"
></wt-lazy>
<ng-template #anchor></ng-template>
@@ -0,0 +1,113 @@
import { ComponentFixture, TestBed, async, fakeAsync, tick } from '@angular/core/testing';
import { FormControl, FormGroup } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';
import { RECAPTCHA_V3_SITE_KEY, RecaptchaComponent } from 'ng-recaptcha';
import { EMPTY, of } from 'rxjs';
import { anyString, instance, mock, when } from 'ts-mockito';

import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer';
import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing';

import { CaptchaFacade } from '../../../facades/captcha.facade';
import { CaptchaV2Component, CaptchaV2ComponentModule } from '../../../shared/captcha/captcha-v2/captcha-v2.component';
import { CaptchaV3Component, CaptchaV3ComponentModule } from '../../../shared/captcha/captcha-v3/captcha-v3.component';

import { LazyCaptchaComponent } from './lazy-captcha.component';

describe('Lazy Captcha Component', () => {
let fixture: ComponentFixture<LazyCaptchaComponent>;
let component: LazyCaptchaComponent;
let element: HTMLElement;
let captchaFacade: CaptchaFacade;

beforeEach(async(() => {
captchaFacade = mock(CaptchaFacade);
when(captchaFacade.captchaVersion$).thenReturn(EMPTY);
when(captchaFacade.captchaSiteKey$).thenReturn(of('captchaSiteKeyASDF'));
when(captchaFacade.captchaActive$(anyString())).thenReturn(of(true));

TestBed.configureTestingModule({
declarations: [MockComponent(RecaptchaComponent)],
imports: [
CaptchaV2ComponentModule,
CaptchaV3ComponentModule,
RouterTestingModule,
TranslateModule.forRoot(),
ngrxTesting({
reducers: { configuration: configurationReducer },
}),
],
providers: [
{ provide: CaptchaFacade, useFactory: () => instance(captchaFacade) },
{ provide: RECAPTCHA_V3_SITE_KEY, useValue: 'captchaSiteKeyQWERTY' },
],
})
.overrideModule(CaptchaV2ComponentModule, { set: { entryComponents: [CaptchaV2Component] } })
.overrideModule(CaptchaV3ComponentModule, { set: { entryComponents: [CaptchaV3Component] } })
// .overrideComponent(LazyCaptchaComponent, { set: { entryComponents: [CaptchaV2Component, CaptchaV3Component] } })
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(LazyCaptchaComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;
component.form = new FormGroup({
captcha: new FormControl(''),
captchaAction: new FormControl(''),
});
component.cssClass = 'd-none';
component.topic = 'register';
});

it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});

it('should render v2 component when configured', fakeAsync(() => {
when(captchaFacade.captchaVersion$).thenReturn(of(2 as 2));
fixture.detectChanges();

tick(500);
expect(element).toMatchInlineSnapshot(`<ish-captcha-v2></ish-captcha-v2>`);
const v2Cmp: CaptchaV2Component = fixture.debugElement.query(By.css('ish-captcha-v2'))?.componentInstance;
expect(v2Cmp).toBeTruthy();
expect(v2Cmp.cssClass).toEqual('d-none');
}));
it('should render v3 component when configured', fakeAsync(() => {
when(captchaFacade.captchaVersion$).thenReturn(of(3 as 3));
fixture.detectChanges();

tick(500);
expect(element).toMatchInlineSnapshot(`<ish-captcha-v3></ish-captcha-v3>`);
const v3Cmp: CaptchaV3Component = fixture.debugElement.query(By.css('ish-captcha-v3'))?.componentInstance;
expect(v3Cmp).toBeTruthy();
}));

// errors are thrown if required input parameters are missing
it('should throw an error if there is no form set as input parameter', () => {
component.form = undefined;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"required input parameter <form> is missing for FormElementComponent"`
);
});

it('should throw an error if there is no control "captcha" in the given form', () => {
delete component.form.controls.captcha;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"form control 'captcha' does not exist in the given form for LazyCaptchaComponent"`
);
});

it('should throw an error if there is no control "captchaAction" in the given form', () => {
delete component.form.controls.captchaAction;
expect(() => fixture.detectChanges()).toThrowErrorMatchingInlineSnapshot(
`"form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent"`
);
});
});
@@ -1,6 +1,18 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import {
ChangeDetectionStrategy,
Compiler,
Component,
Injector,
Input,
NgModuleFactory,
OnDestroy,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { AbstractControl, FormGroup, Validators } from '@angular/forms';
import { Subject } from 'rxjs';
import { switchMapTo, takeUntil } from 'rxjs/operators';

import { CaptchaFacade } from '../../../facades/captcha.facade';

Expand All @@ -21,8 +33,9 @@ import { CaptchaFacade } from '../../../facades/captcha.facade';
templateUrl: './lazy-captcha.component.html',
changeDetection: ChangeDetectionStrategy.Default,
})
// tslint:disable-next-line:component-creation-test
export class LazyCaptchaComponent implements OnInit {
export class LazyCaptchaComponent implements OnInit, OnDestroy {
@ViewChild('anchor', { read: ViewContainerRef, static: true }) anchor: ViewContainerRef;

/**
form containing the captcha form controls
*/
Expand All @@ -40,16 +53,79 @@ export class LazyCaptchaComponent implements OnInit {
| 'redemptionOfGiftCardsAndCertificates'
| 'register';

captchaActive$: Observable<boolean>;

componentLocation = {
moduleId: 'ish-extensions-captcha',
selector: 'ish-captcha',
};
private destroy$ = new Subject();

constructor(private captchaFacade: CaptchaFacade) {}
constructor(private captchaFacade: CaptchaFacade, private compiler: Compiler, private injector: Injector) {}

ngOnInit() {
this.captchaActive$ = this.captchaFacade.captchaActive$(this.topic);
this.sanityCheck();

this.captchaFacade
.captchaActive$(this.topic)
.pipe(switchMapTo(this.captchaFacade.captchaVersion$), takeUntil(this.destroy$))
.subscribe(async version => {
if (version === 3) {
this.actionFormControl.setValue(this.topic);

const { CaptchaV3Component, CaptchaV3ComponentModule } = await import(
'../../../shared/captcha/captcha-v3/captcha-v3.component'
);
const moduleFactory = await this.loadModuleFactory(CaptchaV3ComponentModule);
const moduleRef = moduleFactory.create(this.injector);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV3Component);

const componentRef = this.anchor.createComponent(factory);

componentRef.instance.parentForm = this.form;
} else if (version === 2) {
this.formControl.setValidators([Validators.required]);
this.formControl.updateValueAndValidity();

const { CaptchaV2Component, CaptchaV2ComponentModule } = await import(
'../../../shared/captcha/captcha-v2/captcha-v2.component'
);
const moduleFactory = await this.loadModuleFactory(CaptchaV2ComponentModule);
const moduleRef = moduleFactory.create(this.injector);
const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(CaptchaV2Component);

const componentRef = this.anchor.createComponent(factory);

componentRef.instance.cssClass = this.cssClass;
componentRef.instance.parentForm = this.form;
}
});
}

// tslint:disable-next-line: no-any
private async loadModuleFactory(t: any) {
if (t instanceof NgModuleFactory) {
return t;
} else {
return await this.compiler.compileModuleAsync(t);
}
}

private sanityCheck() {
if (!this.form) {
throw new Error('required input parameter <form> is missing for FormElementComponent');
}
if (!this.formControl) {
throw new Error(`form control 'captcha' does not exist in the given form for LazyCaptchaComponent`);
}
if (!this.actionFormControl) {
throw new Error(`form control 'captchaAction' does not exist in the given form for LazyCaptchaComponent`);
}
}

private get formControl(): AbstractControl {
return this.form?.get('captcha');
}

private get actionFormControl(): AbstractControl {
return this.form?.get('captchaAction');
}

ngOnDestroy() {
this.destroy$.next();
}
}
9 changes: 5 additions & 4 deletions src/app/extensions/captcha/facades/captcha.facade.ts
Expand Up @@ -9,9 +9,9 @@ import { whenTruthy } from 'ish-core/utils/operators';
// tslint:disable:member-ordering
@Injectable({ providedIn: 'root' })
export class CaptchaFacade {
constructor(private store: Store<{}>) {}
constructor(private store: Store) {}

captchaVersion$ = this.store.pipe(
captchaVersion$: Observable<2 | 3 | undefined> = this.store.pipe(
select(
getServerConfigParameter<{
ReCaptchaV2ServiceDefinition: { runnable: string };
Expand Down Expand Up @@ -45,9 +45,10 @@ export class CaptchaFacade {
return this.store.pipe(
filter(() => !!key),
select(getFeatures),
map(features => !features.includes('noCaptcha')),
filter(features => !features.includes('noCaptcha')),
switchMapTo(this.captchaVersion$),
map(x => !!x)
map(x => !!x),
whenTruthy()
/* TODO: replace with the following when configuration REST API sends correct values
select(getServerConfigParameter<boolean>('captcha.' + key))
*/
Expand Down
@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { take } from 'rxjs/operators';

import { whenTruthy } from 'ish-core/utils/operators';

import { CaptchaFacade } from '../../facades/captcha.facade';

@Injectable()
export class SitekeyProviderService {
// not-dead-code
siteKey: string;

constructor(captchaFacade: CaptchaFacade) {
// synchronize asynchronous site key so we can provide it synchronously for the recaptcha service later on.
captchaFacade.captchaSiteKey$.pipe(whenTruthy(), take(1)).subscribe(storeSiteKey => (this.siteKey = storeSiteKey));
}
}

export function getSynchronizedSiteKey(service: SitekeyProviderService) {
return service.siteKey;
}
@@ -1,4 +1,4 @@
<div *ngIf="captchaSiteKey" class="row form-group">
<div *ngIf="captchaSiteKey$ | async as captchaSiteKey" class="row form-group">
<div [ngClass]="cssClass">
<div [ngClass]="{ 'has-error': hasError }">
<re-captcha [siteKey]="captchaSiteKey" (resolved)="resolved($event)" class="captcha"></re-captcha>
Expand Down

0 comments on commit a2d7886

Please sign in to comment.