diff --git a/angular.json b/angular.json index d5195524e7..d5a086559e 100644 --- a/angular.json +++ b/angular.json @@ -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" ] } }, diff --git a/cspell.json b/cspell.json index c60ae1e524..f7625d3e94 100644 --- a/cspell.json +++ b/cspell.json @@ -90,6 +90,7 @@ "numbervisibilityprocess", "OAUTHCONFIG", "oidc", + "overridable", "pdfjs", "penta", "printf", diff --git a/lib/core/feature-flags/README.md b/lib/core/feature-flags/README.md new file mode 100644 index 0000000000..cbd56f3426 --- /dev/null +++ b/lib/core/feature-flags/README.md @@ -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: }, + { provide: FeaturesServiceToken, useExisting: OverridableFeaturesServiceToken }, + { provide: FeaturesServiceConfigToken, useValue: } + ], + bootstrap: [AppComponent], +}) +export class AppModule {} +``` + +`` is a service that implements the `IFeaturesService` interface: + +```javascript +interface IFeaturesService { + init(): Observable; + isOn$(key: string): Observable; + isOff$(key: string): Observable; + getFlags$(): Observable; +} +``` + +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 `` + +```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 + + + diff --git a/lib/core/feature-flags/ng-package.json b/lib/core/feature-flags/ng-package.json new file mode 100644 index 0000000000..c781f0df46 --- /dev/null +++ b/lib/core/feature-flags/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/lib/core/feature-flags/public-api.ts b/lib/core/feature-flags/public-api.ts new file mode 100644 index 0000000000..899ef7d7ed --- /dev/null +++ b/lib/core/feature-flags/public-api.ts @@ -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'; diff --git a/lib/core/feature-flags/src/index.ts b/lib/core/feature-flags/src/index.ts new file mode 100644 index 0000000000..c4fa1aacc3 --- /dev/null +++ b/lib/core/feature-flags/src/index.ts @@ -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'; diff --git a/lib/core/feature-flags/src/lib/components/feature-flags-wrapper.ts b/lib/core/feature-flags/src/lib/components/feature-flags-wrapper.ts new file mode 100644 index 0000000000..b98166f882 --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/feature-flags-wrapper.ts @@ -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: ` +
+ +
+ `, + styles: [ + ` + .adf-feature-flags-wrapper { + width: 100%; + height: 100%; + } + ` + ] +}) +export class FeatureFlagsWrapperComponent {} diff --git a/lib/core/feature-flags/src/lib/components/feature-override-indicator.component.ts b/lib/core/feature-flags/src/lib/components/feature-override-indicator.component.ts new file mode 100644 index 0000000000..884a3a0ac9 --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/feature-override-indicator.component.ts @@ -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: ` + 🟢 + 🔴 + `, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FlagsOverrideComponent implements OnDestroy { + isEnabled = false; + destroy$ = new Subject(); + + @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(); + } +} diff --git a/lib/core/feature-flags/src/lib/components/flags/flags.component.html b/lib/core/feature-flags/src/lib/components/flags/flags.component.html new file mode 100644 index 0000000000..93affacf1d --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/flags/flags.component.html @@ -0,0 +1,70 @@ + +
+ + {{ "CORE.FEATURE-FLAGS.OVERRIDE" | translate }} +
+ + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + {{ element.flag }} +
+ + +
+
+ + +
+
+ + + + + diff --git a/lib/core/feature-flags/src/lib/components/flags/flags.component.scss b/lib/core/feature-flags/src/lib/components/flags/flags.component.scss new file mode 100644 index 0000000000..b4e2b666cc --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/flags/flags.component.scss @@ -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; + } +} diff --git a/lib/core/feature-flags/src/lib/components/flags/flags.component.spec.ts b/lib/core/feature-flags/src/lib/components/flags/flags.component.spec.ts new file mode 100644 index 0000000000..fc05853862 --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/flags/flags.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/lib/core/feature-flags/src/lib/components/flags/flags.component.ts b/lib/core/feature-flags/src/lib/components/flags/flags.component.ts new file mode 100644 index 0000000000..1d984e2460 --- /dev/null +++ b/lib/core/feature-flags/src/lib/components/flags/flags.component.ts @@ -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(); + + inputValue = ''; + inputValue$ = new BehaviorSubject(''); + showPlusButton$!: Observable; + writableFlagChangeset: WritableFlagChangeset = {}; + + constructor( + @Inject(FeaturesServiceToken) + private featuresService: IDebugFeaturesService & IFeaturesService, + @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(); + } +} diff --git a/lib/core/feature-flags/src/lib/directives/features.directive.spec.ts b/lib/core/feature-flags/src/lib/directives/features.directive.spec.ts new file mode 100644 index 0000000000..1a50c489c6 --- /dev/null +++ b/lib/core/feature-flags/src/lib/directives/features.directive.spec.ts @@ -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: ` +
+
+
+ ` +}) +class TestWithEnabledFlagComponent { + features = 'feature1'; +} +@Component({ + template: ` +
+
+
+ ` +}) +class TestWithDisabledFlagComponent { + features = ['feature1', 'feature2']; +} + +describe('FeaturesDirective', () => { + let enabledFixture: ComponentFixture; + let disabledFixture: ComponentFixture; + + 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(); + }); +}); diff --git a/lib/core/feature-flags/src/lib/directives/features.directive.ts b/lib/core/feature-flags/src/lib/directives/features.directive.ts new file mode 100644 index 0000000000..9debddbc02 --- /dev/null +++ b/lib/core/feature-flags/src/lib/directives/features.directive.ts @@ -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, + 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(); + } +} diff --git a/lib/core/feature-flags/src/lib/directives/not-features.directive.spec.ts b/lib/core/feature-flags/src/lib/directives/not-features.directive.spec.ts new file mode 100644 index 0000000000..f8df2d633d --- /dev/null +++ b/lib/core/feature-flags/src/lib/directives/not-features.directive.spec.ts @@ -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: ` +
+
+
+ ` +}) +class TestWithEnabledFlagComponent { + features = ['feature1', 'feature3']; +} + +@Component({ + template: ` +
+
+
+ ` +}) +class TestWithDisabledFlagComponent { + features = 'feature2'; +} + +describe('NotFeaturesDirective', () => { + let enabledFixture: ComponentFixture; + let disabledFixture: ComponentFixture; + + 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(); + }); +}); diff --git a/lib/core/feature-flags/src/lib/directives/not-features.directive.ts b/lib/core/feature-flags/src/lib/directives/not-features.directive.ts new file mode 100644 index 0000000000..8dc0f1fbc9 --- /dev/null +++ b/lib/core/feature-flags/src/lib/directives/not-features.directive.ts @@ -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, + 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(); + } +} diff --git a/lib/core/feature-flags/src/lib/guards/is-feature-off.guard.ts b/lib/core/feature-flags/src/lib/guards/is-feature-off.guard.ts new file mode 100644 index 0000000000..4375c4827e --- /dev/null +++ b/lib/core/feature-flags/src/lib/guards/is-feature-off.guard.ts @@ -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 { + return this.featuresServiceToken.isOff$(route?.data?.['feature']); + } +} diff --git a/lib/core/feature-flags/src/lib/guards/is-feature-on.guard.ts b/lib/core/feature-flags/src/lib/guards/is-feature-on.guard.ts new file mode 100644 index 0000000000..29ebd81aea --- /dev/null +++ b/lib/core/feature-flags/src/lib/guards/is-feature-on.guard.ts @@ -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 { + return this.featuresServiceToken.isOn$(route?.data?.['feature']); + } +} diff --git a/lib/core/feature-flags/src/lib/guards/is-flags-override-on.guard.ts b/lib/core/feature-flags/src/lib/guards/is-flags-override-on.guard.ts new file mode 100644 index 0000000000..4f3eb074b8 --- /dev/null +++ b/lib/core/feature-flags/src/lib/guards/is-flags-override-on.guard.ts @@ -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; + } +} diff --git a/lib/core/feature-flags/src/lib/interfaces/features.interface.ts b/lib/core/feature-flags/src/lib/interfaces/features.interface.ts new file mode 100644 index 0000000000..2a57bbdcd1 --- /dev/null +++ b/lib/core/feature-flags/src/lib/interfaces/features.interface.ts @@ -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('FeatureServiceConfigToken'); +export const FeaturesServiceToken = new InjectionToken('FeaturesService'); +export const WritableFeaturesServiceToken = new InjectionToken('WritableFeaturesServiceToken'); +export const WritableFeaturesServiceConfigToken = new InjectionToken('WritableFeaturesServiceConfigToken'); +export const OverridableFeaturesServiceToken = new InjectionToken('OverridableFeaturesServiceToken'); +export const FlagsOverrideToken = new InjectionToken('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 { + init(): Observable; + isOn$(key: string): Observable; + isOff$(key: string): Observable; + getFlags$(): Observable; +} + +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, 'init'> & { + enable(on: boolean): void; + isEnabled$(): Observable; + resetFlags(flags: FlagSet): void; +}; diff --git a/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts new file mode 100644 index 0000000000..adb7147cbb --- /dev/null +++ b/lib/core/feature-flags/src/lib/mocks/features-service-mock.factory.ts @@ -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) + }; +}; diff --git a/lib/core/feature-flags/src/lib/providers/debug-feature-flags.provider.ts b/lib/core/feature-flags/src/lib/providers/debug-feature-flags.provider.ts new file mode 100644 index 0000000000..2e93bcf8a4 --- /dev/null +++ b/lib/core/feature-flags/src/lib/providers/debug-feature-flags.provider.ts @@ -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 + } + ]; +} diff --git a/lib/core/feature-flags/src/lib/providers/dummy-feature-flags.provider.ts b/lib/core/feature-flags/src/lib/providers/dummy-feature-flags.provider.ts new file mode 100644 index 0000000000..ed33353143 --- /dev/null +++ b/lib/core/feature-flags/src/lib/providers/dummy-feature-flags.provider.ts @@ -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 + ]; +} diff --git a/lib/core/feature-flags/src/lib/services/debug-features.service.spec.ts b/lib/core/feature-flags/src/lib/services/debug-features.service.spec.ts new file mode 100644 index 0000000000..ec75082ea3 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/debug-features.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/lib/core/feature-flags/src/lib/services/debug-features.service.ts b/lib/core/feature-flags/src/lib/services/debug-features.service.ts new file mode 100644 index 0000000000..8c69c9975f --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/debug-features.service.ts @@ -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; + private isInDebugMode$: Observable; + + 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(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 { + return this.isInDebugMode$.pipe( + switchMap((isInDebugMode) => (isInDebugMode ? this.writableFeaturesService : this.overriddenFeaturesService).isOn$(key)) + ); + } + + isOff$(key: string): Observable { + 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 { + 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 { + return this.isInDebugMode$; + } +} diff --git a/lib/core/feature-flags/src/lib/services/dummy-features.service.spec.ts b/lib/core/feature-flags/src/lib/services/dummy-features.service.spec.ts new file mode 100644 index 0000000000..1304d519e2 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/dummy-features.service.spec.ts @@ -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({}); + }); + }); +}); diff --git a/lib/core/feature-flags/src/lib/services/dummy-features.service.ts b/lib/core/feature-flags/src/lib/services/dummy-features.service.ts new file mode 100644 index 0000000000..63d2622f17 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/dummy-features.service.ts @@ -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 { + return of(); + } + + isOn$(): Observable { + return of(false); + } + + isOff$(_key: string): Observable { + return of(true); + } + + getFlags$(): Observable { + return of({}); + } +} diff --git a/lib/core/feature-flags/src/lib/services/flagset.parser.spec.ts b/lib/core/feature-flags/src/lib/services/flagset.parser.spec.ts new file mode 100644 index 0000000000..5088264277 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/flagset.parser.spec.ts @@ -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({}); + }); + }); +}); diff --git a/lib/core/feature-flags/src/lib/services/flagset.parser.ts b/lib/core/feature-flags/src/lib/services/flagset.parser.ts new file mode 100644 index 0000000000..c98cf9045b --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/flagset.parser.ts @@ -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 } : {}) + } + }), + {} + ); + } +} diff --git a/lib/core/feature-flags/src/lib/services/qa-features.helper.ts b/lib/core/feature-flags/src/lib/services/qa-features.helper.ts new file mode 100644 index 0000000000..6ec9f9fbb9 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/qa-features.helper.ts @@ -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; + } +} diff --git a/lib/core/feature-flags/src/lib/services/storage-features.service.spec.ts b/lib/core/feature-flags/src/lib/services/storage-features.service.spec.ts new file mode 100644 index 0000000000..62085a23ca --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/storage-features.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/lib/core/feature-flags/src/lib/services/storage-features.service.ts b/lib/core/feature-flags/src/lib/services/storage-features.service.ts new file mode 100644 index 0000000000..809da22d94 --- /dev/null +++ b/lib/core/feature-flags/src/lib/services/storage-features.service.ts @@ -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({}); + 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 { + 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 { + return this.flags$.pipe(map((flags) => !!flags[key]?.current)); + } + + isOff$(key: string): Observable { + return this.flags$.pipe(map((flags) => !flags[key]?.current)); + } + + getFlags$(): Observable { + 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); + } +} diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 50ca7aa899..8a49799e52 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -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": { diff --git a/lib/core/tsconfig.lib.json b/lib/core/tsconfig.lib.json index 2b9d37bfa8..66265d2a8e 100644 --- a/lib/core/tsconfig.lib.json +++ b/lib/core/tsconfig.lib.json @@ -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/*"] } diff --git a/tsconfig.json b/tsconfig.json index b3a835d5a1..d8ecf9dbb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],