diff --git a/README.md b/README.md index f35dd32dc..99859696f 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ Run `npm install` to install the required node_modules for this project. Run `ng serve` to run the app in dev mode. After executing this command, you can navigate to `http://localhost:4200/` to see the app working. The app will automatically reload if you change anything in the source files. -## Prepare your environment +# Prepare your environment ### Set environment variables -Create a file keys.ts in the path `src/enviroment` with the content pinned in our slack channel: +**1**. Create a file keys.ts in the path `src/enviroment` with the content pinned in our slack channel #time-tracker-developer: ``` export const AUTHORITY = 'XXX'; @@ -44,7 +44,14 @@ export const STACK_EXCHANGE_ID = 'XXX'; export const STACK_EXCHANGE_ACCESS_TOKEN = 'XXX'; export const AZURE_APP_CONFIGURATION_CONNECTION_STRING = 'XXX'; ``` - +**2**. Create a second file `.keys.json` with the content pinned in the slack channel #time-tracker-developer: +``` +{ + "authority": 'XXX', + "client_id": 'XXX', + "scopes": ["XXX"] +} +``` ### Prepare your environment for vscode Install the following extensions: @@ -82,6 +89,12 @@ Install the following extensions: | `feat(pencil): add 'graphiteWidth' option` | ~~Minor~~ Feature Release | | `perf(pencil): remove graphiteWidth option`

