diff --git a/.gitignore b/.gitignore
index 01fc9231bc..cbf74702cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,5 @@ yarn-error.log
*.css
package-lock.json
+
+.java-version
diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5
index 18a0beacc2..81b7857788 100644
--- a/resources/i18n/en.json5
+++ b/resources/i18n/en.json5
@@ -170,6 +170,98 @@
+ "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 (exact)",
+
+ "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.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}}\"",
+
+ "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",
+
+ "admin.search.community.edit": "Edit",
+
+ "admin.search.item.delete": "Delete",
+
+ "admin.search.item.edit": "Edit",
+
+ "admin.search.item.make-private": "Make Private",
+
+ "admin.search.item.make-public": "Make Public",
+
+ "admin.search.item.move": "Move",
+
+ "admin.search.item.private": "Private",
+
+ "admin.search.item.reinstate": "Reinstate",
+
+ "admin.search.item.withdraw": "Withdraw",
+
+ "admin.search.item.withdrawn": "Withdrawn",
+
+ "admin.search.title": "Administrative Search",
+
+
+
"auth.errors.invalid-user": "Invalid email address or password.",
"auth.messages.expired": "Your session has expired. Please log in again.",
@@ -913,6 +1005,12 @@
"item.edit.tabs.status.title": "Item Edit - Status",
+ "item.edit.tabs.versionhistory.head": "Version History",
+
+ "item.edit.tabs.versionhistory.title": "Item Edit - Version History",
+
+ "item.edit.tabs.versionhistory.under-construction": "Editing or adding new versions is not yet possible in this user interface.",
+
"item.edit.tabs.view.head": "View Item",
"item.edit.tabs.view.title": "Item Edit - View",
@@ -992,6 +1090,25 @@
"item.select.table.title": "Title",
+ "item.version.history.empty": "There are no other versions for this item yet.",
+
+ "item.version.history.head": "Version History",
+
+ "item.version.history.return": "Return",
+
+ "item.version.history.selected": "Selected version",
+
+ "item.version.history.table.version": "Version",
+
+ "item.version.history.table.item": "Item",
+
+ "item.version.history.table.editor": "Editor",
+
+ "item.version.history.table.date": "Date",
+
+ "item.version.history.table.summary": "Summary",
+
+
"journal.listelement.badge": "Journal",
@@ -1121,6 +1238,10 @@
+ "menu.section.admin_search": "Admin Search",
+
+
+
"menu.section.browse_community": "This Community",
"menu.section.browse_community_by_author": "By Author",
@@ -1171,18 +1292,10 @@
- "menu.section.find": "Find",
-
- "menu.section.find_items": "Items",
-
- "menu.section.find_private_items": "Private Items",
-
- "menu.section.find_withdrawn_items": "Withdrawn Items",
-
-
-
"menu.section.icon.access_control": "Access Control menu section",
+ "menu.section.icon.admin_search": "Admin search menu section",
+
"menu.section.icon.control_panel": "Control Panel menu section",
"menu.section.icon.curation_task": "Curation Task menu section",
@@ -1357,6 +1470,8 @@
"nav.mydspace": "MyDSpace",
+ "nav.profile": "Profile",
+
"nav.search": "Search",
"nav.statistics.header": "Statistics",
@@ -1415,6 +1530,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.page.contributor": "Contributors",
@@ -1480,7 +1653,7 @@
"relationships.isSingleVolumeOf": "Journal Volume",
"relationships.isVolumeOf": "Journal Volumes",
-
+
"relationships.isContributorOf": "Contributors",
@@ -1502,6 +1675,8 @@
"search.filters.applied.f.dateSubmitted": "Date submitted",
+ "search.filters.applied.f.discoverable": "Private",
+
"search.filters.applied.f.entityType": "Item Type",
"search.filters.applied.f.has_content_in_original_bundle": "Has files",
@@ -1513,10 +1688,15 @@
"search.filters.applied.f.subject": "Subject",
"search.filters.applied.f.submitter": "Submitter",
+
"search.filters.applied.f.jobTitle": "Job Title",
+
"search.filters.applied.f.birthDate.max": "End birth date",
+
"search.filters.applied.f.birthDate.min": "Start birth date",
+ "search.filters.applied.f.withdrawn": "Withdrawn",
+
"search.filters.filter.author.head": "Author",
@@ -1553,6 +1733,10 @@
"search.filters.filter.dateSubmitted.placeholder": "Date submitted",
+ "search.filters.filter.discoverable.head": "Private",
+
+ "search.filters.filter.withdrawn.head": "Withdrawn",
+
"search.filters.filter.entityType.head": "Item Type",
"search.filters.filter.entityType.placeholder": "Item Type",
@@ -1609,6 +1793,25 @@
+ "search.filters.entityType.JournalIssue": "Journal Issue",
+
+ "search.filters.entityType.JournalVolume": "Journal Volume",
+
+ "search.filters.entityType.OrgUnit": "Organizational Unit",
+
+ "search.filters.has_content_in_original_bundle.true": "Yes",
+
+ "search.filters.has_content_in_original_bundle.false": "No",
+
+ "search.filters.discoverable.true": "No",
+
+ "search.filters.discoverable.false": "Yes",
+
+ "search.filters.withdrawn.true": "Yes",
+
+ "search.filters.withdrawn.false": "No",
+
+
"search.filters.head": "Filters",
"search.filters.reset": "Reset filters",
@@ -1988,6 +2191,8 @@
"title": "DSpace",
+ "administrativeView.search.results.head": "Administrative Search",
+
"uploader.browse": "browse",
diff --git a/resources/i18n/nl.json5 b/resources/i18n/nl.json5
index 344a36e2e8..871efcbd04 100644
--- a/resources/i18n/nl.json5
+++ b/resources/i18n/nl.json5
@@ -1248,7 +1248,7 @@
// "journalissue.listelement.badge": "Journal Issue",
- "journalissue.listelement.badge": "Tijdschrift aflevering",
+ "journalissue.listelement.badge": "Tijdschriftaflevering",
// "journalissue.page.description": "Description",
"journalissue.page.description": "Beschrijving",
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..83f67a770e
--- /dev/null
+++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts
@@ -0,0 +1,17 @@
+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' } },
+ ])
+ ]
+})
+/**
+ * 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
new file mode 100644
index 0000000000..0c8573e135
--- /dev/null
+++ b/src/app/+admin/admin-access-control/admin-access-control.module.ts
@@ -0,0 +1,29 @@
+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: []
+})
+/**
+ * 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/epeople-registry.actions.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts
new file mode 100644
index 0000000000..4fd6ecbf5e
--- /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_EDIT_EPERSON'),
+};
+
+/* 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(eperson: EPerson) {
+ this.eperson = eperson;
+ }
+}
+
+/**
+ * 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..dd1e8bb62c
--- /dev/null
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{labelPrefix + 'search.head' | translate}}
+
+
+
0"
+ [paginationOptions]="config"
+ [pageInfoState]="(ePeople | async)?.payload"
+ [collectionSize]="(ePeople | async)?.payload?.totalElements"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ (pageChange)="onPageChange($event)">
+
+
+
+
+
+ {{labelPrefix + 'table.id' | translate}} |
+ {{labelPrefix + 'table.name' | translate}} |
+ {{labelPrefix + 'table.email' | translate}} |
+ {{labelPrefix + 'table.edit' | translate}} |
+
+
+
+
+ {{eperson.id}} |
+ {{eperson.name}} |
+ {{eperson.email}} |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ {{labelPrefix + 'no-items' | translate}}
+
+
+
+
+
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..8857ae0dd4
--- /dev/null
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts
@@ -0,0 +1,205 @@
+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';
+
+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],
+ 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('toggleEditEPerson', () => {
+ 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('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);
+ }
+
+ })
+ });
+ });
+ });
+
+ 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 !== ePeopleIdsFoundBeforeDelete[0].nativeElement.textContent).toBeTrue();
+ });
+ });
+ });
+ });
+
+});
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..38e15b1420
--- /dev/null
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts
@@ -0,0 +1,163 @@
+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: 'metadata',
+ 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.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
new file mode 100644
index 0000000000..87684f52aa
--- /dev/null
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts
@@ -0,0 +1,47 @@
+import { EPerson } from '../../../core/eperson/models/eperson.model';
+import {
+ EPeopleRegistryAction,
+ EPeopleRegistryActionTypes,
+ EPeopleRegistryEditEPersonAction
+} from './epeople-registry.actions';
+
+/**
+ * The EPeople registry state.
+ * @interface EPeopleRegistryState
+ */
+export interface EPeopleRegistryState {
+ editEPerson: EPerson;
+}
+
+/**
+ * The initial state.
+ */
+const initialState: EPeopleRegistryState = {
+ editEPerson: null,
+};
+
+/**
+ * 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.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
new file mode 100644
index 0000000000..d3900aef87
--- /dev/null
+++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts
@@ -0,0 +1,344 @@
+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 { 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';
+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();
+
+ /**
+ * List of subscriptions
+ */
+ 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() {
+ 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',
+ 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,
+ this.lastName,
+ this.email,
+ this.canLogIn,
+ this.requireCertificate,
+ ];
+ this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
+ this.subs.push(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
+ });
+ }));
+ });
+ }
+
+ /**
+ * 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) => {
+ console.log('onsubmit ep', 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,
+ };
+ if (ePerson == null) {
+ this.createNewEPerson(values);
+ } else {
+ this.editEPerson(ePerson, values);
+ }
+ }
+ );
+ }
+
+ /**
+ * Creates new EPerson based on given values from form
+ * @param values
+ */
+ createNewEPerson(values) {
+ console.log('createNewEPerson(values)', values)
+ 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.cancelForm.emit();
+ }
+ });
+ this.showNotificationIfEmailInUse(ePersonToCreate, 'created');
+ }
+
+ /**
+ * Edits existing EPerson based on given values from form and old EPerson
+ * @param ePerson ePerson to edit
+ * @param values new ePerson values (of form)
+ */
+ editEPerson(ePerson: EPerson, values) {
+ const editedEperson = 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),
+ _links: ePerson._links,
+ });
+
+ 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.edited.failure', { name: editedEperson.name }));
+ this.cancelForm.emit();
+ }
+ });
+
+ if (values.email != null && values.email !== ePerson.email) {
+ this.showNotificationIfEmailInUse(editedEperson, 'edited');
+ }
+ }
+
+ /**
+ * 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
+ this.subs.push(this.epersonService.searchByScope('email', 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.' + notificationSection + '.failure.emailInUse', {
+ name: ePerson.name,
+ email: ePerson.email
+ }));
+ }
+ }));
+ }
+
+ /**
+ * Reset all input-fields to be empty
+ */
+ clearFields() {
+ this.formGroup.patchValue({
+ firstName: '',
+ lastName: '',
+ email: '',
+ canLogin: true,
+ requireCertificate: false
+ });
+ }
+
+ /**
+ * Cancel the current edit when component is destroyed & unsub all subscriptions
+ */
+ ngOnDestroy(): void {
+ this.onCancel();
+ this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
+ }
+}
diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts
index 2003ecf124..b4a68d692a 100644
--- a/src/app/+admin/admin-routing.module.ts
+++ b/src/app/+admin/admin-routing.module.ts
@@ -2,8 +2,11 @@ import { RouterModule } from '@angular/router';
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';
const REGISTRIES_MODULE_PATH = 'registries';
+const ACCESS_CONTROL_MODULE_PATH = 'access-control';
export function getRegistriesModulePath() {
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();
@@ -15,7 +18,17 @@ 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 },
+ component: AdminSearchPageComponent,
+ data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' }
+ },
])
]
})
diff --git a/src/app/+admin/admin-search-page/admin-search-page.component.html b/src/app/+admin/admin-search-page/admin-search-page.component.html
new file mode 100644
index 0000000000..69ff132fe3
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-page.component.html
@@ -0,0 +1 @@
+
diff --git a/src/app/+admin/admin-search-page/admin-search-page.component.scss b/src/app/+admin/admin-search-page/admin-search-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-page.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-page.component.spec.ts
new file mode 100644
index 0000000000..05546d180b
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-page.component.spec.ts
@@ -0,0 +1,27 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminSearchPageComponent } from './admin-search-page.component';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+
+describe('AdminSearchPageComponent', () => {
+ let component: AdminSearchPageComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ AdminSearchPageComponent ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AdminSearchPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-page.component.ts b/src/app/+admin/admin-search-page/admin-search-page.component.ts
new file mode 100644
index 0000000000..cbbc65a489
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-page.component.ts
@@ -0,0 +1,18 @@
+import { Component, OnInit } from '@angular/core';
+import { Context } from '../../core/shared/context.model';
+
+@Component({
+ selector: 'ds-admin-search-page',
+ templateUrl: './admin-search-page.component.html',
+ styleUrls: ['./admin-search-page.component.scss']
+})
+
+/**
+ * Component that represents a search page for administrators
+ */
+export class AdminSearchPageComponent {
+ /**
+ * The context of this page
+ */
+ context: Context = Context.AdminSearch;
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.html
new file mode 100644
index 0000000000..8aa75df7dd
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.html
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts
new file mode 100644
index 0000000000..1dcd978095
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts
@@ -0,0 +1,66 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
+import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
+import { SharedModule } from '../../../../../shared/shared.module';
+import { CollectionAdminSearchResultGridElementComponent } from './collection-admin-search-result-grid-element.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model';
+import { Collection } from '../../../../../core/shared/collection.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module';
+
+describe('CollectionAdminSearchResultGridElementComponent', () => {
+ let component: CollectionAdminSearchResultGridElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new CollectionSearchResult();
+ searchResult.indexableObject = new Collection();
+ searchResult.indexableObject.uuid = id;
+ }
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([]),
+ SharedModule
+ ],
+ declarations: [CollectionAdminSearchResultGridElementComponent],
+ providers: [
+ { provide: TruncatableService, useValue: mockTruncatableService },
+ { provide: BitstreamDataService, useValue: {} },
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CollectionAdminSearchResultGridElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render an edit button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.edit-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(getCollectionEditPath(id));
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts
new file mode 100644
index 0000000000..5e784165ab
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model';
+import { Collection } from '../../../../../core/shared/collection.model';
+import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module';
+import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component';
+
+@listableObjectComponent(CollectionSearchResult, ViewMode.GridElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-collection-admin-search-result-list-element',
+ styleUrls: ['./collection-admin-search-result-grid-element.component.scss'],
+ templateUrl: './collection-admin-search-result-grid-element.component.html'
+})
+/**
+ * The component for displaying a list element for a collection search result on the admin search page
+ */
+export class CollectionAdminSearchResultGridElementComponent extends SearchResultGridElementComponent {
+ editPath: string;
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.editPath = getCollectionEditPath(this.dso.uuid);
+ }
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.html
new file mode 100644
index 0000000000..4f6d80c43b
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.html
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts
new file mode 100644
index 0000000000..99d33f841a
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts
@@ -0,0 +1,70 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule } from '@ngx-translate/core';
+import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
+import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
+import { SharedModule } from '../../../../../shared/shared.module';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { CommunityAdminSearchResultGridElementComponent } from './community-admin-search-result-grid-element.component';
+import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model';
+import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module';
+import { Community } from '../../../../../core/shared/community.model';
+import { CommunityAdminSearchResultListElementComponent } from '../../admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component';
+
+describe('CommunityAdminSearchResultGridElementComponent', () => {
+ let component: CommunityAdminSearchResultGridElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new CommunitySearchResult();
+ searchResult.indexableObject = new Community();
+ searchResult.indexableObject.uuid = id;
+ }
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([]),
+ SharedModule
+ ],
+ declarations: [CommunityAdminSearchResultGridElementComponent],
+ providers: [
+ { provide: TruncatableService, useValue: mockTruncatableService },
+ { provide: BitstreamDataService, useValue: {} },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CommunityAdminSearchResultGridElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render an edit button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.edit-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(getCommunityEditPath(id));
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts
new file mode 100644
index 0000000000..8df12e703f
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model';
+import { Community } from '../../../../../core/shared/community.model';
+import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module';
+import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component';
+
+@listableObjectComponent(CommunitySearchResult, ViewMode.GridElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-community-admin-search-result-grid-element',
+ styleUrls: ['./community-admin-search-result-grid-element.component.scss'],
+ templateUrl: './community-admin-search-result-grid-element.component.html'
+})
+/**
+ * The component for displaying a list element for a community search result on the admin search page
+ */
+export class CommunityAdminSearchResultGridElementComponent extends SearchResultGridElementComponent {
+ editPath: string;
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.editPath = getCommunityEditPath(this.dso.uuid);
+ }
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html
new file mode 100644
index 0000000000..c571c3d663
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html
@@ -0,0 +1,15 @@
+
+
+
+
+ {{ "admin.search.item.private" | translate }}
+
+
+ {{ "admin.search.item.withdrawn" | translate }}
+
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts
new file mode 100644
index 0000000000..d940781f79
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts
@@ -0,0 +1,121 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule } from '@ngx-translate/core';
+import { Observable } from 'rxjs/internal/Observable';
+import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
+import { RemoteData } from '../../../../../core/data/remote-data';
+import { Bitstream } from '../../../../../core/shared/bitstream.model';
+import { Item } from '../../../../../core/shared/item.model';
+import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
+import { SharedModule } from '../../../../../shared/shared.module';
+import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
+import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-result-grid-element.component';
+
+describe('ItemAdminSearchResultGridElementComponent', () => {
+ let component: ItemAdminSearchResultGridElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ const mockBitstreamDataService = {
+ getThumbnailFor(item: Item): Observable> {
+ return createSuccessfulRemoteDataObject$(new Bitstream());
+ }
+ };
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new ItemSearchResult();
+ searchResult.indexableObject = new Item();
+ searchResult.indexableObject.uuid = id;
+ }
+
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule(
+ {
+ declarations: [ItemAdminSearchResultGridElementComponent],
+ imports: [
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([]),
+ SharedModule
+ ],
+ providers: [
+ { provide: TruncatableService, useValue: mockTruncatableService },
+ { provide: BitstreamDataService, useValue: mockBitstreamDataService },
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemAdminSearchResultGridElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('when the item is not withdrawn', () => {
+ beforeEach(() => {
+ component.dso.isWithdrawn = false;
+ fixture.detectChanges();
+ });
+
+ it('should not show the withdrawn badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.withdrawn-badge'));
+ expect(badge).toBeNull();
+ });
+ });
+
+ describe('when the item is withdrawn', () => {
+ beforeEach(() => {
+ component.dso.isWithdrawn = true;
+ fixture.detectChanges();
+ });
+
+ it('should show the withdrawn badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.withdrawn-badge'));
+ expect(badge).not.toBeNull();
+ });
+ });
+
+ describe('when the item is not private', () => {
+ beforeEach(() => {
+ component.dso.isDiscoverable = true;
+ fixture.detectChanges();
+ });
+ it('should not show the private badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.private-badge'));
+ expect(badge).toBeNull();
+ });
+ });
+
+ describe('when the item is private', () => {
+ beforeEach(() => {
+ component.dso.isDiscoverable = false;
+ fixture.detectChanges();
+ });
+
+ it('should show the private badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.private-badge'));
+ expect(badge).not.toBeNull();
+ });
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts
new file mode 100644
index 0000000000..04558f6320
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts
@@ -0,0 +1,75 @@
+import { Component, ComponentFactoryResolver, ElementRef, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
+import { Item } from '../../../../../core/shared/item.model';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { getListableObjectComponent, listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
+import { getItemEditPath } from '../../../../../+item-page/item-page-routing.module';
+import { URLCombiner } from '../../../../../core/url-combiner/url-combiner';
+import {
+ ITEM_EDIT_DELETE_PATH,
+ ITEM_EDIT_MOVE_PATH,
+ ITEM_EDIT_PRIVATE_PATH,
+ ITEM_EDIT_PUBLIC_PATH,
+ ITEM_EDIT_REINSTATE_PATH,
+ ITEM_EDIT_WITHDRAW_PATH
+} from '../../../../../+item-page/edit-item-page/edit-item-page.routing.module';
+import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
+import { GenericConstructor } from '../../../../../core/shared/generic-constructor';
+import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive';
+
+@listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-item-admin-search-result-grid-element',
+ styleUrls: ['./item-admin-search-result-grid-element.component.scss'],
+ templateUrl: './item-admin-search-result-grid-element.component.html'
+})
+/**
+ * The component for displaying a list element for an item search result on the admin search page
+ */
+export class ItemAdminSearchResultGridElementComponent extends SearchResultGridElementComponent implements OnInit {
+ @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective;
+ @ViewChild('badges', { static: true }) badges: ElementRef;
+ @ViewChild('buttons', { static: true }) buttons: ElementRef;
+
+ constructor(protected truncatableService: TruncatableService,
+ protected bitstreamDataService: BitstreamDataService,
+ private componentFactoryResolver: ComponentFactoryResolver
+ ) {
+ super(truncatableService, bitstreamDataService);
+ }
+
+ /**
+ * Setup the dynamic child component
+ */
+ ngOnInit(): void {
+ super.ngOnInit();
+ const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent());
+
+ const viewContainerRef = this.listableObjectDirective.viewContainerRef;
+ viewContainerRef.clear();
+
+ const componentRef = viewContainerRef.createComponent(
+ componentFactory,
+ 0,
+ undefined,
+ [
+ [this.badges.nativeElement],
+ [this.buttons.nativeElement]
+ ]);
+ (componentRef.instance as any).object = this.object;
+ (componentRef.instance as any).index = this.index;
+ (componentRef.instance as any).linkType = this.linkType;
+ (componentRef.instance as any).listID = this.listID;
+ }
+
+ /**
+ * Fetch the component depending on the item's relationship type, view mode and context
+ * @returns {GenericConstructor}
+ */
+ private getComponent(): GenericConstructor {
+ return getListableObjectComponent(this.object.getRenderTypes(), ViewMode.GridElement, undefined)
+ }
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.html
new file mode 100644
index 0000000000..e51e207bbe
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.html
@@ -0,0 +1,9 @@
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts
new file mode 100644
index 0000000000..259d1d64aa
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts
@@ -0,0 +1,60 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { CollectionAdminSearchResultListElementComponent } from './collection-admin-search-result-list-element.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model';
+import { Collection } from '../../../../../core/shared/collection.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module';
+
+describe('CollectionAdminSearchResultListElementComponent', () => {
+ let component: CollectionAdminSearchResultListElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new CollectionSearchResult();
+ searchResult.indexableObject = new Collection();
+ searchResult.indexableObject.uuid = id;
+ }
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([])
+ ],
+ declarations: [CollectionAdminSearchResultListElementComponent],
+ providers: [{ provide: TruncatableService, useValue: {} }],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CollectionAdminSearchResultListElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render an edit button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(getCollectionEditPath(id));
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts
new file mode 100644
index 0000000000..e49f272184
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
+import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model';
+import { Collection } from '../../../../../core/shared/collection.model';
+import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module';
+
+@listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-collection-admin-search-result-list-element',
+ styleUrls: ['./collection-admin-search-result-list-element.component.scss'],
+ templateUrl: './collection-admin-search-result-list-element.component.html'
+})
+/**
+ * The component for displaying a list element for a collection search result on the admin search page
+ */
+export class CollectionAdminSearchResultListElementComponent extends SearchResultListElementComponent {
+ editPath: string;
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.editPath = getCollectionEditPath(this.dso.uuid);
+ }
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.html
new file mode 100644
index 0000000000..8938c316f4
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.html
@@ -0,0 +1,9 @@
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts
new file mode 100644
index 0000000000..a7922d7cf5
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts
@@ -0,0 +1,60 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { CommunityAdminSearchResultListElementComponent } from './community-admin-search-result-list-element.component';
+import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model';
+import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module';
+import { Community } from '../../../../../core/shared/community.model';
+
+describe('CommunityAdminSearchResultListElementComponent', () => {
+ let component: CommunityAdminSearchResultListElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new CommunitySearchResult();
+ searchResult.indexableObject = new Community();
+ searchResult.indexableObject.uuid = id;
+ }
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([])
+ ],
+ declarations: [CommunityAdminSearchResultListElementComponent],
+ providers: [{ provide: TruncatableService, useValue: {} }],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CommunityAdminSearchResultListElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render an edit button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(getCommunityEditPath(id));
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts
new file mode 100644
index 0000000000..71fe4203ef
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts
@@ -0,0 +1,26 @@
+import { Component } from '@angular/core';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
+import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model';
+import { Community } from '../../../../../core/shared/community.model';
+import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module';
+
+@listableObjectComponent(CommunitySearchResult, ViewMode.ListElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-community-admin-search-result-list-element',
+ styleUrls: ['./community-admin-search-result-list-element.component.scss'],
+ templateUrl: './community-admin-search-result-list-element.component.html'
+})
+/**
+ * The component for displaying a list element for a community search result on the admin search page
+ */
+export class CommunityAdminSearchResultListElementComponent extends SearchResultListElementComponent {
+ editPath: string;
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.editPath = getCommunityEditPath(this.dso.uuid);
+ }
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.html b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.html
new file mode 100644
index 0000000000..a74e339e63
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.html
@@ -0,0 +1,12 @@
+
+ {{ "admin.search.item.private" | translate }}
+
+
+ {{ "admin.search.item.withdrawn" | translate }}
+
+
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.scss b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts
new file mode 100644
index 0000000000..8395bd4f11
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts
@@ -0,0 +1,101 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
+import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
+import { ItemAdminSearchResultListElementComponent } from './item-admin-search-result-list-element.component';
+import { Item } from '../../../../../core/shared/item.model';
+
+describe('ItemAdminSearchResultListElementComponent', () => {
+ let component: ItemAdminSearchResultListElementComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let searchResult;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ searchResult = new ItemSearchResult();
+ searchResult.indexableObject = new Item();
+ searchResult.indexableObject.uuid = id;
+ }
+
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([])
+ ],
+ declarations: [ItemAdminSearchResultListElementComponent],
+ providers: [{ provide: TruncatableService, useValue: {} }],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemAdminSearchResultListElementComponent);
+ component = fixture.componentInstance;
+ component.object = searchResult;
+ component.linkTypes = CollectionElementLinkType;
+ component.index = 0;
+ component.viewModes = ViewMode;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('when the item is not withdrawn', () => {
+ beforeEach(() => {
+ component.dso.isWithdrawn = false;
+ fixture.detectChanges();
+ });
+
+ it('should not show the withdrawn badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.withdrawn-badge'));
+ expect(badge).toBeNull();
+ });
+ });
+
+ describe('when the item is withdrawn', () => {
+ beforeEach(() => {
+ component.dso.isWithdrawn = true;
+ fixture.detectChanges();
+ });
+
+ it('should show the withdrawn badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.withdrawn-badge'));
+ expect(badge).not.toBeNull();
+ });
+ });
+
+ describe('when the item is not private', () => {
+ beforeEach(() => {
+ component.dso.isDiscoverable = true;
+ fixture.detectChanges();
+ });
+ it('should not show the private badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.private-badge'));
+ expect(badge).toBeNull();
+ });
+ });
+
+ describe('when the item is private', () => {
+ beforeEach(() => {
+ component.dso.isDiscoverable = false;
+ fixture.detectChanges();
+ });
+
+ it('should show the private badge', () => {
+ const badge = fixture.debugElement.query(By.css('div.private-badge'));
+ expect(badge).not.toBeNull();
+ });
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.ts
new file mode 100644
index 0000000000..b1dea11341
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.ts
@@ -0,0 +1,20 @@
+import { Component } from '@angular/core';
+import { Item } from '../../../../../core/shared/item.model';
+import { ViewMode } from '../../../../../core/shared/view-mode.model';
+import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
+import { Context } from '../../../../../core/shared/context.model';
+import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
+import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
+
+@listableObjectComponent(ItemSearchResult, ViewMode.ListElement, Context.AdminSearch)
+@Component({
+ selector: 'ds-item-admin-search-result-list-element',
+ styleUrls: ['./item-admin-search-result-list-element.component.scss'],
+ templateUrl: './item-admin-search-result-list-element.component.html'
+})
+/**
+ * The component for displaying a list element for an item search result on the admin search page
+ */
+export class ItemAdminSearchResultListElementComponent extends SearchResultListElementComponent {
+
+}
diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html
new file mode 100644
index 0000000000..a4a923e725
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html
@@ -0,0 +1,27 @@
+
+ {{"admin.search.item.edit" | translate}}
+
+
+
+ {{"admin.search.item.withdraw" | translate}}
+
+
+
+ {{"admin.search.item.reinstate" | translate}}
+
+
+
+ {{"admin.search.item.make-private" | translate}}
+
+
+
+ {{"admin.search.item.make-public" | translate}}
+
+
+
+ {{"admin.search.item.delete" | translate}}
+
+
+
+ {{"admin.search.item.move" | translate}}
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.scss b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.scss
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.scss
@@ -0,0 +1 @@
+
diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts
new file mode 100644
index 0000000000..c1aceb477d
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts
@@ -0,0 +1,144 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ItemAdminSearchResultActionsComponent } from './item-admin-search-result-actions.component';
+import { Item } from '../../../core/shared/item.model';
+import {
+ ITEM_EDIT_DELETE_PATH,
+ ITEM_EDIT_MOVE_PATH,
+ ITEM_EDIT_PRIVATE_PATH,
+ ITEM_EDIT_PUBLIC_PATH,
+ ITEM_EDIT_REINSTATE_PATH,
+ ITEM_EDIT_WITHDRAW_PATH
+} from '../../../+item-page/edit-item-page/edit-item-page.routing.module';
+import { getItemEditPath } from '../../../+item-page/item-page-routing.module';
+import { URLCombiner } from '../../../core/url-combiner/url-combiner';
+
+describe('ItemAdminSearchResultActionsComponent', () => {
+ let component: ItemAdminSearchResultActionsComponent;
+ let fixture: ComponentFixture;
+ let id;
+ let item;
+
+ function init() {
+ id = '780b2588-bda5-4112-a1cd-0b15000a5339';
+ item = new Item();
+ item.uuid = id;
+ }
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([])
+ ],
+ declarations: [ItemAdminSearchResultActionsComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemAdminSearchResultActionsComponent);
+ component = fixture.componentInstance;
+ component.item = item;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render an edit button with the correct link', () => {
+ const button = fixture.debugElement.query(By.css('a.edit-link'));
+ const link = button.nativeElement.href;
+ expect(link).toContain(getItemEditPath(id));
+ });
+
+ it('should render a delete button with the correct link', () => {
+ const button = fixture.debugElement.query(By.css('a.delete-link'));
+ const link = button.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_DELETE_PATH).toString());
+ });
+
+ it('should render a move button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.move-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_MOVE_PATH).toString());
+ });
+
+ describe('when the item is not withdrawn', () => {
+ beforeEach(() => {
+ component.item.isWithdrawn = false;
+ fixture.detectChanges();
+ });
+
+ it('should render a withdraw button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.withdraw-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_WITHDRAW_PATH).toString());
+ });
+
+ it('should not render a reinstate button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.reinstate-link'));
+ expect(a).toBeNull();
+ });
+ });
+
+ describe('when the item is withdrawn', () => {
+ beforeEach(() => {
+ component.item.isWithdrawn = true;
+ fixture.detectChanges();
+ });
+
+ it('should not render a withdraw button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.withdraw-link'));
+ expect(a).toBeNull();
+ });
+
+ it('should render a reinstate button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.reinstate-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_REINSTATE_PATH).toString());
+ });
+ });
+
+ describe('when the item is not private', () => {
+ beforeEach(() => {
+ component.item.isDiscoverable = true;
+ fixture.detectChanges();
+ });
+
+ it('should render a make private button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.private-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_PRIVATE_PATH).toString());
+ });
+
+ it('should not render a make public button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.public-link'));
+ expect(a).toBeNull();
+ });
+ });
+
+ describe('when the item is private', () => {
+ beforeEach(() => {
+ component.item.isDiscoverable = false;
+ fixture.detectChanges();
+ });
+
+ it('should not render a make private button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.private-link'));
+ expect(a).toBeNull();
+ });
+
+ it('should render a make private button with the correct link', () => {
+ const a = fixture.debugElement.query(By.css('a.public-link'));
+ const link = a.nativeElement.href;
+ expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_PUBLIC_PATH).toString());
+ });
+ })
+});
diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts
new file mode 100644
index 0000000000..40cddc816d
--- /dev/null
+++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts
@@ -0,0 +1,81 @@
+import { Component, Input } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { getItemEditPath } from '../../../+item-page/item-page-routing.module';
+import { URLCombiner } from '../../../core/url-combiner/url-combiner';
+import {
+ ITEM_EDIT_DELETE_PATH,
+ ITEM_EDIT_MOVE_PATH,
+ ITEM_EDIT_PRIVATE_PATH,
+ ITEM_EDIT_PUBLIC_PATH,
+ ITEM_EDIT_REINSTATE_PATH,
+ ITEM_EDIT_WITHDRAW_PATH
+} from '../../../+item-page/edit-item-page/edit-item-page.routing.module';
+
+@Component({
+ selector: 'ds-item-admin-search-result-actions-element',
+ styleUrls: ['./item-admin-search-result-actions.component.scss'],
+ templateUrl: './item-admin-search-result-actions.component.html'
+})
+/**
+ * The component for displaying the actions for a list element for an item search result on the admin search page
+ */
+export class ItemAdminSearchResultActionsComponent {
+ /**
+ * The item to perform the actions on
+ */
+ @Input() public item: Item;
+
+ /**
+ * Whether or not to use small buttons
+ */
+ @Input() public small: boolean;
+
+ /**
+ * Returns the path to the edit page of this item
+ */
+ getEditPath(): string {
+ return getItemEditPath(this.item.uuid)
+ }
+
+ /**
+ * Returns the path to the move page of this item
+ */
+ getMovePath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_MOVE_PATH).toString();
+ }
+
+ /**
+ * Returns the path to the delete page of this item
+ */
+ getDeletePath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_DELETE_PATH).toString();
+ }
+
+ /**
+ * Returns the path to the withdraw page of this item
+ */
+ getWithdrawPath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_WITHDRAW_PATH).toString();
+ }
+
+ /**
+ * Returns the path to the reinstate page of this item
+ */
+ getReinstatePath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_REINSTATE_PATH).toString();
+ }
+
+ /**
+ * Returns the path to the page where the user can make this item private
+ */
+ getPrivatePath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_PRIVATE_PATH).toString();
+ }
+
+ /**
+ * Returns the path to the page where the user can make this item public
+ */
+ getPublicPath(): string {
+ return new URLCombiner(this.getEditPath(), ITEM_EDIT_PUBLIC_PATH).toString();
+ }
+}
diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts
index 72eb306bf1..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,
},
{
@@ -350,53 +350,19 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
link: ''
} as LinkMenuItemModel,
},
-
- /* Search */
+ /* Admin Search */
{
- id: 'find',
+ id: 'admin_search',
active: false,
visible: true,
model: {
- type: MenuItemType.TEXT,
- text: 'menu.section.find'
- } as TextMenuItemModel,
+ type: MenuItemType.LINK,
+ text: 'menu.section.admin_search',
+ link: '/admin/search'
+ } as LinkMenuItemModel,
icon: 'search',
index: 5
},
- {
- id: 'find_items',
- parentID: 'find',
- active: false,
- visible: true,
- model: {
- type: MenuItemType.LINK,
- text: 'menu.section.find_items',
- link: '/search'
- } as LinkMenuItemModel,
- },
- {
- id: 'find_withdrawn_items',
- parentID: 'find',
- active: false,
- visible: true,
- model: {
- type: MenuItemType.LINK,
- text: 'menu.section.find_withdrawn_items',
- link: ''
- } as LinkMenuItemModel,
- },
- {
- id: 'find_private_items',
- parentID: 'find',
- active: false,
- visible: true,
- model: {
- type: MenuItemType.LINK,
- text: 'menu.section.find_private_items',
- link: ''
- } as LinkMenuItemModel,
- },
-
/* Registries */
{
id: 'registries',
diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts
index 1495d0fd8c..fa2480a6ad 100644
--- a/src/app/+admin/admin.module.ts
+++ b/src/app/+admin/admin.module.ts
@@ -1,14 +1,45 @@
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 { SharedModule } from '../shared/shared.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';
+import { CommunityAdminSearchResultListElementComponent } from './admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component';
+import { CollectionAdminSearchResultListElementComponent } from './admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component';
+import { ItemAdminSearchResultGridElementComponent } from './admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component';
+import { CommunityAdminSearchResultGridElementComponent } from './admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component';
+import { CollectionAdminSearchResultGridElementComponent } from './admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component';
+import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin-search-results/item-admin-search-result-actions.component';
@NgModule({
imports: [
- AdminRegistriesModule,
AdminRoutingModule,
+ AdminRegistriesModule,
+ AdminAccessControlModule,
SharedModule,
+ SearchPageModule
],
+ declarations: [
+ AdminSearchPageComponent,
+ ItemAdminSearchResultListElementComponent,
+ CommunityAdminSearchResultListElementComponent,
+ CollectionAdminSearchResultListElementComponent,
+ ItemAdminSearchResultGridElementComponent,
+ CommunityAdminSearchResultGridElementComponent,
+ CollectionAdminSearchResultGridElementComponent,
+ ItemAdminSearchResultActionsComponent
+ ],
+ entryComponents: [
+ ItemAdminSearchResultListElementComponent,
+ CommunityAdminSearchResultListElementComponent,
+ CollectionAdminSearchResultListElementComponent,
+ ItemAdminSearchResultGridElementComponent,
+ CommunityAdminSearchResultGridElementComponent,
+ CollectionAdminSearchResultGridElementComponent,
+ ItemAdminSearchResultActionsComponent
+ ]
})
export class AdminModule {
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index 61cfda0d9e..b51c74b457 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -40,6 +40,7 @@ const COLLECTION_EDIT_PATH = 'edit';
dso: CollectionPageResolver,
breadcrumb: CollectionBreadcrumbResolver
},
+ runGuardsAndResolvers: 'always',
children: [
{
path: COLLECTION_EDIT_PATH,
diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts
index 976a4ad0fe..0e2407577c 100644
--- a/src/app/+community-page/community-page-routing.module.ts
+++ b/src/app/+community-page/community-page-routing.module.ts
@@ -39,6 +39,7 @@ const COMMUNITY_EDIT_PATH = 'edit';
dso: CommunityPageResolver,
breadcrumb: CommunityBreadcrumbResolver
},
+ runGuardsAndResolvers: 'always',
children: [
{
path: COMMUNITY_EDIT_PATH,
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts
index 71924cf6c8..2cbd0c57d1 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts
@@ -22,6 +22,7 @@ import { EditRelationshipComponent } from './item-relationships/edit-relationshi
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
+import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
/**
* Module that contains all components related to the Edit Item page administrator functionality
@@ -47,6 +48,7 @@ import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.co
ItemMetadataComponent,
ItemRelationshipsComponent,
ItemBitstreamsComponent,
+ ItemVersionHistoryComponent,
EditInPlaceFieldComponent,
EditRelationshipComponent,
EditRelationshipListComponent,
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
index da667847f7..e4b1b06730 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
@@ -1,4 +1,3 @@
-import { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component';
@@ -14,13 +13,14 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
+import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
-const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
-const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
-const ITEM_EDIT_PRIVATE_PATH = 'private';
-const ITEM_EDIT_PUBLIC_PATH = 'public';
-const ITEM_EDIT_DELETE_PATH = 'delete';
-const ITEM_EDIT_MOVE_PATH = 'move';
+export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
+export const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
+export const ITEM_EDIT_PRIVATE_PATH = 'private';
+export const ITEM_EDIT_PUBLIC_PATH = 'public';
+export const ITEM_EDIT_DELETE_PATH = 'delete';
+export const ITEM_EDIT_MOVE_PATH = 'move';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -75,6 +75,11 @@ const ITEM_EDIT_MOVE_PATH = 'move';
/* TODO - change when curate page exists */
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true }
+ },
+ {
+ path: 'versionhistory',
+ component: ItemVersionHistoryComponent,
+ data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }
}
]
},
diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html
new file mode 100644
index 0000000000..acabbd1010
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts
new file mode 100644
index 0000000000..9bc39649f4
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts
@@ -0,0 +1,44 @@
+import { ItemVersionHistoryComponent } from './item-version-history.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { RouterTestingModule } from '@angular/router/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { ActivatedRoute } from '@angular/router';
+import { of as observableOf } from 'rxjs';
+import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils';
+
+describe('ItemVersionHistoryComponent', () => {
+ let component: ItemVersionHistoryComponent;
+ let fixture: ComponentFixture;
+
+ const item = Object.assign(new Item(), {
+ uuid: 'item-identifier-1',
+ handle: '123456789/1',
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ItemVersionHistoryComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) } } }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemVersionHistoryComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should initialize the itemRD$ from the route\'s data', (done) => {
+ component.itemRD$.subscribe((itemRD) => {
+ expect(itemRD.payload).toBe(item);
+ done();
+ });
+ });
+});
diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts
new file mode 100644
index 0000000000..ce662c5753
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts
@@ -0,0 +1,35 @@
+import { Component } from '@angular/core';
+import { Observable } from 'rxjs/internal/Observable';
+import { RemoteData } from '../../../core/data/remote-data';
+import { Item } from '../../../core/shared/item.model';
+import { map } from 'rxjs/operators';
+import { getSucceededRemoteData } from '../../../core/shared/operators';
+import { ActivatedRoute } from '@angular/router';
+import { AlertType } from '../../../shared/alert/aletr-type';
+
+@Component({
+ selector: 'ds-item-version-history',
+ templateUrl: './item-version-history.component.html'
+})
+/**
+ * Component for listing and managing an item's version history
+ */
+export class ItemVersionHistoryComponent {
+ /**
+ * The item to display the version history for
+ */
+ itemRD$: Observable>;
+
+ /**
+ * The AlertType enumeration
+ * @type {AlertType}
+ */
+ AlertTypeEnum = AlertType;
+
+ constructor(private route: ActivatedRoute) {
+ }
+
+ ngOnInit(): void {
+ this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable>;
+ }
+}
diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html
index c453df6bff..b93b8f1e12 100644
--- a/src/app/+item-page/full/full-item-page.component.html
+++ b/src/app/+item-page/full/full-item-page.component.html
@@ -21,6 +21,7 @@
+
diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts
index 686c5ff2fc..5caf0e3036 100644
--- a/src/app/+item-page/item-page-routing.module.ts
+++ b/src/app/+item-page/item-page-routing.module.ts
@@ -30,6 +30,7 @@ const ITEM_EDIT_PATH = 'edit';
item: ItemPageResolver,
breadcrumb: ItemBreadcrumbResolver
},
+ runGuardsAndResolvers: 'always',
children: [
{
path: '',
diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts
index 5c54becdde..8d5d78ddd1 100644
--- a/src/app/+item-page/item-page.module.ts
+++ b/src/app/+item-page/item-page.module.ts
@@ -59,7 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent,
- AbstractIncrementalListComponent
+ AbstractIncrementalListComponent,
],
exports: [
ItemComponent,
diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts
index 0f73dc6170..501bb34d2c 100644
--- a/src/app/+item-page/item-page.resolver.ts
+++ b/src/app/+item-page/item-page.resolver.ts
@@ -28,6 +28,7 @@ export class ItemPageResolver implements Resolve> {
followLink('owningCollection'),
followLink('bundles'),
followLink('relationships'),
+ followLink('version', undefined, true, followLink('versionhistory')),
).pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
);
diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html
index b4b32fb05c..3b942f9d33 100644
--- a/src/app/+item-page/simple/item-page.component.html
+++ b/src/app/+item-page/simple/item-page.component.html
@@ -3,6 +3,7 @@
+
diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts
index befac7f331..e6bb10f293 100644
--- a/src/app/+search-page/configuration-search-page.component.ts
+++ b/src/app/+search-page/configuration-search-page.component.ts
@@ -1,3 +1,4 @@
+import { switchMap } from 'rxjs/operators';
import { HostWindowService } from '../shared/host-window.service';
import { SidebarService } from '../shared/sidebar/sidebar.service';
import { SearchComponent } from './search.component';
diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts
index 456446781c..b69dcaf935 100644
--- a/src/app/+search-page/search-page.module.ts
+++ b/src/app/+search-page/search-page.module.ts
@@ -14,6 +14,7 @@ import { SearchPageComponent } from './search-page.component';
import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service';
import { SearchFilterService } from '../core/shared/search/search-filter.service';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
+import { TranslateModule } from '@ngx-translate/core';
const components = [
SearchPageComponent,
@@ -28,7 +29,7 @@ const components = [
CommonModule,
SharedModule,
CoreModule.forRoot(),
- StatisticsModule.forRoot(),
+ StatisticsModule.forRoot()
],
declarations: components,
providers: [
diff --git a/src/app/+search-page/search.component.html b/src/app/+search-page/search.component.html
index 36879f33d4..ee96f703f2 100644
--- a/src/app/+search-page/search.component.html
+++ b/src/app/+search-page/search.component.html
@@ -1,54 +1,55 @@
-
-
-
-
-
+
+
-
-
-
-
diff --git a/src/app/+search-page/search.component.ts b/src/app/+search-page/search.component.ts
index b27ebf625f..bbbfdba513 100644
--- a/src/app/+search-page/search.component.ts
+++ b/src/app/+search-page/search.component.ts
@@ -17,6 +17,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu
import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils';
import { Router } from '@angular/router';
+import { Context } from '../core/shared/context.model';
@Component({
selector: 'ds-search',
@@ -84,6 +85,12 @@ export class SearchComponent implements OnInit {
@Input()
configuration$: Observable
;
+ /**
+ * The current context
+ */
+ @Input()
+ context: Context;
+
/**
* Link to the search page
*/
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index fff1f424cc..2927cd4e65 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -3,7 +3,6 @@ import { RouterModule } from '@angular/router';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
-import { Breadcrumb } from './breadcrumbs/breadcrumb/breadcrumb.model';
import { DSpaceObject } from './core/shared/dspace-object.model';
import { Community } from './core/shared/community.model';
import { getCommunityPageRoute } from './+community-page/community-page-routing.module';
@@ -11,7 +10,6 @@ import { Collection } from './core/shared/collection.model';
import { Item } from './core/shared/item.model';
import { getItemPageRoute } from './+item-page/item-page-routing.module';
import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module';
-import { BrowseByDSOBreadcrumbResolver } from './+browse-by/browse-by-dso-breadcrumb.resolver';
const ITEM_MODULE_PATH = 'items';
@@ -37,6 +35,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:
@@ -78,8 +82,15 @@ export function getDSOPath(dso: DSpaceObject): string {
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 },
- ])
+ ],
+ {
+ onSameUrlNavigation: 'reload',
+ })
],
exports: [RouterModule],
})
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/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index 87729ba087..0f5c06bbc9 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -27,7 +27,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 } from '../shared/operators';
import { AuthMethod } from './models/auth.method';
export const LOGIN_ROUTE = '/login';
@@ -168,7 +168,7 @@ export class AuthService {
*/
public retrieveAuthenticatedUserByHref(userHref: string): Observable {
return this.epersonService.findByHref(userHref).pipe(
- getFirstSucceededRemoteDataPayload()
+ getAllSucceededRemoteDataPayload()
)
}
diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts
index d9df7cd767..7384a031db 100644
--- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts
+++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts
@@ -21,7 +21,7 @@ export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver> {
return [
- followLink('parentCommunity', undefined,
+ followLink('parentCommunity', undefined, true,
followLink('parentCommunity')
)
];
diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts
index 8390c0e001..cd0c23cf82 100644
--- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts
+++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts
@@ -21,8 +21,8 @@ export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver- {
*/
get followLinks(): Array> {
return [
- followLink('owningCollection', undefined,
- followLink('parentCommunity', undefined,
+ followLink('owningCollection', undefined, true,
+ followLink('parentCommunity', undefined, true,
followLink('parentCommunity'))
),
followLink('bundles'),
diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts
index dbd65eefb5..e9b8447c22 100644
--- a/src/app/core/cache/builders/link.service.spec.ts
+++ b/src/app/core/cache/builders/link.service.spec.ts
@@ -90,7 +90,7 @@ describe('LinkService', () => {
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
- service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')))
+ service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor')))
});
it('should call dataservice.findByHref with the correct href and nested links', () => {
expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, followLink('successor'));
@@ -105,7 +105,7 @@ describe('LinkService', () => {
isList: true
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
- service.resolveLink(testModel, followLink('predecessor', { some: 'options '} as any, followLink('successor')))
+ service.resolveLink(testModel, followLink('predecessor', { some: 'options '} as any, true, followLink('successor')))
});
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options '} as any, followLink('successor'));
@@ -119,7 +119,7 @@ describe('LinkService', () => {
propertyName: 'predecessor'
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
- result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')))
+ result = service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor')))
});
it('should call getLinkDefinition with the correct model and link', () => {
@@ -144,7 +144,7 @@ describe('LinkService', () => {
});
it('should throw an error', () => {
expect(() => {
- service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')))
+ service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor')))
}).toThrow();
});
});
@@ -160,7 +160,7 @@ describe('LinkService', () => {
});
it('should throw an error', () => {
expect(() => {
- service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')))
+ service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor')))
}).toThrow();
});
});
diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts
index 53894df5f1..d82a1f31fe 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
- ) {
+ ) {
}
/**
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/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts
index 3a0e801f27..fd398f2971 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,19 @@ 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));
+ if (isNotEmpty(flatPatch)) {
+ this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, flatPatch));
+ }
+ }
+ return new ApplyPatchObjectCacheAction(href);
})
- )
+ );
}
constructor(private actions$: Actions,
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index e894b1a6cd..76b0e3e76c 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -135,6 +135,10 @@ import { PoolTask } from './tasks/models/pool-task-object.model';
import { TaskObject } from './tasks/models/task-object.model';
import { PoolTaskDataService } from './tasks/pool-task-data.service';
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
+import { VersionDataService } from './data/version-data.service';
+import { VersionHistoryDataService } from './data/version-history-data.service';
+import { Version } from './shared/version.model';
+import { VersionHistory } from './shared/version-history.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -251,6 +255,8 @@ const PROVIDERS = [
RelationshipTypeService,
ExternalSourceService,
LookupRelationService,
+ VersionDataService,
+ VersionHistoryDataService,
LicenseDataService,
ItemTypeDataService,
// register AuthInterceptor as HttpInterceptor
@@ -301,6 +307,8 @@ export const models =
ItemType,
ExternalSource,
ExternalSourceEntry,
+ Version,
+ VersionHistory
];
@NgModule({
diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..a1d602dc65
--- /dev/null
+++ b/src/app/core/data/base-response-parsing.service.spec.ts
@@ -0,0 +1,104 @@
+import { BaseResponseParsingService } from './base-response-parsing.service';
+import { GlobalConfig } from '../../../config/global-config.interface';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { CacheableObject } from '../cache/object-cache.reducer';
+import { GetRequest, RestRequest } from './request.models';
+import { DSpaceObject } from '../shared/dspace-object.model';
+
+/* tslint:disable:max-classes-per-file */
+class TestService extends BaseResponseParsingService {
+ toCache = true;
+
+ constructor(protected EnvConfig: GlobalConfig,
+ protected objectCache: ObjectCacheService) {
+ super();
+ }
+
+ // Overwrite methods to make them public for testing
+ public process(data: any, request: RestRequest): any {
+ super.process(data, request);
+ }
+
+ public cache(obj, request: RestRequest, data: any) {
+ super.cache(obj, request, data);
+ }
+}
+
+describe('BaseResponseParsingService', () => {
+ let service: TestService;
+ let config: GlobalConfig;
+ let objectCache: ObjectCacheService;
+
+ const requestUUID = 'request-uuid';
+ const requestHref = 'request-href';
+ const request = new GetRequest(requestUUID, requestHref);
+
+ beforeEach(() => {
+ config = Object.assign({});
+ objectCache = jasmine.createSpyObj('objectCache', {
+ add: {}
+ });
+ service = new TestService(config, objectCache);
+ });
+
+ describe('cache', () => {
+ let obj: CacheableObject;
+
+ describe('when the object is undefined', () => {
+ it('should not throw an error', () => {
+ expect(() => { service.cache(obj, request, {}) }).not.toThrow();
+ });
+
+ it('should not call objectCache add', () => {
+ service.cache(obj, request, {});
+ expect(objectCache.add).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the object has a self link', () => {
+ beforeEach(() => {
+ obj = Object.assign(new DSpaceObject(), {
+ _links: {
+ self: { href: 'obj-selflink' }
+ }
+ });
+ });
+
+ it('should call objectCache add', () => {
+ service.cache(obj, request, {});
+ expect(objectCache.add).toHaveBeenCalledWith(obj, request.responseMsToLive, request.uuid);
+ });
+ });
+ });
+
+ describe('process', () => {
+ let data: any;
+ let result: any;
+
+ describe('when data is valid, but not a real type', () => {
+ beforeEach(() => {
+ data = {
+ type: 'NotARealType',
+ _links: {
+ self: { href: 'data-selflink' }
+ }
+ };
+ });
+
+ it('should not throw an error', () => {
+ expect(() => { result = service.process(data, request) }).not.toThrow();
+ });
+
+ it('should return undefined', () => {
+ result = service.process(data, request);
+ expect(result).toBeUndefined();
+ });
+
+ it('should not call objectCache add', () => {
+ result = service.process(data, request);
+ expect(objectCache.add).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
+/* tslint:enable:max-classes-per-file */
diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts
index 3615ab4023..efbe838d82 100644
--- a/src/app/core/data/base-response-parsing.service.ts
+++ b/src/app/core/data/base-response-parsing.service.ts
@@ -57,7 +57,7 @@ export abstract class BaseResponseParsingService {
.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));
@@ -117,10 +117,12 @@ export abstract class BaseResponseParsingService {
const serializer = new this.serializerConstructor(objConstructor);
return serializer.deserialize(obj);
} else {
+ console.warn('cannot deserialize type ' + type);
return null;
}
} else {
+ console.warn('cannot deserialize type ' + type);
return null;
}
}
@@ -142,7 +144,8 @@ export abstract class BaseResponseParsingService {
} else {
dataJSON = JSON.stringify(data);
}
- throw new Error(`Can't cache incomplete ${type}: ${JSON.stringify(co)}, parsed from (partial) response: ${dataJSON}`);
+ console.warn(`Can't cache incomplete ${type}: ${JSON.stringify(co)}, parsed from (partial) response: ${dataJSON}`);
+ return;
}
this.objectCache.add(co, hasValue(request.responseMsToLive) ? request.responseMsToLive : this.EnvConfig.cache.msToLive.default, request.uuid);
}
diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts
index 347dfa83a4..c370be2b9e 100644
--- a/src/app/core/data/data.service.spec.ts
+++ b/src/app/core/data/data.service.spec.ts
@@ -1,21 +1,25 @@
-import { DataService } from './data.service';
-import { RequestService } from './request.service';
-import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-import { CoreState } from '../core.reducers';
+import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { compare, Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs';
-import { FindListOptions } from './request.models';
+import * as uuidv4 from 'uuid/v4';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
+import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
-import { compare, Operation } from 'fast-json-patch';
+import { CoreState } from '../core.reducers';
+import { Collection } from '../shared/collection.model';
import { DSpaceObject } from '../shared/dspace-object.model';
-import { ChangeAnalyzer } from './change-analyzer';
-import { HttpClient } from '@angular/common/http';
-import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
-import * as uuidv4 from 'uuid/v4';
-import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
+import { ChangeAnalyzer } from './change-analyzer';
+import { DataService } from './data.service';
+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';
@@ -40,6 +44,7 @@ class TestService extends DataService {
return observableOf(endpoint);
}
}
+
class DummyChangeAnalyzer implements ChangeAnalyzer
- {
diff(object1: Item, object2: Item): Operation[] {
return compare((object1 as any).metadata, (object2 as any).metadata);
@@ -50,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;
@@ -144,21 +149,161 @@ describe('DataService', () => {
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
- })
- });
- describe('patch', () => {
- let operations;
- let selfLink;
-
- 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');
});
- it('should call addPatch on the object cache with the right parameters', () => {
- service.patch(selfLink, operations);
- expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
+ it('should include single linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const expected = `${endpoint}?embed=bundles`;
+
+ (service as any).getFindAllHref({}, null, mockFollowLinkConfig).subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+
+ it('should include multiple linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
+
+ (service as any).getFindAllHref({}, null, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3).subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+
+ it('should not include linksToFollow with shouldEmbed = false', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${endpoint}?embed=templateItemOf`;
+
+ (service as any).getFindAllHref({}, null, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3).subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+
+ it('should include nested linksToFollow 3lvl', () => {
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'relationships' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), {
+ name: 'itemtemplate' as any,
+ linksToFollow: mockFollowLinkConfig3,
+ });
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ linksToFollow: mockFollowLinkConfig2,
+ });
+ const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
+
+ (service as any).getFindAllHref({}, null, mockFollowLinkConfig).subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+ });
+
+ describe('getIDHref', () => {
+ const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items';
+ const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89';
+
+ it('should return endpoint', () => {
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock);
+ expect(result).toEqual(endpointMock + '/' + resourceIdMock);
+ });
+
+ it('should include single linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`;
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig);
+ expect(result).toEqual(expected);
+ });
+
+ it('should include multiple linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3);
+ expect(result).toEqual(expected);
+ });
+
+ it('should not include linksToFollow with shouldEmbed = false', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3);
+ expect(result).toEqual(expected);
+ });
+
+ it('should include nested linksToFollow 3lvl', () => {
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'relationships' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), {
+ name: 'itemtemplate' as any,
+ linksToFollow: mockFollowLinkConfig3,
+ });
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ linksToFollow: mockFollowLinkConfig2,
+ });
+ const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig);
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('patch', () => {
+ const dso = {
+ uuid: 'dso-uuid'
+ };
+ const operations = [
+ Object.assign({
+ op: 'move',
+ from: '/1',
+ path: '/5'
+ }) as Operation
+ ];
+
+ beforeEach(() => {
+ service.patch(dso, 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 7dcfb6bd6e..135834b430 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';
@@ -83,14 +83,15 @@ export abstract class DataService {
* @param linkPath The link path for the object
* @return {Observable}
* Return an observable that emits created HREF
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected getFindAllHref(options: FindListOptions = {}, linkPath?: string): Observable {
+ protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable {
let result$: Observable;
const args = [];
result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged());
- return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args)));
+ return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
@@ -100,8 +101,9 @@ export abstract class DataService {
* @param options The [[FindListOptions]] object
* @return {Observable}
* Return an observable that emits created HREF
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected getSearchByHref(searchMethod: string, options: FindListOptions = {}): Observable {
+ protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable {
let result$: Observable;
const args = [];
@@ -113,7 +115,7 @@ export abstract class DataService {
})
}
- return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args)));
+ return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
@@ -124,9 +126,9 @@ export abstract class DataService {
* @param extraArgs Array with additional params to combine with query string
* @return {Observable}
* Return an observable that emits created HREF
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = []): string {
-
+ protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string {
let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
@@ -142,6 +144,7 @@ export abstract class DataService {
if (hasValue(options.startsWith)) {
args = [...args, `startsWith=${options.startsWith}`];
}
+ args = this.addEmbedParams(args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
@@ -149,6 +152,40 @@ export abstract class DataService {
}
}
+ /**
+ * Adds the embed options to the link for the request
+ * @param args params for the query string
+ * @param linksToFollow links we want to embed in query string if shouldEmbed is true
+ */
+ protected addEmbedParams(args: string[], ...linksToFollow: Array>) {
+ linksToFollow.forEach((linkToFollow: FollowLinkConfig) => {
+ if (linkToFollow !== undefined && linkToFollow.shouldEmbed) {
+ const embedString = 'embed=' + String(linkToFollow.name);
+ const embedWithNestedString = this.addNestedEmbeds(embedString, ...linkToFollow.linksToFollow);
+ args = [...args, embedWithNestedString];
+ }
+ });
+ return args;
+ }
+
+ /**
+ * Add the nested followLinks to the embed param, recursively, separated by a /
+ * @param embedString embedString so far (recursive)
+ * @param linksToFollow links we want to embed in query string if shouldEmbed is true
+ */
+ protected addNestedEmbeds(embedString: string, ...linksToFollow: Array>): string {
+ let nestEmbed = embedString;
+ linksToFollow.forEach((linkToFollow: FollowLinkConfig) => {
+ if (linkToFollow !== undefined && linkToFollow.shouldEmbed) {
+ nestEmbed = nestEmbed + '/' + String(linkToFollow.name);
+ if (linkToFollow.linksToFollow !== undefined) {
+ nestEmbed = this.addNestedEmbeds(nestEmbed, ...linkToFollow.linksToFollow);
+ }
+ }
+ });
+ return nestEmbed;
+ }
+
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
@@ -184,12 +221,13 @@ export abstract class DataService {
}
/**
- * Create the HREF for a specific object based on its identifier
+ * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow
* @param endpoint The base endpoint for the type of object
* @param resourceID The identifier for the object
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- getIDHref(endpoint, resourceID): string {
- return `${endpoint}/${resourceID}`;
+ getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string {
+ return this.buildHrefFromFindOptions(endpoint + '/' + resourceID, {}, [], ...linksToFollow);
}
/**
@@ -199,9 +237,8 @@ export abstract class DataService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findById(id: string, ...linksToFollow: Array>): Observable> {
-
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
- map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id))));
+ map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id), ...linksToFollow)));
hrefObs.pipe(
find((href: string) => hasValue(href)))
@@ -223,7 +260,7 @@ export abstract class DataService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, ...linksToFollow: Array>): Observable> {
- const requestHref = this.buildHrefFromFindOptions(href, {}, []);
+ const requestHref = this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow);
const request = new GetRequest(this.requestService.generateRequestId(), requestHref);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
@@ -240,7 +277,7 @@ export abstract class DataService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
- const requestHref = this.buildHrefFromFindOptions(href, findListOptions, []);
+ const requestHref = this.buildHrefFromFindOptions(href, findListOptions, [], ...linksToFollow);
const request = new GetRequest(this.requestService.generateRequestId(), requestHref);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
@@ -269,9 +306,9 @@ export abstract class DataService {
* @return {Observable>}
* Return an observable that emits response from the server
*/
- protected searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
+ searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
- const hrefObs = this.getSearchByHref(searchMethod, options);
+ const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
@@ -292,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)
+ );
}
/**
@@ -372,6 +425,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/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/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts
index 25a148d92b..4ef5bcb8b4 100644
--- a/src/app/core/data/dso-redirect-data.service.spec.ts
+++ b/src/app/core/data/dso-redirect-data.service.spec.ts
@@ -1,15 +1,18 @@
+import { HttpClient } from '@angular/common/http';
+import { Store } from '@ngrx/store';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { CoreState } from '../core.reducers';
+import { Collection } from '../shared/collection.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { Item } from '../shared/item.model';
+import { DsoRedirectDataService } from './dso-redirect-data.service';
import { FindByIDRequest, IdentifierType } from './request.models';
import { RequestService } from './request.service';
-import { ObjectCacheService } from '../cache/object-cache.service';
-import { NotificationsService } from '../../shared/notifications/notifications.service';
-import { HttpClient } from '@angular/common/http';
-import { DsoRedirectDataService } from './dso-redirect-data.service';
-import { Store } from '@ngrx/store';
-import { CoreState } from '../core.reducers';
describe('DsoRedirectDataService', () => {
let scheduler: TestScheduler;
@@ -148,5 +151,71 @@ describe('DsoRedirectDataService', () => {
scheduler.flush();
expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]);
});
- })
+ });
+
+ describe('getIDHref', () => {
+ it('should return endpoint', () => {
+ const result = (service as any).getIDHref(pidLink, dsoUUID);
+ expect(result).toEqual(requestUUIDURL);
+ });
+
+ it('should include single linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const expected = `${requestUUIDURL}&embed=bundles`;
+ const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig);
+ expect(result).toEqual(expected);
+ });
+
+ it('should include multiple linksToFollow as embed', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`;
+ const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3);
+ expect(result).toEqual(expected);
+ });
+
+ it('should not include linksToFollow with shouldEmbed = false', () => {
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'bundles' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ shouldEmbed: false,
+ });
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'templateItemOf' as any,
+ });
+ const expected = `${requestUUIDURL}&embed=templateItemOf`;
+ const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3);
+ expect(result).toEqual(expected);
+ });
+
+ it('should include nested linksToFollow 3lvl', () => {
+ const mockFollowLinkConfig3: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'relationships' as any,
+ });
+ const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), {
+ name: 'itemtemplate' as any,
+ linksToFollow: mockFollowLinkConfig3,
+ });
+ const mockFollowLinkConfig: FollowLinkConfig
- = Object.assign(new FollowLinkConfig(), {
+ name: 'owningCollection' as any,
+ linksToFollow: mockFollowLinkConfig2,
+ });
+ const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
+ const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig);
+ expect(result).toEqual(expected);
+ });
+ });
+
});
diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts
index 232fde65d0..87259a4279 100644
--- a/src/app/core/data/dso-redirect-data.service.ts
+++ b/src/app/core/data/dso-redirect-data.service.ts
@@ -6,6 +6,7 @@ import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
@@ -45,10 +46,11 @@ export class DsoRedirectDataService extends DataService {
}
}
- getIDHref(endpoint, resourceID): string {
+ getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string {
// Supporting both identifier (pid) and uuid (dso) endpoints
- return endpoint.replace(/\{\?id\}/, `?id=${resourceID}`)
- .replace(/\{\?uuid\}/, `?uuid=${resourceID}`);
+ return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`)
+ .replace(/\{\?uuid\}/, `?uuid=${resourceID}`),
+ {}, [], ...linksToFollow);
}
findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> {
diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts
index 38e9f8d888..61cc98281e 100644
--- a/src/app/core/data/dspace-object-data.service.ts
+++ b/src/app/core/data/dspace-object-data.service.ts
@@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
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 { ObjectCacheService } from '../cache/object-cache.service';
@@ -31,8 +32,9 @@ class DataServiceImpl extends DataService {
super();
}
- getIDHref(endpoint, resourceID): string {
- return endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`);
+ getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string {
+ return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`),
+ {}, [], ...linksToFollow);
}
}
diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts
index b9fb36e6d8..a23eb27f4a 100644
--- a/src/app/core/data/item-data.service.ts
+++ b/src/app/core/data/item-data.service.ts
@@ -20,7 +20,7 @@ import { ITEM } from '../shared/item.resource-type';
import {
configureRequest,
filterSuccessfulResponses,
- getRequestFromRequestHref,
+ getRequestFromRequestHref, getRequestFromRequestUUID,
getResponseFromEntry
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
@@ -180,14 +180,17 @@ export class ItemDataService extends DataService
- {
const patchOperation = [{
op: 'replace', path: '/withdrawn', value: withdrawn
}];
+ this.requestService.removeByHrefSubstring('/discover');
+
return this.getItemWithdrawEndpoint(itemId).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
- map((request: RestRequest) => request.href),
- getRequestFromRequestHref(this.requestService),
+ map((request: RestRequest) => request.uuid),
+ getRequestFromRequestUUID(this.requestService),
+ filter((requestEntry: RequestEntry) => requestEntry.completed),
map((requestEntry: RequestEntry) => requestEntry.response)
);
}
@@ -201,14 +204,17 @@ export class ItemDataService extends DataService
- {
const patchOperation = [{
op: 'replace', path: '/discoverable', value: discoverable
}];
+ this.requestService.removeByHrefSubstring('/discover');
+
return this.getItemDiscoverableEndpoint(itemId).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
),
configureRequest(this.requestService),
- map((request: RestRequest) => request.href),
- getRequestFromRequestHref(this.requestService),
+ map((request: RestRequest) => request.uuid),
+ getRequestFromRequestUUID(this.requestService),
+ filter((requestEntry: RequestEntry) => requestEntry.completed),
map((requestEntry: RequestEntry) => requestEntry.response)
);
}
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index df9c7d5bbb..0655333502 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,
diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts
index 1101c851ac..810b0721ae 100644
--- a/src/app/core/data/request.service.ts
+++ b/src/app/core/data/request.service.ts
@@ -3,7 +3,7 @@ import { HttpHeaders } from '@angular/common/http';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable, race as observableRace } from 'rxjs';
-import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators';
+import { filter, map, mergeMap, take } from 'rxjs/operators';
import { cloneDeep, remove } from 'lodash';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts
new file mode 100644
index 0000000000..80fb4f5b80
--- /dev/null
+++ b/src/app/core/data/version-data.service.ts
@@ -0,0 +1,44 @@
+import { Injectable } from '@angular/core';
+import { DataService } from './data.service';
+import { Version } from '../shared/version.model';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { Store } from '@ngrx/store';
+import { CoreState } from '../core.reducers';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
+import { FindListOptions } from './request.models';
+import { Observable } from 'rxjs/internal/Observable';
+import { dataService } from '../cache/builders/build-decorators';
+import { VERSION } from '../shared/version.resource-type';
+
+/**
+ * Service responsible for handling requests related to the Version object
+ */
+@Injectable()
+@dataService(VERSION)
+export class VersionDataService extends DataService {
+ protected linkPath = 'versions';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DefaultChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Get the endpoint for browsing versions
+ */
+ getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable {
+ return this.halService.getEndpoint(this.linkPath);
+ }
+}
diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts
new file mode 100644
index 0000000000..6728df71f1
--- /dev/null
+++ b/src/app/core/data/version-history-data.service.spec.ts
@@ -0,0 +1,54 @@
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { VersionHistoryDataService } from './version-history-data.service';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
+import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
+import { getMockRequestService } from '../../shared/mocks/mock-request.service';
+import { GetRequest } from './request.models';
+
+const url = 'fake-url';
+
+describe('VersionHistoryDataService', () => {
+ let service: VersionHistoryDataService;
+
+ let requestService: RequestService;
+ let notificationsService: any;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: any;
+
+ beforeEach(() => {
+ createService();
+ });
+
+ describe('getVersions', () => {
+ let result;
+
+ beforeEach(() => {
+ result = service.getVersions('1');
+ });
+
+ it('should configure a GET request', () => {
+ expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest));
+ });
+ });
+
+ /**
+ * Create a VersionHistoryDataService used for testing
+ * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
+ */
+ function createService(requestEntry$?) {
+ requestService = getMockRequestService(requestEntry$);
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildList: jasmine.createSpy('buildList')
+ });
+ objectCache = jasmine.createSpyObj('objectCache', {
+ remove: jasmine.createSpy('remove')
+ });
+ halService = new HALEndpointServiceStub(url);
+ notificationsService = new NotificationsServiceStub();
+
+ service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, null, null);
+ }
+});
diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts
new file mode 100644
index 0000000000..b4107d629d
--- /dev/null
+++ b/src/app/core/data/version-history-data.service.ts
@@ -0,0 +1,81 @@
+import { DataService } from './data.service';
+import { VersionHistory } from '../shared/version-history.model';
+import { Injectable } from '@angular/core';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { Store } from '@ngrx/store';
+import { CoreState } from '../core.reducers';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
+import { FindListOptions, GetRequest } from './request.models';
+import { Observable } from 'rxjs/internal/Observable';
+import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
+import { RemoteData } from './remote-data';
+import { PaginatedList } from './paginated-list';
+import { Version } from '../shared/version.model';
+import { map, switchMap, take } from 'rxjs/operators';
+import { dataService } from '../cache/builders/build-decorators';
+import { VERSION_HISTORY } from '../shared/version-history.resource-type';
+import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
+
+/**
+ * Service responsible for handling requests related to the VersionHistory object
+ */
+@Injectable()
+@dataService(VERSION_HISTORY)
+export class VersionHistoryDataService extends DataService {
+ protected linkPath = 'versionhistories';
+ protected versionsEndpoint = 'versions';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DefaultChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Get the endpoint for browsing versions
+ */
+ getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable {
+ return this.halService.getEndpoint(this.linkPath);
+ }
+
+ /**
+ * Get the versions endpoint for a version history
+ * @param versionHistoryId
+ */
+ getVersionsEndpoint(versionHistoryId: string): Observable {
+ return this.getBrowseEndpoint().pipe(
+ switchMap((href: string) => this.halService.getEndpoint(this.versionsEndpoint, `${href}/${versionHistoryId}`))
+ );
+ }
+
+ /**
+ * Get a version history's versions using paginated search options
+ * @param versionHistoryId The version history's ID
+ * @param searchOptions The search options to use
+ * @param linksToFollow HAL Links to follow on the Versions
+ */
+ getVersions(versionHistoryId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> {
+ const hrefObs = this.getVersionsEndpoint(versionHistoryId).pipe(
+ map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
+ );
+ hrefObs.pipe(
+ take(1)
+ ).subscribe((href) => {
+ const request = new GetRequest(this.requestService.generateRequestId(), href);
+ this.requestService.configure(request);
+ });
+
+ return this.rdbService.buildList(hrefObs, ...linksToFollow);
+ }
+}
diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts
new file mode 100644
index 0000000000..1831386321
--- /dev/null
+++ b/src/app/core/eperson/eperson-data.service.spec.ts
@@ -0,0 +1,304 @@
+import { CommonModule } from '@angular/common';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { async, TestBed } from '@angular/core/testing';
+import { Store, StoreModule } from '@ngrx/store';
+import { compare, Operation } from 'fast-json-patch';
+import { getTestScheduler } from 'jasmine-marbles';
+import { of as observableOf } from 'rxjs';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { Observable } from 'rxjs/internal/Observable';
+import { TestScheduler } from 'rxjs/testing';
+import {
+ EPeopleRegistryCancelEPersonAction,
+ EPeopleRegistryEditEPersonAction
+} from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions';
+import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/mock-remote-data-build.service';
+import { getMockRequestService } from '../../shared/mocks/mock-request.service';
+import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
+import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson-mock';
+import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
+import { SearchParam } from '../cache/models/search-param.model';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { CoreState } from '../core.reducers';
+import { ChangeAnalyzer } from '../data/change-analyzer';
+import { PaginatedList } from '../data/paginated-list';
+import { RemoteData } from '../data/remote-data';
+import { DeleteByIDRequest, FindListOptions, PatchRequest } from '../data/request.models';
+import { RequestEntry } from '../data/request.reducer';
+import { RequestService } from '../data/request.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { Item } from '../shared/item.model';
+import { PageInfo } from '../shared/page-info.model';
+import { EPersonDataService } from './eperson-data.service';
+import { EPerson } from './models/eperson.model';
+
+describe('EPersonDataService', () => {
+ let service: EPersonDataService;
+ let store: Store;
+ let requestService: RequestService;
+ let scheduler: TestScheduler;
+
+ const epeople = [EPersonMock, EPersonMock2];
+
+ const restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
+ const epersonsEndpoint = `${restEndpointURL}/epersons`;
+ let halService: any = new HALEndpointServiceStub(restEndpointURL);
+ const epeople$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [epeople]));
+ const rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ });
+ const objectCache = Object.assign({
+ /* tslint:disable:no-empty */
+ remove: () => {
+ },
+ hasBySelfLinkObservable: () => observableOf(false)
+ /* tslint:enable:no-empty */
+ }) as ObjectCacheService;
+
+ TestBed.configureTestingModule({
+ imports: [
+ CommonModule,
+ StoreModule.forRoot({}),
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: MockTranslateLoader
+ }
+ }),
+ ],
+ declarations: [],
+ providers: [],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ });
+
+ const getRequestEntry$ = (successful: boolean) => {
+ return observableOf({
+ completed: true,
+ response: { isSuccessful: successful, payload: epeople } as any
+ } as RequestEntry)
+ };
+
+ function initTestService() {
+ return new EPersonDataService(
+ requestService,
+ rdbService,
+ store,
+ null,
+ halService,
+ null,
+ null,
+ new DummyChangeAnalyzer() as any
+ );
+ }
+
+ beforeEach(() => {
+ requestService = getMockRequestService(getRequestEntry$(true));
+ store = new Store(undefined, undefined, undefined);
+ service = initTestService();
+ spyOn(store, 'dispatch');
+ });
+
+ describe('searchByScope', () => {
+ beforeEach(() => {
+ spyOn(service, 'searchBy');
+ });
+
+ it('search by default scope (byMetadata) and no query', () => {
+ service.searchByScope(null, '');
+ const options = Object.assign(new FindListOptions(), {
+ searchParams: [Object.assign(new SearchParam('query', ''))]
+ });
+ expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
+ });
+
+ it('search metadata scope and no query', () => {
+ service.searchByScope('metadata', '');
+ const options = Object.assign(new FindListOptions(), {
+ searchParams: [Object.assign(new SearchParam('query', ''))]
+ });
+ expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
+ });
+
+ it('search metadata scope and with query', () => {
+ service.searchByScope('metadata', 'test');
+ const options = Object.assign(new FindListOptions(), {
+ searchParams: [Object.assign(new SearchParam('query', 'test'))]
+ });
+ expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
+ });
+
+ it('search email scope and no query', () => {
+ service.searchByScope('email', '');
+ const options = Object.assign(new FindListOptions(), {
+ searchParams: [Object.assign(new SearchParam('email', ''))]
+ });
+ expect(service.searchBy).toHaveBeenCalledWith('byEmail', options);
+ });
+ });
+
+ describe('updateEPerson', () => {
+ beforeEach(() => {
+ spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock));
+ });
+
+ describe('change Email', () => {
+ const newEmail = 'changedemail@test.com';
+ beforeEach(() => {
+ const changedEPerson = Object.assign(new EPerson(), {
+ id: EPersonMock.id,
+ metadata: EPersonMock.metadata,
+ email: newEmail,
+ canLogIn: EPersonMock.canLogIn,
+ requireCertificate: EPersonMock.requireCertificate,
+ _links: EPersonMock._links,
+ });
+ service.updateEPerson(changedEPerson).subscribe();
+ });
+ it('should send PatchRequest with replace email operation', () => {
+ const operations = [{ op: 'replace', path: '/email', value: newEmail }];
+ const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations);
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+ });
+
+ describe('change certificate', () => {
+ beforeEach(() => {
+ const changedEPerson = Object.assign(new EPerson(), {
+ id: EPersonMock.id,
+ metadata: EPersonMock.metadata,
+ email: EPersonMock.email,
+ canLogIn: EPersonMock.canLogIn,
+ requireCertificate: !EPersonMock.requireCertificate,
+ _links: EPersonMock._links,
+ });
+ service.updateEPerson(changedEPerson).subscribe();
+ });
+ it('should send PatchRequest with replace certificate operation', () => {
+ const operations = [{ op: 'replace', path: '/certificate', value: !EPersonMock.requireCertificate }];
+ const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations);
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+ });
+
+ describe('change canLogin', () => {
+ beforeEach(() => {
+ const changedEPerson = Object.assign(new EPerson(), {
+ id: EPersonMock.id,
+ metadata: EPersonMock.metadata,
+ email: EPersonMock.email,
+ canLogIn: !EPersonMock.canLogIn,
+ requireCertificate: EPersonMock.requireCertificate,
+ _links: EPersonMock._links,
+ });
+ service.updateEPerson(changedEPerson).subscribe();
+ });
+ it('should send PatchRequest with replace canLogIn operation', () => {
+ const operations = [{ op: 'replace', path: '/canLogIn', value: !EPersonMock.canLogIn }];
+ const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations);
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+ });
+
+ describe('change name', () => {
+ const newFirstName = 'changedFirst';
+ const newLastName = 'changedLast';
+ beforeEach(() => {
+ const changedEPerson = Object.assign(new EPerson(), {
+ id: EPersonMock.id,
+ metadata: {
+ 'eperson.firstname': [
+ {
+ value: newFirstName,
+ }
+ ],
+ 'eperson.lastname': [
+ {
+ value: newLastName,
+ },
+ ],
+ },
+ email: EPersonMock.email,
+ canLogIn: EPersonMock.canLogIn,
+ requireCertificate: EPersonMock.requireCertificate,
+ _links: EPersonMock._links,
+ });
+ service.updateEPerson(changedEPerson).subscribe();
+ });
+ it('should send PatchRequest with replace name metadata operations', () => {
+ const operations = [
+ { op: 'replace', path: '/eperson.lastname/0/value', value: newLastName },
+ { op: 'replace', path: '/eperson.firstname/0/value', value: newFirstName }];
+ const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations);
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+ });
+ });
+
+ describe('clearEPersonRequests', () => {
+ beforeEach(async(() => {
+ scheduler = getTestScheduler();
+ halService = {
+ getEndpoint(linkPath: string): Observable {
+ return observableOf(restEndpointURL + '/' + linkPath);
+ }
+ } as HALEndpointService;
+ initTestService();
+ service.clearEPersonRequests();
+ }));
+ it('should remove the eperson hrefs in the request service', () => {
+ expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(epersonsEndpoint);
+ });
+ });
+
+ describe('getActiveEPerson', () => {
+ it('should retrieve the ePerson currently getting edited, if any', () => {
+ service.editEPerson(EPersonMock);
+
+ service.getActiveEPerson().subscribe((activeEPerson: EPerson) => {
+ expect(activeEPerson).toEqual(EPersonMock);
+ })
+ });
+
+ it('should retrieve the ePerson currently getting edited, null if none being edited', () => {
+ service.getActiveEPerson().subscribe((activeEPerson: EPerson) => {
+ expect(activeEPerson).toEqual(null);
+ })
+ })
+ });
+
+ describe('cancelEditEPerson', () => {
+ it('should dispatch a CANCEL_EDIT_EPERSON action', () => {
+ service.cancelEditEPerson();
+ expect(store.dispatch).toHaveBeenCalledWith(new EPeopleRegistryCancelEPersonAction());
+ });
+ });
+
+ describe('editEPerson', () => {
+ it('should dispatch a EDIT_EPERSON action with the EPerson to start editing', () => {
+ service.editEPerson(EPersonMock);
+ expect(store.dispatch).toHaveBeenCalledWith(new EPeopleRegistryEditEPersonAction(EPersonMock));
+ });
+ });
+
+ describe('deleteEPerson', () => {
+ beforeEach(() => {
+ spyOn(service, 'findById').and.returnValue(getRemotedataObservable(EPersonMock));
+ service.deleteEPerson(EPersonMock).subscribe();
+ });
+
+ it('should send DeleteRequest', () => {
+ const expected = new DeleteByIDRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, EPersonMock.uuid);
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+ });
+
+});
+
+function getRemotedataObservable(obj: any): Observable> {
+ return observableOf(new RemoteData(false, false, true, undefined, obj));
+}
+
+class DummyChangeAnalyzer implements ChangeAnalyzer
- {
+ diff(object1: Item, object2: Item): Operation[] {
+ return compare((object1 as any).metadata, (object2 as any).metadata);
+ }
+}
diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts
index ef2e76c7c6..ec8b96d1cd 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, map, 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 { 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';
+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 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,167 @@ export class EPersonDataService extends DataService {
super();
}
+ /**
+ * Retrieves all EPeople
+ * @param options The options 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>>;
+ }
+
+ /**
+ * Search the EPeople with a given scope and query
+ * @param scope Scope of the EPeople search, default byMetadata
+ * @param query Query of search
+ * @param options Options of search request
+ */
+ public searchByScope(scope: string, query: string, options: FindListOptions = {}): Observable>> {
+ switch (scope) {
+ case 'metadata':
+ return this.getEpeopleByMetadata(query.trim(), options);
+ case 'email':
+ return this.getEpeopleByEmail(query.trim(), options);
+ default:
+ return this.getEpeopleByMetadata(query.trim(), options);
+ }
+ }
+
+ /**
+ * Returns a search result list of EPeople, by email query (/eperson/epersons/search/{@link searchByEmailPath}?email=<>)
+ * @param query email query
+ * @param options
+ * @param linksToFollow
+ */
+ private 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
+ */
+ private 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);
+ }
+
+ /**
+ * 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
+ */
+ public updateEPerson(ePerson: EPerson): Observable {
+ const requestId = this.requestService.generateRequestId();
+ const oldVersion$ = this.findByHref(ePerson._links.self.href);
+ oldVersion$.pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((oldEPerson: EPerson) => {
+ const operations = this.generateOperations(oldEPerson, ePerson);
+ const patchRequest = new PatchRequest(requestId, ePerson._links.self.href, operations);
+ return this.requestService.configure(patchRequest);
+ }),
+ take(1)
+ ).subscribe();
+
+ return this.fetchResponse(requestId);
+ }
+
+ /**
+ * Metadata operations are generated by the difference between old and new EPerson
+ * Custom replace operations for the other EPerson values
+ * @param oldEPerson
+ * @param newEPerson
+ */
+ private 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
+ }]
+ }
+ 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 ePerson The EPerson to delete
+ */
+ public deleteEPerson(ePerson: EPerson): Observable {
+ return this.delete(ePerson.id);
+ }
+
}
diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts
index c9f6eace8f..f885db1436 100644
--- a/src/app/core/index/index.effects.ts
+++ b/src/app/core/index/index.effects.ts
@@ -12,6 +12,7 @@ import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'
import { hasValue } from '../../shared/empty.util';
import { IndexName } from './index.reducer';
import { RestRequestMethod } from '../data/rest-request-method';
+import { getUrlWithoutEmbedParams } from './index.selectors';
@Injectable()
export class UUIDIndexEffects {
@@ -47,7 +48,7 @@ export class UUIDIndexEffects {
map((action: RequestConfigureAction) => {
return new AddToIndexAction(
IndexName.REQUEST,
- action.payload.href,
+ getUrlWithoutEmbedParams(action.payload.href),
action.payload.uuid
);
})
diff --git a/src/app/core/index/index.selectors.spec.ts b/src/app/core/index/index.selectors.spec.ts
new file mode 100644
index 0000000000..02cce4b7d6
--- /dev/null
+++ b/src/app/core/index/index.selectors.spec.ts
@@ -0,0 +1,32 @@
+import { getUrlWithoutEmbedParams } from './index.selectors';
+
+describe(`index selectors`, () => {
+
+ describe(`getUrlWithoutEmbedParams`, () => {
+
+ it(`should return a url without its embed params`, () => {
+ const source = 'https://rest.api/resource?a=1&embed=2&b=3&embed=4/5&c=6&embed=7/8/9';
+ const result = getUrlWithoutEmbedParams(source);
+ expect(result).toBe('https://rest.api/resource?a=1&b=3&c=6');
+ });
+
+ it(`should return a url without embed params unmodified`, () => {
+ const source = 'https://rest.api/resource?a=1&b=3&c=6';
+ const result = getUrlWithoutEmbedParams(source);
+ expect(result).toBe(source);
+ });
+
+ it(`should return a string that isn't a url unmodified`, () => {
+ const source = 'a=1&embed=2&b=3&embed=4/5&c=6&embed=7/8/9';
+ const result = getUrlWithoutEmbedParams(source);
+ expect(result).toBe(source);
+ });
+
+ it(`should return undefined or null unmodified`, () => {
+ expect(getUrlWithoutEmbedParams(undefined)).toBe(undefined);
+ expect(getUrlWithoutEmbedParams(null)).toBe(null);
+ });
+
+ });
+
+});
diff --git a/src/app/core/index/index.selectors.ts b/src/app/core/index/index.selectors.ts
index de4adab09b..b23496c501 100644
--- a/src/app/core/index/index.selectors.ts
+++ b/src/app/core/index/index.selectors.ts
@@ -1,8 +1,41 @@
import { createSelector, MemoizedSelector } from '@ngrx/store';
-import { hasValue } from '../../shared/empty.util';
+import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
+import { URLCombiner } from '../url-combiner/url-combiner';
import { IndexName, IndexState, MetaIndexState } from './index.reducer';
+import * as parse from 'url-parse';
+
+/**
+ * Return the given url without `embed` params.
+ *
+ * E.g. https://rest.api/resource?size=5&embed=subresource&rpp=3
+ * becomes https://rest.api/resource?size=5&rpp=3
+ *
+ * When you index a request url you don't want to include
+ * embed params because embedded data isn't relevant when
+ * you want to know
+ *
+ * @param url The url to use
+ */
+export const getUrlWithoutEmbedParams = (url: string): string => {
+ if (isNotEmpty(url)) {
+ const parsed = parse(url);
+ if (isNotEmpty(parsed.query)) {
+ const parts = parsed.query.split(/[?|&]/)
+ .filter((part: string) => isNotEmpty(part))
+ .filter((part: string) => !part.startsWith('embed='));
+ let args = '';
+ if (isNotEmpty(parts)) {
+ args = `?${parts.join('&')}`;
+ }
+ url = new URLCombiner(parsed.origin, parsed.pathname, args).toString();
+ return url;
+ }
+ }
+
+ return url;
+};
/**
* Return the MetaIndexState based on the CoreSate
@@ -74,7 +107,7 @@ export const selfLinkFromUuidSelector =
export const uuidFromHrefSelector =
(href: string): MemoizedSelector => createSelector(
requestIndexSelector,
- (state: IndexState) => hasValue(state) ? state[href] : undefined
+ (state: IndexState) => hasValue(state) ? state[getUrlWithoutEmbedParams(href)] : undefined
);
/**
diff --git a/src/app/core/services/route.reducer.ts b/src/app/core/services/route.reducer.ts
index 2d5356a5db..5a56fd3d9b 100644
--- a/src/app/core/services/route.reducer.ts
+++ b/src/app/core/services/route.reducer.ts
@@ -9,6 +9,7 @@ import {
SetQueryParameterAction,
SetQueryParametersAction
} from './route.actions';
+import { isNotEmpty } from '../../shared/empty.util';
/**
* Interface to represent the parameter state of a current route in the store
@@ -81,7 +82,8 @@ function addParameter(state: RouteState, action: AddParameterAction | AddQueryPa
* @param paramType The type of parameter to set: route or query parameter
*/
function setParameters(state: RouteState, action: SetParametersAction | SetQueryParametersAction, paramType: string): RouteState {
- return Object.assign({}, state, { [paramType]: { [action.payload.key]: action.payload.value } });
+ const param = isNotEmpty(action.payload) ? { [paramType]: { [action.payload.key]: action.payload.value } } : {};
+ return Object.assign({}, state, param);
}
/**
diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts
index 17828097e6..59ec899576 100644
--- a/src/app/core/services/route.service.ts
+++ b/src/app/core/services/route.service.ts
@@ -6,7 +6,7 @@ import { combineLatest, Observable } from 'rxjs';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { isEqual } from 'lodash';
-import { AddParameterAction, SetParameterAction, SetParametersAction, SetQueryParametersAction } from './route.actions';
+import { AddParameterAction, SetParameterAction, SetParametersAction, SetQueryParameterAction, SetQueryParametersAction } from './route.actions';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { hasValue } from '../../shared/empty.util';
@@ -196,6 +196,10 @@ export class RouteService {
this.store.dispatch(new SetParameterAction(key, value));
}
+ public setQueryParameter(key, value) {
+ this.store.dispatch(new SetQueryParameterAction(key, value));
+ }
+
/**
* Sets the current route parameters and query parameters in the store
*/
diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts
index 7bfd613b65..6bb3d77140 100644
--- a/src/app/core/shared/context.model.ts
+++ b/src/app/core/shared/context.model.ts
@@ -10,4 +10,5 @@ export enum Context {
Workspace = 'workspace',
AdminMenu = 'adminMenu',
SubmissionModal = 'submissionModal',
+ AdminSearch = 'adminSearch',
}
diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts
index e7f0ae9e10..7f6cf9fe13 100644
--- a/src/app/core/shared/item.model.ts
+++ b/src/app/core/shared/item.model.ts
@@ -18,6 +18,8 @@ import { Relationship } from './item-relationships/relationship.model';
import { RELATIONSHIP } from './item-relationships/relationship.resource-type';
import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model';
+import { Version } from './version.model';
+import { VERSION } from './version.resource-type';
/**
* Class representing a DSpace Item
@@ -67,6 +69,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
bundles: HALLink;
owningCollection: HALLink;
templateItemOf: HALLink;
+ version: HALLink;
self: HALLink;
};
@@ -77,6 +80,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(COLLECTION)
owningCollection?: Observable>;
+ /**
+ * The version this item represents in its history
+ * Will be undefined unless the version {@link HALLink} has been resolved.
+ */
+ @link(VERSION)
+ version?: Observable>;
+
/**
* The list of Bundles inside this Item
* Will be undefined unless the bundles {@link HALLink} has been resolved.
diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts
new file mode 100644
index 0000000000..a8ce982fb2
--- /dev/null
+++ b/src/app/core/shared/version-history.model.ts
@@ -0,0 +1,39 @@
+import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
+import { excludeFromEquals } from '../utilities/equals.decorators';
+import { Observable } from 'rxjs/internal/Observable';
+import { RemoteData } from '../data/remote-data';
+import { PaginatedList } from '../data/paginated-list';
+import { Version } from './version.model';
+import { VERSION_HISTORY } from './version-history.resource-type';
+import { link, typedObject } from '../cache/builders/build-decorators';
+import { DSpaceObject } from './dspace-object.model';
+import { HALLink } from './hal-link.model';
+import { VERSION } from './version.resource-type';
+
+/**
+ * Class representing a DSpace Version History
+ */
+@typedObject
+@inheritSerialization(DSpaceObject)
+export class VersionHistory extends DSpaceObject {
+ static type = VERSION_HISTORY;
+
+ @deserialize
+ _links: {
+ self: HALLink;
+ versions: HALLink;
+ };
+
+ /**
+ * The identifier of this Version History
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The list of versions within this history
+ */
+ @excludeFromEquals
+ @link(VERSION, true)
+ versions: Observable>>;
+}
diff --git a/src/app/core/shared/version-history.resource-type.ts b/src/app/core/shared/version-history.resource-type.ts
new file mode 100644
index 0000000000..c6d92ce138
--- /dev/null
+++ b/src/app/core/shared/version-history.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from './resource-type';
+
+/**
+ * The resource type for VersionHistory
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const VERSION_HISTORY = new ResourceType('versionhistory');
diff --git a/src/app/core/shared/version.model.ts b/src/app/core/shared/version.model.ts
new file mode 100644
index 0000000000..6e109ba9c2
--- /dev/null
+++ b/src/app/core/shared/version.model.ts
@@ -0,0 +1,76 @@
+import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
+import { excludeFromEquals } from '../utilities/equals.decorators';
+import { Item } from './item.model';
+import { RemoteData } from '../data/remote-data';
+import { Observable } from 'rxjs/internal/Observable';
+import { VersionHistory } from './version-history.model';
+import { EPerson } from '../eperson/models/eperson.model';
+import { VERSION } from './version.resource-type';
+import { HALLink } from './hal-link.model';
+import { link, typedObject } from '../cache/builders/build-decorators';
+import { VERSION_HISTORY } from './version-history.resource-type';
+import { ITEM } from './item.resource-type';
+import { EPERSON } from '../eperson/models/eperson.resource-type';
+import { DSpaceObject } from './dspace-object.model';
+
+/**
+ * Class representing a DSpace Version
+ */
+@typedObject
+@inheritSerialization(DSpaceObject)
+export class Version extends DSpaceObject {
+ static type = VERSION;
+
+ @deserialize
+ _links: {
+ self: HALLink;
+ item: HALLink;
+ versionhistory: HALLink;
+ eperson: HALLink;
+ };
+
+ /**
+ * The identifier of this Version
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The version number of the version's history this version represents
+ */
+ @autoserialize
+ version: number;
+
+ /**
+ * The summary for the changes made in this version
+ */
+ @autoserialize
+ summary: string;
+
+ /**
+ * The Date this version was created
+ */
+ @deserialize
+ created: Date;
+
+ /**
+ * The full version history this version is apart of
+ */
+ @excludeFromEquals
+ @link(VERSION_HISTORY)
+ versionhistory: Observable>;
+
+ /**
+ * The item this version represents
+ */
+ @excludeFromEquals
+ @link(ITEM)
+ item: Observable>;
+
+ /**
+ * The e-person who created this version
+ */
+ @excludeFromEquals
+ @link(EPERSON)
+ eperson: Observable>;
+}
diff --git a/src/app/core/shared/version.resource-type.ts b/src/app/core/shared/version.resource-type.ts
new file mode 100644
index 0000000000..ac0f56239e
--- /dev/null
+++ b/src/app/core/shared/version.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from './resource-type';
+
+/**
+ * The resource type for Version
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const VERSION = new ResourceType('version');
diff --git a/src/app/core/url-combiner/url-combiner.ts b/src/app/core/url-combiner/url-combiner.ts
index ae622ab976..e7468c6107 100644
--- a/src/app/core/url-combiner/url-combiner.ts
+++ b/src/app/core/url-combiner/url-combiner.ts
@@ -41,8 +41,8 @@ export class URLCombiner {
// remove consecutive slashes
url = url.replace(/([^:\s])\/+/g, '$1/');
- // remove trailing slash before parameters or hash
- url = url.replace(/\/(\?|&|#[^!])/g, '$1');
+ // remove trailing slash
+ url = url.replace(/\/($|\?|&|#[^!])/g, '$1');
// replace ? in parameters with &
url = url.replace(/(\?.+)\?/g, '$1&');
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
index af339109c6..0f9b5894f9 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
@@ -1,36 +1,43 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
index 2c7f24662a..16e2a8b847 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
@@ -1,36 +1,43 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
index d6b9c4a62e..4902eec71e 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
@@ -1,41 +1,47 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- {{firstMetadataValue('creativework.editor')}}
-
+
+
+
+
+
+
+
+ {{firstMetadataValue('creativework.editor')}}
+
,
- {{firstMetadataValue('creativework.publisher')}}
+ {{firstMetadataValue('creativework.publisher')}}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html
index 0fb1ec02f8..1f64856583 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html
@@ -1,41 +1,49 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- {{firstMetadataValue('organization.address.addressCountry')}}
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{firstMetadataValue('organization.address.addressCountry')}}
+
,
- {{firstMetadataValue('organization.address.addressLocality')}}
+ {{firstMetadataValue('organization.address.addressLocality')}}
-
-
-
-
+
+
+
-
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
index 321ecd4a47..cbe93b2545 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
@@ -1,37 +1,43 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
index c39de6bc2a..22182d50be 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
@@ -1,31 +1,37 @@
-
-
-
-
-
-
-
-
-
+
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..c1c1cff0f3
--- /dev/null
+++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html
@@ -0,0 +1,6 @@
+
+
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
new file mode 100644
index 0000000000..b44faa8c4a
--- /dev/null
+++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts
@@ -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({
+ 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) => {
+ 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.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) => {
+ 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;
+ }
+}
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..50a081c6f2
--- /dev/null
+++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html
@@ -0,0 +1,9 @@
+{{'profile.security.form.info' | translate}}
+
+
+{{'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
new file mode 100644
index 0000000000..324230ce9f
--- /dev/null
+++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts
@@ -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;
+
+ 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();
+ }
+});
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..b8ac07e6d8
--- /dev/null
+++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts
@@ -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;
+ }
+}
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..b6e62665b4
--- /dev/null
+++ b/src/app/profile-page/profile-page.component.html
@@ -0,0 +1,27 @@
+
+
+
{{'profile.head' | translate}}
+
+
+
+
+
+
+
{{'profile.groups.head' | translate}}
+
+
+
+
+
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();
+ });
+ });
+ });
+});
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..5a2736593a
--- /dev/null
+++ b/src/app/profile-page/profile-page.component.ts
@@ -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;
+
+ /**
+ * The groups the user belongs to
+ */
+ groupsRD$: Observable>>;
+
+ /**
+ * Prefix for the notification messages of this component
+ */
+ NOTIFICATIONS_PREFIX = 'profile.notifications.';
+
+ constructor(private store: Store,
+ 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')
+ );
+ }
+ }
+}
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 {
+
+}
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
) {
}
diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html
new file mode 100644
index 0000000000..6e93f4c7ca
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.html
@@ -0,0 +1,47 @@
+
+
+
0 || displayWhenEmpty">
+
{{"item.version.history.head" | translate}}
+
0"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ [paginationOptions]="options"
+ [pageInfoState]="versions"
+ [collectionSize]="versions?.totalElements"
+ [disableRouteParameterUpdate]="true"
+ (pageChange)="switchPage($event)">
+
+
+
+ {{"item.version.history.table.version" | translate}} |
+ {{"item.version.history.table.item" | translate}} |
+ {{"item.version.history.table.editor" | translate}} |
+ {{"item.version.history.table.date" | translate}} |
+ {{"item.version.history.table.summary" | translate}} |
+
+
+
+
+ {{version?.version}} |
+
+
+ {{item?.handle}}
+ *
+
+ |
+
+
+ {{eperson?.name}}
+
+ |
+ {{version?.created}} |
+ {{version?.summary}} |
+
+
+
+ * {{"item.version.history.selected" | translate}}
+
+
+
+
+
diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts
new file mode 100644
index 0000000000..18fa4cf983
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts
@@ -0,0 +1,121 @@
+import { ItemVersionsComponent } from './item-versions.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { VarDirective } from '../../utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { Version } from '../../../core/shared/version.model';
+import { VersionHistory } from '../../../core/shared/version-history.model';
+import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../testing/utils';
+import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
+import { By } from '@angular/platform-browser';
+
+describe('ItemVersionsComponent', () => {
+ let component: ItemVersionsComponent;
+ let fixture: ComponentFixture;
+
+ const versionHistory = Object.assign(new VersionHistory(), {
+ id: '1'
+ });
+ const version1 = Object.assign(new Version(), {
+ id: '1',
+ version: 1,
+ created: new Date(2020, 1, 1),
+ summary: 'first version',
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const version2 = Object.assign(new Version(), {
+ id: '2',
+ version: 2,
+ summary: 'second version',
+ created: new Date(2020, 1, 2),
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const versions = [version1, version2];
+ versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
+ const item1 = Object.assign(new Item(), {
+ uuid: 'item-identifier-1',
+ handle: '123456789/1',
+ version: createSuccessfulRemoteDataObject$(version1)
+ });
+ const item2 = Object.assign(new Item(), {
+ uuid: 'item-identifier-2',
+ handle: '123456789/2',
+ version: createSuccessfulRemoteDataObject$(version2)
+ });
+ const items = [item1, item2];
+ version1.item = createSuccessfulRemoteDataObject$(item1);
+ version2.item = createSuccessfulRemoteDataObject$(item2);
+ const versionHistoryService = jasmine.createSpyObj('versionHistoryService', {
+ getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions))
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ItemVersionsComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: VersionHistoryDataService, useValue: versionHistoryService }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemVersionsComponent);
+ component = fixture.componentInstance;
+ component.item = item1;
+ fixture.detectChanges();
+ });
+
+ it(`should display ${versions.length} rows`, () => {
+ const rows = fixture.debugElement.queryAll(By.css('tbody tr'));
+ expect(rows.length).toBe(versions.length);
+ });
+
+ versions.forEach((version: Version, index: number) => {
+ const versionItem = items[index];
+
+ it(`should display version ${version.version} in the correct column for version ${version.id}`, () => {
+ const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`));
+ expect(id.nativeElement.textContent).toEqual('' + version.version);
+ });
+
+ it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => {
+ const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`));
+ expect(item.nativeElement.textContent).toContain(versionItem.handle);
+ });
+
+ // This version's item is equal to the component's item (the selected item)
+ // Check if the handle contains an asterisk
+ if (item1.uuid === versionItem.uuid) {
+ it('should add an asterisk to the handle of the selected item', () => {
+ const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`));
+ expect(item.nativeElement.textContent).toContain('*');
+ });
+ }
+
+ it(`should display date ${version.created} in the correct column for version ${version.id}`, () => {
+ const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`));
+ expect(date.nativeElement.textContent).toEqual('' + version.created);
+ });
+
+ it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => {
+ const summary = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-summary`));
+ expect(summary.nativeElement.textContent).toEqual(version.summary);
+ });
+ });
+
+ describe('switchPage', () => {
+ const page = 5;
+
+ beforeEach(() => {
+ component.switchPage(page);
+ });
+
+ it('should set the option\'s currentPage to the new page', () => {
+ expect(component.options.currentPage).toEqual(page);
+ });
+ });
+});
diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts
new file mode 100644
index 0000000000..684599d3b5
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.ts
@@ -0,0 +1,130 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { Version } from '../../../core/shared/version.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { Observable } from 'rxjs/internal/Observable';
+import { VersionHistory } from '../../../core/shared/version-history.model';
+import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
+import { map, startWith, switchMap } from 'rxjs/operators';
+import { combineLatest as observableCombineLatest } from 'rxjs';
+import { PaginatedList } from '../../../core/data/paginated-list';
+import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
+import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
+import { AlertType } from '../../alert/aletr-type';
+import { followLink } from '../../utils/follow-link-config.model';
+
+@Component({
+ selector: 'ds-item-versions',
+ templateUrl: './item-versions.component.html'
+})
+/**
+ * Component listing all available versions of the history the provided item is a part of
+ */
+export class ItemVersionsComponent implements OnInit {
+ /**
+ * The item to display a version history for
+ */
+ @Input() item: Item;
+
+ /**
+ * An option to display the list of versions, even when there aren't any.
+ * Instead of the table, an alert will be displayed, notifying the user there are no other versions present
+ * for the current item.
+ */
+ @Input() displayWhenEmpty = false;
+
+ /**
+ * Whether or not to display the title
+ */
+ @Input() displayTitle = true;
+
+ /**
+ * The AlertType enumeration
+ * @type {AlertType}
+ */
+ AlertTypeEnum = AlertType;
+
+ /**
+ * The item's version
+ */
+ versionRD$: Observable>;
+
+ /**
+ * The item's full version history
+ */
+ versionHistoryRD$: Observable>;
+
+ /**
+ * The version history's list of versions
+ */
+ versionsRD$: Observable>>;
+
+ /**
+ * Verify if the list of versions has at least one e-person to display
+ * Used to hide the "Editor" column when no e-persons are present to display
+ */
+ hasEpersons$: Observable;
+
+ /**
+ * The amount of versions to display per page
+ */
+ pageSize = 10;
+
+ /**
+ * The page options to use for fetching the versions
+ * Start at page 1 and always use the set page size
+ */
+ options = Object.assign(new PaginationComponentOptions(),{
+ id: 'item-versions-options',
+ currentPage: 1,
+ pageSize: this.pageSize
+ });
+
+ /**
+ * The current page being displayed
+ */
+ currentPage$ = new BehaviorSubject(1);
+
+ constructor(private versionHistoryService: VersionHistoryDataService) {
+ }
+
+ /**
+ * Initialize all observables
+ */
+ ngOnInit(): void {
+ this.versionRD$ = this.item.version;
+ this.versionHistoryRD$ = this.versionRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ switchMap((version: Version) => version.versionhistory)
+ );
+ const versionHistory$ = this.versionHistoryRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ );
+ this.versionsRD$ = observableCombineLatest(versionHistory$, this.currentPage$).pipe(
+ switchMap(([versionHistory, page]: [VersionHistory, number]) =>
+ this.versionHistoryService.getVersions(versionHistory.id,
+ new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
+ followLink('item'), followLink('eperson')))
+ );
+ this.hasEpersons$ = this.versionsRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((versions: PaginatedList) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0),
+ startWith(false)
+ );
+ }
+
+ /**
+ * Update the current page
+ * @param page
+ */
+ switchPage(page: number) {
+ this.options.currentPage = page;
+ this.currentPage$.next(page);
+ }
+
+}
diff --git a/src/app/shared/mocks/mock-trucatable.service.ts b/src/app/shared/mocks/mock-trucatable.service.ts
new file mode 100644
index 0000000000..0acb0b4c76
--- /dev/null
+++ b/src/app/shared/mocks/mock-trucatable.service.ts
@@ -0,0 +1,19 @@
+import { of as observableOf } from 'rxjs/internal/observable/of';
+
+export const mockTruncatableService: any = {
+ /* tslint:disable:no-empty */
+ isCollapsed: (id: string) => {
+ if (id === '1') {
+ return observableOf(true)
+ } else {
+ return observableOf(false);
+ }
+ },
+ expand: (id: string) => {
+ },
+ collapse: (id: string) => {
+ },
+ toggle: (id: string) => {
+ }
+ /* tslint:enable:no-empty */
+};
diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts
index d27fb331de..3602f45ede 100644
--- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts
+++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts
@@ -1,6 +1,8 @@
import { Component, Input } from '@angular/core';
import { ListableObject } from '../listable-object.model';
import { CollectionElementLinkType } from '../../collection-element-link.type';
+import { Context } from '../../../../core/shared/context.model';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-abstract-object-element',
@@ -22,8 +24,23 @@ export class AbstractListableElementComponent {
*/
@Input() listID: string;
+ /**
+ * The index of this element
+ */
+ @Input() index: number;
+
/**
* The available link types
*/
linkTypes = CollectionElementLinkType;
+
+ /**
+ * The available view modes
+ */
+ viewModes = ViewMode;
+
+ /**
+ * The available contexts
+ */
+ contexts = Context;
}
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
index f6abb444d5..4647a4d4a7 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
@@ -49,10 +49,8 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLink(this.dso, followLink(
- 'workflowitem',
- null,
- followLink('item', null, followLink('bundles')),
+ this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
+ followLink('item', null, true, followLink('bundles')),
followLink('submitter')
));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
index afa4f57d78..423931225e 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
@@ -48,10 +48,8 @@ export class PoolSearchResultDetailElementComponent extends SearchResultDetailEl
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLink(this.dso, followLink(
- 'workflowitem',
- null,
- followLink('item', null, followLink('bundles')),
+ this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
+ followLink('item', null, true, followLink('bundles')),
followLink('submitter')
));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
index 0c45316e30..a2933fd0ec 100644
--- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
@@ -14,4 +14,5 @@
View
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts
index c14e3f6df1..de19f5b74a 100644
--- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
+import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { CommunityDataService } from '../../../../core/data/community-data.service';
import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
@@ -62,6 +63,7 @@ describe('CollectionSearchResultGridElementComponent', () => {
{ provide: UUIDService, useValue: {} },
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
+ { provide: BitstreamDataService, useValue: {} },
{ provide: CommunityDataService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: NotificationsService, useValue: {} },
diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
index d0a9aa700e..8d5f288498 100644
--- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
@@ -1,17 +1,18 @@
-
-
-
-
-
+
+
+
+
+
-
-
{{dso.name}}
-
{{dso.shortDescription}}
-
-
View
+
+
{{dso.name}}
+
{{dso.shortDescription}}
+
-
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts
index 0ea72b52d5..b97c574970 100644
--- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
+import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { CommunityDataService } from '../../../../core/data/community-data.service';
import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
@@ -62,6 +63,7 @@ describe('CommunitySearchResultGridElementComponent', () => {
{ provide: UUIDService, useValue: {} },
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
+ { provide: BitstreamDataService, useValue: {} },
{ provide: CommunityDataService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: NotificationsService, useValue: {} },
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html
index 41c16c6eab..ec0b792e34 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html
@@ -1,41 +1,44 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{firstMetadataValue('dc.date.issued')}}
- ,
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{firstMetadataValue('dc.date.issued')}}
+ ,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts
index 76618f18f2..c96e73d365 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts
@@ -7,6 +7,7 @@ import { Item } from '../../../../../core/shared/item.model';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
@listableObjectComponent('PublicationSearchResult', ViewMode.GridElement)
+@listableObjectComponent(ItemSearchResult, ViewMode.GridElement)
@Component({
selector: 'ds-publication-search-result-grid-element',
styleUrls: ['./publication-search-result-grid-element.component.scss'],
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
index 1648a16d59..cb46e25282 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
@@ -55,11 +55,8 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLink(this.dso, followLink(
- 'workflowitem',
- null,
- followLink('item'),
- followLink('submitter')
+ this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
+ followLink('item'), followLink('submitter')
));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
}
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
index f3368cf64c..8ab00f4b9b 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
@@ -58,11 +58,8 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLink(this.dso, followLink(
- 'workflowitem',
- null,
- followLink('item'),
- followLink('submitter')
+ this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
+ followLink('item'), followLink('submitter')
));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
}
diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts
index 9c378d1aff..04309b6f9f 100644
--- a/src/app/shared/pagination/pagination.component.ts
+++ b/src/app/shared/pagination/pagination.component.ts
@@ -99,6 +99,13 @@ export class PaginationComponent implements OnDestroy, OnInit {
*/
@Input() public hidePagerWhenSinglePage = true;
+ /**
+ * Option for disabling updating and reading route parameters on pagination changes
+ * In other words, changing pagination won't add or update the url parameters on the current page, and the url
+ * parameters won't affect the pagination of this component
+ */
+ @Input() public disableRouteParameterUpdate = false;
+
/**
* Current page.
*/
@@ -173,20 +180,35 @@ export class PaginationComponent implements OnDestroy, OnInit {
this.checkConfig(this.paginationOptions);
this.initializeConfig();
// Listen to changes
- this.subs.push(this.route.queryParams
- .subscribe((queryParams) => {
- if (this.isEmptyPaginationParams(queryParams)) {
- this.initializeConfig(queryParams);
+ if (!this.disableRouteParameterUpdate) {
+ this.subs.push(this.route.queryParams
+ .subscribe((queryParams) => {
+ this.initializeParams(queryParams);
+ }));
+ }
+ }
+
+ /**
+ * Initialize the route and current parameters
+ * This method will fix any invalid or missing parameters
+ * @param params
+ */
+ private initializeParams(params) {
+ if (this.isEmptyPaginationParams(params)) {
+ this.initializeConfig(params);
+ } else {
+ this.currentQueryParams = params;
+ const fixedProperties = this.validateParams(params);
+ if (isNotEmpty(fixedProperties)) {
+ if (!this.disableRouteParameterUpdate) {
+ this.fixRoute(fixedProperties);
} else {
- this.currentQueryParams = queryParams;
- const fixedProperties = this.validateParams(queryParams);
- if (isNotEmpty(fixedProperties)) {
- this.fixRoute(fixedProperties);
- } else {
- this.setFields();
- }
+ this.initializeParams(fixedProperties);
}
- }));
+ } else {
+ this.setFields();
+ }
+ }
}
private fixRoute(fixedProperties) {
@@ -247,7 +269,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page being navigated to.
*/
public doPageChange(page: number) {
- this.updateRoute({ pageId: this.id, page: page.toString() });
+ this.updateParams(Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page.toString() }));
}
/**
@@ -257,7 +279,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page size being navigated to.
*/
public doPageSizeChange(pageSize: number) {
- this.updateRoute({ pageId: this.id, page: 1, pageSize: pageSize });
+ this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, pageSize: pageSize }));
}
/**
@@ -267,7 +289,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort direction being navigated to.
*/
public doSortDirectionChange(sortDirection: SortDirection) {
- this.updateRoute({ pageId: this.id, page: 1, sortDirection: sortDirection });
+ this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, sortDirection: sortDirection }));
}
/**
@@ -277,7 +299,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort field being navigated to.
*/
public doSortFieldChange(field: string) {
- this.updateRoute({ pageId: this.id, page: 1, sortField: field });
+ this.updateParams(Object.assign(this.currentQueryParams,{ pageId: this.id, page: 1, sortField: field }));
}
/**
@@ -347,6 +369,20 @@ export class PaginationComponent implements OnDestroy, OnInit {
})
}
+ /**
+ * Update the current query params and optionally update the route
+ * @param params
+ */
+ private updateParams(params: {}) {
+ if (isNotEmpty(difference(params, this.currentQueryParams))) {
+ if (!this.disableRouteParameterUpdate) {
+ this.updateRoute(params);
+ } else {
+ this.initializeParams(params);
+ }
+ }
+ }
+
/**
* Method to update the route parameters
*/
diff --git a/src/app/shared/search/paginated-search-options.model.spec.ts b/src/app/shared/search/paginated-search-options.model.spec.ts
index 2702c3ff01..1739fd54fb 100644
--- a/src/app/shared/search/paginated-search-options.model.spec.ts
+++ b/src/app/shared/search/paginated-search-options.model.spec.ts
@@ -27,9 +27,9 @@ describe('PaginatedSearchOptions', () => {
'query=search query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' +
- 'f.test=value,query&' +
- 'f.example=another value,query&' +
- 'f.example=second value,query'
+ 'f.test=value&' +
+ 'f.example=another value&' +
+ 'f.example=second value'
);
});
diff --git a/src/app/shared/search/search-filter.model.ts b/src/app/shared/search/search-filter.model.ts
index 9e93bafed8..ee55bec242 100644
--- a/src/app/shared/search/search-filter.model.ts
+++ b/src/app/shared/search/search-filter.model.ts
@@ -1,7 +1,6 @@
/**
* Represents a search filter
*/
-import { hasValue } from '../empty.util';
export class SearchFilter {
key: string;
@@ -11,10 +10,6 @@ export class SearchFilter {
constructor(key: string, values: string[], operator?: string) {
this.key = key;
this.values = values;
- if (hasValue(operator)) {
- this.operator = operator;
- } else {
- this.operator = 'query';
- }
+ this.operator = operator;
}
}
diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
index 9441081661..cf4876e34f 100644
--- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
@@ -2,7 +2,9 @@
[routerLink]="[searchLink]"
[queryParams]="addQueryParams" queryParamsHandling="merge">
- {{filterValue.value}}
+
+ {{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }}
+
{{filterValue.count}}
diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
index 5198433207..a27a5d3d86 100644
--- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
@@ -2,5 +2,7 @@
[routerLink]="[searchLink]"
[queryParams]="removeQueryParams" queryParamsHandling="merge">
- {{selectedValue.label}}
+
+ {{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.value} }}
+
diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.html b/src/app/shared/search/search-labels/search-label/search-label.component.html
index 391efcb763..bffb7f9329 100644
--- a/src/app/shared/search/search-labels/search-label/search-label.component.html
+++ b/src/app/shared/search/search-labels/search-label/search-label.component.html
@@ -1,6 +1,6 @@
- {{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(value)}}
+ {{('search.filters.applied.' + key) | translate}}: {{'search.filters.' + filterName + '.' + value | translate: {default: normalizeFilterValue(value)} }}
×
-
\ No newline at end of file
+
diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.ts b/src/app/shared/search/search-labels/search-label/search-label.component.ts
index 956b5b81de..2203f73a75 100644
--- a/src/app/shared/search/search-labels/search-label/search-label.component.ts
+++ b/src/app/shared/search/search-labels/search-label/search-label.component.ts
@@ -22,6 +22,11 @@ export class SearchLabelComponent implements OnInit {
searchLink: string;
removeParameters: Observable;
+ /**
+ * The name of the filter without the f. prefix
+ */
+ filterName: string;
+
/**
* Initialize the instance variable
*/
@@ -33,6 +38,7 @@ export class SearchLabelComponent implements OnInit {
ngOnInit(): void {
this.searchLink = this.getSearchLink();
this.removeParameters = this.getRemoveParams();
+ this.filterName = this.getFilterName();
}
/**
@@ -74,4 +80,8 @@ export class SearchLabelComponent implements OnInit {
const pattern = /,authority*$/g;
return value.replace(pattern, '');
}
+
+ private getFilterName(): string {
+ return this.key.startsWith('f.') ? this.key.substring(2) : this.key;
+ }
}
diff --git a/src/app/shared/search/search-options.model.spec.ts b/src/app/shared/search/search-options.model.spec.ts
index 3b047b578f..3195ec3660 100644
--- a/src/app/shared/search/search-options.model.spec.ts
+++ b/src/app/shared/search/search-options.model.spec.ts
@@ -21,9 +21,9 @@ describe('SearchOptions', () => {
'query=search query&' +
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
'dsoType=ITEM&' +
- 'f.test=value,query&' +
- 'f.example=another value,query&' +
- 'f.example=second value,query'
+ 'f.test=value&' +
+ 'f.example=another value&' +
+ 'f.example=second value'
);
});
diff --git a/src/app/shared/search/search-options.model.ts b/src/app/shared/search/search-options.model.ts
index a8b115abd3..7d5f4dd207 100644
--- a/src/app/shared/search/search-options.model.ts
+++ b/src/app/shared/search/search-options.model.ts
@@ -50,7 +50,7 @@ export class SearchOptions {
if (isNotEmpty(this.filters)) {
this.filters.forEach((filter: SearchFilter) => {
filter.values.forEach((value) => {
- const filterValue = value.includes(',') ? `${value}` : `${value},${filter.operator}`;
+ const filterValue = value.includes(',') ? `${value}` : value + (filter.operator ? ',' + filter.operator : '');
args.push(`${filter.key}=${filterValue}`)
});
});
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 22e7b06c8c..f16d4f1e6c 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -6,7 +6,7 @@ import { NouisliderModule } from 'ng2-nouislider';
import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
-import { TranslateModule } from '@ngx-translate/core';
+import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core';
import { NgxPaginationModule } from 'ngx-pagination';
import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component';
@@ -178,11 +178,13 @@ import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynam
import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component';
+import { ItemVersionsComponent } from './item/item-versions/item-versions.component';
import { SortablejsModule } from 'ngx-sortablejs';
import { LogInContainerComponent } from './log-in/container/log-in-container.component';
import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component';
import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component';
import { LogInComponent } from './log-in/log-in.component';
+import { MissingTranslationHelper } from './translate/missing-translation.helper';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -200,7 +202,6 @@ const MODULES = [
NgxPaginationModule,
ReactiveFormsModule,
RouterModule,
- TranslateModule,
NouisliderModule,
MomentModule,
TextMaskModule,
@@ -209,7 +210,11 @@ const MODULES = [
];
const ROOT_MODULES = [
- TooltipModule.forRoot()
+ TranslateModule.forChild({
+ missingTranslationHandler: { provide: MissingTranslationHandler, useClass: MissingTranslationHelper },
+ useDefaultLang: true
+ }),
+ TooltipModule.forRoot(),
];
const PIPES = [
@@ -348,7 +353,9 @@ const COMPONENTS = [
ExistingMetadataListElementComponent,
LogInShibbolethComponent,
LogInPasswordComponent,
- LogInContainerComponent
+ LogInContainerComponent,
+ ItemVersionsComponent,
+ PublicationSearchResultListElementComponent,
];
const ENTRY_COMPONENTS = [
@@ -413,7 +420,8 @@ const ENTRY_COMPONENTS = [
DsDynamicLookupRelationExternalSourceTabComponent,
ExternalSourceEntryImportModalComponent,
LogInPasswordComponent,
- LogInShibbolethComponent
+ LogInShibbolethComponent,
+ ItemVersionsComponent,
];
const SHARED_ITEM_PAGE_COMPONENTS = [
@@ -446,8 +454,8 @@ const DIRECTIVES = [
@NgModule({
imports: [
+ ...ROOT_MODULES,
...MODULES,
- ...ROOT_MODULES
],
declarations: [
...PIPES,
@@ -455,8 +463,7 @@ const DIRECTIVES = [
...DIRECTIVES,
...ENTRY_COMPONENTS,
...SHARED_ITEM_PAGE_COMPONENTS,
- PublicationSearchResultListElementComponent,
- ExistingMetadataListElementComponent
+
],
providers: [
...PROVIDERS
diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts
index 44585f278f..81a5a48a2c 100644
--- a/src/app/shared/testing/eperson-mock.ts
+++ b/src/app/shared/testing/eperson-mock.ts
@@ -1,6 +1,7 @@
import { EPerson } from '../../core/eperson/models/eperson.model';
+import { GroupMock } from './group-mock';
-export const EPersonMock: EPerson = Object.assign(new EPerson(),{
+export const EPersonMock: EPerson = Object.assign(new EPerson(), {
handle: null,
groups: [],
netid: 'test@test.com',
@@ -12,7 +13,8 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{
_links: {
self: {
href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid',
- }
+ },
+ groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid/groups' }
},
id: 'testid',
uuid: 'testid',
@@ -44,3 +46,49 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{
]
}
});
+
+export const EPersonMock2: EPerson = Object.assign(new EPerson(), {
+ handle: null,
+ groups: [GroupMock],
+ netid: 'test2@test.com',
+ lastActive: '2019-05-14T12:25:42.411+0000',
+ canLogIn: false,
+ email: 'test2@test.com',
+ requireCertificate: false,
+ selfRegistered: true,
+ _links: {
+ self: {
+ href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2',
+ },
+ groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2/groups' }
+ },
+ id: 'testid2',
+ uuid: 'testid2',
+ type: 'eperson',
+ metadata: {
+ 'dc.title': [
+ {
+ language: null,
+ value: 'User Test 2'
+ }
+ ],
+ 'eperson.firstname': [
+ {
+ language: null,
+ value: 'User2'
+ }
+ ],
+ 'eperson.lastname': [
+ {
+ language: null,
+ value: 'MeepMeep'
+ },
+ ],
+ 'eperson.language': [
+ {
+ language: null,
+ value: 'fr'
+ },
+ ]
+ }
+});
diff --git a/src/app/shared/testing/group-mock.ts b/src/app/shared/testing/group-mock.ts
new file mode 100644
index 0000000000..0c9abb4b7d
--- /dev/null
+++ b/src/app/shared/testing/group-mock.ts
@@ -0,0 +1,16 @@
+import { Group } from '../../core/eperson/models/group.model';
+
+export const GroupMock: Group = Object.assign(new Group(), {
+ handle: null,
+ groups: [],
+ selfRegistered: false,
+ _links: {
+ self: {
+ href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid',
+ },
+ groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups' }
+ },
+ id: 'testgroupid',
+ uuid: 'testgroupid',
+ type: 'group',
+});
diff --git a/src/app/shared/translate/missing-translation.helper.ts b/src/app/shared/translate/missing-translation.helper.ts
new file mode 100644
index 0000000000..71a1dc3620
--- /dev/null
+++ b/src/app/shared/translate/missing-translation.helper.ts
@@ -0,0 +1,18 @@
+import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core';
+
+/**
+ * Class to handle missing translations for the ngx-translate library
+ */
+export class MissingTranslationHelper implements MissingTranslationHandler {
+ /**
+ * Called when there is not translation for a specific key
+ * Will return the 'default' parameter of the translate pipe, if there is one available
+ * @param params
+ */
+ handle(params: MissingTranslationHandlerParams) {
+ if (params.interpolateParams) {
+ return (params.interpolateParams as any).default || params.key;
+ }
+ return params.key;
+ }
+}
diff --git a/src/app/shared/truncatable/truncatable.component.spec.ts b/src/app/shared/truncatable/truncatable.component.spec.ts
index d083c27d07..176beb0f15 100644
--- a/src/app/shared/truncatable/truncatable.component.spec.ts
+++ b/src/app/shared/truncatable/truncatable.component.spec.ts
@@ -1,5 +1,6 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
+import { mockTruncatableService } from '../mocks/mock-trucatable.service';
import { TruncatableComponent } from './truncatable.component';
import { TruncatableService } from './truncatable.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
@@ -10,29 +11,12 @@ describe('TruncatableComponent', () => {
let fixture: ComponentFixture;
const identifier = '1234567890';
let truncatableService;
- const truncatableServiceStub: any = {
- /* tslint:disable:no-empty */
- isCollapsed: (id: string) => {
- if (id === '1') {
- return observableOf(true)
- } else {
- return observableOf(false);
- }
- },
- expand: (id: string) => {
- },
- collapse: (id: string) => {
- },
- toggle: (id: string) => {
- }
- /* tslint:enable:no-empty */
- };
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [TruncatableComponent],
providers: [
- { provide: TruncatableService, useValue: truncatableServiceStub },
+ { provide: TruncatableService, useValue: mockTruncatableService },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(TruncatableComponent, {
diff --git a/src/app/shared/utils/follow-link-config.model.ts b/src/app/shared/utils/follow-link-config.model.ts
index 21df288690..87942d8467 100644
--- a/src/app/shared/utils/follow-link-config.model.ts
+++ b/src/app/shared/utils/follow-link-config.model.ts
@@ -23,6 +23,11 @@ export class FollowLinkConfig {
* use on the retrieved object.
*/
linksToFollow?: Array>;
+
+ /**
+ * Forward to rest which links we're following, so these can already be embedded
+ */
+ shouldEmbed? = true;
}
/**
@@ -36,15 +41,19 @@ export class FollowLinkConfig {
* in a certain way
* @param linksToFollow: a list of {@link FollowLinkConfig}s to
* use on the retrieved object.
+ * @param shouldEmbed: boolean to check whether to forward info on followLinks to rest,
+ * so these can be embedded, default true
*/
export const followLink = (
linkName: keyof R['_links'],
findListOptions?: FindListOptions,
+ shouldEmbed = true,
...linksToFollow: Array>
): FollowLinkConfig => {
return {
name: linkName,
findListOptions,
+ shouldEmbed: shouldEmbed,
linksToFollow
}
};