diff --git a/README.md b/README.md
index d7e31bb05..9233a5d8f 100644
--- a/README.md
+++ b/README.md
@@ -71,11 +71,17 @@ Install the following extensions:
- **test**: Adding missing tests or correcting existing tests.
### Example
- TT-48 fix: #48 implement semantic versioning.
+ fix: TT-48 implement semantic versioning
Prefix to use in the space fix:
`(fix: |feat: |perf: |build: |ci: |docs: |refactor: |style: |test: )`
+| Commit message | Release type |
+|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|
+| `fix(pencil): stop graphite breaking when too much pressure applied` | Patch Release |
+| `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 |
+
## 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`.
@@ -116,6 +122,4 @@ You can visit the app in the following link:
## Further help
-To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
-
-## Test to run semantic release
\ No newline at end of file
+To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
\ No newline at end of file
diff --git a/angular.json b/angular.json
index 5add0a5ba..4988f5368 100644
--- a/angular.json
+++ b/angular.json
@@ -29,6 +29,7 @@
"./node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.scss",
"node_modules/ngx-toastr/toastr.css",
+ "./node_modules/ngx-ui-switch/ui-switch.component.css",
"node_modules/datatables.net-buttons-dt/css/buttons.dataTables.css"
],
"scripts": [
diff --git a/package-lock.json b/package-lock.json
index 3a586e16c..5abb2b1c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "time-tracker",
- "version": "1.31.5",
+ "version": "1.31.6",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -6077,15 +6077,6 @@
"p-try": "^2.0.0"
}
},
- "serialize-javascript": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
- "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
- "dev": true,
- "requires": {
- "randombytes": "^2.1.0"
- }
- },
"ssri": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz",
@@ -11068,6 +11059,11 @@
"tslib": "^1.10.0"
}
},
+ "ngx-ui-switch": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/ngx-ui-switch/-/ngx-ui-switch-10.0.2.tgz",
+ "integrity": "sha512-T8LhlRnjm36kElVNxkzEmpiXA82FO74FGJHpsW60U6CFXjWqlEiQAXuCA8c/qiWDxHqy36KkaxY8/cutv487RA=="
+ },
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -18536,6 +18532,15 @@
}
}
},
+ "serialize-javascript": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+ "dev": true,
+ "requires": {
+ "randombytes": "^2.1.0"
+ }
+ },
"serve-index": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz",
@@ -20010,9 +20015,9 @@
}
},
"serialize-javascript": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
- "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"dev": true,
"requires": {
"randombytes": "^2.1.0"
@@ -21245,9 +21250,9 @@
},
"dependencies": {
"serialize-javascript": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
- "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+ "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"dev": true,
"requires": {
"randombytes": "^2.1.0"
diff --git a/package.json b/package.json
index e236394fb..f5411847c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "time-tracker",
- "version": "1.31.5",
+ "version": "1.31.6",
"scripts": {
"preinstall": "npx npm-force-resolutions",
"ng": "ng",
@@ -22,13 +22,13 @@
"@angular/platform-browser": "~10.2.2",
"@angular/platform-browser-dynamic": "~10.2.2",
"@angular/router": "~10.2.2",
+ "@azure/app-configuration": "^1.1.0",
+ "@azure/identity": "^1.1.0",
"@ngrx/effects": "^10.0.1",
"@ngrx/store": "^10.0.1",
"@ngrx/store-devtools": "^10.0.1",
"@types/datatables.net-buttons": "^1.4.3",
"angular-datatables": "^9.0.2",
- "@azure/app-configuration": "^1.1.0",
- "@azure/identity": "^1.1.0",
"bootstrap": "^4.4.1",
"datatables.net": "^1.10.21",
"datatables.net-buttons": "^1.6.2",
@@ -46,6 +46,7 @@
"ngx-material-timepicker": "^5.5.3",
"ngx-pagination": "^5.0.0",
"ngx-toastr": "^12.0.1",
+ "ngx-ui-switch": "^10.0.2",
"rxjs": "~6.6.3",
"tslib": "^1.10.0",
"zone.js": "~0.10.2"
@@ -100,7 +101,7 @@
"config": {
"commit-message-validator": {
"pattern": "^(fix: TT-|feat: TT-|perf: TT-|build: TT-|ci: TT-|docs: TT-|refactor: TT-|style: TT-|test: TT-)[0-9].*",
- "errorMessage": "Your commit message needs to start with TT-number fix:, feat:, or perf: followed by any commit message, e.g. TT-43 fix: any commit message"
+ "errorMessage": "Your commit message needs to start with fix: , feat:, or perf: followed by any commit message, e.g. fix: TT-43 any commit message"
}
},
"resolutions": {
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 1da328b83..505a1d050 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -72,6 +72,7 @@ import { DialogComponent } from './modules/shared/components/dialog/dialog.compo
import { LoadingBarComponent } from './modules/shared/components/loading-bar/loading-bar.component';
import { UsersComponent } from './modules/users/pages/users.component';
import { UsersListComponent } from './modules/users/components/users-list/users-list.component';
+import { UiSwitchModule } from 'ngx-ui-switch';
import {NgxMaterialTimepickerModule} from 'ngx-material-timepicker';
// tslint:disable-next-line: max-line-length
import { TechnologyReportTableComponent } from './modules/technology-report/components/technology-report-table/technology-report-table.component';
@@ -142,6 +143,7 @@ const maskConfig: Partial = {
DataTablesModule,
AutocompleteLibModule,
NgxMaterialTimepickerModule,
+ UiSwitchModule,
StoreModule.forRoot(reducers, {
metaReducers,
}),
diff --git a/src/app/modules/time-entries/pages/time-entries.component.spec.ts b/src/app/modules/time-entries/pages/time-entries.component.spec.ts
index 9781720d0..dcd21f2bc 100644
--- a/src/app/modules/time-entries/pages/time-entries.component.spec.ts
+++ b/src/app/modules/time-entries/pages/time-entries.component.spec.ts
@@ -247,7 +247,7 @@ describe('TimeEntriesComponent', () => {
uri: 'abc',
}, shouldRestartEntry: false
};
- component.entryId = 'new-entry';
+ component.entryId = 'entry_1';
spyOn(injectedToastrService, 'error');
component.saveEntry(newEntry);
@@ -267,7 +267,7 @@ describe('TimeEntriesComponent', () => {
uri: 'abc',
}, shouldRestartEntry: false
};
- component.entryId = 'new-entry';
+ component.entryId = 'entry_1';
spyOn(injectedToastrService, 'error');
component.saveEntry(newEntry);
@@ -349,8 +349,9 @@ describe('TimeEntriesComponent', () => {
expect(component.doSave).toHaveBeenCalledWith(entryToSave);
}));
- it('when event contains should restart as true, then a restart Entry action should be triggered', () => {
+ it('when event contains should update as true, then a restart Entry action should be triggered', () => {
component.entry = { start_date: new Date(), id: '1234', technologies: [], project_name: 'time-tracker' };
+
const entryToSave = {
entry: {
id: '123',
@@ -363,12 +364,13 @@ describe('TimeEntriesComponent', () => {
}, shouldRestartEntry: true
};
+
component.entryId = '123';
spyOn(store, 'dispatch');
component.doSave(entryToSave);
- expect(store.dispatch).toHaveBeenCalledWith(new entryActions.RestartEntry(entryToSave.entry));
+ expect(store.dispatch).toHaveBeenCalledWith(new entryActions.UpdateEntry(entryToSave.entry));
});
it('should preload data of last entry when a project is selected while creating new entry ', waitForAsync(() => {
diff --git a/src/app/modules/time-entries/pages/time-entries.component.ts b/src/app/modules/time-entries/pages/time-entries.component.ts
index 3c2ef721f..2bc71fa86 100644
--- a/src/app/modules/time-entries/pages/time-entries.component.ts
+++ b/src/app/modules/time-entries/pages/time-entries.component.ts
@@ -104,17 +104,18 @@ export class TimeEntriesComponent implements OnInit, OnDestroy {
}
saveEntry(event: SaveEntryEvent): void {
- if (this.activeTimeEntry) {
+
+ if (this.activeTimeEntry && this.entryId === this.activeTimeEntry.id) {
const startDateAsLocalDate = new Date(event.entry.start_date);
const endDateAsLocalDate = new Date(event.entry.end_date);
const activeEntryAsLocalDate = new Date(this.activeTimeEntry.start_date);
- const isEditingEntryEqualToActiveEntry = this.entryId === this.activeTimeEntry.id;
const isStartDateGreaterThanActiveEntry = startDateAsLocalDate > activeEntryAsLocalDate;
const isEndDateGreaterThanActiveEntry = endDateAsLocalDate > activeEntryAsLocalDate;
const isTimeEntryOverlapping = isStartDateGreaterThanActiveEntry || isEndDateGreaterThanActiveEntry;
- if (!isEditingEntryEqualToActiveEntry && isTimeEntryOverlapping) {
+
+ if (isTimeEntryOverlapping) {
this.toastrService.error('You are on the clock and this entry overlaps it, try with earlier times.');
- } else {
+ } else {
this.doSave(event);
}
} else {
@@ -145,9 +146,6 @@ export class TimeEntriesComponent implements OnInit, OnDestroy {
if (this.entryId) {
event.entry.id = this.entryId;
this.store.dispatch(new entryActions.UpdateEntry(event.entry));
- if (event.shouldRestartEntry) {
- this.store.dispatch(new entryActions.RestartEntry(event.entry));
- }
} else {
this.store.dispatch(new entryActions.CreateEntry(event.entry));
}
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 3b4490c97..71aa110da 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
@@ -1,15 +1,37 @@
-
+
- User Email |
- Names |
+ User Email |
+ Names |
+ Roles |
- {{ user.email }} |
- {{ user.name }} |
+ {{ user.email }} |
+ {{ user.name }} |
+
+
+
+ admin
+
+ test
+
+ |
diff --git a/src/app/modules/users/components/users-list/users-list.component.spec.ts b/src/app/modules/users/components/users-list/users-list.component.spec.ts
index 8cd39d44a..a8e7ead89 100644
--- a/src/app/modules/users/components/users-list/users-list.component.spec.ts
+++ b/src/app/modules/users/components/users-list/users-list.component.spec.ts
@@ -3,14 +3,17 @@ import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { NgxPaginationModule } from 'ngx-pagination';
import { UsersListComponent } from './users-list.component';
-import { UserActionTypes, UserState, LoadUsers } from '../../store';
+import { UserActionTypes, UserState, LoadUsers, GrantRoleUser, RevokeRoleUser } from '../../store';
+import { FeatureManagerService } from 'src/app/modules/shared/feature-toggles/feature-toggle-manager.service';
import { ActionsSubject } from '@ngrx/store';
import { DataTablesModule } from 'angular-datatables';
+import { Observable, of } from 'rxjs';
describe('UsersListComponent', () => {
let component: UsersListComponent;
let fixture: ComponentFixture;
let store: MockStore;
+ let featureManagerService: FeatureManagerService;
const actionSub: ActionsSubject = new ActionsSubject();
const state: UserState = {
@@ -29,13 +32,16 @@ describe('UsersListComponent', () => {
message: '',
};
- beforeEach(waitForAsync(() => {
- TestBed.configureTestingModule({
- imports: [NgxPaginationModule, DataTablesModule],
- declarations: [UsersListComponent],
- providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }],
- }).compileComponents();
- }));
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [NgxPaginationModule, DataTablesModule],
+ declarations: [UsersListComponent],
+ providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }],
+ }).compileComponents();
+ featureManagerService = TestBed.inject(FeatureManagerService);
+ })
+ );
beforeEach(() => {
fixture = TestBed.createComponent(UsersListComponent);
@@ -69,6 +75,76 @@ describe('UsersListComponent', () => {
expect(component.users).toEqual(state.data);
});
+ 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.isUserRoleToggleOn).toBe(true);
+ });
+
+ const actionsParams = [
+ { actionType: UserActionTypes.GRANT_USER_ROLE_SUCCESS },
+ { actionType: UserActionTypes.REVOKE_USER_ROLE_SUCCESS },
+ ];
+
+ actionsParams.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' },
+ ];
+
+ grantRoleTypes.map((param) => {
+ it(`When user switchRole to ${param.roleId} and don't have any role, should grant ${param.roleValue} Role`, () => {
+ const roleId = param.roleId;
+ const roleValue = param.roleValue;
+ const userRoles = [];
+ const userId = 'userId';
+
+ spyOn(store, 'dispatch');
+
+ component.switchRole(userId, userRoles, roleId, roleValue);
+
+ expect(store.dispatch).toHaveBeenCalledWith(new GrantRoleUser(userId, roleId));
+ });
+ });
+
+ const revokeRoleTypes = [
+ { roleId: 'admin', roleValue: 'time-tracker-admin', userRoles: ['time-tracker-admin'] },
+ { roleId: 'test', roleValue: 'time-tracker-tester', userRoles: ['time-tracker-tester'] },
+ ];
+
+ revokeRoleTypes.map((param) => {
+ it(`When user switchRole to ${param.roleId} and have that rol asigned, should revoke ${param.roleValue} Role`, () => {
+ const roleId = param.roleId;
+ const roleValue = param.roleValue;
+ const userRoles = param.userRoles;
+ const userId = 'userId';
+
+ spyOn(store, 'dispatch');
+
+ component.switchRole(userId, userRoles, roleId, roleValue);
+
+ expect(store.dispatch).toHaveBeenCalledWith(new RevokeRoleUser(userId, roleId));
+ });
+ });
+
it('on success load users, the data of roles should be an array and role null', () => {
const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject;
const action = {
@@ -114,7 +190,23 @@ describe('UsersListComponent', () => {
});
});
- it('on success load users, the datatable should be reloaded', async () => {
+ const toggleValues = [true, false];
+ toggleValues.map((toggleValue) => {
+ it(`when FeatureToggle is ${toggleValue} should return ${toggleValue}`, () => {
+ spyOn(featureManagerService, 'isToggleEnabledForUser').and.returnValue(of(toggleValue));
+
+ const isFeatureToggleActivated: Observable = component.isFeatureToggleActivated();
+
+ expect(featureManagerService.isToggleEnabledForUser).toHaveBeenCalled();
+ isFeatureToggleActivated.subscribe((value) => expect(value).toEqual(toggleValue));
+ });
+ });
+
+ /*
+ TODO: block commented on purpose so that when the tests pass and the Feature toggle is removed,
+ the table will be rendered again with dtInstance and not with dtOptions
+
+ it('on success load users, the datatable should be reloaded', async () => {
const actionSubject = TestBed.inject(ActionsSubject);
const action = {
type: UserActionTypes.LOAD_USERS_SUCCESS,
@@ -125,7 +217,7 @@ describe('UsersListComponent', () => {
actionSubject.next(action);
expect(component.dtElement.dtInstance.then).toHaveBeenCalled();
- });
+ });*/
afterEach(() => {
component.dtTrigger.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 5d119fd47..91cfc2e23 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
@@ -2,10 +2,12 @@ import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular
import { ActionsSubject, select, Store } 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 { User } from '../../models/users';
-import { LoadUsers, UserActionTypes } from '../../store/user.actions';
+import { GrantRoleUser, LoadUsers, RevokeRoleUser, UserActionTypes } from '../../store/user.actions';
import { getIsLoading } from '../../store/user.selectors';
+import { FeatureManagerService } from 'src/app/modules/shared/feature-toggles/feature-toggle-manager.service';
+
@Component({
selector: 'app-users-list',
templateUrl: './users-list.component.html',
@@ -15,22 +17,45 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit {
users: User[] = [];
isLoading$: Observable;
loadUsersSubscription: Subscription;
+ switchRoleSubscription: Subscription;
dtTrigger: Subject = new Subject();
@ViewChild(DataTableDirective, { static: false })
dtElement: DataTableDirective;
+ dtOptions: any = {};
+ isUserRoleToggleOn;
- 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));
}
ngOnInit(): void {
+ this.isFeatureToggleActivated().subscribe((flag) => {
+ this.isUserRoleToggleOn = flag;
+ });
+ this.store.dispatch(new LoadUsers());
this.loadUsersSubscription = this.actionsSubject$
.pipe(filter((action: any) => action.type === UserActionTypes.LOAD_USERS_SUCCESS))
.subscribe((action) => {
this.users = action.payload;
this.rerenderDataTable();
});
- this.store.dispatch(new LoadUsers());
+
+ this.switchRoleSubscription = this.actionsSubject$
+ .pipe(
+ filter(
+ (action: any) =>
+ action.type === UserActionTypes.GRANT_USER_ROLE_SUCCESS ||
+ action.type === UserActionTypes.REVOKE_USER_ROLE_SUCCESS
+ )
+ )
+ .subscribe((action) => {
+ this.store.dispatch(new LoadUsers());
+ this.rerenderDataTable();
+ });
}
ngAfterViewInit(): void {
@@ -52,4 +77,18 @@ export class UsersListComponent implements OnInit, OnDestroy, AfterViewInit {
this.dtTrigger.next();
}
}
+
+ switchRole(userId: string, userRoles: string[], roleId: string, roleValue: string) {
+ userRoles.includes(roleValue)
+ ? this.store.dispatch(new RevokeRoleUser(userId, roleId))
+ : this.store.dispatch(new GrantRoleUser(userId, roleId));
+ }
+
+ isFeatureToggleActivated() {
+ return this.featureManagerService.isToggleEnabledForUser('ui-list-test-users').pipe(
+ map((enabled) => {
+ return enabled === true ? true : false;
+ })
+ );
+ }
}
diff --git a/src/app/modules/users/services/users.service.spec.ts b/src/app/modules/users/services/users.service.spec.ts
index 6489f34b9..cba99ef44 100644
--- a/src/app/modules/users/services/users.service.spec.ts
+++ b/src/app/modules/users/services/users.service.spec.ts
@@ -31,4 +31,24 @@ describe('UsersService', () => {
const loadUserRequest = httpMock.expectOne(service.baseUrl);
expect(loadUserRequest.request.method).toBe('GET');
});
+
+ it('grant role to a User', () => {
+ const userId = 'userId';
+ const roleId = 'admin';
+
+ service.grantRole(userId, roleId).subscribe();
+
+ const grantRoleRequest = httpMock.expectOne(`${service.baseUrl}/${userId}/roles/${roleId}/grant`);
+ expect(grantRoleRequest.request.method).toBe('POST');
+ });
+
+ it('revoke role to a User', () => {
+ const userId = 'userId';
+ const roleId = 'admin';
+
+ service.revokeRole(userId, roleId).subscribe();
+
+ const grantRoleRequest = httpMock.expectOne(`${service.baseUrl}/${userId}/roles/${roleId}/revoke`);
+ expect(grantRoleRequest.request.method).toBe('POST');
+ });
});
diff --git a/src/app/modules/users/services/users.service.ts b/src/app/modules/users/services/users.service.ts
index e41b0401a..80e181f9e 100644
--- a/src/app/modules/users/services/users.service.ts
+++ b/src/app/modules/users/services/users.service.ts
@@ -13,4 +13,14 @@ export class UsersService {
loadUsers(): Observable {
return this.http.get(this.baseUrl);
}
+
+ grantRole(userId: string, roleId: string): Observable {
+ const url = `${this.baseUrl}/${userId}/roles/${roleId}/grant`;
+ return this.http.post(url, null);
+ }
+
+ revokeRole(userId: string, roleId: string): Observable {
+ const url = `${this.baseUrl}/${userId}/roles/${roleId}/revoke`;
+ return this.http.post(url, null);
+ }
}
diff --git a/src/app/modules/users/store/user.actions.spec.ts b/src/app/modules/users/store/user.actions.spec.ts
index 6edb98176..aa66253be 100644
--- a/src/app/modules/users/store/user.actions.spec.ts
+++ b/src/app/modules/users/store/user.actions.spec.ts
@@ -1,4 +1,5 @@
import * as actions from './user.actions';
+import { User } from '../models/users';
describe('UserActions', () => {
it('LoadUsers type is UserActionTypes.LOAD_USERS', () => {
@@ -15,4 +16,40 @@ describe('UserActions', () => {
const action = new actions.LoadUsersFail('error');
expect(action.type).toEqual(actions.UserActionTypes.LOAD_USERS_FAIL);
});
+
+ it('GrantRoleUser type is UserActionTypes.GRANT_USER_ROLE', () => {
+ const UserId = 'UserId';
+ const RoleId = 'RoleId';
+ const action = new actions.GrantRoleUser(UserId, RoleId);
+ expect(action.type).toEqual(actions.UserActionTypes.GRANT_USER_ROLE);
+ });
+
+ it('GrantRoleUserSuccess type is UserActionTypes.GRANT_USER_ROLE_SUCCESS', () => {
+ const payload: User = { id: 'id', email: 'email', name: 'name' };
+ const action = new actions.GrantRoleUserSuccess(payload);
+ expect(action.type).toEqual(actions.UserActionTypes.GRANT_USER_ROLE_SUCCESS);
+ });
+
+ it('GrantRoleUserFail type is UserActionTypes.GRANT_USER_ROLE_FAIL', () => {
+ const action = new actions.GrantRoleUserFail('error');
+ expect(action.type).toEqual(actions.UserActionTypes.GRANT_USER_ROLE_FAIL);
+ });
+
+ it('RevokeRoleUser type is UserActionTypes.REVOKE_USER_ROLE', () => {
+ const UserId = 'UserId';
+ const RoleId = 'RoleId';
+ const action = new actions.RevokeRoleUser(UserId, RoleId);
+ expect(action.type).toEqual(actions.UserActionTypes.REVOKE_USER_ROLE);
+ });
+
+ it('RevokeRoleUserSuccess type is UserActionTypes.REVOKE_USER_ROLE_SUCCESS', () => {
+ const payload: User = { id: 'id', email: 'email', name: 'name' };
+ const action = new actions.RevokeRoleUserSuccess(payload);
+ expect(action.type).toEqual(actions.UserActionTypes.REVOKE_USER_ROLE_SUCCESS);
+ });
+
+ it('RevokeRoleUserFail type is UserActionTypes.REVOKE_USER_ROLE_FAIL', () => {
+ const action = new actions.RevokeRoleUserFail('error');
+ expect(action.type).toEqual(actions.UserActionTypes.REVOKE_USER_ROLE_FAIL);
+ });
});
diff --git a/src/app/modules/users/store/user.actions.ts b/src/app/modules/users/store/user.actions.ts
index e6419423f..02ee3fe94 100644
--- a/src/app/modules/users/store/user.actions.ts
+++ b/src/app/modules/users/store/user.actions.ts
@@ -5,6 +5,12 @@ export enum UserActionTypes {
LOAD_USERS = '[User] LOAD_USERS',
LOAD_USERS_SUCCESS = '[User] LOAD_USERS_SUCCESS',
LOAD_USERS_FAIL = '[User] LOAD_USERS_FAIL',
+ GRANT_USER_ROLE = '[User] GRANT_USER_ROLE',
+ GRANT_USER_ROLE_SUCCESS = '[User] GRANT_USER_ROLE_SUCCESS',
+ GRANT_USER_ROLE_FAIL = '[User] GRANT_USER_ROLE_FAIL',
+ REVOKE_USER_ROLE = '[User] REVOKE_USER_ROLE',
+ REVOKE_USER_ROLE_SUCCESS = '[User] REVOKE_USER_ROLE_SUCCESS',
+ REVOKE_USER_ROLE_FAIL = '[User] REVOKE_USER_ROLE_FAIL',
DEFAULT_USER = '[USER] DEFAULT_USER',
}
@@ -22,8 +28,47 @@ export class LoadUsersFail implements Action {
constructor(public error: string) {}
}
+export class GrantRoleUser implements Action {
+ public readonly type = UserActionTypes.GRANT_USER_ROLE;
+ constructor(public userId: string, public roleId: string) {}
+}
+
+export class GrantRoleUserSuccess implements Action {
+ public readonly type = UserActionTypes.GRANT_USER_ROLE_SUCCESS;
+ constructor(public payload: User) {}
+}
+
+export class GrantRoleUserFail implements Action {
+ public readonly type = UserActionTypes.GRANT_USER_ROLE_FAIL;
+ constructor(public error: string) {}
+}
+
+export class RevokeRoleUser implements Action {
+ public readonly type = UserActionTypes.REVOKE_USER_ROLE;
+ constructor(public userId: string, public roleId: string) {}
+}
+
+export class RevokeRoleUserSuccess implements Action {
+ public readonly type = UserActionTypes.REVOKE_USER_ROLE_SUCCESS;
+ constructor(public payload: User) {}
+}
+
+export class RevokeRoleUserFail implements Action {
+ public readonly type = UserActionTypes.REVOKE_USER_ROLE_FAIL;
+ constructor(public error: string) {}
+}
export class DefaultUser implements Action {
public readonly type = UserActionTypes.DEFAULT_USER;
}
-export type UserActions = LoadUsers | LoadUsersSuccess | LoadUsersFail | DefaultUser;
+export type UserActions =
+ | LoadUsers
+ | LoadUsersSuccess
+ | LoadUsersFail
+ | DefaultUser
+ | GrantRoleUser
+ | GrantRoleUserSuccess
+ | GrantRoleUserFail
+ | RevokeRoleUser
+ | RevokeRoleUserSuccess
+ | RevokeRoleUserFail;
diff --git a/src/app/modules/users/store/user.effects.spec.ts b/src/app/modules/users/store/user.effects.spec.ts
index 2ac02426a..4438f64ea 100644
--- a/src/app/modules/users/store/user.effects.spec.ts
+++ b/src/app/modules/users/store/user.effects.spec.ts
@@ -51,4 +51,58 @@ describe('UserEffects', () => {
expect(action.type).toEqual(UserActionTypes.LOAD_USERS_FAIL);
});
});
+
+ it('action type is GRANT_USER_ROLE_SUCCESS when service is executed sucessfully', async () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ actions$ = of({ type: UserActionTypes.GRANT_USER_ROLE, userId, roleId });
+ const serviceSpy = spyOn(service, 'grantRole');
+ spyOn(toastrService, 'success');
+ serviceSpy.and.returnValue(of(user));
+
+ effects.grantUserRole$.subscribe((action) => {
+ expect(toastrService.success).toHaveBeenCalledWith('Grant User Role Success');
+ expect(action.type).toEqual(UserActionTypes.GRANT_USER_ROLE_SUCCESS);
+ });
+ });
+
+ it('action type is GRANT_USER_ROLE_FAIL when service is executed and fail', async () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ actions$ = of({ type: UserActionTypes.GRANT_USER_ROLE, userId, roleId });
+ spyOn(service, 'grantRole').and.returnValue(throwError({ error: { message: 'error' } }));
+ spyOn(toastrService, 'error');
+
+ effects.grantUserRole$.subscribe((action) => {
+ expect(toastrService.error).toHaveBeenCalled();
+ expect(action.type).toEqual(UserActionTypes.GRANT_USER_ROLE_FAIL);
+ });
+ });
+
+ it('action type is REVOKE_USER_ROLE_SUCCESS when service is executed sucessfully', async () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ actions$ = of({ type: UserActionTypes.REVOKE_USER_ROLE, userId, roleId });
+ const serviceSpy = spyOn(service, 'revokeRole');
+ spyOn(toastrService, 'success');
+ serviceSpy.and.returnValue(of(user));
+
+ effects.revokeUserRole$.subscribe((action) => {
+ expect(toastrService.success).toHaveBeenCalledWith('Revoke User Role Success');
+ expect(action.type).toEqual(UserActionTypes.REVOKE_USER_ROLE_SUCCESS);
+ });
+ });
+
+ it('action type is REVOKE_USER_ROLE_FAIL when service is executed and fail', async () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ actions$ = of({ type: UserActionTypes.REVOKE_USER_ROLE, userId, roleId });
+ spyOn(service, 'revokeRole').and.returnValue(throwError({ error: { message: 'error' } }));
+ spyOn(toastrService, 'error');
+
+ effects.revokeUserRole$.subscribe((action) => {
+ expect(toastrService.error).toHaveBeenCalled();
+ expect(action.type).toEqual(UserActionTypes.REVOKE_USER_ROLE_FAIL);
+ });
+ });
});
diff --git a/src/app/modules/users/store/user.effects.ts b/src/app/modules/users/store/user.effects.ts
index 8ed2e1be5..fe715282c 100644
--- a/src/app/modules/users/store/user.effects.ts
+++ b/src/app/modules/users/store/user.effects.ts
@@ -27,4 +27,40 @@ export class UserEffects {
)
)
);
+
+ @Effect()
+ grantUserRole$: Observable = this.actions$.pipe(
+ ofType(actions.UserActionTypes.GRANT_USER_ROLE),
+ map((action: actions.GrantRoleUser) => action),
+ mergeMap((action) =>
+ this.userService.grantRole(action.userId, action.roleId).pipe(
+ map((response) => {
+ this.toastrService.success('Grant User Role Success');
+ return new actions.GrantRoleUserSuccess(response);
+ }),
+ catchError((error) => {
+ this.toastrService.error(error.error.message);
+ return of(new actions.GrantRoleUserFail(error));
+ })
+ )
+ )
+ );
+
+ @Effect()
+ revokeUserRole$: Observable = this.actions$.pipe(
+ ofType(actions.UserActionTypes.REVOKE_USER_ROLE),
+ map((action: actions.RevokeRoleUser) => action),
+ mergeMap((action) =>
+ this.userService.revokeRole(action.userId, action.roleId).pipe(
+ map((response) => {
+ this.toastrService.success('Revoke User Role Success');
+ return new actions.RevokeRoleUserSuccess(response);
+ }),
+ catchError((error) => {
+ this.toastrService.error(error.error.message);
+ return of(new actions.RevokeRoleUserFail(error));
+ })
+ )
+ )
+ );
}
diff --git a/src/app/modules/users/store/user.reducer.spec.ts b/src/app/modules/users/store/user.reducer.spec.ts
index ccb8b69f8..4f89e5d4e 100644
--- a/src/app/modules/users/store/user.reducer.spec.ts
+++ b/src/app/modules/users/store/user.reducer.spec.ts
@@ -1,4 +1,5 @@
import { UserState, userReducer } from './user.reducers';
+import { User } from '../models/users';
import * as actions from './user.actions';
describe('userReducer', () => {
@@ -28,6 +29,70 @@ describe('userReducer', () => {
expect(state.data.length).toBe(0);
});
+ it('on GrantUserRole, isLoading is true', () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ const action = new actions.GrantRoleUser(userId, roleId);
+ const state = userReducer(initialState, action);
+
+ expect(state.isLoading).toEqual(true);
+ });
+
+ it('on GrantRoleUserSuccess, user role should change', () => {
+ const currentState: UserState = {
+ data: [{ id: 'id', name: 'name', email: 'email', role: null }],
+ isLoading: false,
+ message: '',
+ };
+ const userGranted: User = { id: 'id', name: 'name', email: 'email', role: 'admin' };
+ const action = new actions.GrantRoleUserSuccess(userGranted);
+ const state = userReducer(currentState, action);
+
+ expect(state.data).toEqual([userGranted]);
+ expect(state.isLoading).toEqual(false);
+ expect(state.message).toEqual('Grant User Role Success');
+ });
+
+ it('on GrantRoleUserFail, should show a message with an error message', () => {
+ const action = new actions.GrantRoleUserFail('error');
+ const state = userReducer(initialState, action);
+
+ expect(state.message).toEqual('Something went wrong granting user role');
+ expect(state.isLoading).toEqual(false);
+ });
+
+ it('on RevokeUserRole, isLoading is true', () => {
+ const userId = 'userId';
+ const roleId = 'roleId';
+ const action = new actions.RevokeRoleUser(userId, roleId);
+ const state = userReducer(initialState, action);
+
+ expect(state.isLoading).toEqual(true);
+ });
+
+ it('on RevokeRoleUserSuccess, user role should change', () => {
+ const currentState: UserState = {
+ data: [{ id: 'id', name: 'name', email: 'email', role: 'admin' }],
+ isLoading: false,
+ message: '',
+ };
+ const userRevoked: User = { id: 'id', name: 'name', email: 'email', role: null };
+ const action = new actions.RevokeRoleUserSuccess(userRevoked);
+ const state = userReducer(currentState, action);
+
+ expect(state.data).toEqual([userRevoked]);
+ expect(state.isLoading).toEqual(false);
+ expect(state.message).toEqual('Revoke User Role Success');
+ });
+
+ it('on RevokeRoleUserFail, should show a message with an error message', () => {
+ const action = new actions.RevokeRoleUserFail('error');
+ const state = userReducer(initialState, action);
+
+ expect(state.message).toEqual('Something went wrong revoking user role');
+ expect(state.isLoading).toEqual(false);
+ });
+
it('on Default, ', () => {
const action = new actions.DefaultUser();
const state = userReducer(initialState, action);
diff --git a/src/app/modules/users/store/user.reducers.ts b/src/app/modules/users/store/user.reducers.ts
index 8bd0d10e7..40bd5ca87 100644
--- a/src/app/modules/users/store/user.reducers.ts
+++ b/src/app/modules/users/store/user.reducers.ts
@@ -14,6 +14,7 @@ export const initialState: UserState = {
};
export const userReducer = (state: UserState = initialState, action: UserActions) => {
+ const userData = [...state.data];
switch (action.type) {
case UserActionTypes.LOAD_USERS: {
return {
@@ -33,6 +34,58 @@ export const userReducer = (state: UserState = initialState, action: UserActions
...state,
data: [],
isLoading: false,
+ message: action.error,
+ };
+ }
+ case UserActionTypes.GRANT_USER_ROLE: {
+ return {
+ ...state,
+ isLoading: true,
+ };
+ }
+ case UserActionTypes.GRANT_USER_ROLE_SUCCESS: {
+ const index = userData.findIndex((user) => user.id === action.payload.id);
+ userData[index] = action.payload;
+ return {
+ ...state,
+ data: userData,
+ isLoading: false,
+ message: 'Grant User Role Success',
+ };
+ }
+
+ case UserActionTypes.GRANT_USER_ROLE_FAIL: {
+ return {
+ ...state,
+ data: state.data,
+ isLoading: false,
+ message: 'Something went wrong granting user role',
+ };
+ }
+
+ case UserActionTypes.REVOKE_USER_ROLE: {
+ return {
+ ...state,
+ isLoading: true,
+ };
+ }
+ case UserActionTypes.REVOKE_USER_ROLE_SUCCESS: {
+ const index = userData.findIndex((user) => user.id === action.payload.id);
+ userData[index] = action.payload;
+ return {
+ ...state,
+ data: userData,
+ isLoading: false,
+ message: 'Revoke User Role Success',
+ };
+ }
+
+ case UserActionTypes.REVOKE_USER_ROLE_FAIL: {
+ return {
+ ...state,
+ data: state.data,
+ isLoading: false,
+ message: 'Something went wrong revoking user role',
};
}
default: