diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ba7b738227..b77d53c81d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, take, withLatestFrom, delay } from 'rxjs/operators'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, @@ -114,7 +114,10 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - this.router.events.subscribe((event) => { + this.router.events.pipe( + // delay(0) to prevent "Expression has changed after it was checked" errors + delay(0) + ).subscribe((event) => { if (event instanceof NavigationStart) { distinctNext(this.isRouteLoading$, true); } else if ( 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 14bacc52db..da18a28d3f 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -58,6 +58,8 @@ export interface VirtualMetadataSource { export interface RelationshipIdentifiable extends Identifiable { nameVariant?: string; + originalItem: Item; + originalIsLeft: boolean relatedItem: Item; relationship: Relationship; type: RelationshipType; diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts index 00ef14548a..a4c5c0aba1 100644 --- a/src/app/core/data/relationship-data.service.ts +++ b/src/app/core/data/relationship-data.service.ts @@ -114,8 +114,11 @@ export class RelationshipDataService extends IdentifiableDataService> { + deleteRelationship(id: string, copyVirtualMetadata: string, shouldRefresh = true): Observable> { return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), take(1), @@ -126,7 +129,11 @@ export class RelationshipDataService extends IdentifiableDataService this.rdbService.buildFromRequestUUID(restRequest.uuid)), getFirstCompletedRemoteData(), - tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), + tap(() => { + if (shouldRefresh) { + this.refreshRelationshipItemsInCacheByRelationship(id); + } + }), ); } @@ -137,8 +144,11 @@ export class RelationshipDataService extends IdentifiableDataService> { + addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string, shouldRefresh = true): Observable> { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); @@ -153,8 +163,12 @@ export class RelationshipDataService extends IdentifiableDataService this.rdbService.buildFromRequestUUID(restRequest.uuid)), getFirstCompletedRemoteData(), - tap(() => this.refreshRelationshipItemsInCache(item1)), - tap(() => this.refreshRelationshipItemsInCache(item2)), + tap(() => { + if (shouldRefresh) { + this.refreshRelationshipItemsInCache(item1); + this.refreshRelationshipItemsInCache(item2); + } + }), ) as Observable>; } @@ -182,7 +196,7 @@ export class RelationshipDataService extends IdentifiableDataService + this.getItemRelationshipsByLabel(item, label, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); } /** diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.spec.ts b/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.spec.ts new file mode 100644 index 0000000000..93a5425a6c --- /dev/null +++ b/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.spec.ts @@ -0,0 +1,303 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + +import { EntityTypeDataService } from '../../../core/data/entity-type-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { + DeleteRelationship, + RelationshipIdentifiable, +} from '../../../core/data/object-updates/object-updates.reducer'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { EntityTypeDataServiceStub } from '../../../shared/testing/entity-type-data.service.stub'; +import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { ObjectUpdatesServiceStub } from '../../../shared/testing/object-updates.service.stub'; +import { RelationshipDataServiceStub } from '../../../shared/testing/relationship-data.service.stub'; +import { EditItemRelationshipsService } from './edit-item-relationships.service'; + +describe('EditItemRelationshipsService', () => { + let service: EditItemRelationshipsService; + + let itemService: ItemDataServiceStub; + let objectUpdatesService: ObjectUpdatesServiceStub; + let notificationsService: NotificationsServiceStub; + let relationshipService: RelationshipDataServiceStub; + let entityTypeDataService: EntityTypeDataServiceStub; + + let currentItem: Item; + + let relationshipItem1: Item; + let relationshipIdentifiable1: RelationshipIdentifiable; + let relationship1: Relationship; + + let relationshipItem2: Item; + let relationshipIdentifiable2: RelationshipIdentifiable; + let relationship2: Relationship; + + let orgUnitType: ItemType; + let orgUnitToOrgUnitType: RelationshipType; + + beforeEach(() => { + itemService = new ItemDataServiceStub(); + objectUpdatesService = new ObjectUpdatesServiceStub(); + notificationsService = new NotificationsServiceStub(); + relationshipService = new RelationshipDataServiceStub(); + entityTypeDataService = new EntityTypeDataServiceStub(); + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: RelationshipDataService, useValue: relationshipService }, + { provide: EntityTypeDataService, useValue: entityTypeDataService }, + ], + }); + service = TestBed.inject(EditItemRelationshipsService); + }); + + beforeEach(() => { + currentItem = Object.assign(new Item(), { + uuid: uuidv4(), + metadata: { + 'dspace.entity.type': 'OrgUnit', + }, + _links: { + self: { + href: 'selfLink1', + }, + }, + }); + + relationshipItem1 = Object.assign(new Item(), { + uuid: uuidv4(), + metadata: { + 'dspace.entity.type': 'OrgUnit', + }, + _links: { + self: { + href: 'selfLink2', + }, + }, + }); + relationshipIdentifiable1 = { + originalItem: currentItem, + relatedItem: relationshipItem1, + type: orgUnitToOrgUnitType, + uuid: `1-${relationshipItem1.uuid}`, + } as RelationshipIdentifiable; + relationship1 = Object.assign(new Relationship(), { + _links: { + leftItem: currentItem._links.self, + rightItem: relationshipItem1._links.self, + }, + }); + + relationshipItem2 = Object.assign(new Item(), { + uuid: uuidv4(), + metadata: { + 'dspace.entity.type': 'OrgUnit', + }, + _links: { + self: { + href: 'selfLink3', + }, + }, + }); + relationshipIdentifiable2 = { + originalItem: currentItem, + relatedItem: relationshipItem2, + type: orgUnitToOrgUnitType, + uuid: `1-${relationshipItem2.uuid}`, + } as RelationshipIdentifiable; + relationship2 = Object.assign(new Relationship(), { + _links: { + leftItem: currentItem._links.self, + rightItem: relationshipItem2._links.self, + }, + }); + + orgUnitType = Object.assign(new ItemType(), { + id: '2', + label: 'OrgUnit', + }); + orgUnitToOrgUnitType = Object.assign(new RelationshipType(), { + id: '1', + leftMaxCardinality: null, + leftMinCardinality: 0, + leftType: createSuccessfulRemoteDataObject$(orgUnitType), + leftwardType: 'isOrgUnitOfOrgUnit', + rightMaxCardinality: null, + rightMinCardinality: 0, + rightType: createSuccessfulRemoteDataObject$(orgUnitType), + rightwardType: 'isOrgUnitOfOrgUnit', + uuid: 'relationshiptype-1', + }); + }); + + describe('submit', () => { + let fieldUpdateAddRelationship1: FieldUpdate; + let fieldUpdateRemoveRelationship2: FieldUpdate; + + beforeEach(() => { + fieldUpdateAddRelationship1 = { + changeType: FieldChangeType.ADD, + field: relationshipIdentifiable1, + }; + fieldUpdateRemoveRelationship2 = { + changeType: FieldChangeType.REMOVE, + field: relationshipIdentifiable2, + }; + + spyOn(service, 'addRelationship').withArgs(relationshipIdentifiable1).and.returnValue(createSuccessfulRemoteDataObject$(relationship1)); + spyOn(service, 'deleteRelationship').withArgs(relationshipIdentifiable2 as DeleteRelationship).and.returnValue(createSuccessfulRemoteDataObject$({})); + spyOn(itemService, 'invalidateByHref').and.callThrough(); + }); + + it('should support performing multiple relationships manipulations in one submit() call', () => { + spyOn(objectUpdatesService, 'getFieldUpdates').and.returnValue(observableOf({ + [`1-${relationshipItem1.uuid}`]: fieldUpdateAddRelationship1, + [`1-${relationshipItem2.uuid}`]: fieldUpdateRemoveRelationship2, + } as FieldUpdates)); + service.submit(currentItem, `/entities/orgunit/${currentItem.uuid}/edit/relationships`); + + expect(service.addRelationship).toHaveBeenCalledWith(relationshipIdentifiable1); + expect(service.deleteRelationship).toHaveBeenCalledWith(relationshipIdentifiable2 as DeleteRelationship); + + expect(itemService.invalidateByHref).toHaveBeenCalledWith(currentItem.self); + expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem1.self); + // TODO currently this isn't done yet + // expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem2.self); + + expect(notificationsService.success).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteRelationship', () => { + beforeEach(() => { + spyOn(relationshipService, 'deleteRelationship').and.callThrough(); + }); + + it('should pass "all" as copyVirtualMetadata when the user want to keep the data on both sides', () => { + service.deleteRelationship({ + uuid: relationshipItem1.uuid, + keepLeftVirtualMetadata: true, + keepRightVirtualMetadata: true, + } as DeleteRelationship); + + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'all', false); + }); + + it('should pass "left" as copyVirtualMetadata when the user only want to keep the data on the left side', () => { + service.deleteRelationship({ + uuid: relationshipItem1.uuid, + keepLeftVirtualMetadata: true, + keepRightVirtualMetadata: false, + } as DeleteRelationship); + + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'left', false); + }); + + it('should pass "right" as copyVirtualMetadata when the user only want to keep the data on the right side', () => { + service.deleteRelationship({ + uuid: relationshipItem1.uuid, + keepLeftVirtualMetadata: false, + keepRightVirtualMetadata: true, + } as DeleteRelationship); + + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'right', false); + }); + + it('should pass "none" as copyVirtualMetadata when the user doesn\'t want to keep the virtual metadata', () => { + service.deleteRelationship({ + uuid: relationshipItem1.uuid, + keepLeftVirtualMetadata: false, + keepRightVirtualMetadata: false, + } as DeleteRelationship); + + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'none', false); + }); + }); + + describe('addRelationship', () => { + beforeEach(() => { + spyOn(relationshipService, 'addRelationship').and.callThrough(); + }); + + it('should call the addRelationship from relationshipService correctly when original item is on the right', () => { + service.addRelationship({ + originalItem: currentItem, + originalIsLeft: false, + relatedItem: relationshipItem1, + type: orgUnitToOrgUnitType, + uuid: `1-${relationshipItem1.uuid}`, + } as RelationshipIdentifiable); + expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, relationshipItem1, currentItem, undefined, null, false); + }); + + it('should call the addRelationship from relationshipService correctly when original item is on the left', () => { + service.addRelationship({ + originalItem: currentItem, + originalIsLeft: true, + relatedItem: relationshipItem1, + type: orgUnitToOrgUnitType, + uuid: `1-${relationshipItem1.uuid}`, + } as RelationshipIdentifiable); + + expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, currentItem, relationshipItem1, null, undefined, false); + }); + }); + + describe('displayNotifications', () => { + it('should show one success notification when multiple requests succeeded', () => { + service.displayNotifications([ + createSuccessfulRemoteDataObject({}), + createSuccessfulRemoteDataObject({}), + ]); + + expect(notificationsService.success).toHaveBeenCalledTimes(1); + }); + + it('should show one success notification even when some requests failed', () => { + service.displayNotifications([ + createSuccessfulRemoteDataObject({}), + createFailedRemoteDataObject('Request Failed'), + createSuccessfulRemoteDataObject({}), + ]); + + expect(notificationsService.success).toHaveBeenCalledTimes(1); + expect(notificationsService.error).toHaveBeenCalledTimes(1); + }); + + it('should show a separate error notification for each failed request', () => { + service.displayNotifications([ + createSuccessfulRemoteDataObject({}), + createFailedRemoteDataObject('Request Failed 1'), + createSuccessfulRemoteDataObject({}), + createFailedRemoteDataObject('Request Failed 2'), + ]); + + expect(notificationsService.success).toHaveBeenCalledTimes(1); + expect(notificationsService.error).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.ts b/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.ts new file mode 100644 index 0000000000..2cecd878b7 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-relationships/edit-item-relationships.service.ts @@ -0,0 +1,190 @@ +import { Injectable } from '@angular/core'; +import { take, map, switchMap, concatMap, toArray } from 'rxjs/operators'; +import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { hasValue } from '../../../shared/empty.util'; +import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { + DeleteRelationship, + RelationshipIdentifiable +} from '../../../core/data/object-updates/object-updates.reducer'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { EMPTY, Observable, BehaviorSubject, Subscription } from 'rxjs'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { RelationshipDataService } from '../../../core/data/relationship-data.service'; +import { EntityTypeDataService } from '../../../core/data/entity-type-data.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable({ + providedIn: 'root' +}) +export class EditItemRelationshipsService { + public notificationsPrefix = 'item.edit.relationships.notifications.'; + + public isSaving$: BehaviorSubject = new BehaviorSubject(false); + + constructor( + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + protected modalService: NgbModal, + public relationshipService: RelationshipDataService, + public entityTypeService: EntityTypeDataService, + public translateService: TranslateService, + ) { } + + + /** + * Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found + * Make sure the lists are refreshed afterwards and notifications are sent for success and errors + */ + public submit(item: Item, url: string): void { + this.isSaving$.next(true); + this.objectUpdatesService.getFieldUpdates(url, [], true).pipe( + map((fieldUpdates: FieldUpdates) => + Object.values(fieldUpdates) + .filter((fieldUpdate: FieldUpdate) => hasValue(fieldUpdate)) + .filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.ADD || fieldUpdate.changeType === FieldChangeType.REMOVE) + ), + take(1), + // emit each update in the array separately + switchMap((updates) => updates), + // process each update one by one, while waiting for the previous to finish + concatMap((update: FieldUpdate) => { + if (update.changeType === FieldChangeType.REMOVE) { + return this.deleteRelationship(update.field as DeleteRelationship).pipe(take(1)); + } else if (update.changeType === FieldChangeType.ADD) { + return this.addRelationship(update.field as RelationshipIdentifiable).pipe( + take(1), + switchMap((relationshipRD: RemoteData) => { + if (relationshipRD.hasSucceeded) { + // Set the newly related item to stale, so its relationships will update to include + // the new one. Only set the current item to stale at the very end so we only do it + // once + const { leftItem, rightItem } = relationshipRD.payload._links; + if (leftItem.href === item.self) { + return this.itemService.invalidateByHref(rightItem.href).pipe( + // when it's invalidated, emit the original relationshipRD for use in the pipe below + map(() => relationshipRD) + ); + } else { + return this.itemService.invalidateByHref(leftItem.href).pipe( + // when it's invalidated, emit the original relationshipRD for use in the pipe below + map(() => relationshipRD) + ); + } + } else { + return [relationshipRD]; + } + }) + ); + } else { + return EMPTY; + } + }), + toArray(), + switchMap((responses) => { + // once all relationships are made and all related items have been invalidated, invalidate + // the current item + return this.itemService.invalidateByHref(item.self).pipe( + map(() => responses) + ); + }) + ).subscribe((responses) => { + if (responses.length > 0) { + this.initializeOriginalFields(item, url); + this.displayNotifications(responses); + this.modalService.dismissAll(); + this.isSaving$.next(false); + } + }); + } + + /** + * Sends all initial values of this item to the object updates service + */ + public initializeOriginalFields(item: Item, url: string): Subscription { + return this.relationshipService.getRelatedItems(item).pipe( + take(1), + ).subscribe((items: Item[]) => { + this.objectUpdatesService.initialize(url, items, item.lastModified); + }); + } + + deleteRelationship(deleteRelationship: DeleteRelationship): Observable> { + 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, false); + } + + addRelationship(addRelationship: RelationshipIdentifiable): Observable> { + let leftItem: Item; + let rightItem: Item; + let leftwardValue: string; + let rightwardValue: string; + if (addRelationship.originalIsLeft) { + leftItem = addRelationship.originalItem; + rightItem = addRelationship.relatedItem; + leftwardValue = null; + rightwardValue = addRelationship.nameVariant; + } else { + leftItem = addRelationship.relatedItem; + rightItem = addRelationship.originalItem; + leftwardValue = addRelationship.nameVariant; + rightwardValue = null; + } + return this.relationshipService.addRelationship(addRelationship.type.id, leftItem, rightItem, leftwardValue, rightwardValue, false); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param responses + */ + displayNotifications(responses: RemoteData[]): void { + const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); + const successfulResponses = responses.filter((response: RemoteData) => response.hasSucceeded); + + failedResponses.forEach((response: RemoteData) => { + this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + } + + + + /** + * Get translated notification title + * @param key + */ + getNotificationTitle(key: string): string { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + getNotificationContent(key: string): string { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +} 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 b2d597596a..a4cc100377 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,5 +1,5 @@

- {{getRelationshipMessageKey() | async | translate}} + {{relationshipMessageKey$ | async | translate}} + + + +
+
+ +
+
+ + + +
+
+ -
- - -
- -
-
- -
-
-
- - - -
-
- +