From cbb85c37e3a7278e9e070834b9e94d7bf576fc58 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 31 Mar 2018 17:58:29 +0200 Subject: [PATCH] Tests for user management. --- src/Squidex/app-config/karma.coverage.conf.js | 2 +- .../guards/unset-user.guard.spec.ts | 37 +++ .../administration/guards/unset-user.guard.ts | 4 +- .../guards/user-must-exist.guard.spec.ts | 72 ++++++ .../guards/user-must-exist.guard.ts | 4 +- .../event-consumers-page.component.ts | 19 +- .../pages/users/user-page.component.html | 4 +- .../pages/users/user-page.component.ts | 14 +- .../pages/users/users-page.component.html | 4 +- .../pages/users/users-page.component.ts | 6 - .../services/event-consumers.service.spec.ts | 23 -- .../services/event-consumers.service.ts | 12 - .../services/users.service.spec.ts | 40 +-- .../administration/services/users.service.ts | 23 +- .../administration/state/users.state.spec.ts | 236 ++++++++++++++++++ .../administration/state/users.state.ts | 36 ++- .../framework/utils/immutable-array.spec.ts | 6 + .../app/framework/utils/immutable-array.ts | 4 + .../framework/utils/rxjs-extensions-impl.ts | 12 + .../app/framework/utils/rxjs-extensions.ts | 19 +- .../app/shared/components/app-context.ts | 2 +- .../app/shared/state/apps.state.spec.ts | 107 ++++---- src/Squidex/app/shared/state/apps.state.ts | 69 +++-- .../pages/internal/apps-menu.component.html | 70 +++--- .../pages/internal/apps-menu.component.ts | 32 +-- src/Squidex/package-lock.json | 110 +++++--- src/Squidex/package.json | 5 +- 27 files changed, 660 insertions(+), 312 deletions(-) create mode 100644 src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts create mode 100644 src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts create mode 100644 src/Squidex/app/features/administration/state/users.state.spec.ts diff --git a/src/Squidex/app-config/karma.coverage.conf.js b/src/Squidex/app-config/karma.coverage.conf.js index 29ae72b39..20ff2b024 100644 --- a/src/Squidex/app-config/karma.coverage.conf.js +++ b/src/Squidex/app-config/karma.coverage.conf.js @@ -70,7 +70,7 @@ module.exports = function (config) { * * available browser launchers: https://npmjs.org/browse/keyword/karma-launcher */ - browsers: ['PhantomJS'] + browsers: ['ChromeHeadless'] }; config.set(_config); diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts new file mode 100644 index 000000000..a4dba4bea --- /dev/null +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts @@ -0,0 +1,37 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Observable } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; + +import { UsersState } from './../state/users.state'; +import { UnsetUserGuard } from './unset-user.guard'; + +describe('UnsetUserGuard', () => { + let usersState: IMock; + let userGuard: UnsetUserGuard; + + beforeEach(() => { + usersState = Mock.ofType(); + userGuard = new UnsetUserGuard(usersState.object); + }); + + it('should unset user', () => { + usersState.setup(x => x.selectUser(null)) + .returns(() => Observable.of(null)); + + let result: boolean; + + userGuard.canActivate().subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeTruthy(); + + usersState.verify(x => x.selectUser(null), Times.once()); + }); +}); \ 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 index 4acf14c0d..a0f575360 100644 --- a/src/Squidex/app/features/administration/guards/unset-user.guard.ts +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.ts @@ -6,7 +6,7 @@ */ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { CanActivate } from '@angular/router'; import { Observable } from 'rxjs'; import { UsersState } from './../state/users.state'; @@ -18,7 +18,7 @@ export class UnsetUserGuard implements CanActivate { ) { } - public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + public canActivate(): Observable { return this.usersState.selectUser(null).map(u => u === null); } } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts new file mode 100644 index 000000000..829f06c52 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -0,0 +1,72 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; + +import { UserDto } from './../services/users.service'; +import { UsersState } from './../state/users.state'; +import { UserMustExistGuard } from './user-must-exist.guard'; + +describe('UserMustExistGuard', () => { + let usersState: IMock; + let router: IMock; + let userGuard: UserMustExistGuard; + + beforeEach(() => { + router = Mock.ofType(); + usersState = Mock.ofType(); + userGuard = new UserMustExistGuard(usersState.object, router.object); + }); + + it('should load user and return true when found', () => { + usersState.setup(x => x.selectUser('123')) + .returns(() => Observable.of({})); + + let result: boolean; + + const route: any = { + snapshot: { + params: { + userId: '123' + } + } + }; + + userGuard.canActivate(route).subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeTruthy(); + + usersState.verify(x => x.selectUser('123'), Times.once()); + }); + + it('should load user and return false when not found', () => { + usersState.setup(x => x.selectUser('123')) + .returns(() => Observable.of(null)); + + let result: boolean; + + const route: any = { + snapshot: { + params: { + userId: '123' + } + } + }; + + userGuard.canActivate(route).subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeFalsy(); + + router.verify(x => x.navigate(['/404']), Times.once()); + }); +}); \ 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 index 22dce1f0e..5dcb0e6bd 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts @@ -6,7 +6,7 @@ */ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { allParams } from '@app/framework'; @@ -21,7 +21,7 @@ export class UserMustExistGuard implements CanActivate { ) { } - public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + public canActivate(route: ActivatedRouteSnapshot): Observable { const userId = allParams(route)['userId']; const result = diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts index 48af3da86..10d3e7cb8 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts @@ -69,7 +69,7 @@ export class EventConsumersPageComponent implements OnDestroy, OnInit { public start(consumer: EventConsumerDto) { this.eventConsumersService.startEventConsumer(consumer.name) .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.start()); + this.eventConsumers = this.eventConsumers.replaceBy('name', start(consumer)); }, error => { this.dialogs.notifyError(error); }); @@ -78,7 +78,7 @@ export class EventConsumersPageComponent implements OnDestroy, OnInit { public stop(consumer: EventConsumerDto) { this.eventConsumersService.stopEventConsumer(consumer.name) .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.stop()); + this.eventConsumers = this.eventConsumers.replaceBy('name', stop(consumer)); }, error => { this.dialogs.notifyError(error); }); @@ -87,7 +87,7 @@ export class EventConsumersPageComponent implements OnDestroy, OnInit { public reset(consumer: EventConsumerDto) { this.eventConsumersService.resetEventConsumer(consumer.name) .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.reset()); + this.eventConsumers = this.eventConsumers.replaceBy('name', reset(consumer)); }, error => { this.dialogs.notifyError(error); }); @@ -99,3 +99,16 @@ export class EventConsumersPageComponent implements OnDestroy, OnInit { } } + + +function start(es: EventConsumerDto) { + return new EventConsumerDto(es.name, false, false, es.error, es.position); +} + +function stop(es: EventConsumerDto) { + return new EventConsumerDto(es.name, true, false, es.error, es.position); +} + +function reset(es: EventConsumerDto) { + return new EventConsumerDto(es.name, es.isStopped, true, es.error, es.position); +} \ 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 6abd34fa8..277cc5bb3 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 @@ -5,7 +5,7 @@ - + Edit User @@ -42,7 +42,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 c8f0b3548..f16222de2 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 @@ -24,18 +24,10 @@ export class UserPageComponent implements OnDestroy, OnInit { public userForm: UserForm; - public selectedUser = - this.usersState.changes.map(x => x.selectedUser) - .distinctUntilChanged(); - - public isCurrentUser = - this.usersState.changes.map(x => x.isCurrentUser) - .distinctUntilChanged(); - constructor(formBuilder: FormBuilder, + public readonly usersState: UsersState, private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly usersState: UsersState + private readonly router: Router ) { this.userForm = new UserForm(formBuilder); } @@ -46,7 +38,7 @@ export class UserPageComponent implements OnDestroy, OnInit { public ngOnInit() { this.selectedUserSubscription = - this.usersState.changes.map(x => x.selectedUser) + this.usersState.selectedUser .subscribe(user => { this.user = user; this.userForm.load(user); 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 893f3dcb3..926bdcf56 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 @@ -49,7 +49,7 @@
- +
@@ -81,7 +81,7 @@ - + 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 1c2d5456e..2d7989cab 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 @@ -22,12 +22,6 @@ import { UsersState } from './../../state/users.state'; export class UsersPageComponent implements OnInit { public usersFilter = new FormControl(); - public users = - this.usersState.changes.map(x => x.users); - - public usersPager = - this.usersState.changes.map(x => x.usersPager); - constructor( public readonly authState: AuthService, public readonly usersState: UsersState 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 index 683a119e7..947619278 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -12,29 +12,6 @@ import { ApiUrlConfig } from '@app/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({ diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index fdd5f86c8..71cc57b49 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -22,18 +22,6 @@ export class EventConsumerDto { 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() diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index 20603ba9e..0d16017eb 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -18,30 +18,6 @@ import { 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({ @@ -80,14 +56,12 @@ describe('UsersService', () => { 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 } ] @@ -95,8 +69,8 @@ describe('UsersService', () => { 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) + new UserDto('123', 'mail1@domain.com', 'User1', true), + new UserDto('456', 'mail2@domain.com', 'User2', true) ])); })); @@ -121,14 +95,12 @@ describe('UsersService', () => { 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 } ] @@ -136,8 +108,8 @@ describe('UsersService', () => { 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) + new UserDto('123', 'mail1@domain.com', 'User1', true), + new UserDto('456', 'mail2@domain.com', 'User2', true) ])); })); @@ -163,7 +135,7 @@ describe('UsersService', () => { isLocked: true }); - expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', 'path/to/image1', true)); + expect(user).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true)); })); it('should make post request to create user', @@ -184,7 +156,7 @@ describe('UsersService', () => { req.flush({ id: '123', pictureUrl: 'path/to/image1' }); - expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, 'path/to/image1', false)); + expect(user).toEqual(new UserDto('123', dto.email, dto.displayName, false)); })); it('should make put request to update user', diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index 799c2b0be..ac6188b24 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -26,22 +26,9 @@ export class UserDto { 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 { @@ -57,7 +44,7 @@ export class UpdateUserDto { constructor( public readonly email: string, public readonly displayName: string, - public readonly password: string + public readonly password?: string ) { } } @@ -84,7 +71,6 @@ export class UsersService { item.id, item.email, item.displayName, - item.pictureUrl, item.isLocked); }); @@ -104,7 +90,6 @@ export class UsersService { body.id, body.email, body.displayName, - body.pictureUrl, body.isLocked); }) .pretifyError('Failed to load user. Please reload.'); @@ -117,7 +102,11 @@ export class UsersService { .map(response => { const body = response.payload.body; - return new UserDto(body.id, dto.email, dto.displayName, body.pictureUrl, false); + return new UserDto( + body.id, + dto.email, + dto.displayName, + false); }) .pretifyError('Failed to create user. Please reload.'); } diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts new file mode 100644 index 000000000..69290aae0 --- /dev/null +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -0,0 +1,236 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Observable } from 'rxjs'; +import { IMock, It, Mock, Times } from 'typemoq'; + +import { AuthService, DialogService } from '@app/shared'; + +import { UsersState } from './users.state'; + +import { + CreateUserDto, + UserDto, + UpdateUserDto, + UsersDto, + UsersService + } from './../services/users.service'; + +describe('UsersState', () => { + const oldUsers = [ + new UserDto('id1', 'mail1@mail.de', 'name1', false), + new UserDto('id2', 'mail2@mail.de', 'name2', true) + ]; + + const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', false); + + let authService: IMock; + let dialogService: IMock; + let usersService: IMock; + let usersState: UsersState; + + beforeEach(() => { + authService = Mock.ofType(); + + authService.setup(x => x.user) + .returns(() => { id: 'id2' }); + + dialogService = Mock.ofType(); + + usersService = Mock.ofType(); + + usersService.setup(x => x.getUsers(10, 0, undefined)) + .returns(() => Observable.of(new UsersDto(200, oldUsers))); + + usersState = new UsersState(authService.object, dialogService.object, usersService.object); + usersState.loadUsers().subscribe(); + }); + + it('should load apps', () => { + expect(usersState.snapshot.users.values).toEqual(oldUsers); + expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); + + usersService.verifyAll(); + }); + + it('should raise notification on load when notify is true', () => { + usersState.loadUsers(true).subscribe(); + + dialogService.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); + }); + + it('should not load user when already loaded', () => { + let selectedUser: UserDto; + + usersState.selectUser('id1').subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toEqual(oldUsers[0]); + expect(usersState.snapshot.selectedUser).toBe(oldUsers[0]); + + usersService.verify(x => x.getUser(It.isAnyString()), Times.never()); + }); + + it('should mark as current user when selected user equals to profile', () => { + usersState.selectUser('id2').subscribe(); + + expect(usersState.snapshot.isCurrentUser).toBeTruthy(); + }); + + it('should load user when not loaded', () => { + usersService.setup(x => x.getUser('id3')) + .returns(() => Observable.of(newUser)); + + let selectedUser: UserDto; + + usersState.selectUser('id3').subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toEqual(newUser); + expect(usersState.snapshot.selectedUser).toBe(newUser); + }); + + it('should return null when unselecting user', () => { + let selectedUser: UserDto; + + usersState.selectUser(null).subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toBeNull(); + expect(usersState.snapshot.selectedUser).toBeNull(); + + usersService.verify(x => x.getUser(It.isAnyString()), Times.never()); + }); + + it('should return null when user to select is not found', () => { + usersService.setup(x => x.getUser('unknown')) + .returns(() => Observable.throw({})); + + let selectedUser: UserDto; + + usersState.selectUser('unknown').subscribe(x => { + selectedUser = x!; + }).unsubscribe(); + + expect(selectedUser!).toBeNull(); + expect(usersState.snapshot.selectedUser).toBeNull(); + }); + + it('should mark user as locked', () => { + usersService.setup(x => x.lockUser('id1')) + .returns(() => Observable.of({})); + + usersState.selectUser('id1').subscribe(); + usersState.lockUser(oldUsers[0]).subscribe(); + + expect(usersState.snapshot.users.at(0).isLocked).toBeTruthy(); + expect(usersState.snapshot.selectedUser!.isLocked).toBeTruthy(); + }); + + it('should raise notification when locking failed', () => { + usersService.setup(x => x.lockUser('id1')) + .returns(() => Observable.throw({})); + + usersState.lockUser(oldUsers[0]).onErrorResumeNext().subscribe(); + + dialogService.verify(x => x.notifyError(It.isAny()), Times.once()); + }); + + it('should unmark user as locked', () => { + usersService.setup(x => x.unlockUser('id2')) + .returns(() => Observable.of({})); + + usersState.selectUser('id2').subscribe(); + usersState.unlockUser(oldUsers[1]).subscribe(); + + expect(usersState.snapshot.users.at(1).isLocked).toBeFalsy(); + expect(usersState.snapshot.selectedUser!.isLocked).toBeFalsy(); + }); + + it('should raise notification when unlocking failed', () => { + usersService.setup(x => x.unlockUser('id2')) + .returns(() => Observable.throw({})); + + usersState.unlockUser(oldUsers[1]).onErrorResumeNext().subscribe(); + + dialogService.verify(x => x.notifyError(It.isAny()), Times.once()); + }); + + it('should update user on update', () => { + const request = new UpdateUserDto('new@mail.com', 'New'); + + usersService.setup(x => x.putUser('id1', request)) + .returns(() => Observable.of({})); + + usersState.selectUser('id1').subscribe(); + usersState.updateUser(oldUsers[0], request).subscribe(); + + expect(usersState.snapshot.users.at(0).email).toEqual('new@mail.com'); + expect(usersState.snapshot.users.at(0).displayName).toEqual('New'); + expect(usersState.snapshot.selectedUser!.email).toEqual('new@mail.com'); + expect(usersState.snapshot.selectedUser!.displayName).toEqual('New'); + }); + + it('should not raise notification when updating failed', () => { + const request = new UpdateUserDto('new@mail.com', 'New'); + + usersService.setup(x => x.putUser('id1', request)) + .returns(() => Observable.throw({})); + + usersState.updateUser(oldUsers[0], request).onErrorResumeNext().subscribe(); + + dialogService.verify(x => x.notifyError(It.isAny()), Times.never()); + }); + + it('should add user to state when created', () => { + const request = new CreateUserDto(newUser.email, newUser.displayName, 'password'); + + usersService.setup(x => x.postUser(request)) + .returns(() => Observable.of(newUser)); + + usersState.createUser(request).subscribe(); + + expect(usersState.snapshot.users.at(0)).toBe(newUser); + expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); + }); + + it('should not raise notification when creating failed', () => { + const request = new CreateUserDto(newUser.email, newUser.displayName, 'password'); + + usersService.setup(x => x.postUser(request)) + .returns(() => Observable.throw({})); + + usersState.createUser(request).onErrorResumeNext().subscribe(); + + dialogService.verify(x => x.notifyError(It.isAny()), Times.never()); + }); + + it('should load next page and prev page when paging', () => { + usersService.setup(x => x.getUsers(10, 10, undefined)) + .returns(() => Observable.of(new UsersDto(200, []))); + + usersState.goNext().subscribe(); + usersState.goPrev().subscribe(); + + usersService.verify(x => x.getUsers(10, 10, undefined), Times.once()); + usersService.verify(x => x.getUsers(10, 0, undefined), Times.exactly(2)); + }); + + it('should load with query when searching', () => { + usersService.setup(x => x.getUsers(10, 0, 'my-query')) + .returns(() => Observable.of(new UsersDto(0, []))); + + usersState.search('my-query').subscribe(); + + expect(usersState.snapshot.usersQuery).toEqual('my-query'); + + usersService.verify(x => x.getUsers(10, 0, 'my-query'), Times.once()); + }); +}); \ 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 index f852dea75..d50f1a83f 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -71,15 +71,33 @@ export class UserForm extends Form { } interface Snapshot { + isCurrentUser?: boolean; + users: ImmutableArray; usersPager: Pager; usersQuery?: string; + selectedUser?: UserDto; - isCurrentUser?: boolean; } @Injectable() export class UsersState extends State { + public users = + this.changes.map(x => x.users) + .distinctUntilChanged(); + + public usersPager = + this.changes.map(x => x.usersPager) + .distinctUntilChanged(); + + public selectedUser = + this.changes.map(x => x.selectedUser) + .distinctUntilChanged(); + + public isCurrentUser = + this.changes.map(x => x.isCurrentUser) + .distinctUntilChanged(); + constructor( private readonly authState: AuthService, private readonly dialogs: DialogService, @@ -93,7 +111,7 @@ export class UsersState extends State { .do(selectedUser => { const isCurrentUser = id === this.authState.user!.id; - this.next(s => ({...s, selectedUser, isCurrentUser })); + this.next(s => ({ ...s, selectedUser, isCurrentUser })); }); } @@ -125,7 +143,7 @@ export class UsersState extends State { return { ...s, users, usersPager, usersLoading: false }; }); }) - .catch(error => this.dialogs.notifyError(error)); + .notify(this.dialogs); } public createUser(request: CreateUserDto): Observable { @@ -140,7 +158,7 @@ export class UsersState extends State { }); } - public updateUser(user: UserDto, request: CreateUserDto): Observable { + public updateUser(user: UserDto, request: UpdateUserDto): Observable { return this.usersService.putUser(user.id, request) .do(() => { this.dialogs.notifyInfo('User saved successsfull'); @@ -154,7 +172,7 @@ export class UsersState extends State { .do(() => { this.replaceUser(setLocked(user, true)); }) - .catch(error => this.dialogs.notifyError(error)); + .notify(this.dialogs); } public unlockUser(user: UserDto): Observable { @@ -162,11 +180,11 @@ export class UsersState extends State { .do(() => { this.replaceUser(setLocked(user, false)); }) - .catch(error => this.dialogs.notifyError(error)); + .notify(this.dialogs); } public search(query: string): Observable { - this.next({ usersPager: new Pager(0), usersQuery: query }); + this.next(s => ({ ...s, usersPager: new Pager(0), usersQuery: query })); return this.loadUsers(); } @@ -195,7 +213,7 @@ export class UsersState extends State { const update = (user: UserDto, request: UpdateUserDto) => - new UserDto(user.id, request.email, request.displayName, user.pictureUrl, user.isLocked); + new UserDto(user.id, request.email, request.displayName, user.isLocked); const setLocked = (user: UserDto, locked: boolean) => - new UserDto(user.id, user.email, user.displayName, user.pictureUrl, locked); \ No newline at end of file + new UserDto(user.id, user.email, user.displayName, locked); \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/immutable-array.spec.ts b/src/Squidex/app/framework/utils/immutable-array.spec.ts index 99e55ad80..967bf58c1 100644 --- a/src/Squidex/app/framework/utils/immutable-array.spec.ts +++ b/src/Squidex/app/framework/utils/immutable-array.spec.ts @@ -195,6 +195,12 @@ describe('ImmutableArray', () => { expect(array_1.mutableValues).toBe(array_1.mutableValues); }); + it('should provider value at index', () => { + const array_1 = ImmutableArray.of([3, 1, 4, 2]); + + expect(array_1.at(2)).toBe(4); + }); + it('should iterate over array items', () => { const array_1 = ImmutableArray.of([3, 1, 4, 2]); diff --git a/src/Squidex/app/framework/utils/immutable-array.ts b/src/Squidex/app/framework/utils/immutable-array.ts index e719a5210..1dc87cc02 100644 --- a/src/Squidex/app/framework/utils/immutable-array.ts +++ b/src/Squidex/app/framework/utils/immutable-array.ts @@ -53,6 +53,10 @@ export class ImmutableArray implements Iterable { } } + public at(index: number) { + return this.values[index]; + } + public map(projection: (item: T) => R): ImmutableArray { return new ImmutableArray(freeze(this.items.map(v => projection(v!)))); } diff --git a/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts b/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts index c9ad4ca8a..fc9ef25ec 100644 --- a/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts +++ b/src/Squidex/app/framework/utils/rxjs-extensions-impl.ts @@ -5,6 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +import { Observable } from 'rxjs'; + +import { DialogService } from './../services/dialog.service'; + export function nextBy(updater: (value: T) => T): void { return this.next(updater(this.value)); +} + +export function notify(dialogs: DialogService) { + return this.catch((error: any) => { + dialogs.notifyError(error); + + return Observable.throw(error); + }); } \ 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 index b670d9bca..753993ec3 100644 --- a/src/Squidex/app/framework/utils/rxjs-extensions.ts +++ b/src/Squidex/app/framework/utils/rxjs-extensions.ts @@ -6,15 +6,26 @@ */ import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { Observable } from 'rxjs/Observable'; -import { nextBy } from './rxjs-extensions-impl'; +import { DialogService } from './../services/dialog.service'; + +import { nextBy, notify } from './rxjs-extensions-impl'; /* tslint:disable:no-shadowed-variable */ declare module 'rxjs/BehaviorSubject' { - interface BehaviorSubject { - nextBy(updater: (value: T) => T): void; + interface Observable { + notify(dialogs: DialogService): Observable; + } +} + +BehaviorSubject.prototype['nextBy'] = nextBy; + +declare module 'rxjs/Observable' { + interface Observable { + notify(dialogs: DialogService): Observable; } } -BehaviorSubject.prototype['nextBy'] = nextBy; \ No newline at end of file +Observable.prototype['notify'] = notify; \ No newline at end of file diff --git a/src/Squidex/app/shared/components/app-context.ts b/src/Squidex/app/shared/components/app-context.ts index b9ecbca70..1fc3b9b36 100644 --- a/src/Squidex/app/shared/components/app-context.ts +++ b/src/Squidex/app/shared/components/app-context.ts @@ -23,7 +23,7 @@ import { @Injectable() export class AppContext { public get app(): AppDto { - return this.appsState.selectedApp.value!; + return this.appsState.snapshot.selectedApp!; } public get appChanges(): Observable { diff --git a/src/Squidex/app/shared/state/apps.state.spec.ts b/src/Squidex/app/shared/state/apps.state.spec.ts index 93e6a3dec..769f44f68 100644 --- a/src/Squidex/app/shared/state/apps.state.spec.ts +++ b/src/Squidex/app/shared/state/apps.state.spec.ts @@ -13,8 +13,7 @@ import { AppsService, AppsState, CreateAppDto, - DateTime, - ImmutableArray + DateTime } from './../'; describe('AppsState', () => { @@ -27,6 +26,7 @@ describe('AppsState', () => { const newApp = new AppDto('id3', 'new-name', 'Owner', now, now, 'Free', 'Plan'); let appsService: IMock; + let appsState: AppsState; beforeEach(() => { appsService = Mock.ofType(AppsService); @@ -34,57 +34,32 @@ describe('AppsState', () => { appsService.setup(x => x.getApps()) .returns(() => Observable.of(oldApps)) .verifiable(Times.once()); - }); - - it('should load automatically', () => { - const store = new AppsState(appsService.object); - - let result1: ImmutableArray; - let result2: ImmutableArray; - store.apps.subscribe(x => { - result1 = x; - }).unsubscribe(); - - store.apps.subscribe(x => { - result2 = x; - }).unsubscribe(); + appsState = new AppsState(appsService.object); + appsState.loadApps().subscribe(); + }); - expect(result1!.values).toEqual(oldApps); - expect(result2!.values).toEqual(oldApps); + it('should load apps', () => { + expect(appsState.snapshot.apps.values).toEqual(oldApps); appsService.verifyAll(); }); - it('should add app to cache when created', () => { + it('should add app to state when created', () => { const request = new CreateAppDto(newApp.name); appsService.setup(x => x.postApp(request)) .returns(() => Observable.of(newApp)) .verifiable(Times.once()); - const store = new AppsState(appsService.object); - - let result1: ImmutableArray; - let result2: ImmutableArray; - - store.apps.subscribe(x => { - result1 = x; - }).unsubscribe(); - - store.createApp(request, now).subscribe(); - - store.apps.subscribe(x => { - result2 = x; - }).unsubscribe(); + appsState.createApp(request, now).subscribe(); - expect(result1!.values).toEqual(oldApps); - expect(result2!.values).toEqual(oldApps.concat([newApp])); + expect(appsState.snapshot.apps.values).toEqual([newApp, ...oldApps]); appsService.verifyAll(); }); - it('should remove app from cache when archived', () => { + it('should remove app from state when archived', () => { const request = new CreateAppDto(newApp.name); appsService.setup(x => x.postApp(request)) @@ -95,48 +70,50 @@ describe('AppsState', () => { .returns(() => Observable.of({})) .verifiable(Times.once()); - const store = new AppsState(appsService.object); + appsState.createApp(request, now).subscribe(); - let result1: ImmutableArray; - let result2: ImmutableArray; - let result3: ImmutableArray; + const appsAfterCreate = appsState.snapshot.apps.values; - store.apps.subscribe(x => { - result1 = x; - }).unsubscribe(); + appsState.deleteApp(newApp.name).subscribe(); - store.createApp(request, now).subscribe(); + const appsAfterDelete = appsState.snapshot.apps.values; - store.apps.subscribe(x => { - result2 = x; - }).unsubscribe(); + expect(appsAfterCreate).toEqual([newApp, ...oldApps]); + expect(appsAfterDelete).toEqual(oldApps); - store.deleteApp(newApp.name).subscribe(); + appsService.verifyAll(); + }); - store.apps.subscribe(x => { - result3 = x; - }).unsubscribe(); + it('should select app', () => { + let selectedApp: AppDto; - expect(result1!.values).toEqual(oldApps); - expect(result2!.values).toEqual(oldApps.concat([newApp])); - expect(result3!.values).toEqual(oldApps); + appsState.selectApp(oldApps[0].name).subscribe(x => { + selectedApp = x!; + }).unsubscribe(); - appsService.verifyAll(); + expect(selectedApp!).toBe(oldApps[0]); + expect(appsState.snapshot.selectedApp).toBe(oldApps[0]); }); - it('should select app', (done) => { - const store = new AppsState(appsService.object); + it('should return null when unselecting app', () => { + let selectedApp: AppDto; + + appsState.selectApp(null).subscribe(x => { + selectedApp = x!; + }).unsubscribe(); - store.selectApp(oldApps[0].name).subscribe(isSelected => { - expect(isSelected).toBeTruthy(); + expect(selectedApp!).toBeNull(); + expect(appsState.snapshot.selectedApp).toBeNull(); + }); - appsService.verifyAll(); + it('should return null when app to select is not found', () => { + let selectedApp: AppDto; - done(); - }, err => { - expect(err).toBeNull(); + appsState.selectApp('unknown').subscribe(x => { + selectedApp = x!; + }).unsubscribe(); - done(); - }); + expect(selectedApp!).toBeNull(); + expect(appsState.snapshot.selectedApp).toBeNull(); }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/apps.state.ts b/src/Squidex/app/shared/state/apps.state.ts index 76a72b454..6b48aaf0a 100644 --- a/src/Squidex/app/shared/state/apps.state.ts +++ b/src/Squidex/app/shared/state/apps.state.ts @@ -9,9 +9,13 @@ import '@app/framework/utils/rxjs-extensions'; import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; -import { DateTime, ImmutableArray } from '@app/framework'; +import { + DateTime, + ImmutableArray, + State +} from '@app/framework'; import { AppDto, @@ -19,51 +23,70 @@ import { CreateAppDto } from './../services/apps.service'; -@Injectable() -export class AppsState { - public apps = new BehaviorSubject>(ImmutableArray.empty()); +interface Snapshot { + apps: ImmutableArray; - public selectedApp = new BehaviorSubject(null); + selectedApp: AppDto | null; +} +@Injectable() +export class AppsState extends State { public get appName() { - return this.selectedApp.value!.name; + return this.snapshot.selectedApp!.name; } + public selectedApp = + this.changes.map(s => s.selectedApp); + + public apps = + this.changes.map(s => s.apps); + constructor( private readonly appsService: AppsService ) { - } - - public loadApps(): Observable { - return this.appsService.getApps() - .do(dtos => { - this.apps.nextBy(v => ImmutableArray.of(dtos)); - }); + super({ apps: ImmutableArray.empty(), selectedApp: null }); } public selectApp(name: string | null): Observable { const observable = !name ? - Observable.of(null) : - Observable.of(this.apps.value.find(x => x.name === name) || null); + Observable.of(null) : + Observable.of(this.snapshot.apps.find(x => x.name === name) || null); return observable - .do(app => { - this.selectedApp.next(app); + .do(selectedApp => { + this.next(s => { + return { ...s, selectedApp }; + }); }); } - public createApp(dto: CreateAppDto, now?: DateTime): Observable { - return this.appsService.postApp(dto) - .do(app => { - this.apps.nextBy(v => v.push(app)); + public loadApps(): Observable { + return this.appsService.getApps() + .do(dtos => { + this.next(s => { + return { ...s, apps: ImmutableArray.of(dtos) }; + }); + }); + } + + public createApp(request: CreateAppDto, now?: DateTime): Observable { + return this.appsService.postApp(request) + .do(dto => { + this.next(s => { + return { ...s, apps: s.apps.push(dto).sortByStringAsc(x => x.name) }; + }); }); } public deleteApp(name: string): Observable { return this.appsService.deleteApp(name) .do(app => { - this.apps.nextBy(v => v.filter(a => a.name !== name)); + this.next(s => { + const selectedApp = s.selectedApp && s.selectedApp.name === name ? null : s.selectedApp; + + return { apps: s.apps.filter(x => x.name !== name), selectedApp }; + }); }); } } \ No newline at end of file diff --git a/src/Squidex/app/shell/pages/internal/apps-menu.component.html b/src/Squidex/app/shell/pages/internal/apps-menu.component.html index a1586afeb..821017579 100644 --- a/src/Squidex/app/shell/pages/internal/apps-menu.component.html +++ b/src/Squidex/app/shell/pages/internal/apps-menu.component.html @@ -1,37 +1,51 @@