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 @@
+
+
+ {{ error.headers['error-key'] | translate }}
+
+
*{{ '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 }}
+
+
+
+
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 }}
+
+
+
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 @@
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$",