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 db7557b43c..1542d12ce5 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 @@ -17,6 +17,7 @@ import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/e import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; +import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -42,7 +43,8 @@ import { EditRelationshipComponent } from './item-relationships/edit-relationshi ItemRelationshipsComponent, ItemBitstreamsComponent, EditInPlaceFieldComponent, - EditRelationshipComponent + EditRelationshipComponent, + EditRelationshipListComponent ] }) export class EditItemPageModule { 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 new file mode 100644 index 0000000000..ba5164e81a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -0,0 +1,15 @@ + +
+
{{getRelationshipMessageKey(relationshipLabel) | translate}}
+ +
+
+ +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss new file mode 100644 index 0000000000..ec6cd2ba78 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss @@ -0,0 +1,12 @@ +@import '../../../../../styles/variables.scss'; + +.relationship-row:not(.alert-danger) { + padding: $alert-padding-y 0; +} + +.relationship-row.alert-danger { + margin-left: -$alert-padding-x; + margin-right: -$alert-padding-x; + margin-top: -1px; + margin-bottom: -1px; +} 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 new file mode 100644 index 0000000000..bd5f5f2e5c --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -0,0 +1,137 @@ +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'; + +let comp: EditRelationshipListComponent; +let fixture: ComponentFixture; +let de: DebugElement; + +let objectUpdatesService; +let relationshipService; + +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('EditRelationshipListComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { + type: ResourceType.RelationshipType, + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + 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)) + }) + ]; + + 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' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getRelatedItemsByLabel: observableOf([author1, author2]), + } + ); + + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + 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.url = url; + comp.relationshipLabel = relationshipType.leftLabel; + fixture.detectChanges(); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class alert-danger', () => { + 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 new file mode 100644 index 0000000000..765d6484d4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } 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 { switchMap } from 'rxjs/operators'; +import { hasValue } from '../../../../shared/empty.util'; + +@Component({ + selector: 'ds-edit-relationship-list', + styleUrls: ['./edit-relationship-list.component.scss'], + templateUrl: './edit-relationship-list.component.html', +}) +/** + * 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 { + /** + * The item to display related items for + */ + @Input() item: Item; + + /** + * The URL to the current page + * Used to fetch updates for the current item from the store + */ + @Input() url: string; + + /** + * The label of the relationship-type we're rendering a list for + */ + @Input() relationshipLabel: string; + + /** + * The FieldUpdates for the relationships in question + */ + updates$: Observable; + + constructor( + protected objectUpdatesService: ObjectUpdatesService, + protected relationshipService: RelationshipService + ) { + } + + ngOnInit(): void { + this.initUpdates(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.initUpdates(); + } + + /** + * Initialize the FieldUpdates using the related items + */ + initUpdates() { + this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); + } + + /** + * 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((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items)) + ) + } + + /** + * 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; + } + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + +} 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 be400649c4..4bd0b3df2c 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 @@ -18,21 +18,7 @@
- -
-
{{getRelationshipMessageKey(label) | translate}}
- -
-
- -
-
-
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss index cbedd42280..898533a9f0 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss @@ -20,14 +20,3 @@ } - -.relationship-row:not(.alert-danger) { - padding: $alert-padding-y 0; -} - -.relationship-row.alert-danger { - margin-left: -$alert-padding-x; - margin-right: -$alert-padding-x; - margin-top: -1px; - margin-bottom: -1px; -} 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 8579d25fdf..2439eb4c63 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 @@ -1,6 +1,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ItemRelationshipsComponent } from './item-relationships.component'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { RouterStub } from '../../../shared/testing/router-stub'; @@ -24,8 +24,8 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update import { RelationshipService } from '../../../core/data/relationship.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { getTestScheduler } from 'jasmine-marbles'; -import { By } from '@angular/platform-browser'; import { RestResponse } from '../../../core/cache/response.models'; +import { RequestService } from '../../../core/data/request.service'; let comp: any; let fixture: ComponentFixture; @@ -33,6 +33,7 @@ let de: DebugElement; let el: HTMLElement; let objectUpdatesService; let relationshipService; +let requestService; let objectCache; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -158,6 +159,13 @@ describe('ItemRelationshipsComponent', () => { } ); + requestService = jasmine.createSpyObj('requestService', + { + removeByHrefSubstring: {}, + hasByHrefObservable: observableOf(false) + } + ); + objectCache = jasmine.createSpyObj('objectCache', { remove: undefined }); @@ -174,7 +182,9 @@ describe('ItemRelationshipsComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, { provide: RelationshipService, useValue: relationshipService }, - { provide: ObjectCacheService, useValue: objectCache } + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + ChangeDetectorRef ], schemas: [ NO_ERRORS_SCHEMA ] @@ -210,17 +220,6 @@ describe('ItemRelationshipsComponent', () => { }); }); - describe('changeType is REMOVE', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); - it('the div should have class alert-danger', () => { - const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; - expect(element.classList).toContain('alert-danger'); - }); - }); - describe('submit', () => { beforeEach(() => { comp.submit(); @@ -229,6 +228,7 @@ describe('ItemRelationshipsComponent', () => { it('it should delete the correct relationship and de-cache the current item', () => { expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); expect(objectCache.remove).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); }); }); }); 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 8e8a31f896..7db8756c78 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 { Component, Inject } from '@angular/core'; +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 { Observable } from 'rxjs/internal/Observable'; -import { map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -20,6 +20,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; +import { Subscription } from 'rxjs/internal/Subscription'; @Component({ selector: 'ds-item-relationships', @@ -29,13 +30,19 @@ import { RequestService } from '../../../core/data/request.service'; /** * Component for displaying an item's relationships edit page */ -export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { +export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy { /** * The labels of all different relations within this item */ relationLabels$: 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; + constructor( protected itemService: ItemDataService, protected objectUpdatesService: ObjectUpdatesService, @@ -46,7 +53,8 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { protected route: ActivatedRoute, protected relationshipService: RelationshipService, protected objectCache: ObjectCacheService, - protected requestService: RequestService + protected requestService: RequestService, + protected cdRef: ChangeDetectorRef ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } @@ -57,6 +65,16 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { ngOnInit(): void { super.ngOnInit(); this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(this.item); + + // Update the item (and view) when it's removed in the request cache + 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(); + }); } /** @@ -104,13 +122,12 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))), map((responses: RestResponse[]) => responses.filter((response: RestResponse) => response.isSuccessful)) ).subscribe((responses: RestResponse[]) => { - // Make sure the lists are up-to-date and send a notification that the removal was successful - // TODO: Fix lists refreshing correctly + // Remove the item's cache to make sure the lists are reloaded with the newest values this.objectCache.remove(this.item.self); this.requestService.removeByHrefSubstring(this.item.self); - // this.itemService.findById(this.item.id).pipe(getSucceededRemoteData(), take(1)).subscribe((itemRD: RemoteData) => this.item = itemRD.payload); this.initializeOriginalFields(); this.initializeUpdates(); + // Send a notification that the removal was successful this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); }); } @@ -125,33 +142,10 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { } /** - * Transform the item's relationships of a specific type into related items - * @param label The relationship type's label + * Unsubscribe from the item update when the component is destroyed */ - 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((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items)) - ) - } - - /** - * Get the i18n message key for a relationship - * @param label The relationship type's label - */ - public getRelationshipMessageKey(label: string): string { - if (label.indexOf('Of') > -1) { - return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` - } else { - return label; - } + ngOnDestroy(): void { + this.itemUpdateSubscription.unsubscribe(); } } diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index d415da50b3..c1a78225b8 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -3,7 +3,7 @@ import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, take, tap, } from 'rxjs/operators'; import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; @@ -224,6 +224,18 @@ export class ObjectCacheService { return result; } + /** + * Create an observable that emits a new value whenever the availability of the cached object changes. + * The value it emits is a boolean stating if the object exists in cache or not. + * @param selfLink The self link of the object to observe + */ + hasBySelfLinkObservable(selfLink: string): Observable { + return this.store.pipe( + select(entryFromSelfLinkSelector(selfLink)), + map((entry: ObjectCacheEntry) => this.isValid(entry)) + ); + } + /** * Check whether an ObjectCacheEntry should still be cached * diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 82c2a47491..f15a60d5ec 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -265,4 +265,15 @@ export class RequestService { return result; } + /** + * Create an observable that emits a new value whenever the availability of the cached request changes. + * The value it emits is a boolean stating if the request exists in cache or not. + * @param href The href of the request to observe + */ + hasByHrefObservable(href: string): Observable { + return this.getByHref(href).pipe( + map((requestEntry: RequestEntry) => this.isValid(requestEntry)) + ); + } + }