From 51e732e430ce3fa808e27f91326608e21482a5a4 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 10 Mar 2020 17:06:59 +0100 Subject: [PATCH 01/30] 69432: Profile page intermediate commit --- resources/i18n/en.json5 | 34 ++++ src/app/app-routing.module.ts | 1 + .../profile-page-metadata-form.component.html | 5 + .../profile-page-metadata-form.component.ts | 147 ++++++++++++++++++ .../profile-page-routing.module.ts | 15 ++ .../profile-page-security-form.component.html | 6 + .../profile-page-security-form.component.ts | 56 +++++++ .../profile-page/profile-page.component.html | 18 +++ .../profile-page/profile-page.component.ts | 35 +++++ src/app/profile-page/profile-page.module.ts | 23 +++ 10 files changed, 340 insertions(+) create mode 100644 src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html create mode 100644 src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts create mode 100644 src/app/profile-page/profile-page-routing.module.ts create mode 100644 src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html create mode 100644 src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts create mode 100644 src/app/profile-page/profile-page.component.html create mode 100644 src/app/profile-page/profile-page.component.ts create mode 100644 src/app/profile-page/profile-page.module.ts diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index cfa202fe4a..82f295f28e 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1411,6 +1411,40 @@ + "profile.breadcrumbs": "Update Profile", + + "profile.card.identify": "Identify", + + "profile.card.security": "Security", + + "profile.form.submit": "Update Profile", + + "profile.head": "Update Profile", + + "profile.metadata.form.error.firstname": "First Name is required", + + "profile.metadata.form.error.lastname": "Last Name is required", + + "profile.metadata.form.label.email": "Email Address", + + "profile.metadata.form.label.firstname": "First Name", + + "profile.metadata.form.label.language": "Language", + + "profile.metadata.form.label.lastname": "Last Name", + + "profile.metadata.form.label.phone": "Contact Telephone", + + "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", + + "profile.security.form.label.password": "Password", + + "profile.security.form.label.passwordrepeat": "Retype to confirm", + + "profile.title": "Update Profile", + + + "project.listelement.badge": "Research Project", "project.page.contributor": "Contributors", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4cf5efae41..da2b896241 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -68,6 +68,7 @@ export function getDSOPath(dso: DSpaceObject): string { { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' }, + { path: 'profile', loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html new file mode 100644 index 0000000000..8f68b82b7b --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html @@ -0,0 +1,5 @@ + + diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts new file mode 100644 index 0000000000..6c5b9acb55 --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -0,0 +1,147 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; +import { + DynamicFormControlModel, + DynamicFormService, DynamicFormValueControlModel, + DynamicInputModel, DynamicSelectModel +} from '@ng-dynamic-forms/core'; +import { FormGroup } from '@angular/forms'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { LangConfig } from '../../../config/lang-config.interface'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep } from 'lodash'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators'; + +@Component({ + selector: 'ds-profile-page-metadata-form', + templateUrl: './profile-page-metadata-form.component.html' +}) +export class ProfilePageMetadataFormComponent implements OnInit { + /** + * The user to display the form for + */ + @Input() user: EPerson; + + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'email', + name: 'email', + readOnly: true + }), + new DynamicInputModel({ + id: 'firstname', + name: 'eperson.firstname', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'This field is required' + }, + }), + new DynamicInputModel({ + id: 'lastname', + name: 'eperson.lastname', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'This field is required' + }, + }), + new DynamicInputModel({ + id: 'phone', + name: 'eperson.phone' + }), + new DynamicSelectModel({ + id: 'language', + name: 'eperson.language' + }) + ]; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + LABEL_PREFIX = 'profile.metadata.form.label.'; + + ERROR_PREFIX = 'profile.metadata.form.error.'; + + /** + * All of the configured active languages + */ + activeLangs: LangConfig[]; + + constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig, + protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected epersonService: EPersonDataService) { + } + + ngOnInit(): void { + this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true); + this.formModel.forEach( + (fieldModel: DynamicInputModel | DynamicSelectModel) => { + if (fieldModel.name === 'email') { + fieldModel.value = this.user.email; + } else { + fieldModel.value = this.user.firstMetadataValue(fieldModel.name); + } + if (fieldModel.id === 'language') { + (fieldModel as DynamicSelectModel).options = + this.activeLangs.map((langConfig) => Object.assign({ value: langConfig.code, label: langConfig.label })) + } + } + ); + this.formGroup = this.formService.createFormGroup(this.formModel); + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + updateFieldTranslations() { + this.formModel.forEach( + (fieldModel: DynamicInputModel) => { + fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + if (isNotEmpty(fieldModel.validators)) { + fieldModel.errorMessages = {}; + Object.keys(fieldModel.validators).forEach((key) => { + fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_PREFIX + fieldModel.id + '.' + key); + }); + } + } + ); + } + + updateProfile() { + const newMetadata = Object.assign({}, this.user.metadata); + this.formModel.forEach((fieldModel: DynamicFormValueControlModel) => { + if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) { + if (hasValue(fieldModel.value)) { + newMetadata[fieldModel.name][0].value = fieldModel.value; + } else { + newMetadata[fieldModel.name] = []; + } + } else if (hasValue(fieldModel.value)) { + newMetadata[fieldModel.name] = [{ + value: fieldModel.value, + language: null + } as any]; + } + }); + this.epersonService.update(Object.assign(cloneDeep(this.user), { metadata: newMetadata })).pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ).subscribe((user) => { + this.user = user; + }); + } +} diff --git a/src/app/profile-page/profile-page-routing.module.ts b/src/app/profile-page/profile-page-routing.module.ts new file mode 100644 index 0000000000..4b9f2b7fff --- /dev/null +++ b/src/app/profile-page/profile-page-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ProfilePageComponent } from './profile-page.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', pathMatch: 'full', component: ProfilePageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'profile', title: 'profile.title' } } + ]) + ] +}) +export class ProfilePageRoutingModule { + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html new file mode 100644 index 0000000000..59c6f5c248 --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -0,0 +1,6 @@ +
{{'profile.security.form.info' | translate}}
+ + diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts new file mode 100644 index 0000000000..dd5ac478e2 --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit } from '@angular/core'; +import { + DynamicFormControlModel, + DynamicFormService, + DynamicInputModel +} from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; +import { FormGroup } from '@angular/forms'; + +@Component({ + selector: 'ds-profile-page-security-form', + templateUrl: './profile-page-security-form.component.html' +}) +export class ProfilePageSecurityFormComponent implements OnInit { + + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'password', + name: 'password', + inputType: 'password' + }), + new DynamicInputModel({ + id: 'passwordrepeat', + name: 'passwordrepeat', + inputType: 'password' + }) + ]; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + LABEL_PREFIX = 'profile.security.form.label.'; + + constructor(protected formService: DynamicFormService, + protected translate: TranslateService) { + } + + ngOnInit(): void { + this.formGroup = this.formService.createFormGroup(this.formModel); + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + updateFieldTranslations() { + this.formModel.forEach( + (fieldModel: DynamicInputModel) => { + fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + } + ); + } +} diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html new file mode 100644 index 0000000000..3b0b2d712b --- /dev/null +++ b/src/app/profile-page/profile-page.component.html @@ -0,0 +1,18 @@ + +
+

{{'profile.head' | translate}}

