From cdb2c30bd45e233a5de336cbc67f0ddd0b566361 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Wed, 10 Jul 2019 11:14:21 +0200 Subject: [PATCH 01/12] 63667: Extract update tracker --- src/app/shared/shared.module.ts | 5 +- .../abstract-trackable.component.spec.ts | 101 ++++++++++++++++++ .../trackable/abstract-trackable.component.ts | 78 ++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/trackable/abstract-trackable.component.spec.ts create mode 100644 src/app/shared/trackable/abstract-trackable.component.ts 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..cd1b425f10 --- /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 + */ + 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'); + + } +} From 4f3ec612a18ea5e34569c1b4f17ead73bb266887 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 25 Jul 2019 13:39:28 +0200 Subject: [PATCH 02/12] 63945: Abstract item-update component --- .../abstract-item-update.component.ts | 120 +++++++++++++ .../item-metadata/item-metadata.component.ts | 160 +++--------------- .../trackable/abstract-trackable.component.ts | 4 +- 3 files changed, 149 insertions(+), 135 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts 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/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 6b3e05c818..9b1d2c9648 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'; @@ -20,6 +18,7 @@ import { RegistryService } from '../../../core/registry/registry.service'; import { MetadataField } from '../../../core/metadata/metadatafield.model'; import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { Metadata } from '../../../core/shared/metadata.utils'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; @Component({ selector: 'ds-item-metadata', @@ -29,28 +28,7 @@ import { Metadata } from '../../../core/shared/metadata.utils'; /** * 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/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts index cd1b425f10..e1a99d90b9 100644 --- a/src/app/shared/trackable/abstract-trackable.component.ts +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -63,7 +63,7 @@ export class AbstractTrackableComponent { * Get translated notification title * @param key */ - private getNotificationTitle(key: string) { + getNotificationTitle(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.title'); } @@ -71,7 +71,7 @@ export class AbstractTrackableComponent { * Get translated notification content * @param key */ - private getNotificationContent(key: string) { + getNotificationContent(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.content'); } From 4461e9025be6cd43eda70a1f3602a7cf6511bc78 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 25 Jul 2019 17:46:23 +0200 Subject: [PATCH 03/12] 63945: Edit bitstream tab intermediate commit --- resources/i18n/en.json | 18 +++++ .../edit-item-page/edit-item-page.module.ts | 4 +- .../item-bitstreams.component.html | 70 ++++++++++++++++++- .../item-bitstreams.component.ts | 63 ++++++++++++++++- .../item-edit-bitstream.component.html | 23 ++++++ .../item-edit-bitstream.component.ts | 64 +++++++++++++++++ .../object-updates/object-updates.service.ts | 21 ++++++ src/app/core/shared/item-bitstreams-utils.ts | 50 +++++++++++++ 8 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html create mode 100644 src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts create mode 100644 src/app/core/shared/item-bitstreams-utils.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a066ffe9d0..cc81394386 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -269,6 +269,24 @@ "content": "Your changes to this item's metadata were saved." } } + }, + "bitstreams": { + "discard-button": "Discard", + "reinstate-button": "Undo", + "save-button": "Save", + "headers": { + "name": "Name", + "description": "Description", + "format": "Format", + "actions": "Actions", + "bundle": "Bundle" + }, + "edit": { + "buttons": { + "remove": "Remove", + "undo": "Undo changes" + } + } } } }, 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..77f22e12ea 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,71 @@ -
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + +
{{'item.edit.bitstreams.headers.name' | translate}}{{'item.edit.bitstreams.headers.description' | translate}}{{'item.edit.bitstreams.headers.format' | translate}}{{'item.edit.bitstreams.headers.actions' | translate}}
{{'item.edit.bitstreams.headers.bundle' | translate}}: {{ updatesItem.key }}
+
+
+ + + +
+
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..1c347f6270 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,11 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { switchMap, take } from 'rxjs/operators'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { getBundleNames, toBitstreamsArray, toBundleMap } from '../../../core/shared/item-bitstreams-utils'; +import { Observable } from 'rxjs/internal/Observable'; +import { FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Subscription } from 'rxjs/internal/Subscription'; @Component({ selector: 'ds-item-bitstreams', @@ -8,6 +15,56 @@ 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 { + + bundleNames$: Observable; + + updatesMap: Map>; + + updatesMapSub: Subscription; + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.bundleNames$ = this.item.bitstreams.pipe(getBundleNames()); + } + + initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.bitstreams.notifications.'; + } + + initializeOriginalFields(): void { + this.item.bitstreams.pipe( + toBitstreamsArray(), + take(1) + ).subscribe((bitstreams: Bitstream[]) => { + this.objectUpdatesService.initialize(this.url, bitstreams, this.item.lastModified); + }); + } + + initializeUpdates(): void { + this.updates$ = this.item.bitstreams.pipe( + toBitstreamsArray(), + switchMap((bitstreams: Bitstream[]) => this.objectUpdatesService.getFieldUpdates(this.url, bitstreams)) + ); + this.updatesMapSub = this.item.bitstreams.pipe( + toBundleMap() + ).subscribe((bundleMap: Map) => { + const updatesMap = new Map(); + bundleMap.forEach((bitstreams: Bitstream[], bundleName: string) => { + updatesMap.set(bundleName, this.objectUpdatesService.getFieldUpdatesExclusive(this.url, bitstreams)); + }); + this.updatesMap = updatesMap; + }); + } + + submit() { + // TODO: submit changes + } + + ngOnDestroy(): void { + this.updatesMapSub.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..6a931df074 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -0,0 +1,23 @@ + + {{ bitstream.name }} + + + {{ bitstream.description }} + + + {{ (format$ | async).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..dccfaea5d6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts @@ -0,0 +1,64 @@ +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', +}) +export class ItemEditBitstreamComponent implements OnChanges { + @Input() fieldUpdate: FieldUpdate; + + @Input() url: string; + + bitstream: Bitstream; + + format$: Observable; + + constructor(private objectUpdatesService: ObjectUpdatesService) { + } + + 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/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/shared/item-bitstreams-utils.ts b/src/app/core/shared/item-bitstreams-utils.ts new file mode 100644 index 0000000000..9d91b0ba76 --- /dev/null +++ b/src/app/core/shared/item-bitstreams-utils.ts @@ -0,0 +1,50 @@ +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 { combineLatest as observableCombineLatest } from 'rxjs'; +import { getSucceededRemoteData } from './operators'; + +export const toBitstreamsArray = () => + (source: Observable>>): Observable => + source.pipe( + getSucceededRemoteData(), + map((bitstreamRD: RemoteData>) => bitstreamRD.payload.page) + ); + +export const getBundleNames = () => + (source: Observable>>): Observable => + source.pipe( + toBitstreamsArray(), + map((bitstreams: Bitstream[]) => { + const result = []; + bitstreams.forEach((bitstream: Bitstream) => { + if (result.indexOf(bitstream.bundleName) < 0) { + result.push(bitstream.bundleName); + } + }); + return result; + }) + ); + +export const filterByBundleName = (bundleName: string) => + (source: Observable>>): Observable => + source.pipe( + toBitstreamsArray(), + map((bitstreams: Bitstream[]) => + bitstreams.filter((bitstream: Bitstream) => bitstream.bundleName === bundleName) + ) + ); + +export const toBundleMap = () => + (source: Observable>>): Observable> => + observableCombineLatest(source.pipe(toBitstreamsArray()), source.pipe(getBundleNames())).pipe( + map(([bitstreams, bundleNames]) => { + const bundleMap = new Map(); + bundleNames.forEach((bundleName: string) => { + bundleMap.set(bundleName, bitstreams.filter((bitstream: Bitstream) => bitstream.bundleName === bundleName)); + }); + return bundleMap; + }) + ); From 277aad26c887d6e20cddfdd25abf989803775743 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 26 Jul 2019 10:32:51 +0200 Subject: [PATCH 04/12] 63945: BitstreamDataService + bitstream remove --- .../item-bitstreams.component.ts | 42 +++++++++++++++++-- src/app/core/data/bitstream-data.service.ts | 40 ++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/app/core/data/bitstream-data.service.ts 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 1c347f6270..2bd6ff04a2 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,11 +1,22 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, Inject, OnDestroy } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; -import { switchMap, take } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { getBundleNames, toBitstreamsArray, toBundleMap } from '../../../core/shared/item-bitstreams-utils'; import { Observable } from 'rxjs/internal/Observable'; -import { FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +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 { isNotEmptyOperator } from '../../../shared/empty.util'; +import { zip as observableZip } from 'rxjs'; +import { RestResponse } from '../../../core/cache/response.models'; @Component({ selector: 'ds-item-bitstreams', @@ -23,6 +34,19 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme updatesMapSub: 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 + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + /** * Set up and initialize all fields */ @@ -61,7 +85,17 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme } submit() { - // TODO: submit changes + const removedBitstreams$ = this.item.bitstreams.pipe( + toBitstreamsArray(), + switchMap((bitstreams: Bitstream[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, bitstreams) 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.delete(bitstream)))) + ).subscribe(); } ngOnDestroy(): void { diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts new file mode 100644 index 0000000000..76839c7ea3 --- /dev/null +++ b/src/app/core/data/bitstream-data.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { DataService } from './data.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { BrowseService } from '../browse/browse.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindAllOptions } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; + +@Injectable() +export class BitstreamDataService extends DataService { + protected linkPath = 'bitstreams'; + protected forceBypassCache = false; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected bs: BrowseService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer) { + super(); + } + + getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return this.halService.getEndpoint(linkPath); + } +} From f93a104d689a33d8b5c0ccb6686db0115a9d8e1e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 26 Jul 2019 10:37:51 +0200 Subject: [PATCH 05/12] 63945: deleteAndReturnResponse on data-service --- src/app/core/data/data.service.ts | 33 +++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index fc4da69a5c..5693b31d2a 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; } /** From 27ec82814231f8c564110b628de938b9a35d2bd7 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 26 Jul 2019 13:28:32 +0200 Subject: [PATCH 06/12] 63945: Bitstream delete notifications, de-caching, reloading and small layout changes --- resources/i18n/en.json | 17 +++ .../item-bitstreams.component.html | 66 +++++----- .../item-bitstreams.component.ts | 119 ++++++++++++++++-- src/app/core/core.module.ts | 2 + src/app/core/data/bitstream-data.service.ts | 8 ++ src/app/core/data/request.service.ts | 11 ++ 6 files changed, 182 insertions(+), 41 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index cc81394386..b503696178 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -286,6 +286,23 @@ "remove": "Remove", "undo": "Undo changes" } + }, + "notifications": { + "outdated": { + "title": "Changed outdated", + "content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts" + }, + "discarded": { + "title": "Changed discarded", + "content": "Your changes were discarded. To reinstate your changes click the 'Undo' button" + }, + "saved": { + "title": "Bitstreams saved", + "content": "Your changes to this item's bitstreams were saved." + }, + "failed": { + "title": "Error deleting bitstream" + } } } } 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 77f22e12ea..d74f8fe352 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,24 +1,26 @@
-
- - - +
+
+ + + +
- +
@@ -29,27 +31,29 @@ - + - - - + + + + +
{{'item.edit.bitstreams.headers.name' | translate}}
{{'item.edit.bitstreams.headers.bundle' | translate}}: {{ updatesItem.key }}
-
+
- - - - - - - - - + +
{{'item.edit.bitstreams.headers.name' | translate}}{{'item.edit.bitstreams.headers.description' | translate}}{{'item.edit.bitstreams.headers.format' | translate}}{{'item.edit.bitstreams.headers.actions' | translate}}
+ - - - - + + + + + - - - - - + + + - - -
{{'item.edit.bitstreams.headers.bundle' | translate}}: {{ updatesItem.key }}{{'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}}
+ + + + +
- - -
+
+ + + +
+ + + +
- -