Merge branch 'w2p-63945_Edit-bitstream-tab' into w2p-64387_Edit+Upload-bitstreams

Conflicts:
	resources/i18n/en.json
	src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts
	src/app/core/core.module.ts
	src/app/core/data/bitstream-data.service.ts
	src/app/core/data/item-data.service.ts
This commit is contained in:
Kristof De Langhe
2019-08-21 17:47:53 +02:00
21 changed files with 1196 additions and 150 deletions

View File

@@ -182,6 +182,27 @@
"item.bitstreams.upload.failed": "Upload failed. Please verify the content before retrying.", "item.bitstreams.upload.failed": "Upload failed. Please verify the content before retrying.",
"item.bitstreams.upload.item": "Item: ", "item.bitstreams.upload.item": "Item: ",
"item.bitstreams.upload.title": "Upload bitstream", "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.cancel": "Cancel",
"item.edit.delete.confirm": "Delete", "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.", "item.edit.delete.description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.",

View File

@@ -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<FieldUpdates>;
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<Item>) => 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;
}

View File

@@ -15,6 +15,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.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 * 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, ItemStatusComponent,
ItemMetadataComponent, ItemMetadataComponent,
ItemBitstreamsComponent, ItemBitstreamsComponent,
EditInPlaceFieldComponent EditInPlaceFieldComponent,
ItemEditBitstreamComponent
] ]
}) })
export class EditItemPageModule { export class EditItemPageModule {

View File

@@ -1,3 +1,85 @@
<div> <div class="item-bitstreams">
<div class="button-row top d-flex mt-2">
<button class="mr-auto btn btn-success"
[routerLink]="['/items/', item.id, 'bitstreams', 'new']"><i
class="fas fa-upload"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.upload-button" | translate}}</span>
</button>
<button class="btn btn-danger mr-1" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning mr-1" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
</div>
<ds-pagination *ngIf="(bitstreams$ | async) && !(bitstreams$ | async)?.isLoading &&
(updates$ | async) && ((updates$ | async) | dsObjectValues).length > 0"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[pageInfoState]="(bitstreams$ | async)?.payload"
[collectionSize]="(bitstreams$ | async)?.payload?.totalElements"
[sortOptions]="(searchOptions$ | async)?.sort"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<table class="table table-responsive table-striped table-bordered">
<tbody>
<tr>
<th>{{'item.edit.bitstreams.headers.name' | translate}}</th>
<th>{{'item.edit.bitstreams.headers.bundle' | translate}}</th>
<th>{{'item.edit.bitstreams.headers.description' | translate}}</th>
<th class="text-center">{{'item.edit.bitstreams.headers.format' | translate}}</th>
<th class="text-center">{{'item.edit.bitstreams.headers.actions' | translate}}</th>
</tr>
<ng-container *ngVar="((updates$ | async) | dsObjectValues) as updateValues">
<tr *ngFor="let updateValue of updateValues"
ds-item-edit-bitstream
[fieldUpdate]="updateValue"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1
}">
</tr>
</ng-container>
</tbody>
</table>
</ds-pagination>
<div *ngIf="(bitstreams$ | async) && !(bitstreams$ | async)?.isLoading &&
!(updates$ | async) || (updates$ | async) && ((updates$ | async) | dsObjectValues).length === 0"
class="alert alert-info w-100 d-inline-block mt-4" role="alert">
{{'item.edit.bitstreams.empty' | translate}}
</div>
<ds-loading *ngIf="!(bitstreams$ | async) || (bitstreams$ | async)?.isLoading" message="{{'loading.bitstreams' | translate}}"></ds-loading>
<div class="button-row bottom">
<div class="my-2 float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
</div>
</div>
</div> </div>

View File

@@ -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<ItemBitstreamsComponent>;
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);
}

View File