+
+
{{'profile.card.identify' | translate}}
+
+ +
+
+
+
{{'profile.card.security' | translate}}
+
+ +
+
+ +
+
diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts new file mode 100644 index 0000000000..eb182aab4e --- /dev/null +++ b/src/app/profile-page/profile-page.component.ts @@ -0,0 +1,35 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { select, Store } from '@ngrx/store'; +import { getAuthenticatedUser } from '../core/auth/selectors'; +import { AppState } from '../app.reducer'; +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; + +@Component({ + selector: 'ds-profile-page', + templateUrl: './profile-page.component.html' +}) +/** + * Component for a user to edit their profile information + */ +export class ProfilePageComponent implements OnInit { + + @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + + /** + * The authenticated user + */ + user$: Observable; + + constructor(private store: Store) { + } + + ngOnInit(): void { + this.user$ = this.store.pipe(select(getAuthenticatedUser)); + } + + updateProfile() { + this.metadataForm.updateProfile(); + } +} diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts new file mode 100644 index 0000000000..f40c125ff8 --- /dev/null +++ b/src/app/profile-page/profile-page.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { ProfilePageRoutingModule } from './profile-page-routing.module'; +import { ProfilePageComponent } from './profile-page.component'; +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; + +@NgModule({ + imports: [ + ProfilePageRoutingModule, + CommonModule, + SharedModule + ], + declarations: [ + ProfilePageComponent, + ProfilePageMetadataFormComponent, + ProfilePageSecurityFormComponent + ] +}) +export class ProfilePageModule { + +} From 53c457689cc7a65e94e05b7efce100078807549a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 10:50:04 +0100 Subject: [PATCH 02/30] 69432: Object update metadata PUT to PATCH --- .../core/cache/server-sync-buffer.effects.ts | 23 +++++++++++-------- .../core/data/dso-change-analyzer.service.ts | 19 ++++++++++++++- src/app/core/data/request.models.ts | 2 ++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 3a0e801f27..96cd8496d2 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -16,13 +16,15 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { RequestService } from '../data/request.service'; -import { PutRequest } from '../data/request.models'; +import { PatchRequest, PutRequest } from '../data/request.models'; import { ObjectCacheService } from './object-cache.service'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; import { GenericConstructor } from '../shared/generic-constructor'; import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { RestRequestMethod } from '../data/rest-request-method'; +import { ObjectCacheEntry } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; @Injectable() export class ServerSyncBufferEffects { @@ -96,17 +98,20 @@ export class ServerSyncBufferEffects { * @returns {Observable} ApplyPatchObjectCacheAction to be dispatched */ private applyPatch(href: string): Observable { - const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1)); + const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1)); return patchObject.pipe( - map((object) => { - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); - - this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject)); - - return new ApplyPatchObjectCacheAction(href) + map((entry: ObjectCacheEntry) => { + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const objectPatch = flatPatch.filter((op: Operation) => op.path.startsWith('/metadata')); + if (isNotEmpty(objectPatch)) { + this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, objectPatch)); + } + } + return new ApplyPatchObjectCacheAction(href); }) - ) + ); } constructor(private actions$: Actions, diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index dba8395bc5..af0b95234b 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -3,6 +3,8 @@ import { compare } from 'fast-json-patch'; import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { MetadataMap } from '../shared/metadata.models'; +import { cloneDeep } from 'lodash'; /** * A class to determine what differs between two @@ -21,6 +23,21 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer * The second object to compare */ diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] { - return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + return compare(this.filterUUIDsFromMetadata(object1.metadata), this.filterUUIDsFromMetadata(object2.metadata)) + .map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + } + + /** + * Filter the UUIDs out of a MetadataMap + * @param metadata + */ + filterUUIDsFromMetadata(metadata: MetadataMap): MetadataMap { + const result = cloneDeep(metadata); + for (const key of Object.keys(result)) { + for (const metadataValue of result[key]) { + metadataValue.uuid = undefined; + } + } + return result; } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index fe992146d8..e17ffcac3f 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -119,6 +119,8 @@ export class HeadRequest extends RestRequest { } export class PatchRequest extends RestRequest { + public responseMsToLive = 60 * 15 * 1000; + constructor( public uuid: string, public href: string, From 004297fcfae3acaffcd1b48b222f83a40ff8e4fc Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 11:54:32 +0100 Subject: [PATCH 03/30] 69432: Profile metadata form validation + notifications --- resources/i18n/en.json5 | 10 +++- src/app/app-routing.module.ts | 8 ++- .../profile-page-metadata-form.component.ts | 50 +++++++++++++++---- .../user-menu/user-menu.component.html | 1 + .../user-menu/user-menu.component.ts | 6 +++ 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 82f295f28e..a9cee1bd6f 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1353,6 +1353,8 @@ "nav.mydspace": "MyDSpace", + "nav.profile": "Profile", + "nav.search": "Search", "nav.statistics.header": "Statistics", @@ -1421,9 +1423,9 @@ "profile.head": "Update Profile", - "profile.metadata.form.error.firstname": "First Name is required", + "profile.metadata.form.error.firstname.required": "First Name is required", - "profile.metadata.form.error.lastname": "Last Name is required", + "profile.metadata.form.error.lastname.required": "Last Name is required", "profile.metadata.form.label.email": "Email Address", @@ -1435,6 +1437,10 @@ "profile.metadata.form.label.phone": "Contact Telephone", + "profile.metadata.form.notifications.success.content": "Your changes to the profile were saved.", + + "profile.metadata.form.notifications.success.title": "Profile saved", + "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", "profile.security.form.label.password": "Password", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index da2b896241..a2c3713861 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -37,6 +37,12 @@ export function getAdminModulePath() { return `/${ADMIN_MODULE_PATH}`; } +const PROFILE_MODULE_PATH = 'profile'; + +export function getProfileModulePath() { + return `/${PROFILE_MODULE_PATH}`; +} + export function getDSOPath(dso: DSpaceObject): string { switch ((dso as any).type) { case Community.type.value: @@ -68,7 +74,7 @@ export function getDSOPath(dso: DSpaceObject): string { { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' }, - { path: 'profile', loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, + { path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts index 6c5b9acb55..cf59f2d331 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; import { DynamicFormControlModel, DynamicFormService, DynamicFormValueControlModel, @@ -14,6 +14,10 @@ import { LangConfig } from '../../../config/lang-config.interface'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { cloneDeep } from 'lodash'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators'; +import { FormService } from '../../shared/form/form.service'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { FormComponent } from '../../shared/form/form.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; @Component({ selector: 'ds-profile-page-metadata-form', @@ -25,6 +29,12 @@ export class ProfilePageMetadataFormComponent implements OnInit { */ @Input() user: EPerson; + /** + * Reference to the form component + * Used for validating the form before sending update requests + */ + @ViewChild(FormComponent, { static: false }) formRef: FormComponent; + formModel: DynamicFormControlModel[] = [ new DynamicInputModel({ id: 'email', @@ -72,6 +82,8 @@ export class ProfilePageMetadataFormComponent implements OnInit { ERROR_PREFIX = 'profile.metadata.form.error.'; + NOTIFICATION_PREFIX = 'profile.metadata.form.notifications.'; + /** * All of the configured active languages */ @@ -79,13 +91,24 @@ export class ProfilePageMetadataFormComponent implements OnInit { constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig, protected location: Location, - protected formService: DynamicFormService, + protected formService: FormService, + protected formBuilderService: FormBuilderService, protected translate: TranslateService, - protected epersonService: EPersonDataService) { + protected epersonService: EPersonDataService, + protected notificationsService: NotificationsService) { } ngOnInit(): void { this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true); + this.setFormValues(); + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + setFormValues() { this.formModel.forEach( (fieldModel: DynamicInputModel | DynamicSelectModel) => { if (fieldModel.name === 'email') { @@ -99,12 +122,7 @@ export class ProfilePageMetadataFormComponent implements OnInit { } } ); - this.formGroup = this.formService.createFormGroup(this.formModel); - this.updateFieldTranslations(); - this.translate.onLangChange - .subscribe(() => { - this.updateFieldTranslations(); - }); + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); } updateFieldTranslations() { @@ -122,8 +140,13 @@ export class ProfilePageMetadataFormComponent implements OnInit { } updateProfile() { - const newMetadata = Object.assign({}, this.user.metadata); - this.formModel.forEach((fieldModel: DynamicFormValueControlModel) => { + if (!this.formRef.formGroup.valid) { + this.formService.validateAllFormFields(this.formGroup); + return; + } + + const newMetadata = cloneDeep(this.user.metadata); + this.formModel.filter((fieldModel) => fieldModel.id !== 'email').forEach((fieldModel: DynamicFormValueControlModel) => { if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) { if (hasValue(fieldModel.value)) { newMetadata[fieldModel.name][0].value = fieldModel.value; @@ -142,6 +165,11 @@ export class ProfilePageMetadataFormComponent implements OnInit { getRemoteDataPayload() ).subscribe((user) => { this.user = user; + this.setFormValues(); + this.notificationsService.success( + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.content') + ); }); } } diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html index fef47b395b..ac55a211e9 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -1,6 +1,7 @@
{{(user$ | async)?.name}} ({{(user$ | async)?.email}}) + {{'nav.profile' | translate}} {{'nav.mydspace' | translate}} diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts index e3c21b4e24..2d57a837c7 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -7,6 +7,7 @@ import { EPerson } from '../../../core/eperson/models/eperson.model'; import { AppState } from '../../../app.reducer'; import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors'; import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { getProfileModulePath } from '../../../app-routing.module'; /** * This component represents the user nav menu. @@ -36,6 +37,11 @@ export class UserMenuComponent implements OnInit { */ public mydspaceRoute = MYDSPACE_ROUTE; + /** + * The profile page route + */ + public profileRoute = getProfileModulePath(); + constructor(private store: Store) { } From 1f1846c487d2b0dc2fc451db6ac5438555c10092 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 12:38:32 +0100 Subject: [PATCH 04/30] 69432: Immediate patch --- src/app/core/data/data.service.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 3e67675290..a7528bae0c 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -44,7 +44,7 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest + GetRequest, PatchRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; @@ -337,6 +337,32 @@ export abstract class DataService { this.objectCache.addPatch(href, operations); } + /** + * Send out an immediate patch request, instead of adding to the object cache first + * This is useful in cases where you need the returned response and an object cache update is not needed + * @param dso The dso to send the patch to + * @param operations The patch operations + */ + immediatePatch(dso: T, operations: Operation[]): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PatchRequest(requestId, href, operations); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + /** * Add a new patch to the object cache * The patch is derived from the differences between the given object and its version in the object cache From 892a985156815572dd803ba993cb019bef2f697e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 14:32:38 +0100 Subject: [PATCH 05/30] 69432: Functional profile security form + notifications + metadata form refactoring --- resources/i18n/en.json5 | 14 +++++ src/app/core/eperson/eperson-data.service.ts | 2 +- .../profile-page-metadata-form.component.html | 1 + .../profile-page-metadata-form.component.ts | 49 ++++++++-------- .../profile-page-security-form.component.html | 2 + .../profile-page-security-form.component.ts | 57 +++++++++++++++++-- .../profile-page/profile-page.component.html | 2 +- .../profile-page/profile-page.component.ts | 20 ++++++- 8 files changed, 116 insertions(+), 31 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index a9cee1bd6f..63e2b161fe 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1441,12 +1441,26 @@ "profile.metadata.form.notifications.success.title": "Profile saved", + "profile.notifications.warning.no-changes.content": "No changes were made to the Profile.", + + "profile.notifications.warning.no-changes.title": "No changes", + + "profile.security.form.error.matching-passwords": "The passwords do not match.", + "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", "profile.security.form.label.password": "Password", "profile.security.form.label.passwordrepeat": "Retype to confirm", + "profile.security.form.notifications.success.content": "Successfully changed password.", + + "profile.security.form.notifications.success.title": "Password changed", + + "profile.security.form.notifications.error.title": "Error changing passwords", + + "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", + "profile.title": "Update Profile", diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index ef2e76c7c6..6a46b20792 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -20,7 +20,7 @@ import { EPERSON } from './models/eperson.resource-type'; @dataService(EPERSON) export class EPersonDataService extends DataService { - protected linkPath: 'epersons'; + protected linkPath = 'epersons'; constructor( protected requestService: RequestService, diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html index 8f68b82b7b..c1c1cff0f3 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html @@ -1,5 +1,6 @@ diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts index cf59f2d331..dbd49b802a 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -29,12 +29,6 @@ export class ProfilePageMetadataFormComponent implements OnInit { */ @Input() user: EPerson; - /** - * Reference to the form component - * Used for validating the form before sending update requests - */ - @ViewChild(FormComponent, { static: false }) formRef: FormComponent; - formModel: DynamicFormControlModel[] = [ new DynamicInputModel({ id: 'email', @@ -91,7 +85,6 @@ export class ProfilePageMetadataFormComponent implements OnInit { constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig, protected location: Location, - protected formService: FormService, protected formBuilderService: FormBuilderService, protected translate: TranslateService, protected epersonService: EPersonDataService, @@ -139,37 +132,47 @@ export class ProfilePageMetadataFormComponent implements OnInit { ); } - updateProfile() { - if (!this.formRef.formGroup.valid) { - this.formService.validateAllFormFields(this.formGroup); - return; + updateProfile(): boolean { + if (!this.formGroup.valid) { + return false; } const newMetadata = cloneDeep(this.user.metadata); + let changed = false; this.formModel.filter((fieldModel) => fieldModel.id !== 'email').forEach((fieldModel: DynamicFormValueControlModel) => { if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) { if (hasValue(fieldModel.value)) { - newMetadata[fieldModel.name][0].value = fieldModel.value; + if (newMetadata[fieldModel.name][0].value !== fieldModel.value) { + newMetadata[fieldModel.name][0].value = fieldModel.value; + changed = true; + } } else { newMetadata[fieldModel.name] = []; + changed = true; } } else if (hasValue(fieldModel.value)) { newMetadata[fieldModel.name] = [{ value: fieldModel.value, language: null } as any]; + changed = true; } }); - this.epersonService.update(Object.assign(cloneDeep(this.user), { metadata: newMetadata })).pipe( - getSucceededRemoteData(), - getRemoteDataPayload() - ).subscribe((user) => { - this.user = user; - this.setFormValues(); - this.notificationsService.success( - this.translate.instant(this.NOTIFICATION_PREFIX + 'success.title'), - this.translate.instant(this.NOTIFICATION_PREFIX + 'success.content') - ); - }); + + if (changed) { + this.epersonService.update(Object.assign(cloneDeep(this.user), {metadata: newMetadata})).pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ).subscribe((user) => { + this.user = user; + this.setFormValues(); + this.notificationsService.success( + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.content') + ); + }); + } + + return changed; } } diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index 59c6f5c248..81519e5a42 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -2,5 +2,7 @@ +
{{'profile.security.form.error.matching-passwords' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index dd5ac478e2..d5a8d358b8 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -1,17 +1,26 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { DynamicFormControlModel, - DynamicFormService, + DynamicFormService, DynamicFormValueControlModel, DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { FormGroup } from '@angular/forms'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; @Component({ selector: 'ds-profile-page-security-form', templateUrl: './profile-page-security-form.component.html' }) export class ProfilePageSecurityFormComponent implements OnInit { + /** + * The user to display the form for + */ + @Input() user: EPerson; formModel: DynamicFormControlModel[] = [ new DynamicInputModel({ @@ -31,14 +40,18 @@ export class ProfilePageSecurityFormComponent implements OnInit { */ formGroup: FormGroup; + NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + LABEL_PREFIX = 'profile.security.form.label.'; constructor(protected formService: DynamicFormService, - protected translate: TranslateService) { + protected translate: TranslateService, + protected epersonService: EPersonDataService, + protected notificationsService: NotificationsService) { } ngOnInit(): void { - this.formGroup = this.formService.createFormGroup(this.formModel); + this.formGroup = this.formService.createFormGroup(this.formModel, { validators: this.checkPasswords }); this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { @@ -53,4 +66,40 @@ export class ProfilePageSecurityFormComponent implements OnInit { } ); } + + checkPasswords(group: FormGroup) { + const pass = group.get('password').value; + const repeatPass = group.get('passwordrepeat').value; + + return isEmpty(repeatPass) || pass === repeatPass ? null : { notSame: true }; + } + + updateSecurity() { + const pass = this.formGroup.get('password').value; + const passEntered = isNotEmpty(pass); + if (!this.formGroup.valid) { + if (passEntered) { + this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); + return true; + } + return false; + } + if (passEntered) { + const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); + this.epersonService.immediatePatch(this.user, [operation]).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') + ); + } else { + this.notificationsService.error( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } + + return passEntered; + } } diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 3b0b2d712b..1d1112f52e 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -10,7 +10,7 @@
{{'profile.card.security' | translate}}
- +
diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index eb182aab4e..1b0baddbee 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -5,6 +5,9 @@ import { select, Store } from '@ngrx/store'; import { getAuthenticatedUser } from '../core/auth/selectors'; import { AppState } from '../app.reducer'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'ds-profile-page', @@ -17,12 +20,18 @@ export class ProfilePageComponent implements OnInit { @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + @ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent; + /** * The authenticated user */ user$: Observable; - constructor(private store: Store) { + NOTIFICATIONS_PREFIX = 'profile.notifications.'; + + constructor(private store: Store, + private notificationsService: NotificationsService, + private translate: TranslateService) { } ngOnInit(): void { @@ -30,6 +39,13 @@ export class ProfilePageComponent implements OnInit { } updateProfile() { - this.metadataForm.updateProfile(); + const metadataChanged = this.metadataForm.updateProfile(); + const securityChanged = this.securityForm.updateSecurity(); + if (!metadataChanged && !securityChanged) { + this.notificationsService.warning( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.content') + ); + } } } From 7d26fd478d39ee8a445ea4778a9dbdfee7dfb309 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 14:56:35 +0100 Subject: [PATCH 06/30] 69432: Groups on profile page --- resources/i18n/en.json5 | 2 ++ .../profile-page/profile-page.component.html | 9 +++++++ .../profile-page/profile-page.component.ts | 25 +++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 63e2b161fe..a82b4d6593 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1421,6 +1421,8 @@ "profile.form.submit": "Update Profile", + "profile.groups.head": "Authorization groups you belong to", + "profile.head": "Update Profile", "profile.metadata.form.error.firstname.required": "First Name is required", diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 1d1112f52e..b6e62665b4 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -14,5 +14,14 @@
+ + +
+

{{'profile.groups.head' | translate}}

+
    +
  • {{group.name}}
  • +
+
+
diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 1b0baddbee..f353ab108f 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -8,6 +8,14 @@ import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/p import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { Group } from '../core/eperson/models/group.model'; +import { RemoteData } from '../core/data/remote-data'; +import { PaginatedList } from '../core/data/paginated-list'; +import { filter, switchMap, tap } from 'rxjs/operators'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators'; +import { hasValue } from '../shared/empty.util'; +import { followLink } from '../shared/utils/follow-link-config.model'; @Component({ selector: 'ds-profile-page', @@ -27,15 +35,28 @@ export class ProfilePageComponent implements OnInit { */ user$: Observable; + /** + * The groups the user belongs to + */ + groupsRD$: Observable>>; + NOTIFICATIONS_PREFIX = 'profile.notifications.'; constructor(private store: Store, private notificationsService: NotificationsService, - private translate: TranslateService) { + private translate: TranslateService, + private epersonService: EPersonDataService) { } ngOnInit(): void { - this.user$ = this.store.pipe(select(getAuthenticatedUser)); + this.user$ = this.store.pipe( + select(getAuthenticatedUser), + filter((user: EPerson) => hasValue(user.id)), + switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))), + getAllSucceededRemoteData(), + getRemoteDataPayload() + ); + this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); } updateProfile() { From adfffac730177b76c81c1feff3eaba2c6e398404 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 11 Mar 2020 15:53:48 +0100 Subject: [PATCH 07/30] 69432: Profile page - missing JSDocs --- .../profile-page-metadata-form.component.ts | 38 +++++++++++++++++++ .../profile-page-security-form.component.ts | 30 +++++++++++++++ .../profile-page/profile-page.component.ts | 14 ++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts index dbd49b802a..0b06b3f076 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -23,12 +23,24 @@ import { NotificationsService } from '../../shared/notifications/notifications.s selector: 'ds-profile-page-metadata-form', templateUrl: './profile-page-metadata-form.component.html' }) +/** + * Component for a user to edit their metadata + * Displays a form containing: + * - readonly email field, + * - required first name text field + * - required last name text field + * - phone text field + * - language dropdown + */ export class ProfilePageMetadataFormComponent implements OnInit { /** * The user to display the form for */ @Input() user: EPerson; + /** + * The form's input models + */ formModel: DynamicFormControlModel[] = [ new DynamicInputModel({ id: 'email', @@ -72,14 +84,24 @@ export class ProfilePageMetadataFormComponent implements OnInit { */ formGroup: FormGroup; + /** + * Prefix for the form's label messages of this component + */ LABEL_PREFIX = 'profile.metadata.form.label.'; + /** + * Prefix for the form's error messages of this component + */ ERROR_PREFIX = 'profile.metadata.form.error.'; + /** + * Prefix for the notification messages of this component + */ NOTIFICATION_PREFIX = 'profile.metadata.form.notifications.'; /** * All of the configured active languages + * Used to populate the language dropdown */ activeLangs: LangConfig[]; @@ -101,6 +123,10 @@ export class ProfilePageMetadataFormComponent implements OnInit { }); } + /** + * Loop over all the form's input models and set their values depending on the user's metadata + * Create the FormGroup + */ setFormValues() { this.formModel.forEach( (fieldModel: DynamicInputModel | DynamicSelectModel) => { @@ -118,6 +144,9 @@ export class ProfilePageMetadataFormComponent implements OnInit { this.formGroup = this.formBuilderService.createFormGroup(this.formModel); } + /** + * Update the translations of the field labels and error messages + */ updateFieldTranslations() { this.formModel.forEach( (fieldModel: DynamicInputModel) => { @@ -132,6 +161,15 @@ export class ProfilePageMetadataFormComponent implements OnInit { ); } + /** + * Update the user's metadata + * + * Sends a patch request for updating the user's metadata when at least one value changed or got added/removed and the + * form is valid. + * Nothing happens when the form is invalid or no metadata changed. + * + * Returns false when nothing happened. + */ updateProfile(): boolean { if (!this.formGroup.valid) { return false; diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index d5a8d358b8..2c02027b4a 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -16,12 +16,19 @@ import { NotificationsService } from '../../shared/notifications/notifications.s selector: 'ds-profile-page-security-form', templateUrl: './profile-page-security-form.component.html' }) +/** + * Component for a user to edit their security information + * Displays a form containing a password field and a confirmation of the password + */ export class ProfilePageSecurityFormComponent implements OnInit { /** * The user to display the form for */ @Input() user: EPerson; + /** + * The form's input models + */ formModel: DynamicFormControlModel[] = [ new DynamicInputModel({ id: 'password', @@ -40,8 +47,14 @@ export class ProfilePageSecurityFormComponent implements OnInit { */ formGroup: FormGroup; + /** + * Prefix for the notification messages of this component + */ NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + /** + * Prefix for the form's label messages of this component + */ LABEL_PREFIX = 'profile.security.form.label.'; constructor(protected formService: DynamicFormService, @@ -59,6 +72,9 @@ export class ProfilePageSecurityFormComponent implements OnInit { }); } + /** + * Update the translations of the field labels + */ updateFieldTranslations() { this.formModel.forEach( (fieldModel: DynamicInputModel) => { @@ -67,6 +83,10 @@ export class ProfilePageSecurityFormComponent implements OnInit { ); } + /** + * Check if both password fields are filled in and equal + * @param group The FormGroup to validate + */ checkPasswords(group: FormGroup) { const pass = group.get('password').value; const repeatPass = group.get('passwordrepeat').value; @@ -74,6 +94,16 @@ export class ProfilePageSecurityFormComponent implements OnInit { return isEmpty(repeatPass) || pass === repeatPass ? null : { notSame: true }; } + /** + * Update the user's security details + * + * Sends a patch request for changing the user's password when a new password is present and the password confirmation + * matches the new password. + * Nothing happens when no passwords are filled in. + * An error notification is displayed when the password confirmation does not match the new password. + * + * Returns false when nothing happened + */ updateSecurity() { const pass = this.formGroup.get('password').value; const passEntered = isNotEmpty(pass); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index f353ab108f..5a2736593a 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -25,9 +25,14 @@ import { followLink } from '../shared/utils/follow-link-config.model'; * Component for a user to edit their profile information */ export class ProfilePageComponent implements OnInit { - + /** + * A reference to the metadata form component + */ @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + /** + * A reference to the security form component + */ @ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent; /** @@ -40,6 +45,9 @@ export class ProfilePageComponent implements OnInit { */ groupsRD$: Observable>>; + /** + * Prefix for the notification messages of this component + */ NOTIFICATIONS_PREFIX = 'profile.notifications.'; constructor(private store: Store, @@ -59,6 +67,10 @@ export class ProfilePageComponent implements OnInit { this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); } + /** + * Fire an update on both the metadata and security forms + * Show a warning notification when no changes were made in both forms + */ updateProfile() { const metadataChanged = this.metadataForm.updateProfile(); const securityChanged = this.securityForm.updateSecurity(); From 20f8f913cf154399508e276d2b5420a05de07acb Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 12 Mar 2020 10:18:49 +0100 Subject: [PATCH 08/30] 69432: Tests --- .../cache/server-sync-buffer.effects.spec.ts | 8 + ...ofile-page-metadata-form.component.spec.ts | 142 ++++++++++++++++++ .../profile-page-metadata-form.component.ts | 6 +- ...ofile-page-security-form.component.spec.ts | 106 +++++++++++++ .../profile-page-security-form.component.ts | 4 +- .../profile-page.component.spec.ts | 129 ++++++++++++++++ 6 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts create mode 100644 src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts create mode 100644 src/app/profile-page/profile-page.component.spec.ts diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 37ad0e6346..c2aa7b14f9 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -51,6 +51,14 @@ describe('ServerSyncBufferEffects', () => { _links: { self: { href: link } } }); return observableOf(object); + }, + getBySelfLink: (link) => { + const object = Object.assign(new DSpaceObject(), { + _links: { + self: { href: link } + } + }); + return observableOf(object); } } }, diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts new file mode 100644 index 0000000000..7aeb33d84d --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts @@ -0,0 +1,142 @@ +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { GLOBAL_CONFIG } from '../../../config'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep } from 'lodash'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; + +describe('ProfilePageMetadataFormComponent', () => { + let component: ProfilePageMetadataFormComponent; + let fixture: ComponentFixture; + + const config = { + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: true, + }] + } as any; + + const user = Object.assign(new EPerson(), { + email: 'example@gmail.com', + metadata: { + 'eperson.firstname': [ + { + value: 'John', + language: null + } + ], + 'eperson.lastname': [ + { + value: 'Doe', + language: null + } + ], + 'eperson.language': [ + { + value: 'de', + language: null + } + ] + } + }); + + const epersonService = jasmine.createSpyObj('epersonService', { + update: createSuccessfulRemoteDataObject$(user) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + const translate = { + instant: () => 'translated', + onLangChange: new EventEmitter() + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageMetadataFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: GLOBAL_CONFIG, useValue: config }, + { provide: EPersonDataService, useValue: epersonService }, + { provide: TranslateService, useValue: translate }, + { provide: NotificationsService, useValue: notificationsService }, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageMetadataFormComponent); + component = fixture.componentInstance; + component.user = user; + fixture.detectChanges(); + }); + + it('should automatically fill in the user\'s email in the correct field', () => { + expect(component.formGroup.get('email').value).toEqual(user.email); + }); + + it('should automatically fill the present metadata values and leave missing ones empty', () => { + expect(component.formGroup.get('firstname').value).toEqual('John'); + expect(component.formGroup.get('lastname').value).toEqual('Doe'); + expect(component.formGroup.get('phone').value).toBeUndefined(); + expect(component.formGroup.get('language').value).toEqual('de'); + }); + + describe('updateProfile', () => { + describe('when no values changed', () => { + let result; + + beforeEach(() => { + result = component.updateProfile(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.update', () => { + expect(epersonService.update).not.toHaveBeenCalled(); + }); + }); + + describe('when a form value changed', () => { + let result; + let newUser; + + beforeEach(() => { + newUser = cloneDeep(user); + newUser.metadata['eperson.firstname'][0].value = 'Johnny'; + setModelValue('firstname', 'Johnny'); + result = component.updateProfile(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should call epersonService.update', () => { + expect(epersonService.update).toHaveBeenCalledWith(newUser); + }); + }); + }); + + function setModelValue(id: string, value: string) { + component.formModel.filter((model) => model.id === id).forEach((model) => (model as any).value = value); + } +}); diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts index 0b06b3f076..b44faa8c4a 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { DynamicFormControlModel, DynamicFormService, DynamicFormValueControlModel, @@ -6,7 +6,6 @@ import { } from '@ng-dynamic-forms/core'; import { FormGroup } from '@angular/forms'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { Location } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @@ -14,9 +13,7 @@ import { LangConfig } from '../../../config/lang-config.interface'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { cloneDeep } from 'lodash'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators'; -import { FormService } from '../../shared/form/form.service'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; -import { FormComponent } from '../../shared/form/form.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @Component({ @@ -106,7 +103,6 @@ export class ProfilePageMetadataFormComponent implements OnInit { activeLangs: LangConfig[]; constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig, - protected location: Location, protected formBuilderService: FormBuilderService, protected translate: TranslateService, protected epersonService: EPersonDataService, diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts new file mode 100644 index 0000000000..da0ad049c4 --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -0,0 +1,106 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../../core/cache/response.models'; + +describe('ProfilePageSecurityFormComponent', () => { + let component: ProfilePageSecurityFormComponent; + let fixture: ComponentFixture; + + const user = new EPerson(); + + const epersonService = jasmine.createSpyObj('epersonService', { + immediatePatch: observableOf(new RestResponse(true, 200, 'OK')) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageSecurityFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: EPersonDataService, useValue: epersonService }, + { provide: NotificationsService, useValue: notificationsService }, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageSecurityFormComponent); + component = fixture.componentInstance; + component.user = user; + fixture.detectChanges(); + }); + + describe('updateSecurity', () => { + describe('when no values changed', () => { + let result; + + beforeEach(() => { + result = component.updateSecurity(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.immediatePatch', () => { + expect(epersonService.immediatePatch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, but the confirm field is empty', () => { + let result; + + beforeEach(() => { + setModelValue('password', 'test'); + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + }); + + describe('when both password fields are filled in and equal', () => { + let result; + let operations; + + beforeEach(() => { + setModelValue('password', 'test'); + setModelValue('passwordrepeat', 'test'); + operations = [{ op: 'replace', path: '/password', value: 'test' }]; + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should return call epersonService.immediatePatch', () => { + expect(epersonService.immediatePatch).toHaveBeenCalledWith(user, operations); + }); + }); + }); + + function setModelValue(id: string, value: string) { + component.formGroup.patchValue({ + [id]: value + }); + component.formGroup.markAllAsTouched(); + } +}); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index 2c02027b4a..5885cc48db 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -1,12 +1,12 @@ import { Component, Input, OnInit } from '@angular/core'; import { DynamicFormControlModel, - DynamicFormService, DynamicFormValueControlModel, + DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { FormGroup } from '@angular/forms'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts new file mode 100644 index 0000000000..5992012be9 --- /dev/null +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -0,0 +1,129 @@ +import { ProfilePageComponent } from './profile-page.component'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; +import { Store, StoreModule } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { authReducer } from '../core/auth/auth.reducer'; + +describe('ProfilePageComponent', () => { + let component: ProfilePageComponent; + let fixture: ComponentFixture; + + const user = Object.assign(new EPerson(), { + groups: createSuccessfulRemoteDataObject$(createPaginatedList([])) + }); + const authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + user: user + }; + + const epersonService = jasmine.createSpyObj('epersonService', { + findById: createSuccessfulRemoteDataObject$(user) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageComponent, VarDirective], + imports: [StoreModule.forRoot(authReducer), TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: EPersonDataService, useValue: epersonService }, + { provide: NotificationsService, useValue: notificationsService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + fixture = TestBed.createComponent(ProfilePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('updateProfile', () => { + describe('when the metadata form returns false and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: true + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns true and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: false + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns true and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: true + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns false and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: false + }); + component.updateProfile(); + }); + + it('should display a warning', () => { + expect(notificationsService.warning).toHaveBeenCalled(); + }); + }); + }); +}); From c1bd6938a7ad8f91f1a393a0d38070dc95534387 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 12 Mar 2020 16:24:47 +0100 Subject: [PATCH 09/30] 69432: Remove immediatePatch and replace usage with patch --- resources/i18n/en.json5 | 4 +-- .../core/cache/server-sync-buffer.effects.ts | 5 ++-- src/app/core/data/data.service.ts | 28 +------------------ .../profile-page-security-form.component.ts | 17 ++++------- 4 files changed, 10 insertions(+), 44 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index a82b4d6593..decad7309b 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1455,9 +1455,9 @@ "profile.security.form.label.passwordrepeat": "Retype to confirm", - "profile.security.form.notifications.success.content": "Successfully changed password.", + "profile.security.form.notifications.success.content": "Your changes to the password were saved.", - "profile.security.form.notifications.success.title": "Password changed", + "profile.security.form.notifications.success.title": "Password saved", "profile.security.form.notifications.error.title": "Error changing passwords", diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 96cd8496d2..fd398f2971 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -104,9 +104,8 @@ export class ServerSyncBufferEffects { map((entry: ObjectCacheEntry) => { if (isNotEmpty(entry.patches)) { const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); - const objectPatch = flatPatch.filter((op: Operation) => op.path.startsWith('/metadata')); - if (isNotEmpty(objectPatch)) { - this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, objectPatch)); + if (isNotEmpty(flatPatch)) { + this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, flatPatch)); } } return new ApplyPatchObjectCacheAction(href); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index a7528bae0c..3e67675290 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -44,7 +44,7 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest, PatchRequest + GetRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; @@ -337,32 +337,6 @@ export abstract class DataService { this.objectCache.addPatch(href, operations); } - /** - * Send out an immediate patch request, instead of adding to the object cache first - * This is useful in cases where you need the returned response and an object cache update is not needed - * @param dso The dso to send the patch to - * @param operations The patch operations - */ - immediatePatch(dso: T, operations: Operation[]): Observable { - const requestId = this.requestService.generateRequestId(); - - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); - - hrefObs.pipe( - find((href: string) => hasValue(href)), - map((href: string) => { - const request = new PatchRequest(requestId, href, operations); - this.requestService.configure(request); - }) - ).subscribe(); - - return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response) - ); - } - /** * Add a new patch to the object cache * The patch is derived from the differences between the given object and its version in the object cache diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index 5885cc48db..d7f6213dc6 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -116,18 +116,11 @@ export class ProfilePageSecurityFormComponent implements OnInit { } if (passEntered) { const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); - this.epersonService.immediatePatch(this.user, [operation]).subscribe((response: RestResponse) => { - if (response.isSuccessful) { - this.notificationsService.success( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') - ); - } else { - this.notificationsService.error( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage - ); - } - }); + this.epersonService.patch(this.user.self, [operation]); + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') + ); } return passEntered; From 5c096d5f6608ebe109c7cc34ffafd53827be4c91 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 12 Mar 2020 16:56:00 +0100 Subject: [PATCH 10/30] 69432: Fix for automatically updating authenticated user --- src/app/core/auth/auth.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index fd5c98f789..f8847b0b2e 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -23,7 +23,7 @@ import { NativeWindowRef, NativeWindowService } from '../services/window.service import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -149,7 +149,7 @@ export class AuthService { */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { return this.epersonService.findByHref(userHref).pipe( - getFirstSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload() ) } From d9e6d25da0f9ea96ebbedd8fff95f5bd90d79474 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 12 Mar 2020 17:18:28 +0100 Subject: [PATCH 11/30] 69432: Test fixes --- ...rofile-page-security-form.component.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index da0ad049c4..9c05fa98d1 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -8,17 +8,19 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component'; -import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../../core/cache/response.models'; describe('ProfilePageSecurityFormComponent', () => { let component: ProfilePageSecurityFormComponent; let fixture: ComponentFixture; - const user = new EPerson(); + const user = Object.assign(new EPerson(), { + _links: { + self: { href: 'user-selflink' } + } + }); const epersonService = jasmine.createSpyObj('epersonService', { - immediatePatch: observableOf(new RestResponse(true, 200, 'OK')) + patch: {} }); const notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, @@ -58,8 +60,8 @@ describe('ProfilePageSecurityFormComponent', () => { expect(result).toEqual(false); }); - it('should not call epersonService.immediatePatch', () => { - expect(epersonService.immediatePatch).not.toHaveBeenCalled(); + it('should not call epersonService.patch', () => { + expect(epersonService.patch).not.toHaveBeenCalled(); }); }); @@ -91,8 +93,8 @@ describe('ProfilePageSecurityFormComponent', () => { expect(result).toEqual(true); }); - it('should return call epersonService.immediatePatch', () => { - expect(epersonService.immediatePatch).toHaveBeenCalledWith(user, operations); + it('should return call epersonService.patch', () => { + expect(epersonService.patch).toHaveBeenCalledWith(user.self, operations); }); }); }); From 33e395a7f638e85d72ef351fec9e9ee00cf0f11b Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 12 Mar 2020 17:59:12 +0100 Subject: [PATCH 12/30] 69432: refactor patch to return response --- src/app/core/data/data.service.spec.ts | 29 ++++++++++++------- src/app/core/data/data.service.ts | 26 +++++++++++++---- ...ofile-page-security-form.component.spec.ts | 6 ++-- .../profile-page-security-form.component.ts | 18 ++++++++---- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index c5748b05fb..c370be2b9e 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -16,8 +16,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ChangeAnalyzer } from './change-analyzer'; import { DataService } from './data.service'; -import { FindListOptions } from './request.models'; +import { FindListOptions, PatchRequest } from './request.models'; import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; const endpoint = 'https://rest.api/core'; @@ -53,8 +55,8 @@ class DummyChangeAnalyzer implements ChangeAnalyzer { describe('DataService', () => { let service: TestService; let options: FindListOptions; - const requestService = { generateRequestId: () => uuidv4() } as RequestService; - const halService = {} as HALEndpointService; + const requestService = getMockRequestService(); + const halService = new HALEndpointServiceStub('url') as any; const rdbService = {} as RemoteDataBuildService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; @@ -285,18 +287,23 @@ describe('DataService', () => { }); describe('patch', () => { - let operations; - let selfLink; + const dso = { + uuid: 'dso-uuid' + }; + const operations = [ + Object.assign({ + op: 'move', + from: '/1', + path: '/5' + }) as Operation + ]; beforeEach(() => { - operations = [{ op: 'replace', path: '/metadata/dc.title', value: 'random string' } as Operation]; - selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; - spyOn(objectCache, 'addPatch'); + service.patch(dso, operations); }); - it('should call addPatch on the object cache with the right parameters', () => { - service.patch(selfLink, operations); - expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + it('should configure a PatchRequest', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PatchRequest)); }); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 3e67675290..473e03d6f7 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -44,7 +44,7 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest + GetRequest, PatchRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; @@ -329,12 +329,28 @@ export abstract class DataService { } /** - * Add a new patch to the object cache to a specified object - * @param {string} href The selflink of the object that will be patched + * Send a patch request for a specified object + * @param {T} dso The object to send a patch request for * @param {Operation[]} operations The patch operations to be performed */ - patch(href: string, operations: Operation[]) { - this.objectCache.addPatch(href, operations); + patch(dso: T, operations: Operation[]): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PatchRequest(requestId, href, operations); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); } /** diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 9c05fa98d1..2cb687d34d 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -8,6 +8,8 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../../core/cache/response.models'; describe('ProfilePageSecurityFormComponent', () => { let component: ProfilePageSecurityFormComponent; @@ -20,7 +22,7 @@ describe('ProfilePageSecurityFormComponent', () => { }); const epersonService = jasmine.createSpyObj('epersonService', { - patch: {} + patch: observableOf(new RestResponse(true, 200, 'OK')) }); const notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, @@ -94,7 +96,7 @@ describe('ProfilePageSecurityFormComponent', () => { }); it('should return call epersonService.patch', () => { - expect(epersonService.patch).toHaveBeenCalledWith(user.self, operations); + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); }); }); }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index d7f6213dc6..f964b02280 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -116,11 +116,19 @@ export class ProfilePageSecurityFormComponent implements OnInit { } if (passEntered) { const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); - this.epersonService.patch(this.user.self, [operation]); - this.notificationsService.success( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') - ); + this.epersonService.patch(this.user, [operation]).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') + ); + } else { + this.notificationsService.error( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } return passEntered; From c6de988486245ed4de7356bb1495c9810b68eb58 Mon Sep 17 00:00:00 2001 From: Ben Bosman Date: Tue, 17 Mar 2020 12:00:17 +0100 Subject: [PATCH 13/30] If the data is part of a type not yet known to Angular, the object is null The null object causes an exception further down the road --- src/app/core/data/base-response-parsing.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 3d00c5d0bd..08b5d9f382 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -51,7 +51,7 @@ export abstract class BaseResponseParsingService { return this.processArray(data, request); } else if (isRestDataObject(data)) { const object = this.deserialize(data); - if (isNotEmpty(data._embedded)) { + if (isNotEmpty(data._embedded) && hasValue(object)) { Object .keys(data._embedded) .filter((property) => data._embedded.hasOwnProperty(property)) From 18f50fb0c075b6902a8df568bdf57240f56ba746 Mon Sep 17 00:00:00 2001 From: Ben Bosman Date: Wed, 18 Mar 2020 10:56:37 +0100 Subject: [PATCH 14/30] If the data is part of a type not yet known to Angular, the object is null The null object causes an exception further down the road --- src/app/core/data/base-response-parsing.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 08b5d9f382..efbe838d82 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -51,13 +51,13 @@ export abstract class BaseResponseParsingService { return this.processArray(data, request); } else if (isRestDataObject(data)) { const object = this.deserialize(data); - if (isNotEmpty(data._embedded) && hasValue(object)) { + if (isNotEmpty(data._embedded)) { Object .keys(data._embedded) .filter((property) => data._embedded.hasOwnProperty(property)) .forEach((property) => { const parsedObj = this.process(data._embedded[property], request); - if (this.shouldDirectlyAttachEmbeds && isNotEmpty(parsedObj)) { + if (hasValue(object) && this.shouldDirectlyAttachEmbeds && isNotEmpty(parsedObj)) { if (isRestPaginatedList(data._embedded[property])) { object[property] = parsedObj; object[property].page = parsedObj.page.map((obj) => this.retrieveObjectOrUrl(obj)); From a8636b0e5c99e4e956032f9560fbcfc81bfdb4ee Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 18 Mar 2020 11:43:56 +0100 Subject: [PATCH 15/30] 69432: Fix being able to send invalid password form + 6 character password validator --- resources/i18n/en.json5 | 4 ++++ .../profile-page-security-form.component.html | 1 + ...ofile-page-security-form.component.spec.ts | 8 +++---- .../profile-page-security-form.component.ts | 23 +++++++++++++++---- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index decad7309b..a1610c9fd9 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1449,6 +1449,8 @@ "profile.security.form.error.matching-passwords": "The passwords do not match.", + "profile.security.form.error.password-length": "The password should be at least 6 characters long.", + "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", "profile.security.form.label.password": "Password", @@ -1461,6 +1463,8 @@ "profile.security.form.notifications.error.title": "Error changing passwords", + "profile.security.form.notifications.error.not-long-enough": "The password has to be at least 6 characters long.", + "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", "profile.title": "Update Profile", diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index 81519e5a42..50a081c6f2 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -5,4 +5,5 @@ [formGroup]="formGroup" [displaySubmit]="false"> +
{{'profile.security.form.error.password-length' | translate}}
{{'profile.security.form.error.matching-passwords' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 2cb687d34d..324230ce9f 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -80,14 +80,14 @@ describe('ProfilePageSecurityFormComponent', () => { }); }); - describe('when both password fields are filled in and equal', () => { + describe('when both password fields are filled in, long enough and equal', () => { let result; let operations; beforeEach(() => { - setModelValue('password', 'test'); - setModelValue('passwordrepeat', 'test'); - operations = [{ op: 'replace', path: '/password', value: 'test' }]; + setModelValue('password', 'testest'); + setModelValue('passwordrepeat', 'testest'); + operations = [{ op: 'replace', path: '/password', value: 'testest' }]; result = component.updateSecurity(); }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index f964b02280..b8ac07e6d8 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -64,7 +64,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { } ngOnInit(): void { - this.formGroup = this.formService.createFormGroup(this.formModel, { validators: this.checkPasswords }); + this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] }); this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { @@ -87,11 +87,21 @@ export class ProfilePageSecurityFormComponent implements OnInit { * Check if both password fields are filled in and equal * @param group The FormGroup to validate */ - checkPasswords(group: FormGroup) { + checkPasswordsEqual(group: FormGroup) { const pass = group.get('password').value; const repeatPass = group.get('passwordrepeat').value; - return isEmpty(repeatPass) || pass === repeatPass ? null : { notSame: true }; + return pass === repeatPass ? null : { notSame: true }; + } + + /** + * Check if the password is at least 6 characters long + * @param group The FormGroup to validate + */ + checkPasswordLength(group: FormGroup) { + const pass = group.get('password').value; + + return isEmpty(pass) || pass.length >= 6 ? null : { notLongEnough: true }; } /** @@ -109,7 +119,12 @@ export class ProfilePageSecurityFormComponent implements OnInit { const passEntered = isNotEmpty(pass); if (!this.formGroup.valid) { if (passEntered) { - this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); + if (this.checkPasswordsEqual(this.formGroup) != null) { + this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); + } + if (this.checkPasswordLength(this.formGroup) != null) { + this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-long-enough')); + } return true; } return false; From c14f5bee5581efde7178343f21bad4289414efd1 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Wed, 26 Feb 2020 19:20:14 +0100 Subject: [PATCH 16/30] 69110: EPeople admin page - CRUD & search on name, email, md --- resources/i18n/en.json5 | 62 +++- .../admin-access-control-routing.module.ts | 14 + .../admin-access-control.module.ts | 26 ++ .../epeople-registry.actions.ts | 49 +++ .../epeople-registry.component.html | 90 ++++++ .../epeople-registry.component.ts | 190 ++++++++++++ .../epeople-registry.reducers.ts | 49 +++ .../eperson-form/eperson-form.component.html | 17 + .../eperson-form/eperson-form.component.ts | 291 ++++++++++++++++++ src/app/+admin/admin-routing.module.ts | 9 +- .../admin-sidebar/admin-sidebar.component.ts | 34 +- src/app/+admin/admin.module.ts | 6 +- src/app/app.reducer.ts | 36 ++- src/app/core/cache/object-cache.service.ts | 6 +- src/app/core/eperson/eperson-data.service.ts | 204 +++++++++++- 15 files changed, 1038 insertions(+), 45 deletions(-) create mode 100644 src/app/+admin/admin-access-control/admin-access-control-routing.module.ts create mode 100644 src/app/+admin/admin-access-control/admin-access-control.module.ts create mode 100644 src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts create mode 100644 src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html create mode 100644 src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts create mode 100644 src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts create mode 100644 src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html create mode 100644 src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index d3ce471e05..6b70888484 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -170,6 +170,66 @@ + "admin.access-control.epeople.title": "DSpace Angular :: EPeople", + + "admin.access-control.epeople.head": "EPeople", + + "admin.access-control.epeople.search.head": "Search", + + "admin.access-control.epeople.search.scope.name": "Name", + + "admin.access-control.epeople.search.scope.email": "E-mail", + + "admin.access-control.epeople.search.scope.metadata": "Metadata", + + "admin.access-control.epeople.search.button": "Search", + + "admin.access-control.epeople.button.add": "Add EPerson", + + "admin.access-control.epeople.table.id": "ID", + + "admin.access-control.epeople.table.name": "Name", + + "admin.access-control.epeople.table.email": "E-mail", + + "admin.access-control.epeople.table.edit": "Edit", + + "item.access-control.epeople.table.edit.buttons.edit": "Edit", + + "item.access-control.epeople.table.edit.buttons.remove": "Remove", + + "admin.access-control.epeople.no-items": "No EPeople to show.", + + "admin.access-control.epeople.form.create": "Create EPerson", + + "admin.access-control.epeople.form.edit": "Edit EPerson", + + "admin.access-control.epeople.form.firstName": "First name", + + "admin.access-control.epeople.form.lastName": "Last name", + + "admin.access-control.epeople.form.email": "E-mail", + + "admin.access-control.epeople.form.emailHint": "Must be valid e-mail address", + + "admin.access-control.epeople.form.canLogIn": "Can log in", + + "admin.access-control.epeople.form.requireCertificate": "Requires certificate", + + "admin.access-control.epeople.form.notification.created.success": "Successfully created EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.created.failure": "Failed to create EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.edited.success": "Successfully edited EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", + + "admin.access-control.epeople.notification.deleted.failure": "Failed to delete EPerson: \"{{name}}\"", + + "admin.access-control.epeople.notification.deleted.success": "Successfully deleted EPerson: \"{{name}}\"", + + + "admin.search.breadcrumbs": "Administrative Search", "admin.search.collection.edit": "Edit", @@ -1500,7 +1560,7 @@ "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isVolumeOf": "Journal Volumes", - + "relationships.isContributorOf": "Contributors", diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts new file mode 100644 index 0000000000..8f1927ad63 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } }, + ]) + ] +}) +export class AdminAccessControlRoutingModule { + +} diff --git a/src/app/+admin/admin-access-control/admin-access-control.module.ts b/src/app/+admin/admin-access-control/admin-access-control.module.ts new file mode 100644 index 0000000000..5893332971 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../shared/shared.module'; +import { AdminAccessControlRoutingModule } from './admin-access-control-routing.module'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RouterModule, + TranslateModule, + AdminAccessControlRoutingModule + ], + declarations: [ + EPeopleRegistryComponent, + EPersonFormComponent + ], + entryComponents: [] +}) +export class AdminAccessControlModule { + +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts new file mode 100644 index 0000000000..b2d1c1b8ac --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts @@ -0,0 +1,49 @@ +import { Action } from '@ngrx/store'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { type } from '../../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const EPeopleRegistryActionTypes = { + + EDIT_EPERSON: type('dspace/epeople-registry/EDIT_EPERSON'), + CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_SCHEMA'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Used to edit an EPerson in the EPeople registry + */ +export class EPeopleRegistryEditEPersonAction implements Action { + type = EPeopleRegistryActionTypes.EDIT_EPERSON; + + eperson: EPerson; + + constructor(registry: EPerson) { + this.eperson = registry; + } +} + +/** + * Used to cancel the editing of an EPerson in the EPeople registry + */ +export class EPeopleRegistryCancelEPersonAction implements Action { + type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON; +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + * These are all the actions to perform on the EPeople registry state + */ +export type EPeopleRegistryAction + = EPeopleRegistryEditEPersonAction + | EPeopleRegistryCancelEPersonAction diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html new file mode 100644 index 0000000000..46c3e8fe12 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -0,0 +1,90 @@ +
+
+
+ + + + + +
+ +
+ + +
+
+ +
+
+
+ + + + +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} +
+ + +
+
+
+ +
+ + + +
+
+
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts new file mode 100644 index 0000000000..3994c3e839 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -0,0 +1,190 @@ +import { Component } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { RemoteData } from '../../../core/data/remote-data'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; + +@Component({ + selector: 'ds-epeople-registry', + templateUrl: './epeople-registry.component.html', +}) +/** + * A component used for managing all existing epeople within the repository. + * The admin can create, edit or delete epeople here. + */ +export class EPeopleRegistryComponent { + + labelPrefix = 'admin.access-control.epeople.'; + + /** + * A list of all the current EPeople within the repository or the result of the search + */ + ePeople: Observable>>; + + /** + * Pagination config used to display the list of epeople + */ + config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'epeople-list-pagination', + pageSize: 5, + currentPage: 1 + }); + + /** + * Whether or not to show the EPerson form + */ + isEPersonFormShown: boolean; + + // The search form + searchForm; + + constructor(private epersonService: EPersonDataService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private formBuilder: FormBuilder) { + this.updateEPeople({ + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + this.isEPersonFormShown = false; + this.searchForm = this.formBuilder.group(({ + scope: 'name', + query: '', + })); + } + + /** + * Event triggered when the user changes page + * @param event + */ + onPageChange(event) { + this.updateEPeople({ + currentPage: event, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Update the list of EPeople by fetching it from the rest api or cache + */ + private updateEPeople(options) { + this.ePeople = this.epersonService.getEPeople(options); + } + + /** + * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing + * a new REST call + */ + public forceUpdateEPeople() { + this.epersonService.clearEPersonRequests(); + this.isEPersonFormShown = false; + this.updateEPeople({ + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Search in the EPeople by name, email or metadata + * @param data Contains scope and query param + */ + search(data: any) { + const query = data.query; + const scope = data.scope; + switch (scope) { + case 'name': + this.ePeople = this.epersonService.getEpeopleByName(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + case 'email': + this.ePeople = this.epersonService.getEpeopleByEmail(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + case 'metadata': + this.ePeople = this.epersonService.getEpeopleByMetadata(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + default: + this.ePeople = this.epersonService.getEpeopleByEmail(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + } + } + + /** + * Checks whether the given EPerson is active (being edited) + * @param eperson + */ + isActive(eperson: EPerson): Observable { + return this.getActiveEPerson().pipe( + map((activeEPerson) => eperson === activeEPerson) + ); + } + + /** + * Gets the active eperson (being edited) + */ + getActiveEPerson(): Observable { + return this.epersonService.getActiveEPerson(); + } + + /** + * Start editing the selected metadata schema + * @param schema + */ + editEPerson(ePerson: EPerson) { + this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson) => { + if (ePerson === activeEPerson) { + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + } else { + this.epersonService.editEPerson(ePerson); + this.isEPersonFormShown = true; + } + }); + this.scrollToTop() + } + + /** + * Delete EPerson + */ + deleteEPerson(ePerson: EPerson) { + if (hasValue(ePerson.id)) { + this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { + if (success) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); + this.forceUpdateEPeople(); + } else { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); + } + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + }) + } + } + + scrollToTop() { + (function smoothscroll() { + const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; + if (currentScroll > 0) { + window.requestAnimationFrame(smoothscroll); + window.scrollTo(0, currentScroll - (currentScroll / 8)); + } + })(); + } +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts new file mode 100644 index 0000000000..55fea8f862 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts @@ -0,0 +1,49 @@ +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { + EPeopleRegistryAction, + EPeopleRegistryActionTypes, + EPeopleRegistryEditEPersonAction +} from './epeople-registry.actions'; + +/** + * The metadata registry state. + * @interface EPeopleRegistryState + */ +export interface EPeopleRegistryState { + editEPerson: EPerson; + selectedEPeople: EPerson[]; +} + +/** + * The initial state. + */ +const initialState: EPeopleRegistryState = { + editEPerson: null, + selectedEPeople: [], +}; + +/** + * Reducer that handles EPeopleRegistryActions to modify EPeople + * @param state The current EPeopleRegistryState + * @param action The EPeopleRegistryAction to perform on the state + */ +export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegistryAction): EPeopleRegistryState { + + switch (action.type) { + + case EPeopleRegistryActionTypes.EDIT_EPERSON: { + return Object.assign({}, state, { + editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson + }); + } + + case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { + return Object.assign({}, state, { + editEPerson: null + }); + } + + default: + return state; + } +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html new file mode 100644 index 0000000000..6d44dd7fa1 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -0,0 +1,17 @@ +
+ + +

{{messagePrefix + '.create' | translate}}

+
+ + +

{{messagePrefix + '.edit' | translate}}

+
+ + + diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts new file mode 100644 index 0000000000..ce08c2c0f8 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -0,0 +1,291 @@ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { + DynamicCheckboxModel, + DynamicFormControlModel, + DynamicFormLayout, + DynamicInputModel +} from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest } from 'rxjs/internal/observable/combineLatest'; +import { take } from 'rxjs/operators'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-eperson-form', + templateUrl: './eperson-form.component.html' +}) +/** + * A form used for creating and editing EPeople + */ +export class EPersonFormComponent implements OnInit, OnDestroy { + + labelPrefix = 'admin.access-control.epeople.form.'; + + /** + * A unique id used for ds-form + */ + formId = 'eperson-form'; + + /** + * The labelPrefix for all messages related to this form + */ + messagePrefix = 'admin.access-control.epeople.form'; + + /** + * Dynamic input models for the inputs of form + */ + firstName: DynamicInputModel; + lastName: DynamicInputModel; + email: DynamicInputModel; + // booleans + canLogIn: DynamicCheckboxModel; + requireCertificate: DynamicCheckboxModel; + + /** + * A list of all dynamic input models + */ + formModel: DynamicFormControlModel[]; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + firstName: { + grid: { + host: 'row' + } + }, + lastName: { + grid: { + host: 'row' + } + }, + email: { + grid: { + host: 'row' + } + }, + canLogIn: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + requireCertificate: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + }; + + /** + * A FormGroup that combines all inputs + */ + formGroup: FormGroup; + + /** + * An EventEmitter that's fired whenever the form is being submitted + */ + @Output() submitForm: EventEmitter = new EventEmitter(); + + /** + * An EventEmitter that's fired whenever the form is cancelled + */ + @Output() cancelForm: EventEmitter = new EventEmitter(); + + constructor(private epersonService: EPersonDataService, + private formBuilderService: FormBuilderService, + private translateService: TranslateService, + private notificationsService: NotificationsService,) { + } + + ngOnInit() { + combineLatest( + this.translateService.get(`${this.messagePrefix}.firstName`), + this.translateService.get(`${this.messagePrefix}.lastName`), + this.translateService.get(`${this.messagePrefix}.email`), + this.translateService.get(`${this.messagePrefix}.canLogIn`), + this.translateService.get(`${this.messagePrefix}.requireCertificate`), + this.translateService.get(`${this.messagePrefix}.emailHint`), + ).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { + this.firstName = new DynamicInputModel({ + id: 'firstName', + label: firstName, + name: 'firstName', + validators: { + required: null, + }, + required: true, + }); + this.lastName = new DynamicInputModel({ + id: 'lastName', + label: lastName, + name: 'lastName', + validators: { + required: null, + }, + required: true, + }); + this.email = new DynamicInputModel({ + id: 'email', + label: email, + name: 'email', + validators: { + required: null, + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' + }, + required: true, + hint: emailHint + }); + this.canLogIn = new DynamicCheckboxModel( + { + id: 'canLogIn', + label: canLogIn, + name: 'canLogIn', + }); + this.requireCertificate = new DynamicCheckboxModel( + { + id: 'requireCertificate', + label: requireCertificate, + name: 'requireCertificate', + }); + this.formModel = [ + this.firstName, + this.lastName, + this.email, + this.canLogIn, + this.requireCertificate, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + this.formGroup.patchValue({ + firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', + lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', + email: eperson != null ? eperson.email : '', + canLogIn: eperson != null ? eperson.canLogIn : true, + requireCertificate: eperson != null ? eperson.requireCertificate : false, + selfRegistered: false, + }); + }); + }); + } + + /** + * Stop editing the currently selected eperson + */ + onCancel() { + this.epersonService.cancelEditEPerson(); + this.cancelForm.emit(); + } + + /** + * Submit the form + * When the eperson has an id attached -> Edit the eperson + * When the eperson has no id attached -> Create new eperson + * Emit the updated/created eperson using the EventEmitter submitForm + */ + onSubmit() { + this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( + (ePerson: EPerson) => { + const values = { + metadata: { + 'eperson.firstname': [ + { + value: this.firstName.value + } + ], + 'eperson.lastname': [ + { + value: this.lastName.value + }, + ], + }, + email: this.email.value, + canLogIn: this.canLogIn.value, + requireCertificate: this.requireCertificate.value, + selfRegistered: false, + }; + if (ePerson == null) { + this.createNewEPerson(values); + } else { + this.editEPerson(ePerson, values); + } + this.clearFields(); + } + ); + } + + /** + * Creates new EPerson based on given values from form + * @param values + */ + createNewEPerson(values) { + this.epersonService.createOrUpdateEPerson(Object.assign(new EPerson(), values)) + .pipe( + getSucceededRemoteData(), + getRemoteDataPayload()) + .subscribe((newEPerson: EPerson) => { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: newEPerson.name })); + this.submitForm.emit(newEPerson); + }); + } + + /** + * Edits existing EPerson based on given values from form and old EPerson + * @param ePerson + * @param values + */ + editEPerson(ePerson: EPerson, values) { + this.epersonService.createOrUpdateEPerson(Object.assign(new EPerson(), { + id: ePerson.id, + metadata: { + 'eperson.firstname': [ + { + value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')) + } + ], + 'eperson.lastname': [ + { + value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')) + }, + ], + }, + email: (hasValue(values.email) ? values.email : ePerson.email), + canLogIn: (hasValue(values.canLogIn) ? values.canLogIn : ePerson.canLogIn), + requireCertificate: (hasValue(values.requireCertificate) ? values.requireCertificate : ePerson.requireCertificate), + selfRegistered: false, + _links: ePerson._links, + })) + .pipe( + getSucceededRemoteData(), + getRemoteDataPayload()) + .subscribe((updatedEPerson: EPerson) => { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: updatedEPerson.name })); + this.submitForm.emit(updatedEPerson); + }); + } + + /** + * Reset all input-fields to be empty + */ + clearFields() { + this.formGroup.patchValue({ + firstName: '', + lastName: '', + email: '', + canLogin: '', + }); + } + + /** + * Cancel the current edit when component is destroyed + */ + ngOnDestroy(): void { + this.onCancel(); + } +} diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 373551d3c0..2e583ec175 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,11 +1,12 @@ -import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; -import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { RouterModule } from '@angular/router'; import { getAdminModulePath } from '../app-routing.module'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; const REGISTRIES_MODULE_PATH = 'registries'; +const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getRegistriesModulePath() { return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); @@ -18,6 +19,10 @@ export function getRegistriesModulePath() { path: REGISTRIES_MODULE_PATH, loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }, + { + path: ACCESS_CONTROL_MODULE_PATH, + loadChildren: './admin-access-control/admin-access-control.module#AdminAccessControlModule' + }, { path: 'search', resolve: { breadcrumb: I18nBreadcrumbResolver }, diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index 9f4fa5f3f6..e3f55b8e18 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -1,23 +1,23 @@ import { Component, Injector, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; -import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; -import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; -import { MenuService } from '../../shared/menu/menu.service'; -import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; -import { MenuComponent } from '../../shared/menu/menu.component'; -import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; -import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; -import { AuthService } from '../../core/auth/auth.service'; -import { first, map } from 'rxjs/operators'; -import { combineLatest as combineLatestObservable } from 'rxjs'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; -import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { combineLatest as combineLatestObservable } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { first, map } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; +import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; -import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; -import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; -import {CreateItemParentSelectorComponent} from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; +import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; +import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; +import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; +import { MenuComponent } from '../../shared/menu/menu.component'; +import { MenuService } from '../../shared/menu/menu.service'; +import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; /** * Component representing the admin sidebar @@ -325,7 +325,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_people', - link: '' + link: '/admin/access-control/epeople' } as LinkMenuItemModel, }, { diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index 90fe03cba0..d1db17535f 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; -import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; -import { AdminRoutingModule } from './admin-routing.module'; import { SharedModule } from '../shared/shared.module'; +import { AdminAccessControlModule } from './admin-access-control/admin-access-control.module'; +import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { SearchPageModule } from '../+search-page/search-page.module'; import { ItemAdminSearchResultListElementComponent } from './admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component'; @@ -15,7 +15,7 @@ import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin @NgModule({ imports: [ AdminRegistriesModule, - AdminRoutingModule, + AdminAccessControlModule, SharedModule, SearchPageModule ], diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 837fb9befd..a40005814a 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,30 +1,34 @@ -import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; - -import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; -import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; -import { formReducer, FormState } from './shared/form/form.reducer'; -import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; -import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; -import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; -import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; -import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; +import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState +} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; import { metadataRegistryReducer, MetadataRegistryState } from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; +import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { hasValue } from './shared/empty.util'; -import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; +import { + NameVariantListsState, + nameVariantReducer +} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { formReducer, FormState } from './shared/form/form.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; import { selectableListReducer, SelectableListsState } from './shared/object-list/selectable-list/selectable-list.reducer'; import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; -import { - NameVariantListsState, - nameVariantReducer -} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; + +import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; +import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; +import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; +import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; +import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -42,6 +46,7 @@ export interface AppState { selectableLists: SelectableListsState; relationshipLists: NameVariantListsState; communityList: CommunityListState; + epeopleRegistry: EPeopleRegistryState; } export const appReducers: ActionReducerMap = { @@ -60,6 +65,7 @@ export const appReducers: ActionReducerMap = { selectableLists: selectableListReducer, relationshipLists: nameVariantReducer, communityList: CommunityListReducer, + epeopleRegistry: ePeopleRegistryReducer, }; export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 53894df5f1..036c9f0ed8 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -10,6 +10,7 @@ import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; import { selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; +import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; import { AddPatchObjectCacheAction, @@ -20,7 +21,6 @@ import { import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { getClassForType } from './builders/build-decorators'; /** * The base selector function to select the object cache in the store @@ -48,7 +48,7 @@ export class ObjectCacheService { constructor( private store: Store, private linkService: LinkService - ) { + ) { } /** @@ -276,6 +276,8 @@ export class ObjectCacheService { * list of operations to perform */ public addPatch(selfLink: string, patch: Operation[]) { + console.log('selfLink addPatch', selfLink) + console.log('patch addPatch', patch) this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch)); this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); } diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index ef2e76c7c6..5262f800d1 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -1,31 +1,52 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch/lib/core'; +import { Observable } from 'rxjs'; +import { filter, mergeMap, take } from 'rxjs/operators'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction +} from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; +import { EPeopleRegistryState } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; +import { AppState } from '../../app.reducer'; +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions, FindListRequest, PatchRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { EPERSON } from './models/eperson.resource-type'; +const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; +const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); + /** - * A service to retrieve {@link EPerson}s from the REST API + * A service to retrieve {@link EPerson}s from the REST API & EPerson related CRUD actions */ @Injectable() @dataService(EPERSON) export class EPersonDataService extends DataService { - protected linkPath: 'epersons'; + protected linkPath = 'epersons'; + protected searchByNamePath = 'byName'; + protected searchByEmailPath = 'byEmail'; + protected searchByMetadataPath = 'byMetadata'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, + protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, @@ -35,4 +56,177 @@ export class EPersonDataService extends DataService { super(); } + /** + * Retrieves all EPeople + * @param pagination The pagination info used to retrieve the EPeople + */ + public getEPeople(options: FindListOptions = {}): Observable>> { + const hrefObs = this.getFindAllHref(options, this.linkPath); + hrefObs.pipe( + filter((href: string) => hasValue(href)), + take(1)) + .subscribe((href: string) => { + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; + } + + /** + * Returns a search result list of EPeople, by name query (/eperson/epersons/search/{@link searchByNamePath}?q=<>) + * @param query name query + * @param options + * @param linksToFollow + */ + public getEpeopleByName(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('q', query)]; + return this.getEPeopleBy(searchParams, this.searchByNamePath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople, by email query (/eperson/epersons/search/{@link searchByEmailPath}?email=<>) + * @param query email query + * @param options + * @param linksToFollow + */ + public getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('email', query)]; + return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople, by metadata query (/eperson/epersons/search/{@link searchByMetadataPath}?query=<>) + * @param query metadata query + * @param options + * @param linksToFollow + */ + public getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('query', query)]; + return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople in a given searchMethod, with given searchParams + * @param searchParams query parameters in the search + * @param searchMethod searchBy path + * @param options + * @param linksToFollow + */ + private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy(searchMethod, findListOptions, ...linksToFollow); + } + + /** + * Create or Update an EPerson + * If the EPerson contains an id, it is assumed the eperson already exists and is updated instead + * @param ePerson The EPerson to create or update + */ + public createOrUpdateEPerson(ePerson: EPerson): Observable> { + const isUpdate = hasValue(ePerson.id); + if (isUpdate) { + return this.updateEPerson(ePerson); + } else { + return this.create(ePerson, null); + } + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} ePerson The given object + */ + updateEPerson(ePerson: EPerson): Observable> { + const oldVersion$ = this.findByHref(ePerson._links.self.href); + return oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + mergeMap((oldEPerson: EPerson) => { + const operations = this.generateOperations(oldEPerson, ePerson); + const patchRequest = new PatchRequest(this.requestService.generateRequestId(), ePerson._links.self.href, operations); + this.requestService.configure(patchRequest); + return this.findByHref(ePerson._links.self.href); + }), + ); + } + + /** + * Metadata operations are generated by the difference between old and new EPerson + * Custom replace operations for the other EPerson values + * @param oldEPerson + * @param newEPerson + */ + generateOperations(oldEPerson: EPerson, newEPerson: EPerson): Operation[] { + let operations = this.comparator.diff(oldEPerson, newEPerson).filter((operation: Operation) => operation.op === 'replace'); + if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) { + operations = [...operations, { + op: 'replace', path: '/email', value: newEPerson.email + }] + } + if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) { + operations = [...operations, { + op: 'replace', path: '/certificate', value: newEPerson.requireCertificate + }] + } + if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) { + operations = [...operations, { + op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn + }] + } + if (hasValue(oldEPerson.netid) && oldEPerson.netid !== newEPerson.netid) { + operations = [...operations, { + op: 'replace', path: '/netid', value: newEPerson.netid + }] + } + return operations; + } + + /** + * Method that clears a cached EPerson request and returns its REST url + */ + public clearEPersonRequests(): void { + this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => { + this.requestService.removeByHrefSubstring(link); + }); + } + + /** + * Method to retrieve the eperson that is currently being edited + */ + public getActiveEPerson(): Observable { + return this.store.pipe(select(editEPersonSelector)) + } + + /** + * Method to cancel editing an EPerson, dispatches a cancel EPerson action + */ + public cancelEditEPerson() { + this.store.dispatch(new EPeopleRegistryCancelEPersonAction()); + } + + /** + * Method to set the EPerson being edited, dispatches an edit EPerson action + * @param ePerson The EPerson to edit + */ + public editEPerson(ePerson: EPerson) { + this.store.dispatch(new EPeopleRegistryEditEPersonAction(ePerson)); + } + + /** + * Method to delete an EPerson + * @param id The EPerson to delete + */ + public deleteEPerson(ePerson: EPerson): Observable { + return this.delete(ePerson); + } + } From 48efc905313a471b2f05edcd952bc09900e08fbf Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Thu, 27 Feb 2020 10:57:11 +0100 Subject: [PATCH 17/30] 69110: Prod build (AoT) permission issue --- .../epeople-registry/eperson-form/eperson-form.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index ce08c2c0f8..230466f0f8 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -98,7 +98,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ @Output() cancelForm: EventEmitter = new EventEmitter(); - constructor(private epersonService: EPersonDataService, + constructor(public epersonService: EPersonDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, private notificationsService: NotificationsService,) { From 4f4c2d25ed69c5d21cf9b7a437e6af980f82f1fc Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 28 Feb 2020 17:07:05 +0100 Subject: [PATCH 18/30] 69110: Add EPerson and EPerson form mutually exclusive & resend search after addEPerson --- .../epeople-registry/epeople-registry.component.html | 2 +- .../epeople-registry/epeople-registry.component.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 46c3e8fe12..0a077355c6 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -7,7 +7,7 @@ -
+
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts new file mode 100644 index 0000000000..bc24894145 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -0,0 +1,199 @@ +import { of as observableOf } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { RemoteData } from '../../../core/data/remote-data'; +import { FindListOptions } from '../../../core/data/request.models'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../shared/mocks/mock-form-builder-service'; +import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader'; +import { getMockTranslateService } from '../../../shared/mocks/mock-translate.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson-mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; +import { EPeopleRegistryComponent } from './epeople-registry.component'; +import { EPersonFormComponent } from './eperson-form/eperson-form.component'; + +describe('EPeopleRegistryComponent', () => { + let component: EPeopleRegistryComponent; + let fixture: ComponentFixture; + let translateService: TranslateService; + let builderService: FormBuilderService; + + const mockEPeople = [EPersonMock, EPersonMock2]; + let ePersonDataServiceStub: any; + + beforeEach(async(() => { + ePersonDataServiceStub = { + activeEPerson: null, + allEpeople: mockEPeople, + getEPeople(): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + }, + getActiveEPerson(): Observable { + return observableOf(this.activeEPerson); + }, + searchByScope(scope: string, query: string, options: FindListOptions = {}): Observable>> { + if (scope === 'email') { + const result = this.allEpeople.find((ePerson: EPerson) => { + return ePerson.email === query + }); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + } + if (scope === 'metadata') { + const result = this.allEpeople.find((ePerson: EPerson) => { + return (ePerson.name.includes(query) || ePerson.email.includes(query)) + }); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + } + return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + }, + deleteEPerson(ePerson: EPerson): Observable { + this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { + return (ePerson2.uuid !== ePerson.uuid); + }); + return observableOf(true); + }, + editEPerson(ePerson: EPerson) { + this.activeEPerson = ePerson; + }, + cancelEditEPerson() { + this.activeEPerson = null; + }, + clearEPersonRequests(): void { + // empty + } + }; + builderService = getMockFormBuilderService(); + translateService = getMockTranslateService(); + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [EPeopleRegistryComponent, EPersonFormComponent], + providers: [EPeopleRegistryComponent, + { provide: EPersonDataService, useValue: ePersonDataServiceStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: FormBuilderService, useValue: builderService }, + EPeopleRegistryComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EPeopleRegistryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => { + expect(comp).toBeDefined(); + })); + + it('should display list of ePeople', () => { + const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + expect(ePeopleIdsFound.length).toEqual(2); + mockEPeople.map((ePerson: EPerson) => { + expect(ePeopleIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); + })).toBeTruthy(); + }) + }); + + describe('search', () => { + describe('when searching with scope/query (scope metadata)', () => { + let ePeopleIdsFound; + beforeEach(fakeAsync(() => { + component.search({ scope: 'metadata', query: EPersonMock2.name }); + tick(); + fixture.detectChanges(); + ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + })); + + it('should display search result', () => { + expect(ePeopleIdsFound.length).toEqual(1); + expect(ePeopleIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); + })).toBeTruthy(); + }); + }); + + describe('when searching with scope/query (scope email)', () => { + let ePeopleIdsFound; + beforeEach(fakeAsync(() => { + component.search({ scope: 'email', query: EPersonMock.email }); + tick(); + fixture.detectChanges(); + ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + })); + + it('should display search result', () => { + expect(ePeopleIdsFound.length).toEqual(1); + expect(ePeopleIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); + })).toBeTruthy(); + }); + }); + }); + + describe('editEPerson', () => { + describe('when you click on first edit eperson button', () => { + beforeEach(fakeAsync(() => { + const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton')); + editButtons[0].triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + + it('edit eperson form shows', () => { + const editFormTitle = fixture.debugElement.queryAll(By.css('ds-eperson-form h4')); + expect(editFormTitle[0]).toBeDefined(); + }); + }); + }); + + describe('deleteEPerson', () => { + describe('when you click on first delete eperson button', () => { + let ePeopleIdsFoundBeforeDelete; + let ePeopleIdsFoundAfterDelete; + beforeEach(fakeAsync(() => { + ePeopleIdsFoundBeforeDelete = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton')); + deleteButtons[0].triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + ePeopleIdsFoundAfterDelete = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + })); + + it('first ePerson is deleted', () => { + expect(ePeopleIdsFoundBeforeDelete.length === ePeopleIdsFoundAfterDelete + 1); + ePeopleIdsFoundAfterDelete.forEach((epersonElement) => { + expect(epersonElement.nativeElement.textContent !== ePeopleIdsFoundBeforeDelete[0].nativeElement.textContent).toBeTrue(); + }); + }); + }); + }); + +}); diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts index 73bdd56b0b..81a5a48a2c 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -81,7 +81,7 @@ export const EPersonMock2: EPerson = Object.assign(new EPerson(), { 'eperson.lastname': [ { language: null, - value: 'Test2' + value: 'MeepMeep' }, ], 'eperson.language': [ From 1853d1bda2451a6ed337e38032d7c4fb5b360c1c Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 13 Mar 2020 17:28:37 +0100 Subject: [PATCH 23/30] 69110: Failure notification on create and edit eperson --- resources/i18n/en.json5 | 2 + .../eperson-form/eperson-form.component.ts | 61 +++++++++++++------ src/app/core/data/data.service.ts | 42 +++++++++++++ src/app/core/eperson/eperson-data.service.ts | 15 +++-- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 6b70888484..d9132313f7 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -220,6 +220,8 @@ "admin.access-control.epeople.form.notification.created.failure": "Failed to create EPerson \"{{name}}\"", + "admin.access-control.epeople.form.notification.created.failure.emailInUse": "Failed to create EPerson \"{{name}}\", email \"{{email}}\" already in use.", + "admin.access-control.epeople.form.notification.edited.success": "Successfully edited EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index 915c4ba67a..b4c0cd311b 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -10,6 +10,8 @@ import { TranslateService } from '@ngx-translate/core'; import { combineLatest } from 'rxjs/internal/observable/combineLatest'; import { Subscription } from 'rxjs/internal/Subscription'; import { take } from 'rxjs/operators'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { PaginatedList } from '../../../../core/data/paginated-list'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; @@ -231,14 +233,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * @param values */ createNewEPerson(values) { - this.subs.push(this.epersonService.create(Object.assign(new EPerson(), values), null) - .pipe( - getSucceededRemoteData(), - getRemoteDataPayload()) - .subscribe((newEPerson: EPerson) => { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: newEPerson.name })); - this.submitForm.emit(newEPerson); - })); + const ePersonToCreate = Object.assign(new EPerson(), values); + + const response = this.epersonService.tryToCreate(ePersonToCreate); + response.pipe(take(1)).subscribe((restResponse: RestResponse) => { + if (restResponse.isSuccessful) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name })); + this.submitForm.emit(ePersonToCreate); + } else { + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name })); + } + }); + this.showNotificationIfEmailInUse(ePersonToCreate); } /** @@ -247,7 +253,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * @param values */ editEPerson(ePerson: EPerson, values) { - this.epersonService.updateEPerson(Object.assign(new EPerson(), { + const editedEperson = Object.assign(new EPerson(), { id: ePerson.id, metadata: { 'eperson.firstname': [ @@ -266,14 +272,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy { requireCertificate: (hasValue(values.requireCertificate) ? values.requireCertificate : ePerson.requireCertificate), selfRegistered: false, _links: ePerson._links, - })) - .pipe( - getSucceededRemoteData(), - getRemoteDataPayload()) - .subscribe((updatedEPerson: EPerson) => { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: updatedEPerson.name })); - this.submitForm.emit(updatedEPerson); - }); + }); + + const response = this.epersonService.updateEPerson(editedEperson); + response.pipe(take(1)).subscribe((restResponse: RestResponse) => { + if (restResponse.isSuccessful) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name })); + this.submitForm.emit(editedEperson); + } else { + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: editedEperson.name })); + } + }); + this.showNotificationIfEmailInUse(editedEperson); + } + + private showNotificationIfEmailInUse(ePersonToCreate: EPerson) { + // Relevant message for email in use + // TODO: should be changed to email scope, but byEmail currently not in backend + this.subs.push(this.epersonService.searchByScope(null, ePersonToCreate.email, { + currentPage: 1, + elementsPerPage: 0 + }).pipe(getSucceededRemoteData(), getRemoteDataPayload()) + .subscribe((list: PaginatedList) => { + if (list.totalElements > 0) { + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure.emailInUse', { + name: ePersonToCreate.name, + email: ePersonToCreate.email + })); + } + })); } /** diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 5226b1a9f6..85e58bc2c4 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -409,6 +409,48 @@ export abstract class DataService { ) } + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache, returns observable of the response to determine success + * + * @param {DSpaceObject} dso + * The object to create + */ + tryToCreate(dso: T): Observable { + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + ); + + const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso))) + ); + + // Execute the post request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + return this.fetchResponse(requestId); + } + + /** + * Gets the restResponse from the requestService + * @param requestId + */ + protected fetchResponse(requestId: string): Observable { + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + return response; + }) + ); + } + /** * Delete an existing DSpace Object on the server * @param dsoID The DSpace Object' id to be removed diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 687e89d412..8898212e53 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch/lib/core'; import { Observable } from 'rxjs'; -import { filter, mergeMap, take } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction @@ -17,6 +17,7 @@ import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { PaginatedList } from '../data/paginated-list'; @@ -136,18 +137,20 @@ export class EPersonDataService extends DataService { * The patch is derived from the differences between the given object and its version in the object cache * @param {DSpaceObject} ePerson The given object */ - public updateEPerson(ePerson: EPerson): Observable> { + public updateEPerson(ePerson: EPerson): Observable { + const requestId = this.requestService.generateRequestId(); const oldVersion$ = this.findByHref(ePerson._links.self.href); - return oldVersion$.pipe( + oldVersion$.pipe( getSucceededRemoteData(), getRemoteDataPayload(), - mergeMap((oldEPerson: EPerson) => { + map((oldEPerson: EPerson) => { const operations = this.generateOperations(oldEPerson, ePerson); - const patchRequest = new PatchRequest(this.requestService.generateRequestId(), ePerson._links.self.href, operations); + const patchRequest = new PatchRequest(requestId, ePerson._links.self.href, operations); this.requestService.configure(patchRequest); - return this.findByHref(ePerson._links.self.href); }), ); + + return this.fetchResponse(requestId); } /** From 2cb6ba98ce3262b49659eba538d7fdcf6a2c0fea Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 13 Mar 2020 17:49:48 +0100 Subject: [PATCH 24/30] 69110: Failure notification on edit eperson --- resources/i18n/en.json5 | 2 ++ .../eperson-form/eperson-form.component.ts | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index d9132313f7..d83cbbd7f9 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -222,6 +222,8 @@ "admin.access-control.epeople.form.notification.created.failure.emailInUse": "Failed to create EPerson \"{{name}}\", email \"{{email}}\" already in use.", + "admin.access-control.epeople.form.notification.edited.failure.emailInUse": "Failed to edit EPerson \"{{name}}\", email \"{{email}}\" already in use.", + "admin.access-control.epeople.form.notification.edited.success": "Successfully edited EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index b4c0cd311b..ca89d9c125 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -244,13 +244,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name })); } }); - this.showNotificationIfEmailInUse(ePersonToCreate); + this.showNotificationIfEmailInUse(ePersonToCreate, 'created'); } /** * Edits existing EPerson based on given values from form and old EPerson - * @param ePerson - * @param values + * @param ePerson ePerson to edit + * @param values new ePerson values (of form) */ editEPerson(ePerson: EPerson, values) { const editedEperson = Object.assign(new EPerson(), { @@ -280,24 +280,33 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name })); this.submitForm.emit(editedEperson); } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: editedEperson.name })); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name })); } }); - this.showNotificationIfEmailInUse(editedEperson); + + if (values.email != null && values.email !== ePerson.email) { + this.showNotificationIfEmailInUse(editedEperson, 'edited'); + } } - private showNotificationIfEmailInUse(ePersonToCreate: EPerson) { + /** + * Checks for the given ePerson if there is already an ePerson in the system with that email + * and shows notification if this is the case + * @param ePerson ePerson values to check + * @param notificationSection whether in create or edit + */ + private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) { // Relevant message for email in use // TODO: should be changed to email scope, but byEmail currently not in backend - this.subs.push(this.epersonService.searchByScope(null, ePersonToCreate.email, { + this.subs.push(this.epersonService.searchByScope(null, ePerson.email, { currentPage: 1, elementsPerPage: 0 }).pipe(getSucceededRemoteData(), getRemoteDataPayload()) .subscribe((list: PaginatedList) => { if (list.totalElements > 0) { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure.emailInUse', { - name: ePersonToCreate.name, - email: ePersonToCreate.email + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { + name: ePerson.name, + email: ePerson.email })); } })); From 30d46bedf2ce9b46504d67498bc97d6a70b4bbeb Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Mon, 16 Mar 2020 19:01:06 +0100 Subject: [PATCH 25/30] Fix test after changes for failure notification on updateEPerson --- .../epeople-registry/epeople-registry.component.spec.ts | 2 +- src/app/core/eperson/eperson-data.service.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index bc24894145..9534f1c720 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -190,7 +190,7 @@ describe('EPeopleRegistryComponent', () => { it('first ePerson is deleted', () => { expect(ePeopleIdsFoundBeforeDelete.length === ePeopleIdsFoundAfterDelete + 1); ePeopleIdsFoundAfterDelete.forEach((epersonElement) => { - expect(epersonElement.nativeElement.textContent !== ePeopleIdsFoundBeforeDelete[0].nativeElement.textContent).toBeTrue(); + expect(epersonElement !== ePeopleIdsFoundBeforeDelete[0].nativeElement.textContent).toBeTrue(); }); }); }); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 8898212e53..ec8b96d1cd 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -146,9 +146,10 @@ export class EPersonDataService extends DataService { map((oldEPerson: EPerson) => { const operations = this.generateOperations(oldEPerson, ePerson); const patchRequest = new PatchRequest(requestId, ePerson._links.self.href, operations); - this.requestService.configure(patchRequest); + return this.requestService.configure(patchRequest); }), - ); + take(1) + ).subscribe(); return this.fetchResponse(requestId); } From 32dfaa17b3a60d5d2a72dd8e53309d89ae1956fc Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Tue, 17 Mar 2020 19:22:33 +0100 Subject: [PATCH 26/30] retrieve active eperson at component construct for checkboxes --- resources/i18n/en.json5 | 2 +- .../admin-access-control-routing.module.ts | 3 +++ .../admin-access-control.module.ts | 3 +++ .../eperson-form/eperson-form.component.ts | 21 ++++++++++++------- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index d83cbbd7f9..e3fb25ac55 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -178,7 +178,7 @@ "admin.access-control.epeople.search.scope.name": "Name", - "admin.access-control.epeople.search.scope.email": "E-mail", + "admin.access-control.epeople.search.scope.email": "E-mail (exact)", "admin.access-control.epeople.search.scope.metadata": "Metadata", diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts index 8f1927ad63..83f67a770e 100644 --- a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -9,6 +9,9 @@ import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.co ]) ] }) +/** + * Routing module for the AccessControl section of the admin sidebar + */ export class AdminAccessControlRoutingModule { } diff --git a/src/app/+admin/admin-access-control/admin-access-control.module.ts b/src/app/+admin/admin-access-control/admin-access-control.module.ts index 5893332971..0c8573e135 100644 --- a/src/app/+admin/admin-access-control/admin-access-control.module.ts +++ b/src/app/+admin/admin-access-control/admin-access-control.module.ts @@ -21,6 +21,9 @@ import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-fo ], entryComponents: [] }) +/** + * This module handles all components related to the access control pages + */ export class AdminAccessControlModule { } diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index ca89d9c125..dc271a7ee0 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -106,10 +106,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ subs: Subscription[] = []; + /** + * Try to retrieve initial active eperson, to fill in checkboxes at component creation + */ + epersonInitial: EPerson; + constructor(public epersonService: EPersonDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, private notificationsService: NotificationsService,) { + this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + this.epersonInitial = eperson; + })); } ngOnInit() { @@ -155,12 +163,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy { id: 'canLogIn', label: canLogIn, name: 'canLogIn', + value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true) }); this.requireCertificate = new DynamicCheckboxModel( { id: 'requireCertificate', label: requireCertificate, name: 'requireCertificate', + value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false) }); this.formModel = [ this.firstName, @@ -175,10 +185,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy { firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', email: eperson != null ? eperson.email : '', - canLogIn: eperson != null ? eperson.canLogIn : true, - requireCertificate: eperson != null ? eperson.requireCertificate : false, - selfRegistered: false, }); + this.formGroup.get('canLogIn').patchValue((eperson != null ? eperson.canLogIn : true)); + this.formGroup.get('requireCertificate').patchValue((eperson != null ? eperson.requireCertificate : false)); })); }); } @@ -216,14 +225,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { email: this.email.value, canLogIn: this.canLogIn.value, requireCertificate: this.requireCertificate.value, - selfRegistered: false, }; if (ePerson == null) { this.createNewEPerson(values); } else { this.editEPerson(ePerson, values); } - this.clearFields(); } ); } @@ -270,7 +277,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { email: (hasValue(values.email) ? values.email : ePerson.email), canLogIn: (hasValue(values.canLogIn) ? values.canLogIn : ePerson.canLogIn), requireCertificate: (hasValue(values.requireCertificate) ? values.requireCertificate : ePerson.requireCertificate), - selfRegistered: false, _links: ePerson._links, }); @@ -320,7 +326,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy { firstName: '', lastName: '', email: '', - canLogin: '', + canLogin: true, + requireCertificate: false }); } From 2f201a49f4fd87922d3e225bf3cc9384305bd776 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Wed, 18 Mar 2020 11:18:10 +0100 Subject: [PATCH 27/30] reducer tests, test fix & email in use notification fix --- .../epeople-registry.component.html | 3 +- .../epeople-registry.component.spec.ts | 20 +- .../epeople-registry.component.ts | 264 +++++++++--------- .../epeople-registry.reducers.spec.ts | 54 ++++ .../epeople-registry.reducers.ts | 2 - .../eperson-form/eperson-form.component.ts | 6 +- 6 files changed, 205 insertions(+), 144 deletions(-) create mode 100644 src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.spec.ts diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 185900930b..33bd5496dc 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -62,7 +62,8 @@ {{eperson.email}}
- diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index 9534f1c720..83499b6e17 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -22,9 +22,8 @@ import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson-mock' import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { EPeopleRegistryComponent } from './epeople-registry.component'; -import { EPersonFormComponent } from './eperson-form/eperson-form.component'; -describe('EPeopleRegistryComponent', () => { +fdescribe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; let translateService: TranslateService; @@ -85,7 +84,7 @@ describe('EPeopleRegistryComponent', () => { } }), ], - declarations: [EPeopleRegistryComponent, EPersonFormComponent], + declarations: [EPeopleRegistryComponent], providers: [EPeopleRegistryComponent, { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, @@ -152,7 +151,7 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('editEPerson', () => { + describe('toggleEditEPerson', () => { describe('when you click on first edit eperson button', () => { beforeEach(fakeAsync(() => { const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton')); @@ -164,9 +163,16 @@ describe('EPeopleRegistryComponent', () => { fixture.detectChanges(); })); - it('edit eperson form shows', () => { - const editFormTitle = fixture.debugElement.queryAll(By.css('ds-eperson-form h4')); - expect(editFormTitle[0]).toBeDefined(); + it('editEPerson form is toggled', () => { + const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { + if (activeEPerson === ePeopleIds[0].nativeElement.textContent) { + expect(component.isEPersonFormShown).toEqual(false); + } else { + expect(component.isEPersonFormShown).toEqual(true); + } + + }) }); }); }); diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts index 099d9a5b4c..38e15b1420 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -12,8 +12,8 @@ import { NotificationsService } from '../../../shared/notifications/notification import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; @Component({ - selector: 'ds-epeople-registry', - templateUrl: './epeople-registry.component.html', + selector: 'ds-epeople-registry', + templateUrl: './epeople-registry.component.html', }) /** * A component used for managing all existing epeople within the repository. @@ -21,143 +21,143 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio */ export class EPeopleRegistryComponent { - labelPrefix = 'admin.access-control.epeople.'; + labelPrefix = 'admin.access-control.epeople.'; - /** - * A list of all the current EPeople within the repository or the result of the search - */ - ePeople: Observable>>; + /** + * A list of all the current EPeople within the repository or the result of the search + */ + ePeople: Observable>>; - /** - * Pagination config used to display the list of epeople - */ - config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'epeople-list-pagination', - pageSize: 5, - currentPage: 1 - }); - - /** - * Whether or not to show the EPerson form - */ - isEPersonFormShown: boolean; - - // The search form - searchForm; - - constructor(private epersonService: EPersonDataService, - private translateService: TranslateService, - private notificationsService: NotificationsService, - private formBuilder: FormBuilder) { - this.updateEPeople({ - currentPage: 1, - elementsPerPage: this.config.pageSize + /** + * Pagination config used to display the list of epeople + */ + config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'epeople-list-pagination', + pageSize: 5, + currentPage: 1 }); - this.isEPersonFormShown = false; - this.searchForm = this.formBuilder.group(({ - scope: 'metadata', - query: '', - })); - } - /** - * Event triggered when the user changes page - * @param event - */ - onPageChange(event) { - this.updateEPeople({ - currentPage: event, - elementsPerPage: this.config.pageSize - }); - } + /** + * Whether or not to show the EPerson form + */ + isEPersonFormShown: boolean; - /** - * Update the list of EPeople by fetching it from the rest api or cache - */ - private updateEPeople(options) { - this.ePeople = this.epersonService.getEPeople(options); - } + // The search form + searchForm; - /** - * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing - * a new REST call - */ - public forceUpdateEPeople() { - this.epersonService.clearEPersonRequests(); - this.isEPersonFormShown = false; - this.search({ query: '', scope: 'metadata' }) - } - - /** - * Search in the EPeople by metadata (default) or email - * @param data Contains scope and query param - */ - search(data: any) { - this.ePeople = this.epersonService.searchByScope(data.scope, data.query, { - currentPage: 1, - elementsPerPage: this.config.pageSize - }); - } - - /** - * Checks whether the given EPerson is active (being edited) - * @param eperson - */ - isActive(eperson: EPerson): Observable { - return this.getActiveEPerson().pipe( - map((activeEPerson) => eperson === activeEPerson) - ); - } - - /** - * Gets the active eperson (being edited) - */ - getActiveEPerson(): Observable { - return this.epersonService.getActiveEPerson(); - } - - /** - * Start editing the selected EPerson - * @param ePerson - */ - editEPerson(ePerson: EPerson) { - this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { - if (ePerson === activeEPerson) { - this.epersonService.cancelEditEPerson(); + constructor(private epersonService: EPersonDataService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private formBuilder: FormBuilder) { + this.updateEPeople({ + currentPage: 1, + elementsPerPage: this.config.pageSize + }); this.isEPersonFormShown = false; - } else { - this.epersonService.editEPerson(ePerson); - this.isEPersonFormShown = true; - } - }); - this.scrollToTop() - } - - /** - * Deletes EPerson, show notification on success/failure & updates EPeople list - */ - deleteEPerson(ePerson: EPerson) { - if (hasValue(ePerson.id)) { - this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { - if (success) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); - this.forceUpdateEPeople(); - } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); - } - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }) + this.searchForm = this.formBuilder.group(({ + scope: 'metadata', + query: '', + })); } - } - scrollToTop() { - (function smoothscroll() { - const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; - if (currentScroll > 0) { - window.requestAnimationFrame(smoothscroll); - window.scrollTo(0, currentScroll - (currentScroll / 8)); - } - })(); - } + /** + * Event triggered when the user changes page + * @param event + */ + onPageChange(event) { + this.updateEPeople({ + currentPage: event, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Update the list of EPeople by fetching it from the rest api or cache + */ + private updateEPeople(options) { + this.ePeople = this.epersonService.getEPeople(options); + } + + /** + * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing + * a new REST call + */ + public forceUpdateEPeople() { + this.epersonService.clearEPersonRequests(); + this.isEPersonFormShown = false; + this.search({ query: '', scope: 'metadata' }) + } + + /** + * Search in the EPeople by metadata (default) or email + * @param data Contains scope and query param + */ + search(data: any) { + this.ePeople = this.epersonService.searchByScope(data.scope, data.query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Checks whether the given EPerson is active (being edited) + * @param eperson + */ + isActive(eperson: EPerson): Observable { + return this.getActiveEPerson().pipe( + map((activeEPerson) => eperson === activeEPerson) + ); + } + + /** + * Gets the active eperson (being edited) + */ + getActiveEPerson(): Observable { + return this.epersonService.getActiveEPerson(); + } + + /** + * Start editing the selected EPerson + * @param ePerson + */ + toggleEditEPerson(ePerson: EPerson) { + this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { + if (ePerson === activeEPerson) { + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + } else { + this.epersonService.editEPerson(ePerson); + this.isEPersonFormShown = true; + } + }); + this.scrollToTop() + } + + /** + * Deletes EPerson, show notification on success/failure & updates EPeople list + */ + deleteEPerson(ePerson: EPerson) { + if (hasValue(ePerson.id)) { + this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { + if (success) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); + this.forceUpdateEPeople(); + } else { + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); + } + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + }) + } + } + + scrollToTop() { + (function smoothscroll() { + const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; + if (currentScroll > 0) { + window.requestAnimationFrame(smoothscroll); + window.scrollTo(0, currentScroll - (currentScroll / 8)); + } + })(); + } } diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.spec.ts new file mode 100644 index 0000000000..49f9a9cd93 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.spec.ts @@ -0,0 +1,54 @@ +import { EPersonMock } from '../../../shared/testing/eperson-mock'; +import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions'; +import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers'; + +const initialState: EPeopleRegistryState = { + editEPerson: null, +}; + +const editState: EPeopleRegistryState = { + editEPerson: EPersonMock, +}; + +class NullAction extends EPeopleRegistryEditEPersonAction { + type = null; + + constructor() { + super(undefined); + } +} + +describe('epeopleRegistryReducer', () => { + + it('should return the current state when no valid actions have been made', () => { + const state = initialState; + const action = new NullAction(); + const newState = ePeopleRegistryReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with an initial state', () => { + const state = initialState; + const action = new NullAction(); + const initState = ePeopleRegistryReducer(undefined, action); + + expect(initState).toEqual(state); + }); + + it('should update the current state to change the editEPerson to a new eperson when EPeopleRegistryEditEPersonAction is dispatched', () => { + const state = editState; + const action = new EPeopleRegistryEditEPersonAction(EPersonMock); + const newState = ePeopleRegistryReducer(state, action); + + expect(newState.editEPerson).toEqual(EPersonMock); + }); + + it('should update the current state to remove the editEPerson from the state when EPeopleRegistryCancelEPersonAction is dispatched', () => { + const state = editState; + const action = new EPeopleRegistryCancelEPersonAction(); + const newState = ePeopleRegistryReducer(state, action); + + expect(newState.editEPerson).toEqual(null); + }); +}); diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts index 3f90bc5207..87684f52aa 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts @@ -11,7 +11,6 @@ import { */ export interface EPeopleRegistryState { editEPerson: EPerson; - selectedEPeople: EPerson[]; } /** @@ -19,7 +18,6 @@ export interface EPeopleRegistryState { */ const initialState: EPeopleRegistryState = { editEPerson: null, - selectedEPeople: [], }; /** diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index dc271a7ee0..8549cb1f54 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -198,6 +198,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onCancel() { this.epersonService.cancelEditEPerson(); this.cancelForm.emit(); + this.clearFields(); } /** @@ -249,6 +250,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.submitForm.emit(ePersonToCreate); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name })); + this.cancelForm.emit(); } }); this.showNotificationIfEmailInUse(ePersonToCreate, 'created'); @@ -287,6 +289,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.submitForm.emit(editedEperson); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name })); + this.cancelForm.emit(); } }); @@ -303,8 +306,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) { // Relevant message for email in use - // TODO: should be changed to email scope, but byEmail currently not in backend - this.subs.push(this.epersonService.searchByScope(null, ePerson.email, { + this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { currentPage: 1, elementsPerPage: 0 }).pipe(getSucceededRemoteData(), getRemoteDataPayload()) From 2e574ce0bfd4676e5b66a95b552f167cdb255323 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Wed, 18 Mar 2020 13:45:37 +0100 Subject: [PATCH 28/30] eperson form tests --- .../epeople-registry.component.html | 4 +- .../epeople-registry.component.spec.ts | 2 +- .../eperson-form.component.spec.ts | 206 ++++++++++++++++++ .../eperson-form/eperson-form.component.ts | 7 +- 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 33bd5496dc..dd1e8bb62c 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -8,10 +8,10 @@ (cancelForm)="isEPersonFormShown = false">
-
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index 83499b6e17..8857ae0dd4 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -23,7 +23,7 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications- import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { EPeopleRegistryComponent } from './epeople-registry.component'; -fdescribe('EPeopleRegistryComponent', () => { +describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; let translateService: TranslateService; diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts new file mode 100644 index 0000000000..df04cd8c2d --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -0,0 +1,206 @@ +import { of as observableOf } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../../../../core/cache/response.models'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { FindListOptions } from '../../../../core/data/request.models'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../../shared/mocks/mock-form-builder-service'; +import { getMockTranslateService } from '../../../../shared/mocks/mock-translate.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson-mock'; +import { MockTranslateLoader } from '../../../../shared/testing/mock-translate-loader'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { EPeopleRegistryComponent } from '../epeople-registry.component'; +import { EPersonFormComponent } from './eperson-form.component'; + +describe('EPersonFormComponent', () => { + let component: EPersonFormComponent; + let fixture: ComponentFixture; + let translateService: TranslateService; + let builderService: FormBuilderService; + + const mockEPeople = [EPersonMock, EPersonMock2]; + let ePersonDataServiceStub: any; + + beforeEach(async(() => { + ePersonDataServiceStub = { + activeEPerson: null, + allEpeople: mockEPeople, + getEPeople(): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + }, + getActiveEPerson(): Observable { + return observableOf(this.activeEPerson); + }, + searchByScope(scope: string, query: string, options: FindListOptions = {}): Observable>> { + if (scope === 'email') { + const result = this.allEpeople.find((ePerson: EPerson) => { + return ePerson.email === query + }); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + } + if (scope === 'metadata') { + const result = this.allEpeople.find((ePerson: EPerson) => { + return (ePerson.name.includes(query) || ePerson.email.includes(query)) + }); + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + } + return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + }, + deleteEPerson(ePerson: EPerson): Observable { + this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { + return (ePerson2.uuid !== ePerson.uuid); + }); + return observableOf(true); + }, + create(ePerson: EPerson) { + this.allEpeople = [...this.allEpeople, ePerson] + }, + editEPerson(ePerson: EPerson) { + this.activeEPerson = ePerson; + }, + cancelEditEPerson() { + this.activeEPerson = null; + }, + clearEPersonRequests(): void { + // empty + }, + tryToCreate(ePerson: EPerson): Observable { + this.allEpeople = [...this.allEpeople, ePerson] + return observableOf(new RestResponse(true, 200, 'Success')); + }, + updateEPerson(ePerson: EPerson): Observable { + this.allEpeople.forEach((ePersonInList: EPerson, i: number) => { + if (ePersonInList.id === ePerson.id) { + this.allEpeople[i] = ePerson; + } + }); + return observableOf(new RestResponse(true, 200, 'Success')); + + } + }; + builderService = getMockFormBuilderService(); + translateService = getMockTranslateService(); + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [EPeopleRegistryComponent, EPersonFormComponent], + providers: [EPersonFormComponent, + { provide: EPersonDataService, useValue: ePersonDataServiceStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: FormBuilderService, useValue: builderService }, + EPeopleRegistryComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EPersonFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create EPersonFormComponent', inject([EPersonFormComponent], (comp: EPersonFormComponent) => { + expect(comp).toBeDefined(); + })); + + describe('when submitting the form', () => { + const firstName = 'testName'; + const lastName = 'testLastName'; + const email = 'testEmail@test.com'; + const canLogIn = false; + const requireCertificate = false; + + const expected = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: firstName + } + ], + 'eperson.lastname': [ + { + value: lastName + }, + ], + }, + email: email, + canLogIn: canLogIn, + requireCertificate: requireCertificate, + }); + beforeEach(() => { + spyOn(component.submitForm, 'emit'); + component.firstName.value = firstName; + component.lastName.value = lastName; + component.email.value = email; + component.canLogIn.value = canLogIn; + component.requireCertificate.value = requireCertificate; + }); + describe('without active EPerson', () => { + beforeEach(() => { + spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(undefined)); + component.onSubmit(); + fixture.detectChanges(); + }); + + it('should emit a new eperson using the correct values', async(() => { + fixture.whenStable().then(() => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expected); + }); + })); + }); + + describe('with an active eperson', () => { + const expectedWithId = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: firstName + } + ], + 'eperson.lastname': [ + { + value: lastName + }, + ], + }, + email: email, + canLogIn: canLogIn, + requireCertificate: requireCertificate, + }); + + beforeEach(() => { + spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); + component.onSubmit(); + fixture.detectChanges(); + }); + + it('should emit the existing eperson using the correct values', async(() => { + fixture.whenStable().then(() => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); + }); + })); + }); + }); + +}); diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index 8549cb1f54..d3900aef87 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -185,9 +185,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy { firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', email: eperson != null ? eperson.email : '', + canLogIn: eperson != null ? eperson.canLogIn : true, + requireCertificate: eperson != null ? eperson.requireCertificate : false }); - this.formGroup.get('canLogIn').patchValue((eperson != null ? eperson.canLogIn : true)); - this.formGroup.get('requireCertificate').patchValue((eperson != null ? eperson.requireCertificate : false)); })); }); } @@ -198,7 +198,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onCancel() { this.epersonService.cancelEditEPerson(); this.cancelForm.emit(); - this.clearFields(); } /** @@ -210,6 +209,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onSubmit() { this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( (ePerson: EPerson) => { + console.log('onsubmit ep', ePerson) const values = { metadata: { 'eperson.firstname': [ @@ -241,6 +241,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * @param values */ createNewEPerson(values) { + console.log('createNewEPerson(values)', values) const ePersonToCreate = Object.assign(new EPerson(), values); const response = this.epersonService.tryToCreate(ePersonToCreate); From 4e9afc9f673b7477bd16dc877644387ee45c6b9f Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Wed, 18 Mar 2020 14:49:37 +0100 Subject: [PATCH 29/30] fix after rebasing from master --- src/app/+admin/admin-routing.module.ts | 4 ++-- src/app/+admin/admin.module.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 2e583ec175..b4a68d692a 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,7 +1,7 @@ -import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { getAdminModulePath } from '../app-routing.module'; +import { NgModule } from '@angular/core'; import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getAdminModulePath } from '../app-routing.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index d1db17535f..fa2480a6ad 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { AdminAccessControlModule } from './admin-access-control/admin-access-control.module'; import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; +import { AdminRoutingModule } from './admin-routing.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { SearchPageModule } from '../+search-page/search-page.module'; import { ItemAdminSearchResultListElementComponent } from './admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component'; @@ -14,6 +15,7 @@ import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin @NgModule({ imports: [ + AdminRoutingModule, AdminRegistriesModule, AdminAccessControlModule, SharedModule, From 58f2a5bb2af25f642146761fa50e969343b76ec4 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Wed, 18 Mar 2020 15:52:55 +0100 Subject: [PATCH 30/30] fix tests after rebasing from master --- src/app/core/eperson/eperson-data.service.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index d301f9759d..1831386321 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -20,7 +20,6 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; @@ -40,10 +39,6 @@ describe('EPersonDataService', () => { let requestService: RequestService; let scheduler: TestScheduler; - const responseCacheEntry = new RequestEntry(); - responseCacheEntry.response = new RestResponse(true, 200, 'Success'); - responseCacheEntry.completed = true; - const epeople = [EPersonMock, EPersonMock2]; const restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; @@ -77,6 +72,7 @@ describe('EPersonDataService', () => { const getRequestEntry$ = (successful: boolean) => { return observableOf({ + completed: true, response: { isSuccessful: successful, payload: epeople } as any } as RequestEntry) };