diff --git a/resources/i18n/en.json b/resources/i18n/en.json index d2a974ea42..308e1708c7 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -182,6 +182,27 @@ "item.bitstreams.upload.failed": "Upload failed. Please verify the content before retrying.", "item.bitstreams.upload.item": "Item: ", "item.bitstreams.upload.title": "Upload bitstream", + "item.edit.bitstreams.discard-button": "Discard", + "item.edit.bitstreams.edit.buttons.download": "Download", + "item.edit.bitstreams.edit.buttons.edit": "Edit", + "item.edit.bitstreams.edit.buttons.remove": "Remove", + "item.edit.bitstreams.edit.buttons.undo": "Undo changes", + "item.edit.bitstreams.empty": "This item doesn't contain any bitstreams. Click the upload button to create one.", + "item.edit.bitstreams.headers.actions": "Actions", + "item.edit.bitstreams.headers.bundle": "Bundle", + "item.edit.bitstreams.headers.description": "Description", + "item.edit.bitstreams.headers.format": "Format", + "item.edit.bitstreams.headers.name": "Name", + "item.edit.bitstreams.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + "item.edit.bitstreams.notifications.discarded.title": "Changes discarded", + "item.edit.bitstreams.notifications.failed.title": "Error deleting bitstream", + "item.edit.bitstreams.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.bitstreams.notifications.outdated.title": "Changes outdated", + "item.edit.bitstreams.notifications.saved.content": "Your changes to this item's bitstreams were saved.", + "item.edit.bitstreams.notifications.saved.title": "Changes saved", + "item.edit.bitstreams.reinstate-button": "Undo", + "item.edit.bitstreams.save-button": "Save", + "item.edit.bitstreams.upload-button": "Upload", "item.edit.delete.cancel": "Cancel", "item.edit.delete.confirm": "Delete", "item.edit.delete.description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", 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..255532c8c4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -0,0 +1,120 @@ +import { 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'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; + +@Injectable() +/** + * Abstract component for managing object updates of an item + */ +export abstract class AbstractItemUpdateComponent extends AbstractTrackableComponent 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; + + constructor( + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute + ) { + super(objectUpdatesService, notificationsService, translateService) + } + + /** + * 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; + } + + /** + * 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; +} 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..bb3bf3ecb3 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,7 @@ 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 { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -38,7 +39,8 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo ItemStatusComponent, ItemMetadataComponent, ItemBitstreamsComponent, - EditInPlaceFieldComponent + EditInPlaceFieldComponent, + ItemEditBitstreamComponent ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index b80e6e0678..9c4248475b 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -1,3 +1,85 @@ -
+
+
+ + + + +
+ + + + + + + + + + + + + + + +
{{'item.edit.bitstreams.headers.name' | translate}}{{'item.edit.bitstreams.headers.bundle' | translate}}{{'item.edit.bitstreams.headers.description' | translate}}{{'item.edit.bitstreams.headers.format' | translate}}{{'item.edit.bitstreams.headers.actions' | translate}}
+
+ + + +
+
+ + + +
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts new file mode 100644 index 0000000000..3ead77ae92 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -0,0 +1,175 @@ +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Item } from '../../../core/shared/item.model'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemBitstreamsComponent } from './item-bitstreams.component'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { TranslateModule } from '@ngx-translate/core'; +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 { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { getMockRequestService } from '../../../shared/mocks/mock-request.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; +import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; + +let comp: ItemBitstreamsComponent; +let fixture: ComponentFixture; + +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 bitstream1 = Object.assign(new Bitstream(), { + uuid: 'bitstream1' +}); +const bitstream2 = Object.assign(new Bitstream(), { + uuid: 'bitstream2' +}); +const fieldUpdate1 = { + field: bitstream1, + changeType: undefined +}; +const fieldUpdate2 = { + field: bitstream2, + changeType: FieldChangeType.REMOVE +}; +const date = new Date(); +const url = 'thisUrl'; +let item: Item; +let itemService: ItemDataService; +let objectUpdatesService: ObjectUpdatesService; +let router: Router; +let route: ActivatedRoute; +let notificationsService: NotificationsService; +let bitstreamService: BitstreamDataService; +let objectCache: ObjectCacheService; +let requestService: RequestService; +let searchConfig: SearchConfigurationService; + +describe('ItemBitstreamsComponent', () => { + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + getFieldUpdatesExclusive: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([bitstream1, bitstream2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true) + } + ); + router = Object.assign(new RouterStub(), { + url: url + }); + notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + bitstreamService = jasmine.createSpyObj('bitstreamService', { + deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse') + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + requestService = getMockRequestService(); + searchConfig = Object.assign( { + paginatedSearchOptions: observableOf({}) + }); + + item = Object.assign(new Item(), { + uuid: 'item', + id: 'item', + bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2]), + lastModified: date + }); + itemService = Object.assign( { + getBitstreams: () => createMockRDPaginatedObs([bitstream1, bitstream2]), + findById: () => createMockRDObs(item) + }); + route = Object.assign({ + parent: { + data: observableOf({ item: createMockRD(item) }) + }, + url: url + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: BitstreamDataService, useValue: bitstreamService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: SearchConfigurationService, useValue: searchConfig }, + ChangeDetectorRef + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemBitstreamsComponent); + comp = fixture.componentInstance; + comp.url = url; + fixture.detectChanges(); + }); + + describe('when submit is called', () => { + beforeEach(() => { + comp.submit(); + }); + + it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => { + expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2); + }); + + it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => { + expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1); + }); + }); +}); + +export function createMockRDPaginatedObs(list: any[]) { + return createMockRDObs(new PaginatedList(new PageInfo(), list)); +} + +export function createMockRDObs(obj: any) { + return observableOf(createMockRD(obj)); +} + +export function createMockRD(obj: any) { + return new RemoteData(false, false, true, null, obj); +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 71f25cd5cf..0add78f3d7 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,4 +1,31 @@ -import { Component } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { toBitstreamsArray } from '../../../core/shared/item-bitstreams-utils'; +import { Observable } from 'rxjs/internal/Observable'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Subscription } from 'rxjs/internal/Subscription'; +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 { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; +import { zip as observableZip } from 'rxjs'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; +import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; @Component({ selector: 'ds-item-bitstreams', @@ -8,6 +35,174 @@ import { Component } from '@angular/core'; /** * Component for displaying an item's bitstreams edit page */ -export class ItemBitstreamsComponent { - /* TODO implement */ +export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy { + + /** + * The currently listed bitstreams + */ + bitstreams$: BehaviorSubject>> = new BehaviorSubject>>(null); + + /** + * The current paginated search options + */ + searchOptions$: 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 bitstreams are deleted + */ + itemUpdateSubscription: Subscription; + + /** + * A subscription keeping track of the current search options and applying them to the bitstreams$ observable + */ + bitstreamsSubscription: Subscription; + + constructor( + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute, + public bitstreamService: BitstreamDataService, + public objectCache: ObjectCacheService, + public requestService: RequestService, + public cdRef: ChangeDetectorRef, + public searchConfig: SearchConfigurationService + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.searchOptions$ = this.searchConfig.paginatedSearchOptions; + this.initializeBitstreamsUpdate(); + this.initializeItemUpdate(); + } + + /** + * Initialize the notification messages prefix + */ + initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.bitstreams.notifications.'; + } + + /** + * Initialize the original fields for the object-updates-service + */ + initializeOriginalFields(): void { + this.objectUpdatesService.initialize(this.url, [], this.item.lastModified); + } + + /** + * Initialize field updates + */ + initializeUpdates(): void { + this.updates$ = this.bitstreams$.pipe( + toBitstreamsArray(), + switchMap((bitstreams: Bitstream[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, bitstreams)) + ); + } + + /** + * Update the item (and view) when it's removed in the request cache + * Also re-initialize the original fields and updates + */ + 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) => { + if (hasValue(itemRD)) { + this.item = itemRD.payload; + this.initializeOriginalFields(); + this.initializeUpdates(); + // Navigate back to the first page to force a reload of the bitstream page + this.router.navigate([this.url], {queryParamsHandling: 'merge', queryParams: {page: 0}}); + this.cdRef.detectChanges(); + } + }); + } + + /** + * Initialize the bitstream update subscription, which keeps track of the current search options and applies + * them to the bitstreams$ observable by sending out a REST request + */ + initializeBitstreamsUpdate(): void { + this.bitstreamsSubscription = this.searchOptions$.pipe( + switchMap((searchOptions) => this.itemService.getBitstreams(this.item.id, searchOptions)) + ).subscribe((bitsreams: RemoteData>) => { + this.bitstreams$.next(bitsreams); + }); + } + + /** + * Submit the current changes + * Bitstreams marked as deleted send out a delete request to the rest API + * Display notifications and reset the current item/updates + */ + submit() { + const removedBitstreams$ = this.bitstreams$.pipe( + toBitstreamsArray(), + switchMap((bitstreams: Bitstream[]) => this.objectUpdatesService.getFieldUpdates(this.url, bitstreams, true) as Observable), + map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field)), + isNotEmptyOperator() + ); + removedBitstreams$.pipe( + take(1), + switchMap((removedBistreams: Bitstream[]) => observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream)))) + ).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')); + } + } + + /** + * De-cache the current item (it should automatically reload due to itemUpdateSubscription) + */ + reset() { + this.refreshItemCache(); + this.initializeItemUpdate(); + } + + /** + * Remove the current item's cache from object- and request-cache + */ + refreshItemCache() { + this.objectCache.remove(this.item.self); + this.requestService.removeByHrefSubstring(this.item.self); + } + + /** + * Unsubscribe from open subscriptions whenever the component gets destroyed + */ + ngOnDestroy(): void { + this.itemUpdateSubscription.unsubscribe(); + this.bitstreamsSubscription.unsubscribe(); + } } diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html new file mode 100644 index 0000000000..1dc53fc4b0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -0,0 +1,35 @@ + + {{ bitstream.name }} + + + {{ bitstream.bundleName }} + + + {{ bitstream.description }} + + + {{ (format$ | async).shortDescription }} + + +
+ + + + + + +
+ diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts new file mode 100644 index 0000000000..dd85931664 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts @@ -0,0 +1,96 @@ +import { ItemEditBitstreamComponent } from './item-edit-bitstream.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { createMockRDObs } from '../item-bitstreams.component.spec'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { By } from '@angular/platform-browser'; + +let comp: ItemEditBitstreamComponent; +let fixture: ComponentFixture; + +const format = Object.assign(new BitstreamFormat(), { + shortDescription: 'PDF' +}); +const bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUUID', + name: 'Fake Bitstream', + bundleName: 'ORIGINAL', + description: 'Description', + format: createMockRDObs(format) +}); +const fieldUpdate = { + field: bitstream, + changeType: undefined +}; +const date = new Date(); +const url = 'thisUrl'; + +let objectUpdatesService: ObjectUpdatesService; + +describe('ItemEditBitstreamComponent', () => { + let tdElements: DebugElement[]; + + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [bitstream.uuid]: fieldUpdate, + }), + getFieldUpdatesExclusive: observableOf({ + [bitstream.uuid]: fieldUpdate, + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([bitstream]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true) + } + ); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemEditBitstreamComponent, VarDirective], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemEditBitstreamComponent); + comp = fixture.componentInstance; + comp.fieldUpdate = fieldUpdate; + comp.url = url; + comp.ngOnChanges(undefined); + fixture.detectChanges(); + + tdElements = fixture.debugElement.queryAll(By.css('td')); + }); + + it('should display the bitstream\'s name in the first table cell', () => { + expect(tdElements[0].nativeElement.textContent.trim()).toEqual(bitstream.name); + }); + + it('should display the bitstream\'s bundle in the second table cell', () => { + expect(tdElements[1].nativeElement.textContent.trim()).toEqual(bitstream.bundleName); + }); + + it('should display the bitstream\'s description in the third table cell', () => { + expect(tdElements[2].nativeElement.textContent.trim()).toEqual(bitstream.description); + }); + + it('should display the bitstream\'s format in the fourth table cell', () => { + expect(tdElements[3].nativeElement.textContent.trim()).toEqual(format.shortDescription); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts new file mode 100644 index 0000000000..6086da541f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts @@ -0,0 +1,83 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { cloneDeep } from 'lodash'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { Observable } from 'rxjs/internal/Observable'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-item-edit-bitstream]', + templateUrl: './item-edit-bitstream.component.html', +}) +/** + * Component that displays a single bitstream of an item on the edit page + */ +export class ItemEditBitstreamComponent implements OnChanges { + /** + * The current field, value and state of the bitstream + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The bitstream of this field + */ + bitstream: Bitstream; + + /** + * The format of the bitstream + */ + format$: Observable; + + constructor(private objectUpdatesService: ObjectUpdatesService) { + } + + /** + * Update the current bitstream and its format on changes + * @param changes + */ + ngOnChanges(changes: SimpleChanges): void { + this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream; + this.format$ = this.bitstream.format.pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ); + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.bitstream); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.bitstream.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-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 8148b89bd4..ca266da364 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, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - private route: ActivatedRoute, - private metadataFieldService: RegistryService, + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute, + public 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(); - } - }); + /** + * Initialize the values and updates of the current item's metadata fields + */ + public initializeUpdates(): void { this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.metadata.notifications.'; } /** @@ -104,44 +76,20 @@ 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() { + public 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; - } - /** * 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; @@ -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 */ diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts new file mode 100644 index 0000000000..df55376768 --- /dev/null +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -0,0 +1,48 @@ +import { BitstreamDataService } from './bitstream-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RequestService } from './request.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +describe('BitstreamDataService', () => { + let service: BitstreamDataService; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + + const bitstream = Object.assign(new Bitstream(), { + uuid: 'fake-bitstream', + self: 'fake-bitstream-self' + }); + const url = 'fake-bitstream-url'; + + beforeEach(() => { + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + requestService = getMockRequestService(); + halService = Object.assign(new HALEndpointServiceStub(url)); + + service = new BitstreamDataService(requestService, null, null, null, null, objectCache, halService, null, null, null); + }); + + describe('when deleting a bitstream', () => { + let response$: Observable; + + beforeEach(() => { + response$ = service.deleteAndReturnResponse(bitstream); + }); + + it('should de-cache the bitstream\'s object cache', () => { + expect(objectCache.remove).toHaveBeenCalledWith(bitstream.self); + }); + + it('should de-cache the bitstream\'s request cache', () => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(bitstream.self); + }); + }); +}); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 361c38861a..679ea0035b 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -14,6 +14,7 @@ import { HttpClient } from '@angular/common/http'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindAllOptions } from './request.models'; import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; /** * A service responsible for fetching/sending data from/to the REST API on the bitstreams endpoint @@ -46,4 +47,17 @@ export class BitstreamDataService extends DataService { getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { return this.halService.getEndpoint(linkPath); } + + /** + * Delete an existing DSpace Object on the server + * @param bitstream The Bitstream to be removed + * De-cache the removed bitstream from Object and Request cache + * Return an observable of the completed response + */ + deleteAndReturnResponse(bitstream: Bitstream): Observable { + const response$ = super.deleteAndReturnResponse(bitstream); + this.objectCache.remove(bitstream.self); + this.requestService.removeByHrefSubstring(bitstream.self); + return response$; + } } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ad0db51980..d1f6444a4f 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -284,6 +284,34 @@ export abstract class DataService { * Return an observable that emits true when the deletion was successful, false when it failed */ delete(dso: T): Observable { + const requestId = this.deleteAndReturnRequestId(dso); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response.isSuccessful) + ); + } + + /** + * Delete an existing DSpace Object on the server + * @param dso The DSpace Object to be removed + * Return an observable of the completed response + */ + deleteAndReturnResponse(dso: T): Observable { + const requestId = this.deleteAndReturnRequestId(dso); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + + /** + * Delete an existing DSpace Object on the server + * @param dso The DSpace Object to be removed + * Return the delete request's ID + */ + deleteAndReturnRequestId(dso: T): string { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -297,10 +325,7 @@ export abstract class DataService { }) ).subscribe(); - return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response.isSuccessful) - ); + return requestId; } /** diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index a275b611c4..dcfb9d894a 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,4 +1,4 @@ -import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; @@ -12,7 +12,7 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions, PatchRequest, RestRequest } from './request.models'; +import { FindAllOptions, GetRequest, PatchRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @@ -20,6 +20,10 @@ import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; import { RequestEntry } from './request.reducer'; +import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; +import { Bitstream } from '../shared/bitstream.model'; @Injectable() export class ItemDataService extends DataService { @@ -128,4 +132,25 @@ export class ItemDataService extends DataService { switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) ); } + + /** + * Get an item's bitstreams using paginated search options + * @param itemId The item's ID + * @param searchOptions The search options to use + */ + public getBitstreams(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { + const hrefObs = this.getItemWithdrawEndpoint(itemId).pipe( + map((href) => `${href}/bitstreams`), + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + ); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs); + } + } 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..c93dbe5c6b 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -88,12 +88,14 @@ export class ObjectUpdatesService { * 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 + * @param ignoreStates Ignore the fieldStates to loop over the fieldUpdates instead */ - getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe(map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; - Object.keys(objectEntry.fieldStates).forEach((uuid) => { + console.log(objectEntry); + Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => { let fieldUpdate = objectEntry.fieldUpdates[uuid]; if (isEmpty(fieldUpdate)) { const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); @@ -105,6 +107,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/request.service.ts b/src/app/core/data/request.service.ts index 83071382ed..36d554b1ec 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -315,4 +315,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-bitstreams-utils.ts b/src/app/core/shared/item-bitstreams-utils.ts new file mode 100644 index 0000000000..122d2c10ae --- /dev/null +++ b/src/app/core/shared/item-bitstreams-utils.ts @@ -0,0 +1,16 @@ +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { Bitstream } from './bitstream.model'; +import { map } from 'rxjs/operators'; +import { hasValueOperator } from '../../shared/empty.util'; + +/** + * Operator for turning the current page of bitstreams into an array + */ +export const toBitstreamsArray = () => + (source: Observable>>): Observable => + source.pipe( + hasValueOperator(), + map((bitstreamRD: RemoteData>) => bitstreamRD.payload.page) + ); diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index 9a320b749c..2e1e27e6ad 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -9,8 +9,7 @@ export function getMockRequestService(requestEntry$: Observable = getByHref: requestEntry$, getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), - /* tslint:disable:no-empty */ - removeByHrefSubstring: () => {} - /* tslint:enable:no-empty */ + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'), + hasByHrefObservable: observableOf(true) }); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 816139c8b9..4654789b90 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -138,6 +138,7 @@ import { RoleDirective } from './roles/role.directive'; import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; +import { AbstractTrackableComponent } from './trackable/abstract-trackable.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -257,7 +258,8 @@ const COMPONENTS = [ ItemSearchResultListElementComponent, TypedItemSearchResultListElementComponent, ItemTypeSwitcherComponent, - BrowseByComponent + BrowseByComponent, + AbstractTrackableComponent ]; const ENTRY_COMPONENTS = [ @@ -311,6 +313,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ const PROVIDERS = [ TruncatableService, MockAdminGuard, + AbstractTrackableComponent, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn diff --git a/src/app/shared/trackable/abstract-trackable.component.spec.ts b/src/app/shared/trackable/abstract-trackable.component.spec.ts new file mode 100644 index 0000000000..3755092263 --- /dev/null +++ b/src/app/shared/trackable/abstract-trackable.component.spec.ts @@ -0,0 +1,101 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AbstractTrackableComponent } from './abstract-trackable.component'; +import { INotification, Notification } from '../notifications/models/notification.model'; +import { NotificationType } from '../notifications/models/notification-type'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +describe('AbstractTrackableComponent', () => { + let comp: AbstractTrackableComponent; + let fixture: ComponentFixture; + let objectUpdatesService; + let scheduler: TestScheduler; + + 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 url = 'http://test-url.com/test-url'; + + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + scheduler = getTestScheduler(); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [AbstractTrackableComponent], + providers: [ + {provide: ObjectUpdatesService, useValue: objectUpdatesService}, + {provide: NotificationsService, useValue: notificationsService}, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AbstractTrackableComponent); + comp = fixture.componentInstance; + comp.url = url; + + fixture.detectChanges(); + }); + + it('should discard object updates', () => { + comp.discard(); + + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + it('should undo the discard of object updates', () => { + comp.reinstate(); + + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + + describe('isReinstatable', () => { + beforeEach(() => { + objectUpdatesService.isReinstatable.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.isReinstatable()).toBe(expected, {a: true}); + }); + }); + + describe('hasChanges', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, {a: true}); + }); + }); + +}); diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts new file mode 100644 index 0000000000..e1a99d90b9 --- /dev/null +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -0,0 +1,78 @@ +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { Component } from '@angular/core'; + +/** + * Abstract Component that is able to track changes made in the inheriting component using the ObjectUpdateService + */ +@Component({ + selector: 'ds-abstract-trackable', + template: '' +}) +export class AbstractTrackableComponent { + + /** + * The time span for being able to undo discarding changes + */ + public discardTimeOut: number; + public message: string; + public url: string; + public notificationsPrefix = 'static-pages.form.notification'; + + constructor( + public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + public translateService: TranslateService, + ) { + + } + + /** + * 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 object is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Checks whether or not there are currently updates for this object + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Get translated notification title + * @param key + */ + getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +}