HXCS-3659 copy feature-flags lib (#9655)

* HXCS-3659 copy feature-flags lib

* HXCS-3659 remove hxps reference

* HXCS-3659 update component selectors

* HXCS-3659 replace word overridability with override

* HXCS-3659 remove commented/dead code

* HXCS-3659 rename files

* HXCS-3659 fix imports after renaming files

* HXCS-3659 update names to not refer ng 14

* HXCS-3659 update license header

* HXCS-3659 remove unused param

* HXCS-3659 test StorageFesturesService

* HXCS-3659 test DummyFesturesService

* HXCS-3659 test DebugFeaturesService in debug mode

* HXCS-3659 test DebugFeaturesService in debug mode

* HXCS-3659 test DebugFeaturesService not in debug mode

* HXCS-3659 test FlagSetParser

* HXCS-3659 test feature flags directives

* HXCS-3659 test flags component

* HXCS-3659 update readme

* HXCS-3659 link docs into readme

* HXCS-3659 update adf-feature-flags-wrapper css rules

* HXCS-3659 update Directive selectors

* HXCS-3659 add i18n

* HXCS-3659 update FlagsComponent css

* HXCS-3659 update directives @Input property names

* HXCS-3659 provides guards in the root

* HXCS-3659 remove deprecated method getFlagsSnapshot

---------

Co-authored-by: Adriano Costa <Adriano.Costa@hyland.comgit>
This commit is contained in:
Adriano Costa 2024-05-22 11:28:38 +02:00 committed by GitHub
parent 3df9a8cedf
commit 2d347acda1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2129 additions and 1 deletions

View File

@ -368,7 +368,8 @@
"lib/core/shell/**/*.ts",
"lib/core/shell/**/*.html",
"lib/core/breadcrumbs/**/*.ts",
"lib/core/breadcrumbs/**/*.html"
"lib/core/breadcrumbs/**/*.html",
"lib/core/feature-flags/**/*.ts"
]
}
},

View File

@ -90,6 +90,7 @@
"numbervisibilityprocess",
"OAUTHCONFIG",
"oidc",
"overridable",
"pdfjs",
"penta",
"printf",

View File

@ -0,0 +1,62 @@
# @alfresco/adf-core/feature-flags
Secondary entry point of `@alfresco/adf-core`. It can be used by importing from `@alfresco/adf-core/feature-flags`.
Feature flags (aka feature toggles) are a concept that allow product owners to control the availability of a feature in a particular environment. A product manager may use feature flags to hide a feature that isn't complete yet, roll out a feature to a select set of end-users in order to gather feedback, or coordinate a feature "go live" with marketing and other departments. From a developer perspective, feature flags are an important tool that allows them to continually commit their code even if a feature is not complete, thus a proper feature flag capability is essential to a functional continuous delivery model.
Because this library system is BE/Framework agnostic, it's required to implement the service that manages the retrieval of FeatureFlags and provide it to the AppModule:
```javascript
@NgModule({
declarations: [AppComponent],
providers: [
{ provide: OverridableFeaturesServiceToken, useClass: <YourCustomFeaturesService> },
{ provide: FeaturesServiceToken, useExisting: OverridableFeaturesServiceToken },
{ provide: FeaturesServiceConfigToken, useValue: <CustomConfiguration> }
],
bootstrap: [AppComponent],
})
export class AppModule {}
```
`<YourCustomFeaturesService>` is a service that implements the `IFeaturesService` interface:
```javascript
interface IFeaturesService<T = FlagChangeset> {
init(): Observable<T>;
isOn$(key: string): Observable<boolean>;
isOff$(key: string): Observable<boolean>;
getFlags$(): Observable<T>;
}
```
A `FlagChangeset` is an Object in which keys are Feature Flag names, and values are the current and previous enabled status for that particular flag. The previous status can be null.
```javascript
interface FlagChangeset {
[key: string]: {
current: boolean;
previous: boolean | null;
};
}
```
Optionally, is possible to provide a `<CustomConfiguration>`
```javascript
{
storageKey?: string;
helperExposeKeyOnDocument?: string;
}
```
`storageKey`: Local Storage key to save feature flags (default: `'feature-flags'`)
`helperExposeKeyOnDocument`: browser document key to add commands to enable or disable feature flags from the browser console.
Furher reading:
https://hyland.atlassian.net/wiki/spaces/HXP/pages/1308017277/Studio+Management+of+Feature+Flags
https://hyland.atlassian.net/wiki/spaces/HXP/pages/1308031390/Developing+frontend+apps+libs+e2es+with+feature+flags

View File

@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}

View File

@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './src/index';

View File

@ -0,0 +1,29 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './lib/components/flags/flags.component';
export * from './lib/components/feature-override-indicator.component';
export * from './lib/components/feature-flags-wrapper';
export * from './lib/directives/features.directive';
export * from './lib/directives/not-features.directive';
export * from './lib/guards/is-feature-on.guard';
export * from './lib/guards/is-feature-off.guard';
export * from './lib/guards/is-flags-override-on.guard';
export * from './lib/providers/dummy-feature-flags.provider';
export * from './lib/providers/debug-feature-flags.provider';
export * from './lib/interfaces/features.interface';
export * from './lib/mocks/features-service-mock.factory';

View File

@ -0,0 +1,39 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { FlagsComponent } from './flags/flags.component';
@Component({
selector: 'adf-feature-flags-wrapper',
standalone: true,
imports: [FlagsComponent],
template: `
<div class="adf-feature-flags-wrapper">
<adf-feature-flags-overrides></adf-feature-flags-overrides>
</div>
`,
styles: [
`
.adf-feature-flags-wrapper {
width: 100%;
height: 100%;
}
`
]
})
export class FeatureFlagsWrapperComponent {}

View File

