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 + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} +
+ + +
+
+
+ +
+ + + +
+
+
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.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 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-

- - - -

-
- View -
-
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
-
+ + +
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 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-

- - - -

-
- View -
-
-
- +
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+ + +
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')}} - -

-

- - - -

-
- View -
-
+ +

+

+ + + +

+
+ View +
-
+ + +
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')}} - -

-
- View -
-
+ +

+
+ View +
-
+ + +
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 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
- -

- - - -

-
- View -
-
+
+ + +

+
+ +

+ + + +

+
+ View +
-
+ + +
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 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-
- View -
-
+
+ + +

+
+

+ + + +

+
+ View +
-
+ + +
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.card.identify' | translate}}
+
+ +
+
+
+
{{'profile.card.security' | translate}}
+
+ +
+
+ + + +
+

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

+
    +
  • {{group.name}}
  • +
+
+
+
+
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 @@ +
+
+
+

{{"item.version.history.head" | translate}}

+ + + + + + + + + + + + + + + + + + + + +
{{"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}}

+
+ View +
-
+
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')}} - , - - - -

-

- - - -

-
- View -
-
-
-
+
+ + + +
+ + +
+
+ +
+ + +
+
+
+ + +

+
+

+ + {{firstMetadataValue('dc.date.issued')}} + , + + + +

+

+ + + +

+
+ View +
+
+
+ +
+ 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 } };