mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-12 17:04:57 +00:00
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:
parent
3df9a8cedf
commit
2d347acda1
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -90,6 +90,7 @@
|
||||
"numbervisibilityprocess",
|
||||
"OAUTHCONFIG",
|
||||
"oidc",
|
||||
"overridable",
|
||||
"pdfjs",
|
||||
"penta",
|
||||
"printf",
|
||||
|
62
lib/core/feature-flags/README.md
Normal file
62
lib/core/feature-flags/README.md
Normal 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
|
||||
|
||||
|
||||
|
5
lib/core/feature-flags/ng-package.json
Normal file
5
lib/core/feature-flags/ng-package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"lib": {
|
||||
"entryFile": "src/index.ts"
|
||||
}
|
||||
}
|
18
lib/core/feature-flags/public-api.ts
Normal file
18
lib/core/feature-flags/public-api.ts
Normal 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';
|
29
lib/core/feature-flags/src/index.ts
Normal file
29
lib/core/feature-flags/src/index.ts
Normal 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';
|
@ -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 {}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -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']);
|
||||
}
|
||||
}
|
32
lib/core/feature-flags/src/lib/guards/is-feature-on.guard.ts
Normal file
32
lib/core/feature-flags/src/lib/guards/is-feature-on.guard.ts
Normal 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']);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
@ -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)
|
||||
};
|
||||
};
|
@ -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
|
||||
}
|
||||
];
|
||||
}
|
@ -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
|
||||
];
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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$;
|
||||
}
|
||||
}
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
@ -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({});
|
||||
}
|
||||
}
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
54
lib/core/feature-flags/src/lib/services/flagset.parser.ts
Normal file
54
lib/core/feature-flags/src/lib/services/flagset.parser.ts
Normal 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 } : {})
|
||||
}
|
||||
}),
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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": {
|
||||
|
@ -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/*"]
|
||||
}
|
||||
|
@ -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"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user