@ -0,0 +1,79 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnDestroy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FeaturesServiceToken, IDebugFeaturesService } from '../interfaces/features.interface';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
@Component({
selector: 'adf-feature-flags-override-indicator',
standalone: true,
imports: [CommonModule],
styles: [
`
.adf-activity-indicator {
font-size: 0.885rem;
}
`,
`
.adf-activity-indicator .small {
font-size: 0.7rem;
}
`,
`
.adf-activity-indicator .large {
font-size: 1.2rem;
}
`
],
template: `
<span [ngClass]="['activity-indicator', size]" *ngIf="isEnabled; else inActive">🟢</span>
<ng-template #inActive><span [ngClass]="['activity-indicator', size]">🔴</span></ng-template>
`,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlagsOverrideComponent implements OnDestroy {
isEnabled = false;
destroy$ = new Subject<void>();
@Input()
size: 'small' | 'medium' | 'large' = 'medium';
constructor(
@Inject(FeaturesServiceToken)
private featuresService: IDebugFeaturesService,
changeDetectorRef: ChangeDetectorRef
) {
if (this.featuresService.isEnabled$) {
this.featuresService
.isEnabled$()
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEnabled = isEnabled;
changeDetectorRef.markForCheck();
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,70 @@
<mat-toolbar class="adf-feature-flags-overrides-header">
<div class="adf-feature-flags-overrides-header-text">
<adf-feature-flags-override-indicator></adf-feature-flags-override-indicator>
<span>{{ "CORE.FEATURE-FLAGS.OVERRIDE" | translate }}</span>
</div>
<mat-slide-toggle
color="warning"
[checked]="isEnabled"
(change)="onEnable($event.checked)">
</mat-slide-toggle>
<button
class="adf-feature-flags-overrides-header-close"
mat-icon-button
mat-dialog-close>
<mat-icon>close</mat-icon>
</button>
</mat-toolbar>
<ng-container *ngIf="flags$ | async as flags">
<table mat-table [dataSource]="flags" class="adf-feature-flags-overrides-table mat-elevation-z0">
<ng-container matColumnDef="icon">
<th mat-header-cell class="adf-icon-col adf-header-cell" *matHeaderCellDef>
<mat-icon class="material-icons-outlined" fontIcon="search" class="adf-search-icon"></mat-icon>
</th>
<td mat-cell class="adf-icon-col" *matCellDef="let element">
<button mat-icon-button *ngIf="element.fictive; else flagFromApi" class="adf-fictive-flag-button" (click)="onDelete(element.flag)">
<mat-icon class="material-icons-outlined adf-custom-flag-icon" fontIcon="memory"></mat-icon>
<mat-icon class="material-icons-outlined adf-trash-icon" fontIcon="delete"></mat-icon>
</button>
</td>
</ng-container>
<ng-container matColumnDef="flag">
<th mat-header-cell class="flag-col header-cell" *matHeaderCellDef>
<mat-form-field class="adf-flag-form-field" appearance="outline" floatLabel="auto">
<input class="flag-input" [placeholder]="(isEnabled ? 'CORE.FEATURE-FLAGS.FILTER_OR_ADD_NEW' : 'CORE.FEATURE-FLAGS.FILTER') | translate" matInput type="text" [(ngModel)]="inputValue" (keyup)="onInputChange(inputValue)" (keypress)="onAdd($event)">
</mat-form-field>
</th>
<td mat-cell class="flag-col" *matCellDef="let element">{{ element.flag }}</td>
</ng-container>
<ng-container matColumnDef="value">
<th mat-header-cell class="adf-val-col header-cell" *matHeaderCellDef>
<div class="adf-input-field-buttons-container">
<button *ngIf="showPlusButton$ | async" mat-icon-button matTooltip="{{'CORE.FEATURE-FLAGS.ADD_NEW' | translate}}" color="accent" (click)="onAddButtonClick()">
<mat-icon class="material-icons-outlined" fontIcon="add_circle"></mat-icon>
</button>
<button *ngIf="inputValue" matSuffix mat-icon-button aria-label="Clear" (click)="onClearInput()" class="adf-clear-button">
<mat-icon>cancel</mat-icon>
</button>
</div>
</th>
<td mat-cell class="adf-val-col" *matCellDef="let element">
<mat-slide-toggle
[checked]="element.value"
(change)="onChange(element.flag, $event.checked)"
[disabled]="!isEnabled">
</mat-slide-toggle>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</ng-container>
<ng-template #flagFromApi>
<mat-icon class="material-icons-outlined" fontIcon="cloud"></mat-icon>
</ng-template>

View File

@ -0,0 +1,112 @@
/* stylelint-disable selector-class-pattern */
.adf-feature-flags-overrides-header {
position: sticky;
/* stylelint-disable-next-line length-zero-no-unit */
top: 0px;
height: 64px;
z-index: 101;
display: flex;
.adf-activity-indicator {
margin-right: 12px;
position: relative;
top: -2px;
}
&-text {
flex: 1;
}
&-close {
margin-left: 12px;
}
}
.adf-feature-flags-overrides-table {
width: 100%;
.adf-header-cell.adf-icon-col {
top: 64px;
}
.adf-search-icon {
position: relative;
top: 4px;
}
.adf-flag-form-field {
width: 100%;
display: flex;
.mat-form-field-flex {
margin: 0;
padding: 0;
}
.mat-form-field-wrapper,
.mat-form-field-appearance-outline .mat-form-field-wrapper {
padding: 0;
margin: 0;
}
.adf-feature-flags-overrides-table .adf-flag-form-field .mat-form-field-wrapper {
width: 100%;
}
.mat-form-field-infix {
padding: 0;
border: none;
}
.mat-form-field-outline,
.mat-form-field-label-wrapper {
display: none;
}
}
.adf-input-field-buttons-container {
display: flex;
justify-content: flex-end;
}
.adf-clear-button {
margin-left: -14px;
}
.adf-fictive-flag-button {
margin-left: -8px;
position: relative;
mat-icon {
position: relative;
left: 8px;
}
.adf-custom-flag-icon {
display: block;
}
.adf-rash-icon {
display: none;
}
&:hover {
.adf-custom-flag-icon {
display: none;
}
.adf-trash-icon {
display: block;
}
}
}
.adf-icon-col {
width: 56px;
}
.adf-val-col {
width: 85px;
text-align: right;
}
}

View File

@ -0,0 +1,88 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { FlagsComponent } from './flags.component';
import { FeaturesDirective } from '../../directives/features.directive';
import { WritableFeaturesServiceToken } from '../../interfaces/features.interface';
import { provideMockFeatureFlags } from '../../mocks/features-service-mock.factory';
import { StorageFeaturesService } from '../../services/storage-features.service';
describe('FlagsComponent', () => {
let component: FlagsComponent;
let fixture: ComponentFixture<FlagsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FlagsComponent, TranslateModule.forRoot(), FeaturesDirective, NoopAnimationsModule],
providers: [
{ provide: WritableFeaturesServiceToken, useClass: StorageFeaturesService },
provideMockFeatureFlags({
feature1: true,
feature2: false,
feature3: true
})
]
}).compileComponents();
const storageFeaturesService = TestBed.inject(WritableFeaturesServiceToken);
storageFeaturesService.init();
fixture = TestBed.createComponent(FlagsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should initialize flags$', (done) => {
component.flags$.subscribe((flags) => {
expect(flags).toEqual([
{ fictive: false, flag: 'feature1', value: true },
{ fictive: false, flag: 'feature2', value: false },
{ fictive: false, flag: 'feature3', value: true }
]);
done();
});
});
it('should update inputValue$ when onInputChange is called', (done) => {
(component as any).onInputChange('test');
component.inputValue$.subscribe((value) => {
expect(value).toBe('test');
done();
});
});
it('should clear inputValue when onClearInput is called', () => {
component.inputValue = 'test';
(component as any).onClearInput();
expect(component.inputValue).toBe('');
});
it('should filter flags when when onClearInput is called', (done) => {
(component as any).onInputChange('feature1');
component.flags$.subscribe((flags) => {
expect(flags).toEqual([{ fictive: false, flag: 'feature1', value: true }]);
done();
});
});
afterEach(() => {
fixture.destroy();
});
});

View File

@ -0,0 +1,159 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, Inject, OnDestroy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
IWritableFeaturesService,
FeaturesServiceToken,
WritableFeaturesServiceToken,
IDebugFeaturesService,
WritableFlagChangeset,
IFeaturesService
} from '../../interfaces/features.interface';
import { BehaviorSubject, Observable, Subject, combineLatest } from 'rxjs';
import { debounceTime, map, take, takeUntil, tap } from 'rxjs/operators';
import { MatTableModule } from '@angular/material/table';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FlagsOverrideComponent } from '../feature-override-indicator.component';
import { MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'adf-feature-flags-overrides',
standalone: true,
imports: [
FlagsOverrideComponent,
CommonModule,
FormsModule,
MatTableModule,
MatSlideToggleModule,
MatToolbarModule,
MatIconModule,
MatButtonModule,
MatInputModule,
MatTooltipModule,
MatDialogModule,
TranslateModule
],
templateUrl: './flags.component.html',
styleUrls: ['./flags.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlagsComponent implements OnDestroy {
displayedColumns: string[] = ['icon', 'flag', 'value'];
flags$: Observable<{ fictive: boolean; flag: string; value: any }[]>;
isEnabled = false;
destroy$ = new Subject<void>();
inputValue = '';
inputValue$ = new BehaviorSubject<string>('');
showPlusButton$!: Observable<boolean>;
writableFlagChangeset: WritableFlagChangeset = {};
constructor(
@Inject(FeaturesServiceToken)
private featuresService: IDebugFeaturesService & IFeaturesService<WritableFlagChangeset>,
@Inject(WritableFeaturesServiceToken)
private writableFeaturesService: IWritableFeaturesService
) {
if (this.featuresService.isEnabled$) {
this.featuresService
.isEnabled$()
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEnabled = isEnabled;
});
}
const flags$ = this.featuresService.getFlags$().pipe(
tap((flags) => (this.writableFlagChangeset = flags)),
map((flags) =>
Object.keys(flags).map((key) => ({
flag: key,
value: flags[key].current,
fictive: flags[key]?.fictive ?? false
}))
)
);
const debouncedInputValue$ = this.inputValue$.pipe(debounceTime(100));
this.flags$ = combineLatest([flags$, debouncedInputValue$]).pipe(
map(([flags, inputValue]) => {
if (!inputValue) {
return flags;
}
return flags.filter((flag) => flag.flag.includes(inputValue));
})
);
this.showPlusButton$ = this.flags$.pipe(
map((filteredFlags) => this.isEnabled && filteredFlags.length === 0 && this.inputValue.trim().length > 0)
);
}
protected onChange(flag: string, value: boolean) {
this.writableFeaturesService.setFlag(flag, value);
}
protected onEnable(value: boolean) {
if (value) {
this.writableFeaturesService.mergeFlags(this.writableFlagChangeset);
}
this.featuresService.enable(value);
}
protected onInputChange(text: string) {
this.inputValue$.next(text);
}
protected onClearInput() {
this.inputValue = '';
this.inputValue$.next('');
}
protected onAdd(event: KeyboardEvent) {
this.showPlusButton$.pipe(take(1)).subscribe((showPlusButton) => {
if (showPlusButton && event.key === 'Enter' && event.shiftKey) {
this.writableFeaturesService.setFlag(this.inputValue, false);
}
});
}
protected onAddButtonClick() {
this.writableFeaturesService.setFlag(this.inputValue, false);
}
protected onDelete(flag: string) {
this.writableFeaturesService.removeFlag(flag);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,81 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockFeatureFlags } from '../mocks/features-service-mock.factory';
import { FeaturesDirective } from './features.directive';
import { By } from '@angular/platform-browser';
@Component({
template: `
<div>
<div id="underFeatureFlag" *adfForFeatures="features"></div>
</div>
`
})
class TestWithEnabledFlagComponent {
features = 'feature1';
}
@Component({
template: `
<div>
<div id="underFeatureFlag" *adfForFeatures="features"></div>
</div>
`
})
class TestWithDisabledFlagComponent {
features = ['feature1', 'feature2'];
}
describe('FeaturesDirective', () => {
let enabledFixture: ComponentFixture<TestWithEnabledFlagComponent>;
let disabledFixture: ComponentFixture<TestWithDisabledFlagComponent>;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [CommonModule, FeaturesDirective],
providers: [
provideMockFeatureFlags({
feature1: true,
feature2: false,
feature3: true
}),
FeaturesDirective
],
declarations: [TestWithEnabledFlagComponent, TestWithDisabledFlagComponent]
});
enabledFixture = TestBed.createComponent(TestWithEnabledFlagComponent);
enabledFixture.detectChanges();
disabledFixture = TestBed.createComponent(TestWithDisabledFlagComponent);
disabledFixture.detectChanges();
await enabledFixture.whenStable();
await disabledFixture.whenStable();
});
it('should render the element with enabled features', () => {
expect(enabledFixture.debugElement.query(By.css('#underFeatureFlag'))).toBeDefined();
expect(enabledFixture.debugElement.query(By.css('#underFeatureFlag')).nativeElement).toBeDefined();
});
it('should not render the element with disabled features', () => {
expect(disabledFixture.debugElement.query(By.css('#underFeatureFlag'))).toBeNull();
});
});

