mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
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:
@@ -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.",
|
||||||
|
@@ -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;
|
||||||
|
}
|
@@ -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 {
|
||||||
|
@@ -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"> {{"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"> {{"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"> {{"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"> {{"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"> {{"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"> {{"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"> {{"item.edit.bitstreams.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -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);
|
||||||
|
}
|
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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
|
||||||
*/
|
*/
|
||||||
|
48
src/app/core/data/bitstream-data.service.spec.ts
Normal file
48
src/app/core/data/bitstream-data.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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$;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
16
src/app/core/shared/item-bitstreams-utils.ts
Normal file
16
src/app/core/shared/item-bitstreams-utils.ts
Normal 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)
|
||||||
|
);
|
@@ -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 */
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
101
src/app/shared/trackable/abstract-trackable.component.spec.ts
Normal file
101
src/app/shared/trackable/abstract-trackable.component.spec.ts
Normal 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});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
78
src/app/shared/trackable/abstract-trackable.component.ts
Normal file
78
src/app/shared/trackable/abstract-trackable.component.ts
Normal 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');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user