diff --git a/.travis.yml b/.travis.yml index c42923886d..5e4bae7892 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,11 @@ before_install: - sudo mv docker-compose /usr/local/bin install: + # update chrome + - wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + - sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' + - sudo apt-get update + - sudo apt-get install google-chrome-stable # Start up DSpace 7 using the entities database dump - docker-compose -f ./docker/docker-compose-travis.yml up -d # Use the dspace-cli image to populate the assetstore. Trigger a discovery and oai update diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts index 6856a6f01b..995340941f 100644 --- a/e2e/app.e2e-spec.ts +++ b/e2e/app.e2e-spec.ts @@ -13,7 +13,7 @@ describe('protractor App', () => { }); it('should contain a news section', () => { - page.navigateTo(); - expect(page.getHomePageNewsText()).toBeDefined(); + page.navigateTo() + .then(() => expect(page.getHomePageNewsText()).toBeDefined()); }); }); diff --git a/e2e/app.po.ts b/e2e/app.po.ts index c76bef118f..2ee9a86201 100644 --- a/e2e/app.po.ts +++ b/e2e/app.po.ts @@ -11,6 +11,6 @@ export class ProtractorPage { } getHomePageNewsText() { - return element(by.xpath('//ds-home-news')).getText(); + return element(by.css('ds-home-news')).getText(); } } diff --git a/e2e/search-page/search-page.e2e-spec.ts b/e2e/search-page/search-page.e2e-spec.ts index cb9c92a87b..e2ab6de824 100644 --- a/e2e/search-page/search-page.e2e-spec.ts +++ b/e2e/search-page/search-page.e2e-spec.ts @@ -11,33 +11,36 @@ describe('protractor SearchPage', () => { it('should contain query value when navigating to page with query parameter', () => { const queryString = 'Interesting query string'; - page.navigateToSearchWithQueryParameter(queryString); - page.getCurrentQuery().then((query: string) => { - expect(query).toEqual(queryString); - }); + page.navigateToSearchWithQueryParameter(queryString) + .then(() => page.getCurrentQuery()) + .then((query: string) => { + expect(query).toEqual(queryString); + }); }); it('should have right scope selected when navigating to page with scope parameter', () => { - const scope: promise.Promise = page.getRandomScopeOption(); - scope.then((scopeString: string) => { - page.navigateToSearchWithScopeParameter(scopeString); - page.getCurrentScope().then((s: string) => { - expect(s).toEqual(scopeString); + page.navigateToSearch() + .then(() => page.getRandomScopeOption()) + .then((scopeString: string) => { + page.navigateToSearchWithScopeParameter(scopeString); + page.getCurrentScope().then((s: string) => { + expect(s).toEqual(scopeString); + }); }); - }); }); it('should redirect to the correct url when scope was set and submit button was triggered', () => { - const scope: promise.Promise = page.getRandomScopeOption(); - scope.then((scopeString: string) => { - page.setCurrentScope(scopeString); - page.submitSearchForm(); - browser.wait(() => { - return browser.getCurrentUrl().then((url: string) => { - return url.indexOf('scope=' + encodeURI(scopeString)) !== -1; + page.navigateToSearch() + .then(() => page.getRandomScopeOption()) + .then((scopeString: string) => { + page.setCurrentScope(scopeString); + page.submitSearchForm(); + browser.wait(() => { + return browser.getCurrentUrl().then((url: string) => { + return url.indexOf('scope=' + encodeURI(scopeString)) !== -1; + }); }); }); - }); }); it('should redirect to the correct url when query was set and submit button was triggered', () => { diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 913ee4da20..fcfa4a4861 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -608,6 +608,8 @@ "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", + "error.validation.filerequired": "The file upload is mandatory", + "footer.copyright": "copyright © 2002-{{ year }}", @@ -1579,6 +1581,8 @@ "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isVolumeOf": "Journal Volumes", + + "relationships.isContributorOf": "Contributors", @@ -2089,11 +2093,16 @@ "uploader.drag-message": "Drag & Drop your files here", - "uploader.or": ", or ", + "uploader.or": ", or", "uploader.processing": "Processing", "uploader.queue-length": "Queue length", + "virtual-metadata.delete-item.info": "Select the types for which you want to save the virtual metadata as real metadata", + + "virtual-metadata.delete-item.modal-head": "The virtual metadata of this relation", + + "virtual-metadata.delete-relationship.modal-head": "Select the items for which you want to save the virtual metadata as real metadata", } 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 1d379cccf8..880fd93ec9 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 @@ -28,6 +28,7 @@ import { BundleDataService } from '../../core/data/bundle-data.service'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; +import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -64,7 +65,8 @@ import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/it EditRelationshipListComponent, ItemCollectionMapperComponent, ItemMoveComponent, - ItemEditBitstreamDragHandleComponent + ItemEditBitstreamDragHandleComponent, + VirtualMetadataComponent, ], providers: [ BundleDataService diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html new file mode 100644 index 0000000000..5e7297409b --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html @@ -0,0 +1,98 @@ +
+
+
+ +

{{headerMessage | translate: {id: item.handle} }}

+

{{descriptionMessage | translate}}