View File

@ -0,0 +1,64 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Directive, Inject, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { IFeaturesService, FeaturesServiceToken, FlagChangeset } from '../interfaces/features.interface';
import { takeUntil } from 'rxjs/operators';
@Directive({
/* eslint-disable-next-line @angular-eslint/directive-selector */
selector: '[adfForFeatures]',
standalone: true
})
export class FeaturesDirective implements OnDestroy {
private hasView = false;
private inputUpdate$ = new BehaviorSubject([] as string[]);
private destroy$ = new Subject();
@Input()
set adfForFeatures(feature: string[] | string) {
this.inputUpdate$.next(Array.isArray(feature) ? feature : [feature]);
}
constructor(
@Inject(FeaturesServiceToken) private featuresService: IFeaturesService,
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {
combineLatest([this.featuresService.getFlags$(), this.inputUpdate$])
.pipe(takeUntil(this.destroy$))
.subscribe(([flags, features]: any) => this.updateView(flags, features));
}
private updateView(flags: FlagChangeset, features: string[]) {
const shouldShow = features.every((feature) => flags[feature]?.current);
if (shouldShow && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!shouldShow && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,79 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideMockFeatureFlags } from '../mocks/features-service-mock.factory';
import { NotFeaturesDirective } from './not-features.directive';
@Component({
template: `
<div>
<div id="underFeatureFlag" *adfNotForFeatures="features"></div>
</div>
`
})
class TestWithEnabledFlagComponent {
features = ['feature1', 'feature3'];
}
@Component({
template: `
<div>
<div id="underFeatureFlag" *adfNotForFeatures="features"></div>
</div>
`
})
class TestWithDisabledFlagComponent {
features = 'feature2';
}
describe('NotFeaturesDirective', () => {
let enabledFixture: ComponentFixture<TestWithEnabledFlagComponent>;
let disabledFixture: ComponentFixture<TestWithDisabledFlagComponent>;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [CommonModule, NotFeaturesDirective],
providers: [
provideMockFeatureFlags({
feature1: true,
feature2: false,
feature3: true
}),
NotFeaturesDirective
],
declarations: [TestWithEnabledFlagComponent, TestWithDisabledFlagComponent]
});
enabledFixture = TestBed.createComponent(TestWithEnabledFlagComponent);
enabledFixture.detectChanges();
disabledFixture = TestBed.createComponent(TestWithDisabledFlagComponent);
disabledFixture.detectChanges();
});
it('should render the element with disabled features', () => {
expect(disabledFixture.debugElement.query(By.css('#underFeatureFlag'))).toBeDefined();
expect(disabledFixture.debugElement.query(By.css('#underFeatureFlag')).nativeElement).toBeDefined();
});
it('should not render the element with enabled features', () => {
expect(enabledFixture.debugElement.query(By.css('#underFeatureFlag'))).toBeNull();
});
});

