AAE-36660 Update flags component to use signals

This commit is contained in:
Wojciech Duda
2025-11-03 14:57:30 +01:00
parent ac9175ae7a
commit 4ad40afc08
4 changed files with 149 additions and 129 deletions

View File

@@ -1,66 +1,68 @@
<mat-toolbar class="adf-feature-flags-overrides-header">
<div class="adf-feature-flags-overrides-header-text" tabindex="0">
<adf-feature-flags-override-indicator
class="adf-activity-indicator"
size='large' />
<span>{{ "CORE.FEATURE-FLAGS.OVERRIDES" | translate }}</span>
</div>
<mat-slide-toggle
[checked]="isEnabled"
(change)="onEnable($event.checked)" />
<button class="adf-feature-flags-overrides-header-close" mat-icon-button mat-dialog-close>
<mat-icon adf-icon="close" />
</button>
<div class="adf-feature-flags-overrides-header-text" tabindex="0">
<adf-feature-flags-override-indicator class="adf-activity-indicator" size='large' />
<span>{{ "CORE.FEATURE-FLAGS.OVERRIDES" | translate }}</span>
</div>
<mat-slide-toggle [checked]="isEnabled" (change)="onEnable($event.checked)" />
<button class="adf-feature-flags-overrides-header-close" mat-icon-button mat-dialog-close>
<mat-icon adf-icon="close" />
</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 adf-search-icon" adf-icon="search" />
</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" adf-icon="memory" />
<mat-icon class="material-icons-outlined adf-trash-icon" adf-icon="delete" />
</button>
</td>
</ng-container>
<ng-container *ngIf="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 adf-search-icon" adf-icon="search" />
</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"
adf-icon="memory" />
<mat-icon class="material-icons-outlined adf-trash-icon" adf-icon="delete" />
</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="fill" 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="flag">
<th mat-header-cell class="flag-col header-cell" *matHeaderCellDef>
<mat-form-field class="adf-flag-form-field" appearance="fill" 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 title="{{'CORE.FEATURE-FLAGS.ADD_NEW' | translate}}" color="accent" (click)="onAddButtonClick()">
<mat-icon class="material-icons-outlined" adf-icon="add_circle" />
</button>
<button *ngIf="inputValue" matSuffix mat-icon-button aria-label="Clear" (click)="onClearInput()" class="adf-clear-button">
<mat-icon adf-icon="cancel" />
</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" />
</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()" mat-icon-button
title="{{'CORE.FEATURE-FLAGS.ADD_NEW' | translate}}" color="accent"
(click)="onAddButtonClick()">
<mat-icon class="material-icons-outlined" adf-icon="add_circle" />
</button>
<button *ngIf="inputValue" matSuffix mat-icon-button aria-label="Clear"
(click)="onClearInput()" class="adf-clear-button">
<mat-icon adf-icon="cancel" />
</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" />
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<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" adf-icon="cloud" />
<mat-icon class="material-icons-outlined" adf-icon="cloud" />
</ng-template>

View File