`BREAKING CHANGE: The graphiteWidth option has been removed.`
`The default graphite width of 10mm is always used for performance reasons.` | ~~Major~~ Breaking Release | +### Branch names format +For example if your task in Jira is **TT-48 implement semantic versioning** your branch name is: +``` + TT-48-implement-semantic-versioning +``` + ## Code scaffolding Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. @@ -127,4 +140,7 @@ To get more help on the Angular CLI use `ng help` or go check out the [Angular C ## Feature Toggles dictionary Shared file with all the Feature Toggles we create, so we can have a history of them -[Feature Toggles dictionary](https://github.com/ioet/time-tracker-ui/wiki/Feature-Toggles-dictionary) \ No newline at end of file +[Feature Toggles dictionary](https://github.com/ioet/time-tracker-ui/wiki/Feature-Toggles-dictionary) + +## More information about the project +[Starting in Time Tracker](https://github.com/ioet/time-tracker-ui/wiki/Time-tracker) \ No newline at end of file diff --git a/src/app/modules/users/components/users-list/users-list.component.html b/src/app/modules/users/components/users-list/users-list.component.html index bab11ae27..498d5e0dd 100644 --- a/src/app/modules/users/components/users-list/users-list.component.html +++ b/src/app/modules/users/components/users-list/users-list.component.html @@ -10,7 +10,7 @@ User Email Names - Roles + {{ isUserGroupsToggleOn ? 'Groups' : 'Roles' }} @@ -19,7 +19,22 @@ {{ user.email }} {{ user.name }} -
+
+ + admin + + test +
+ +
{ let component: UsersListComponent; let fixture: ComponentFixture; let store: MockStore; const actionSub: ActionsSubject = new ActionsSubject(); + const fakeAppConfigurationConnectionString = 'Endpoint=http://fake.foo;Id=fake.id;Secret=fake.secret'; + let service: FeatureManagerService; + let fakeFeatureToggleProvider; const state: UserState = { data: [ @@ -19,6 +36,7 @@ describe('UsersListComponent', () => { name: 'name', email: 'email', roles: ['admin', 'test'], + groups: ['time-tracker-admin', 'time-tracker-tester'], id: 'id', tenant_id: 'tenant id', deleted: 'delete', @@ -30,10 +48,20 @@ describe('UsersListComponent', () => { beforeEach( waitForAsync(() => { + fakeFeatureToggleProvider = new FeatureToggleProvider( + new AppConfigurationClient(fakeAppConfigurationConnectionString), + new FeatureFilterProvider(new AzureAdB2CService()) + ); + service = new FeatureManagerService(fakeFeatureToggleProvider); + TestBed.configureTestingModule({ imports: [NgxPaginationModule, DataTablesModule], declarations: [UsersListComponent], - providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }], + providers: [ + provideMockStore({ initialState: state }), + { provide: ActionsSubject, useValue: actionSub }, + { provide: FeatureManagerService, useValue: service } + ], }).compileComponents(); }) ); @@ -91,6 +119,27 @@ describe('UsersListComponent', () => { }); }); + const actionGroupParams = [ + { actionType: UserActionTypes.ADD_USER_TO_GROUP_SUCCESS }, + { actionType: UserActionTypes.REMOVE_USER_FROM_GROUP_SUCCESS }, + ]; + + actionGroupParams.map((param) => { + it(`When action ${param.actionType} is dispatched should triggered load Users action`, () => { + spyOn(store, 'dispatch'); + + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: param.actionType, + payload: state.data, + }; + + actionSubject.next(action); + + expect(store.dispatch).toHaveBeenCalledWith(new LoadUsers()); + }); + }); + const grantRoleTypes = [ { roleId: 'admin', roleValue: 'time-tracker-admin' }, { roleId: 'test', roleValue: 'time-tracker-tester' }, @@ -111,6 +160,32 @@ describe('UsersListComponent', () => { }); }); + const AddGroupTypes = [ + { groupName: 'time-tracker-admin' }, + { groupName: 'time-tracker-tester' } + ]; + + AddGroupTypes.map((param) => { + it(`When user switchGroup to ${param.groupName} and doesn't belong to any group, should add ${param.groupName} group to user`, () => { + const groupName = param.groupName; + const user = { + name: 'name', + email: 'email', + roles: [], + groups: [], + id: 'id', + tenant_id: 'tenant id', + deleted: 'delete', + } ; + + spyOn(store, 'dispatch'); + + component.switchGroup(groupName, user); + + expect(store.dispatch).toHaveBeenCalledWith(new AddUserToGroup(user.id, groupName)); + }); + }); + const revokeRoleTypes = [ { roleId: 'admin', roleValue: 'time-tracker-admin', userRoles: ['time-tracker-admin'] }, { roleId: 'test', roleValue: 'time-tracker-tester', userRoles: ['time-tracker-tester'] }, @@ -131,6 +206,33 @@ describe('UsersListComponent', () => { }); }); + const removeGroupTypes = [ + { groupName: 'time-tracker-admin', userGroups: ['time-tracker-admin'] }, + { groupName: 'time-tracker-tester', userGroups: ['time-tracker-tester'] }, + ]; + + removeGroupTypes.map((param) => { + it(`When user switchGroup to ${param.groupName} and belongs to group, should remove ${param.groupName} group from user`, () => { + const groupName = param.groupName; + const user = { + name: 'name', + email: 'email', + roles: [], + groups: param.userGroups, + id: 'id', + tenant_id: 'tenant id', + deleted: 'delete', + } ; + + + spyOn(store, 'dispatch'); + + component.switchGroup(groupName, user); + + expect(store.dispatch).toHaveBeenCalledWith(new RemoveUserFromGroup(user.id, groupName)); + }); + }); + it('on success load users, the data of roles should be an array', () => { const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; const action = { @@ -145,6 +247,20 @@ describe('UsersListComponent', () => { }); }); + it('on success load users, the data of groups should be an array', () => { + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: UserActionTypes.LOAD_USERS_SUCCESS, + payload: state.data, + }; + + actionSubject.next(action); + + component.users.map((user) => { + expect(user.groups).toEqual(['time-tracker-admin', 'time-tracker-tester']); + }); + }); + it('on success load users, the datatable should be reloaded', async () => { const actionSubject = TestBed.inject(ActionsSubject); const action = { @@ -158,6 +274,27 @@ describe('UsersListComponent', () => { expect(component.dtElement.dtInstance.then).toHaveBeenCalled(); }); + it('When Component is created, should call the feature toggle method', () => { + spyOn(component, 'isFeatureToggleActivated').and.returnValue(of(true)); + + component.ngOnInit(); + + expect(component.isFeatureToggleActivated).toHaveBeenCalled(); + expect(component.isUserGroupsToggleOn).toBe(true); + }); + + const toggleValues = [true, false]; + toggleValues.map((toggleValue) => { + it(`when FeatureToggle is ${toggleValue} should return ${toggleValue}`, () => { + spyOn(service, 'isToggleEnabledForUser').and.returnValue(of(toggleValue)); + + const isFeatureToggleActivated: Observable = component.isFeatureToggleActivated(); + + expect(service.isToggleEnabledForUser).toHaveBeenCalled(); + isFeatureToggleActivated.subscribe((value) => expect(value).toEqual(toggleValue)); + }); + }); + afterEach(() => { component.dtTrigger.unsubscribe(); component.loadUsersSubscription.unsubscribe(); diff --git a/src/app/modules/users/components/users-list/users-list.component.ts b/src/app/modules/users/components/users-list/users-list.component.ts index 8ac50f8b4..33ef09035 100644 --- a/src/app/modules/users/components/users-list/users-list.component.ts +++ b/src/app/modules/users/components/users-list/users-list.component.ts @@ -1,10 +1,18 @@ import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActionsSubject, select, Store } from '@ngrx/store'; +import { ActionsSubject, select, Store, Action } from '@ngrx/store'; import { DataTableDirective } from 'angular-datatables'; import { Observable, Subject, Subscription } from 'rxjs'; -import { delay, filter } from 'rxjs/operators'; +import { delay, filter, map } from 'rxjs/operators'; +import { FeatureManagerService } from 'src/app/modules/shared/feature-toggles/feature-toggle-manager.service'; import { User } from '../../models/users'; -import { GrantRoleUser, LoadUsers, RevokeRoleUser, UserActionTypes } from '../../store/user.actions'; +import { + GrantRoleUser, + LoadUsers, + RevokeRoleUser, + UserActionTypes, + AddUserToGroup, + RemoveUserFromGroup, +} from '../../store/user.actions'; import { getIsLoading } from '../../store/user.selectors'; @Component({ @@ -21,8 +29,15 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild(DataTableDirective, { static: false }) dtElement: DataTableDirective; dtOptions: any = {}; + switchGroupsSubscription: Subscription; + isEnableToggleSubscription: Subscription; + isUserGroupsToggleOn: boolean; - constructor(private store: Store, private actionsSubject$: ActionsSubject) { + constructor( + private store: Store, + private actionsSubject$: ActionsSubject, + private featureManagerService: FeatureManagerService + ) { this.isLoading$ = store.pipe(delay(0), select(getIsLoading)); } @@ -35,6 +50,14 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { this.rerenderDataTable(); }); + this.isEnableToggleSubscription = this.isFeatureToggleActivated().subscribe((flag) => { + this.isUserGroupsToggleOn = flag; + }); + + this.switchGroupsSubscription = this.filterUserGroup().subscribe((action) => { + this.store.dispatch(new LoadUsers()); + }); + this.switchRoleSubscription = this.actionsSubject$ .pipe( filter( @@ -55,6 +78,7 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { ngOnDestroy() { this.loadUsersSubscription.unsubscribe(); this.dtTrigger.unsubscribe(); + this.isEnableToggleSubscription.unsubscribe(); } private rerenderDataTable(): void { @@ -73,4 +97,27 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit { ? this.store.dispatch(new RevokeRoleUser(userId, roleId)) : this.store.dispatch(new GrantRoleUser(userId, roleId)); } + + switchGroup(groupName: string, user: User): void { + this.store.dispatch( + user.groups.includes(groupName) + ? new RemoveUserFromGroup(user.id, groupName) + : new AddUserToGroup(user.id, groupName) + ); + } + + isFeatureToggleActivated(): Observable { + return this.featureManagerService.isToggleEnabledForUser('switch-group') + .pipe(map((enabled: boolean) => enabled)); + } + + filterUserGroup(): Observable { + return this.actionsSubject$.pipe( + filter( + (action: Action) => + action.type === UserActionTypes.ADD_USER_TO_GROUP_SUCCESS || + action.type === UserActionTypes.REMOVE_USER_FROM_GROUP_SUCCESS + ) + ); + } }