View File

@ -0,0 +1,64 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Directive, Inject, Input, OnDestroy, TemplateRef, ViewContainerRef } from '@angular/core';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { IFeaturesService, FeaturesServiceToken, FlagChangeset } from '../interfaces/features.interface';
import { takeUntil } from 'rxjs/operators';
@Directive({
/* eslint-disable-next-line @angular-eslint/directive-selector */
selector: '[adfNotForFeatures]',
standalone: true
})
export class NotFeaturesDirective implements OnDestroy {
private hasView = false;
private inputUpdate$ = new BehaviorSubject([] as string[]);
private destroy$ = new Subject();
@Input()
set adfNotForFeatures(feature: string[] | string) {
this.inputUpdate$.next(Array.isArray(feature) ? feature : [feature]);
}
constructor(
@Inject(FeaturesServiceToken) private featuresService: IFeaturesService,
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {
combineLatest([this.featuresService.getFlags$(), this.inputUpdate$])
.pipe(takeUntil(this.destroy$))
.subscribe(([flags, features]: any) => this.updateView(flags, features));
}
private updateView(flags: FlagChangeset, features: string[]) {
const shouldShow = features.every((feature) => !flags[feature]?.current);
if (shouldShow && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!shouldShow && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,32 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable, inject } from '@angular/core';
import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface';
import { CanMatch, Route } from '@angular/router';
import { Observable } from 'rxjs';
export const isFeatureOff = (flag: string) => () => inject(FeaturesServiceToken).isOff$(flag);
@Injectable({ providedIn: 'root' })
export class IsFeatureOff implements CanMatch {
constructor(@Inject(FeaturesServiceToken) private featuresServiceToken: IFeaturesService) {}
canMatch(route: Route): Observable<boolean> {
return this.featuresServiceToken.isOff$(route?.data?.['feature']);
}
}

View File

@ -0,0 +1,32 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable, inject } from '@angular/core';
import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface';
import { CanMatch, Route } from '@angular/router';
import { Observable } from 'rxjs';
export const isFeatureOn = (flag: string) => () => inject(FeaturesServiceToken).isOn$(flag);
@Injectable({ providedIn: 'root' })
export class IsFeatureOn implements CanMatch {
constructor(@Inject(FeaturesServiceToken) private featuresServiceToken: IFeaturesService) {}
canMatch(route: Route): Observable<boolean> {
return this.featuresServiceToken.isOn$(route?.data?.['feature']);
}
}

View File

@ -0,0 +1,31 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable, Optional, inject } from '@angular/core';
import { FlagsOverrideToken } from '../interfaces/features.interface';
import { CanMatch } from '@angular/router';
export const isFlagsOverrideOn = () => () => inject(FlagsOverrideToken) ?? false;
@Injectable({ providedIn: 'root' })
export class IsFlagsOverrideOn implements CanMatch {
constructor(@Optional() @Inject(FlagsOverrideToken) private devToolsToken: boolean) {}
canMatch(): boolean {
return !!this.devToolsToken;
}
}

View File

@ -0,0 +1,72 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
export const FeaturesServiceConfigToken = new InjectionToken<any>('FeatureServiceConfigToken');
export const FeaturesServiceToken = new InjectionToken<IFeaturesService>('FeaturesService');
export const WritableFeaturesServiceToken = new InjectionToken<IFeaturesService & IWritableFeaturesService>('WritableFeaturesServiceToken');
export const WritableFeaturesServiceConfigToken = new InjectionToken<WritableFeaturesServiceConfig>('WritableFeaturesServiceConfigToken');
export const OverridableFeaturesServiceToken = new InjectionToken<IFeaturesService>('OverridableFeaturesServiceToken');
export const FlagsOverrideToken = new InjectionToken<boolean>('FlagsOverrideToken');
export interface WritableFeaturesServiceConfig {
storageKey?: string;
}
export interface QaFeaturesHelperConfig {
helperExposeKeyOnDocument?: string;
}
export interface FlagChangeset {
[key: string]: {
current: any;
previous: any;
};
}
export interface WritableFlagChangeset {
[key: string]: {
current: any;
previous: any;
fictive?: boolean;
};
}
export interface FlagSet {
[key: string]: any;
}
export interface IFeaturesService<T = FlagChangeset> {
init(): Observable<T>;
isOn$(key: string): Observable<boolean>;
isOff$(key: string): Observable<boolean>;
getFlags$(): Observable<T>;
}
export interface IWritableFeaturesService {
setFlag(key: string, value: any): void;
resetFlags(flags: FlagSet): void;
removeFlag(key: string): void;
mergeFlags(flags: FlagChangeset): void;
}
export type IDebugFeaturesService = Omit<IFeaturesService<WritableFlagChangeset>, 'init'> & {
enable(on: boolean): void;
isEnabled$(): Observable<boolean>;
resetFlags(flags: FlagSet): void;
};

View File

@ -0,0 +1,76 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { of } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
export interface MockFeatureFlags {
[key: string]: boolean;
}
const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
const flagChangesetValue = flagChangeset[key];
if (flagChangesetValue === undefined) {
throw new Error(
`ERROR FEATURE-FLAG\n'${key}' feature is not mocked, please mock '${key}' using '${provideMockFeatureFlags.name}' helper in your test\n`
);
}
};
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({
init: () => of(flagChangeset),
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
},
getFlags$: () => of(flagChangeset)
});
const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
const flagChangeset: FlagChangeset = {};
featureFlags.forEach((featureFlag) => {
flagChangeset[featureFlag] = { current: true, previous: null };
});
return flagChangeset;
};
const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) => {
const flagChangeset: FlagChangeset = {};
const featureFlags = Object.keys(mockFeatureFlags);
featureFlags.forEach((featureFlag) => {
flagChangeset[featureFlag] = { current: mockFeatureFlags[featureFlag], previous: null };
});
return flagChangeset;
};
export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => {
if (typeof featureFlag === 'string') {
featureFlag = [featureFlag];
}
const flagChangeset = Array.isArray(featureFlag) ? arrayToFlagChangeset(featureFlag) : mockFeatureFlagsToFlagChangeset(featureFlag);
return {
provide: FeaturesServiceToken,
useValue: mockFeaturesService(flagChangeset)
};
};