@@ -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({ @Component({
selector: 'ds-item-bitstreams', selector: 'ds-item-bitstreams',
@@ -8,6 +35,174 @@ import { Component } from '@angular/core';
/** /**
* Component for displaying an item's bitstreams edit page * Component for displaying an item's bitstreams edit page
*/ */
export class ItemBitstreamsComponent { export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy {
/* TODO implement */
/**
* The currently listed bitstreams
*/
bitstreams$: BehaviorSubject<RemoteData<PaginatedList<Bitstream>>> = new BehaviorSubject<RemoteData<PaginatedList<Bitstream>>>(null);
/**
* The current paginated search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* 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<Item>) => {
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<PaginatedList<Bitstream>>) => {
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<FieldUpdates>),
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();
}
} }

View File

@@ -0,0 +1,35 @@
<td class="w-100">
{{ bitstream.name }}
</td>
<td>
{{ bitstream.bundleName }}
</td>
<td>
{{ bitstream.description }}
</td>
<td class="text-center">
{{ (format$ | async).shortDescription }}
</td>
<td class="text-center">
<div class="btn-group relationship-action-buttons">
<a [href]="bitstream?.content"
class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}">
<i class="fas fa-download fa-fw"></i>
</a>
<button [routerLink]="['/bitstreams/', bitstream.id, 'edit']" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!canRemove()" (click)="remove()"
class="btn btn-outline-danger btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.remove' | translate}}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<button [disabled]="!canUndo()" (click)="undo()"
class="btn btn-outline-warning btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.undo' | translate}}">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
</div>
</td>

View File

@@ -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<ItemEditBitstreamComponent>;
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);
});
});

View File

@@ -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<BitstreamFormat>;
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;
}
}

View File

@@ -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 { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.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 { cloneDeep } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
FieldUpdate,
FieldUpdates,
Identifiable Identifiable
} from '../../../core/data/object-updates/object-updates.reducer'; } from '../../../core/data/object-updates/object-updates.reducer';
import { first, map, switchMap, take, tap } from 'rxjs/operators'; 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 { RegistryService } from '../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils'; 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'; import { MetadataField } from '../../../core/metadata/metadata-field.model';
@Component({ @Component({
@@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model';
/** /**
* Component for displaying an item's metadata edit page * Component for displaying an item's metadata edit page
*/ */
export class ItemMetadataComponent implements OnInit { export class ItemMetadataComponent extends AbstractItemUpdateComponent {
/**
* The item to display the edit page for
*/
item: Item;
/**
* The current values and updates for all this item's metadata fields
*/
updates$: Observable<FieldUpdates>;
/**
* 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.';
/** /**
* Observable with a list of strings with all existing metadata field keys * Observable with a list of strings with all existing metadata field keys
@@ -58,90 +36,60 @@ export class ItemMetadataComponent implements OnInit {
metadataFields$: Observable<string[]>; metadataFields$: Observable<string[]>;
constructor( constructor(
private itemService: ItemDataService, public itemService: ItemDataService,
private objectUpdatesService: ObjectUpdatesService, public objectUpdatesService: ObjectUpdatesService,
private router: Router, public router: Router,
private notificationsService: NotificationsService, public notificationsService: NotificationsService,
private translateService: TranslateService, public translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
private route: ActivatedRoute, public route: ActivatedRoute,
private metadataFieldService: RegistryService, public metadataFieldService: RegistryService,
) { ) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
} }
/** /**
* Set up and initialize all fields * Set up and initialize all fields
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit();
this.metadataFields$ = this.findMetadataFields(); this.metadataFields$ = this.findMetadataFields();
this.route.parent.data.pipe(map((data) => data.item)) }
.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.item = item;
});
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; /**
this.url = this.router.url; * Initialize the values and updates of the current item's metadata fields
if (this.url.indexOf('?') > 0) { */
this.url = this.url.substr(0, this.url.indexOf('?')); public initializeUpdates(): void {
}
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
if (!hasChanges) {
this.initializeOriginalFields();
} else {
this.checkLastModified();
}
});
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); 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.';
}
/** /**
* Sends a new add update for a field to the object updates service * Sends a new add update for a field to the object updates service
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum * @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
*/ */
add(metadata: MetadatumViewModel = new MetadatumViewModel()) { add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); 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 * 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); 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 * 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 * Makes sure the new version of the item is rendered on the page
*/ */
submit() { public submit() {
this.isValid().pipe(first()).subscribe((isValid) => { this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) { if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>; const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
@@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit {
}); });
} }
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
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 * Method to request all metadata fields and convert them to a list of strings
*/ */

View File

@@ -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<RestResponse>;
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);
});
});
});

View File

@@ -14,6 +14,7 @@ import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { FindAllOptions } from './request.models'; import { FindAllOptions } from './request.models';
import { Observable } from 'rxjs/internal/Observable'; 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 * 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<Bitstream> {
getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> { getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return this.halService.getEndpoint(linkPath); 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<RestResponse> {
const response$ = super.deleteAndReturnResponse(bitstream);
this.objectCache.remove(bitstream.self);
this.requestService.removeByHrefSubstring(bitstream.self);
return response$;
}
} }