+ + + + +
+ + {{'virtual-metadata.delete-item.info' | translate}} + +
+ +
+ +
+ +
+ +
+
+ {{getRelationshipMessageKey(getLabel(type) | async) | translate}} +
+
+ + + + +
+
+ +
+
+ + +
+ + +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ + + + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts index 82d03f1f1b..00ae038dae 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -1,44 +1,132 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { Item } from '../../../core/shared/item.model'; -import { RouterStub } from '../../../shared/testing/router-stub'; -import { of as observableOf } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { RouterTestingModule } from '@angular/router/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { ItemDeleteComponent } from './item-delete.component'; -import { getItemEditPath } from '../../item-page-routing.module'; -import { RestResponse } from '../../../core/cache/response.models'; -import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import {Item} from '../../../core/shared/item.model'; +import {RouterStub} from '../../../shared/testing/router-stub'; +import {of as observableOf} from 'rxjs'; +import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {RouterTestingModule} from '@angular/router/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {ItemDeleteComponent} from './item-delete.component'; +import {getItemEditPath} from '../../item-page-routing.module'; +import {createSuccessfulRemoteDataObject} from '../../../shared/testing/utils'; +import {VarDirective} from '../../../shared/utils/var.directive'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; +import {RemoteData} from '../../../core/data/remote-data'; +import {PaginatedList} from '../../../core/data/paginated-list'; +import {PageInfo} from '../../../core/shared/page-info.model'; +import {EntityTypeService} from '../../../core/data/entity-type.service'; let comp: ItemDeleteComponent; let fixture: ComponentFixture; let mockItem; +let itemType; +let type1; +let type2; +let types; +let relationships; let itemPageUrl; let routerStub; let mockItemDataService: ItemDataService; let routeStub; +let objectUpdatesServiceStub; +let relationshipService; +let entityTypeService; let notificationsServiceStub; +let typesSelection; describe('ItemDeleteComponent', () => { beforeEach(async(() => { mockItem = Object.assign(new Item(), { id: 'fake-id', + uuid: 'fake-uuid', handle: 'fake/handle', lastModified: '2018', isWithdrawn: true }); + itemType = Object.assign(new ItemType(), { + id: 'itemType', + uuid: 'itemType', + }); + + type1 = Object.assign(new RelationshipType(), { + id: '1', + uuid: 'type-1', + }); + + type2 = Object.assign(new RelationshipType(), { + id: '2', + uuid: 'type-2', + }); + + types = [type1, type2]; + + relationships = [ + Object.assign(new Relationship(), { + id: '1', + uuid: 'relationship-1', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + null, + type1 + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + null, + mockItem, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + null, + Object.assign(new Item(), {}) + )), + }), + Object.assign(new Relationship(), { + id: '2', + uuid: 'relationship-2', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + null, + type2 + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + null, + mockItem, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + null, + Object.assign(new Item(), {}) + )), + }), + ]; + itemPageUrl = `fake-url/${mockItem.id}`; routerStub = Object.assign(new RouterStub(), { url: `${itemPageUrl}/edit` @@ -54,16 +142,56 @@ describe('ItemDeleteComponent', () => { }) }; + typesSelection = { + type1: false, + type2: true, + }; + + entityTypeService = jasmine.createSpyObj('entityTypeService', + { + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + itemType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), types), + )), + } + ); + + objectUpdatesServiceStub = { + initialize: () => { + // do nothing + }, + isSelectedVirtualMetadata: (type) => observableOf(typesSelection[type]), + }; + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getItemRelationshipsArray: observableOf(relationships), + } + ); + notificationsServiceStub = new NotificationsServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [ItemDeleteComponent], + declarations: [ItemDeleteComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: routeStub }, { provide: Router, useValue: routerStub }, { provide: ItemDataService, useValue: mockItemDataService }, { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: ObjectUpdatesService, useValue: objectUpdatesServiceStub }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: EntityTypeService, useValue: entityTypeService }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -91,7 +219,8 @@ describe('ItemDeleteComponent', () => { it('should call delete function from the ItemDataService', () => { spyOn(comp, 'notify'); comp.performAction(); - expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem); + expect(mockItemDataService.delete) + .toHaveBeenCalledWith(mockItem, types.filter((type) => typesSelection[type]).map((type) => type.id)); expect(comp.notify).toHaveBeenCalled(); }); }); diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts index 2700b45475..6fe44c109b 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -1,29 +1,323 @@ -import { Component } from '@angular/core'; -import { first } from 'rxjs/operators'; -import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; -import { getItemEditPath } from '../../item-page-routing.module'; -import { RestResponse } from '../../../core/cache/response.models'; +import {Component, Input, OnInit} from '@angular/core'; +import {filter, first, map, switchMap, take} from 'rxjs/operators'; +import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; +import {getItemEditPath} from '../../item-page-routing.module'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {combineLatest as observableCombineLatest, combineLatest, Observable} from 'rxjs'; +import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; +import {VirtualMetadata} from '../virtual-metadata/virtual-metadata.component'; +import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators'; +import {hasValue, isNotEmpty} from '../../../shared/empty.util'; +import {Item} from '../../../core/shared/item.model'; +import {MetadataValue} from '../../../core/shared/metadata.models'; +import {ViewMode} from '../../../core/shared/view-mode.model'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {TranslateService} from '@ngx-translate/core'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {EntityTypeService} from '../../../core/data/entity-type.service'; @Component({ selector: 'ds-item-delete', - templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' + templateUrl: '../item-delete/item-delete.component.html' }) /** * Component responsible for rendering the item delete page */ -export class ItemDeleteComponent extends AbstractSimpleItemActionComponent { +export class ItemDeleteComponent + extends AbstractSimpleItemActionComponent + implements OnInit { + + /** + * The current url of this page + */ + @Input() url: string; protected messageKey = 'delete'; /** - * Perform the delete action to the item + * The view-mode we're currently on + */ + viewMode = ViewMode.ListElement; + + /** + * A list of the relationship types for which this item has relations as an observable. + * The list doesn't contain duplicates. + */ + types$: Observable; + + /** + * A map which stores the relationships of this item for each type as observable lists + */ + relationships$: Map> + = new Map>(); + + /** + * A map which stores the related item of each relationship of this item as an observable + */ + relatedItems$: Map> = new Map>(); + + /** + * A map which stores the virtual metadata (of the related) item corresponding to each relationship of this item + * as an observable list + */ + virtualMetadata$: Map> = new Map>(); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected notificationsService: NotificationsService, + protected itemDataService: ItemDataService, + protected translateService: TranslateService, + protected modalService: NgbModal, + protected objectUpdatesService: ObjectUpdatesService, + protected relationshipService: RelationshipService, + protected entityTypeService: EntityTypeService, + ) { + super( + route, + router, + notificationsService, + itemDataService, + translateService, + ); + } + + /** + * Set up and initialize all fields + */ + ngOnInit() { + + super.ngOnInit(); + this.url = this.router.url; + + this.types$ = this.entityTypeService.getEntityTypeByLabel( + this.item.firstMetadataValue('relationship.type') + ).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)), + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + switchMap((types) => + combineLatest(types.map((type) => this.getRelationships(type))).pipe( + map((relationships) => + types.reduce((includedTypes, type, index) => { + if (!includedTypes.some((includedType) => includedType.id === type.id) + && !(relationships[index].length === 0)) { + return [...includedTypes, type]; + } else { + return includedTypes; + } + }, []) + ), + ) + ), + ); + + this.types$.pipe( + take(1), + ).subscribe((types) => + this.objectUpdatesService.initialize(this.url, types, this.item.lastModified) + ); + } + + /** + * Open the modal which lists the virtual metadata of a relation + * @param content the html content of the modal + */ + openVirtualMetadataModal(content: any) { + this.modalRef = this.modalService.open(content); + } + + /** + * Close the modal which lists the virtual metadata of a relation + */ + closeVirtualMetadataModal() { + this.modalRef.close(); + } + + /** + * Get the i18n message key for a relationship + * @param label The relationship type's label + */ + getRelationshipMessageKey(label: string): string { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + } + + /** + * Get the relationship type label relevant for this item as an observable + * @param relationshipType the relationship type to get the label for + */ + getLabel(relationshipType: RelationshipType): Observable { + + return this.getRelationships(relationshipType).pipe( + switchMap((relationships) => + this.isLeftItem(relationships[0]).pipe( + map((isLeftItem) => isLeftItem ? relationshipType.leftwardType : relationshipType.rightwardType), + ) + ), + ) + } + + /** + * Get the relationships of this item with a given type as an observable + * @param relationshipType the relationship type to filter the item's relationships on + */ + getRelationships(relationshipType: RelationshipType): Observable { + + if (!this.relationships$.has(relationshipType)) { + this.relationships$.set( + relationshipType, + this.relationshipService.getItemRelationshipsArray(this.item).pipe( + // filter on type + switchMap((relationships) => + observableCombineLatest( + relationships.map((relationship) => this.getRelationshipType(relationship)) + ).pipe( + map((types) => relationships.filter( + (relationship, index) => relationshipType.id === types[index].id + )), + ) + ), + ) + ); + } + + return this.relationships$.get(relationshipType); + } + + /** + * Get the type of a given relationship as an observable + * @param relationship the relationship to get the type for + */ + private getRelationshipType(relationship: Relationship): Observable { + + return relationship.relationshipType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((relationshipType: RelationshipType) => hasValue(relationshipType) && isNotEmpty(relationshipType.uuid)) + ); + } + + /** + * Get the item this item is related to through a given relationship as an observable + * @param relationship the relationship to get the other item for + */ + getRelatedItem(relationship: Relationship): Observable { + + if (!this.relatedItems$.has(relationship)) { + + this.relatedItems$.set( + relationship, + this.isLeftItem(relationship).pipe( + switchMap((isLeftItem) => isLeftItem ? relationship.rightItem : relationship.leftItem), + getSucceededRemoteData(), + getRemoteDataPayload(), + ), + ); + } + + return this.relatedItems$.get(relationship); + } + + /** + * Get the virtual metadata for a given relationship of the related item. + * @param relationship the relationship to get the virtual metadata for + */ + getVirtualMetadata(relationship: Relationship): Observable { + + if (!this.virtualMetadata$.has(relationship)) { + + this.virtualMetadata$.set( + relationship, + this.getRelatedItem(relationship).pipe( + map((relatedItem) => + Object.entries(relatedItem.metadata) + .map(([key, value]) => value + .filter((metadata: MetadataValue) => + metadata.authority && metadata.authority.endsWith(relationship.id)) + .map((metadata: MetadataValue) => { + return { + metadataField: key, + metadataValue: metadata, + } + })) + .reduce((previous, current) => previous.concat(current)) + ), + ) + ); + } + + return this.virtualMetadata$.get(relationship); + } + + /** + * Check whether this item is the left item of a given relationship, as an observable boolean + * @param relationship the relationship for which to check whether this item is the left item + */ + private isLeftItem(relationship: Relationship): Observable { + + return relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)), + map((leftItem) => leftItem.uuid === this.item.uuid) + ); + } + + /** + * Check whether a given relationship type is selected to save the corresponding virtual metadata + * @param type the relationship type for which to check whether it is selected + */ + isSelected(type: RelationshipType): Observable { + return this.objectUpdatesService.isSelectedVirtualMetadata(this.url, this.item.uuid, type.uuid); + } + + /** + * Select/deselect a given relationship type to save the corresponding virtual metadata + * @param type the relationship type to select/deselect + * @param selected whether the type should be selected + */ + setSelected(type: RelationshipType, selected: boolean): void { + this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.item.uuid, type.uuid, selected); + } + + /** + * Perform the delete operation */ performAction() { - this.itemDataService.delete(this.item).pipe(first()).subscribe( - (succeeded: boolean) => { - this.notify(succeeded); - } - ); + + this.types$.pipe( + switchMap((types) => + combineLatest( + types.map((type) => this.isSelected(type)) + ).pipe( + map((selection) => types.filter( + (type, index) => selection[index] + )), + map((selectedTypes) => selectedTypes.map((type) => type.id)), + ) + ), + ).subscribe((types) => { + this.itemDataService.delete(this.item, types).pipe(first()).subscribe( + (succeeded: boolean) => { + this.notify(succeeded); + } + ); + }); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html index ba5164e81a..1a7cc2e2df 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -1,15 +1,15 @@ - -
-
{{getRelationshipMessageKey(relationshipLabel) | translate}}
- -
-
- +
{{getRelationshipMessageKey() | async | translate}}
+ + + + + + -
+
no relationships
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 54cb2837a2..cede48e6ee 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -1,27 +1,26 @@ -import { EditRelationshipListComponent } from './edit-relationship-list.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../../core/shared/resource-type'; -import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; -import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Item } from '../../../../core/shared/item.model'; -import { PaginatedList } from '../../../../core/data/paginated-list'; -import { PageInfo } from '../../../../core/shared/page-info.model'; -import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; -import { SharedModule } from '../../../../shared/shared.module'; -import { TranslateModule } from '@ngx-translate/core'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { RelationshipService } from '../../../../core/data/relationship.service'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; +import {EditRelationshipListComponent} from './edit-relationship-list.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; +import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; +import {of as observableOf} from 'rxjs/internal/observable/of'; +import {RemoteData} from '../../../../core/data/remote-data'; +import {Item} from '../../../../core/shared/item.model'; +import {PaginatedList} from '../../../../core/data/paginated-list'; +import {PageInfo} from '../../../../core/shared/page-info.model'; +import {FieldChangeType} from '../../../../core/data/object-updates/object-updates.actions'; +import {SharedModule} from '../../../../shared/shared.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {ObjectUpdatesService} from '../../../../core/data/object-updates/object-updates.service'; +import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {ItemType} from '../../../../core/shared/item-relationships/item-type.model'; let comp: EditRelationshipListComponent; let fixture: ComponentFixture; let de: DebugElement; let objectUpdatesService; -let relationshipService; +let entityTypeService; const url = 'http://test-url.com/test-url'; @@ -30,42 +29,66 @@ let author1; let author2; let fieldUpdate1; let fieldUpdate2; -let relationships; +let relationship1; +let relationship2; let relationshipType; +let entityType; +let relatedEntityType; describe('EditRelationshipListComponent', () => { - beforeEach(async(() => { + + beforeEach(() => { + + entityType = Object.assign(new ItemType(), { + id: 'entityType', + }); + + relatedEntityType = Object.assign(new ItemType(), { + id: 'relatedEntityType', + }); + relationshipType = Object.assign(new RelationshipType(), { id: '1', uuid: '1', leftwardType: 'isAuthorOfPublication', - rightwardType: 'isPublicationOfAuthor' + rightwardType: 'isPublicationOfAuthor', + leftType: observableOf(new RemoteData(false, false, true, undefined, entityType)), + rightType: observableOf(new RemoteData(false, false, true, undefined, relatedEntityType)), }); - relationships = [ - Object.assign(new Relationship(), { - self: url + '/2', - id: '2', - uuid: '2', - leftId: 'author1', - rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }), - Object.assign(new Relationship(), { - self: url + '/3', - id: '3', - uuid: '3', - leftId: 'author2', - rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }) - ]; + relationship1 = Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, author1)), + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + relationship2 = Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, author2)), + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); item = Object.assign(new Item(), { self: 'fake-item-url/publication', id: 'publication', uuid: 'publication', - relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + relationships: observableOf(new RemoteData( + false, + false, + true, + undefined, + new PaginatedList(new PageInfo(), [relationship1, relationship2]) + )) }); author1 = Object.assign(new Item(), { @@ -88,16 +111,29 @@ describe('EditRelationshipListComponent', () => { objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { - getFieldUpdatesExclusive: observableOf({ + getFieldUpdates: observableOf({ [author1.uuid]: fieldUpdate1, [author2.uuid]: fieldUpdate2 }) } ); - relationshipService = jasmine.createSpyObj('relationshipService', + entityTypeService = jasmine.createSpyObj('entityTypeService', { - getRelatedItemsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [author1, author2]))), + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + entityType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), [relationshipType]), + )), } ); @@ -106,29 +142,27 @@ describe('EditRelationshipListComponent', () => { declarations: [EditRelationshipListComponent], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: RelationshipService, useValue: relationshipService } ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(EditRelationshipListComponent); comp = fixture.componentInstance; de = fixture.debugElement; + comp.item = item; + comp.itemType = entityType; comp.url = url; - comp.relationshipLabel = relationshipType.leftwardType; + comp.relationshipType = relationshipType; + fixture.detectChanges(); }); describe('changeType is REMOVE', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); it('the div should have class alert-danger', () => { + + fieldUpdate1.changeType = FieldChangeType.REMOVE; const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; expect(element.classList).toContain('alert-danger'); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 3a145c99e0..73e3e1f875 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -1,13 +1,15 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { Observable } from 'rxjs/internal/Observable'; -import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer'; -import { RelationshipService } from '../../../../core/data/relationship.service'; -import { Item } from '../../../../core/shared/item.model'; -import { map, switchMap } from 'rxjs/operators'; -import { hasValue } from '../../../../shared/empty.util'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { PaginatedList } from '../../../../core/data/paginated-list'; +import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer'; +import {Item} from '../../../../core/shared/item.model'; +import {map, switchMap} from 'rxjs/operators'; +import {hasValue} from '../../../../shared/empty.util'; +import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; +import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators'; +import {combineLatest as observableCombineLatest, combineLatest} from 'rxjs'; +import {ItemType} from '../../../../core/shared/item-relationships/item-type.model'; @Component({ selector: 'ds-edit-relationship-list', @@ -18,12 +20,15 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; * A component creating a list of editable relationships of a certain type * The relationships are rendered as a list of related items */ -export class EditRelationshipListComponent implements OnInit, OnChanges { +export class EditRelationshipListComponent implements OnInit { + /** * The item to display related items for */ @Input() item: Item; + @Input() itemType: ItemType; + /** * The URL to the current page * Used to fetch updates for the current item from the store @@ -33,7 +38,7 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { /** * The label of the relationship-type we're rendering a list for */ - @Input() relationshipLabel: string; + @Input() relationshipType: RelationshipType; /** * The FieldUpdates for the relationships in question @@ -42,53 +47,42 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { constructor( protected objectUpdatesService: ObjectUpdatesService, - protected relationshipService: RelationshipService ) { } - ngOnInit(): void { - this.initUpdates(); - } + /** + * Get the i18n message key for this relationship type + */ + public getRelationshipMessageKey(): Observable { - ngOnChanges(changes: SimpleChanges): void { - this.initUpdates(); + return this.getLabel().pipe( + map((label) => { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + }), + ); } /** - * Initialize the FieldUpdates using the related items + * Get the relevant label for this relationship type */ - initUpdates() { - this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); - } + private getLabel(): Observable { - /** - * Transform the item's relationships of a specific type into related items - * @param label The relationship type's label - */ - public getRelatedItemsByLabel(label: string): Observable>> { - return this.relationshipService.getRelatedItemsByLabel(this.item, label); - } - - /** - * Get FieldUpdates for the relationships of a specific type - * @param label The relationship type's label - */ - public getUpdatesByLabel(label: string): Observable { - return this.getRelatedItemsByLabel(label).pipe( - switchMap((itemsRD) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, itemsRD.payload.page)) - ) - } - - /** - * Get the i18n message key for a relationship - * @param label The relationship type's label - */ - public getRelationshipMessageKey(label: string): string { - if (hasValue(label) && label.indexOf('Of') > -1) { - return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` - } else { - return label; - } + return combineLatest([ + this.relationshipType.leftType, + this.relationshipType.rightType, + ].map((itemTypeRD) => itemTypeRD.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ))).pipe( + map((itemTypes) => [ + this.relationshipType.leftwardType, + this.relationshipType.rightwardType, + ][itemTypes.findIndex((itemType) => itemType.id === this.itemType.id)]), + ); } /** @@ -98,4 +92,26 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { return update && update.field ? update.field.uuid : undefined; } + ngOnInit(): void { + this.updates$ = this.item.relationships.pipe( + map((relationships) => relationships.payload.page.filter((relationship) => relationship)), + switchMap((itemRelationships) => + observableCombineLatest( + itemRelationships + .map((relationship) => relationship.relationshipType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + )) + ).pipe( + map((relationshipTypes) => itemRelationships.filter( + (relationship, index) => relationshipTypes[index].id === this.relationshipType.id) + ), + map((relationships) => relationships.map((relationship) => + Object.assign(new Relationship(), relationship, {uuid: relationship.id}) + )), + ) + ), + switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields)), + ); + } } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html index 03040ce8e0..7e61e8958f 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -1,10 +1,10 @@ -
+
- +
-
+ + + + diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts index 54fce0a68e..b3c3e773b2 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -11,11 +11,13 @@ import { Item } from '../../../../core/shared/item.model'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -let objectUpdatesService: ObjectUpdatesService; +let objectUpdatesService; const url = 'http://test-url.com/test-url'; let item; +let relatedItem; let author1; let author2; let fieldUpdate1; @@ -29,7 +31,9 @@ let de; let el; describe('EditRelationshipComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { id: '1', uuid: '1', @@ -37,6 +41,17 @@ describe('EditRelationshipComponent', () => { rightwardType: 'isPublicationOfAuthor' }); + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + relatedItem = Object.assign(new Item(), { + uuid: 'related item id', + }); + relationships = [ Object.assign(new Relationship(), { self: url + '/2', @@ -44,7 +59,9 @@ describe('EditRelationshipComponent', () => { uuid: '2', leftId: 'author1', rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)), + leftItem: observableOf(new RemoteData(false, false, true, undefined, relatedItem)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, item)), }), Object.assign(new Relationship(), { self: url + '/3', @@ -56,13 +73,6 @@ describe('EditRelationshipComponent', () => { }) ]; - item = Object.assign(new Item(), { - self: 'fake-item-url/publication', - id: 'publication', - uuid: 'publication', - relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) - }); - author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -73,38 +83,44 @@ describe('EditRelationshipComponent', () => { }); fieldUpdate1 = { - field: author1, + field: relationships[0], changeType: undefined }; fieldUpdate2 = { - field: author2, + field: relationships[1], changeType: FieldChangeType.REMOVE }; - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - saveChangeFieldUpdate: {}, - saveRemoveFieldUpdate: {}, - setEditableFieldUpdate: {}, - setValidFieldUpdate: {}, - removeSingleFieldUpdate: {}, - isEditable: observableOf(false), // should always return something --> its in ngOnInit - isValid: observableOf(true) // should always return something --> its in ngOnInit - } - ); + const itemSelection = {}; + itemSelection[relatedItem.uuid] = false; + itemSelection[item.uuid] = true; + + objectUpdatesService = { + isSelectedVirtualMetadata: () => null, + removeSingleFieldUpdate: jasmine.createSpy('removeSingleFieldUpdate'), + saveRemoveFieldUpdate: jasmine.createSpy('saveRemoveFieldUpdate'), + }; + + spyOn(objectUpdatesService, 'isSelectedVirtualMetadata').and.callFake((a, b, uuid) => observableOf(itemSelection[uuid])); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [EditRelationshipComponent], providers: [ - { provide: ObjectUpdatesService, useValue: objectUpdatesService } - ], schemas: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: NgbModal, useValue: { + open: () => {/*comment*/ + } + }, + }, + ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); })); beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipComponent); comp = fixture.componentInstance; de = fixture.debugElement; @@ -112,7 +128,8 @@ describe('EditRelationshipComponent', () => { comp.url = url; comp.fieldUpdate = fieldUpdate1; - comp.item = item; + comp.editItem = item; + comp.relatedItem$ = observableOf(relatedItem); fixture.detectChanges(); }); @@ -156,23 +173,30 @@ describe('EditRelationshipComponent', () => { }); describe('remove', () => { + beforeEach(() => { + spyOn(comp, 'closeVirtualMetadataModal'); + comp.ngOnChanges(); comp.remove(); }); - it('should call saveRemoveFieldUpdate with the correct arguments', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); + it('should close the virtual metadata modal and call saveRemoveFieldUpdate with the correct arguments', () => { + expect(comp.closeVirtualMetadataModal).toHaveBeenCalled(); + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith( + url, + Object.assign({}, fieldUpdate1.field, { + keepLeftVirtualMetadata: false, + keepRightVirtualMetadata: true, + }), + ); }); }); describe('undo', () => { - beforeEach(() => { - comp.undo(); - }); it('should call removeSingleFieldUpdate with the correct arguments', () => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid); + comp.undo(); + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, relationships[0].uuid); }); }); - }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index ee9d2cda27..d1ee99c3a7 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -1,14 +1,19 @@ -import { Component, Input, OnChanges } from '@angular/core'; -import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; -import { cloneDeep } from 'lodash'; -import { Item } from '../../../../core/shared/item.model'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { DeleteRelationship, FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { Item } from '../../../../core/shared/item.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @Component({ // tslint:disable-next-line:component-selector - selector: '[ds-edit-relationship]', + selector: 'ds-edit-relationship', styleUrls: ['./edit-relationship.component.scss'], templateUrl: './edit-relationship.component.html', }) @@ -23,38 +28,108 @@ export class EditRelationshipComponent implements OnChanges { */ @Input() url: string; + /** + * The item being edited + */ + @Input() editItem: Item; + + /** + * The relationship being edited + */ + get relationship(): Relationship { + return this.fieldUpdate.field as Relationship; + } + + private leftItem$: Observable; + private rightItem$: Observable; + /** * The related item of this relationship */ - item: Item; + relatedItem$: Observable; /** * The view-mode we're currently on */ viewMode = ViewMode.ListElement; - constructor(private objectUpdatesService: ObjectUpdatesService) { + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + constructor( + private objectUpdatesService: ObjectUpdatesService, + private modalService: NgbModal, + ) { } /** * Sets the current relationship based on the fieldUpdate input field */ ngOnChanges(): void { - this.item = cloneDeep(this.fieldUpdate.field) as Item; + this.leftItem$ = this.relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.rightItem$ = this.relationship.rightItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.relatedItem$ = observableCombineLatest( + this.leftItem$, + this.rightItem$, + ).pipe( + map((items: Item[]) => + items.find((item) => item.uuid !== this.editItem.uuid) + ) + ); } /** * Sends a new remove update for this field to the object updates service */ remove(): void { - this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); + this.closeVirtualMetadataModal(); + observableCombineLatest( + this.leftItem$, + this.rightItem$, + ).pipe( + map((items: Item[]) => + items.map((item) => this.objectUpdatesService + .isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid)) + ), + switchMap((selection$) => observableCombineLatest(selection$)), + map((selection: boolean[]) => { + return Object.assign({}, + this.fieldUpdate.field, + { + keepLeftVirtualMetadata: selection[0] === true, + keepRightVirtualMetadata: selection[1] === true, + } + ) as DeleteRelationship + }), + take(1), + ).subscribe((deleteRelationship: DeleteRelationship) => + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship) + ); + } + + openVirtualMetadataModal(content: any) { + this.modalRef = this.modalService.open(content); + } + + closeVirtualMetadataModal() { + this.modalRef.close(); } /** * Cancels the current update for this field in the object updates service */ undo(): void { - this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.fieldUpdate.field.uuid); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html index 4bd0b3df2c..384a469f24 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -17,8 +17,13 @@  {{"item.edit.metadata.save-button" | translate}}
-
- +
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 37745ec96a..aa812354b6 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -13,9 +13,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { GLOBAL_CONFIG } from '../../../../config'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../core/shared/resource-type'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; -import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -26,6 +25,8 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { getTestScheduler } from 'jasmine-marbles'; import { RestResponse } from '../../../core/cache/response.models'; import { RequestService } from '../../../core/data/request.service'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; let comp: any; let fixture: ComponentFixture; @@ -34,6 +35,7 @@ let el: HTMLElement; let objectUpdatesService; let relationshipService; let requestService; +let entityTypeService; let objectCache; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -58,6 +60,7 @@ let author1; let author2; let fieldUpdate1; let fieldUpdate2; +let entityType; let relationships; let relationshipType; @@ -95,6 +98,10 @@ describe('ItemRelationshipsComponent', () => { lastModified: date }); + entityType = Object.assign(new ItemType(), { + id: 'entityType', + }); + author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -110,11 +117,14 @@ describe('ItemRelationshipsComponent', () => { relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); fieldUpdate1 = { - field: author1, + field: relationships[0], changeType: undefined }; fieldUpdate2 = { - field: author2, + field: Object.assign( + relationships[1], + {keepLeftVirtualMetadata: true, keepRightVirtualMetadata: false} + ), changeType: FieldChangeType.REMOVE }; @@ -130,12 +140,12 @@ describe('ItemRelationshipsComponent', () => { objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { getFieldUpdates: observableOf({ - [author1.uuid]: fieldUpdate1, - [author2.uuid]: fieldUpdate2 + [relationships[0].uuid]: fieldUpdate1, + [relationships[1].uuid]: fieldUpdate2 }), getFieldUpdatesExclusive: observableOf({ - [author1.uuid]: fieldUpdate1, - [author2.uuid]: fieldUpdate2 + [relationships[0].uuid]: fieldUpdate1, + [relationships[1].uuid]: fieldUpdate2 }), saveAddFieldUpdate: {}, discardFieldUpdates: {}, @@ -173,6 +183,25 @@ describe('ItemRelationshipsComponent', () => { remove: undefined }); + entityTypeService = jasmine.createSpyObj('entityTypeService', + { + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + entityType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), [relationshipType]), + )), + } + ); + scheduler = getTestScheduler(); TestBed.configureTestingModule({ imports: [SharedModule, TranslateModule.forRoot()], @@ -185,6 +214,7 @@ describe('ItemRelationshipsComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, { provide: RelationshipService, useValue: relationshipService }, + { provide: EntityTypeService, useValue: entityTypeService }, { provide: ObjectCacheService, useValue: objectCache }, { provide: RequestService, useValue: requestService }, ChangeDetectorRef @@ -229,7 +259,7 @@ describe('ItemRelationshipsComponent', () => { }); it('it should delete the correct relationship', () => { - expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left'); }); }); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index c5c9730c5f..0374e83782 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; -import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { DeleteRelationship, FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; -import { filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { zip as observableZip } from 'rxjs'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -12,15 +12,18 @@ import { NotificationsService } from '../../../shared/notifications/notification import { TranslateService } from '@ngx-translate/core'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; import { RelationshipService } from '../../../core/data/relationship.service'; -import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; -import { isNotEmptyOperator } from '../../../shared/empty.util'; import { RemoteData } from '../../../core/data/remote-data'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; import { Subscription } from 'rxjs/internal/Subscription'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { isNotEmptyOperator } from '../../../shared/empty.util'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; @Component({ selector: 'ds-item-relationships', @@ -35,13 +38,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl /** * The labels of all different relations within this item */ - relationLabels$: Observable; + relationshipTypes$: Observable; /** * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request * This is used to update the item in cache after relationships are deleted */ itemUpdateSubscription: Subscription; + entityType$: Observable; constructor( public itemService: ItemDataService, @@ -54,7 +58,8 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl public relationshipService: RelationshipService, public objectCache: ObjectCacheService, public requestService: RequestService, - public cdRef: ChangeDetectorRef + public entityTypeService: EntityTypeService, + public cdr: ChangeDetectorRef, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } @@ -64,21 +69,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl */ ngOnInit(): void { super.ngOnInit(); - this.relationLabels$ = this.relationshipService.getRelationshipTypeLabelsByItem(this.item); - this.initializeItemUpdate(); - } - - /** - * Update the item (and view) when it's removed in the request cache - */ - public initializeItemUpdate(): void { this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( filter((exists: boolean) => !exists), switchMap(() => this.itemService.findById(this.item.uuid)), getSucceededRemoteData(), ).subscribe((itemRD: RemoteData) => { this.item = itemRD.payload; - this.cdRef.detectChanges(); + this.cdr.detectChanges(); + this.initializeUpdates(); }); } @@ -86,8 +84,22 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl * Initialize the values and updates of the current item's relationship fields */ public initializeUpdates(): void { - this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe( - switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items)) + + this.entityType$ = this.entityTypeService.getEntityTypeByLabel( + this.item.firstMetadataValue('relationship.type') + ).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + + this.relationshipTypes$ = this.entityType$.pipe( + switchMap((entityType) => + this.entityTypeService.getEntityTypeRelationships(entityType.id).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + ) + ), ); } @@ -103,26 +115,41 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl * Make sure the lists are refreshed afterwards and notifications are sent for success and errors */ public submit(): void { - // Get all IDs of related items of which their relationship with the current item is about to be removed - const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe( - switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable), - map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), - map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]), - isNotEmptyOperator() - ); // Get all the relationships that should be removed - const removedRelationships$ = removedItemIds$.pipe( - flatMap((uuids) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids)) - ); - // const removedRelationships$ = removedItemIds$.pipe(flatMap((uuids: string[]) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids))); - // Request a delete for every relationship found in the observable created above - removedRelationships$.pipe( + this.relationshipService.getItemRelationshipsArray(this.item).pipe( + map((relationships: Relationship[]) => relationships.map((relationship) => + Object.assign(new Relationship(), relationship, {uuid: relationship.id}) + )), + switchMap((relationships: Relationship[]) => { + return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable + }), + map((fieldUpdates: FieldUpdates) => + Object.values(fieldUpdates) + .filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE) + .map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship) + ), + isNotEmptyOperator(), take(1), - map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), - switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) + switchMap((deleteRelationships: DeleteRelationship[]) => + observableZip(...deleteRelationships.map((deleteRelationship) => { + let copyVirtualMetadata: string; + if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) { + copyVirtualMetadata = 'all'; + } else if (deleteRelationship.keepLeftVirtualMetadata) { + copyVirtualMetadata = 'left'; + } else if (deleteRelationship.keepRightVirtualMetadata) { + copyVirtualMetadata = 'right'; + } else { + copyVirtualMetadata = 'none'; + } + return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata); + } + )) + ), ).subscribe((responses: RestResponse[]) => { - this.displayNotifications(responses); - this.reset(); + this.itemUpdateSubscription.add(() => { + this.displayNotifications(responses); + }); }); } @@ -144,22 +171,12 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl } } - /** - * Re-initialize fields and subscriptions - */ - reset() { - this.initializeOriginalFields(); - this.initializeUpdates(); - this.initializeItemUpdate(); - } - /** * Sends all initial values of this item to the object updates service */ public initializeOriginalFields() { - this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => { - this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); - }); + const initialFields = []; + this.objectUpdatesService.initialize(this.url, initialFields, this.item.lastModified); } /** @@ -168,5 +185,4 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl ngOnDestroy(): void { this.itemUpdateSubscription.unsubscribe(); } - } diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html new file mode 100644 index 0000000000..c103d83c71 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html @@ -0,0 +1,38 @@ +
+ + +
diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts new file mode 100644 index 0000000000..f2732d081a --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts @@ -0,0 +1,102 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {of as observableOf} from 'rxjs/internal/observable/of'; +import {TranslateModule} from '@ngx-translate/core'; +import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {VirtualMetadataComponent} from './virtual-metadata.component'; +import {Item} from '../../../core/shared/item.model'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {VarDirective} from '../../../shared/utils/var.directive'; + +describe('VirtualMetadataComponent', () => { + + let comp: VirtualMetadataComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + let objectUpdatesService; + + const url = 'http://test-url.com/test-url'; + + let item; + let relatedItem; + let relationshipId; + + beforeEach(() => { + + relationshipId = 'relationship id'; + + item = Object.assign(new Item(), { + uuid: 'publication', + metadata: [], + }); + + relatedItem = Object.assign(new Item(), { + uuid: 'relatedItem', + metadata: [], + }); + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + isSelectedVirtualMetadata: observableOf(false), + setSelectedVirtualMetadata: null, + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [VirtualMetadataComponent, VarDirective], + providers: [ + {provide: ObjectUpdatesService, useValue: objectUpdatesService}, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + + fixture = TestBed.createComponent(VirtualMetadataComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + + comp.url = url; + comp.leftItem = item; + comp.rightItem = relatedItem; + comp.relationshipId = relationshipId; + + fixture.detectChanges(); + }); + + describe('when clicking the save button', () => { + it('should emit a save event', () => { + + spyOn(comp.save, 'emit'); + fixture.debugElement + .query(By.css('button.save')) + .triggerEventHandler('click', null); + expect(comp.save.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the close button', () => { + it('should emit a close event', () => { + + spyOn(comp.close, 'emit'); + fixture.debugElement + .query(By.css('button.close')) + .triggerEventHandler('click', null); + expect(comp.close.emit).toHaveBeenCalled(); + }); + }); + + describe('when selecting an item', () => { + it('should call the updates service setSelectedVirtualMetadata method', () => { + + fixture.debugElement + .query(By.css('div.item')) + .triggerEventHandler('click', null); + expect(objectUpdatesService.setSelectedVirtualMetadata).toHaveBeenCalledWith( + url, + relationshipId, + item.uuid, + true + ); + }); + }) +}); diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts new file mode 100644 index 0000000000..cac46724f0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts @@ -0,0 +1,120 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {Observable} from 'rxjs'; +import {Item} from '../../../core/shared/item.model'; +import {MetadataValue} from '../../../core/shared/metadata.models'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; + +@Component({ + selector: 'ds-virtual-metadata', + templateUrl: './virtual-metadata.component.html' +}) +/** + * Component that lists both items of a relationship, along with their virtual metadata of the relationship. + * The component is shown when a relationship is marked to be deleted. + * Each item has a checkbox to indicate whether its virtual metadata should be saved as real metadata. + */ +export class VirtualMetadataComponent implements OnInit { + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The id of the relationship to be deleted. + */ + @Input() relationshipId: string; + + /** + * The left item of the relationship to be deleted. + */ + @Input() leftItem: Item; + + /** + * The right item of the relationship to be deleted. + */ + @Input() rightItem: Item; + + /** + * Emits when the close button is pressed. + */ + @Output() close = new EventEmitter(); + + /** + * Emits when the save button is pressed. + */ + @Output() save = new EventEmitter(); + + /** + * Get an array of the left and the right item of the relationship to be deleted. + */ + get items() { + return [this.leftItem, this.rightItem]; + } + + private virtualMetadata: Map = new Map(); + + constructor( + protected objectUpdatesService: ObjectUpdatesService, + ) { + } + + /** + * Get the virtual metadata of a given item corresponding to this relationship. + * @param item the item to get the virtual metadata for + */ + getVirtualMetadata(item: Item): VirtualMetadata[] { + + return Object.entries(item.metadata) + .map(([key, value]) => + value + .filter((metadata: MetadataValue) => + !key.startsWith('relation') && metadata.authority && metadata.authority.endsWith(this.relationshipId)) + .map((metadata: MetadataValue) => { + return { + metadataField: key, + metadataValue: metadata, + } + }) + ) + .reduce((previous, current) => previous.concat(current), []); + } + + /** + * Select/deselect the virtual metadata of an item to be saved as real metadata. + * @param item the item for which (not) to save the virtual metadata as real metadata + * @param selected whether or not to save the virtual metadata as real metadata + */ + setSelectedVirtualMetadataItem(item: Item, selected: boolean) { + this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid, selected); + } + + /** + * Check whether the virtual metadata of a given item is selected to be saved as real metadata + * @param item the item for which to check whether the virtual metadata is selected to be saved as real metadata + */ + isSelectedVirtualMetadataItem(item: Item): Observable { + return this.objectUpdatesService.isSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid); + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackItem(index, item: Item) { + return item && item.uuid; + } + + ngOnInit(): void { + this.items.forEach((item) => { + this.virtualMetadata.set(item.uuid, this.getVirtualMetadata(item)); + }); + } +} + +/** + * Represents a virtual metadata entry. + */ +export interface VirtualMetadata { + metadataField: string, + metadataValue: MetadataValue, +} diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 4511f16aae..21903e6557 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -11,6 +11,7 @@ let fixture: ComponentFixture; const mockField = 'dc.identifier.uri'; const mockValue = 'test value'; +const mockLabel = 'test label'; describe('ItemPageUriFieldComponent', () => { beforeEach(async(() => { @@ -32,6 +33,8 @@ describe('ItemPageUriFieldComponent', () => { fixture = TestBed.createComponent(ItemPageUriFieldComponent); comp = fixture.componentInstance; comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = [mockField]; + comp.label = mockLabel; fixture.detectChanges(); })); diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index c9cd5f1a00..b70cbbd5e8 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -8,7 +8,8 @@ import { ItemPageFieldComponent } from '../item-page-field.component'; templateUrl: './item-page-uri-field.component.html' }) /** - * This component is used for displaying the uri (dc.identifier.uri) metadata of an item + * This component can be used to represent any uri on a simple item page. + * It expects 4 parameters: The item, a separator, the metadata keys and an i18n key */ export class ItemPageUriFieldComponent extends ItemPageFieldComponent { @@ -21,19 +22,16 @@ export class ItemPageUriFieldComponent extends ItemPageFieldComponent { * Separator string between multiple values of the metadata fields defined * @type {string} */ - separator: string; + @Input() separator: string; /** * Fields (schema.element.qualifier) used to render their values. - * In this component, we want to display values for metadata 'dc.identifier.uri' */ - fields: string[] = [ - 'dc.identifier.uri' - ]; + @Input() fields: string[]; /** * Label i18n key for the rendered metadata */ - label = 'item.page.uri'; + @Input() label: string; } diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html index 3cd4d0aa69..c45e85668a 100644 --- a/src/app/+item-page/simple/item-types/publication/publication.component.html +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -62,8 +62,11 @@ - - + + +
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 485767efdc..e2c8e1a3b5 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -124,6 +124,7 @@ import { ContentSourceResponseParsingService } from './data/content-source-respo import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service'; import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; +import { EntityTypeService } from './data/entity-type.service'; import { SiteDataService } from './data/site-data.service'; import { NormalizedSite } from './cache/models/normalized-site.model'; @@ -249,6 +250,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + EntityTypeService, ContentSourceResponseParsingService, SearchService, SidebarService, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 683cc6fb86..e065e79e5b 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -345,10 +345,12 @@ export abstract class DataService { /** * Delete an existing DSpace Object on the server * @param dso The DSpace Object to be removed - * Return an observable that emits true when the deletion was successful, false when it failed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return an observable that emits true when the deletion was successful, false when it failed */ - delete(dso: T): Observable { - const requestId = this.deleteAndReturnRequestId(dso); + delete(dso: T, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dso, copyVirtualMetadata); return this.requestService.getByUUID(requestId).pipe( find((request: RequestEntry) => request.completed), @@ -359,10 +361,12 @@ export abstract class DataService { /** * Delete an existing DSpace Object on the server * @param dso The DSpace Object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata * Return an observable of the completed response */ - deleteAndReturnResponse(dso: T): Observable { - const requestId = this.deleteAndReturnRequestId(dso); + deleteAndReturnResponse(dso: T, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dso, copyVirtualMetadata); return this.requestService.getByUUID(requestId).pipe( hasValueOperator(), @@ -374,9 +378,11 @@ export abstract class DataService { /** * Delete an existing DSpace Object on the server * @param dso The DSpace Object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata * Return the delete request's ID */ - private deleteAndReturnRequestId(dso: T): string { + private deleteAndReturnRequestId(dso: T, copyVirtualMetadata?: string[]): string { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -385,6 +391,13 @@ export abstract class DataService { hrefObs.pipe( find((href: string) => hasValue(href)), map((href: string) => { + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id + ); + } const request = new DeleteByIDRequest(requestId, href, dso.uuid); this.requestService.configure(request); }) diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts new file mode 100644 index 0000000000..583601d898 --- /dev/null +++ b/src/app/core/data/entity-type.service.ts @@ -0,0 +1,103 @@ +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { GetRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import {switchMap, take, tap} from 'rxjs/operators'; +import { RemoteData } from './remote-data'; +import {RelationshipType} from '../shared/item-relationships/relationship-type.model'; +import {PaginatedList} from './paginated-list'; +import {ItemType} from '../shared/item-relationships/item-type.model'; + +/** + * Service handling all ItemType requests + */ +@Injectable() +export class EntityTypeService extends DataService { + + protected linkPath = 'entitytypes'; + protected forceBypassCache = false; + + constructor(protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + getBrowseEndpoint(options, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for the item type's allowed relationship types + * @param entityTypeId + */ + getRelationshipTypesEndpoint(entityTypeId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + ); + } + + /** + * Get the allowed relationship types for an entity type + * @param entityTypeId + */ + getEntityTypeRelationships(entityTypeId: string): Observable>> { + + const href$ = this.getRelationshipTypesEndpoint(entityTypeId); + + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(href$); + } + + /** + * Get an entity type by their label + * @param label + */ + getEntityTypeByLabel(label: string): Observable> { + + // TODO: Remove mock data once REST API supports this + /* + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildSingle(href$); + */ + + // Mock: + const index = [ + 'Publication', + 'Person', + 'Project', + 'OrgUnit', + 'Journal', + 'JournalVolume', + 'JournalIssue', + 'DataPackage', + 'DataFile', + ].indexOf(label); + + return this.findById((index + 1) + ''); + } +} diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 70585bc3d9..7fedc17545 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -4,7 +4,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../shared/search/facet-value.model'; +import {FacetValue} from '../../shared/search/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GLOBAL_CONFIG } from '../../../config'; diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index d7e6510c1a..868496b079 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,7 +1,7 @@ -import { type } from '../../../shared/ngrx/type'; -import { Action } from '@ngrx/store'; -import { Identifiable } from './object-updates.reducer'; -import { INotification } from '../../../shared/notifications/models/notification.model'; +import {type} from '../../../shared/ngrx/type'; +import {Action} from '@ngrx/store'; +import {Identifiable} from './object-updates.reducer'; +import {INotification} from '../../../shared/notifications/models/notification.model'; /** * The list of ObjectUpdatesAction type definitions @@ -12,6 +12,7 @@ export const ObjectUpdatesActionTypes = { SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), + SELECT_VIRTUAL_METADATA: type('dspace/core/cache/object-updates/SELECT_VIRTUAL_METADATA'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), @@ -126,6 +127,41 @@ export class AddFieldUpdateAction implements Action { } } +/** + * An ngrx action to select/deselect virtual metadata in the ObjectUpdates state for a certain page url + */ +export class SelectVirtualMetadataAction implements Action { + + type = ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA; + payload: { + url: string, + source: string, + uuid: string, + select: boolean; + }; + + /** + * Create a new SelectVirtualMetadataAction + * + * @param url + * the unique url of the page for which a field update is added + * @param source + * the id of the relationship which adds the virtual metadata + * @param uuid + * the id of the item which has the virtual metadata + * @param select + * whether to select or deselect the virtual metadata to be saved as real metadata + */ + constructor( + url: string, + source: string, + uuid: string, + select: boolean, + ) { + this.payload = { url, source, uuid, select: select}; + } +} + /** * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url */ @@ -334,4 +370,5 @@ export type ObjectUpdatesAction | RemoveObjectUpdatesAction | RemoveFieldUpdateAction | MoveFieldUpdateAction - | AddPageToCustomOrderAction; + | AddPageToCustomOrderAction + | SelectVirtualMetadataAction; diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index dc2d3cb508..bdf202049e 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -5,10 +5,11 @@ import { FieldChangeType, InitializeFieldsAction, MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, - RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -44,6 +45,7 @@ const identifiable3 = { language: null, value: 'Unchanged value' }; +const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; @@ -80,6 +82,9 @@ describe('objectUpdatesReducer', () => { } }, lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, customOrder: { initialOrderPages: [ { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } @@ -113,6 +118,9 @@ describe('objectUpdatesReducer', () => { }, }, lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, customOrder: { initialOrderPages: [ { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } @@ -154,6 +162,9 @@ describe('objectUpdatesReducer', () => { } }, lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, customOrder: { initialOrderPages: [ { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } @@ -225,6 +236,12 @@ describe('objectUpdatesReducer', () => { objectUpdatesReducer(testState, action); }); + it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { + const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0); @@ -243,6 +260,7 @@ describe('objectUpdatesReducer', () => { }, }, fieldUpdates: {}, + virtualMetadataSources: {}, lastModified: modDate, customOrder: { initialOrderPages: [ diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 2b24f4a1c5..759a9f5c87 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -7,11 +7,15 @@ import { ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction + RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, + SetValidFieldUpdateAction, + SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { from } from 'rxjs/internal/observable/from'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; /** * Path where discarded objects are saved @@ -56,6 +60,29 @@ export interface FieldUpdates { [uuid: string]: FieldUpdate; } +/** + * The states of all virtual metadata selections available for a single page, mapped by the relationship uuid + */ +export interface VirtualMetadataSources { + [source: string]: VirtualMetadataSource +} + +/** + * The selection of virtual metadata for a relationship, mapped by the uuid of either the item or the relationship type + */ +export interface VirtualMetadataSource { + [uuid: string]: boolean, +} + +/** + * A fieldupdate interface which represents a relationship selected to be deleted, + * along with a selection of the virtual metadata to keep + */ +export interface DeleteRelationship extends Relationship { + keepLeftVirtualMetadata: boolean, + keepRightVirtualMetadata: boolean, +} + /** * A custom order given to the list of objects */ @@ -75,7 +102,8 @@ export interface OrderPage { */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates + fieldUpdates: FieldUpdates; + virtualMetadataSources: VirtualMetadataSources; lastModified: Date; customOrder: CustomOrder } @@ -116,6 +144,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.ADD_FIELD: { return addFieldUpdate(state, action as AddFieldUpdateAction); } + case ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA: { + return selectVirtualMetadata(state, action as SelectVirtualMetadataAction); + } case ObjectUpdatesActionTypes.DISCARD: { return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); } @@ -165,6 +196,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, + { virtualMetadataSources: {} }, { lastModified: lastModifiedServer }, { customOrder: { initialOrderPages: initialOrderPages, @@ -227,6 +259,51 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } +/** + * Update the selected virtual metadata in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) { + + const url: string = action.payload.url; + const source: string = action.payload.source; + const uuid: string = action.payload.uuid; + const select: boolean = action.payload.select; + + const pageState: ObjectUpdatesEntry = state[url] || {}; + + const virtualMetadataSource = Object.assign( + {}, + pageState.virtualMetadataSources[source], + { + [uuid]: select, + }, + ); + + const virtualMetadataSources = Object.assign( + {}, + pageState.virtualMetadataSources, + { + [source]: virtualMetadataSource, + }, + ); + + const newPageState = Object.assign( + {}, + pageState, + {virtualMetadataSources: virtualMetadataSources}, + ); + + return Object.assign( + {}, + state, + { + [url]: newPageState, + } + ); +} + /** * Discard all updates for a specific action's url in the store * @param state The current state diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 924b7dd996..9de966268d 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -5,13 +5,14 @@ import { AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction } from './object-updates.actions'; import { of as observableOf } from 'rxjs'; import { Notification } from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -23,6 +24,7 @@ describe('ObjectUpdatesService', () => { const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; const identifiables = [identifiable1, identifiable2]; + const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, @@ -39,7 +41,7 @@ describe('ObjectUpdatesService', () => { }; const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); @@ -275,4 +277,10 @@ describe('ObjectUpdatesService', () => { }); }); + describe('setSelectedVirtualMetadata', () => { + it('should dispatch a SELECT_VIRTUAL_METADATA action with the correct URL, relationship, identifiable and boolean', () => { + service.setSelectedVirtualMetadata(url, relationship.uuid, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true)); + }); + }); }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 4ae5810fe3..c9a7f47e81 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,7 +8,8 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState, OrderPage + ObjectUpdatesState, OrderPage, + VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { @@ -19,10 +20,11 @@ import { MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; @@ -41,6 +43,10 @@ function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): Memoiz return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); } +function virtualMetadataSourceSelector(url: string, source: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.virtualMetadataSources[source]); +} + /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ @@ -119,20 +125,24 @@ export class ObjectUpdatesService { */ getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable { const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - if (hasValue(objectEntry)) { - Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => { - let fieldUpdate = objectEntry.fieldUpdates[uuid]; - if (isEmpty(fieldUpdate)) { - const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); - fieldUpdate = {field: identifiable, changeType: undefined}; - } - fieldUpdates[uuid] = fieldUpdate; - }); - } - return fieldUpdates; - })) + return objectUpdates.pipe( + switchMap((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + if (hasValue(objectEntry)) { + Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => { + fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid]; + }); + } + return this.getFieldUpdatesExclusive(url, initialFields).pipe( + map((fieldUpdatesExclusive) => { + Object.keys(fieldUpdatesExclusive).forEach((uuid) => { + fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; + }); + return fieldUpdates; + }) + ); + }), + ); } /** @@ -263,6 +273,34 @@ export class ObjectUpdatesService { this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field)); } + /** + * Check whether the virtual metadata of a given item is selected to be saved as real metadata + * @param url The URL of the page on which the field resides + * @param relationship The id of the relationship for which to check whether the virtual metadata is selected to be + * saved as real metadata + * @param item The id of the item for which to check whether the virtual metadata is selected to be + * saved as real metadata + */ + isSelectedVirtualMetadata(url: string, relationship: string, item: string): Observable { + + return this.store + .pipe( + select(virtualMetadataSourceSelector(url, relationship)), + map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), + ); + } + + /** + * Method to dispatch a SelectVirtualMetadataAction to the store + * @param url The page's URL for which the changes are saved + * @param relationship the relationship for which virtual metadata is selected + * @param uuid the selection identifier, can either be the item uuid or the relationship type uuid + * @param selected whether or not to select the virtual metadata to be saved + */ + setSelectedVirtualMetadata(url: string, relationship: string, uuid: string, selected: boolean) { + this.store.dispatch(new SelectVirtualMetadataAction(url, relationship, uuid, selected)); + } + /** * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state * @param url The URL of the page on which the field resides diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index 9287935f59..99442da58d 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -54,10 +54,12 @@ describe('RelationshipService', () => { }); const relatedItem1 = Object.assign(new Item(), { + self: 'fake-item-url/author1', id: 'author1', uuid: 'author1' }); const relatedItem2 = Object.assign(new Item(), { + self: 'fake-item-url/author2', id: 'author2', uuid: 'author2' }); @@ -112,19 +114,19 @@ describe('RelationshipService', () => { beforeEach(() => { spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); spyOn(objectCache, 'remove'); - service.deleteRelationship(relationships[0].uuid).subscribe(); + service.deleteRelationship(relationships[0].uuid, 'right').subscribe(); }); it('should send a DeleteRequest', () => { - const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid + '?copyVirtualMetadata=right'); expect(requestService.configure).toHaveBeenCalledWith(expected); }); - it('should clear the related items their cache', () => { + it('should clear the cache of the related items', () => { expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); expect(objectCache.remove).toHaveBeenCalledWith(item.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); }); }); diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index d6993ebcee..0448c18ec6 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -1,35 +1,47 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; -import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; -import { AppState, keySelector } from '../../app.reducer'; -import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; -import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; -import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; -import { SearchParam } from '../cache/models/search-param.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; -import { RestResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { + configureRequest, + getRemoteDataPayload, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; +import { Item } from '../shared/item.model'; +import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RemoteData, RemoteDataState } from './remote-data'; +import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; import { PaginatedList } from './paginated-list'; import { ItemDataService } from './item-data.service'; -import { Relationship } from '../shared/item-relationships/relationship.model'; -import { Item } from '../shared/item.model'; +import { + compareArraysUsingIds, + paginatedRelationsToItems, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DataService } from './data.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { RequestService } from './request.service'; -import { Observable } from 'rxjs/internal/Observable'; +import { SearchParam } from '../cache/models/search-param.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AppState, keySelector } from '../../app.reducer'; +import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { + RemoveNameVariantAction, + SetNameVariantAction +} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; @@ -81,15 +93,22 @@ export class RelationshipService extends DataService { * Send a delete request for a relationship by ID * @param id */ - deleteRelationship(id: string): Observable { + deleteRelationship(id: string, copyVirtualMetadata: string): Observable { return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), take(1), - map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + distinctUntilChanged(), + map((endpointURL: string) => + new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata) + ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - tap(() => this.removeRelationshipItemsFromCacheByRelationship(id)) + switchMap((response) => + this.clearRelatedCache(id).pipe( + map(() => response), + ) + ), ); } @@ -417,4 +436,26 @@ export class RelationshipService extends DataService { return update$; } + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid The uuid of the relationship for which to clear the related items from the cache + */ + clearRelatedCache(uuid: string): Observable { + return this.findById(uuid).pipe( + getSucceededRemoteData(), + switchMap((rd: RemoteData) => + observableCombineLatest( + rd.payload.leftItem.pipe(getSucceededRemoteData()), + rd.payload.rightItem.pipe(getSucceededRemoteData()) + ) + ), + take(1), + map(([leftItem, rightItem]) => { + this.objectCache.remove(leftItem.payload.self); + this.objectCache.remove(rightItem.payload.self); + this.requestService.removeByHrefSubstring(leftItem.payload.self); + this.requestService.removeByHrefSubstring(rightItem.payload.self); + }), + ); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts index e26abf94c1..201d50e511 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts @@ -128,7 +128,7 @@ export class RelationshipEffects { this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( take(1), hasValueOperator(), - mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id)), + mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id, 'none')), take(1) ).subscribe(); } diff --git a/src/app/shared/mocks/mock-submission.ts b/src/app/shared/mocks/mock-submission.ts index 922e6ad02d..a97d2fb31a 100644 --- a/src/app/shared/mocks/mock-submission.ts +++ b/src/app/shared/mocks/mock-submission.ts @@ -1325,7 +1325,7 @@ export const mockUploadConfigResponse = { }, self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' }, - required: false, + required: true, maxSize: 536870912, name: 'upload', type: 'submissionupload', @@ -1336,6 +1336,10 @@ export const mockUploadConfigResponse = { self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' }; +// Clone the object and change one property +export const mockUploadConfigResponseNotRequired = JSON.parse(JSON.stringify(mockUploadConfigResponse)); +mockUploadConfigResponseNotRequired.required = false; + export const mockAccessConditionOptions = [ { name: 'openaccess', diff --git a/src/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts index fd9f88d939..a58de09b8d 100644 --- a/src/app/submission/sections/upload/section-upload.component.spec.ts +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$, createTestComponent } from '../../../shared/testing/utils'; +import { SubmissionObjectState } from '../../objects/submission-objects.reducer'; import { SubmissionService } from '../../submission.service'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; import { SectionsService } from '../sections.service'; @@ -18,7 +19,7 @@ import { mockSubmissionId, mockSubmissionState, mockUploadConfigResponse, - mockUploadFiles + mockUploadConfigResponseNotRequired, mockUploadFiles, } from '../../../shared/mocks/mock-submission'; import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; @@ -31,7 +32,6 @@ import { cold, hot } from 'jasmine-marbles'; import { Collection } from '../../../core/shared/collection.model'; import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; -import { RemoteData } from '../../../core/data/remote-data'; import { ConfigData } from '../../../core/config/config-data'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Group } from '../../../core/eperson/models/group.model'; @@ -65,17 +65,7 @@ function getMockResourcePolicyService(): ResourcePolicyService { }); } -const sectionObject: SectionDataObject = { - config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', - mandatory: true, - data: { - files: [] - }, - errors: [], - header: 'submit.progressbar.describe.upload', - id: 'upload', - sectionType: SectionsType.Upload -}; +let sectionObject: SectionDataObject; describe('SubmissionSectionUploadComponent test suite', () => { @@ -90,30 +80,48 @@ describe('SubmissionSectionUploadComponent test suite', () => { let uploadsConfigService: any; let bitstreamService: any; - const submissionId = mockSubmissionId; - const collectionId = mockSubmissionCollectionId; - const submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]); - const mockCollection = Object.assign(new Collection(), { - name: 'Community 1-Collection 1', - id: collectionId, - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1-Collection 1' - }], - _links: { - defaultAccessConditions: collectionId + '/defaultAccessConditions' - } - }); - const mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { - name: null, - groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', - id: 20, - uuid: 'resource-policy-20' - }); + let submissionId: string; + let collectionId: string; + let submissionState: SubmissionObjectState; + let mockCollection: Collection; + let mockDefaultAccessCondition: ResourcePolicy; beforeEach(async(() => { + sectionObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', + mandatory: true, + data: { + files: [] + }, + errors: [], + header: 'submit.progressbar.describe.upload', + id: 'upload', + sectionType: SectionsType.Upload + }; + submissionId = mockSubmissionId; + collectionId = mockSubmissionCollectionId; + submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]) as any; + mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + _links: { + defaultAccessConditions: collectionId + '/defaultAccessConditions' + } + }); + + mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20' + }); + TestBed.configureTestingModule({ imports: [ BrowserModule, @@ -206,7 +214,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -215,6 +223,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -245,7 +254,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -254,6 +263,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -263,17 +273,67 @@ describe('SubmissionSectionUploadComponent test suite', () => { }); - it('should the properly section status', () => { - bitstreamService.getUploadedFileList.and.returnValue(hot('-a-b', { + it('should properly read the section status when required is true', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponse as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { a: [], b: mockUploadFiles })); + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(true); + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { c: false, d: true })); }); + + it('should properly read the section status when required is false', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponseNotRequired as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { + a: [], + b: mockUploadFiles + })); + + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(false); + + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { + c: true, + d: true + })); + }); }); }); diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 9dbd1079f4..6c2506b773 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { combineLatest, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription} from 'rxjs'; import { distinctUntilChanged, filter, find, flatMap, map, reduce, take, tap } from 'rxjs/operators'; import { SectionModelComponent } from '../models/section.model'; @@ -104,6 +104,12 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { */ protected availableGroups: Map; // Groups for any policy + /** + * Is the upload required + * @type {boolean} + */ + public required$ = new BehaviorSubject(true); + /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} @@ -172,6 +178,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { }), flatMap(() => config$), flatMap((config: SubmissionUploadsModel) => { + this.required$.next(config.required); this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 @@ -221,7 +228,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { }), // retrieve submission's bitstreams from state - combineLatest(this.configMetadataForm$, + observableCombineLatest(this.configMetadataForm$, this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id)).pipe( filter(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { return isNotEmpty(configMetadataForm) && isNotUndefined(fileList) @@ -273,8 +280,13 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { * the section status */ protected getSectionStatus(): Observable { - return this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id).pipe( - map((fileList: any[]) => (isNotUndefined(fileList) && fileList.length > 0))); + // if not mandatory, always true + // if mandatory, at least one file is required + return observableCombineLatest(this.required$, + this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id), + (required,fileList: any[]) => { + return (!required || (isNotUndefined(fileList) && fileList.length > 0)); + }); } /** diff --git a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html index 35dc903432..50b5fed9d3 100644 --- a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html +++ b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html @@ -50,7 +50,10 @@ a
[fields]="['dc.identifier.citation']" [label]="'item.page.citation'"> - + < +