View File

@ -0,0 +1,59 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { APP_INITIALIZER } from '@angular/core';
import {
FlagsOverrideToken,
FeaturesServiceToken,
QaFeaturesHelperConfig,
WritableFeaturesServiceConfig,
WritableFeaturesServiceConfigToken,
WritableFeaturesServiceToken
} from '../interfaces/features.interface';
import { StorageFeaturesService } from '../services/storage-features.service';
import { DebugFeaturesService } from '../services/debug-features.service';
import { QaFeaturesHelper } from '../services/qa-features.helper';
import { DOCUMENT } from '@angular/common';
/**
*
* @param config Configuration for the Feature Flags
* @returns Environment Providers for Feature Flags
*/
export function provideDebugFeatureFlags(config: WritableFeaturesServiceConfig & QaFeaturesHelperConfig) {
return [
{ provide: FlagsOverrideToken, useValue: true },
{ provide: FeaturesServiceToken, useClass: DebugFeaturesService },
{ provide: WritableFeaturesServiceConfigToken, useValue: config },
{ provide: WritableFeaturesServiceToken, useClass: StorageFeaturesService },
{ provide: QaFeaturesHelper, useClass: QaFeaturesHelper },
{
provide: APP_INITIALIZER,
useFactory: (featuresService: StorageFeaturesService) => () => featuresService.init(),
deps: [WritableFeaturesServiceToken],
multi: true
},
{
provide: APP_INITIALIZER,
useFactory: (qaFeaturesHelper: QaFeaturesHelper, document: Document & { [key: string]: QaFeaturesHelper }) => () => {
document[config.helperExposeKeyOnDocument ?? 'featureOverrides'] = qaFeaturesHelper;
},
deps: [QaFeaturesHelper, DOCUMENT],
multi: true
}
];
}

View File

@ -0,0 +1,37 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IsFlagsOverrideOn } from '../guards/is-flags-override-on.guard';
import { IsFeatureOn } from '../guards/is-feature-on.guard';
import { IsFeatureOff } from '../guards/is-feature-off.guard';
import { FeaturesServiceToken, FlagsOverrideToken } from '../interfaces/features.interface';
import { DummyFeaturesService } from '../services/dummy-features.service';
/**
* Provides the dummy feature flags.
*
* @returns Environment Providers for Feature Flags.
*/
export function provideDummyFeatureFlags() {
return [
{ provide: FeaturesServiceToken, useClass: DummyFeaturesService },
{ provide: FlagsOverrideToken, useValue: false },
IsFeatureOn,
IsFeatureOff,
IsFlagsOverrideOn
];
}

View File

