diff --git a/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts b/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts index 6d5f7aa88e..468ebaac96 100644 --- a/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts +++ b/e2e/cypress/integration/pages/organizationmanagement/users-detail.page.ts @@ -1,7 +1,7 @@ import { HeaderModule } from '../header.module'; export class UsersDetailPage { - readonly tag = 'ish-users-detail-page'; + readonly tag = 'ish-user-detail-page'; readonly header = new HeaderModule(); diff --git a/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts b/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts index 5264288c9f..b40c287913 100644 --- a/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts +++ b/e2e/cypress/integration/specs/organizationmanagement/user-management-browsing.b2b.e2e-spec.ts @@ -34,7 +34,7 @@ describe('User Management', () => { page.goToUserDetailLink(_.selectedUser.email); }); at(UsersDetailPage, page => { - page.name.should('have.text', `${_.selectedUser.name}`); + page.name.should('contain', `${_.selectedUser.name}`); page.email.should('have.text', `${_.selectedUser.email}`); }); }); diff --git a/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.html b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.html new file mode 100644 index 0000000000..c820e3abbe --- /dev/null +++ b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.html @@ -0,0 +1,39 @@ +
+ +

*{{ 'account.required_field.message' | translate }}

+
+ + + +
+
+ +
+
+ +
+
diff --git a/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.spec.ts b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.spec.ts new file mode 100644 index 0000000000..aaf5d3ec2a --- /dev/null +++ b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.spec.ts @@ -0,0 +1,65 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { CustomValidators } from 'ngx-custom-validators'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { Locale } from 'ish-core/models/locale/locale.model'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { InputComponent } from 'ish-shared/forms/components/input/input.component'; +import { SelectTitleComponent } from 'ish-shared/forms/components/select-title/select-title.component'; + +import { UserProfileFormComponent } from './user-profile-form.component'; + +describe('User Profile Form Component', () => { + let component: UserProfileFormComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let fb: FormBuilder; + let appFacade: AppFacade; + + beforeEach(async(() => { + appFacade = mock(AppFacade); + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(ErrorMessageComponent), + MockComponent(InputComponent), + MockComponent(SelectTitleComponent), + UserProfileFormComponent, + ], + providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserProfileFormComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fb = TestBed.inject(FormBuilder); + when(appFacade.currentLocale$).thenReturn(of({ lang: 'en_US' } as Locale)); + + component.form = fb.group({ + email: ['', [Validators.required, CustomValidators.email]], + }); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display form input fields on creation', () => { + fixture.detectChanges(); + + expect(element.querySelector('[controlname=firstName]')).toBeTruthy(); + expect(element.querySelector('[controlname=lastName]')).toBeTruthy(); + expect(element.querySelector('[controlname=phone]')).toBeTruthy(); + }); +}); diff --git a/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.ts b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.ts new file mode 100644 index 0000000000..53f587f5c2 --- /dev/null +++ b/projects/organization-management/src/app/components/user/user-profile-form/user-profile-form.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { Locale } from 'ish-core/models/locale/locale.model'; +import { whenTruthy } from 'ish-core/utils/operators'; +import { determineSalutations } from 'ish-shared/forms/utils/form-utils'; + +@Component({ + selector: 'ish-user-profile-form', + templateUrl: './user-profile-form.component.html', + changeDetection: ChangeDetectionStrategy.Default, +}) +export class UserProfileFormComponent implements OnInit, OnDestroy { + @Input() form: FormGroup; + @Input() error: HttpError; + + currentLocale$: Observable; + private destroy$ = new Subject(); + + titles = []; + + constructor(private appFacade: AppFacade) {} + + ngOnInit() { + this.currentLocale$ = this.appFacade.currentLocale$; + + // determine default language from session and available locales + this.currentLocale$.pipe(whenTruthy(), takeUntil(this.destroy$)).subscribe(locale => { + this.titles = locale?.lang ? determineSalutations(locale.lang.slice(3)) : undefined; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/organization-management/src/app/exports/index.ts b/projects/organization-management/src/app/exports/index.ts index 2b2f703466..814d86979b 100644 --- a/projects/organization-management/src/app/exports/index.ts +++ b/projects/organization-management/src/app/exports/index.ts @@ -1,3 +1,5 @@ // tslint:disable: no-barrel-files export { OrganizationManagementModule } from '../organization-management.module'; + +export { OrganizationManagementBreadcrumbService } from '../services/organization-management-breadcrumb/organization-management-breadcrumb.service'; diff --git a/projects/organization-management/src/app/facades/organization-management.facade.ts b/projects/organization-management/src/app/facades/organization-management.facade.ts index 072c25e915..28974f50de 100644 --- a/projects/organization-management/src/app/facades/organization-management.facade.ts +++ b/projects/organization-management/src/app/facades/organization-management.facade.ts @@ -1,18 +1,44 @@ import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; -import { getSelectedUser, getUsers, getUsersError, getUsersLoading, loadUsers } from '../store/users'; +import { B2bUser } from '../models/b2b-user/b2b-user.model'; +import { + addUser, + getSelectedUser, + getUsers, + getUsersError, + getUsersLoading, + loadUsers, + updateUser, +} from '../store/users'; // tslint:disable:member-ordering @Injectable({ providedIn: 'root' }) export class OrganizationManagementFacade { constructor(private store: Store) {} + usersError$ = this.store.pipe(select(getUsersError)); + usersLoading$ = this.store.pipe(select(getUsersLoading)); + selectedUser$ = this.store.pipe(select(getSelectedUser)); + users$() { this.store.dispatch(loadUsers()); return this.store.pipe(select(getUsers)); } - usersError$ = this.store.pipe(select(getUsersError)); - usersLoading$ = this.store.pipe(select(getUsersLoading)); - selectedUser$ = this.store.pipe(select(getSelectedUser)); + + addUser(user: B2bUser) { + this.store.dispatch( + addUser({ + user, + }) + ); + } + + updateUser(user: B2bUser) { + this.store.dispatch( + updateUser({ + user, + }) + ); + } } diff --git a/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts b/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts index 9734ca5249..553ba3a724 100644 --- a/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts +++ b/projects/organization-management/src/app/models/b2b-user/b2b-user.model.ts @@ -1,5 +1,11 @@ +import { Customer } from 'ish-core/models/customer/customer.model'; import { User } from 'ish-core/models/user/user.model'; +export interface CustomerB2bUserType { + customer: Customer; + user: B2bUser; +} + export interface B2bUser extends Partial { name?: string; // list call only } diff --git a/projects/organization-management/src/app/organization-management.module.ts b/projects/organization-management/src/app/organization-management.module.ts index a7af10f3e7..ee7797f06f 100644 --- a/projects/organization-management/src/app/organization-management.module.ts +++ b/projects/organization-management/src/app/organization-management.module.ts @@ -2,13 +2,22 @@ import { NgModule } from '@angular/core'; import { SharedModule } from 'ish-shared/shared.module'; +import { UserProfileFormComponent } from './components/user/user-profile-form/user-profile-form.component'; import { OrganizationManagementRoutingModule } from './pages/organization-management-routing.module'; -import { UsersDetailPageComponent } from './pages/users-detail/users-detail-page.component'; +import { UserCreatePageComponent } from './pages/user-create/user-create-page.component'; +import { UserDetailPageComponent } from './pages/user-detail/user-detail-page.component'; +import { UserEditProfilePageComponent } from './pages/user-edit-profile/user-edit-profile-page.component'; import { UsersPageComponent } from './pages/users/users-page.component'; import { OrganizationManagementStoreModule } from './store/organization-management-store.module'; @NgModule({ - declarations: [UsersDetailPageComponent, UsersPageComponent], + declarations: [ + UserCreatePageComponent, + UserDetailPageComponent, + UserEditProfilePageComponent, + UserProfileFormComponent, + UsersPageComponent, + ], imports: [OrganizationManagementRoutingModule, OrganizationManagementStoreModule, SharedModule], }) export class OrganizationManagementModule {} diff --git a/projects/organization-management/src/app/pages/organization-management-routing.module.ts b/projects/organization-management/src/app/pages/organization-management-routing.module.ts index e15c67d43b..dbd4cc579e 100644 --- a/projects/organization-management/src/app/pages/organization-management-routing.module.ts +++ b/projects/organization-management/src/app/pages/organization-management-routing.module.ts @@ -1,20 +1,33 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { UsersDetailPageComponent } from './users-detail/users-detail-page.component'; +import { UserCreatePageComponent } from './user-create/user-create-page.component'; +import { UserDetailPageComponent } from './user-detail/user-detail-page.component'; +import { UserEditProfilePageComponent } from './user-edit-profile/user-edit-profile-page.component'; import { UsersPageComponent } from './users/users-page.component'; -const routes: Routes = [ +/** + * routes for the organization management + * + * visible for testing + */ +export const routes: Routes = [ { path: '', redirectTo: 'users', pathMatch: 'full' }, { path: 'users', component: UsersPageComponent, - data: { breadcrumbData: [{ key: 'account.organization.user_management' }] }, + }, + { + path: 'users/create', + component: UserCreatePageComponent, }, { path: 'users/:B2BCustomerLogin', - component: UsersDetailPageComponent, - data: { breadcrumbData: [{ key: 'account.organization.user_management.user_detail' }] }, + component: UserDetailPageComponent, + }, + { + path: 'users/:B2BCustomerLogin/profile', + component: UserEditProfilePageComponent, }, ]; diff --git a/projects/organization-management/src/app/pages/user-create/user-create-page.component.html b/projects/organization-management/src/app/pages/user-create/user-create-page.component.html new file mode 100644 index 0000000000..29ff9b5691 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-create/user-create-page.component.html @@ -0,0 +1,17 @@ +
+
+

{{ 'account.user.new.heading' | translate }}

+
+ +
+
+ + {{ 'account.cancel.link' | translate }} +
+
+
+
+ +
diff --git a/projects/organization-management/src/app/pages/user-create/user-create-page.component.spec.ts b/projects/organization-management/src/app/pages/user-create/user-create-page.component.spec.ts new file mode 100644 index 0000000000..d62bea9205 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-create/user-create-page.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { CustomValidators } from 'ngx-custom-validators'; +import { instance, mock } from 'ts-mockito'; + +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { UserProfileFormComponent } from '../../components/user/user-profile-form/user-profile-form.component'; +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; + +import { UserCreatePageComponent } from './user-create-page.component'; + +describe('User Create Page Component', () => { + let component: UserCreatePageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let organizationManagementFacade: OrganizationManagementFacade; + let fb: FormBuilder; + + beforeEach(async(() => { + organizationManagementFacade = mock(OrganizationManagementFacade); + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [MockComponent(LoadingComponent), MockComponent(UserProfileFormComponent), UserCreatePageComponent], + providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserCreatePageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fb = TestBed.inject(FormBuilder); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should submit a valid form when the user fills all required fields', () => { + fixture.detectChanges(); + + component.form = fb.group({ + profile: fb.group({ + firstName: ['Bernhard', [Validators.required]], + lastName: ['Boldner', [Validators.required]], + email: ['test@gmail.com', [Validators.required, CustomValidators.email]], + preferredLanguage: ['en_US', [Validators.required]], + }), + }); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + expect(component.formDisabled).toBeFalse(); + }); + + it('should disable submit button when the user submits an invalid form', () => { + fixture.detectChanges(); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + expect(component.formDisabled).toBeTrue(); + }); +}); diff --git a/projects/organization-management/src/app/pages/user-create/user-create-page.component.ts b/projects/organization-management/src/app/pages/user-create/user-create-page.component.ts new file mode 100644 index 0000000000..49cb8e21cc --- /dev/null +++ b/projects/organization-management/src/app/pages/user-create/user-create-page.component.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { UUID } from 'angular2-uuid'; +import { CustomValidators } from 'ngx-custom-validators'; +import { Observable } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; + +@Component({ + selector: 'ish-user-create-page', + templateUrl: './user-create-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserCreatePageComponent implements OnInit { + loading$: Observable; + userError$: Observable; + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder, private organizationManagementFacade: OrganizationManagementFacade) {} + + ngOnInit() { + this.loading$ = this.organizationManagementFacade.usersLoading$; + this.userError$ = this.organizationManagementFacade.usersError$; + + this.createAddUserForm(); + } + + createAddUserForm() { + this.form = this.fb.group({ + profile: this.fb.group({ + title: [''], + firstName: ['', [Validators.required]], + lastName: ['', [Validators.required]], + email: ['', [Validators.required, CustomValidators.email]], + phone: [''], + birthday: [''], + preferredLanguage: ['en_US', [Validators.required]], + }), + }); + } + + get profile() { + return this.form.get('profile'); + } + + submitForm() { + if (this.form.invalid) { + this.submitted = true; + markAsDirtyRecursive(this.form); + return; + } + + const formValue = this.form.value; + + const user: B2bUser = { + title: formValue.profile.title, + firstName: formValue.profile.firstName, + lastName: formValue.profile.lastName, + email: formValue.profile.email, + phoneHome: formValue.profile.phone, + birthday: formValue.profile.birthday === '' ? undefined : formValue.birthday, // TODO: see IS-22276 + preferredLanguage: formValue.profile.preferredLanguage, + businessPartnerNo: 'U' + UUID.UUID(), + }; + + this.organizationManagementFacade.addUser(user); + } + + get formDisabled() { + return this.form.invalid && this.submitted; + } +} diff --git a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.html b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.html similarity index 67% rename from projects/organization-management/src/app/pages/users-detail/users-detail-page.component.html rename to projects/organization-management/src/app/pages/user-detail/user-detail-page.component.html index 43c9058161..37753be32d 100644 --- a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.html +++ b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.html @@ -9,7 +9,9 @@

{{ 'account.user.details.profile.heading' | translate }}

{{ 'account.user.details.profile.name' | translate }}
-
{{ user.firstName }} {{ user.lastName }}
+
+ {{ user.title }} {{ user.firstName }} {{ user.lastName }} +
{{ 'account.user.details.profile.email' | translate }}
{{ user.email }}
{{ 'account.user.details.profile.phone' | translate }}
@@ -18,6 +20,17 @@

{{ 'account.user.details.profile.heading' | translate }}

+ +
+ + + +
{{ 'account.organization.user_management.back_to_list' | translate }} diff --git a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.spec.ts b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts similarity index 69% rename from projects/organization-management/src/app/pages/users-detail/users-detail-page.component.spec.ts rename to projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts index 17ecc32732..4597f6a0d9 100644 --- a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.spec.ts +++ b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.spec.ts @@ -1,4 +1,5 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent } from 'ng-mocks'; @@ -8,28 +9,29 @@ import { instance, mock, when } from 'ts-mockito'; import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; -import { UsersDetailPageComponent } from './users-detail-page.component'; +import { UserDetailPageComponent } from './user-detail-page.component'; -describe('Users Detail Page Component', () => { - let component: UsersDetailPageComponent; - let fixture: ComponentFixture; +describe('User Detail Page Component', () => { + let component: UserDetailPageComponent; + let fixture: ComponentFixture; let element: HTMLElement; let organizationManagementFacade: OrganizationManagementFacade; const user = { login: '1', firstName: 'Patricia', lastName: 'Miller', email: 'pmiller@test.intershop.de' } as B2bUser; - beforeEach(() => { + beforeEach(async(() => { organizationManagementFacade = mock(OrganizationManagementFacade); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [MockComponent(FaIconComponent), UsersDetailPageComponent], + imports: [RouterTestingModule, TranslateModule.forRoot()], + declarations: [MockComponent(FaIconComponent), UserDetailPageComponent], providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], - }); + }).compileComponents(); + })); - fixture = TestBed.createComponent(UsersDetailPageComponent); + beforeEach(() => { + fixture = TestBed.createComponent(UserDetailPageComponent); component = fixture.componentInstance; element = fixture.nativeElement; - when(organizationManagementFacade.selectedUser$).thenReturn(of(user)); }); it('should be created', () => { @@ -39,9 +41,10 @@ describe('Users Detail Page Component', () => { }); it('should display user data after creation ', () => { + when(organizationManagementFacade.selectedUser$).thenReturn(of(user)); fixture.detectChanges(); - expect(element.querySelector('[data-testing-id="name-field"]').innerHTML).toBe('Patricia Miller'); + expect(element.querySelector('[data-testing-id="name-field"]').innerHTML).toContain('Patricia Miller'); expect(element.querySelector('[data-testing-id="email-field"]').innerHTML).toBe('pmiller@test.intershop.de'); expect(element.querySelector('[data-testing-id="phone-label"]')).toBeTruthy(); }); diff --git a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.ts b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.ts similarity index 78% rename from projects/organization-management/src/app/pages/users-detail/users-detail-page.component.ts rename to projects/organization-management/src/app/pages/user-detail/user-detail-page.component.ts index c856badd27..785826f0a6 100644 --- a/projects/organization-management/src/app/pages/users-detail/users-detail-page.component.ts +++ b/projects/organization-management/src/app/pages/user-detail/user-detail-page.component.ts @@ -5,11 +5,11 @@ import { OrganizationManagementFacade } from '../../facades/organization-managem import { B2bUser } from '../../models/b2b-user/b2b-user.model'; @Component({ - selector: 'ish-users-detail-page', - templateUrl: './users-detail-page.component.html', + selector: 'ish-user-detail-page', + templateUrl: './user-detail-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UsersDetailPageComponent implements OnInit { +export class UserDetailPageComponent implements OnInit { user$: Observable; constructor(private organizationManagementFacade: OrganizationManagementFacade) {} diff --git a/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.html b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.html new file mode 100644 index 0000000000..2f55e798d1 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.html @@ -0,0 +1,15 @@ +
+

{{ 'account.user.update_profile.heading' | translate }} - {{ user.firstName }} {{ user.lastName }}

+
+ +
+
+ + {{ 'account.cancel.link' | translate }} +
+
+
+ +
diff --git a/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.spec.ts b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.spec.ts new file mode 100644 index 0000000000..81d3c40567 --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { UserProfileFormComponent } from '../../components/user/user-profile-form/user-profile-form.component'; +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; + +import { UserEditProfilePageComponent } from './user-edit-profile-page.component'; + +describe('User Edit Profile Page Component', () => { + let component: UserEditProfilePageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let organizationManagementFacade: OrganizationManagementFacade; + let fb: FormBuilder; + + const user = { + login: '1', + title: 'Mr.', + firstName: 'Bernhard', + lastName: 'Boldner', + preferredLanguage: 'en_US', + email: 'test@gmail.com', + } as B2bUser; + + beforeEach(async(() => { + organizationManagementFacade = mock(OrganizationManagementFacade); + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + MockComponent(LoadingComponent), + MockComponent(UserProfileFormComponent), + UserEditProfilePageComponent, + ], + providers: [{ provide: OrganizationManagementFacade, useFactory: () => instance(organizationManagementFacade) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserEditProfilePageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + fb = TestBed.inject(FormBuilder); + + component.user = user; + + component.profileForm = fb.group({ + profile: fb.group({ + title: [component.user.title, [Validators.required]], + firstName: [component.user.firstName, [Validators.required]], + lastName: [component.user.lastName, [Validators.required]], + preferredLanguage: [component.user.preferredLanguage, [Validators.required]], + }), + }); + + when(organizationManagementFacade.selectedUser$).thenReturn(of(user)); + when(organizationManagementFacade.usersLoading$).thenReturn(of(false)); + when(organizationManagementFacade.usersError$).thenReturn(of()); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should submit a valid form when the user fills all required fields', () => { + fixture.detectChanges(); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + expect(component.formDisabled).toBeFalse(); + }); +}); diff --git a/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.ts b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.ts new file mode 100644 index 0000000000..eb294a02cb --- /dev/null +++ b/projects/organization-management/src/app/pages/user-edit-profile/user-edit-profile-page.component.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { whenTruthy } from 'ish-core/utils/operators'; +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; + +@Component({ + selector: 'ish-user-edit-profile-page', + templateUrl: './user-edit-profile-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserEditProfilePageComponent implements OnInit, OnDestroy { + loading$: Observable; + userError$: Observable; + selectedUser$: Observable; + private destroy$ = new Subject(); + + error: HttpError; + profileForm: FormGroup; + + user: B2bUser; + submitted = false; + + constructor(private fb: FormBuilder, private organizationManagementFacade: OrganizationManagementFacade) {} + + ngOnInit() { + this.loading$ = this.organizationManagementFacade.usersLoading$; + this.userError$ = this.organizationManagementFacade.usersError$; + this.selectedUser$ = this.organizationManagementFacade.selectedUser$; + + this.selectedUser$.pipe(whenTruthy(), takeUntil(this.destroy$)).subscribe(user => { + this.user = user; + this.editUserProfileForm(user); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + editUserProfileForm(userProfile: B2bUser) { + this.profileForm = this.fb.group({ + title: [userProfile.title ? userProfile.title : ''], + firstName: [userProfile.firstName, [Validators.required]], + lastName: [userProfile.lastName, [Validators.required]], + phone: [userProfile.phoneHome], + }); + } + + submitForm() { + if (this.profileForm.invalid) { + markAsDirtyRecursive(this.profileForm); + return; + } + + const formValue = this.profileForm.value; + + const user: B2bUser = { + ...this.user, + title: formValue.title, + firstName: formValue.firstName, + lastName: formValue.lastName, + phoneHome: formValue.phone, + }; + this.organizationManagementFacade.updateUser(user); + } + + get formDisabled() { + return this.profileForm.invalid && this.submitted; + } +} diff --git a/projects/organization-management/src/app/pages/users/users-page.component.html b/projects/organization-management/src/app/pages/users/users-page.component.html index bd067df6be..2bfa304d8a 100644 --- a/projects/organization-management/src/app/pages/users/users-page.component.html +++ b/projects/organization-management/src/app/pages/users/users-page.component.html @@ -1,5 +1,8 @@

{{ 'account.organization.user_management' | translate }} + {{ + 'account.user.list.link.add_user' | translate + }}

diff --git a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..cd81163ea2 --- /dev/null +++ b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.spec.ts @@ -0,0 +1,139 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; + +import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { routes } from '../../pages/organization-management-routing.module'; +import { OrganizationManagementStoreModule } from '../../store/organization-management-store.module'; +import { loadUserSuccess } from '../../store/users'; + +import { OrganizationManagementBreadcrumbService } from './organization-management-breadcrumb.service'; + +describe('Organization Management Breadcrumb Service', () => { + let organizationManagementBreadcrumbService: OrganizationManagementBreadcrumbService; + let router: Router; + let store$: Store; + + beforeEach(() => { + @Component({ template: 'dummy' }) + class DummyComponent {} + + TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [ + CoreStoreModule.forTesting(['router', 'configuration']), + OrganizationManagementStoreModule.forTesting('users'), + RouterTestingModule.withRoutes([ + ...routes.map(r => ({ ...r, component: r.component && DummyComponent })), + { path: '**', component: DummyComponent }, + ]), + TranslateModule.forRoot(), + ], + }); + + organizationManagementBreadcrumbService = TestBed.inject(OrganizationManagementBreadcrumbService); + router = TestBed.inject(Router); + store$ = TestBed.inject(Store); + }); + + it('should be created', () => { + expect(organizationManagementBreadcrumbService).toBeTruthy(); + }); + + describe('breadcrumb$', () => { + describe('unrelated routes', () => { + it('should not report a breadcrumb for unrelated routes', done => { + router.navigateByUrl('/foobar'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(fail, fail, fail); + + setTimeout(done, 2000); + }); + }); + + describe('user management routes', () => { + it('should set breadcrumb for users list view', done => { + router.navigateByUrl('/users'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.organization.user_management", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for user create page', done => { + router.navigateByUrl('/users/create'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.organization.user_management", + "link": "/my-account/users", + }, + Object { + "key": "account.user.breadcrumbs.new_user.text", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for user detail page', done => { + store$.dispatch(loadUserSuccess({ user: { login: '1', firstName: 'John', lastName: 'Doe' } as B2bUser })); + router.navigateByUrl('/users/1'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.organization.user_management", + "link": "/my-account/users", + }, + Object { + "text": "account.organization.user_management.user_detail.breadcrumb - John Doe", + }, + ] + `); + done(); + }); + }); + + it('should set breadcrumb for user detail edit page', done => { + store$.dispatch(loadUserSuccess({ user: { login: '1', firstName: 'John', lastName: 'Doe' } as B2bUser })); + router.navigateByUrl('/users/1/profile'); + + organizationManagementBreadcrumbService.breadcrumb$('/my-account').subscribe(breadcrumbData => { + expect(breadcrumbData).toMatchInlineSnapshot(` + Array [ + Object { + "key": "account.organization.user_management", + "link": "/my-account/users", + }, + Object { + "link": "/my-account/users/1", + "text": "account.organization.user_management.user_detail.breadcrumb - John Doe", + }, + Object { + "key": "account.user.update_profile.heading", + }, + ] + `); + done(); + }); + }); + }); + }); +}); diff --git a/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts new file mode 100644 index 0000000000..268ec75e9d --- /dev/null +++ b/projects/organization-management/src/app/services/organization-management-breadcrumb/organization-management-breadcrumb.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { EMPTY, Observable, of } from 'rxjs'; +import { map, switchMap, withLatestFrom } from 'rxjs/operators'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { BreadcrumbItem } from 'ish-core/models/breadcrumb-item/breadcrumb-item.interface'; +import { whenFalsy, whenTruthy } from 'ish-core/utils/operators'; + +import { OrganizationManagementFacade } from '../../facades/organization-management.facade'; + +@Injectable({ providedIn: 'root' }) +export class OrganizationManagementBreadcrumbService { + constructor( + private appFacade: AppFacade, + private organizationManagementFacade: OrganizationManagementFacade, + private translateService: TranslateService + ) {} + + breadcrumb$(prefix: string): Observable { + return this.appFacade.routingInProgress$.pipe( + whenFalsy(), + withLatestFrom(this.appFacade.path$), + switchMap(([, path]) => { + if (path.endsWith('users')) { + return of([{ key: 'account.organization.user_management' }]); + } else if (path.endsWith('users/create')) { + return of([ + { key: 'account.organization.user_management', link: prefix + '/users' }, + { key: 'account.user.breadcrumbs.new_user.text' }, + ]); + } else if (/users\/:B2BCustomerLogin(\/profile)?$/.test(path)) { + return this.organizationManagementFacade.selectedUser$.pipe( + whenTruthy(), + withLatestFrom(this.translateService.get('account.organization.user_management.user_detail.breadcrumb')), + map(([user, translation]) => + path.endsWith('profile') + ? // edit user detail + [ + { key: 'account.organization.user_management', link: prefix + '/users' }, + { + text: `${translation} - ${user.firstName} ${user.lastName}`, + link: `${prefix}/users/${user.login}`, + }, + { key: 'account.user.update_profile.heading' }, + ] + : // user detail + [ + { key: 'account.organization.user_management', link: prefix + '/users' }, + { text: translation + (user.firstName ? ` - ${user.firstName} ${user.lastName}` : '') }, + ] + ) + ); + } + return EMPTY; + }) + ); + } +} diff --git a/projects/organization-management/src/app/services/users/users.service.spec.ts b/projects/organization-management/src/app/services/users/users.service.spec.ts index a698a0874b..b46fa09485 100644 --- a/projects/organization-management/src/app/services/users/users.service.spec.ts +++ b/projects/organization-management/src/app/services/users/users.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; -import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import { anyString, anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import { Customer } from 'ish-core/models/customer/customer.model'; import { ApiService } from 'ish-core/services/api/api.service'; +import { B2bUser, CustomerB2bUserType } from '../../models/b2b-user/b2b-user.model'; + import { UsersService } from './users.service'; describe('Users Service', () => { @@ -25,7 +28,7 @@ describe('Users Service', () => { expect(usersService).toBeTruthy(); }); - it('should call the users of customer API when fetching users', done => { + it('should call the getUsers of customer API when fetching users', done => { usersService.getUsers().subscribe(() => { verify(apiService.get(anything())).once(); expect(capture(apiService.get).last()).toMatchInlineSnapshot(` @@ -37,7 +40,7 @@ describe('Users Service', () => { }); }); - it('should call the user of customer API when fetching user', done => { + it('should call the getUser of customer API when fetching user', done => { usersService.getUser('pmiller@test.intershop.de').subscribe(() => { verify(apiService.get(anything())).once(); expect(capture(apiService.get).last()).toMatchInlineSnapshot(` @@ -48,4 +51,32 @@ describe('Users Service', () => { done(); }); }); + + it('should call the addUser for creating a new b2b user', done => { + when(apiService.post(anyString(), anything())).thenReturn(of({})); + + const payload = { + customer: { customerNo: '4711', isBusinessCustomer: true } as Customer, + user: { login: 'pmiller@test.intershop.de' } as B2bUser, + } as CustomerB2bUserType; + + usersService.addUser(payload).subscribe(() => { + verify(apiService.post(`customers/${payload.customer.customerNo}/users`, anything())).once(); + done(); + }); + }); + + it('should call the opdateUser for updating a b2b user', done => { + when(apiService.put(anyString(), anything())).thenReturn(of({})); + + const payload = { + customer: { customerNo: '4711', isBusinessCustomer: true } as Customer, + user: { login: 'pmiller@test.intershop.de' } as B2bUser, + } as CustomerB2bUserType; + + usersService.updateUser(payload).subscribe(() => { + verify(apiService.put(`customers/${payload.customer.customerNo}/users/${payload.user.login}`, anything())).once(); + done(); + }); + }); }); diff --git a/projects/organization-management/src/app/services/users/users.service.ts b/projects/organization-management/src/app/services/users/users.service.ts index b0024e95bf..9685d3590a 100644 --- a/projects/organization-management/src/app/services/users/users.service.ts +++ b/projects/organization-management/src/app/services/users/users.service.ts @@ -1,18 +1,18 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Observable, throwError } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; import { ApiService } from 'ish-core/services/api/api.service'; import { B2bUserMapper } from '../../models/b2b-user/b2b-user.mapper'; -import { B2bUser } from '../../models/b2b-user/b2b-user.model'; +import { B2bUser, CustomerB2bUserType } from '../../models/b2b-user/b2b-user.model'; @Injectable({ providedIn: 'root' }) export class UsersService { constructor(private apiService: ApiService) {} /** - * Gets all users of a customer. The current user is supposed to have administrator rights. + * Get all users of a customer. The current user is expected to have user management permission. * @returns All users of the customer. */ getUsers(): Observable { @@ -20,11 +20,65 @@ export class UsersService { } /** - * Gets the data of a b2b user. The current user is supposed to have administrator rights. + * Get the data of a b2b user. The current user is expected to have user management permission. * @param login The login of the user. * @returns The user. */ getUser(login: string): Observable { return this.apiService.get(`customers/-/users/${login}`).pipe(map(B2bUserMapper.fromData)); } + + /** + * Create a b2b user. The current user is expected to have user management permission. + * @param body The user data (customer, user, credentials, address) to create a new user. + * @returns The created user. + */ + addUser(body: CustomerB2bUserType): Observable { + if (!body || !body.customer || !body.user) { + return throwError('addUser() called without required body data'); + } + + return this.apiService + .post(`customers/${body.customer.customerNo}/users`, { + type: 'SMBCustomerUserCollection', + name: 'Users', + elements: [ + { + ...body.customer, + ...body.user, + preferredInvoiceToAddress: { urn: body.user.preferredInvoiceToAddressUrn }, + preferredShipToAddress: { urn: body.user.preferredShipToAddressUrn }, + preferredPaymentInstrument: { id: body.user.preferredPaymentInstrumentId }, + preferredInvoiceToAddressUrn: undefined, + preferredShipToAddressUrn: undefined, + preferredPaymentInstrumentId: undefined, + }, + ], + }) + .pipe(concatMap(() => this.getUser(body.user.email))); + } + + /** + * Update a b2b user. The current user is expected to have user management permission. + * @param body The user data (customer, user, credentials, address) to update the user. + * @returns The updated user. + */ + updateUser(body: CustomerB2bUserType): Observable { + if (!body || !body.customer || !body.user) { + return throwError('updateUser() called without required body data'); + } + + return this.apiService + .put(`customers/${body.customer.customerNo}/users/${body.user.login}`, { + ...body.customer, + ...body.user, + preferredInvoiceToAddress: { urn: body.user.preferredInvoiceToAddressUrn }, + preferredShipToAddress: { urn: body.user.preferredShipToAddressUrn }, + preferredPaymentInstrument: { id: body.user.preferredPaymentInstrumentId }, + preferredInvoiceToAddressUrn: undefined, + preferredShipToAddressUrn: undefined, + preferredPaymentInstrumentId: undefined, + }) + .pipe(map(B2bUserMapper.fromData)); + } } diff --git a/projects/organization-management/src/app/store/users/users.actions.ts b/projects/organization-management/src/app/store/users/users.actions.ts index f3724cb0ad..18468d4f70 100644 --- a/projects/organization-management/src/app/store/users/users.actions.ts +++ b/projects/organization-management/src/app/store/users/users.actions.ts @@ -14,4 +14,16 @@ export const loadUserFail = createAction('[Users API] Load User Fail', httpError export const loadUserSuccess = createAction('[Users API] Load User Success', payload<{ user: B2bUser }>()); +export const addUser = createAction('[Users] Add User', payload<{ user: B2bUser }>()); + +export const addUserFail = createAction('[Users API] Add User Fail', httpError()); + +export const addUserSuccess = createAction('[Users API] Add User Success', payload<{ user: B2bUser }>()); + +export const updateUser = createAction('[Users] Update User', payload<{ user: B2bUser }>()); + +export const updateUserFail = createAction('[Users API] Update User Fail', httpError()); + +export const updateUserSuccess = createAction('[Users API] Update User Success', payload<{ user: B2bUser }>()); + export const resetUsers = createAction('[Users] Reset Users'); diff --git a/projects/organization-management/src/app/store/users/users.effects.spec.ts b/projects/organization-management/src/app/store/users/users.effects.spec.ts index e58e316b2b..21b92ad5a9 100644 --- a/projects/organization-management/src/app/store/users/users.effects.spec.ts +++ b/projects/organization-management/src/app/store/users/users.effects.spec.ts @@ -1,46 +1,63 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Action, Store } from '@ngrx/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { Observable, of } from 'rxjs'; +import { Action } from '@ngrx/store'; +import { cold, hot } from 'jest-marbles'; +import { Observable, of, throwError } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { HttpErrorMapper } from 'ish-core/models/http-error/http-error.mapper'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { displaySuccessMessage } from 'ish-core/store/core/messages'; +import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; import { UsersService } from '../../services/users/users.service'; import { OrganizationManagementStoreModule } from '../organization-management-store.module'; -import { loadUsers, loadUsersSuccess } from './users.actions'; +import { + addUser, + addUserFail, + addUserSuccess, + loadUsers, + loadUsersFail, + updateUser, + updateUserFail, + updateUserSuccess, +} from './users.actions'; import { UsersEffects } from './users.effects'; @Component({ template: 'dummy' }) class DummyComponent {} -const users = [{ login: '1', firstName: 'Patricia', lastName: 'Miller' }, { login: '2' }] as B2bUser[]; +const users = [ + { login: '1', firstName: 'Patricia', lastName: 'Miller', name: 'Patricia Miller' }, + { login: '2' }, +] as B2bUser[]; describe('Users Effects', () => { let actions$: Observable; let effects: UsersEffects; let usersService: UsersService; - let store$: Store; let router: Router; beforeEach(() => { usersService = mock(UsersService); when(usersService.getUsers()).thenReturn(of(users)); when(usersService.getUser(anything())).thenReturn(of(users[0])); + when(usersService.addUser(anything())).thenReturn(of(users[0])); + when(usersService.updateUser(anything())).thenReturn(of(users[0])); TestBed.configureTestingModule({ declarations: [DummyComponent], imports: [ CoreStoreModule.forTesting(['router']), + CustomerStoreModule.forTesting('user'), OrganizationManagementStoreModule.forTesting('users'), RouterTestingModule.withRoutes([{ path: 'users/:B2BCustomerLogin', component: DummyComponent }]), - TranslateModule.forRoot(), ], providers: [ UsersEffects, @@ -50,75 +67,147 @@ describe('Users Effects', () => { }); effects = TestBed.inject(UsersEffects); - store$ = TestBed.inject(Store); router = TestBed.inject(Router); }); - describe('loadUsers$', () => { - it('should call the service for retrieving users', done => { - actions$ = of(loadUsers()); + describe('Users Effects', () => { + describe('loadUsers$', () => { + it('should call the service for retrieving users', done => { + actions$ = of(loadUsers()); - effects.loadUsers$.subscribe(() => { - verify(usersService.getUsers()).once(); - done(); + effects.loadUsers$.subscribe(() => { + verify(usersService.getUsers()).once(); + done(); + }); + }); + + it('should retrieve users when triggered', done => { + actions$ = of(loadUsers()); + + effects.loadUsers$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Users API] Load Users Success: + users: [{"login":"1","firstName":"Patricia","lastName":"Miller","na... + `); + done(); + }); }); - }); - it('should retrieve users when triggered', done => { - actions$ = of(loadUsers()); + it('should dispatch a loadUsersFail action on failed users load', () => { + // tslint:disable-next-line:ban-types + const error = { status: 401, headers: new HttpHeaders().set('error-key', 'feld') } as HttpErrorResponse; + when(usersService.getUsers()).thenReturn(throwError(error)); - effects.loadUsers$.subscribe(action => { - expect(action).toMatchInlineSnapshot(` - [Users API] Load Users Success: - users: [{"login":"1","firstName":"Patricia","lastName":"Miller"},{"... - `); - done(); + const action = loadUsers(); + const completion = loadUsersFail({ error: HttpErrorMapper.fromError(error) }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-b', { b: completion }); + + expect(effects.loadUsers$).toBeObservable(expected$); }); }); - }); - describe('loadDetailedUser$', () => { - it('should call the service for retrieving user', done => { - router.navigate(['users', '1']); + describe('loadDetailedUser$', () => { + it('should call the service for retrieving user', done => { + router.navigate(['users', '1']); + + effects.loadDetailedUser$.subscribe(() => { + verify(usersService.getUser(users[0].login)).once(); + done(); + }); + }); + + it('should retrieve the user when triggered', done => { + router.navigate(['users', '1']); - effects.loadDetailedUser$.subscribe(() => { - verify(usersService.getUser(users[0].login)).once(); - done(); + effects.loadDetailedUser$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Users API] Load User Success: + user: {"login":"1","firstName":"Patricia","lastName":"Miller","nam... + `); + done(); + }); }); }); - it('should retrieve the user when triggered', done => { - router.navigate(['users', '1']); + describe('addUser$', () => { + it('should call the service for adding a user', done => { + actions$ = of(addUser({ user: users[0] })); + + effects.addUser$.subscribe(() => { + verify(usersService.addUser(anything())).once(); + done(); + }); + }); + + it('should create a user when triggered', () => { + const action = addUser({ user: users[0] }); + + const completion = addUserSuccess({ user: users[0] }); + const completion2 = displaySuccessMessage({ + message: 'account.organization.user_management.new_user.confirmation', + messageParams: { 0: `${users[0].firstName} ${users[0].lastName}` }, + }); + + actions$ = hot('-a----a----a----|', { a: action }); + const expected$ = cold('-(cd)-(cd)-(cd)-|', { c: completion, d: completion2 }); + + expect(effects.addUser$).toBeObservable(expected$); + }); + + it('should dispatch an UpdateUserFail action on failed user update', () => { + // tslint:disable-next-line:ban-types + const error = { status: 401, headers: new HttpHeaders().set('error-key', 'feld') } as HttpErrorResponse; + when(usersService.addUser(anything())).thenReturn(throwError(error)); + + const action = addUser({ user: users[0] }); + const completion = addUserFail({ error: HttpErrorMapper.fromError(error) }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-b', { b: completion }); - effects.loadDetailedUser$.subscribe(action => { - expect(action).toMatchInlineSnapshot(` - [Users API] Load User Success: - user: {"login":"1","firstName":"Patricia","lastName":"Miller"} - `); - done(); + expect(effects.addUser$).toBeObservable(expected$); }); }); - }); - describe('setUserDetailBreadcrumb$', () => { - it('should set the breadcrumb of user detail', done => { - store$.dispatch(loadUsersSuccess({ users })); - router.navigate(['users', '1']); - effects.setUserDetailBreadcrumb$.subscribe(action => { - expect(action.payload).toMatchInlineSnapshot(` - Object { - "breadcrumbData": Array [ - Object { - "key": "account.organization.user_management", - "link": "/account/organization/users", - }, - Object { - "text": "account.organization.user_management.user_detail.breadcrumb - Patricia Miller", - }, - ], - } - `); - done(); + describe('updateUser$', () => { + it('should call the service for updating a user', done => { + actions$ = of(updateUser({ user: users[0] })); + + effects.updateUser$.subscribe(() => { + verify(usersService.updateUser(anything())).once(); + done(); + }); + }); + + it('should update a user when triggered', () => { + const action = updateUser({ user: users[0] }); + + const completion = updateUserSuccess({ user: users[0] }); + const completion2 = displaySuccessMessage({ + message: 'account.organization.user_management.update_user.confirmation', + messageParams: { 0: `${users[0].firstName} ${users[0].lastName}` }, + }); + + actions$ = hot('-a----a----a----|', { a: action }); + const expected$ = cold('-(cd)-(cd)-(cd)-|', { c: completion, d: completion2 }); + + expect(effects.updateUser$).toBeObservable(expected$); + }); + + it('should dispatch an UpdateUserFail action on failed user update', () => { + // tslint:disable-next-line:ban-types + const error = { status: 401, headers: new HttpHeaders().set('error-key', 'feld') } as HttpErrorResponse; + when(usersService.updateUser(anything())).thenReturn(throwError(error)); + + const action = updateUser({ user: users[0] }); + const completion = updateUserFail({ error: HttpErrorMapper.fromError(error) }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-b', { b: completion }); + + expect(effects.updateUser$).toBeObservable(expected$); }); }); }); diff --git a/projects/organization-management/src/app/store/users/users.effects.ts b/projects/organization-management/src/app/store/users/users.effects.ts index beaa6cbb5b..306c392d19 100644 --- a/projects/organization-management/src/app/store/users/users.effects.ts +++ b/projects/organization-management/src/app/store/users/users.effects.ts @@ -1,18 +1,31 @@ import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store, select } from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; -import { debounceTime, exhaustMap, map, mapTo, mergeMap, withLatestFrom } from 'rxjs/operators'; +import { concatMap, debounceTime, exhaustMap, map, mapTo, mergeMap, tap, withLatestFrom } from 'rxjs/operators'; +import { Customer } from 'ish-core/models/customer/customer.model'; +import { displaySuccessMessage } from 'ish-core/store/core/messages'; import { selectRouteParam } from 'ish-core/store/core/router'; -import { setBreadcrumbData } from 'ish-core/store/core/viewconf'; -import { logoutUser } from 'ish-core/store/customer/user'; -import { mapErrorToAction, whenTruthy } from 'ish-core/utils/operators'; +import { getLoggedInCustomer, logoutUser } from 'ish-core/store/customer/user'; +import { mapErrorToAction, mapToPayload, whenTruthy } from 'ish-core/utils/operators'; import { UsersService } from '../../services/users/users.service'; -import { loadUserFail, loadUserSuccess, loadUsers, loadUsersFail, loadUsersSuccess, resetUsers } from './users.actions'; -import { getSelectedUser } from './users.selectors'; +import { + addUser, + addUserFail, + addUserSuccess, + loadUserFail, + loadUserSuccess, + loadUsers, + loadUsersFail, + loadUsersSuccess, + resetUsers, + updateUser, + updateUserFail, + updateUserSuccess, +} from './users.actions'; @Injectable() export class UsersEffects { @@ -20,7 +33,7 @@ export class UsersEffects { private actions$: Actions, private usersService: UsersService, private store: Store, - private translateService: TranslateService + private router: Router ) {} loadUsers$ = createEffect(() => @@ -49,21 +62,60 @@ export class UsersEffects { ) ); - setUserDetailBreadcrumb$ = createEffect(() => - this.store.pipe( - select(getSelectedUser), - whenTruthy(), - withLatestFrom(this.translateService.get('account.organization.user_management.user_detail.breadcrumb')), - map(([user, prefixBreadcrumb]) => - setBreadcrumbData({ - breadcrumbData: [ - { key: 'account.organization.user_management', link: '/account/organization/users' }, - { text: `${prefixBreadcrumb} - ${user.firstName} ${user.lastName}` }, - ], - }) + addUser$ = createEffect(() => + this.actions$.pipe( + ofType(addUser), + mapToPayload(), + withLatestFrom(this.store.pipe(select(getLoggedInCustomer))), + concatMap(([payload, customer]) => + this.usersService.addUser({ user: payload.user, customer }).pipe( + tap(() => { + this.navigateToParent(); + }), + mergeMap(user => [ + addUserSuccess({ user }), + displaySuccessMessage({ + message: 'account.organization.user_management.new_user.confirmation', + messageParams: { 0: `${user.firstName} ${user.lastName}` }, + }), + ]), + mapErrorToAction(addUserFail) + ) + ) + ) + ); + + updateUser$ = createEffect(() => + this.actions$.pipe( + ofType(updateUser), + mapToPayload(), + withLatestFrom(this.store.pipe(select(getLoggedInCustomer))), + concatMap(([payload, customer]) => + this.usersService.updateUser({ user: payload.user, customer }).pipe( + tap(() => { + this.navigateToParent(); + }), + mergeMap(user => [ + updateUserSuccess({ user }), + displaySuccessMessage({ + message: 'account.organization.user_management.update_user.confirmation', + messageParams: { 0: `${user.firstName} ${user.lastName}` }, + }), + ]), + mapErrorToAction(updateUserFail) + ) ) ) ); resetUsersAfterLogout$ = createEffect(() => this.actions$.pipe(ofType(logoutUser), mapTo(resetUsers()))); + + private navigateToParent(): void { + // find current ActivatedRoute by following first activated children + let currentRoute = this.router.routerState.root; + while (currentRoute.firstChild) { + currentRoute = currentRoute.firstChild; + } + this.router.navigate(['../'], { relativeTo: currentRoute }); + } } diff --git a/projects/organization-management/src/app/store/users/users.reducer.ts b/projects/organization-management/src/app/store/users/users.reducer.ts index 6f8068c549..62c55da2e9 100644 --- a/projects/organization-management/src/app/store/users/users.reducer.ts +++ b/projects/organization-management/src/app/store/users/users.reducer.ts @@ -6,7 +6,20 @@ import { setErrorOn, setLoadingOn } from 'ish-core/utils/ngrx-creators'; import { B2bUser } from '../../models/b2b-user/b2b-user.model'; -import { loadUserFail, loadUserSuccess, loadUsers, loadUsersFail, loadUsersSuccess, resetUsers } from './users.actions'; +import { + addUser, + addUserFail, + addUserSuccess, + loadUserFail, + loadUserSuccess, + loadUsers, + loadUsersFail, + loadUsersSuccess, + resetUsers, + updateUser, + updateUserFail, + updateUserSuccess, +} from './users.actions'; export const usersAdapter = createEntityAdapter({ selectId: user => user.login, @@ -24,8 +37,8 @@ const initialState: UsersState = usersAdapter.getInitialState({ export const usersReducer = createReducer( initialState, - setLoadingOn(loadUsers), - setErrorOn(loadUsersFail, loadUserFail), + setLoadingOn(loadUsers, addUser, updateUser), + setErrorOn(loadUsersFail, loadUserFail, addUserFail, updateUserFail), on(loadUsersSuccess, (state: UsersState, action) => { const { users } = action.payload; @@ -44,5 +57,23 @@ export const usersReducer = createReducer( error: undefined, }; }), + on(addUserSuccess, (state: UsersState, action) => { + const { user } = action.payload; + + return { + ...usersAdapter.addOne(user, state), + loading: false, + error: undefined, + }; + }), + on(updateUserSuccess, (state: UsersState, action) => { + const { user } = action.payload; + + return { + ...usersAdapter.upsertOne(user, state), + loading: false, + error: undefined, + }; + }), on(resetUsers, () => initialState) ); diff --git a/projects/organization-management/src/app/store/users/users.selectors.spec.ts b/projects/organization-management/src/app/store/users/users.selectors.spec.ts index c4e10a4159..e6cdf821df 100644 --- a/projects/organization-management/src/app/store/users/users.selectors.spec.ts +++ b/projects/organization-management/src/app/store/users/users.selectors.spec.ts @@ -1,4 +1,7 @@ -import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { User } from 'ish-core/models/user/user.model'; @@ -8,18 +11,28 @@ import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ng import { OrganizationManagementStoreModule } from '../organization-management-store.module'; import { loadUsers, loadUsersFail, loadUsersSuccess } from './users.actions'; -import { getUsers, getUsersError, getUsersLoading } from './users.selectors'; +import { getSelectedUser, getUsers, getUsersError, getUsersLoading } from './users.selectors'; + +@Component({ template: 'dummy' }) +class DummyComponent {} describe('Users Selectors', () => { let store$: StoreWithSnapshots; + let router: Router; beforeEach(() => { TestBed.configureTestingModule({ - imports: [CoreStoreModule.forTesting(), OrganizationManagementStoreModule.forTesting('users')], + declarations: [DummyComponent], + imports: [ + CoreStoreModule.forTesting(['router']), + OrganizationManagementStoreModule.forTesting('users'), + RouterTestingModule.withRoutes([{ path: 'users/:B2BCustomerLogin', component: DummyComponent }]), + ], providers: [provideStoreSnapshots()], }); store$ = TestBed.inject(StoreWithSnapshots); + router = TestBed.inject(Router); }); describe('initial state', () => { @@ -89,4 +102,28 @@ describe('Users Selectors', () => { }); }); }); + + describe('SelectedUser', () => { + beforeEach(() => { + const users = [{ login: '1' }, { login: '2' }] as User[]; + const successAction = loadUsersSuccess({ users }); + store$.dispatch(successAction); + }); + + describe('with category route', () => { + beforeEach(fakeAsync(() => { + router.navigate(['users', '1']); + tick(500); + })); + + it('should return the category information when used', () => { + expect(getUsers(store$.state)).not.toBeEmpty(); + expect(getUsersLoading(store$.state)).toBeFalse(); + }); + + it('should return the selected user when the customer login is given as query param', () => { + expect(getSelectedUser(store$.state)).toBeTruthy(); + }); + }); + }); }); diff --git a/src/app/core/store/customer/customer-store.module.ts b/src/app/core/store/customer/customer-store.module.ts index bbfac0e8f8..95b0db4651 100644 --- a/src/app/core/store/customer/customer-store.module.ts +++ b/src/app/core/store/customer/customer-store.module.ts @@ -17,6 +17,7 @@ import { basketReducer } from './basket/basket.reducer'; import { CustomerState } from './customer-store'; import { OrdersEffects } from './orders/orders.effects'; import { ordersReducer } from './orders/orders.reducer'; +import { OrganizationManagementEffects } from './organization-management/organization-management.effects'; import { RestoreEffects } from './restore/restore.effects'; import { UserEffects } from './user/user.effects'; import { userReducer } from './user/user.reducer'; @@ -39,6 +40,7 @@ const customerEffects = [ OrdersEffects, RestoreEffects, UserEffects, + OrganizationManagementEffects, ]; const metaReducers = [resetOnLogoutMeta]; diff --git a/src/app/core/store/customer/organization-management/organization-management.effects.ts b/src/app/core/store/customer/organization-management/organization-management.effects.ts new file mode 100644 index 0000000000..86cf5113e0 --- /dev/null +++ b/src/app/core/store/customer/organization-management/organization-management.effects.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { createEffect } from '@ngrx/effects'; +import { OrganizationManagementBreadcrumbService } from 'organization-management'; +import { map } from 'rxjs/operators'; + +import { setBreadcrumbData } from 'ish-core/store/core/viewconf'; + +@Injectable() +export class OrganizationManagementEffects { + constructor(private organizationManagementBreadcrumbService: OrganizationManagementBreadcrumbService) {} + + setOrganizationManagementBreadcrumb$ = createEffect(() => + this.organizationManagementBreadcrumbService + .breadcrumb$('/account/organization') + .pipe(map(breadcrumbData => setBreadcrumbData({ breadcrumbData }))) + ); +} diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index fa4ff5cd72..23667b8465 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -2865,6 +2865,8 @@ "account.organization.user_management.user_detail": "Benutzerdaten", "account.organization.user_management.user_detail.breadcrumb": "Benutzerdaten", "account.organization.user_management.back_to_list": "Zurück zur Benutzerverwaltung", + "account.organization.user_management.new_user.confirmation": "Der Benutzer \"{{0}}\" wurde erstellt.", + "account.organization.user_management.update_user.confirmation": "Der Benutzer \"{{0}}\" wurde geändert.", "subject.has.no.permission.assigned": "Sie verfügen nicht über die erforderlichen Rechte zur Durchführung dieser Aktion.", "user.not.authenticated": "Sie sind für den Zugriff auf diese Seite nicht authentifiziert." } diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 4528de9a52..095a31bd3a 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -2788,6 +2788,7 @@ "account.user.widget.monthly_spend_limit.label": "Monthly spend limit", "account.user.widget.order_spend_limit.label": "Order spend limit", "account.user.widget.view_all_users.link": "View all Users", + "account.user.update_profile.heading": "Edit User Profile", "account.user.new.heading": "Create New User", "account.user.new.active.label": "Active", "account.user.new.active.yes.text": "yes", @@ -2868,6 +2869,8 @@ "account.organization.user_management.user_detail": "User Details", "account.organization.user_management.user_detail.breadcrumb": "User Details", "account.organization.user_management.back_to_list": "Back to User Management", + "account.organization.user_management.new_user.confirmation": "The user \"{{0}}\" has been created.", + "account.organization.user_management.update_user.confirmation": "The user \"{{0}}\" has been updated.", "subject.has.no.permission.assigned": "You do not have the required permission to perform this action.", "user.not.authenticated": "You are not authenticated to access this page." } diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index c6c3da6aa3..4badeb3f22 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -2867,6 +2867,8 @@ "account.organization.user_management.user_detail": "Détails de l’utilisateur", "account.organization.user_management.user_detail.breadcrumb": "Détails de l’utilisateur", "account.organization.user_management.back_to_list": "Retourner à la gestion des utilisateurs", + "account.organization.user_management.new_user.confirmation": " L’utilisateur \"{{0}}\" a été créé.", + "account.organization.user_management.update_user.confirmation": "L’utilisateur \"{{0}}\" a été mise à jour.", "subject.has.no.permission.assigned": "Vous n’avez pas l’autorisation nécessaire d’effectuer cette action.", "user.not.authenticated": "Vous n’êtes pas authentifié pour accéder à cette page." } diff --git a/tslint.json b/tslint.json index 34ac7ab825..842f06f274 100644 --- a/tslint.json +++ b/tslint.json @@ -606,6 +606,7 @@ "^.*/src/app/shared/([a-z][a-z0-9-]+)/\\1\\.module\\.ts$", "^.*/src/app/shared/[a-z][a-z0-9-]+/(configurations|pipes|utils|validators|directives)/.*$", "^.*/src/app/shared/[a-z][a-z0-9-]+/components/([a-z][a-z0-9-]+)/\\1\\.component\\.ts$", + "^.*/projects/[a-z][a-z0-9-]+/src/app/components/[a-z][a-z0-9-]+/([a-z][a-z0-9-]+)/\\1\\.component\\.ts$", "^.*/src/app/shared/address-forms/components/([a-z][a-z0-9-]+)/\\1\\.factory\\.ts$", // aggregation modules "^.*/src/app/(shell|shared)/\\1\\.module\\.ts$",