View File

@@ -284,6 +284,34 @@ export abstract class DataService<T extends CacheableObject> {
* Return an observable that emits true when the deletion was successful, false when it failed * Return an observable that emits true when the deletion was successful, false when it failed
*/ */
delete(dso: T): Observable<boolean> { delete(dso: T): Observable<boolean> {
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<RestResponse> {
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 requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -297,10 +325,7 @@ export abstract class DataService<T extends CacheableObject> {
}) })
).subscribe(); ).subscribe();
return this.requestService.getByUUID(requestId).pipe( return requestId;
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
} }
/** /**

View File

@@ -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 { Injectable } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -12,7 +12,7 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.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 { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.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 { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; import { configureRequest, getRequestFromRequestHref } from '../shared/operators';
import { RequestEntry } from './request.reducer'; 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() @Injectable()
export class ItemDataService extends DataService<Item> { export class ItemDataService extends DataService<Item> {
@@ -128,4 +132,25 @@ export class ItemDataService extends DataService<Item> {
switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) 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<RemoteData<PaginatedList<Bitstream>>> {
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<Bitstream>(hrefObs);
}
} }

View File

@@ -88,12 +88,14 @@ export class ObjectUpdatesService {
* a FieldUpdates object * a FieldUpdates object
* @param url The URL of the page for which the FieldUpdates should be requested * @param url The URL of the page for which the FieldUpdates should be requested
* @param initialFields The initial values of the fields * @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<FieldUpdates> { getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url); const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => { return objectUpdates.pipe(map((objectEntry) => {
const fieldUpdates: FieldUpdates = {}; 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]; let fieldUpdate = objectEntry.fieldUpdates[uuid];
if (isEmpty(fieldUpdate)) { if (isEmpty(fieldUpdate)) {
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); 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<FieldUpdates> {
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 * 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 * @param url The URL of the page on which the field resides

View File

@@ -315,4 +315,15 @@ export class RequestService {
return result; 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<boolean> {
return this.getByHref(href).pipe(
map((requestEntry: RequestEntry) => this.isValid(requestEntry))
);
}
} }

View File

@@ -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<RemoteData<PaginatedList<Bitstream>>>): Observable<Bitstream[]> =>
source.pipe(
hasValueOperator(),
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => bitstreamRD.payload.page)
);

View File

@@ -9,8 +9,7 @@ export function getMockRequestService(requestEntry$: Observable<RequestEntry> =
getByHref: requestEntry$, getByHref: requestEntry$,
getByUUID: requestEntry$, getByUUID: requestEntry$,
uriEncodeBody: jasmine.createSpy('uriEncodeBody'), uriEncodeBody: jasmine.createSpy('uriEncodeBody'),
/* tslint:disable:no-empty */ removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'),
removeByHrefSubstring: () => {} hasByHrefObservable: observableOf(true)
/* tslint:enable:no-empty */
}); });
} }

View File

@@ -138,6 +138,7 @@ import { RoleDirective } from './roles/role.directive';
import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; 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 { 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 { 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 = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -257,7 +258,8 @@ const COMPONENTS = [
ItemSearchResultListElementComponent, ItemSearchResultListElementComponent,
TypedItemSearchResultListElementComponent, TypedItemSearchResultListElementComponent,
ItemTypeSwitcherComponent, ItemTypeSwitcherComponent,
BrowseByComponent BrowseByComponent,
AbstractTrackableComponent
]; ];
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
@@ -311,6 +313,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [
const PROVIDERS = [ const PROVIDERS = [
TruncatableService, TruncatableService,
MockAdminGuard, MockAdminGuard,
AbstractTrackableComponent,
{ {
provide: DYNAMIC_FORM_CONTROL_MAP_FN, provide: DYNAMIC_FORM_CONTROL_MAP_FN,
useValue: dsDynamicFormControlMapFn useValue: dsDynamicFormControlMapFn

View File

@@ -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<AbstractTrackableComponent>;
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});
});
});
});

View File

@@ -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<boolean> {
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
* Checks whether or not there are currently updates for this object
*/
hasChanges(): Observable<boolean> {
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');
}
}