@ -0,0 +1,119 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { DebugFeaturesService } from './debug-features.service';
import { StorageService } from '../../../../src/lib/common/services/storage.service';
import { OverridableFeaturesServiceToken, WritableFeaturesServiceToken } from '../interfaces/features.interface';
import { DummyFeaturesService } from './dummy-features.service';
import { StorageFeaturesService } from './storage-features.service';
import { take } from 'rxjs/operators';
describe('DebugFeaturesService', () => {
let service: DebugFeaturesService;
const mockStorage = {
getItem: () =>
JSON.stringify({
feature1: {
current: true
},
feature2: {
current: false,
fictive: true
}
}),
setItem: () => {}
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DebugFeaturesService,
{ provide: StorageService, useValue: mockStorage },
{ provide: WritableFeaturesServiceToken, useClass: StorageFeaturesService },
{ provide: OverridableFeaturesServiceToken, useClass: DummyFeaturesService }
]
});
service = TestBed.inject(DebugFeaturesService);
});
it('should return false for isOn$ when flag is enabled', (done) => {
const flagKey = 'feature1';
service
.isOn$(flagKey)
.pipe(take(1))
.subscribe((isEnabled) => {
expect(isEnabled).toBeFalse();
done();
});
});
it('should return false for isOn$ when flag is disabled', (done) => {
const flagKey = 'feature2';
service
.isOn$(flagKey)
.pipe(take(1))
.subscribe((isEnabled) => {
expect(isEnabled).toBeFalse();
done();
});
});
it('should return true for isOff$ when flag is enabled', (done) => {
const flagKey = 'feature3';
service
.isOff$(flagKey)
.pipe(take(1))
.subscribe((isEnabled) => {
expect(isEnabled).toBeTrue();
done();
});
});
it('should return true for isOff$ when flag is disabled', (done) => {
const flagKey = 'feature4';
service
.isOff$(flagKey)
.pipe(take(1))
.subscribe((isEnabled) => {
expect(isEnabled).toBeTrue();
done();
});
});
it('should always reset specified flags', () => {
const flagsToReset = {
feature1: true
};
const writableFeaturesServiceToken = TestBed.inject(WritableFeaturesServiceToken);
const spy = spyOn(writableFeaturesServiceToken, 'resetFlags');
service.resetFlags(flagsToReset);
expect(spy).toHaveBeenCalled();
});
it('should get the flags as an observable', (done) => {
service.getFlags$().subscribe((flags) => {
expect(flags).toEqual({});
done();
});
});
});

View File

@ -0,0 +1,96 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable, Optional } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { skip, switchMap } from 'rxjs/operators';
import {
IDebugFeaturesService,
IFeaturesService,
FlagChangeset,
OverridableFeaturesServiceToken,
WritableFeaturesServiceToken,
WritableFeaturesServiceConfigToken,
WritableFeaturesServiceConfig,
FlagSet,
IWritableFeaturesService
} from '../interfaces/features.interface';
import { StorageService } from '@alfresco/adf-core';
@Injectable()
export class DebugFeaturesService implements IDebugFeaturesService {
private isInDebugMode: BehaviorSubject<boolean>;
private isInDebugMode$: Observable<boolean>;
get storageKey(): string {
return `${this.config?.storageKey || 'feature-flags'}-override`;
}
constructor(
@Inject(OverridableFeaturesServiceToken) private overriddenFeaturesService: IFeaturesService,
@Inject(WritableFeaturesServiceToken) private writableFeaturesService: IFeaturesService & IWritableFeaturesService,
private storageService: StorageService,
@Optional() @Inject(WritableFeaturesServiceConfigToken) private config?: WritableFeaturesServiceConfig
) {
this.isInDebugMode = new BehaviorSubject<boolean>(JSON.parse(this.storageService.getItem(this.storageKey) || 'false'));
this.isInDebugMode$ = this.isInDebugMode.asObservable();
this.isInDebugMode.pipe(skip(1)).subscribe((debugMode) => {
this.storageService.setItem(this.storageKey, JSON.stringify(debugMode));
});
}
isOn$(key: string): Observable<boolean> {
return this.isInDebugMode$.pipe(
switchMap((isInDebugMode) => (isInDebugMode ? this.writableFeaturesService : this.overriddenFeaturesService).isOn$(key))
);
}
isOff$(key: string): Observable<boolean> {
return this.isInDebugMode$.pipe(
switchMap((isInDebugMode) => (isInDebugMode ? this.writableFeaturesService : this.overriddenFeaturesService).isOff$(key))
);
}
/**
* Gets the flags as an observable.
*
* @returns the observable that emits the flag changeset.
*/
getFlags$(): Observable<FlagChangeset> {
return this.isInDebugMode$.pipe(
switchMap((isInDebugMode) => (isInDebugMode ? this.writableFeaturesService : this.overriddenFeaturesService).getFlags$())
);
}
/**
* Resets the specified flags.
*
* @param flags The flags to reset.
*/
resetFlags(flags: FlagSet): void {
this.writableFeaturesService.resetFlags(flags);
}
enable(on: boolean): void {
this.isInDebugMode.next(on);
}
isEnabled$(): Observable<boolean> {
return this.isInDebugMode$;
}
}

View File

@ -0,0 +1,60 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { DummyFeaturesService } from './dummy-features.service';
describe('DummyFeaturesService', () => {
let service: DummyFeaturesService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DummyFeaturesService]
});
service = TestBed.inject(DummyFeaturesService);
});
it('should initialize the service', () => {
service.init().subscribe((changeset) => {
expect(changeset).toBeUndefined();
});
});
it('should return false when isOn$ is called', () => {
service.isOn$().subscribe((isOn) => {
expect(isOn).toBeFalse();
});
});
it('should return true when isOff$ is called with any key', () => {
service.isOff$('').subscribe((isOff) => {
expect(isOff).toBeTrue();
});
service.isOff$('key').subscribe((isOff) => {
expect(isOff).toBeTrue();
});
service.isOff$('salkjdaskd').subscribe((isOff) => {
expect(isOff).toBeTrue();
});
});
it('should return an empty object when getFlags$ is called', () => {
service.getFlags$().subscribe((flags) => {
expect(flags).toEqual({});
});
});
});

