Skip to content

Commit

Permalink
Feature/feature toggle (#686)
Browse files Browse the repository at this point in the history
* Feature flags: add service

* Feature flags: adapt navigation side menu

* New page "Search with AQL" (#624)

* New page "Search with AQL"

* Improve "Search with AQL" page

* add feature toggle for data explorer for the manager role

* search-with-aql to search-by-manager feature name

* testing

* test fixes

* test fixes

* add:
 FeatureService,
 HttpClient,
 HttpHandler,

* test datafilter fix

* FeatureActivatedDirective not standalone

---------

Co-authored-by: Andreas Kling <[email protected]>
  • Loading branch information
ramueSVA and askask authored Nov 25, 2024
1 parent 5dda392 commit bc9d5dc
Show file tree
Hide file tree
Showing 30 changed files with 469 additions and 57 deletions.
13 changes: 13 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ export const routes: Routes = [
(m) => m.SearchModule
),
},
{
path: 'search-with-aql',
canLoad: [RoleGuard, AuthGuard],
data: {
navId: 'search-with-aql',
roles: [AvailableRoles.Manager],
onlyApprovedUsers: true,
},
loadChildren: () =>
import(
/* webpackChunkName: "SearchWithAql.Module" */ './modules/search-with-aql/search-with-aql.module'
).then((m) => m.SearchWithAqlModule),
},
{
path: 'projects',
canLoad: [RoleGuard, AuthGuard],
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const TIME_TO_WAIT_IDLE = 3600
export class AuthService {
private userInfo: IAuthUserInfo = { sub: undefined }
private userInfoSubject$ = new BehaviorSubject(this.userInfo)
public userInfoObservable$ = this.userInfoSubject$.asObservable()
public userInfoObservable$: Observable<IAuthUserInfo> = this.userInfoSubject$.asObservable()
public timedOut = false
public lastPing?: Date = null

Expand Down
8 changes: 8 additions & 0 deletions src/app/core/constants/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { AvailableRoles } from 'src/app/shared/models/available-roles.enum'
import INavItem from '../../layout/models/nav-item.interface'
import { USERMANUAL } from './constants'
import { AvailableFeatures } from '../../shared/models/feature/available-features.enum'

export const mainNavItems: INavItem[] = [
{
routeTo: 'home',
icon: 'num-welcome',
translationKey: 'NAVIGATION.DASHBOARD',
},
{
routeTo: 'search-with-aql',
icon: 'search',
translationKey: 'NAVIGATION.SEARCH_WITH_AQL',
feature: [AvailableFeatures.SearchByManager],
},
{
routeTo: 'search',
icon: 'search',
Expand All @@ -31,6 +38,7 @@ export const mainNavItems: INavItem[] = [
translationKey: 'NAVIGATION.DATA_RETRIEVAL',
roles: [AvailableRoles.Manager],
disabled: true,
feature: [AvailableFeatures.SearchByManager],
},
],
},
Expand Down
47 changes: 47 additions & 0 deletions src/app/core/services/feature/feature.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable, throwError } from 'rxjs'
import { catchError, map, shareReplay } from 'rxjs/operators'
import { AppConfigService } from 'src/app/config/app-config.service'
import { IFeature } from '../../../shared/models/feature/feature.interface'
import { AvailableFeatures } from '../../../shared/models/feature/available-features.enum'

@Injectable({
providedIn: 'root',
})
export class FeatureService {
private baseUrl: string

private features: Observable<AvailableFeatures[]>

constructor(
private appConfigService: AppConfigService,
private httpClient: HttpClient
) {
this.baseUrl = `${this.appConfigService.config.api.baseUrl}`
this.createObservable()
}

getFeature(): Observable<AvailableFeatures[]> {
return this.features
}

createObservable(): void {
this.features = this.httpClient.get<IFeature>(`${this.baseUrl}/feature`).pipe(
catchError(this.handleError),
map((res) => {
return Object.values(AvailableFeatures)
.filter((k) => isNaN(Number(k)))
.map((value, index) =>
res[value[0].toLowerCase() + value.toString().substring(1)] ? index : null
)
.filter((value) => value != null)
}),
shareReplay(1)
)
}

handleError(error: HttpErrorResponse): Observable<never> {
return throwError(() => error)
}
}
46 changes: 46 additions & 0 deletions src/app/core/services/query/query.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable, throwError } from 'rxjs'
import { catchError, tap } from 'rxjs/operators'
import { AppConfigService } from 'src/app/config/app-config.service'
import { IAqlExecutionResponse } from 'src/app/shared/models/aql/execution/aql-execution-response.interface'

@Injectable({
providedIn: 'root',
})
export class QueryService {
private baseUrl: string

private projectData: IAqlExecutionResponse = null
private projectDataSubject$ = new BehaviorSubject(this.projectData)
private query: string

constructor(
private appConfigService: AppConfigService,
private httpClient: HttpClient
) {
this.baseUrl = `${this.appConfigService.config.api.baseUrl}`
}

setQuery(query: string) {
this.query = query
}

getData(): Observable<IAqlExecutionResponse> {
return this.httpClient
.post<IAqlExecutionResponse>(`${this.baseUrl}/query/execute`, {
aql: this.query,
})
.pipe(
tap((res) => {
this.projectData = res
this.projectDataSubject$.next(res)
}),
catchError(this.handleError)
)
}

handleError(error: HttpErrorResponse): Observable<never> {
return throwError(() => error)
}
}
28 changes: 15 additions & 13 deletions src/app/layout/components/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,21 @@ <h1 data-test="header__page-headline">
</h1>
<nav mat-tab-nav-bar *ngIf="currentTabNav && !unapprovedUser" [tabPanel]="tabPanel">
<ng-container *ngFor="let tab of currentTabNav">
<span
class="no-focus"
mat-tab-link
[routerLink]="tab.routeTo"
routerLinkActive
#rla="routerLinkActive"
[routerLinkActiveOptions]="{ exact: true }"
[active]="tab.id === currentTabNavSelected"
[disabled]="tab.id === currentTabNavSelected || tab.disabled === true"
[attr.data-test]="'header__tab-nav__' + tab.translationKey"
*numUserHasRole="tab.roles"
>
{{ tab.translationKey | translate }}
<span *featureIsActive="tab.feature">
<span
class="no-focus"
mat-tab-link
[routerLink]="tab.routeTo"
routerLinkActive
#rla="routerLinkActive"
[routerLinkActiveOptions]="{ exact: true }"
[active]="tab.id === currentTabNavSelected"
[disabled]="tab.id === currentTabNavSelected || tab.disabled === true"
[attr.data-test]="'header__tab-nav__' + tab.translationKey"
*numUserHasRole="tab.roles"
>
{{ tab.translationKey | translate }}
</span>
</span>
</ng-container>
</nav>
Expand Down
20 changes: 14 additions & 6 deletions src/app/layout/components/header/header.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ActivatedRouteSnapshot, ActivationEnd, ActivationStart, Router } from '
import { RouterTestingModule } from '@angular/router/testing'
import { FontAwesomeTestingModule } from '@fortawesome/angular-fontawesome/testing'
import { TranslateModule } from '@ngx-translate/core'
import { Subject } from 'rxjs'
import { MaterialModule } from '../../material/material.module'
import INavItem from '../../models/nav-item.interface'
import { ButtonComponent } from '../../../shared/components/button/button.component'
Expand All @@ -19,6 +18,12 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'
import { MatTabLinkHarness } from '@angular/material/tabs/testing'
import { UserHasRoleDirective } from 'src/app/shared/directives/user-has-role.directive'
import { AppConfigService } from 'src/app/config/app-config.service'
import { AvailableFeatures } from '../../../shared/models/feature/available-features.enum'
import { FeatureIsActiveDirective } from '../../../shared/directives/feature-is-active.directive'
import { FeatureService } from '../../../core/services/feature/feature.service'
import { HttpClient, HttpHandler } from '@angular/common/http'
import spyOn = jest.spyOn
import { of, Subject } from 'rxjs'

