diff --git a/src/Squidex/app/features/administration/declarations.ts b/src/Squidex/app/features/administration/declarations.ts index 16f825083..6be8c6e4f 100644 --- a/src/Squidex/app/features/administration/declarations.ts +++ b/src/Squidex/app/features/administration/declarations.ts @@ -5,8 +5,16 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +export * from './administration-area.component'; + +export * from './guards/user-must-exist.guard'; +export * from './guards/unset-user.guard'; + export * from './pages/event-consumers/event-consumers-page.component'; export * from './pages/users/user-page.component'; export * from './pages/users/users-page.component'; -export * from './administration-area.component'; \ No newline at end of file +export * from './services/event-consumers.service'; +export * from './services/users.service'; + +export * from './state/users.state'; \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/resolve-user.guard.spec.ts b/src/Squidex/app/features/administration/guards/resolve-user.guard.spec.ts new file mode 100644 index 000000000..9f2f017a1 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/resolve-user.guard.spec.ts @@ -0,0 +1,87 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ +/* +import { IMock, Mock } from 'typemoq'; +import { Observable } from 'rxjs'; + +import { UserManagementService } from 'shared'; + +import { ResolveUserGuard } from './resolve-user.guard'; +import { RouterMockup } from './router-mockup'; + +describe('ResolveUserGuard', () => { + const route = { + params: {}, + parent: { + params: { + userId: 'my-user' + } + } + }; + + let usersService: IMock; + + beforeEach(() => { + usersService = Mock.ofType(UserManagementService); + }); + + it('should throw if route does not contain parameter', () => { + const guard = new ResolveUserGuard(usersService.object, new RouterMockup()); + + expect(() => guard.resolve({ params: {} }, {})).toThrow('Route must contain user id.'); + }); + + it('should navigate to 404 page if user is not found', (done) => { + usersService.setup(x => x.getUser('my-user')) + .returns(() => Observable.of(null!)); + const router = new RouterMockup(); + + const guard = new ResolveUserGuard(usersService.object, router); + + guard.resolve(route, {}) + .subscribe(result => { + expect(result).toBeFalsy(); + expect(router.lastNavigation).toEqual(['/404']); + + done(); + }); + }); + + it('should navigate to 404 page if user loading fails', (done) => { + usersService.setup(x => x.getUser('my-user')) + .returns(() => Observable.throw(null!)); + const router = new RouterMockup(); + + const guard = new ResolveUserGuard(usersService.object, router); + + guard.resolve(route, {}) + .subscribe(result => { + expect(result).toBeFalsy(); + expect(router.lastNavigation).toEqual(['/404']); + + done(); + }); + }); + + it('should return user if loading succeeded', (done) => { + const user: any = {}; + + usersService.setup(x => x.getUser('my-user')) + .returns(() => Observable.of(user)); + const router = new RouterMockup(); + + const guard = new ResolveUserGuard(usersService.object, router); + + guard.resolve(route, {}) + .subscribe(result => { + expect(result).toBe(user); + + done(); + }); + }); +}); +*/ \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.ts new file mode 100644 index 000000000..73b3ca7b7 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.ts @@ -0,0 +1,24 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { UsersState } from './../state/users.state'; + +@Injectable() +export class UnsetUserGuard implements CanActivate { + constructor( + private readonly usersState: UsersState + ) { + } + + public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.usersState.selectUser(null).map(r => !r); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts new file mode 100644 index 000000000..defab9e6b --- /dev/null +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts @@ -0,0 +1,48 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { allParams } from 'framework'; + +import { UsersState } from './../state/users.state'; + +@Injectable() +export class UserMustExistGuard implements CanActivate { + constructor( + private readonly usersState: UsersState, + private readonly router: Router + ) { + } + + public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const params = allParams(route); + + const userId = params['userId']; + + if (!userId) { + throw 'Route must contain user id.'; + } + + const result = + this.usersState.selectUser(userId).map(u => !!u) + .do(dto => { + if (!dto) { + this.router.navigate(['/404']); + } + }) + .catch(error => { + this.router.navigate(['/404']); + + return Observable.of(false); + }); + + return result; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/module.ts b/src/Squidex/app/features/administration/module.ts index 761d63159..40bb7ae2a 100644 --- a/src/Squidex/app/features/administration/module.ts +++ b/src/Squidex/app/features/administration/module.ts @@ -9,16 +9,19 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { - ResolveUserGuard, - SqxFrameworkModule, - SqxSharedModule + SqxFrameworkModule } from 'shared'; import { AdministrationAreaComponent, EventConsumersPageComponent, + EventConsumersService, + UnsetUserGuard, UserPageComponent, - UsersPageComponent + UserMustExistGuard, + UsersPageComponent, + UsersService, + UsersState } from './declarations'; const routes: Routes = [ @@ -39,14 +42,13 @@ const routes: Routes = [ children: [ { path: 'new', - component: UserPageComponent + component: UserPageComponent, + canActivate: [UnsetUserGuard] }, { path: ':userId', component: UserPageComponent, - resolve: { - user: ResolveUserGuard - } + canActivate: [UserMustExistGuard] } ] } @@ -59,7 +61,6 @@ const routes: Routes = [ @NgModule({ imports: [ SqxFrameworkModule, - SqxSharedModule, RouterModule.forChild(routes) ], declarations: [ @@ -67,6 +68,11 @@ const routes: Routes = [ EventConsumersPageComponent, UserPageComponent, UsersPageComponent + ], + providers: [ + EventConsumersService, + UsersService, + UsersState ] }) export class SqxFeatureAdministrationModule { } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.html b/src/Squidex/app/features/administration/pages/users/user-page.component.html index 8a019c60e..c12681d22 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.html @@ -50,7 +50,7 @@ -
+
diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index deddd7689..9bf855d0f 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -5,18 +5,17 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormGroup, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Subscription } from 'rxjs'; import { AppContext, UserDto, - UserManagementService, ValidatorsEx } from 'shared'; -import { UserCreated, UserUpdated } from './../messages'; +import { UsersState } from './../../state/users.state'; @Component({ selector: 'sqx-user-page', @@ -26,8 +25,8 @@ import { UserCreated, UserUpdated } from './../messages'; AppContext ] }) -export class UserPageComponent implements OnInit { - public user: UserDto; +export class UserPageComponent implements OnDestroy, OnInit { + private selectedUserSubscription: Subscription; public userFormSubmitted = false; public userForm: FormGroup; @@ -38,18 +37,17 @@ export class UserPageComponent implements OnInit { constructor(public readonly ctx: AppContext, private readonly formBuilder: FormBuilder, - private readonly router: Router, - private readonly userManagementService: UserManagementService + private readonly state: UsersState ) { } - public ngOnInit() { - this.ctx.route.data.map(d => d.user) - .subscribe((user: UserDto) => { - this.user = user; + public ngOnDestroy() { + this.selectedUserSubscription.unsubscribe(); + } - this.setupAndPopulateForm(); - }); + public ngOnInit() { + this.selectedUserSubscription = + this.state.selectedUser.subscribe(user => this.setupAndPopulateForm(user!)); } public save() { @@ -61,34 +59,19 @@ export class UserPageComponent implements OnInit { const requestDto = this.userForm.value; if (this.isNewMode) { - this.userManagementService.postUser(requestDto) - .subscribe(created => { - this.user = - new UserDto( - created.id, - requestDto.email, - requestDto.displayName, - created.pictureUrl!, - false); - + this.state.createUser(requestDto) + .subscribe(() => { this.ctx.notifyInfo('User created successfully.'); - this.emitUserCreated(this.user); - this.back(); + this.resetUserForm(); }, error => { this.resetUserForm(error.displayMessage); }); } else { - this.userManagementService.putUser(this.user.id, requestDto) + this.state.updateUser(requestDto) .subscribe(() => { - this.user = - this.user.update( - requestDto.email, - requestDto.displayMessage); - this.ctx.notifyInfo('User saved successfully.'); - this.emitUserUpdated(this.user); this.resetUserForm(); }, error => { this.resetUserForm(error.displayMessage); @@ -97,31 +80,21 @@ export class UserPageComponent implements OnInit { } } - private back() { - this.router.navigate(['../'], { relativeTo: this.ctx.route, replaceUrl: true }); - } - - private emitUserCreated(user: UserDto) { - this.ctx.bus.emit(new UserCreated(user)); - } - - private emitUserUpdated(user: UserDto) { - this.ctx.bus.emit(new UserUpdated(user)); - } + private setupAndPopulateForm(user: UserDto | null) { + const userData: any = user || {}; - private setupAndPopulateForm() { - const input = this.user || {}; + this.isNewMode = !user; + this.isCurrentUser = user !== null && user.id === this.ctx.userId; - this.isNewMode = !this.user; this.userForm = this.formBuilder.group({ - email: [input['email'], + email: [userData.email, [ Validators.email, Validators.required, Validators.maxLength(100) ]], - displayName: [input['displayName'], + displayName: [userData.displayName, [ Validators.required, Validators.maxLength(100) @@ -136,7 +109,6 @@ export class UserPageComponent implements OnInit { ]] }); - this.isCurrentUser = this.user && this.user.id === this.ctx.userId; this.resetUserForm(); } diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index de7e0fdd9..0e23805ea 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -10,13 +10,13 @@ - +
-
@@ -56,7 +56,7 @@
- +
@@ -88,18 +88,20 @@ - diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index f8c38cc94..5eec724aa 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -5,19 +5,12 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { Subscription } from 'rxjs'; -import { - AppContext, - ImmutableArray, - Pager, - UserDto, - UserManagementService -} from 'shared'; +import { AppContext, UserDto } from 'shared'; -import { UserCreated, UserUpdated } from './../messages'; +import { UsersState } from './../../state/users.state'; @Component({ selector: 'sqx-users-page', @@ -27,91 +20,37 @@ import { UserCreated, UserUpdated } from './../messages'; AppContext ] }) -export class UsersPageComponent implements OnDestroy, OnInit { - private userCreatedSubscription: Subscription; - private userUpdatedSubscription: Subscription; - - public usersItems = ImmutableArray.empty(); - public usersPager = new Pager(0); +export class UsersPageComponent implements OnInit { public usersFilter = new FormControl(); - public usersQuery = ''; constructor(public readonly ctx: AppContext, - private readonly userManagementService: UserManagementService + public readonly state: UsersState ) { } - public ngOnDestroy() { - this.userCreatedSubscription.unsubscribe(); - this.userUpdatedSubscription.unsubscribe(); - } - public ngOnInit() { - this.userCreatedSubscription = - this.ctx.bus.of(UserCreated) - .subscribe(message => { - this.usersItems = this.usersItems.pushFront(message.user); - this.usersPager = this.usersPager.incrementCount(); - }); - - this.userUpdatedSubscription = - this.ctx.bus.of(UserUpdated) - .subscribe(message => { - this.usersItems = this.usersItems.replaceBy('id', message.user); - }); - this.load(); } public search() { - this.usersPager = new Pager(0); - this.usersQuery = this.usersFilter.value; - - this.load(); + this.state.search(this.usersFilter.value); } public load(showInfo = false) { - this.userManagementService.getUsers(this.usersPager.pageSize, this.usersPager.skip, this.usersQuery) - .subscribe(dtos => { - this.usersItems = ImmutableArray.of(dtos.items); - this.usersPager = this.usersPager.setCount(dtos.total); - + this.state.loadUsers() + .subscribe(() => { if (showInfo) { this.ctx.notifyInfo('Users reloaded.'); } - }, error => { - this.ctx.notifyError(error); }); } public lock(user: UserDto) { - this.userManagementService.lockUser(user.id) - .subscribe(() => { - this.usersItems = this.usersItems.replaceBy('id', user.lock()); - }, error => { - this.ctx.notifyError(error); - }); + this.state.lockUser(user.id).subscribe(); } public unlock(user: UserDto) { - this.userManagementService.unlockUser(user.id) - .subscribe(() => { - this.usersItems = this.usersItems.replaceBy('id', user.unlock()); - }, error => { - this.ctx.notifyError(error); - }); - } - - public goNext() { - this.usersPager = this.usersPager.goNext(); - - this.load(); - } - - public goPrev() { - this.usersPager = this.usersPager.goPrev(); - - this.load(); + this.state.unlockUser(user.id).subscribe(); } } diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts new file mode 100644 index 000000000..acef1ec06 --- /dev/null +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -0,0 +1,130 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ApiUrlConfig } from 'framework'; + +import { EventConsumerDto, EventConsumersService } from './event-consumers.service'; + +describe('EventConsumerDto', () => { + it('should update isStopped property when starting', () => { + const consumer_1 = new EventConsumerDto('consumer', true, false, 'error', 'position'); + const consumer_2 = consumer_1.start(); + + expect(consumer_2.isStopped).toBeFalsy(); + }); + + it('should update isStopped property when starting', () => { + const consumer_1 = new EventConsumerDto('consumer', false, false, 'error', 'position'); + const consumer_2 = consumer_1.stop(); + + expect(consumer_2.isStopped).toBeTruthy(); + }); + + it('should update isResetting property when resetting', () => { + const consumer_1 = new EventConsumerDto('consumer', false, false, 'error', 'position'); + const consumer_2 = consumer_1.reset(); + + expect(consumer_2.isResetting).toBeTruthy(); + }); +}); + +describe('EventConsumersService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + EventConsumersService, + { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') } + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get event consumers', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + let eventConsumers: EventConsumerDto[] | null = null; + + eventConsumersService.getEventConsumers().subscribe(result => { + eventConsumers = result; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush([ + { + name: 'event-consumer1', + position: '13', + isStopped: true, + isResetting: true, + error: 'an error 1' + }, + { + name: 'event-consumer2', + position: '29', + isStopped: true, + isResetting: true, + error: 'an error 2' + } + ]); + + expect(eventConsumers).toEqual([ + new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), + new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') + ]); + })); + + it('should make put request to start event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.startEventConsumer('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/start'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to stop event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.stopEventConsumer('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/stop'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to reset event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.resetEventConsumer('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/reset'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts new file mode 100644 index 000000000..fb4ae66ea --- /dev/null +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -0,0 +1,88 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import 'framework/angular/http-extensions'; + +import { ApiUrlConfig, HTTP } from 'framework'; + +export class EventConsumerDto { + constructor( + public readonly name: string, + public readonly isStopped: boolean, + public readonly isResetting: boolean, + public readonly error: string, + public readonly position: string + ) { + } + + public start(): EventConsumerDto { + return new EventConsumerDto(this.name, false, false, this.error, this.position); + } + + public stop(): EventConsumerDto { + return new EventConsumerDto(this.name, true, false, this.error, this.position); + } + + public reset(): EventConsumerDto { + return new EventConsumerDto(this.name, this.isStopped, true, this.error, this.position); + } +} + +@Injectable() +export class EventConsumersService { + constructor( + private readonly http: HttpClient, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getEventConsumers(): Observable { + const url = this.apiUrl.buildUrl('/api/event-consumers'); + + return HTTP.getVersioned(this.http, url) + .map(response => { + const body = response.payload.body; + + const items: any[] = body; + + return items.map(item => { + return new EventConsumerDto( + item.name, + item.isStopped, + item.isResetting, + item.error, + item.position); + }); + }) + .pretifyError('Failed to load event consumers. Please reload.'); + } + + public startEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + + return HTTP.putVersioned(this.http, url, {}) + .pretifyError('Failed to start event consumer. Please reload.'); + } + + public stopEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + + return HTTP.putVersioned(this.http, url, {}) + .pretifyError('Failed to stop event consumer. Please reload.'); + } + + public resetEventConsumer(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + + return HTTP.putVersioned(this.http, url, {}) + .pretifyError('Failed to reset event consumer. Please reload.'); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts new file mode 100644 index 000000000..675c5e24c --- /dev/null +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -0,0 +1,230 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ApiUrlConfig } from 'framework'; + +import { + CreateUserDto, + UpdateUserDto, + UserDto, + UsersService, + UsersDto +} from './users.service'; + +describe('UserDto', () => { + it('should update email and display name property when unlocking', () => { + const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', true); + const user_2 = user_1.update('qaisar@squidex.io', 'Qaisar'); + + expect(user_2.email).toEqual('qaisar@squidex.io'); + expect(user_2.displayName).toEqual('Qaisar'); + }); + + it('should update isLocked property when locking', () => { + const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', false); + const user_2 = user_1.lock(); + + expect(user_2.isLocked).toBeTruthy(); + }); + + it('should update isLocked property when unlocking', () => { + const user_1 = new UserDto('1', 'sebastian@squidex.io', 'Sebastian', 'picture', true); + const user_2 = user_1.unlock(); + + expect(user_2.isLocked).toBeFalsy(); + }); +}); + +describe('UsersService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + UsersService, + { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') } + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get many users', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let users: UsersDto | null = null; + + userManagementService.getUsers(20, 30).subscribe(result => { + users = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query='); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + total: 100, + items: [ + { + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + pictureUrl: 'path/to/image1', + isLocked: true + }, + { + id: '456', + email: 'mail2@domain.com', + displayName: 'User2', + pictureUrl: 'path/to/image2', + isLocked: true + } + ] + }); + + expect(users).toEqual( + new UsersDto(100, [ + new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true), + new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true) + ])); + })); + + it('should make get request with query to get many users', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let users: UsersDto | null = null; + + userManagementService.getUsers(20, 30, 'my-query').subscribe(result => { + users = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query=my-query'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + total: 100, + items: [ + { + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + pictureUrl: 'path/to/image1', + isLocked: true + }, + { + id: '456', + email: 'mail2@domain.com', + displayName: 'User2', + pictureUrl: 'path/to/image2', + isLocked: true + } + ] + }); + + expect(users).toEqual( + new UsersDto(100, [ + new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true), + new UserDto('456', 'mail2@domain.com', 'User2', 'path/to/image2', true) + ])); + })); + + it('should make get request to get single user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let user: UserDto | null = null; + + userManagementService.getUser('123').subscribe(result => { + user = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management/123'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + pictureUrl: 'path/to/image1', + isLocked: true + }); + + expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true)); + })); + + it('should make post request to create user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + const dto = new CreateUserDto('mail@squidex.io', 'Squidex User', 'password'); + + let user: UserDto | null = null; + + userManagementService.postUser(dto).subscribe(result => { + user = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ id: '123', pictureUrl: 'path/to/image1' }); + + expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, 'path/to/image1', false)); + })); + + it('should make put request to update user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + const dto = new UpdateUserDto('mail@squidex.io', 'Squidex User', 'password'); + + userManagementService.putUser('123', dto).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to lock user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + userManagementService.lockUser('123').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123/lock'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to unlock user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + userManagementService.unlockUser('123').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts new file mode 100644 index 000000000..9b8b28ff0 --- /dev/null +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -0,0 +1,145 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import 'framework/angular/http-extensions'; + +import { ApiUrlConfig, HTTP } from 'framework'; + +export class UsersDto { + constructor( + public readonly total: number, + public readonly items: UserDto[] + ) { + } +} + +export class UserDto { + constructor( + public readonly id: string, + public readonly email: string, + public readonly displayName: string, + public readonly pictureUrl: string | null, + public readonly isLocked: boolean + ) { + } + + public update(email: string, displayName: string): UserDto { + return new UserDto(this.id, email, displayName, this.pictureUrl, this.isLocked); + } + + public lock(): UserDto { + return new UserDto(this.id, this.email, this.displayName, this.pictureUrl, true); + } + + public unlock(): UserDto { + return new UserDto(this.id, this.email, this.displayName, this.pictureUrl, false); + } +} + +export class CreateUserDto { + constructor( + public readonly email: string, + public readonly displayName: string, + public readonly password: string + ) { + } +} + +export class UpdateUserDto { + constructor( + public readonly email: string, + public readonly displayName: string, + public readonly password: string + ) { + } +} + +@Injectable() +export class UsersService { + constructor( + private readonly http: HttpClient, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getUsers(take: number, skip: number, query?: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); + + return HTTP.getVersioned(this.http, url) + .map(response => { + const body = response.payload.body; + + const items: any[] = body.items; + + const users = items.map(item => { + return new UserDto( + item.id, + item.email, + item.displayName, + item.pictureUrl, + item.isLocked); + }); + + return new UsersDto(body.total, users); + }) + .pretifyError('Failed to load users. Please reload.'); + } + + public getUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + + return HTTP.getVersioned(this.http, url) + .map(response => { + const body = response.payload.body; + + return new UserDto( + body.id, + body.email, + body.displayName, + body.pictureUrl, + body.isLocked); + }) + .pretifyError('Failed to load user. Please reload.'); + } + + public postUser(dto: CreateUserDto): Observable { + const url = this.apiUrl.buildUrl('api/user-management'); + + return HTTP.postVersioned(this.http, url, dto) + .map(response => { + const body = response.payload.body; + + return new UserDto(body.id, dto.email, dto.displayName, body.pictureUrl, false); + }) + .pretifyError('Failed to create user. Please reload.'); + } + + public putUser(id: string, dto: UpdateUserDto): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + + return HTTP.putVersioned(this.http, url, dto) + .pretifyError('Failed to update user. Please reload.'); + } + + public lockUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); + + return HTTP.putVersioned(this.http, url, {}) + .pretifyError('Failed to load users. Please retry.'); + } + + public unlockUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); + + return HTTP.putVersioned(this.http, url, {}) + .pretifyError('Failed to load users. Please retry.'); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts new file mode 100644 index 000000000..22b5fbd6c --- /dev/null +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -0,0 +1,138 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import 'framework/utils/rxjs-extensions'; + +import { + DialogService, + ErrorDto, + ImmutableArray, + Notification, + Pager +} from 'shared'; + +import { + CreateUserDto, + UserDto, + UsersService +} from './../services/users.service'; + +@Injectable() +export class UsersState { + public usersItems = new BehaviorSubject>(ImmutableArray.empty()); + public usersPager = new BehaviorSubject(new Pager(0)); + public usersQuery = new BehaviorSubject(''); + + public selectedUser = new BehaviorSubject(null); + + constructor( + private readonly usersService: UsersService, + private readonly dialogs: DialogService + ) { + } + + public selectUser(id: string | null): Observable { + const observable = + !id ? + Observable.of(null) : + Observable.of(this.usersItems.value.find(x => x.id === id)) + .switchMap(user => { + if (!user) { + return this.usersService.getUser(id).catch(() => Observable.of(null)); + } else { + return Observable.of(user); + } + }); + + return observable + .do(user => { + this.selectedUser.next(user); + }) + .map(u => u !== null); + } + + public loadUsers(): Observable { + return this.usersService.getUsers(this.usersPager.value.pageSize, this.usersPager.value.skip, this.usersQuery.value) + .catch(error => this.notifyError(error)) + .do(dtos => { + this.usersItems.nextBy(v => ImmutableArray.of(dtos.items)); + this.usersPager.nextBy(v => v.setCount(dtos.total)); + }); + } + + public updateUser(request: CreateUserDto): Observable { + const id = this.selectedUser.value!.id; + + return this.usersService.putUser(id, request) + .do(() => { + this.dialogs.notify(Notification.info('User saved successsfull')); + + this.usersItems.nextBy(v => v.replaceAll(x => x.id === id, u => u.update(request.email, request.displayName))); + }); + } + + public createUser(request: CreateUserDto): Observable { + return this.usersService.postUser(request) + .catch(error => this.notifyError(error)) + .do(user => { + this.usersItems.nextBy(v => v.pushFront(user)); + this.usersPager.nextBy(v => v.incrementCount()); + }); + } + + public lockUser(id: string): Observable { + return this.usersService.lockUser(id) + .catch(error => this.notifyError(error)) + .do(() => { + this.usersItems.nextBy(v => v.replaceAll(x => x.id === id, u => u.lock())); + }); + } + + public unlockUser(id: string): Observable { + return this.usersService.lockUser(id) + .catch(error => this.notifyError(error)) + .do(() => { + this.usersItems.nextBy(v => v.replaceAll(x => x.id === id, u => u.unlock())); + }); + } + + public search(filter: string) { + this.usersPager.nextBy(v => new Pager(0)); + this.usersQuery.nextBy(v => filter); + + this.loadUsers(); + } + + public goNext() { + this.usersPager.nextBy(v => v.goNext()); + + this.loadUsers(); + } + + public goPrev() { + this.usersPager.nextBy(v => v.goPrev()); + + this.loadUsers(); + } + + public trackBy(index: number, user: UserDto): any { + return user.id; + } + + private notifyError(error: string | ErrorDto) { + if (error instanceof ErrorDto) { + this.dialogs.notify(Notification.error(error.displayMessage)); + } else { + this.dialogs.notify(Notification.error(error)); + } + + return Observable.throw(error); + } +} \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts b/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts new file mode 100644 index 000000000..c9ad4ca8a --- /dev/null +++ b/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts @@ -0,0 +1,10 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +export function nextBy(updater: (value: T) => T): void { + return this.next(updater(this.value)); +} \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/rxjs-extensions.ts b/src/Squidex/app/framework/utils/rxjs-extensions.ts new file mode 100644 index 000000000..b670d9bca --- /dev/null +++ b/src/Squidex/app/framework/utils/rxjs-extensions.ts @@ -0,0 +1,20 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { nextBy } from './rxjs-extensions-impl'; + +/* tslint:disable:no-shadowed-variable */ + +declare module 'rxjs/BehaviorSubject' { + interface BehaviorSubject { + nextBy(updater: (value: T) => T): void; + } +} + +BehaviorSubject.prototype['nextBy'] = nextBy; \ No newline at end of file