View File

@ -0,0 +1,40 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { IFeaturesService, FlagChangeset } from '../interfaces/features.interface';
@Injectable()
export class DummyFeaturesService implements IFeaturesService {
init(): Observable<FlagChangeset> {
return of();
}
isOn$(): Observable<boolean> {
return of(false);
}
isOff$(_key: string): Observable<boolean> {
return of(true);
}
getFlags$(): Observable<FlagChangeset> {
return of({});
}
}

View File

@ -0,0 +1,77 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FlagSetParser } from './flagset.parser';
describe('FlagSetParser', () => {
describe('serialize', () => {
it('should serialize flags correctly', () => {
const flags = {
feature1: {
current: true,
previous: null
},
feature2: {
current: false,
previous: true,
fictive: true
}
};
const serializedFlags = FlagSetParser.serialize(flags);
expect(serializedFlags).toEqual({
feature1: { current: true, fictive: undefined },
feature2: { current: false, fictive: true }
});
});
it('should handle empty flags', () => {
const flags = {};
const serializedFlags = FlagSetParser.serialize(flags);
expect(serializedFlags).toEqual({});
});
});
describe('deserialize', () => {
it('should deserialize flags correctly', () => {
const serializedFlags = {
feature1: { current: true },
feature2: { current: false },
feature3: { current: true }
};
const flags = FlagSetParser.deserialize(serializedFlags);
expect(flags).toEqual({
feature1: { current: true, previous: null },
feature2: { current: false, previous: null },
feature3: { current: true, previous: null }
});
});
it('should handle empty serialized flags', () => {
const serializedFlags = {};
const flags = FlagSetParser.deserialize(serializedFlags);
expect(flags).toEqual({});
});
});
});

View File

@ -0,0 +1,54 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { WritableFlagChangeset } from '../interfaces/features.interface';
interface SerializedFlagSet {
[key: string]: {
current: boolean;
fictive?: boolean;
};
}
export class FlagSetParser {
static serialize(flags: WritableFlagChangeset): SerializedFlagSet {
return Object.keys(flags).reduce(
(acc, key) => ({
...acc,
[key]: {
current: flags[key].current,
fictive: flags[key].fictive
}
}),
{}
);
}
static deserialize(serializedFlags: SerializedFlagSet): WritableFlagChangeset {
return Object.keys(serializedFlags).reduce(
(acc, key) => ({
...acc,
[key]: {
current: serializedFlags[key].current,
previous: null,
...(serializedFlags[key].fictive ? { fictive: true } : {})
}
}),
{}
);
}
}

View File

@ -0,0 +1,56 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApplicationRef, Inject, Injectable } from '@angular/core';
import { FeaturesServiceToken, FlagSet } from '../interfaces/features.interface';
import { DebugFeaturesService } from './debug-features.service';
@Injectable()
export class QaFeaturesHelper {
constructor(private applicationRef: ApplicationRef, @Inject(FeaturesServiceToken) private debugFeaturesService: DebugFeaturesService) {}
isOn(key: string): boolean {
let isOn = false;
this.debugFeaturesService.isOn$(key).subscribe((on) => {
isOn = on;
});
return isOn;
}
resetFlags(flags: FlagSet): void {
this.debugFeaturesService.resetFlags(flags);
this.applicationRef.tick();
}
enable(): void {
this.debugFeaturesService.enable(true);
this.applicationRef.tick();
}
disable(): void {
this.debugFeaturesService.enable(false);
this.applicationRef.tick();
}
isEnabled(): boolean {
let enabled = false;
this.debugFeaturesService.isEnabled$().subscribe((isEnabled) => {
enabled = isEnabled;
});
return enabled;
}
}

View File

@ -0,0 +1,192 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { StorageFeaturesService } from './storage-features.service';
import { CoreTestingModule, StorageService } from '../../../../src/public-api';
import { FlagSet, WritableFeaturesServiceConfigToken } from '../interfaces/features.interface';
import { skip, take } from 'rxjs/operators';
describe('StorageFeaturesService', () => {
let storageFeaturesService: StorageFeaturesService;
describe('if flags are present in LocalStorage', () => {
const mockStorage = {
getItem: () =>
JSON.stringify({
feature1: {
current: true
},
feature2: {
current: false,
fictive: true
}
}),
setItem: () => {}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule],
providers: [
{ provide: StorageService, useValue: mockStorage },
{
provide: WritableFeaturesServiceConfigToken,
useValue: {
storageKey: 'storage-key-test'
}
},
StorageFeaturesService
]
});
storageFeaturesService = TestBed.inject(StorageFeaturesService);
storageFeaturesService.init();
});
it('should return the stored flag set', (done) => {
storageFeaturesService
.getFlags$()
.pipe(take(1))
.subscribe((flags) => {
expect(flags).toEqual({
feature1: {
current: true,
previous: null
},
feature2: {
current: false,
fictive: true,
previous: null
}
});
done();
});
});
it('should set a flag and retrieve its value', (done) => {
const flagKey = 'testFlag';
const flagValue = true;
storageFeaturesService.setFlag(flagKey, flagValue);
storageFeaturesService
.getFlags$()
.pipe(take(1))
.subscribe((flags) => {
expect(flags[flagKey]).toEqual({ current: true, previous: null, fictive: true });
done();
});
});
it('should remove a flag', (done) => {
const flagKey = 'testFlag';
const flagValue = true;
storageFeaturesService.setFlag(flagKey, flagValue);
storageFeaturesService.removeFlag(flagKey);
storageFeaturesService
.getFlags$()
.pipe(take(1))
.subscribe((flags) => {
expect(flags[flagKey]).toBeUndefined();
done();
});
});
it('should reset flags to the provided set', (done) => {
const flagSet: FlagSet = { feature1: true };
storageFeaturesService.resetFlags(flagSet);
storageFeaturesService
.getFlags$()
.pipe(take(1))
.subscribe((flags) => {
expect(flags.feature1.previous).toBeNull();
expect(flags.feature1.fictive).toBe(true);
done();
});
});
it('should merge flags to the provided set', (done) => {
const newFlags = {
feature2: {
current: false,
previous: null
},
feature3: {
current: false,
previous: null
}
};
storageFeaturesService.mergeFlags(newFlags);
storageFeaturesService
.getFlags$()
.pipe(take(1))
.subscribe((flags) => {
expect(flags).toEqual({
feature1: { current: true, previous: null, fictive: true },
feature2: { current: false, previous: false },
feature3: { current: false, previous: null }
});
done();
});
});
it('should emit flag changes when a flag is set', (done) => {
const flagKey = 'testFlag';
const flagValue = true;
storageFeaturesService
.getFlags$()
.pipe(skip(1))
.subscribe((flags) => {
expect(flags[flagKey]).toEqual({ current: true, previous: null, fictive: true });
done();
});
storageFeaturesService.setFlag(flagKey, flagValue);
});
it('should return custom storageFeaturesService key', () => {
expect(storageFeaturesService.storageKey).toEqual('storage-key-test');
});
});
describe('if flags are not present in LocalStorage and no configuration is provided', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CoreTestingModule],
providers: [StorageFeaturesService]
});
storageFeaturesService = TestBed.inject(StorageFeaturesService);
});
it('should return initial empty flag set', (done) => {
storageFeaturesService.init().subscribe((flags) => {
expect(flags).toEqual({});
done();
});
});
it('should return default storageFeaturesService key', () => {
expect(storageFeaturesService.storageKey).toEqual('feature-flags');
});
});
});

