Skip to content

Commit

Permalink
feat: introduce module-loader for lazy loading extension modules depe…
Browse files Browse the repository at this point in the history
…nding on feature toggles (#215)
  • Loading branch information
dhhyi committed May 12, 2020
1 parent 382b2dd commit fc6e504
Show file tree
Hide file tree
Showing 8 changed files with 103 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { NgModule } from '@angular/core';
import { ReactiveComponentLoaderModule } from '@wishtack/reactive-component-loader';

import { FeatureToggleModule } from 'ish-core/feature-toggle.module';
import { LAZY_FEATURE_MODULE } from 'ish-core/utils/module-loader/module-loader.service';

@NgModule({
imports: [
FeatureToggleModule,
ReactiveComponentLoaderModule.withModule({
moduleId: 'ish-extensions-<%= dasherize(name) %>',
loadChildren: '../<%= dasherize(name) %>.module#<%= classify(name) %>Module',
}),
imports: [FeatureToggleModule],
providers: [
{
provide: LAZY_FEATURE_MODULE,
useValue: { feature: '<%= dasherize(name) %>', location: import('../<%= dasherize(name) %>.module') },
multi: true,
},
],
declarations: [],
exports: [],
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/configurations/ngrx-state-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { mergeDeep } from 'ish-core/utils/functions';

// tslint:disable:no-any

const NGRX_STATE_SK = makeStateKey('ngrxState');
export const NGRX_STATE_SK = makeStateKey('ngrxState');
const STATE_ACTION_TYPE = '[Internal] Import NgRx State';

/**
Expand Down
6 changes: 5 additions & 1 deletion src/app/core/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { IconModule } from './icon.module';
import { AuthInterceptor } from './interceptors/auth.interceptor';
import { MockInterceptor } from './interceptors/mock.interceptor';
import { StateManagementModule } from './state-management.module';
import { ModuleLoaderService } from './utils/module-loader/module-loader.service';

export function translateFactory(http: HttpClient) {
return new TranslateHttpLoader(http, 'assets/i18n/', '.json');
Expand Down Expand Up @@ -70,7 +71,8 @@ export class CoreModule {
@Optional()
@SkipSelf()
parentModule: CoreModule,
popoverConfig: NgbPopoverConfig
popoverConfig: NgbPopoverConfig,
moduleLoader: ModuleLoaderService
) {
if (parentModule) {
throw new Error('CoreModule is already loaded. Import it in the AppModule only');
Expand All @@ -79,5 +81,7 @@ export class CoreModule {
popoverConfig.placement = 'top';
popoverConfig.triggers = 'hover';
popoverConfig.container = 'body';

moduleLoader.init();
}
}
42 changes: 24 additions & 18 deletions src/app/core/store/configuration/configuration.effects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { ApplicationRef, Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { ApplicationRef, Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core';
import { TransferState } from '@angular/platform-browser';
import { Actions, Effect, ROOT_EFFECTS_INIT, ofType } from '@ngrx/effects';
import { routerNavigationAction } from '@ngrx/router-store';
import { Store, select } from '@ngrx/store';
Expand All @@ -19,6 +20,7 @@ import {
} from 'rxjs/operators';

import { LARGE_BREAKPOINT_WIDTH, MEDIUM_BREAKPOINT_WIDTH } from 'ish-core/configurations/injection-keys';
import { NGRX_STATE_SK } from 'ish-core/configurations/ngrx-state-transfer';
import { DeviceType } from 'ish-core/models/viewtype/viewtype.types';
import { ConfigurationService } from 'ish-core/services/configuration/configuration.service';
import { distinctCompareWith, mapErrorToAction, mapToProperty, whenFalsy, whenTruthy } from 'ish-core/utils/operators';
Expand All @@ -41,6 +43,7 @@ export class ConfigurationEffects {
private configService: ConfigurationService,
private translateService: TranslateService,
private stateProperties: StatePropertiesService,
@Optional() private transferState: TransferState,
@Inject(PLATFORM_ID) private platformId: string,
private appRef: ApplicationRef,
@Inject(MEDIUM_BREAKPOINT_WIDTH) private mediumBreakpointWidth: number,
Expand Down Expand Up @@ -77,23 +80,26 @@ export class ConfigurationEffects {
);

@Effect()
setInitialRestEndpoint$ = this.actions$.pipe(
ofType(ROOT_EFFECTS_INIT),
take(1),
withLatestFrom(
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_BASE_URL', 'icmBaseURL'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_SERVER', 'icmServer'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_SERVER_STATIC', 'icmServerStatic'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_CHANNEL', 'icmChannel'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_APPLICATION', 'icmApplication'),
this.stateProperties
.getStateOrEnvOrDefault<string | string[]>('FEATURES', 'features')
.pipe(map(x => (typeof x === 'string' ? x.split(/,/g) : x))),
this.stateProperties.getStateOrEnvOrDefault<string>('THEME', 'theme').pipe(map(x => x || 'default'))
),
map(
([, baseURL, server, serverStatic, channel, application, features, theme]) =>
new ApplyConfiguration({ baseURL, server, serverStatic, channel, application, features, theme })
setInitialRestEndpoint$ = iif(
() => !this.transferState || !this.transferState.hasKey(NGRX_STATE_SK),
this.actions$.pipe(
ofType(ROOT_EFFECTS_INIT),
take(1),
withLatestFrom(
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_BASE_URL', 'icmBaseURL'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_SERVER', 'icmServer'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_SERVER_STATIC', 'icmServerStatic'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_CHANNEL', 'icmChannel'),
this.stateProperties.getStateOrEnvOrDefault<string>('ICM_APPLICATION', 'icmApplication'),
this.stateProperties
.getStateOrEnvOrDefault<string | string[]>('FEATURES', 'features')
.pipe(map(x => (typeof x === 'string' ? x.split(/,/g) : x))),
this.stateProperties.getStateOrEnvOrDefault<string>('THEME', 'theme').pipe(map(x => x || 'default'))
),
map(
([, baseURL, server, serverStatic, channel, application, features, theme]) =>
new ApplyConfiguration({ baseURL, server, serverStatic, channel, application, features, theme })
)
)
);

Expand Down
2 changes: 1 addition & 1 deletion src/app/core/store/configuration/configuration.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const initialState: ConfigurationState = {
serverStatic: undefined,
channel: undefined,
application: undefined,
features: [],
features: undefined,
gtmToken: undefined,
theme: undefined,
locales: environment.locales,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('Configuration Selectors', () => {
expect(getICMBaseURL(store$.state)).toBeUndefined();
expect(getICMServerURL(store$.state)).toBeUndefined();
expect(getICMStaticURL(store$.state)).toBeUndefined();
expect(getFeatures(store$.state)).toBeEmpty();
expect(getFeatures(store$.state)).toBeUndefined();
expect(getGTMToken(store$.state)).toBeUndefined();
expect(isServerConfigurationLoaded(store$.state)).toBeFalsy();
expect(getServerConfigParameter('application.applicationType')(store$.state)).toMatchInlineSnapshot(`undefined`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class FeatureToggleService {
private featureToggles: string[];

constructor(store: Store<{}>) {
store.pipe(select(getFeatures)).subscribe(features => (this.featureToggles = features));
store.pipe(select(getFeatures)).subscribe(features => (this.featureToggles = features || []));
}

enabled(feature: string): boolean {
Expand Down
62 changes: 62 additions & 0 deletions src/app/core/utils/module-loader/module-loader.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Compiler, Injectable, InjectionToken, Injector, NgModuleFactory } from '@angular/core';
import { Store, select } from '@ngrx/store';

import { getFeatures } from 'ish-core/store/configuration';
import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service';
import { whenTruthy } from 'ish-core/utils/operators';

declare interface LazyModuleType {
feature: string;
// tslint:disable-next-line: no-any
location: any;
}

export const LAZY_FEATURE_MODULE = new InjectionToken<LazyModuleType>('lazyModule');

@Injectable({ providedIn: 'root' })
export class ModuleLoaderService {
private loadedModules: string[] = [];

constructor(
private compiler: Compiler,
private injector: Injector,
private featureToggleService: FeatureToggleService,
private store: Store<{}>
) {}

init() {
this.store
.pipe(
select(getFeatures),
whenTruthy()
)
.subscribe(() => {
const lazyModules = this.injector.get<LazyModuleType[]>(LAZY_FEATURE_MODULE, []);
lazyModules
.filter(mod => !this.loadedModules.includes(mod.feature))
.filter(mod => this.featureToggleService.enabled(mod.feature))
.forEach(async mod => {
await this.loadModule(mod.location);
this.loadedModules.push(mod.feature);
});
});
}

private async loadModule(loc) {
const loaded = await loc;
Object.keys(loaded)
.filter(key => key.endsWith('Module'))
.forEach(async key => {
const moduleFactory = await this.loadModuleFactory(loaded[key]);
moduleFactory.create(this.injector);
});
}

private async loadModuleFactory(t) {
if (t instanceof NgModuleFactory) {
return t;
} else {
return await this.compiler.compileModuleAsync(t);
}
}
}

0 comments on commit fc6e504

Please sign in to comment.