describe('HeaderComponent', () => {
let component: HeaderComponent
Expand Down Expand Up @@ -88,6 +93,7 @@ describe('HeaderComponent', () => {
routeTo: 'restrictedToManager',
translationKey: 'restrictedToManager',
roles: [AvailableRoles.Manager],
feature: [AvailableFeatures.SearchByManager],
},
],
}
Expand All @@ -99,15 +105,13 @@ describe('HeaderComponent', () => {
homeNavItem,
navItemsWithRestrictedTabs,
]

const mockUserInfoSubject = new Subject<IAuthUserInfo>()
const mockAuthService = {
get isLoggedIn(): boolean {
return true
},
userInfoObservable$: mockUserInfoSubject.asObservable(),
} as unknown as AuthService

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
Expand All @@ -116,6 +120,7 @@ describe('HeaderComponent', () => {
StubComponent,
ButtonComponent,
UserHasRoleDirective,
FeatureIsActiveDirective,
],
imports: [
FontAwesomeTestingModule,
Expand Down Expand Up @@ -144,6 +149,9 @@ describe('HeaderComponent', () => {
provide: AppConfigService,
useValue: mockConfigService,
},
FeatureService,
HttpClient,
HttpHandler,
],
}).compileComponents()
})
Expand Down Expand Up @@ -268,21 +276,21 @@ describe('HeaderComponent', () => {
sub: 'test-sub-2',
groups: [AvailableRoles.Researcher],
}
const mockFeature: AvailableFeatures[] = [AvailableFeatures.SearchByManager]
beforeEach(() => {
const featureService = TestBed.inject(FeatureService)
spyOn(featureService, 'getFeature').mockReturnValue(of(mockFeature))
routerEventsSubject.next(routerEvent)
fixture.detectChanges()
harnessLoader = TestbedHarnessEnvironment.loader(fixture)
})
it('should show all tabs to user with required roles', async () => {
mockUserInfoSubject.next(mockManagerInfo)
fixture.detectChanges()
const tabLinks = await harnessLoader.getAllHarnesses(MatTabLinkHarness)
expect(tabLinks.length).toBe(navItemsWithRestrictedTabs.tabNav.length)
})

it('should restrict tabs be only visible to allowed roles', async () => {
mockUserInfoSubject.next(mockResearcherInfo)
fixture.detectChanges()
const tabLinks = await harnessLoader.getAllHarnesses(MatTabLinkHarness)
expect(tabLinks.length).toBe(navItemsWithRestrictedTabs.tabNav.length - 1)
})
Expand Down
3 changes: 3 additions & 0 deletions src/app/layout/components/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import INavItem from '../../models/nav-item.interface'
import { mainNavItems, secondaryNavItemsLoggedIn } from '../../../core/constants/navigation'
import { AppConfigService } from 'src/app/config/app-config.service'
import { TranslateService } from '@ngx-translate/core'
import { AvailableFeatures } from '../../../shared/models/feature/available-features.enum'

@Component({
selector: 'num-header',
Expand Down Expand Up @@ -74,4 +75,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.currentNavItem = navItem
this.currentTabNav = navItem?.tabNav
}

protected readonly AvailableFeatures = AvailableFeatures
}
26 changes: 14 additions & 12 deletions src/app/layout/components/side-menu/side-menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@
</a>

<ng-template ngFor let-item [ngForOf]="mainNavItems">
<mat-list-item
*numUserHasRole="item.roles"
mat-list-item
class="num-mat-list-item"
(click)="menuItemClicked($event, item)"
[routerLinkActive]="'num-mat-list-item--active'"
[routerLink]="item.routeTo"
[attr.data-test]="'side-menu__main-nav__' + item.translationKey"
>
<fa-icon size="lg" [fixedWidth]="true" [icon]="item.icon"></fa-icon>
<span>{{ item.translationKey | translate }}</span>
</mat-list-item>
<ng-container *featureIsActive="item.feature">
<mat-list-item
*numUserHasRole="item.roles"
mat-list-item
class="num-mat-list-item"
(click)="menuItemClicked($event, item)"
[routerLinkActive]="'num-mat-list-item--active'"
[routerLink]="item.routeTo"
[attr.data-test]="'side-menu__main-nav__' + item.translationKey"
>
<fa-icon size="lg" [fixedWidth]="true" [icon]="item.icon"></fa-icon>
<span>{{ item.translationKey | translate }}</span>
</mat-list-item>
</ng-container>
</ng-template>
<ng-template ngFor let-item [ngForOf]="mainNavItemsExternal">
<a
Expand Down
1 change: 0 additions & 1 deletion src/app/layout/layout.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { TranslateModule } from '@ngx-translate/core'
import { DirectivesModule } from '../shared/directives/directives.module'
import { SharedComponentsModule } from '../shared/components/shared-components.module'
import { CUSTOM_ICONS } from './custom-icons'

const SHARED_MODULES = [MaterialModule, FlexLayoutModule, FontAwesomeModule]

@NgModule({
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout/models/nav-item.interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AvailableRoles } from 'src/app/shared/models/available-roles.enum'
import { AvailableFeatures } from '../../shared/models/feature/available-features.enum'

export default interface INavItem {
routeTo?: string
Expand All @@ -10,4 +11,5 @@ export default interface INavItem {
disabled?: boolean
isExternal?: boolean
highlighted?: boolean
feature?: AvailableFeatures[]
}
1 change: 1 addition & 0 deletions src/app/modules/aqls/aqls.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ import { AqlBuilderWhereGroupComponent } from './components/aql-builder-where-gr
AqlBuilderWhereGroupComponent,
],
imports: [CommonModule, AqlsRoutingModule, SharedModule, LayoutModule, CodeEditorModule],
exports: [AqlEditorCeatorComponent],
})
export class AqlsModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class AqlEditorCeatorComponent {
private dialogService: DialogService,
private aqlEditorService: AqlEditorService,
private aqlService: AqlService,
private toastMessageService: ToastMessageService
public toastMessageService: ToastMessageService
) {}
formatter = new NumAqlFormattingProvider()
formatSubject$ = new Subject<monaco.editor.IMarkerData[] | void>()
Expand Down
Loading

0 comments on commit bc9d5dc

Please sign in to comment.