mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'master' into manage-epersons-page
This commit is contained in:
@@ -1441,6 +1441,8 @@
|
|||||||
|
|
||||||
"nav.mydspace": "MyDSpace",
|
"nav.mydspace": "MyDSpace",
|
||||||
|
|
||||||
|
"nav.profile": "Profile",
|
||||||
|
|
||||||
"nav.search": "Search",
|
"nav.search": "Search",
|
||||||
|
|
||||||
"nav.statistics.header": "Statistics",
|
"nav.statistics.header": "Statistics",
|
||||||
@@ -1499,6 +1501,64 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"profile.breadcrumbs": "Update Profile",
|
||||||
|
|
||||||
|
"profile.card.identify": "Identify",
|
||||||
|
|
||||||
|
"profile.card.security": "Security",
|
||||||
|
|
||||||
|
"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",
|
||||||
|
|
||||||
|
"profile.metadata.form.error.lastname.required": "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.metadata.form.notifications.success.content": "Your changes to the profile were saved.",
|
||||||
|
|
||||||
|
"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.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",
|
||||||
|
|
||||||
|
"profile.security.form.label.passwordrepeat": "Retype to confirm",
|
||||||
|
|
||||||
|
"profile.security.form.notifications.success.content": "Your changes to the password were saved.",
|
||||||
|
|
||||||
|
"profile.security.form.notifications.success.title": "Password saved",
|
||||||
|
|
||||||
|
"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",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"project.listelement.badge": "Research Project",
|
"project.listelement.badge": "Research Project",
|
||||||
|
|
||||||
"project.page.contributor": "Contributors",
|
"project.page.contributor": "Contributors",
|
||||||
|
@@ -35,6 +35,12 @@ export function getAdminModulePath() {
|
|||||||
return `/${ADMIN_MODULE_PATH}`;
|
return `/${ADMIN_MODULE_PATH}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROFILE_MODULE_PATH = 'profile';
|
||||||
|
|
||||||
|
export function getProfileModulePath() {
|
||||||
|
return `/${PROFILE_MODULE_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDSOPath(dso: DSpaceObject): string {
|
export function getDSOPath(dso: DSpaceObject): string {
|
||||||
switch ((dso as any).type) {
|
switch ((dso as any).type) {
|
||||||
case Community.type.value:
|
case Community.type.value:
|
||||||
@@ -66,6 +72,7 @@ export function getDSOPath(dso: DSpaceObject): string {
|
|||||||
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
|
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
|
||||||
{ path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' },
|
{ path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' },
|
||||||
{ path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
|
{ path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
|
||||||
|
{ path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] },
|
||||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
@@ -23,7 +23,7 @@ import { NativeWindowRef, NativeWindowService } from '../services/window.service
|
|||||||
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
|
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
|
||||||
import { RouteService } from '../services/route.service';
|
import { RouteService } from '../services/route.service';
|
||||||
import { EPersonDataService } from '../eperson/eperson-data.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 LOGIN_ROUTE = '/login';
|
||||||
export const LOGOUT_ROUTE = '/logout';
|
export const LOGOUT_ROUTE = '/logout';
|
||||||
@@ -149,7 +149,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> {
|
public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> {
|
||||||
return this.epersonService.findByHref(userHref).pipe(
|
return this.epersonService.findByHref(userHref).pipe(
|
||||||
getFirstSucceededRemoteDataPayload()
|
getAllSucceededRemoteDataPayload()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -51,6 +51,14 @@ describe('ServerSyncBufferEffects', () => {
|
|||||||
_links: { self: { href: link } }
|
_links: { self: { href: link } }
|
||||||
});
|
});
|
||||||
return observableOf(object);
|
return observableOf(object);
|
||||||
|
},
|
||||||
|
getBySelfLink: (link) => {
|
||||||
|
const object = Object.assign(new DSpaceObject(), {
|
||||||
|
_links: {
|
||||||
|
self: { href: link }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return observableOf(object);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
22
src/app/core/cache/server-sync-buffer.effects.ts
vendored
22
src/app/core/cache/server-sync-buffer.effects.ts
vendored
@@ -16,13 +16,15 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s
|
|||||||
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
|
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
|
||||||
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||||
import { RequestService } from '../data/request.service';
|
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 { ObjectCacheService } from './object-cache.service';
|
||||||
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
|
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
|
||||||
import { GenericConstructor } from '../shared/generic-constructor';
|
import { GenericConstructor } from '../shared/generic-constructor';
|
||||||
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { RestRequestMethod } from '../data/rest-request-method';
|
import { RestRequestMethod } from '../data/rest-request-method';
|
||||||
|
import { ObjectCacheEntry } from './object-cache.reducer';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerSyncBufferEffects {
|
export class ServerSyncBufferEffects {
|
||||||
@@ -96,17 +98,19 @@ export class ServerSyncBufferEffects {
|
|||||||
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
|
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
|
||||||
*/
|
*/
|
||||||
private applyPatch(href: string): Observable<Action> {
|
private applyPatch(href: string): Observable<Action> {
|
||||||
const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1));
|
const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
|
||||||
|
|
||||||
return patchObject.pipe(
|
return patchObject.pipe(
|
||||||
map((object) => {
|
map((entry: ObjectCacheEntry) => {
|
||||||
const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object);
|
if (isNotEmpty(entry.patches)) {
|
||||||
|
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
|
||||||
this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject));
|
if (isNotEmpty(flatPatch)) {
|
||||||
|
this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, flatPatch));
|
||||||
return new ApplyPatchObjectCacheAction(href)
|
}
|
||||||
|
}
|
||||||
|
return new ApplyPatchObjectCacheAction(href);
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private actions$: Actions,
|
constructor(private actions$: Actions,
|
||||||
|
@@ -16,8 +16,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
|
|||||||
import { Item } from '../shared/item.model';
|
import { Item } from '../shared/item.model';
|
||||||
import { ChangeAnalyzer } from './change-analyzer';
|
import { ChangeAnalyzer } from './change-analyzer';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
import { FindListOptions } from './request.models';
|
import { FindListOptions, PatchRequest } from './request.models';
|
||||||
import { RequestService } from './request.service';
|
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';
|
const endpoint = 'https://rest.api/core';
|
||||||
|
|
||||||
@@ -53,8 +55,8 @@ class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
|||||||
describe('DataService', () => {
|
describe('DataService', () => {
|
||||||
let service: TestService;
|
let service: TestService;
|
||||||
let options: FindListOptions;
|
let options: FindListOptions;
|
||||||
const requestService = { generateRequestId: () => uuidv4() } as RequestService;
|
const requestService = getMockRequestService();
|
||||||
const halService = {} as HALEndpointService;
|
const halService = new HALEndpointServiceStub('url') as any;
|
||||||
const rdbService = {} as RemoteDataBuildService;
|
const rdbService = {} as RemoteDataBuildService;
|
||||||
const notificationsService = {} as NotificationsService;
|
const notificationsService = {} as NotificationsService;
|
||||||
const http = {} as HttpClient;
|
const http = {} as HttpClient;
|
||||||
@@ -285,18 +287,23 @@ describe('DataService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('patch', () => {
|
describe('patch', () => {
|
||||||
let operations;
|
const dso = {
|
||||||
let selfLink;
|
uuid: 'dso-uuid'
|
||||||
|
};
|
||||||
|
const operations = [
|
||||||
|
Object.assign({
|
||||||
|
op: 'move',
|
||||||
|
from: '/1',
|
||||||
|
path: '/5'
|
||||||
|
}) as Operation
|
||||||
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
operations = [{ op: 'replace', path: '/metadata/dc.title', value: 'random string' } as Operation];
|
service.patch(dso, operations);
|
||||||
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
|
||||||
spyOn(objectCache, 'addPatch');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call addPatch on the object cache with the right parameters', () => {
|
it('should configure a PatchRequest', () => {
|
||||||
service.patch(selfLink, operations);
|
expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PatchRequest));
|
||||||
expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -44,7 +44,7 @@ import {
|
|||||||
FindByIDRequest,
|
FindByIDRequest,
|
||||||
FindListOptions,
|
FindListOptions,
|
||||||
FindListRequest,
|
FindListRequest,
|
||||||
GetRequest
|
GetRequest, PatchRequest
|
||||||
} from './request.models';
|
} from './request.models';
|
||||||
import { RequestEntry } from './request.reducer';
|
import { RequestEntry } from './request.reducer';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
@@ -329,12 +329,28 @@ export abstract class DataService<T extends CacheableObject> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new patch to the object cache to a specified object
|
* Send a patch request for a specified object
|
||||||
* @param {string} href The selflink of the object that will be patched
|
* @param {T} dso The object to send a patch request for
|
||||||
* @param {Operation[]} operations The patch operations to be performed
|
* @param {Operation[]} operations The patch operations to be performed
|
||||||
*/
|
*/
|
||||||
patch(href: string, operations: Operation[]) {
|
patch(dso: T, operations: Operation[]): Observable<RestResponse> {
|
||||||
this.objectCache.addPatch(href, operations);
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -3,6 +3,8 @@ import { compare } from 'fast-json-patch';
|
|||||||
import { ChangeAnalyzer } from './change-analyzer';
|
import { ChangeAnalyzer } from './change-analyzer';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
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
|
* A class to determine what differs between two
|
||||||
@@ -21,6 +23,21 @@ export class DSOChangeAnalyzer<T extends DSpaceObject> implements ChangeAnalyzer
|
|||||||
* The second object to compare
|
* The second object to compare
|
||||||
*/
|
*/
|
||||||
diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -119,6 +119,8 @@ export class HeadRequest extends RestRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PatchRequest extends RestRequest {
|
export class PatchRequest extends RestRequest {
|
||||||
|
public responseMsToLive = 60 * 15 * 1000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public uuid: string,
|
public uuid: string,
|
||||||
public href: string,
|
public href: string,
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
<ds-form *ngIf="formModel"
|
||||||
|
[formId]="'profile-page-metadata-form-id'"
|
||||||
|
[formModel]="formModel"
|
||||||
|
[formGroup]="formGroup"
|
||||||
|
[displaySubmit]="false">
|
||||||
|
</ds-form>
|
@@ -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<ProfilePageMetadataFormComponent>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
@@ -0,0 +1,212 @@
|
|||||||
|
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 { 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';
|
||||||
|
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
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',
|
||||||
|
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<string>({
|
||||||
|
id: 'language',
|
||||||
|
name: 'eperson.language'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form group of this form
|
||||||
|
*/
|
||||||
|
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[];
|
||||||
|
|
||||||
|
constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig,
|
||||||
|
protected formBuilderService: FormBuilderService,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>) => {
|
||||||
|
if (fieldModel.name === 'email') {
|
||||||
|
fieldModel.value = this.user.email;
|
||||||
|
} else {
|
||||||
|
fieldModel.value = this.user.firstMetadataValue(fieldModel.name);
|
||||||
|
}
|
||||||
|
if (fieldModel.id === 'language') {
|
||||||
|
(fieldModel as DynamicSelectModel<string>).options =
|
||||||
|
this.activeLangs.map((langConfig) => Object.assign({ value: langConfig.code, label: langConfig.label }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the translations of the field labels and error messages
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMetadata = cloneDeep(this.user.metadata);
|
||||||
|
let changed = false;
|
||||||
|
this.formModel.filter((fieldModel) => fieldModel.id !== 'email').forEach((fieldModel: DynamicFormValueControlModel<string>) => {
|
||||||
|
if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) {
|
||||||
|
if (hasValue(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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
15
src/app/profile-page/profile-page-routing.module.ts
Normal file
15
src/app/profile-page/profile-page-routing.module.ts
Normal file
@@ -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 {
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="container-fluid mb-4">{{'profile.security.form.info' | translate}}</div>
|
||||||
|
<ds-form *ngIf="formModel"
|
||||||
|
[formId]="'profile-page-security-form-id'"
|
||||||
|
[formModel]="formModel"
|
||||||
|
[formGroup]="formGroup"
|
||||||
|
[displaySubmit]="false">
|
||||||
|
</ds-form>
|
||||||
|
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notLongEnough')">{{'profile.security.form.error.password-length' | translate}}</div>
|
||||||
|
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notSame')">{{'profile.security.form.error.matching-passwords' | translate}}</div>
|
@@ -0,0 +1,110 @@
|
|||||||
|
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<ProfilePageSecurityFormComponent>;
|
||||||
|
|
||||||
|
const user = Object.assign(new EPerson(), {
|
||||||
|
_links: {
|
||||||
|
self: { href: 'user-selflink' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const epersonService = jasmine.createSpyObj('epersonService', {
|
||||||
|
patch: 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.patch', () => {
|
||||||
|
expect(epersonService.patch).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, long enough and equal', () => {
|
||||||
|
let result;
|
||||||
|
let operations;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setModelValue('password', 'testest');
|
||||||
|
setModelValue('passwordrepeat', 'testest');
|
||||||
|
operations = [{ op: 'replace', path: '/password', value: 'testest' }];
|
||||||
|
result = component.updateSecurity();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true', () => {
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return call epersonService.patch', () => {
|
||||||
|
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setModelValue(id: string, value: string) {
|
||||||
|
component.formGroup.patchValue({
|
||||||
|
[id]: value
|
||||||
|
});
|
||||||
|
component.formGroup.markAllAsTouched();
|
||||||
|
}
|
||||||
|
});
|
@@ -0,0 +1,151 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import {
|
||||||
|
DynamicFormControlModel,
|
||||||
|
DynamicFormService,
|
||||||
|
DynamicInputModel
|
||||||
|
} from '@ng-dynamic-forms/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
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';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
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',
|
||||||
|
name: 'password',
|
||||||
|
inputType: 'password'
|
||||||
|
}),
|
||||||
|
new DynamicInputModel({
|
||||||
|
id: 'passwordrepeat',
|
||||||
|
name: 'passwordrepeat',
|
||||||
|
inputType: 'password'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form group of this form
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
protected epersonService: EPersonDataService,
|
||||||
|
protected notificationsService: NotificationsService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] });
|
||||||
|
this.updateFieldTranslations();
|
||||||
|
this.translate.onLangChange
|
||||||
|
.subscribe(() => {
|
||||||
|
this.updateFieldTranslations();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the translations of the field labels
|
||||||
|
*/
|
||||||
|
updateFieldTranslations() {
|
||||||
|
this.formModel.forEach(
|
||||||
|
(fieldModel: DynamicInputModel) => {
|
||||||
|
fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if both password fields are filled in and equal
|
||||||
|
* @param group The FormGroup to validate
|
||||||
|
*/
|
||||||
|
checkPasswordsEqual(group: FormGroup) {
|
||||||
|
const pass = group.get('password').value;
|
||||||
|
const repeatPass = group.get('passwordrepeat').value;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
if (!this.formGroup.valid) {
|
||||||
|
if (passEntered) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (passEntered) {
|
||||||
|
const operation = Object.assign({ op: 'replace', path: '/password', value: pass });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
27
src/app/profile-page/profile-page.component.html
Normal file
27
src/app/profile-page/profile-page.component.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<ng-container *ngVar="(user$ | async) as user">
|
||||||
|
<div class="container" *ngIf="user">
|
||||||
|
<h3 class="mb-4">{{'profile.head' | translate}}</h3>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'profile.card.identify' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ds-profile-page-metadata-form [user]="user"></ds-profile-page-metadata-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'profile.card.security' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ds-profile-page-security-form [user]="user"></ds-profile-page-security-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary" (click)="updateProfile()">{{'profile.form.submit' | translate}}</button>
|
||||||
|
|
||||||
|
<ng-container *ngVar="(groupsRD$ | async)?.payload?.page as groups">
|
||||||
|
<div *ngIf="groups">
|
||||||
|
<h3 class="mt-4">{{'profile.groups.head' | translate}}</h3>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li *ngFor="let group of groups" class="list-group-item">{{group.name}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
129
src/app/profile-page/profile-page.component.spec.ts
Normal file
129
src/app/profile-page/profile-page.component.spec.ts
Normal file
@@ -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<ProfilePageComponent>;
|
||||||
|
|
||||||
|
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<AppState>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
84
src/app/profile-page/profile-page.component.ts
Normal file
84
src/app/profile-page/profile-page.component.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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';
|
||||||
|
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',
|
||||||
|
templateUrl: './profile-page.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authenticated user
|
||||||
|
*/
|
||||||
|
user$: Observable<EPerson>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The groups the user belongs to
|
||||||
|
*/
|
||||||
|
groupsRD$: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix for the notification messages of this component
|
||||||
|
*/
|
||||||
|
NOTIFICATIONS_PREFIX = 'profile.notifications.';
|
||||||
|
|
||||||
|
constructor(private store: Store<AppState>,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private epersonService: EPersonDataService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
src/app/profile-page/profile-page.module.ts
Normal file
23
src/app/profile-page/profile-page.module.ts
Normal file
@@ -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 {
|
||||||
|
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
<ds-loading *ngIf="(loading$ | async)"></ds-loading>
|
<ds-loading *ngIf="(loading$ | async)"></ds-loading>
|
||||||
<div *ngIf="!(loading$ | async)">
|
<div *ngIf="!(loading$ | async)">
|
||||||
<span class="dropdown-item-text">{{(user$ | async)?.name}} ({{(user$ | async)?.email}})</span>
|
<span class="dropdown-item-text">{{(user$ | async)?.name}} ({{(user$ | async)?.email}})</span>
|
||||||
|
<a class="dropdown-item" [routerLink]="[profileRoute]" routerLinkActive="active">{{'nav.profile' | translate}}</a>
|
||||||
<a class="dropdown-item" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a>
|
<a class="dropdown-item" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<ds-log-out></ds-log-out>
|
<ds-log-out></ds-log-out>
|
||||||
|
@@ -7,6 +7,7 @@ import { EPerson } from '../../../core/eperson/models/eperson.model';
|
|||||||
import { AppState } from '../../../app.reducer';
|
import { AppState } from '../../../app.reducer';
|
||||||
import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors';
|
import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors';
|
||||||
import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component';
|
import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component';
|
||||||
|
import { getProfileModulePath } from '../../../app-routing.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component represents the user nav menu.
|
* This component represents the user nav menu.
|
||||||
@@ -36,6 +37,11 @@ export class UserMenuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public mydspaceRoute = MYDSPACE_ROUTE;
|
public mydspaceRoute = MYDSPACE_ROUTE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile page route
|
||||||
|
*/
|
||||||
|
public profileRoute = getProfileModulePath();
|
||||||
|
|
||||||
constructor(private store: Store<AppState>) {
|
constructor(private store: Store<AppState>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user