View File

@ -0,0 +1,136 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Inject, Injectable, Optional } from '@angular/core';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { map, skip } from 'rxjs/operators';
import {
FlagChangeset,
IFeaturesService,
FlagSet,
IWritableFeaturesService,
WritableFeaturesServiceConfigToken,
WritableFlagChangeset,
WritableFeaturesServiceConfig
} from '../interfaces/features.interface';
import { FlagSetParser } from './flagset.parser';
import { StorageService } from '@alfresco/adf-core';
@Injectable()
export class StorageFeaturesService implements IFeaturesService, IWritableFeaturesService {
private currentFlagState: WritableFlagChangeset = {};
private flags = new BehaviorSubject<WritableFlagChangeset>({});
private flags$ = this.flags.asObservable();
constructor(
private storageService: StorageService,
@Optional() @Inject(WritableFeaturesServiceConfigToken) private config?: WritableFeaturesServiceConfig
) {
this.flags.pipe(skip(1)).subscribe((flags) => {
this.currentFlagState = flags;
this.storageService.setItem(this.storageKey, JSON.stringify(FlagSetParser.serialize(flags)));
});
}
get storageKey(): string {
return this.config?.storageKey || 'feature-flags';
}
init(): Observable<WritableFlagChangeset> {
const storedFlags = JSON.parse(this.storageService.getItem(this.storageKey) || '{}');
const initialFlagChangeSet = FlagSetParser.deserialize(storedFlags);
this.flags.next(initialFlagChangeSet);
return of(initialFlagChangeSet);
}
isOn$(key: string): Observable<boolean> {
return this.flags$.pipe(map((flags) => !!flags[key]?.current));
}
isOff$(key: string): Observable<boolean> {
return this.flags$.pipe(map((flags) => !flags[key]?.current));
}
getFlags$(): Observable<WritableFlagChangeset> {
return this.flags$;
}
setFlag(key: string, value: any): void {
let fictive = {};
if (!this.currentFlagState[key]) {
fictive = { fictive: true };
} else {
fictive = this.currentFlagState[key]?.fictive ? { fictive: true } : {};
}
this.flags.next({
...this.currentFlagState,
[key]: {
current: value,
previous: this.currentFlagState[key]?.current ?? null,
...fictive
}
});
}
removeFlag(key: string): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _, ...flags } = this.currentFlagState;
this.flags.next(flags);
}
resetFlags(flags: FlagSet): void {
this.flags.next(
Object.keys(flags).reduce(
(acc, key) => ({
...acc,
[key]: {
current: flags[key],
previous: null,
fictive: true
}
}),
{}
)
);
}
mergeFlags(flags: FlagChangeset): void {
const mergedFlags: WritableFlagChangeset = Object.keys(flags).reduce((acc, key) => {
const current = this.currentFlagState[key]?.current;
return {
...acc,
[key]: {
current: current ?? flags[key].current,
previous: current ?? null
}
};
}, {});
Object.keys(this.currentFlagState)
.filter((key) => !flags[key])
.forEach((key) => {
mergedFlags[key] = {
current: this.currentFlagState[key].current,
previous: this.currentFlagState[key].previous,
fictive: true
};
});
this.flags.next(mergedFlags);
}
}

View File

@ -252,6 +252,12 @@
"SEARCH": {
"TOGGLE_ASC_DESC_ORDER": "Toggle results between ascending and descending order",
"SORT_BY": "Sort by"
},
"FEATURE-FLAGS": {
"OVERRIDES": "Feature flag overrides",
"FILTER_OR_ADD_NEW": "Filter or add new",
"FILTER": "Filter",
"ADD_NEW": "Add new feature flag (Shift + Enter)"
}
},
"COMMENTS": {

View File

@ -10,6 +10,7 @@
"@alfresco/adf-core/auth": ["../auth/src/index.ts"],
"@alfresco/adf-core/shell": ["../shell/src/index.ts"],
"@alfresco/adf-core/api": ["../api/src/index.ts"],
"@alfresco/adf-core/feature-flags": ["../feature-flags/src/index.ts"],
"@alfresco/js-api": ["../../../dist/libs/js-api"],
"@alfresco/js-api/*": ["../../../dist/libs/js-api/*"]
}

View File

@ -28,6 +28,7 @@
"@alfresco/adf-core/api": ["lib/core/api/src/index.ts"],
"@alfresco/adf-core/auth": ["lib/core/auth/src/index.ts"],
"@alfresco/adf-core/breadcrumbs": ["lib/core/breadcrumbs/src/index.ts"],
"@alfresco/adf-core/feature-flags": ["lib/core/feature-flags/src/index.ts"],
"@alfresco/adf-core/shell": ["lib/core/shell/src/index.ts"],
"@alfresco/adf-extensions": ["lib/extensions/src/public-api.ts"],
"@alfresco/adf-insights": ["lib/insights/src/public-api.ts"],