diff --git a/config/environment.test.js b/config/environment.test.js index f4d625303f..0652755bc7 100644 --- a/config/environment.test.js +++ b/config/environment.test.js @@ -1,3 +1,5 @@ module.exports = { - + theme: { + name: 'default', + } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 234967231e..50f67c6cb8 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -256,12 +256,26 @@ "item.edit.reinstate.error": "An error occurred while reinstating the item", "item.edit.reinstate.header": "Reinstate item: {{ id }}", "item.edit.reinstate.success": "The item was reinstated successfully", + "item.edit.relationships.discard-button": "Discard", + "item.edit.relationships.edit.buttons.remove": "Remove", + "item.edit.relationships.edit.buttons.undo": "Undo changes", + "item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + "item.edit.relationships.notifications.discarded.title": "Changes discarded", + "item.edit.relationships.notifications.failed.title": "Error deleting relationship", + "item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", + "item.edit.relationships.notifications.outdated.title": "Changes outdated", + "item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.", + "item.edit.relationships.notifications.saved.title": "Relationships saved", + "item.edit.relationships.reinstate-button": "Undo", + "item.edit.relationships.save-button": "Save", "item.edit.tabs.bitstreams.head": "Item Bitstreams", "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", "item.edit.tabs.curate.head": "Curate", "item.edit.tabs.curate.title": "Item Edit - Curate", "item.edit.tabs.metadata.head": "Item Metadata", "item.edit.tabs.metadata.title": "Item Edit - Metadata", + "item.edit.tabs.relationships.head": "Item Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", "item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies", "item.edit.tabs.status.buttons.delete.button": "Permanently delete", diff --git a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts new file mode 100644 index 0000000000..c49def3dd2 --- /dev/null +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -0,0 +1,179 @@ +import { Component, Inject, Injectable, OnInit } from '@angular/core'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { Item } from '../../../core/shared/item.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { first, map } from 'rxjs/operators'; +import { RemoteData } from '../../../core/data/remote-data'; + +@Injectable() +/** + * Abstract component for managing object updates of an item + */ +export abstract class AbstractItemUpdateComponent implements OnInit { + /** + * The item to display the edit page for + */ + item: Item; + /** + * The current values and updates for all this item's fields + * Should be initialized in the initializeUpdates method of the child component + */ + updates$: Observable; + /** + * The current url of this page + */ + url: string; + /** + * Prefix for this component's notification translate keys + * Should be initialized in the initializeNotificationsPrefix method of the child component + */ + notificationsPrefix; + /** + * The time span for being able to undo discarding changes + */ + discardTimeOut: number; + + constructor( + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute + ) { + + } + + /** + * Initialize common properties between item-update components + */ + ngOnInit(): void { + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + + this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.hasChanges().pipe(first()).subscribe((hasChanges) => { + if (!hasChanges) { + this.initializeOriginalFields(); + } else { + this.checkLastModified(); + } + }); + + this.initializeNotificationsPrefix(); + this.initializeUpdates(); + } + + /** + * Initialize the values and updates of the current item's fields + */ + abstract initializeUpdates(): void; + + /** + * Initialize the prefix for notification messages + */ + abstract initializeNotificationsPrefix(): void; + + /** + * Sends all initial values of this item to the object updates service + */ + abstract initializeOriginalFields(): void; + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + + /** + * Checks whether or not there are currently updates for this item + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Check if the current page is entirely valid + */ + protected isValid() { + return this.objectUpdatesService.isValidPage(this.url); + } + + /** + * Checks if the current item is still in sync with the version in the store + * If it's not, a notification is shown and the changes are removed + */ + private checkLastModified() { + const currentVersion = this.item.lastModified; + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + (updateVersion: Date) => { + if (updateVersion.getDate() !== currentVersion.getDate()) { + this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); + this.initializeOriginalFields(); + } + } + ); + } + + /** + * Submit the current changes + */ + abstract submit(): void; + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Get translated notification title + * @param key + */ + protected getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + protected getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +} 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 0c1de642ce..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 @@ -15,6 +15,9 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; 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 @@ -37,8 +40,11 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemRelationshipsComponent, ItemBitstreamsComponent, - EditInPlaceFieldComponent + EditInPlaceFieldComponent, + EditRelationshipComponent, + EditRelationshipListComponent ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 811965e145..f298f7df39 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -10,6 +10,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -50,6 +51,11 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: ItemMetadataComponent, data: { title: 'item.edit.tabs.metadata.title' } }, + { + path: 'relationships', + component: ItemRelationshipsComponent, + data: { title: 'item.edit.tabs.relationships.title' } + }, { path: 'view', /* TODO - change when view page exists */ diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts index e51182e7b3..0c164b7bc2 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -31,7 +31,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; -let comp: ItemMetadataComponent; +let comp: any; let fixture: ComponentFixture; let de: DebugElement; let el: HTMLElement; diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 8148b89bd4..be657d71dc 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; @@ -6,8 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { cloneDeep } from 'lodash'; import { Observable } from 'rxjs'; import { - FieldUpdate, - FieldUpdates, Identifiable } from '../../../core/data/object-updates/object-updates.reducer'; import { first, map, switchMap, take, tap } from 'rxjs/operators'; @@ -19,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { RegistryService } from '../../../core/registry/registry.service'; import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { Metadata } from '../../../core/shared/metadata.utils'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; @Component({ @@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model'; /** * Component for displaying an item's metadata edit page */ -export class ItemMetadataComponent implements OnInit { - - /** - * The item to display the edit page for - */ - item: Item; - /** - * The current values and updates for all this item's metadata fields - */ - updates$: Observable; - /** - * The current url of this page - */ - url: string; - /** - * The time span for being able to undo discarding changes - */ - private discardTimeOut: number; - /** - * Prefix for this component's notification translate keys - */ - private notificationsPrefix = 'item.edit.metadata.notifications.'; +export class ItemMetadataComponent extends AbstractItemUpdateComponent { /** * Observable with a list of strings with all existing metadata field keys @@ -58,44 +36,38 @@ export class ItemMetadataComponent implements OnInit { metadataFields$: Observable; constructor( - private itemService: ItemDataService, - private objectUpdatesService: ObjectUpdatesService, - private router: Router, - private notificationsService: NotificationsService, - private translateService: TranslateService, + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - private route: ActivatedRoute, - private metadataFieldService: RegistryService, + protected route: ActivatedRoute, + protected metadataFieldService: RegistryService, ) { - + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } /** * Set up and initialize all fields */ ngOnInit(): void { + super.ngOnInit(); this.metadataFields$ = this.findMetadataFields(); - this.route.parent.data.pipe(map((data) => data.item)) - .pipe( - first(), - map((data: RemoteData) => data.payload) - ).subscribe((item: Item) => { - this.item = item; - }); + } - this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; - this.url = this.router.url; - if (this.url.indexOf('?') > 0) { - this.url = this.url.substr(0, this.url.indexOf('?')); - } - this.hasChanges().pipe(first()).subscribe((hasChanges) => { - if (!hasChanges) { - this.initializeOriginalFields(); - } else { - this.checkLastModified(); - } - }); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + /** + * Initialize the values and updates of the current item's metadata fields + */ + public initializeUpdates(): void { + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.metadata.notifications.'; } /** @@ -104,47 +76,23 @@ export class ItemMetadataComponent implements OnInit { */ add(metadata: MetadatumViewModel = new MetadatumViewModel()) { this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); - - } - - /** - * Request the object updates service to discard all current changes to this item - * Shows a notification to remind the user that they can undo this - */ - discard() { - const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); - this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); - } - - /** - * Request the object updates service to undo discarding all changes to this item - */ - reinstate() { - this.objectUpdatesService.reinstateFieldUpdates(this.url); } /** * Sends all initial values of this item to the object updates service */ - private initializeOriginalFields() { - this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); - } - - /** - * Prevent unnecessary rerendering so fields don't lose focus - */ - trackUpdate(index, update: FieldUpdate) { - return update && update.field ? update.field.uuid : undefined; + public initializeOriginalFields() { + this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified); } /** * Requests all current metadata for this item and requests the item service to update the item * Makes sure the new version of the item is rendered on the page */ - submit() { + public submit() { this.isValid().pipe(first()).subscribe((isValid) => { if (isValid) { - const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.getMetadataAsListExcludingRelationships()) as Observable; metadata$.pipe( first(), switchMap((metadata: MetadatumViewModel[]) => { @@ -157,7 +105,7 @@ export class ItemMetadataComponent implements OnInit { (rd: RemoteData) => { this.item = rd.payload; this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); } ) @@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit { }); } - /** - * Checks whether or not there are currently updates for this item - */ - hasChanges(): Observable { - return this.objectUpdatesService.hasUpdates(this.url); - } - - /** - * Checks whether or not the item is currently reinstatable - */ - isReinstatable(): Observable { - return this.objectUpdatesService.isReinstatable(this.url); - } - - /** - * Checks if the current item is still in sync with the version in the store - * If it's not, a notification is shown and the changes are removed - */ - private checkLastModified() { - const currentVersion = this.item.lastModified; - this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( - (updateVersion: Date) => { - if (updateVersion.getDate() !== currentVersion.getDate()) { - this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); - this.initializeOriginalFields(); - } - } - ); - } - - /** - * Check if the current page is entirely valid - */ - private isValid() { - return this.objectUpdatesService.isValidPage(this.url); - } - - /** - * Get translated notification title - * @param key - */ - private getNotificationTitle(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.title'); - } - - /** - * Get translated notification content - * @param key - */ - private getNotificationContent(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.content'); - - } - /** * Method to request all metadata fields and convert them to a list of strings */ @@ -230,4 +124,8 @@ export class ItemMetadataComponent implements OnInit { take(1), map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); } + + getMetadataAsListExcludingRelationships(): MetadatumViewModel[] { + return this.item.metadataAsList.filter((metadata: MetadatumViewModel) => !metadata.key.startsWith('relation.') && !metadata.key.startsWith('relationship.')); + } } 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..3748ebca9d --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -0,0 +1,136 @@ +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(), { + 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/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html new file mode 100644 index 0000000000..e25c3c204f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss new file mode 100644 index 0000000000..808a8344ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss @@ -0,0 +1,15 @@ +@import '../../../../../styles/variables.scss'; + +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.relationship-action-buttons { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} 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 new file mode 100644 index 0000000000..3306d8eb01 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -0,0 +1,179 @@ +import { async, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EditRelationshipComponent } from './edit-relationship.component'; +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 { 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'; + +let objectUpdatesService: ObjectUpdatesService; +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +let fixture; +let comp: EditRelationshipComponent; +let de; +let el; + +describe('EditRelationshipComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new 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', + { + 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 + } + ); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [EditRelationshipComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + + comp.url = url; + comp.fieldUpdate = fieldUpdate1; + comp.item = item; + + fixture.detectChanges(); + }); + + describe('when fieldUpdate has no changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate1; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return true', () => { + expect(comp.canRemove()).toBe(true); + }); + }); + + describe('canUndo', () => { + it('should return false', () => { + expect(comp.canUndo()).toBe(false); + }); + }); + }); + + describe('when fieldUpdate has DELETE as changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate2; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return false', () => { + expect(comp.canRemove()).toBe(false); + }); + }); + + describe('canUndo', () => { + it('should return true', () => { + expect(comp.canUndo()).toBe(true); + }); + }); + }); + + describe('remove', () => { + beforeEach(() => { + comp.remove(); + }); + + it('should call saveRemoveFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); + }); + }); + + describe('undo', () => { + beforeEach(() => { + comp.undo(); + }); + + it('should call removeSingleFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.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 new file mode 100644 index 0000000000..497b06775a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -0,0 +1,74 @@ +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 { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { ItemViewMode } from '../../../../shared/items/item-type-decorator'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-edit-relationship]', + styleUrls: ['./edit-relationship.component.scss'], + templateUrl: './edit-relationship.component.html', +}) +export class EditRelationshipComponent implements OnChanges { + /** + * The current field, value and state of the relationship + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The related item of this relationship + */ + item: Item; + + /** + * The view-mode we're currently on + */ + viewMode = ItemViewMode.Element; + + constructor(private objectUpdatesService: ObjectUpdatesService) { + } + + /** + * Sets the current relationship based on the fieldUpdate input field + */ + ngOnChanges(): void { + this.item = cloneDeep(this.fieldUpdate.field) as Item; + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(): boolean { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(): boolean { + return this.fieldUpdate.changeType >= 0; + } + +} 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 new file mode 100644 index 0000000000..4bd0b3df2c --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -0,0 +1,43 @@ +
+
+ + + +
+
+ +
+
+
+ + + +
+
+
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 new file mode 100644 index 0000000000..898533a9f0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/variables.scss'; + +.button-row { + .btn { + margin-right: 0.5 * $spacer; + + &:last-child { + margin-right: 0; + } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } + } + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + } + + +} 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 new file mode 100644 index 0000000000..b1a4e11371 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -0,0 +1,233 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemRelationshipsComponent } from './item-relationships.component'; +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'; +import { TestScheduler } from 'rxjs/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +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 { 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 { RelationshipService } from '../../../core/data/relationship.service'; +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'; + +let comp: any; +let fixture: ComponentFixture; +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'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } +); +const router = new RouterStub(); +let routeStub; +let itemService; + +const url = 'http://test-url.com/test-url'; +router.url = url; + +let scheduler: TestScheduler; +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('ItemRelationshipsComponent', () => { + beforeEach(async(() => { + const date = new Date(); + + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + 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))), + lastModified: date + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + relationships[0].leftItem = observableOf(new RemoteData(false, false, true, undefined, author1)); + relationships[0].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + relationships[1].leftItem = observableOf(new RemoteData(false, false, true, undefined, author2)); + relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + itemService = jasmine.createSpyObj('itemService', { + findById: observableOf(new RemoteData(false, false, true, undefined, item)) + }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([author1, author2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getItemRelationshipLabels: observableOf(['isAuthorOfPublication']), + getRelatedItems: observableOf([author1, author2]), + getRelatedItemsByLabel: observableOf([author1, author2]), + getItemRelationshipsArray: observableOf(relationships), + deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')), + getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)) + } + ); + + requestService = jasmine.createSpyObj('requestService', + { + removeByHrefSubstring: {}, + hasByHrefObservable: observableOf(false) + } + ); + + objectCache = jasmine.createSpyObj('objectCache', { + remove: undefined + }); + + scheduler = getTestScheduler(); + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [ItemRelationshipsComponent], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + ChangeDetectorRef + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemRelationshipsComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + comp.url = url; + fixture.detectChanges(); + }); + + describe('discard', () => { + beforeEach(() => { + comp.discard(); + }); + + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + }); + + describe('reinstate', () => { + beforeEach(() => { + comp.reinstate(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + }); + + describe('submit', () => { + beforeEach(() => { + comp.submit(); + }); + + it('it should delete the correct relationship', () => { + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); + }); + }); +}); 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 new file mode 100644 index 0000000000..e8f34bc70e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -0,0 +1,172 @@ +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 { filter, map, switchMap, take, tap } 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'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +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 { RequestService } from '../../../core/data/request.service'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils'; + +@Component({ + selector: 'ds-item-relationships', + styleUrls: ['./item-relationships.component.scss'], + templateUrl: './item-relationships.component.html', +}) +/** + * Component for displaying an item's relationships edit page + */ +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, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute, + protected relationshipService: RelationshipService, + protected objectCache: ObjectCacheService, + protected requestService: RequestService, + protected cdRef: ChangeDetectorRef + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(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(); + }); + } + + /** + * 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)) + ); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.relationships.notifications.'; + } + + /** + * 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(): 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( + getRelationsByRelatedItemIds(this.item, this.relationshipService) + ); + // Request a delete for every relationship found in the observable created above + removedRelationships$.pipe( + take(1), + map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), + switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) + ).subscribe((responses: RestResponse[]) => { + this.displayNotifications(responses); + this.reset(); + }); + } + + /** + * 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: RestResponse[]) { + const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful); + const successfulResponses = responses.filter((response: RestResponse) => response.isSuccessful); + + failedResponses.forEach((response: ErrorResponse) => { + this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + } + + /** + * 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); + }); + } + + /** + * Unsubscribe from the item update when the component is destroyed + */ + ngOnDestroy(): void { + this.itemUpdateSubscription.unsubscribe(); + } + +} diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts index 65ad743245..b4eda2abfb 100644 --- a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -7,10 +7,12 @@ import { hasNoValue, hasValue } from '../../../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; -import { distinctUntilChanged, flatMap, map, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, tap } from 'rxjs/operators'; import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs'; import { ItemDataService } from '../../../../core/data/item-data.service'; import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { RelationshipService } from '../../../../core/data/relationship.service'; /** * Operator for comparing arrays using a mapping function @@ -147,3 +149,17 @@ export const relationsToRepresentations = (parentId: string, itemType: string, m ) ) ); + +/** + * Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) + * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned + * @param item + * @param relationshipService + */ +export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) => + (source: Observable): Observable => + source.pipe( + flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe( + map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1)) + )) + ); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 1deff8d103..0558065142 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -121,10 +121,7 @@ export class SearchPageComponent implements OnInit { this.searchLink = this.getSearchLink(); this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( - switchMap((options) => this.service.search(options).pipe( - getSucceededRemoteData(), - startWith(undefined) - ))) + switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined)))) .subscribe((results) => { this.resultsRD$.next(results); }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index a0a6aa7364..e2b24200df 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,7 +1,7 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { NavigationExtras, Router } from '@angular/router'; -import { first, map, switchMap } from 'rxjs/operators'; +import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { first, map, switchMap, tap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, @@ -23,7 +23,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -103,11 +103,18 @@ export class SearchService implements OnDestroy { * @returns {Observable>>>} Emits a paginated list with all search results found */ search(searchOptions?: PaginatedSearchOptions): Observable>>> { - const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe( + const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe( map((url: string) => { if (hasValue(searchOptions)) { - url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); + return (searchOptions as PaginatedSearchOptions).toRestUrl(url); + } else { + return url; } + }) + ); + + const requestObs = hrefObs.pipe( + map((url: string) => { const request = new this.request(this.requestService.generateRequestId(), url); const getResponseParserFn: () => GenericConstructor = () => { @@ -136,10 +143,11 @@ export class SearchService implements OnDestroy { map((sqr: SearchQueryResponse) => { return sqr.objects .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) - .map((nsr: NormalizedSearchResult) => { - return this.rdb.buildSingle(nsr.indexableObject); - }) + .map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject)) }), + // Send a request for each item to ensure fresh cache + tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))), + map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))), switchMap((input: Array>>) => this.rdb.aggregate(input)), ); @@ -168,11 +176,20 @@ export class SearchService implements OnDestroy { const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe( map(([tDomainList, pageInfo]) => { - return new PaginatedList(pageInfo, tDomainList); + return new PaginatedList(pageInfo, tDomainList.filter((obj) => hasValue(obj))); }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe( + switchMap(([href, tDomainList, requestEntry]) => { + if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) { + this.requestService.removeByHrefSubstring(href); + return this.search(searchOptions) + } else { + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + }) + ); } /** diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index b7b34f9c55..d6315ce4f6 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -44,6 +44,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RouteService } from './core/services/route.service'; import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockRouter } from './shared/mocks/mock-router'; +import { CookieService } from './shared/services/cookie.service'; +import { MockCookieService } from './shared/mocks/mock-cookie.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -78,6 +80,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: CookieService, useValue: new MockCookieService()}, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9c1b86c2ae..0c6fd538fa 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -32,6 +32,11 @@ import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; +import { ClientCookieService } from './shared/services/client-cookie.service'; +import { isNotEmpty } from './shared/empty.util'; +import { CookieService } from './shared/services/cookie.service'; + +export const LANG_COOKIE = 'language_cookie'; @Component({ selector: 'ds-app', @@ -61,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, + private cookie: CookieService ) { // Load all the languages that are defined as active from the config file translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -68,11 +74,20 @@ export class AppComponent implements OnInit, AfterViewInit { // Load the default language from the config file translate.setDefaultLang(config.defaultLanguage); - // Attempt to get the browser language from the user - if (translate.getLangs().includes(translate.getBrowserLang())) { - translate.use(translate.getBrowserLang()); + // Attempt to get the language from a cookie + const lang = cookie.get(LANG_COOKIE); + if (isNotEmpty(lang)) { + // Cookie found + // Use the language from the cookie + translate.use(lang); } else { - translate.use(config.defaultLanguage); + // Cookie not found + // Attempt to get the browser language from the user + if (translate.getLangs().includes(translate.getBrowserLang())) { + translate.use(translate.getBrowserLang()); + } else { + translate.use(config.defaultLanguage); + } } metadata.listenForRouteChange(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ce5a2d78a2..3781edf532 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -39,6 +39,7 @@ import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/e import { NavbarModule } from './navbar/navbar.module'; import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; +import { ClientCookieService } from './shared/services/client-cookie.service'; export function getConfig() { return ENV_CONFIG; @@ -97,7 +98,8 @@ const PROVIDERS = [ { provide: RouterStateSerializer, useClass: DSpaceRouterStateSerializer - } + }, + ClientCookieService ]; const DECLARATIONS = [ diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index e18ff3b3a4..11f3a6ce3e 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -4,7 +4,7 @@ import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; -import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; @@ -68,8 +68,8 @@ export class ObjectCacheService { * @param href * The unique href of the object to be removed */ - remove(uuid: string): void { - this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); + remove(href: string): void { + this.store.dispatch(new RemoveFromObjectCacheAction(href)); } /** @@ -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/core.module.ts b/src/app/core/core.module.ts index d49265ab73..f4d4dcb269 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -101,6 +101,7 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; +import { RelationshipService } from './data/relationship.service'; import { RoleService } from './roles/role.service'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; @@ -200,6 +201,7 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, + RelationshipService, MyDSpaceGuard, RoleService, TaskResponseParsingService, 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 22d5fd3e77..08745f9223 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -105,6 +105,27 @@ export class ObjectUpdatesService { })) } + /** + * Method that combines the state's updates (excluding updates that aren't part of the initialFields) with + * the initial values (when there's no update) to create a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; + } + return fieldUpdates; + })) + } + /** * Method to check if a specific field is currently editable in the store * @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 new file mode 100644 index 0000000000..0ced517d74 --- /dev/null +++ b/src/app/core/data/relationship.service.spec.ts @@ -0,0 +1,157 @@ +import { RelationshipService } from './relationship.service'; +import { RequestService } from './request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RequestEntry } from './request.reducer'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../shared/resource-type'; +import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RemoteData } from './remote-data'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { Item } from '../shared/item.model'; +import { PaginatedList } from './paginated-list'; +import { PageInfo } from '../shared/page-info.model'; +import { DeleteRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Observable } from 'rxjs/internal/Observable'; + +describe('RelationshipService', () => { + let service: RelationshipService; + let requestService: RequestService; + + const restEndpointURL = 'https://rest.api/'; + const relationshipsEndpointURL = `${restEndpointURL}/relationships`; + const halService: any = new HALEndpointServiceStub(restEndpointURL); + const rdbService = getMockRemoteDataBuildService(); + const objectCache = Object.assign({ + /* tslint:disable:no-empty */ + remove: () => {} + /* tslint:enable:no-empty */ + }) as ObjectCacheService; + + const relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftLabel: 'isAuthorOfPublication', + rightLabel: 'isPublicationOfAuthor' + }); + + const relationship1 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + const relationship2 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + const relationships = [ relationship1, relationship2 ]; + + const 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))) + }); + + const relatedItem1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + const relatedItem2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + relationship1.leftItem = getRemotedataObservable(relatedItem1); + relationship1.rightItem = getRemotedataObservable(item); + relationship2.leftItem = getRemotedataObservable(relatedItem2); + relationship2.rightItem = getRemotedataObservable(item); + const relatedItems = [relatedItem1, relatedItem2]; + + const itemService = jasmine.createSpyObj('itemService', { + findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0]) + }); + + function initTestService() { + return new RelationshipService( + requestService, + halService, + rdbService, + itemService, + objectCache + ); + } + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: relationships } as any + } as RequestEntry) + }; + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + service = initTestService(); + }); + + describe('deleteRelationship', () => { + beforeEach(() => { + spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); + spyOn(objectCache, 'remove'); + service.deleteRelationship(relationships[0].uuid).subscribe(); + }); + + it('should send a DeleteRequest', () => { + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); + }); + + it('should clear the related items their cache', () => { + expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); + expect(objectCache.remove).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); + }); + }); + + describe('getItemRelationshipsArray', () => { + it('should return the item\'s relationships in the form of an array', () => { + service.getItemRelationshipsArray(item).subscribe((result) => { + expect(result).toEqual(relationships); + }); + }); + }); + + describe('getItemRelationshipLabels', () => { + it('should return the correct labels', () => { + service.getItemRelationshipLabels(item).subscribe((result) => { + expect(result).toEqual([relationshipType.rightLabel]); + }); + }); + }); + + describe('getRelatedItems', () => { + it('should return the related items', () => { + service.getRelatedItems(item).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }); + + describe('getRelatedItemsByLabel', () => { + it('should return the related items by label', () => { + service.getRelatedItemsByLabel(item, relationshipType.rightLabel).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }) + +}); + +function getRemotedataObservable(obj: any): Observable> { + return observableOf(new RemoteData(false, false, true, undefined, obj)); +} diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts new file mode 100644 index 0000000000..1699b6a27d --- /dev/null +++ b/src/app/core/data/relationship.service.ts @@ -0,0 +1,235 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { + configureRequest, + filterSuccessfulResponses, + getRemoteDataPayload, getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { DeleteRequest, 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 } from './remote-data'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { zip as observableZip } from 'rxjs'; +import { PaginatedList } from './paginated-list'; +import { ItemDataService } from './item-data.service'; +import { + compareArraysUsingIds, filterRelationsByTypeLabel, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +/** + * The service handling all relationship requests + */ +@Injectable() +export class RelationshipService { + protected linkPath = 'relationships'; + + constructor(protected requestService: RequestService, + protected halService: HALEndpointService, + protected rdbService: RemoteDataBuildService, + protected itemService: ItemDataService, + protected objectCache: ObjectCacheService) { + } + + /** + * Get the endpoint for a relationship by ID + * @param uuid + */ + getRelationshipEndpoint(uuid: string) { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${uuid}`) + ); + } + + /** + * Find a relationship by its UUID + * @param uuid + */ + findById(uuid: string): Observable> { + const href$ = this.getRelationshipEndpoint(uuid); + return this.rdbService.buildSingle(href$); + } + + /** + * Send a delete request for a relationship by ID + * @param uuid + */ + deleteRelationship(uuid: string): Observable { + return this.getRelationshipEndpoint(uuid).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), + getResponseFromEntry(), + tap(() => this.clearRelatedCache(uuid)) + ); + } + + /** + * Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types + * This is used for easier access of a relationship's type because they exist as observables + * @param item + */ + getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemRelationshipsArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types + * This is used for easier access of a relationship's type and left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves + * This is used for easier access of the relationship and their left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipsArray(item) + ); + } + + /** + * Get an item their relationships in the form of an array + * @param item + */ + getItemRelationshipsArray(item: Item): Observable { + return item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((rels: PaginatedList) => rels.page), + hasValueOperator(), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item their relationship types in the form of an array + * @param item + */ + getItemRelationshipTypesArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload).filter((type) => hasValue(type))), + filter((arr) => arr.length === rels.length) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's left-side related items in the form of an array + * @param item + */ + getItemLeftRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's right-side related items in the form of an array + * @param item + */ + getItemRightRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an array of an item their unique relationship type's labels + * The array doesn't contain any duplicate labels + * @param item + */ + getItemRelationshipLabels(item: Item): Observable { + return this.getItemResolvedRelatedItemsAndTypes(item).pipe( + map(([leftItems, rightItems, relTypesCurrentPage]) => { + return relTypesCurrentPage.map((type, index) => { + if (leftItems[index].uuid === item.uuid) { + return type.leftLabel; + } else { + return type.rightLabel; + } + }); + }), + map((labels: string[]) => Array.from(new Set(labels))) + ) + } + + /** + * Resolve a given item's relationships into related items and return the items as an array + * @param item + */ + getRelatedItems(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + relationsToItems(item.uuid) + ); + } + + /** + * Resolve a given item's relationships into related items, filtered by a relationship label + * and return the items as an array + * @param item + * @param label + */ + getRelatedItemsByLabel(item: Item, label: string): Observable { + return this.getItemResolvedRelsAndTypes(item).pipe( + filterRelationsByTypeLabel(label), + relationsToItems(item.uuid) + ); + } + + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid + */ + clearRelatedCache(uuid: string) { + this.findById(uuid).pipe( + getSucceededRemoteData(), + flatMap((rd: RemoteData) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))), + take(1) + ).subscribe(([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/core/data/request.service.ts b/src/app/core/data/request.service.ts index 83071382ed..775118dbc0 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -65,8 +65,7 @@ const uuidsFromHrefSubstringSelector = const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => { let result = []; if (isNotEmpty(state)) { - result = Object.values(state) - .filter((value: string) => value.startsWith(href)); + result = Object.keys(state).filter((key) => key.startsWith(href)).map((key) => state[key]); } return result; }; @@ -315,4 +314,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)) + ); + } + } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 515ea3cd69..a3e625c022 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,10 +5,11 @@ import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; import { Relationship } from './item-relationships/relationship.model'; import { ResourceType } from './resource-type'; +import { getSucceededRemoteData } from './operators'; export class Item extends DSpaceObject { static type = new ResourceType('item'); @@ -97,7 +98,7 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( - filter((rd: RemoteData>) => !rd.isResponsePending && isNotUndefined(rd.payload)), + getSucceededRemoteData(), map((rd: RemoteData>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), take(1), diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index ae46691e39..d46c688e68 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -91,7 +91,7 @@ export const toDSpaceObjectListRD = () => source.pipe( filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { - const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.indexableObject); + const dsoPage: T[] = rd.payload.page.filter((result) => hasValue(result)).map((searchResult: SearchResult) => searchResult.indexableObject); const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList; return Object.assign(rd, { payload: payload }); }) diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts index 68a05b66c3..d13feda406 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts index 5a8fca5fc6..8c854aeb77 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts index 8c12c1a266..0d0f77842a 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { JournalGridElementComponent } from './journal-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -39,7 +42,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts index 39ddea4c7b..15c7b75bf5 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { OrgunitGridElementComponent } from './orgunit-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -39,7 +42,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts index a0f8e4c29e..25268261e1 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { PersonGridElementComponent } from './person-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts index 9ad26935b7..969912976c 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts @@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { ProjectGridElementComponent } from './project-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { @@ -27,7 +30,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), { const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { - bitstreams: observableOf({}), + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { diff --git a/src/app/shared/lang-switch/lang-switch.component.html b/src/app/shared/lang-switch/lang-switch.component.html index 745facc95c..b61ec5592e 100644 --- a/src/app/shared/lang-switch/lang-switch.component.html +++ b/src/app/shared/lang-switch/lang-switch.component.html @@ -4,7 +4,7 @@