@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { FlagsComponent } from './flags.component';
import { FeaturesDirective } from '../../directives/features.directive';
import { WritableFeaturesServiceToken } from '../../interfaces/features.interface';
@@ -41,23 +41,25 @@ describe('FlagsComponent', () => {
const storageFeaturesService = TestBed.inject(WritableFeaturesServiceToken);
storageFeaturesService.init();
});
it('should initialize flags signal', fakeAsync(() => {
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();
});
});
tick(100);
const flags = component.flags();
expect(flags).toEqual([
{ fictive: false, flag: 'feature1', value: true },
{ fictive: false, flag: 'feature2', value: false },
{ fictive: false, flag: 'feature3', value: true }
]);
}));
it('should update inputValue$ when onInputChange is called', (done) => {
fixture = TestBed.createComponent(FlagsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.onInputChange('test');
component.inputValue$.subscribe((value) => {
expect(value).toBe('test');
@@ -66,18 +68,23 @@ describe('FlagsComponent', () => {
});
it('should clear inputValue when onClearInput is called', () => {
fixture = TestBed.createComponent(FlagsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.inputValue = 'test';
component.onClearInput();
expect(component.inputValue).toBe('');
});
it('should filter flags when when onClearInput is called', (done) => {
it('should filter flags when input value changes', fakeAsync(() => {
fixture = TestBed.createComponent(FlagsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.onInputChange('feature1');
component.flags$.subscribe((flags) => {
expect(flags).toEqual([{ fictive: false, flag: 'feature1', value: true }]);
done();
});
});
tick(150);
const flags = component.flags();
expect(flags).toEqual([{ fictive: false, flag: 'feature1', value: true }]);
}));
afterEach(() => {
fixture.destroy();

View File

@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, signal, Signal, ViewEncapsulation, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FeaturesServiceToken,
@@ -24,8 +24,8 @@ import {
WritableFeaturesServiceToken,
WritableFlagChangeset
} from '../../interfaces/features.interface';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { debounceTime, map, take, tap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, map, tap } from 'rxjs/operators';
import { MatTableModule } from '@angular/material/table';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatToolbarModule } from '@angular/material/toolbar';
@@ -35,7 +35,7 @@ import { FormsModule } from '@angular/forms';
import { FlagsOverrideComponent } from '../feature-override-indicator.component';
import { MatDialogModule } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { toSignal } from '@angular/core/rxjs-interop';
import { IconModule } from '@alfresco/adf-core';
@Component({
@@ -63,22 +63,20 @@ export class FlagsComponent {
private readonly writableFeaturesService = inject<IWritableFeaturesService>(WritableFeaturesServiceToken);
displayedColumns: string[] = ['icon', 'flag', 'value'];
flags$: Observable<{ fictive: boolean; flag: string; value: any }[]>;
isEnabled = false;
flags: Signal<{ fictive: boolean; flag: string; value: any }[]>;
isEnabled: Signal<boolean>;
inputValue = '';
inputValue$ = new BehaviorSubject<string>('');
showPlusButton$!: Observable<boolean>;
showPlusButton: Signal<boolean>;
writableFlagChangeset: WritableFlagChangeset = {};
constructor() {
if (this.featuresService.isEnabled$) {
this.featuresService
.isEnabled$()
.pipe(takeUntilDestroyed())
.subscribe((isEnabled) => {
this.isEnabled = isEnabled;
});
}
constructor(
@Inject(FeaturesServiceToken)
private featuresService: IDebugFeaturesService & IFeaturesService<WritableFlagChangeset>,
@Inject(WritableFeaturesServiceToken)
private writableFeaturesService: IWritableFeaturesService
) {
this.isEnabled = this.featuresService.isEnabled$() ? toSignal(this.featuresService.isEnabled$()) : signal(false);
const flags$ = this.featuresService.getFlags$().pipe(
tap((flags) => (this.writableFlagChangeset = flags)),
@@ -93,19 +91,20 @@ export class FlagsComponent {
const debouncedInputValue$ = this.inputValue$.pipe(debounceTime(100));
this.flags$ = combineLatest([flags$, debouncedInputValue$]).pipe(
map(([flags, inputValue]) => {
if (!inputValue) {
return flags;
}
this.flags = toSignal(
combineLatest([flags$, debouncedInputValue$]).pipe(
map(([flags, inputValue]) => {
if (!inputValue) {
return flags;
}
return flags.filter((flag) => flag.flag.includes(inputValue));
})
return flags.filter((flag) => flag.flag.includes(inputValue));
})
),
{ initialValue: [] }
);
this.showPlusButton$ = this.flags$.pipe(
map((filteredFlags) => this.isEnabled && filteredFlags.length === 0 && this.inputValue.trim().length > 0)
);
this.showPlusButton = computed(() => this.isEnabled() && this.flags()?.length === 0 && this.inputValue.trim().length > 0);
}
protected onChange(flag: string, value: boolean) {
@@ -130,11 +129,9 @@ export class FlagsComponent {
}
protected onAdd(event: KeyboardEvent) {
this.showPlusButton$.pipe(take(1)).subscribe((showPlusButton) => {
if (showPlusButton && event.key === 'Enter' && event.shiftKey) {
this.writableFeaturesService.setFlag(this.inputValue, false);
}
});
if (this.showPlusButton() && event.key === 'Enter' && event.shiftKey) {
this.writableFeaturesService.setFlag(this.inputValue, false);
}
}
protected onAddButtonClick() {

View File

@@ -15,8 +15,14 @@
* limitations under the License.
*/
import { of } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
import { BehaviorSubject, of } from 'rxjs';
import {
FeaturesServiceToken,
FlagChangeset,
IFeaturesService,
IDebugFeaturesService,
WritableFlagChangeset
} from '../interfaces/features.interface';
import { signal } from '@angular/core';
export interface MockFeatureFlags {
@@ -33,27 +39,35 @@ const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
}
};
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({
init: () => of(flagChangeset),
isOn: (key) => {
assertFeatureFlag(flagChangeset, key);
return signal(flagChangeset[key].current);
},
isOff: (key) => {
assertFeatureFlag(flagChangeset, key);
return signal(!flagChangeset[key].current);
},
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
},
getFlags: () => signal(flagChangeset),
getFlags$: () => of(flagChangeset)
});
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService & Partial<IDebugFeaturesService> => {
const isEnabled$ = new BehaviorSubject<boolean>(false);
const flags$ = new BehaviorSubject<WritableFlagChangeset>(flagChangeset as WritableFlagChangeset);
return {
init: () => of(flagChangeset),
isOn: (key) => {
assertFeatureFlag(flagChangeset, key);
return signal(flagChangeset[key].current);
},
isOff: (key) => {
assertFeatureFlag(flagChangeset, key);
return signal(!flagChangeset[key].current);
},
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
},
getFlags: () => signal(flagChangeset as WritableFlagChangeset),
getFlags$: () => flags$.asObservable(),
isEnabled$: () => isEnabled$.asObservable(),
enable: (on: boolean) => isEnabled$.next(on),
resetFlags: () => {}
};
};
const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
const flagChangeset: FlagChangeset = {};