From b31995c51d946be986e867b85853a0c06b26a12a Mon Sep 17 00:00:00 2001 From: Janik Endtner Date: Fri, 2 Feb 2024 10:50:26 +0100 Subject: [PATCH] #790 call backend when creating users + check if email not exists --- .../java/ch/puzzle/okr/dto/NewUserDto.java | 4 +++ .../src/app/services/user.service.spec.ts | 36 +++++++++++++++---- frontend/src/app/services/user.service.ts | 12 ++++++- .../invite-user-dialog.component.spec.ts | 28 ++++++++++++++- .../invite-user-dialog.component.ts | 11 ++++-- .../member-detail/member-detail.component.ts | 2 -- .../new-user/new-user.component.html | 13 +++++-- .../new-user/new-user.component.ts | 6 ++-- .../new-user/unique-mail.directive.spec.ts | 36 +++++++++++++++++++ .../new-user/unique-mail.directive.ts | 32 +++++++++++++++++ .../team-management/team-management.module.ts | 2 ++ frontend/src/assets/i18n/de.json | 3 +- 12 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 backend/src/main/java/ch/puzzle/okr/dto/NewUserDto.java create mode 100644 frontend/src/app/team-management/new-user/unique-mail.directive.spec.ts create mode 100644 frontend/src/app/team-management/new-user/unique-mail.directive.ts diff --git a/backend/src/main/java/ch/puzzle/okr/dto/NewUserDto.java b/backend/src/main/java/ch/puzzle/okr/dto/NewUserDto.java new file mode 100644 index 0000000000..5874453023 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/dto/NewUserDto.java @@ -0,0 +1,4 @@ +package ch.puzzle.okr.dto; + +public record NewUserDto(String firstname, String lastname, String email) { +} diff --git a/frontend/src/app/services/user.service.spec.ts b/frontend/src/app/services/user.service.spec.ts index c672c89e26..852129736f 100644 --- a/frontend/src/app/services/user.service.spec.ts +++ b/frontend/src/app/services/user.service.spec.ts @@ -1,7 +1,7 @@ -import { getTestBed, TestBed } from '@angular/core/testing'; +import { fakeAsync, getTestBed, TestBed, tick } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { UserService } from './user.service'; -import { users } from '../shared/testData'; +import { testUser, users } from '../shared/testData'; describe('UserService', () => { let service: UserService; @@ -21,11 +21,11 @@ describe('UserService', () => { httpMock.verify(); }); - test('should be created', () => { + it('should be created', () => { expect(service).toBeTruthy(); }); - test('getUsers should only reload users when they are not loaded yet', (done) => { + it('getUsers should only reload users when they are not loaded yet', (done) => { const spy = jest.spyOn(service, 'reloadUsers'); service.getUsers().subscribe(() => { expect(spy).toBeCalledTimes(1); @@ -38,11 +38,11 @@ describe('UserService', () => { }); }); - test('get current user should throw error, when not loaded', () => { + it('get current user should throw error, when not loaded', () => { expect(() => service.getCurrentUser()).toThrowError('user should not be undefined here'); }); - test('init current user should load user', (done) => { + it('init current user should load user', (done) => { expect(() => service.getCurrentUser()).toThrowError('user should not be undefined here'); service.getOrInitCurrentUser().subscribe(() => { expect(service.getCurrentUser()).toBe(users[0]); @@ -51,4 +51,28 @@ describe('UserService', () => { const req = httpMock.expectOne('api/v1/users/current'); req.flush(users[0]); }); + + it('setIsOkrChampion should call put operation, reloadUsers and reloadCurrentUser', fakeAsync(() => { + service.setIsOkrChampion(testUser, true).subscribe(); + const req = httpMock.expectOne(`api/v1/users/${testUser.id}/isokrchampion/true`); + req.flush(users[0]); + + tick(); + + const req2 = httpMock.expectOne(`api/v1/users`); + const req3 = httpMock.expectOne(`api/v1/users/current`); + req2.flush({}); + req3.flush({}); + })); + + it('createUsers should call createAll and reloadUsers', fakeAsync(() => { + service.createUsers(users).subscribe(); + const req = httpMock.expectOne(`api/v1/users/createall`); + req.flush(users); + + tick(); + + const req2 = httpMock.expectOne(`api/v1/users`); + req2.flush({}); + })); }); diff --git a/frontend/src/app/services/user.service.ts b/frontend/src/app/services/user.service.ts index c632de9e0e..8900999470 100644 --- a/frontend/src/app/services/user.service.ts +++ b/frontend/src/app/services/user.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, of, tap } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { User } from '../shared/types/model/User'; +import { NewUser } from '../shared/types/model/NewUser'; @Injectable({ providedIn: 'root', @@ -50,6 +51,15 @@ export class UserService { } setIsOkrChampion(user: User, isOkrChampion: boolean) { - return this.httpClient.put(`${this.API_URL}/${user.id}/isokrchampion/${isOkrChampion}`, {}); + return this.httpClient.put(`${this.API_URL}/${user.id}/isokrchampion/${isOkrChampion}`, {}).pipe( + tap(() => { + this.reloadUsers(); + this.reloadCurrentUser().subscribe(); + }), + ); + } + + createUsers(userList: NewUser[]) { + return this.httpClient.post(`${this.API_URL}/createall`, userList).pipe(tap(() => this.reloadUsers())); } } diff --git a/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.spec.ts b/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.spec.ts index 394b706aaf..ee15263da7 100644 --- a/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.spec.ts +++ b/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { InviteUserDialogComponent } from './invite-user-dialog.component'; import { MatDialogModule } from '@angular/material/dialog'; @@ -6,15 +6,31 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NewUserComponent } from '../new-user/new-user.component'; import { PuzzleIconComponent } from '../../shared/custom/puzzle-icon/puzzle-icon.component'; import { PuzzleIconButtonComponent } from '../../shared/custom/puzzle-icon-button/puzzle-icon-button.component'; +import { UserService } from '../../services/user.service'; +import { testUser } from '../../shared/testData'; +import { DialogRef } from '@angular/cdk/dialog'; +import { of } from 'rxjs'; describe('InviteUserDialogComponent', () => { let component: InviteUserDialogComponent; let fixture: ComponentFixture; + const userServiceMock = { + createUsers: jest.fn(), + }; + + const dialogRefMock = { + close: jest.fn(), + }; + beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [InviteUserDialogComponent, NewUserComponent, PuzzleIconComponent, PuzzleIconButtonComponent], imports: [MatDialogModule, FormsModule, ReactiveFormsModule], + providers: [ + { provide: UserService, useValue: userServiceMock }, + { provide: DialogRef, useValue: dialogRefMock }, + ], }).compileComponents(); fixture = TestBed.createComponent(InviteUserDialogComponent); @@ -41,4 +57,14 @@ describe('InviteUserDialogComponent', () => { expect(component.users).toStrictEqual([user1, user3]); }); + + it('inviteUsers should call createUsers and close dialog', fakeAsync(() => { + userServiceMock.createUsers.mockReturnValue(of([testUser])); + component.inviteUsers(); + tick(); + + expect(userServiceMock.createUsers).toBeCalledTimes(1); + expect(userServiceMock.createUsers).toBeCalledWith(component.users); + expect(dialogRefMock.close).toBeCalledTimes(1); + })); }); diff --git a/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.ts b/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.ts index a292c22e0f..87a2a722b1 100644 --- a/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.ts +++ b/frontend/src/app/team-management/invite-user-dialog/invite-user-dialog.component.ts @@ -1,6 +1,8 @@ import { Component, ViewChild } from '@angular/core'; import { NewUser } from '../../shared/types/model/NewUser'; import { NgForm } from '@angular/forms'; +import { UserService } from '../../services/user.service'; +import { DialogRef } from '@angular/cdk/dialog'; @Component({ selector: 'app-invite-user-dialog', @@ -14,9 +16,14 @@ export class InviteUserDialogComponent { users: NewUser[] = [{ ...this.emptyUser }]; - constructor() {} + constructor( + private readonly userService: UserService, + private readonly dialogRef: DialogRef, + ) {} - inviteUsers() {} + inviteUsers() { + this.userService.createUsers(this.users).subscribe(() => this.dialogRef.close()); + } addUser() { this.users.push({ ...this.emptyUser }); diff --git a/frontend/src/app/team-management/member-detail/member-detail.component.ts b/frontend/src/app/team-management/member-detail/member-detail.component.ts index d6a2127e68..b840c238fa 100644 --- a/frontend/src/app/team-management/member-detail/member-detail.component.ts +++ b/frontend/src/app/team-management/member-detail/member-detail.component.ts @@ -130,9 +130,7 @@ export class MemberDetailComponent implements OnInit, OnDestroy { isOkrChampionChange(okrChampion: boolean, user: User) { this.userService.setIsOkrChampion(user, okrChampion).subscribe(() => { this.loadUser(user.id); - this.userService.reloadUsers(); this.teamService.reloadTeams(); - this.userService.reloadCurrentUser().subscribe(); }); } } diff --git a/frontend/src/app/team-management/new-user/new-user.component.html b/frontend/src/app/team-management/new-user/new-user.component.html index 613bfc28a4..7b2c8a0cbc 100644 --- a/frontend/src/app/team-management/new-user/new-user.component.html +++ b/frontend/src/app/team-management/new-user/new-user.component.html @@ -18,7 +18,7 @@ @if (showError(firstName)) { @if (firstName.invalid) { - Ungültige Angabe + Angabe benötigt } } @@ -41,7 +41,7 @@ @if (showError(lastName)) { @if (lastName.invalid) { - Ungültige Angabe + Angabe benötigt } } @@ -60,12 +60,19 @@ class="value-field" #email="ngModel" email + appUniqueEmail [(ngModel)]="user.email" /> @if (showError(email)) { @if (email.invalid) { - Ungültige Angabe + @if (email.errors?.["required"]) { + Angabe benötigt + } @else if (email.errors?.["notUniqueMail"]) { + E-Mail existiert bereits + } @else if (email.errors?.["email"]) { + E-Mail ungültig + } } } diff --git a/frontend/src/app/team-management/new-user/new-user.component.ts b/frontend/src/app/team-management/new-user/new-user.component.ts index 613b2412ed..c6494edd91 100644 --- a/frontend/src/app/team-management/new-user/new-user.component.ts +++ b/frontend/src/app/team-management/new-user/new-user.component.ts @@ -18,8 +18,6 @@ import { ControlContainer, NgForm, NgModel } from '@angular/forms'; changeDetection: ChangeDetectionStrategy.Default, }) export class NewUserComponent implements AfterViewInit { - randNr = Math.round(Math.random() * 10000); - @Input({ required: true }) index!: number; @@ -35,8 +33,8 @@ export class NewUserComponent implements AfterViewInit { this.firstInput.nativeElement.focus(); } - showError(firstName: NgModel) { - return firstName.invalid && (firstName.dirty || firstName.touched); + showError(control: NgModel) { + return control.invalid && (control.dirty || control.touched); } remove() { diff --git a/frontend/src/app/team-management/new-user/unique-mail.directive.spec.ts b/frontend/src/app/team-management/new-user/unique-mail.directive.spec.ts new file mode 100644 index 0000000000..aa4d8068da --- /dev/null +++ b/frontend/src/app/team-management/new-user/unique-mail.directive.spec.ts @@ -0,0 +1,36 @@ +import { UniqueEmailValidatorDirective } from './unique-mail.directive'; +import { users } from '../../shared/testData'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AbstractControl } from '@angular/forms'; + +describe('UniqueMailDirective', () => { + const userServiceMock = { + getUsers: jest.fn(), + } as any; + + beforeEach(() => { + userServiceMock.getUsers.mockReturnValue(of(users)); + }); + + it('should create an instance', () => { + TestBed.runInInjectionContext(() => { + const directive = new UniqueEmailValidatorDirective(userServiceMock); + expect(directive).toBeTruthy(); + }); + }); + + it('should return validationError if user exists, otherwise null', () => { + TestBed.runInInjectionContext(() => { + const directive = new UniqueEmailValidatorDirective(userServiceMock); + + let control = { value: users[0].email } as AbstractControl; + expect(directive.validate(control)).toStrictEqual({ notUniqueMail: { value: users[0].email } }); + + control = { value: 'notexistinguser@test.com' } as AbstractControl; + expect(directive.validate(control)).toStrictEqual(null); + + expect(directive).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/app/team-management/new-user/unique-mail.directive.ts b/frontend/src/app/team-management/new-user/unique-mail.directive.ts new file mode 100644 index 0000000000..cec125ab34 --- /dev/null +++ b/frontend/src/app/team-management/new-user/unique-mail.directive.ts @@ -0,0 +1,32 @@ +import { Directive } from '@angular/core'; +import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { UserService } from '../../services/user.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Directive({ + selector: '[appUniqueEmail]', + providers: [ + { + provide: NG_VALIDATORS, + useExisting: UniqueEmailValidatorDirective, + multi: true, + }, + ], +}) +export class UniqueEmailValidatorDirective implements Validator { + private existingUserMails: string[] = []; + + constructor(private readonly userService: UserService) { + this.userService + .getUsers() + .pipe(takeUntilDestroyed()) + .subscribe((users) => { + this.existingUserMails = users.map((u) => u.email); + }); + } + + validate(control: AbstractControl): ValidationErrors | null { + const existingUser = this.existingUserMails.includes(control.value); + return existingUser ? { notUniqueMail: { value: control.value } } : null; + } +} diff --git a/frontend/src/app/team-management/team-management.module.ts b/frontend/src/app/team-management/team-management.module.ts index 03a57af44e..4308be31aa 100644 --- a/frontend/src/app/team-management/team-management.module.ts +++ b/frontend/src/app/team-management/team-management.module.ts @@ -40,6 +40,7 @@ import { EditOkrChampionComponent } from './edit-okr-champion/edit-okr-champion. import { MatTooltipModule } from '@angular/material/tooltip'; import { NewUserComponent } from './new-user/new-user.component'; import { InviteUserDialogComponent } from './invite-user-dialog/invite-user-dialog.component'; +import { UniqueEmailValidatorDirective } from './new-user/unique-mail.directive'; @NgModule({ declarations: [ @@ -64,6 +65,7 @@ import { InviteUserDialogComponent } from './invite-user-dialog/invite-user-dial EditOkrChampionComponent, NewUserComponent, InviteUserDialogComponent, + UniqueEmailValidatorDirective, ], imports: [ CommonModule, diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index d0219e84a2..8dee2ef4f2 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -81,7 +81,8 @@ "PUT": "Das Check-in wurde erfolgreich aktualisiert." }, "USERS": { - "PUT": "Der User wurde erfolgreich aktualisiert." + "PUT": "Der Member wurde erfolgreich aktualisiert.", + "POST": "Die Members wurden erfolgreich eingeladen" } }, "DIALOG_ERRORS": {