Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: feature toggle #954

Merged
49 changes: 47 additions & 2 deletions src/app/core/directives/feature-toggle.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { FeatureToggleModule } from 'ish-core/feature-toggle.module';
@Component({
template: `
<div>unrelated</div>
<div *ishFeature="'feature1'">content1</div>
<div *ishFeature="'feature2'">content2</div>
<div *ishFeature="'feature1'; else feature1Else">content1</div>
<ng-template #feature1Else><div>contentElse1</div></ng-template>
<div *ishFeature="'feature2'; else feature2Else">content2</div>
<ng-template #feature2Else><div>contentElse2</div></ng-template>
<div *ishFeature="'always'">contentAlways</div>
<div *ishFeature="'never'">contentNever</div>
`,
Expand Down Expand Up @@ -40,15 +42,58 @@ describe('Feature Toggle Directive', () => {
expect(element.textContent).toContain('content1');
});

it('should not render the else content of enabled features', () => {
expect(element.textContent).not.toContain('contentElse1');
});

it('should not render content of disabled features', () => {
expect(element.textContent).not.toContain('content2');
});

it('should render the else content of disabled features', () => {
expect(element.textContent).toContain('contentElse2');
});

it("should always render content for 'always'", () => {
expect(element.textContent).toContain('contentAlways');
});

it("should never render content for 'never'", () => {
expect(element.textContent).not.toContain('contentNever');
});

describe('after activating the other feature', () => {
beforeEach(() => {
FeatureToggleModule.switchTestingFeatures('feature2');
fixture.detectChanges();
});

it('should always render unrelated content', () => {
expect(element.textContent).toContain('unrelated');
});

it('should render content of enabled features', () => {
expect(element.textContent).toContain('content2');
});

it('should not render the else content of enabled features', () => {
expect(element.textContent).not.toContain('contentElse2');
});

it('should not render content of disabled features', () => {
expect(element.textContent).not.toContain('content1');
});

it('should render the else content of disabled features', () => {
expect(element.textContent).toContain('contentElse1');
});

it("should always render content for 'always'", () => {
expect(element.textContent).toContain('contentAlways');
});

it("should never render content for 'never'", () => {
expect(element.textContent).not.toContain('contentNever');
});
});
});
61 changes: 44 additions & 17 deletions src/app/core/directives/feature-toggle.directive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { ChangeDetectorRef, Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
import { BehaviorSubject, Subject, Subscription, combineLatest } from 'rxjs';
import { distinctUntilChanged, filter, takeUntil } from 'rxjs/operators';

import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service';

Expand All @@ -17,34 +19,59 @@ import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-togg
@Directive({
selector: '[ishFeature]',
})
export class FeatureToggleDirective {
export class FeatureToggleDirective implements OnDestroy {
private otherTemplateRef: TemplateRef<unknown>;
private feature: string;
private subscription: Subscription;
private enabled$ = new BehaviorSubject<boolean>(undefined);
private tick$ = new BehaviorSubject<void>(undefined);

private destroy$ = new Subject();

constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private featureToggle: FeatureToggleService
) {}
private featureToggle: FeatureToggleService,
private cdRef: ChangeDetectorRef
) {
combineLatest([
this.enabled$.pipe(
distinctUntilChanged(),
filter(val => typeof val === 'boolean')
cesarGamaSa marked this conversation as resolved.
Show resolved Hide resolved
),
this.tick$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([enabled]) => {
this.viewContainer.clear();
if (enabled) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else if (this.otherTemplateRef) {
this.viewContainer.createEmbeddedView(this.otherTemplateRef);
}
this.cdRef.markForCheck();
});
}

@Input() set ishFeature(feature: string) {
this.feature = feature;
this.updateView();
// end previous subscription and newly subscribe
if (this.subscription) {
// tslint:disable-next-line: ban
this.subscription.unsubscribe();
}

this.subscription = this.featureToggle
.enabled$(feature)
.pipe(takeUntil(this.destroy$))
.subscribe({ next: val => this.enabled$.next(val) });
}

@Input() set ishFeatureElse(otherTemplateRef: TemplateRef<unknown>) {
this.otherTemplateRef = otherTemplateRef;
this.updateView();
this.tick$.next();
}

private updateView() {
const enabled = this.featureToggle.enabled(this.feature);

this.viewContainer.clear();
if (enabled) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else if (this.otherTemplateRef) {
this.viewContainer.createEmbeddedView(this.otherTemplateRef);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
45 changes: 35 additions & 10 deletions src/app/core/directives/not-feature-toggle.directive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { ChangeDetectorRef, Directive, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
import { ReplaySubject, Subject, Subscription } from 'rxjs';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';

import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service';

Expand All @@ -16,20 +18,43 @@ import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-togg
@Directive({
selector: '[ishNotFeature]',
})
export class NotFeatureToggleDirective {
export class NotFeatureToggleDirective implements OnDestroy {
private subscription: Subscription;
private disabled$ = new ReplaySubject<boolean>(1);

private destroy$ = new Subject();

constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private featureToggle: FeatureToggleService
) {}
private featureToggle: FeatureToggleService,
private cdRef: ChangeDetectorRef
) {
this.disabled$.pipe(distinctUntilChanged(), takeUntil(this.destroy$)).subscribe(disabled => {
if (disabled) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
this.cdRef.markForCheck();
});
}

@Input() set ishNotFeature(val: string) {
const enabled = !this.featureToggle.enabled(val);

if (enabled) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
// end previous subscription and newly subscribe
if (this.subscription) {
// tslint:disable-next-line: ban
this.subscription.unsubscribe();
}

this.subscription = this.featureToggle
.enabled$(val)
.pipe(takeUntil(this.destroy$))
.subscribe({ next: value => this.disabled$.next(!value) });
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
13 changes: 10 additions & 3 deletions src/app/core/feature-toggle.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';

import { FeatureToggleDirective } from './directives/feature-toggle.directive';
import { NotFeatureToggleDirective } from './directives/not-feature-toggle.directive';
Expand All @@ -9,7 +11,7 @@ import { FeatureToggleService, checkFeature } from './utils/feature-toggle/featu
exports: [FeatureToggleDirective, NotFeatureToggleDirective],
})
export class FeatureToggleModule {
private static features: string[];
private static features = new BehaviorSubject<string[]>(undefined);

static forTesting(...features: string[]): ModuleWithProviders<FeatureToggleModule> {
FeatureToggleModule.switchTestingFeatures(...features);
Expand All @@ -18,14 +20,19 @@ export class FeatureToggleModule {
providers: [
{
provide: FeatureToggleService,
useValue: { enabled: (feature: string) => checkFeature(FeatureToggleModule.features, feature) },
useValue: {
enabled$: (feature: string) =>
FeatureToggleModule.features.pipe(map(toggles => checkFeature(toggles, feature))),
// tslint:disable-next-line: rxjs-no-subject-value
enabled: (feature: string) => checkFeature(FeatureToggleModule.features.value, feature),
},
},
],
};
}

static switchTestingFeatures(...features: string[]) {
FeatureToggleModule.features = features;
FeatureToggleModule.features.next(features);
}
}

Expand Down
27 changes: 27 additions & 0 deletions src/app/core/pipes/feature-toggle.pipe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,31 @@ describe('Feature Toggle Pipe', () => {
it("should never render content for 'never'", () => {
expect(element.textContent).not.toContain('contentNever');
});

describe('after switching features', () => {
beforeEach(() => {
FeatureToggleModule.switchTestingFeatures('feature2');
fixture.detectChanges();
});

it('should always render unrelated content', () => {
expect(element.textContent).toContain('unrelated');
});

it('should render content of enabled features', () => {
expect(element.textContent).toContain('content2');
});

it('should not render content of disabled features', () => {
expect(element.textContent).not.toContain('content1');
});

it("should always render content for 'always'", () => {
expect(element.textContent).toContain('contentAlways');
});

it("should never render content for 'never'", () => {
expect(element.textContent).not.toContain('contentNever');
});
});
});
34 changes: 29 additions & 5 deletions src/app/core/pipes/feature-toggle.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service';

Expand All @@ -11,11 +13,33 @@ import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-togg
* @example
* <ish-product-add-to-compare *ngIf="'compare' | ishFeature"> ...</ish-product-add-to-compare>
*/
@Pipe({ name: 'ishFeature', pure: true })
export class FeatureTogglePipe implements PipeTransform {
constructor(private featureToggleService: FeatureToggleService) {}
@Pipe({ name: 'ishFeature', pure: false })
export class FeatureTogglePipe implements PipeTransform, OnDestroy {
private enabled: boolean;
private destroy$ = new Subject();
private subscription: Subscription;

constructor(private featureToggleService: FeatureToggleService, private cdRef: ChangeDetectorRef) {}

transform(feature: string): boolean {
return this.featureToggleService.enabled(feature);
if (this.subscription) {
// tslint:disable-next-line: ban
this.subscription.unsubscribe();
}

this.subscription = this.featureToggleService
.enabled$(feature)
.pipe(takeUntil(this.destroy$))
.subscribe(val => {
this.enabled = val;
this.cdRef.markForCheck();
});

return this.enabled;
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, Params, UrlSegment } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { anyString, instance, mock, when } from 'ts-mockito';
import { instance, mock } from 'ts-mockito';

import { AccountFacade } from 'ish-core/facades/account.facade';
import { FeatureToggleService } from 'ish-core/utils/feature-toggle/feature-toggle.service';
import { FeatureToggleModule } from 'ish-core/feature-toggle.module';
import { extractKeys } from 'ish-shared/formly/dev/testing/formly-testing-utils';

import {
Expand All @@ -15,17 +15,12 @@ import {
describe('Registration Form Configuration Service', () => {
let registrationConfigurationService: RegistrationFormConfigurationService;
let accountFacade: AccountFacade;
let featureToggleService: FeatureToggleService;

beforeEach(() => {
accountFacade = mock(AccountFacade);
featureToggleService = mock(FeatureToggleService);
TestBed.configureTestingModule({
imports: [RouterTestingModule],
providers: [
{ provide: AccountFacade, useFactory: () => instance(accountFacade) },
{ provide: FeatureToggleService, useFactory: () => instance(featureToggleService) },
],
imports: [FeatureToggleModule.forTesting(), RouterTestingModule],
providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }],
});
registrationConfigurationService = TestBed.inject(RegistrationFormConfigurationService);
});
Expand Down Expand Up @@ -119,7 +114,6 @@ describe('Registration Form Configuration Service', () => {
queryParams: {},
url: [{ path: '/register' } as UrlSegment],
} as ActivatedRouteSnapshot;
when(featureToggleService.enabled(anyString())).thenReturn(false);

expect(registrationConfigurationService.extractConfig(snapshot)).toMatchInlineSnapshot(`
Object {
Expand All @@ -136,7 +130,7 @@ describe('Registration Form Configuration Service', () => {
queryParams: { userid: 'uid', cancelUrl: '/checkout' } as Params,
url: [{ path: '/register' } as UrlSegment, { path: 'sso' } as UrlSegment],
} as ActivatedRouteSnapshot;
when(featureToggleService.enabled(anyString())).thenReturn(true);
FeatureToggleModule.switchTestingFeatures('businessCustomerRegistration');

expect(registrationConfigurationService.extractConfig(snapshot)).toMatchInlineSnapshot(`
Object {
Expand Down
Loading