Merge remote-tracking branch 'remotes/origin/master' into #601-resource-policies

# Conflicts:
#	resources/i18n/en.json5
#	src/app/+item-page/edit-item-page/edit-item-page.module.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2020-04-04 15:59:44 +02:00
116 changed files with 5568 additions and 583 deletions

View File

@@ -268,6 +268,42 @@
"bitstream.edit.bitstream": "Bitstream: ",
"bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"<i>Main article</i>\" or \"<i>Experiment data readings</i>\".",
"bitstream.edit.form.description.label": "Description",
"bitstream.edit.form.embargo.hint": "The first day from which access is allowed. <b>This date cannot be modified on this form.</b> To set an embargo date for a bitstream, go to the <i>Item Status</i> tab, click <i>Authorizations...</i>, create or edit the bitstream's <i>READ</i> policy, and set the <i>Start Date</i> as desired.",
"bitstream.edit.form.embargo.label": "Embargo until specific date",
"bitstream.edit.form.fileName.hint": "Change the filename for the bitstream. Note that this will change the display bitstream URL, but old links will still resolve as long as the sequence ID does not change.",
"bitstream.edit.form.fileName.label": "Filename",
"bitstream.edit.form.newFormat.label": "Describe new format",
"bitstream.edit.form.newFormat.hint": "The application you used to create the file, and the version number (for example, \"<i>ACMESoft SuperApp version 1.5</i>\").",
"bitstream.edit.form.primaryBitstream.label": "Primary bitstream",
"bitstream.edit.form.selectedFormat.hint": "If the format is not in the above list, <b>select \"format not in list\" above</b> and describe it under \"Describe new format\".",
"bitstream.edit.form.selectedFormat.label": "Selected Format",
"bitstream.edit.form.selectedFormat.unknown": "Format not in list",
"bitstream.edit.notifications.error.format.title": "An error occurred saving the bitstream's format",
"bitstream.edit.notifications.saved.content": "Your changes to this bitstream were saved.",
"bitstream.edit.notifications.saved.title": "Bitstream saved",
"bitstream.edit.title": "Edit bitstream",
"browse.comcol.by.author": "By Author",
"browse.comcol.by.dateissued": "By Issue Date",
@@ -638,6 +674,8 @@
"error.bitstream": "Error fetching bitstream",
"error.browse-by": "Error fetching items",
"error.collection": "Error fetching collection",
@@ -750,6 +788,93 @@
"item.edit.authorizations.title": "Edit item's Policies",
"item.bitstreams.upload.bundle": "Bundle",
"item.bitstreams.upload.bundle.placeholder": "Select a bundle",
"item.bitstreams.upload.bundle.new": "Create bundle",
"item.bitstreams.upload.bundles.empty": "This item doesn\'t contain any bundles to upload a bitstream to.",
"item.bitstreams.upload.cancel": "Cancel",
"item.bitstreams.upload.drop-message": "Drop a file to upload",
"item.bitstreams.upload.item": "Item: ",
"item.bitstreams.upload.notifications.bundle.created.content": "Successfully created new bundle.",
"item.bitstreams.upload.notifications.bundle.created.title": "Created bundle",
"item.bitstreams.upload.notifications.upload.failed": "Upload failed. Please verify the content before retrying.",
"item.bitstreams.upload.title": "Upload bitstream",
"item.edit.bitstreams.bundle.edit.buttons.upload": "Upload",
"item.edit.bitstreams.bundle.displaying": "Currently displaying {{ amount }} bitstreams of {{ total }}.",
"item.edit.bitstreams.bundle.load.all": "Load all ({{ total }})",
"item.edit.bitstreams.bundle.load.more": "Load more",
"item.edit.bitstreams.bundle.name": "BUNDLE: {{ name }}",
"item.edit.bitstreams.discard-button": "Discard",
"item.edit.bitstreams.edit.buttons.download": "Download",
"item.edit.bitstreams.edit.buttons.drag": "Drag",
"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.move.failed.title": "Error moving bitstreams",
"item.edit.bitstreams.notifications.move.saved.content": "Your move changes to this item's bitstreams and bundles have been saved.",
"item.edit.bitstreams.notifications.move.saved.title": "Move changes saved",
"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.remove.failed.title": "Error deleting bitstream",
"item.edit.bitstreams.notifications.remove.saved.content": "Your removal changes to this item's bitstreams have been saved.",
"item.edit.bitstreams.notifications.remove.saved.title": "Removal changes saved",
"item.edit.bitstreams.reinstate-button": "Undo",
"item.edit.bitstreams.save-button": "Save",
"item.edit.bitstreams.upload-button": "Upload",
"item.edit.delete.cancel": "Cancel",
"item.edit.delete.confirm": "Delete",
@@ -948,7 +1073,7 @@
"item.edit.tabs.bitstreams.head": "Item Bitstreams",
"item.edit.tabs.bitstreams.head": "Bitstreams",
"item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams",
@@ -956,11 +1081,11 @@
"item.edit.tabs.curate.title": "Item Edit - Curate",
"item.edit.tabs.metadata.head": "Item Metadata",
"item.edit.tabs.metadata.head": "Metadata",
"item.edit.tabs.metadata.title": "Item Edit - Metadata",
"item.edit.tabs.relationships.head": "Item Relationships",
"item.edit.tabs.relationships.head": "Relationships",
"item.edit.tabs.relationships.title": "Item Edit - Relationships",
@@ -998,7 +1123,7 @@
"item.edit.tabs.status.description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.",
"item.edit.tabs.status.head": "Item Status",
"item.edit.tabs.status.head": "Status",
"item.edit.tabs.status.labels.handle": "Handle",
@@ -1167,6 +1292,10 @@
"loading.bitstream": "Loading bitstream...",
"loading.bitstreams": "Loading bitstreams...",
"loading.browse-by": "Loading items...",
"loading.browse-by-page": "Loading page...",

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { BitstreamPageResolver } from './bitstream-page.resolver';
const EDIT_BITSTREAM_PATH = ':id/edit';
/**
* Routing module to help navigate Bitstream pages
*/
@NgModule({
imports: [
RouterModule.forChild([
{
path: EDIT_BITSTREAM_PATH,
component: EditBitstreamPageComponent,
resolve: {
bitstream: BitstreamPageResolver
},
canActivate: [AuthenticatedGuard]
}
])
],
providers: [
BitstreamPageResolver,
]
})
export class BitstreamPageRoutingModule {
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module';
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
/**
* This module handles all components that are necessary for Bitstream related pages
*/
@NgModule({
imports: [
CommonModule,
SharedModule,
BitstreamPageRoutingModule
],
declarations: [
EditBitstreamPageComponent
]
})
export class BitstreamPageModule {
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../core/data/remote-data';
import { Observable } from 'rxjs/internal/Observable';
import { find } from 'rxjs/operators';
import { hasValue } from '../shared/empty.util';
import { Bitstream } from '../core/shared/bitstream.model';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
/**
* This class represents a resolver that requests a specific bitstream before the route is activated
*/
@Injectable()
export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
constructor(private bitstreamService: BitstreamDataService) {
}
/**
* Method for resolving a bitstream based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<<RemoteData<Item>> Emits the found bitstream based on the parameters in the current route,
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Bitstream>> {
return this.bitstreamService.findById(route.params.id)
.pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
);
}
}

View File

@@ -0,0 +1,29 @@
<ng-container *ngVar="(bitstreamRD$ | async) as bitstreamRD">
<div class="container" *ngVar="(bitstreamFormatsRD$ | async) as formatsRD">
<div class="row" *ngIf="bitstreamRD?.hasSucceeded && formatsRD?.hasSucceeded">
<div class="col-md-2">
<ds-thumbnail [thumbnail]="bitstreamRD?.payload"></ds-thumbnail>
</div>
<div class="col-md-10">
<div class="container">
<div class="row">
<div class="col-12">
<h3>{{bitstreamRD?.payload?.name}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h3>
</div>
</div>
</div>
<ds-form [formId]="'edit-bitstream-form-id'"
[formGroup]="formGroup"
[formModel]="formModel"
[formLayout]="formLayout"
[submitLabel]="'form.save'"
(submitForm)="onSubmit()"
(cancel)="onCancel()"
(dfChange)="onChange($event)"></ds-form>
</div>
</div>
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
<ds-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
message="{{'loading.bitstream' | translate}}"></ds-loading>
</div>
</ng-container>

View File

@@ -0,0 +1,8 @@
:host {
::ng-deep {
.switch {
position: absolute;
top: $spacer*2.5;
}
}
}

View File

@@ -0,0 +1,216 @@
import { EditBitstreamPageComponent } from './edit-bitstream-page.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { RemoteData } from '../../core/data/remote-data';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { ActivatedRoute } from '@angular/router';
import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
import { Bitstream } from '../../core/shared/bitstream.model';
import { NotificationType } from '../../shared/notifications/models/notification-type';
import { INotification, Notification } from '../../shared/notifications/models/notification.model';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
import { hasValue } from '../../shared/empty.util';
import { FormControl, FormGroup } from '@angular/forms';
import { PaginatedList } from '../../core/data/paginated-list';
import { PageInfo } from '../../core/shared/page-info.model';
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { RestResponse } from '../../core/cache/response.models';
import { VarDirective } from '../../shared/utils/var.directive';
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');
let notificationsService: NotificationsService;
let formService: DynamicFormService;
let bitstreamService: BitstreamDataService;
let bitstreamFormatService: BitstreamFormatDataService;
let bitstream: Bitstream;
let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[];
describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent;
let fixture: ComponentFixture<EditBitstreamPageComponent>;
beforeEach(async(() => {
allFormats = [
Object.assign({
id: '1',
shortDescription: 'Unknown',
description: 'Unknown format',
supportLevel: BitstreamFormatSupportLevel.Unknown,
_links: {
self: { href: 'format-selflink-1' }
}
}),
Object.assign({
id: '2',
shortDescription: 'PNG',
description: 'Portable Network Graphics',
supportLevel: BitstreamFormatSupportLevel.Known,
_links: {
self: { href: 'format-selflink-2' }
}
}),
Object.assign({
id: '3',
shortDescription: 'GIF',
description: 'Graphics Interchange Format',
supportLevel: BitstreamFormatSupportLevel.Known,
_links: {
self: { href: 'format-selflink-3' }
}
})
] as BitstreamFormat[];
selectedFormat = allFormats[1];
notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
formService = Object.assign({
createFormGroup: (fModel: DynamicFormControlModel[]) => {
const controls = {};
if (hasValue(fModel)) {
fModel.forEach((controlModel) => {
controls[controlModel.id] = new FormControl((controlModel as any).value);
});
return new FormGroup(controls);
}
return undefined;
}
});
bitstream = Object.assign(new Bitstream(), {
metadata: {
'dc.description': [
{
value: 'Bitstream description'
}
],
'dc.title': [
{
value: 'Bitstream title'
}
]
},
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
_links: {
self: 'bitstream-selflink'
}
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: observableOf(new RemoteData(false, false, true, null, bitstream)),
update: observableOf(new RemoteData(false, false, true, null, bitstream)),
updateFormat: observableOf(new RestResponse(true, 200, 'OK')),
commitUpdates: {},
patch: {}
});
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats)))
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
providers: [
{ provide: NotificationsService, useValue: notificationsService },
{ provide: DynamicFormService, useValue: formService },
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } },
{ provide: BitstreamDataService, useValue: bitstreamService },
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('on startup', () => {
let rawForm;
beforeEach(() => {
rawForm = comp.formGroup.getRawValue();
});
it('should fill in the bitstream\'s title', () => {
expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name);
});
it('should fill in the bitstream\'s description', () => {
expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description'));
});
it('should select the correct format', () => {
expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id);
});
it('should put the \"New Format\" input on invisible', () => {
expect(comp.formLayout.newFormat.grid.host).toContain('invisible');
});
});
describe('when an unknown format is selected', () => {
beforeEach(() => {
comp.updateNewFormatLayout(allFormats[0].id);
});
it('should remove the invisible class from the \"New Format\" input', () => {
expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible');
});
});
describe('onSubmit', () => {
describe('when selected format hasn\'t changed', () => {
beforeEach(() => {
comp.onSubmit();
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
});
});
describe('when selected format has changed', () => {
beforeEach(() => {
comp.formGroup.patchValue({
formatContainer: {
selectedFormat: allFormats[2].id
}
});
fixture.detectChanges();
comp.onSubmit();
});
it('should call update', () => {
expect(bitstreamService.update).toHaveBeenCalled();
});
it('should call updateFormat', () => {
expect(bitstreamService.updateFormat).toHaveBeenCalled();
});
it('should commit the updates', () => {
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,524 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Bitstream } from '../../core/shared/bitstream.model';
import { ActivatedRoute, Router } from '@angular/router';
import { filter, map, switchMap } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { Subscription } from 'rxjs/internal/Subscription';
import {
DynamicFormControlModel,
DynamicFormGroupModel,
DynamicFormLayout,
DynamicFormService,
DynamicInputModel,
DynamicSelectModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
import { cloneDeep } from 'lodash';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import {
getAllSucceededRemoteData, getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
getSucceededRemoteData
} from '../../core/shared/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
import { RestResponse } from '../../core/cache/response.models';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { Metadata } from '../../core/shared/metadata.utils';
import { Location } from '@angular/common';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { getItemEditPath } from '../../+item-page/item-page-routing.module';
@Component({
selector: 'ds-edit-bitstream-page',
styleUrls: ['./edit-bitstream-page.component.scss'],
templateUrl: './edit-bitstream-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
/**
* Page component for editing a bitstream
*/
export class EditBitstreamPageComponent implements OnInit, OnDestroy {
/**
* The bitstream's remote data observable
* Tracks changes and updates the view
*/
bitstreamRD$: Observable<RemoteData<Bitstream>>;
/**
* The formats their remote data observable
* Tracks changes and updates the view
*/
bitstreamFormatsRD$: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
/**
* The bitstream to edit
*/
bitstream: Bitstream;
/**
* The originally selected format
*/
originalFormat: BitstreamFormat;
/**
* A list of all available bitstream formats
*/
formats: BitstreamFormat[];
/**
* @type {string} Key prefix used to generate form messages
*/
KEY_PREFIX = 'bitstream.edit.form.';
/**
* @type {string} Key suffix used to generate form labels
*/
LABEL_KEY_SUFFIX = '.label';
/**
* @type {string} Key suffix used to generate form labels
*/
HINT_KEY_SUFFIX = '.hint';
/**
* @type {string} Key prefix used to generate notification messages
*/
NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.';
/**
* Options for fetching all bitstream formats
*/
findAllOptions = { elementsPerPage: 9999 };
/**
* The Dynamic Input Model for the file's name
*/
fileNameModel = new DynamicInputModel({
id: 'fileName',
name: 'fileName',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'You must provide a file name for the bitstream'
}
});
/**
* The Dynamic Switch Model for the file's name
*/
primaryBitstreamModel = new DynamicCustomSwitchModel({
id: 'primaryBitstream',
name: 'primaryBitstream'
});
/**
* The Dynamic TextArea Model for the file's description
*/
descriptionModel = new DynamicTextAreaModel({
id: 'description',
name: 'description',
rows: 10
});
/**
* The Dynamic Input Model for the file's embargo (disabled on this page)
*/
embargoModel = new DynamicInputModel({
id: 'embargo',
name: 'embargo',
disabled: true
});
/**
* The Dynamic Input Model for the selected format
*/
selectedFormatModel = new DynamicSelectModel({
id: 'selectedFormat',
name: 'selectedFormat'
});
/**
* The Dynamic Input Model for supplying more format information
*/
newFormatModel = new DynamicInputModel({
id: 'newFormat',
name: 'newFormat'
});
/**
* All input models in a simple array for easier iterations
*/
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel];
/**
* The dynamic form fields used for editing the information of a bitstream
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
*/
formModel: DynamicFormControlModel[] = [
new DynamicFormGroupModel({
id: 'fileNamePrimaryContainer',
group: [
this.fileNameModel,
this.primaryBitstreamModel
]
}),
new DynamicFormGroupModel({
id: 'descriptionContainer',
group: [
this.descriptionModel
]
}),
new DynamicFormGroupModel({
id: 'embargoContainer',
group: [
this.embargoModel
]
}),
new DynamicFormGroupModel({
id: 'formatContainer',
group: [
this.selectedFormatModel,
this.newFormatModel
]
})
];
/**
* The base layout of the "Other Format" input
*/
newFormatBaseLayout = 'col col-sm-6 d-inline-block';
/**
* Layout used for structuring the form inputs
*/
formLayout: DynamicFormLayout = {
fileName: {
grid: {
host: 'col col-sm-8 d-inline-block'
}
},
primaryBitstream: {
grid: {
host: 'col col-sm-4 d-inline-block switch'
}
},
description: {
grid: {
host: 'col-12 d-inline-block'
}
},
embargo: {
grid: {
host: 'col-12 d-inline-block'
}
},
selectedFormat: {
grid: {
host: 'col col-sm-6 d-inline-block'
}
},
newFormat: {
grid: {
host: this.newFormatBaseLayout + ' invisible'
}
},
fileNamePrimaryContainer: {
grid: {
host: 'row position-relative'
}
},
descriptionContainer: {
grid: {
host: 'row'
}
},
embargoContainer: {
grid: {
host: 'row'
}
},
formatContainer: {
grid: {
host: 'row'
}
}
};
/**
* The form group of this form
*/
formGroup: FormGroup;
/**
* The ID of the item the bitstream originates from
* Taken from the current query parameters when present
*/
itemId: string;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
protected subs: Subscription[] = [];
constructor(private route: ActivatedRoute,
private router: Router,
private location: Location,
private formService: DynamicFormService,
private translate: TranslateService,
private bitstreamService: BitstreamDataService,
private notificationsService: NotificationsService,
private bitstreamFormatService: BitstreamFormatDataService) {
}
/**
* Initialize the component
* - Create a FormGroup using the FormModel defined earlier
* - Subscribe on the route data to fetch the bitstream to edit and update the form values
* - Translate the form labels and hints
*/
ngOnInit(): void {
this.formGroup = this.formService.createFormGroup(this.formModel);
this.itemId = this.route.snapshot.queryParams.itemId;
this.bitstreamRD$ = this.route.data.pipe(map((data) => data.bitstream));
this.bitstreamFormatsRD$ = this.bitstreamFormatService.findAll(this.findAllOptions);
const bitstream$ = this.bitstreamRD$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
filter((bs: Bitstream) => hasValue(bs)))
)
);
const allFormats$ = this.bitstreamFormatsRD$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload()
);
this.subs.push(
observableCombineLatest(
bitstream$,
allFormats$
).subscribe(([bitstream, allFormats]) => {
this.bitstream = bitstream as Bitstream;
this.formats = allFormats.page;
this.updateFormatModel();
this.updateForm(this.bitstream);
})
);
this.updateFieldTranslations();
this.subs.push(
this.translate.onLangChange
.subscribe(() => {
this.updateFieldTranslations();
})
);
};
/**
* Update the current form values with bitstream properties
* @param bitstream
*/
updateForm(bitstream: Bitstream) {
this.formGroup.patchValue({
fileNamePrimaryContainer: {
fileName: bitstream.name,
primaryBitstream: false
},
descriptionContainer: {
description: bitstream.firstMetadataValue('dc.description')
},
formatContainer: {
newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined
}
});
this.bitstream.format.pipe(
getAllSucceededRemoteDataPayload()
).subscribe((format: BitstreamFormat) => {
this.originalFormat = format;
this.formGroup.patchValue({
formatContainer: {
selectedFormat: format.id
}
});
this.updateNewFormatLayout(format.id);
});
}
/**
* Create the list of unknown format IDs an add options to the selectedFormatModel
*/
updateFormatModel() {
this.selectedFormatModel.options = this.formats.map((format: BitstreamFormat) =>
Object.assign({
value: format.id,
label: this.isUnknownFormat(format.id) ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription
}));
}
/**
* Update the layout of the "Other Format" input depending on the selected format
* @param selectedId
*/
updateNewFormatLayout(selectedId: string) {
if (this.isUnknownFormat(selectedId)) {
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout;
} else {
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout + ' invisible';
}
}
/**
* Is the provided format (id) part of the list of unknown formats?
* @param id
*/
isUnknownFormat(id: string): boolean {
const format = this.formats.find((f: BitstreamFormat) => f.id === id);
return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown;
}
/**
* Used to update translations of labels and hints on init and on language change
*/
private updateFieldTranslations() {
this.inputModels.forEach(
(fieldModel: DynamicFormControlModel) => {
this.updateFieldTranslation(fieldModel);
}
);
}
/**
* Update the translations of a DynamicFormControlModel
* @param fieldModel
*/
private updateFieldTranslation(fieldModel) {
fieldModel.label = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.LABEL_KEY_SUFFIX);
if (fieldModel.id !== this.primaryBitstreamModel.id) {
fieldModel.hint = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.HINT_KEY_SUFFIX);
}
}
/**
* Fired whenever the form receives an update and changes the layout of the "Other Format" input, depending on the selected format
* @param event
*/
onChange(event) {
const model = event.model;
if (model.id === this.selectedFormatModel.id) {
this.updateNewFormatLayout(model.value);
}
}
/**
* Check for changes against the bitstream and send update requests to the REST API
*/
onSubmit() {
const updatedValues = this.formGroup.getRawValue();
const updatedBitstream = this.formToBitstream(updatedValues);
const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat);
const isNewFormat = selectedFormat.id !== this.originalFormat.id;
let bitstream$;
if (isNewFormat) {
bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe(
switchMap((formatResponse: RestResponse) => {
if (hasValue(formatResponse) && !formatResponse.isSuccessful) {
this.notificationsService.error(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.format.title'),
formatResponse.statusText
);
} else {
return this.bitstreamService.findById(this.bitstream.id).pipe(
getFirstSucceededRemoteDataPayload()
);
}
})
);
} else {
bitstream$ = observableOf(this.bitstream);
}
bitstream$.pipe(
switchMap(() => {
return this.bitstreamService.update(updatedBitstream).pipe(
getFirstSucceededRemoteDataPayload()
);
})
).subscribe(() => {
this.bitstreamService.commitUpdates();
this.notificationsService.success(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.title'),
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.content')
);
this.navigateToItemEditBitstreams();
});
}
/**
* Parse form data to an updated bitstream object
* @param rawForm Raw form data
*/
formToBitstream(rawForm): Bitstream {
const updatedBitstream = cloneDeep(this.bitstream);
const newMetadata = updatedBitstream.metadata;
// TODO: Set bitstream to primary when supported
const primary = rawForm.fileNamePrimaryContainer.primaryBitstream;
Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName);
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description);
if (isNotEmpty(rawForm.formatContainer.newFormat)) {
Metadata.setFirstValue(newMetadata, 'dc.format', rawForm.formatContainer.newFormat);
}
updatedBitstream.metadata = newMetadata;
return updatedBitstream;
}
/**
* Cancel the form and return to the previous page
*/
onCancel() {
this.navigateToItemEditBitstreams();
}
/**
* When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous
* page the user came from
*/
navigateToItemEditBitstreams() {
if (hasValue(this.itemId)) {
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
} else {
this.location.back();
}
}
/**
* Unsubscribe from open subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -0,0 +1,41 @@
<div class="container" *ngVar="(bundlesRD$ | async)?.payload?.page as bundles">
<ng-container *ngIf="bundles">
<div class="row">
<div class="col-12 mb-4">
<h2>{{'item.bitstreams.upload.title' | translate}}</h2>
<ng-container *ngVar="(itemRD$ | async)?.payload as item">
<div *ngIf="item">
<span class="font-weight-bold">{{'item.bitstreams.upload.item' | translate}}</span>
<span>{{item.name}}</span>
</div>
</ng-container>
</div>
<div class="col-12">
<label class="font-weight-bold">{{'item.bitstreams.upload.bundle' | translate}}</label>
<ds-dso-input-suggestions #f id="search-form"
[suggestions]="bundles"
[placeholder]="'item.bitstreams.upload.bundle.placeholder' | translate"
[action]="getCurrentUrl()"
[name]="'bundle-select'"
[debounceTime]="50"
[(ngModel)]="selectedBundleName"
(typeSuggestion)="bundleNameChange()"
(clickSuggestion)="onClick($event)"
(click)="f.open()"
ngDefaultControl>
</ds-dso-input-suggestions>
<button *ngIf="!selectedBundleId && selectedBundleName?.length > 0" class="btn btn-success" (click)="createBundle()">
<i class="fa fa-plus"></i> {{ 'item.bitstreams.upload.bundle.new' | translate }}
</button>
<ds-uploader class="w-100" *ngIf="selectedBundleId"
[dropMsg]="'item.bitstreams.upload.drop-message'"
[dropOverDocumentMsg]="'item.bitstreams.upload.drop-message'"
[enableDragOverDocument]="true"
[uploadFilesOptions]="uploadFilesOptions"
(onCompleteItem)="onCompleteItem($event)"
(onUploadError)="onUploadError()"></ds-uploader>
<button class="btn btn-outline-secondary" (click)="onCancel()">{{'item.bitstreams.upload.cancel' | translate}}</button>
</div>
</div>
</ng-container>
</div>

View File

@@ -0,0 +1,236 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { ItemDataService } from '../../../core/data/item-data.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { UploadBitstreamComponent } from './upload-bitstream.component';
import { AuthService } from '../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../shared/testing/auth-service-stub';
import { Item } from '../../../core/shared/item.model';
import { of as observableOf } from 'rxjs';
import {
createPaginatedList,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../../shared/testing/utils';
import { RouterStub } from '../../../shared/testing/router-stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import { VarDirective } from '../../../shared/utils/var.directive';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { BundleDataService } from '../../../core/data/bundle-data.service';
import { Bundle } from '../../../core/shared/bundle.model';
import { RequestService } from '../../../core/data/request.service';
describe('UploadBistreamComponent', () => {
let comp: UploadBitstreamComponent;
let fixture: ComponentFixture<UploadBitstreamComponent>;
const bundle = Object.assign(new Bundle(), {
id: 'bundle',
uuid: 'bundle',
metadata: {
'dc.title': [
{
value: 'bundleName',
language: null
}
]
},
_links: {
self: { href: 'bundle-selflink' }
}
});
const customName = 'Custom Name';
const createdBundle = Object.assign(new Bundle(), {
id: 'created-bundle',
uuid: 'created-bundle',
metadata: {
'dc.title': [
{
value: customName,
language: null
}
]
},
_links: {
self: { href: 'created-bundle-selflink' }
}
});
const itemName = 'fake-name';
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
metadata: {
'dc.title': [
{
language: null,
value: itemName
}
]
},
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([bundle]))
});
let routeStub;
const routerStub = new RouterStub();
const restEndpoint = 'fake-rest-endpoint';
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
getBitstreamsEndpoint: observableOf(restEndpoint),
createBundle: createSuccessfulRemoteDataObject$(createdBundle)
});
const bundleService = jasmine.createSpyObj('bundleService', {
getBitstreamsEndpoint: observableOf(restEndpoint),
findById: createSuccessfulRemoteDataObject$(bundle)
});
const authToken = 'fake-auth-token';
const authServiceStub = Object.assign(new AuthServiceStub(), {
buildAuthHeader: () => authToken
});
const notificationsServiceStub = new NotificationsServiceStub();
const uploaderComponent = jasmine.createSpyObj('uploaderComponent', ['ngOnInit', 'ngAfterViewInit']);
const requestService = jasmine.createSpyObj('requestService', {
removeByHrefSubstring: {}
});
describe('when a file is uploaded', () => {
beforeEach(async(() => {
createUploadBitstreamTestingModule({});
}));
beforeEach(() => {
loadFixtureAndComp();
});
describe('and it fails, calling onUploadError', () => {
beforeEach(() => {
comp.onUploadError();
});
it('should display an error notification', () => {
expect(notificationsServiceStub.error).toHaveBeenCalled();
});
});
describe('and it succeeds, calling onCompleteItem', () => {
const createdBitstream = Object.assign(new Bitstream(), {
id: 'fake-bitstream'
});
beforeEach(() => {
comp.onCompleteItem(createdBitstream);
});
it('should navigate the user to the next page', () => {
expect(routerStub.navigate).toHaveBeenCalled();
});
});
});
describe('when a bundle url parameter is present', () => {
beforeEach(async(() => {
createUploadBitstreamTestingModule({
bundle: bundle.id
});
}));
beforeEach(() => {
loadFixtureAndComp();
});
it('should set the selected id to the bundle\'s id', () => {
expect(comp.selectedBundleId).toEqual(bundle.id);
});
it('should set the selected name to the bundle\'s name', () => {
expect(comp.selectedBundleName).toEqual(bundle.name);
});
describe('and bundle name changed', () => {
beforeEach(() => {
comp.bundleNameChange();
});
it('should clear out the selected id', () => {
expect(comp.selectedBundleId).toBeUndefined();
});
});
});
describe('when a name is filled in, but no ID is selected', () => {
beforeEach(async(() => {
createUploadBitstreamTestingModule({});
}));
beforeEach(() => {
loadFixtureAndComp();
comp.selectedBundleName = customName;
});
describe('createBundle', () => {
beforeEach(() => {
comp.createBundle();
});
it('should create a new bundle', () => {
expect(mockItemDataService.createBundle).toHaveBeenCalledWith(mockItem.id, customName);
});
it('should set the selected id to the id of the new bundle', () => {
expect(comp.selectedBundleId).toEqual(createdBundle.id);
});
it('should display a success notification', () => {
expect(notificationsServiceStub.success).toHaveBeenCalled();
});
});
});
/**
* Setup an UploadBitstreamComponent testing module with custom queryParams for the route
* @param queryParams
*/
function createUploadBitstreamTestingModule(queryParams) {
routeStub = {
data: observableOf({
item: createSuccessfulRemoteDataObject(mockItem)
}),
queryParams: observableOf(queryParams),
snapshot: {
queryParams: queryParams,
params: {
id: mockItem.id
}
}
};
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [UploadBitstreamComponent, VarDirective],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: AuthService, useValue: authServiceStub },
{ provide: BundleDataService, useValue: bundleService },
{ provide: RequestService, useValue: requestService }
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}
/**
* Load the TestBed's fixture and component
*/
function loadFixtureAndComp() {
fixture = TestBed.createComponent(UploadBitstreamComponent);
comp = fixture.componentInstance;
comp.uploaderComponent = uploaderComponent;
fixture.detectChanges();
}
});

View File

@@ -0,0 +1,218 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
import { Subscription } from 'rxjs/internal/Subscription';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { ItemDataService } from '../../../core/data/item-data.service';
import { AuthService } from '../../../core/auth/auth.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { getBitstreamModulePath } from '../../../app-routing.module';
import { PaginatedList } from '../../../core/data/paginated-list';
import { Bundle } from '../../../core/shared/bundle.model';
import { BundleDataService } from '../../../core/data/bundle-data.service';
import {
getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators';
import { UploaderComponent } from '../../../shared/uploader/uploader.component';
import { getItemEditPath } from '../../item-page-routing.module';
import { RequestService } from '../../../core/data/request.service';
@Component({
selector: 'ds-upload-bitstream',
templateUrl: './upload-bitstream.component.html'
})
/**
* Page component for uploading a bitstream to an item
*/
export class UploadBitstreamComponent implements OnInit, OnDestroy {
/**
* The file uploader component
*/
@ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent;
/**
* The ID of the item to upload a bitstream to
*/
itemId: string;
/**
* The item to upload a bitstream to
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* The item's bundles
*/
bundlesRD$: Observable<RemoteData<PaginatedList<Bundle>>>;
/**
* The ID of the currently selected bundle to upload a bitstream to
*/
selectedBundleId: string;
/**
* The name of the currently selected bundle to upload a bitstream to
*/
selectedBundleName: string;
/**
* The uploader configuration options
* @type {UploaderOptions}
*/
uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), {
// URL needs to contain something to not produce any errors. This will be replaced once a bundle has been selected.
url: 'placeholder',
authToken: null,
disableMultipart: false,
itemAlias: null
});
/**
* The prefix for all i18n notification messages within this component
*/
NOTIFICATIONS_PREFIX = 'item.bitstreams.upload.notifications.';
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
subs: Subscription[] = [];
constructor(protected route: ActivatedRoute,
protected router: Router,
protected itemService: ItemDataService,
protected bundleService: BundleDataService,
protected authService: AuthService,
protected notificationsService: NotificationsService,
protected translate: TranslateService,
protected requestService: RequestService) {
}
/**
* Initialize component properties:
* itemRD$ Fetched from the current route data (populated by BitstreamPageResolver)
* bundlesRD$ List of bundles on the item
* selectedBundleId Starts off by checking if the route's queryParams contain a "bundle" parameter. If none is found,
* the ID of the first bundle in the list is selected.
* Calls setUploadUrl after setting the selected bundle
*/
ngOnInit(): void {
this.itemId = this.route.snapshot.params.id;
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
this.bundlesRD$ = this.itemRD$.pipe(
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
);
this.selectedBundleId = this.route.snapshot.queryParams.bundle;
if (isNotEmpty(this.selectedBundleId)) {
this.bundleService.findById(this.selectedBundleId).pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((bundle: Bundle) => {
this.selectedBundleName = bundle.name;
});
this.setUploadUrl();
}
}
/**
* Create a new bundle with the filled in name on the current item
*/
createBundle() {
this.itemService.createBundle(this.itemId, this.selectedBundleName).pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((bundle: Bundle) => {
this.selectedBundleId = bundle.id;
this.notificationsService.success(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.title'),
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.content')
);
this.setUploadUrl();
});
}
/**
* The user changed the bundle name
* Reset the bundle ID
*/
bundleNameChange() {
this.selectedBundleId = undefined;
}
/**
* Set the upload url to match the selected bundle ID
*/
setUploadUrl() {
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
this.uploadFilesOptions.url = href;
if (isEmpty(this.uploadFilesOptions.authToken)) {
this.uploadFilesOptions.authToken = this.authService.buildAuthHeader();
}
// Re-initialize the uploader component to ensure the latest changes to the options are applied
if (this.uploaderComponent) {
this.uploaderComponent.ngOnInit();
this.uploaderComponent.ngAfterViewInit();
}
});
}
/**
* The request was successful, redirect the user to the new bitstream's edit page
* @param bitstream
*/
public onCompleteItem(bitstream) {
// Clear cached requests for this bundle's bitstreams to ensure lists on all pages are up-to-date
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
this.requestService.removeByHrefSubstring(href);
});
// Bring over the item ID as a query parameter
const queryParams = { itemId: this.itemId };
this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit'], { queryParams: queryParams });
}
/**
* The request was unsuccessful, display an error notification
*/
public onUploadError() {
this.notificationsService.error(null, this.translate.get(this.NOTIFICATIONS_PREFIX + 'upload.failed'));
}
/**
* The user selected a bundle from the input suggestions
* Set the bundle ID and Name properties, as well as the upload URL
* @param bundle
*/
onClick(bundle: Bundle) {
this.selectedBundleId = bundle.id;
this.selectedBundleName = bundle.name;
this.setUploadUrl();
}
/**
* When cancel is clicked, navigate back to the item's edit bitstreams page
*/
onCancel() {
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
}
/**
* @returns {string} the current URL
*/
getCurrentUrl() {
return this.router.url;
}
/**
* Unsubscribe from all open subscriptions when the component is destroyed
*/
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -10,12 +10,16 @@ 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()
@Component({
selector: 'ds-abstract-item-update',
template: ''
})
/**
* Abstract component for managing object updates of an item
*/
export abstract class AbstractItemUpdateComponent implements OnInit {
export class AbstractItemUpdateComponent extends AbstractTrackableComponent implements OnInit {
/**
* The item to display the edit page for
*/
@@ -25,30 +29,17 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
* Should be initialized in the initializeUpdates method of the child component
*/
updates$: Observable<FieldUpdates>;
/**
* The current url of this page
*/
url: string;
/**
* Prefix for this component's notification translate keys
* Should be initialized in the initializeNotificationsPrefix method of the child component
*/
notificationsPrefix;
/**
* The time span for being able to undo discarding changes
*/
discardTimeOut: number;
constructor(
protected itemService: ItemDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected router: Router,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected route: ActivatedRoute
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)
}
/**
@@ -61,6 +52,7 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.item = item;
this.postItemInit();
});
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
@@ -81,19 +73,44 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
}
/**
* Initialize the values and updates of the current item's fields
* Actions to perform after the item has been initialized
* Abstract method: Should be overwritten in the sub class
*/
abstract initializeUpdates(): void;
postItemInit(): void {
// Overwrite in subclasses
}
/**
* Initialize the values and updates of the current item's fields
* Abstract method: Should be overwritten in the sub class
*/
initializeUpdates(): void {
// Overwrite in subclasses
}
/**
* Initialize the prefix for notification messages
* Abstract method: Should be overwritten in the sub class
*/
abstract initializeNotificationsPrefix(): void;
initializeNotificationsPrefix(): void {
// Overwrite in subclasses
}
/**
* Sends all initial values of this item to the object updates service
* Abstract method: Should be overwritten in the sub class
*/
abstract initializeOriginalFields(): void;
initializeOriginalFields(): void {
// Overwrite in subclasses
}
/**
* Submit the current changes
* Abstract method: Should be overwritten in the sub class
*/
submit(): void {
// Overwrite in subclasses
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
@@ -102,13 +119,6 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
return update && update.field ? update.field.uuid : undefined;
}
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Check if the current page is entirely valid
*/
@@ -131,49 +141,4 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
}
);
}
/**
* Submit the current changes
*/
abstract submit(): void;
/**
* Request the object updates service to discard all current changes to this item
* Shows a notification to remind the user that they can undo this
*/
discard() {
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
* Get translated notification title
* @param key
*/
protected getNotificationTitle(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
protected getNotificationContent(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
}

View File

@@ -15,12 +15,19 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
import { SearchPageModule } from '../../+search-page/search-page.module';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { AbstractItemUpdateComponent } from './abstract-item-update/abstract-item-update.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemEditBitstreamBundleComponent } from './item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component';
import { BundleDataService } from '../../core/data/bundle-data.service';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component';
import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component';
@@ -35,12 +42,14 @@ import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/cr
CommonModule,
SharedModule,
EditItemPageRoutingModule,
SearchPageModule
SearchPageModule,
DragDropModule
],
declarations: [
EditItemPageComponent,
ItemOperationComponent,
AbstractSimpleItemActionComponent,
AbstractItemUpdateComponent,
ModifyItemOverviewComponent,
ItemWithdrawComponent,
ItemReinstateComponent,
@@ -53,14 +62,22 @@ import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/cr
ItemBitstreamsComponent,
ItemVersionHistoryComponent,
EditInPlaceFieldComponent,
ItemEditBitstreamComponent,
ItemEditBitstreamBundleComponent,
PaginatedDragAndDropBitstreamListComponent,
EditInPlaceFieldComponent,
EditRelationshipComponent,
EditRelationshipListComponent,
ItemCollectionMapperComponent,
ItemMoveComponent,
ItemEditBitstreamDragHandleComponent,
VirtualMetadataComponent,
ItemAuthorizationsComponent,
ResourcePolicyEditComponent,
ResourcePolicyCreateComponent,
],
providers: [
BundleDataService
]
})
export class EditItemPageModule {

View File

@@ -1,3 +1,68 @@
<div>
<div class="item-bitstreams" *ngVar="(bundles$ | async) as bundles">
<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) || submitting"
(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) || submitting"
(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 *ngIf="item && bundles?.length > 0" class="container table-bordered mt-4">
<div class="row header-row font-weight-bold">
<div class="{{columnSizes.columns[0].buildClasses()}} row-element">
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
{{'item.edit.bitstreams.headers.name' | translate}}
</div>
<div class="{{columnSizes.columns[1].buildClasses()}} row-element">{{'item.edit.bitstreams.headers.description' | translate}}</div>
<div class="{{columnSizes.columns[2].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.format' | translate}}</div>
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.actions' | translate}}</div>
</div>
<ds-item-edit-bitstream-bundle *ngFor="let bundle of bundles"
[bundle]="bundle"
[item]="item"
[columnSizes]="columnSizes">
</ds-item-edit-bitstream-bundle>
</div>
<div *ngIf="bundles?.length === 0"
class="alert alert-info w-100 d-inline-block mt-4" role="alert">
{{'item.edit.bitstreams.empty' | translate}}
</div>
<ds-loading *ngIf="!bundles" message="{{'loading.bitstreams' | translate}}"></ds-loading>
<div class="button-row bottom">
<div class="mt-4 float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async) || submitting"
(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) || submitting"
(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>

View File

@@ -0,0 +1,42 @@
.header-row {
color: $table-dark-color;
background-color: $table-dark-bg;
border-color: $table-dark-border-color;
}
.bundle-row {
color: $table-head-color;
background-color: $table-head-bg;
border-color: $table-border-color;
}
.row-element {
padding: 12px;
padding: 0.75em;
border-bottom: $table-border-width solid $table-border-color;
}
.drag-handle {
visibility: hidden;
&:hover {
cursor: grab;
}
}
:host ::ng-deep .bitstream-row:hover .drag-handle {
visibility: visible !important;
}
.cdk-drag-preview {
margin-left: 0;
box-sizing: border-box;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

View File

@@ -0,0 +1,224 @@
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 { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe';
import { VarDirective } from '../../../shared/utils/var.directive';
import { BundleDataService } from '../../../core/data/bundle-data.service';
import { Bundle } from '../../../core/shared/bundle.model';
import { RestResponse } from '../../../core/cache/response.models';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
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(), {
id: 'bitstream1',
uuid: 'bitstream1'
});
const bitstream2 = Object.assign(new Bitstream(), {
id: 'bitstream2',
uuid: 'bitstream2'
});
const fieldUpdate1 = {
field: bitstream1,
changeType: undefined
};
const fieldUpdate2 = {
field: bitstream2,
changeType: FieldChangeType.REMOVE
};
const bundle = Object.assign(new Bundle(), {
id: 'bundle1',
uuid: 'bundle1',
_links: {
self: { href: 'bundle1-selflink' }
},
bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2])
});
const moveOperations = [
{
op: 'move',
from: '/0',
path: '/1'
}
];
const date = new Date();
const url = 'thisUrl';
let item: Item;
let itemService: ItemDataService;
let objectUpdatesService: ObjectUpdatesService;
let router: any;
let route: ActivatedRoute;
let notificationsService: NotificationsService;
let bitstreamService: BitstreamDataService;
let objectCache: ObjectCacheService;
let requestService: RequestService;
let searchConfig: SearchConfigurationService;
let bundleService: BundleDataService;
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: {},
discardAllFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([bitstream1, bitstream2]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false),
isValidPage: observableOf(true),
getMoveOperations: observableOf(moveOperations)
}
);
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',
_links: {
self: { href: 'item-selflink' }
},
bundles: createMockRDPaginatedObs([bundle]),
lastModified: date
});
itemService = Object.assign( {
getBitstreams: () => createMockRDPaginatedObs([bitstream1, bitstream2]),
findById: () => createMockRDObs(item),
getBundles: () => createMockRDPaginatedObs([bundle])
});
route = Object.assign({
parent: {
data: observableOf({ item: createMockRD(item) })
},
url: url
});
bundleService = jasmine.createSpyObj('bundleService', {
patch: observableOf(new RestResponse(true, 200, 'OK'))
});
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 },
{ provide: BundleDataService, useValue: bundleService },
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.id);
});
it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id);
});
it('should send out a patch for the move operations', () => {
expect(bundleService.patch).toHaveBeenCalled();
});
});
describe('discard', () => {
it('should discard ALL field updates', () => {
comp.discard();
expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled();
});
});
describe('reinstate', () => {
it('should reinstate field updates on the bundle', () => {
comp.reinstate();
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self);
});
});
});
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,34 @@
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 { Observable } from 'rxjs/internal/Observable';
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 { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { zip as observableZip, combineLatest as observableCombineLatest, of as observableOf } 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 { getRemoteDataPayload, 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 { Bundle } from '../../../core/shared/bundle.model';
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { Operation } from 'fast-json-patch';
import { MoveOperation } from 'fast-json-patch/lib/core';
import { BundleDataService } from '../../../core/data/bundle-data.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes';
import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes';
@Component({
selector: 'ds-item-bitstreams',
@@ -8,6 +38,273 @@ import { Component } from '@angular/core';
/**
* Component for displaying an item's bitstreams edit page
*/
export class ItemBitstreamsComponent {
/* TODO implement */
export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy {
/**
* The currently listed bundles
*/
bundles$: Observable<Bundle[]>;
/**
* The page options to use for fetching the bundles
*/
bundlesOptions = {
id: 'bundles-pagination-options',
currentPage: 1,
pageSize: 9999
} as any;
/**
* The bootstrap sizes used for the columns within this table
*/
columnSizes = new ResponsiveTableSizes([
// Name column
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
// Description column
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
// Format column
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
// Actions column
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
]);
/**
* Are we currently submitting the changes?
* Used to disable any action buttons until the submit finishes
*/
submitting = false;
/**
* 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;
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 bundleService: BundleDataService
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
}
/**
* Set up and initialize all fields
*/
ngOnInit(): void {
super.ngOnInit();
this.initializeItemUpdate();
}
/**
* Actions to perform after the item has been initialized
*/
postItemInit(): void {
this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: this.bundlesOptions})).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((bundlePage: PaginatedList<Bundle>) => bundlePage.page)
);
}
/**
* Initialize the notification messages prefix
*/
initializeNotificationsPrefix(): void {
this.notificationsPrefix = 'item.edit.bitstreams.notifications.';
}
/**
* 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.postItemInit();
this.initializeOriginalFields();
this.initializeUpdates();
this.cdRef.detectChanges();
}
});
}
/**
* Submit the current changes
* Bitstreams that were dragged around send out a patch request with move operations to the rest API
* Bitstreams marked as deleted send out a delete request to the rest API
* Display notifications and reset the current item/updates
*/
submit() {
this.submitting = true;
const bundlesOnce$ = this.bundles$.pipe(take(1));
// Fetch all move operations for each bundle
const moveOperations$ = bundlesOnce$.pipe(
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) =>
this.objectUpdatesService.getMoveOperations(bundle.self).pipe(
take(1),
map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, {
from: `/_links/bitstreams${operation.from}/href`,
path: `/_links/bitstreams${operation.path}/href`
}))])
)
)))
);
// Send out an immediate patch request for each bundle
const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe(
switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) =>
observableZip(...bundles.map((bundle: Bundle, index: number) => {
if (isNotEmpty(moveOperationList[index])) {
return this.bundleService.patch(bundle, moveOperationList[index]);
} else {
return observableOf(undefined);
}
}))
)
);
// Fetch all removed bitstreams from the object update service
const removedBitstreams$ = bundlesOnce$.pipe(
switchMap((bundles: Bundle[]) => observableZip(
...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true))
)),
map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat(
...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE))
)),
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field))
);
// Send out delete requests for all deleted bitstreams
const removedResponses$ = removedBitstreams$.pipe(
take(1),
switchMap((removedBistreams: Bitstream[]) => {
if (isNotEmpty(removedBistreams)) {
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id)));
} else {
return observableOf(undefined);
}
})
);
// Perform the setup actions from above in order and display notifications
patchResponses$.pipe(
switchMap((responses: RestResponse[]) => {
this.displayNotifications('item.edit.bitstreams.notifications.move', responses);
return removedResponses$
}),
take(1)
).subscribe((responses: RestResponse[]) => {
this.displayNotifications('item.edit.bitstreams.notifications.remove', responses);
this.reset();
this.submitting = false;
});
}
/**
* Display notifications
* - Error notification for each failed response with their message
* - Success notification in case there's at least one successful response
* @param key The i18n key for the notification messages
* @param responses The returned responses to display notifications for
*/
displayNotifications(key: string, responses: RestResponse[]) {
if (isNotEmpty(responses)) {
const failedResponses = responses.filter((response: RestResponse) => hasValue(response) && !response.isSuccessful);
const successfulResponses = responses.filter((response: RestResponse) => hasValue(response) && response.isSuccessful);
failedResponses.forEach((response: ErrorResponse) => {
this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage);
});
if (successfulResponses.length > 0) {
this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`));
}
}
}
/**
* 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.discardAllFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
bundles.forEach((bundle: Bundle) => {
this.objectUpdatesService.reinstateFieldUpdates(bundle.self);
});
});
}
/**
* Checks whether or not the object is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.bundles$.pipe(
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)))),
map((reinstatable: boolean[]) => reinstatable.includes(true))
);
}
/**
* Checks whether or not there are currently updates for this object
*/
hasChanges(): Observable<boolean> {
return this.bundles$.pipe(
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)))),
map((hasChanges: boolean[]) => hasChanges.includes(true))
);
}
/**
* 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.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
bundles.forEach((bundle: Bundle) => {
this.objectCache.remove(bundle.self);
this.requestService.removeByHrefSubstring(bundle.self);
});
this.objectCache.remove(this.item.self);
this.requestService.removeByHrefSubstring(this.item.self);
});
}
/**
* Unsubscribe from open subscriptions whenever the component gets destroyed
*/
ngOnDestroy(): void {
if (this.itemUpdateSubscription) {
this.itemUpdateSubscription.unsubscribe();
}
}
}

View File

@@ -0,0 +1,21 @@
<ng-template #bundleView>
<div class="row bundle-row">
<div class="{{bundleNameColumn.buildClasses()}} font-weight-bold row-element d-flex">
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
<div class="float-left d-flex align-items-center">
{{'item.edit.bitstreams.bundle.name' | translate:{ name: bundle.name } }}
</div>
</div>
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">
<div class="btn-group bundle-action-buttons">
<button [routerLink]="['/items/', item.id, 'bitstreams', 'new']"
[queryParams]="{bundle: bundle.id}"
class="btn btn-outline-success btn-sm"
title="{{'item.edit.bitstreams.bundle.edit.buttons.upload' | translate}}">
<i class="fas fa-upload fa-fw"></i>
</button>
</div>
</div>
</div>
<ds-paginated-drag-and-drop-bitstream-list [bundle]="bundle" [columnSizes]="columnSizes"></ds-paginated-drag-and-drop-bitstream-list>
</ng-template>

View File

@@ -0,0 +1,58 @@
import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
import { Bundle } from '../../../../core/shared/bundle.model';
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
describe('ItemEditBitstreamBundleComponent', () => {
let comp: ItemEditBitstreamBundleComponent;
let fixture: ComponentFixture<ItemEditBitstreamBundleComponent>;
let viewContainerRef: ViewContainerRef;
const columnSizes = new ResponsiveTableSizes([
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
]);
const item = Object.assign(new Item(), {
id: 'item-1',
uuid: 'item-1'
});
const bundle = Object.assign(new Bundle(), {
id: 'bundle-1',
uuid: 'bundle-1',
_links: {
self: { href: 'bundle-1-selflink' }
}
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ItemEditBitstreamBundleComponent],
schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemEditBitstreamBundleComponent);
comp = fixture.componentInstance;
comp.item = item;
comp.bundle = bundle;
comp.columnSizes = columnSizes;
viewContainerRef = (comp as any).viewContainerRef;
spyOn(viewContainerRef, 'createEmbeddedView');
fixture.detectChanges();
});
it('should create an embedded view of the component', () => {
expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { Bundle } from '../../../../core/shared/bundle.model';
import { Item } from '../../../../core/shared/item.model';
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
@Component({
selector: 'ds-item-edit-bitstream-bundle',
styleUrls: ['../item-bitstreams.component.scss'],
templateUrl: './item-edit-bitstream-bundle.component.html',
})
/**
* Component that displays a single bundle of an item on the item bitstreams edit page
* Creates an embedded view of the contents. This is to ensure the table structure won't break.
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element)
*/
export class ItemEditBitstreamBundleComponent implements OnInit {
/**
* The view on the bundle information and bitstreams
*/
@ViewChild('bundleView', {static: true}) bundleView;
/**
* The bundle to display bitstreams for
*/
@Input() bundle: Bundle;
/**
* The item the bundle belongs to
*/
@Input() item: Item;
/**
* The bootstrap sizes used for the columns within this table
*/
@Input() columnSizes: ResponsiveTableSizes;
/**
* The bootstrap sizes used for the Bundle Name column
* This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit
*/
bundleNameColumn: ResponsiveColumnSizes;
constructor(private viewContainerRef: ViewContainerRef) {
}
ngOnInit(): void {
this.bundleNameColumn = this.columnSizes.combineColumns(0, 2);
this.viewContainerRef.createEmbeddedView(this.bundleView);
}
}

View File

@@ -0,0 +1,30 @@
<ds-pagination *ngIf="(objectsRD$ | async)?.payload"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[hidePaginationDetail]="true"
[paginationOptions]="options"
[pageInfoState]="(objectsRD$ | async)?.payload"
[collectionSize]="(objectsRD$ | async)?.payload?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchPage($event)">
<div [id]="bundle.id" class="bundle-bitstreams-list"
[ngClass]="{'mb-3': (objectsRD$ | async)?.payload?.totalElements > pageSize}"
*ngVar="((updates$ | async) | dsObjectValues) as updateValues" cdkDropList (cdkDropListDropped)="drop($event)">
<div class="row bitstream-row" *ngFor="let updateValue of updateValues" cdkDrag
[id]="updateValue.field.uuid"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1,
'bg-white': updateValue.changeType === undefined
}">
<ds-item-edit-bitstream [fieldUpdate]="updateValue"
[bundleUrl]="bundle.self"
[columnSizes]="columnSizes">
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle>
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
</div>
</ds-item-edit-bitstream>
</div>
</div>
</ds-pagination>

View File

@@ -0,0 +1,132 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Bundle } from '../../../../../core/shared/bundle.model';
import { TranslateModule } from '@ngx-translate/core';
import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list.component';
import { VarDirective } from '../../../../../shared/utils/var.directive';
import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe';
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
import { createMockRDObs } from '../../item-bitstreams.component.spec';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model';
import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { take } from 'rxjs/operators';
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes';
describe('PaginatedDragAndDropBitstreamListComponent', () => {
let comp: PaginatedDragAndDropBitstreamListComponent;
let fixture: ComponentFixture<PaginatedDragAndDropBitstreamListComponent>;
let objectUpdatesService: ObjectUpdatesService;
let bundleService: BundleDataService;
const columnSizes = new ResponsiveTableSizes([
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
]);
const bundle = Object.assign(new Bundle(), {
id: 'bundle-1',
uuid: 'bundle-1',
_links: {
self: { href: 'bundle-1-selflink' }
}
});
const date = new Date();
const format = Object.assign(new BitstreamFormat(), {
shortDescription: 'PDF'
});
const bitstream1 = Object.assign(new Bitstream(), {
uuid: 'bitstreamUUID1',
name: 'Fake Bitstream 1',
bundleName: 'ORIGINAL',
description: 'Description',
format: createMockRDObs(format)
});
const fieldUpdate1 = {
field: bitstream1,
changeType: undefined
};
const bitstream2 = Object.assign(new Bitstream(), {
uuid: 'bitstreamUUID2',
name: 'Fake Bitstream 2',
bundleName: 'ORIGINAL',
description: 'Description',
format: createMockRDObs(format)
});
const fieldUpdate2 = {
field: bitstream2,
changeType: undefined
};
beforeEach(async(() => {
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[bitstream1.uuid]: fieldUpdate1,
[bitstream2.uuid]: fieldUpdate2,
}),
getFieldUpdatesExclusive: observableOf({
[bitstream1.uuid]: fieldUpdate1,
[bitstream2.uuid]: fieldUpdate2,
}),
getFieldUpdatesByCustomOrder: observableOf({
[bitstream1.uuid]: fieldUpdate1,
[bitstream2.uuid]: fieldUpdate2,
}),
saveMoveFieldUpdate: {},
saveRemoveFieldUpdate: {},
removeSingleFieldUpdate: {},
saveAddFieldUpdate: {},
discardFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([bitstream1, bitstream2]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false),
isValidPage: observableOf(true),
initializeWithCustomOrder: {},
addPageToCustomOrder: {}
}
);
bundleService = jasmine.createSpyObj('bundleService', {
getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2]))
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe],
providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: BundleDataService, useValue: bundleService }
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PaginatedDragAndDropBitstreamListComponent);
comp = fixture.componentInstance;
comp.bundle = bundle;
comp.columnSizes = columnSizes;
fixture.detectChanges();
});
it('should initialize the objectsRD$', (done) => {
comp.objectsRD$.pipe(take(1)).subscribe((objects) => {
expect(objects.payload.page).toEqual([bitstream1, bitstream2]);
done();
});
});
it('should initialize the URL', () => {
expect(comp.url).toEqual(bundle.self);
});
});

View File

@@ -0,0 +1,63 @@
import { AbstractPaginatedDragAndDropListComponent } from '../../../../../shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component';
import { Component, ElementRef, Input, OnInit } from '@angular/core';
import { Bundle } from '../../../../../core/shared/bundle.model';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
import { switchMap } from 'rxjs/operators';
import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model';
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { followLink } from '../../../../../shared/utils/follow-link-config.model';
@Component({
selector: 'ds-paginated-drag-and-drop-bitstream-list',
styleUrls: ['../../item-bitstreams.component.scss'],
templateUrl: './paginated-drag-and-drop-bitstream-list.component.html',
})
/**
* A component listing edit-bitstream rows for each bitstream within the given bundle.
* This component makes use of the AbstractPaginatedDragAndDropListComponent, allowing for users to drag and drop
* bitstreams within the paginated list. To drag and drop a bitstream between two pages, drag the row on top of the
* page number you want the bitstream to end up at. Doing so will add the bitstream to the top of that page.
*/
export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginatedDragAndDropListComponent<Bitstream> implements OnInit {
/**
* The bundle to display bitstreams for
*/
@Input() bundle: Bundle;
/**
* The bootstrap sizes used for the columns within this table
*/
@Input() columnSizes: ResponsiveTableSizes;
constructor(protected objectUpdatesService: ObjectUpdatesService,
protected elRef: ElementRef,
protected bundleService: BundleDataService) {
super(objectUpdatesService, elRef);
}
ngOnInit() {
super.ngOnInit();
}
/**
* Initialize the bitstreams observable depending on currentPage$
*/
initializeObjectsRD(): void {
this.objectsRD$ = this.currentPage$.pipe(
switchMap((page: number) => this.bundleService.getBitstreams(
this.bundle.id,
new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
followLink('format')
))
);
}
/**
* Initialize the URL used for the field-update store, in this case the bundle's self-link
*/
initializeURL(): void {
this.url = this.bundle.self;
}
}

View File

@@ -0,0 +1,5 @@
<ng-template #handleView>
<div class="drag-handle text-muted float-left p-1 mr-2">
<i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
</div>
</ng-template>

View File

@@ -0,0 +1,26 @@
import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
@Component({
selector: 'ds-item-edit-bitstream-drag-handle',
styleUrls: ['../item-bitstreams.component.scss'],
templateUrl: './item-edit-bitstream-drag-handle.component.html',
})
/**
* Component displaying a drag handle for the item-edit-bitstream page
* Creates an embedded view of the contents
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element)
*/
export class ItemEditBitstreamDragHandleComponent implements OnInit {
/**
* The view on the drag-handle
*/
@ViewChild('handleView', {static: true}) handleView;
constructor(private viewContainerRef: ViewContainerRef) {
}
ngOnInit(): void {
this.viewContainerRef.createEmbeddedView(this.handleView);
}
}

View File

@@ -0,0 +1,43 @@
<ng-template #bitstreamView>
<div class="{{columnSizes.columns[0].buildClasses()}} row-element d-flex">
<ng-content select="[slot=drag-handle]"></ng-content>
<div class="float-left d-flex align-items-center">
{{ bitstreamName }}
</div>
</div>
<div class="{{columnSizes.columns[1].buildClasses()}} row-element d-flex align-items-center">
<div class="w-100">
{{ bitstream?.firstMetadataValue('dc.description') }}
</div>
</div>
<div class="{{columnSizes.columns[2].buildClasses()}} row-element d-flex align-items-center">
<div class="text-center w-100">
{{ (format$ | async)?.shortDescription }}
</div>
</div>
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
<div class="text-center w-100">
<div class="btn-group relationship-action-buttons">
<a [href]="bitstream?._links?.content?.href"
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>
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,119 @@
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 { NO_ERRORS_SCHEMA } from '@angular/core';
import { createMockRDObs } from '../item-bitstreams.component.spec';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
let comp: ItemEditBitstreamComponent;
let fixture: ComponentFixture<ItemEditBitstreamComponent>;
const columnSizes = new ResponsiveTableSizes([
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
]);
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', () => {
beforeEach(async(() => {
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[bitstream.uuid]: fieldUpdate,
}),
getFieldUpdatesExclusive: observableOf({
[bitstream.uuid]: fieldUpdate,
}),
saveRemoveFieldUpdate: {},
removeSingleFieldUpdate: {},
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.bundleUrl = url;
comp.columnSizes = columnSizes;
comp.ngOnChanges(undefined);
fixture.detectChanges();
});
describe('when remove is called', () => {
beforeEach(() => {
comp.remove();
});
it('should call saveRemoveFieldUpdate on objectUpdatesService', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, bitstream);
});
});
describe('when undo is called', () => {
beforeEach(() => {
comp.undo();
});
it('should call removeSingleFieldUpdate on objectUpdatesService', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, bitstream.uuid);
});
});
describe('when canRemove is called', () => {
it('should return true', () => {
expect(comp.canRemove()).toEqual(true)
});
});
describe('when canUndo is called', () => {
it('should return false', () => {
expect(comp.canUndo()).toEqual(false)
});
});
});

View File

@@ -0,0 +1,110 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } 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';
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
@Component({
selector: 'ds-item-edit-bitstream',
styleUrls: ['../item-bitstreams.component.scss'],
templateUrl: './item-edit-bitstream.component.html',
})
/**
* Component that displays a single bitstream of an item on the edit page
* Creates an embedded view of the contents
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element)
*/
export class ItemEditBitstreamComponent implements OnChanges, OnInit {
/**
* The view on the bitstream
*/
@ViewChild('bitstreamView', {static: true}) bitstreamView;
/**
* The current field, value and state of the bitstream
*/
@Input() fieldUpdate: FieldUpdate;
/**
* The url of the bundle
*/
@Input() bundleUrl: string;
/**
* The bootstrap sizes used for the columns within this table
*/
@Input() columnSizes: ResponsiveTableSizes;
/**
* The bitstream of this field
*/
bitstream: Bitstream;
/**
* The bitstream's name
*/
bitstreamName: string;
/**
* The format of the bitstream
*/
format$: Observable<BitstreamFormat>;
constructor(private objectUpdatesService: ObjectUpdatesService,
private dsoNameService: DSONameService,
private viewContainerRef: ViewContainerRef) {
}
ngOnInit(): void {
this.viewContainerRef.createEmbeddedView(this.bitstreamView);
}
/**
* Update the current bitstream and its format on changes
* @param changes
*/
ngOnChanges(changes: SimpleChanges): void {
this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream;
this.bitstreamName = this.dsoNameService.getName(this.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.bundleUrl, this.bitstream);
}
/**
* Cancels the current update for this field in the object updates service
*/
undo(): void {
this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, 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

@@ -37,14 +37,14 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
metadataFields$: Observable<string[]>;
constructor(
protected itemService: ItemDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected router: Router,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected route: ActivatedRoute,
protected metadataFieldService: RegistryService,
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
public router: Router,
public notificationsService: NotificationsService,
public translateService: TranslateService,
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
public route: ActivatedRoute,
public metadataFieldService: RegistryService,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
}
@@ -61,8 +61,8 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
* Initialize the values and updates of the current item's metadata fields
*/
public initializeUpdates(): void {
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
}
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
}
/**
* Initialize the prefix for notification messages
@@ -83,7 +83,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified);
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
}
/**

View File

@@ -49,18 +49,18 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
entityType$: Observable<ItemType>;
constructor(
protected itemService: ItemDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected router: Router,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected route: ActivatedRoute,
protected relationshipService: RelationshipService,
protected objectCache: ObjectCacheService,
protected requestService: RequestService,
protected entityTypeService: EntityTypeService,
protected cdr: ChangeDetectorRef,
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 relationshipService: RelationshipService,
public objectCache: ObjectCacheService,
public requestService: RequestService,
public entityTypeService: EntityTypeService,
public cdr: ChangeDetectorRef,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
}

View File

@@ -10,6 +10,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
export function getItemPageRoute(itemId: string) {
return new URLCombiner(getItemModulePath(), itemId).toString();
@@ -20,6 +21,7 @@ export function getItemEditPath(id: string) {
}
const ITEM_EDIT_PATH = 'edit';
const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
@NgModule({
imports: [
@@ -45,6 +47,11 @@ const ITEM_EDIT_PATH = 'edit';
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
},
{
path: UPLOAD_BITSTREAM_PATH,
component: UploadBitstreamComponent,
canActivate: [AuthenticatedGuard]
}
],
}

View File

@@ -26,6 +26,7 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent
import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component';
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component';
import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
import { StatisticsModule } from '../statistics/statistics.module';
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
@@ -58,6 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
GenericItemPageFieldComponent,
MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent,
UploadBitstreamComponent,
TabbedRelatedEntitiesSearchComponent,
AbstractIncrementalListComponent,
],

View File

@@ -28,6 +28,10 @@ const COMMUNITY_MODULE_PATH = 'communities';
export function getCommunityModulePath() {
return `/${COMMUNITY_MODULE_PATH}`;
}
const BITSTREAM_MODULE_PATH = 'bitstreams';
export function getBitstreamModulePath() {
return `/${BITSTREAM_MODULE_PATH}`;
}
const ADMIN_MODULE_PATH = 'admin';
@@ -63,6 +67,7 @@ export function getDSOPath(dso: DSpaceObject): string {
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
{
path: 'mydspace',
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',

View File

@@ -2,7 +2,6 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { coreSelector } from '../core.selectors';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import {
AddToSSBAction,
CommitSSBAction,
@@ -16,10 +15,9 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { RequestService } from '../data/request.service';
import { PatchRequest, PutRequest } from '../data/request.models';
import { PatchRequest } from '../data/request.models';
import { ObjectCacheService } from './object-cache.service';
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
import { GenericConstructor } from '../shared/generic-constructor';
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable';
import { RestRequestMethod } from '../data/rest-request-method';

View File

@@ -68,6 +68,8 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi
const actionEntry = action.payload as ServerSyncBufferEntry;
if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) {
return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) });
} else {
return state;
}
}

View File

@@ -135,10 +135,14 @@ import { PoolTask } from './tasks/models/pool-task-object.model';
import { TaskObject } from './tasks/models/task-object.model';
import { PoolTaskDataService } from './tasks/pool-task-data.service';
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service';
import { BitstreamDataService } from './data/bitstream-data.service';
import { VersionDataService } from './data/version-data.service';
import { VersionHistoryDataService } from './data/version-history-data.service';
import { Version } from './shared/version.model';
import { VersionHistory } from './shared/version-history.model';
import { WorkflowActionDataService } from './data/workflow-action-data.service';
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -233,6 +237,7 @@ const PROVIDERS = [
DSpaceObjectDataService,
DSOChangeAnalyzer,
DefaultChangeAnalyzer,
ArrayMoveChangeAnalyzer,
ObjectSelectService,
CSSVariableService,
MenuService,
@@ -244,6 +249,7 @@ const PROVIDERS = [
TaskResponseParsingService,
ClaimedTaskDataService,
PoolTaskDataService,
BitstreamDataService,
EntityTypeService,
ContentSourceResponseParsingService,
SearchService,
@@ -259,6 +265,7 @@ const PROVIDERS = [
VersionHistoryDataService,
LicenseDataService,
ItemTypeDataService,
WorkflowActionDataService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
@@ -308,7 +315,8 @@ export const models =
ExternalSource,
ExternalSourceEntry,
Version,
VersionHistory
VersionHistory,
WorkflowAction
];
@NgModule({

View File

@@ -0,0 +1,107 @@
import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service';
import { moveItemInArray } from '@angular/cdk/drag-drop';
import { Operation } from 'fast-json-patch';
/**
* Helper class for creating move tests
* Define a "from" and "to" index to move objects within the array before comparing
*/
class MoveTest {
from: number;
to: number;
constructor(from: number, to: number) {
this.from = from;
this.to = to;
}
}
describe('ArrayMoveChangeAnalyzer', () => {
const comparator = new ArrayMoveChangeAnalyzer<string>();
let originalArray = [];
describe('when all values are defined', () => {
beforeEach(() => {
originalArray = [
'98700118-d65d-4636-b1d0-dba83fc932e1',
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1',
'0f608168-cdfc-46b0-92ce-889f7d3ac684',
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
];
});
testMove([
{ op: 'move', from: '/2', path: '/4' },
], new MoveTest(2, 4));
testMove([
{ op: 'move', from: '/0', path: '/3' },
], new MoveTest(0, 3));
testMove([
{ op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 3), new MoveTest(1, 2));
testMove([
{ op: 'move', from: '/0', path: '/1' },
{ op: 'move', from: '/3', path: '/4' }
], new MoveTest(0, 1), new MoveTest(3, 4));
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
testMove([
{ op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
});
describe('when some values are undefined (index 2 and 3)', () => {
beforeEach(() => {
originalArray = [
'98700118-d65d-4636-b1d0-dba83fc932e1',
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
undefined,
undefined,
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
];
});
// It can't create a move operation for undefined values, so it should create move operations for the defined values instead
testMove([
{ op: 'move', from: '/4', path: '/3' },
], new MoveTest(2, 4));
// Moving a defined value should result in the same operations
testMove([
{ op: 'move', from: '/0', path: '/3' },
], new MoveTest(0, 3));
});
/**
* Helper function for creating a move test
*
* @param expectedOperations An array of expected operations after comparing the original array with the array
* created using the provided MoveTests
* @param moves An array of MoveTest objects telling the test where to move objects before comparing
*/
function testMove(expectedOperations: Operation[], ...moves: MoveTest[]) {
describe(`move ${moves.map((move) => `${move.from} to ${move.to}`).join(' and ')}`, () => {
let result;
beforeEach(() => {
const movedArray = [...originalArray];
moves.forEach((move) => {
moveItemInArray(movedArray, move.from, move.to);
});
result = comparator.diff(originalArray, movedArray);
});
it('should create the expected move operations', () => {
expect(result).toEqual(expectedOperations);
});
});
}
});

View File

@@ -0,0 +1,37 @@
import { MoveOperation } from 'fast-json-patch/lib/core';
import { Injectable } from '@angular/core';
import { moveItemInArray } from '@angular/cdk/drag-drop';
import { hasValue } from '../../shared/empty.util';
/**
* A class to determine move operations between two arrays
*/
@Injectable()
export class ArrayMoveChangeAnalyzer<T> {
/**
* Compare two arrays detecting and returning move operations
*
* @param array1 The original array
* @param array2 The custom array to compare with the original
*/
diff(array1: T[], array2: T[]): MoveOperation[] {
const result = [];
const moved = [...array1];
array1.forEach((value: T, index: number) => {
if (hasValue(value)) {
const otherIndex = array2.indexOf(value);
const movedIndex = moved.indexOf(value);
if (index !== otherIndex && movedIndex !== otherIndex) {
moveItemInArray(moved, movedIndex, otherIndex);
result.push(Object.assign({
op: 'move',
from: '/' + movedIndex,
path: '/' + otherIndex
}) as MoveOperation)
}
}
});
return result;
}
}

View File

@@ -0,0 +1,58 @@
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 { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { BitstreamFormatDataService } from './bitstream-format-data.service';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level';
import { PutRequest } from './request.models';
describe('BitstreamDataService', () => {
let service: BitstreamDataService;
let objectCache: ObjectCacheService;
let requestService: RequestService;
let halService: HALEndpointService;
let bitstreamFormatService: BitstreamFormatDataService;
const bitstreamFormatHref = 'rest-api/bitstreamformats';
const bitstream = Object.assign(new Bitstream(), {
uuid: 'fake-bitstream',
_links: {
self: { href: 'fake-bitstream-self' }
}
});
const format = Object.assign(new BitstreamFormat(), {
id: '2',
shortDescription: 'PNG',
description: 'Portable Network Graphics',
supportLevel: BitstreamFormatSupportLevel.Known
});
const url = 'fake-bitstream-url';
beforeEach(() => {
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
});
requestService = getMockRequestService();
halService = Object.assign(new HALEndpointServiceStub(url));
bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', {
getBrowseEndpoint: observableOf(bitstreamFormatHref)
});
service = new BitstreamDataService(requestService, null, null, null, objectCache, halService, null, null, null, null, bitstreamFormatService);
});
describe('when updating the bitstream\'s format', () => {
beforeEach(() => {
service.updateFormat(bitstream, format);
});
it('should configure a put request', () => {
expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest));
});
});
});

View File

@@ -1,8 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/internal/Observable';
import { map, switchMap } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -22,8 +22,14 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import { RemoteDataError } from './remote-data-error';
import { FindListOptions } from './request.models';
import { FindListOptions, PutRequest } from './request.models';
import { RequestService } from './request.service';
import { BitstreamFormatDataService } from './bitstream-format-data.service';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { combineLatest as observableCombineLatest } from 'rxjs';
/**
* A service to retrieve {@link Bitstream}s from the REST API
@@ -50,6 +56,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Bitstream>,
protected bundleService: BundleDataService,
protected bitstreamFormatService: BitstreamFormatDataService
) {
super();
}
@@ -167,4 +174,37 @@ export class BitstreamDataService extends DataService<Bitstream> {
);
}
/**
* Set the format of a bitstream
* @param bitstream
* @param format
*/
updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const bitstreamHref$ = this.getBrowseEndpoint().pipe(
map((href: string) => `${href}/${bitstream.id}`),
switchMap((href: string) => this.halService.getEndpoint('format', href))
);
const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe(
map((href: string) => `${href}/${format.id}`)
);
observableCombineLatest([bitstreamHref$, formatHref$]).pipe(
map(([bitstreamHref, formatHref]) => {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
options.headers = headers;
return new PutRequest(requestId, bitstreamHref, formatHref, options);
}),
configureRequest(this.requestService),
take(1)
).subscribe(() => {
this.requestService.removeByHrefSubstring(bitstream.self + '/format');
});
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry()
);
}
}

View File

@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/internal/Observable';
import { map } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -18,8 +18,10 @@ import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import { FindListOptions } from './request.models';
import { FindListOptions, GetRequest } from './request.models';
import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bitstream } from '../shared/bitstream.model';
/**
* A service to retrieve {@link Bundle}s from the REST API
@@ -30,6 +32,7 @@ import { RequestService } from './request.service';
@dataService(BUNDLE)
export class BundleDataService extends DataService<Bundle> {
protected linkPath = 'bundles';
protected bitstreamsEndpoint = 'bitstreams';
constructor(
protected requestService: RequestService,
@@ -81,4 +84,34 @@ export class BundleDataService extends DataService<Bundle> {
}),
);
}
/**
* Get the bitstreams endpoint for a bundle
* @param bundleId
*/
getBitstreamsEndpoint(bundleId: string): Observable<string> {
return this.getBrowseEndpoint().pipe(
switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`))
);
}
/**
* Get a bundle's bitstreams using paginated search options
* @param bundleId The bundle's ID
* @param searchOptions The search options to use
* @param linksToFollow The {@link FollowLinkConfig}s for the request
*/
getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
const hrefObs = this.getBitstreamsEndpoint(bundleId).pipe(
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, ...linksToFollow);
}
}

View File

@@ -14,7 +14,7 @@ import {
take,
tap
} from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -44,7 +44,8 @@ import {
FindByIDRequest,
FindListOptions,
FindListRequest,
GetRequest, PatchRequest
GetRequest,
PatchRequest
} from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service';
@@ -502,6 +503,39 @@ export abstract class DataService<T extends CacheableObject> {
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
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 dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return an observable of the completed response
*/
deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
hasValueOperator(),
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
/**
* Delete an existing DSpace Object on the server
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return the delete request's ID
*/
private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -522,10 +556,7 @@ export abstract class DataService<T extends CacheableObject> {
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
return requestId;
}
/**

View File

@@ -47,6 +47,9 @@ describe('ItemDataService', () => {
return cold('a', { a: itemEndpoint });
}
} as HALEndpointService;
const bundleService = jasmine.createSpyObj('bundleService', {
findByHref: {}
});
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
const options = Object.assign(new FindListOptions(), {
@@ -87,7 +90,8 @@ describe('ItemDataService', () => {
halEndpointService,
notificationsService,
http,
comparator
comparator,
bundleService
);
}
@@ -212,4 +216,20 @@ describe('ItemDataService', () => {
});
});
describe('createBundle', () => {
const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429';
const bundleName = 'ORIGINAL';
let result;
beforeEach(() => {
service = initTestService();
spyOn(requestService, 'configure');
result = service.createBundle(itemId, bundleName);
});
it('should configure a POST request', () => {
result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest)));
});
});
});

View File

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, find, map, switchMap, tap } from 'rxjs/operators';
import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BrowseService } from '../browse/browse.service';
@@ -32,6 +32,7 @@ import { RemoteData } from './remote-data';
import {
DeleteRequest,
FindListOptions,
GetRequest,
MappedCollectionsRequest,
PatchRequest,
PostRequest,
@@ -40,6 +41,10 @@ import {
} from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bundle } from '../shared/bundle.model';
import { MetadataMap } from '../shared/metadata.models';
import { BundleDataService } from './bundle-data.service';
@Injectable()
@dataService(ITEM)
@@ -56,6 +61,7 @@ export class ItemDataService extends DataService<Item> {
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Item>,
protected bundleService: BundleDataService
) {
super();
}
@@ -219,6 +225,76 @@ export class ItemDataService extends DataService<Item> {
);
}
/**
* Get the endpoint for an item's bundles
* @param itemId
*/
public getBundlesEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`))
);
}
/**
* Get an item's bundles using paginated search options
* @param itemId The item's ID
* @param searchOptions The search options to use
*/
public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<Bundle>>> {
const hrefObs = this.getBundlesEndpoint(itemId).pipe(
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<Bundle>(hrefObs);
}
/**
* Create a new bundle on an item
* @param itemId The item's ID
* @param bundleName The new bundle's name
* @param metadata Optional metadata for the bundle
*/
public createBundle(itemId: string, bundleName: string, metadata?: MetadataMap): Observable<RemoteData<Bundle>> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getBundlesEndpoint(itemId);
const bundleJson = {
name: bundleName,
metadata: metadata ? metadata : {}
};
hrefObs.pipe(
take(1)
).subscribe((href) => {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/json');
options.headers = headers;
const request = new PostRequest(requestId, href, JSON.stringify(bundleJson), options);
this.requestService.configure(request);
});
const selfLink$ = this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry(),
map((response: any) => {
if (isNotEmpty(response.resourceSelfLinks)) {
return response.resourceSelfLinks[0];
}
}),
distinctUntilChanged()
) as Observable<string>;
return selfLink$.pipe(
switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)),
);
}
/**
* Get the endpoint to move the item
* @param itemId

View File

@@ -8,6 +8,7 @@ import {INotification} from '../../../shared/notifications/models/notification.m
*/
export const ObjectUpdatesActionTypes = {
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'),
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
@@ -15,7 +16,9 @@ export const ObjectUpdatesActionTypes = {
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'),
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
MOVE: type('dspace/core/cache/object-updates/MOVE'),
};
/* tslint:disable:max-classes-per-file */
@@ -26,7 +29,8 @@ export const ObjectUpdatesActionTypes = {
export enum FieldChangeType {
UPDATE = 0,
ADD = 1,
REMOVE = 2
REMOVE = 2,
MOVE = 3
}
/**
@@ -37,7 +41,10 @@ export class InitializeFieldsAction implements Action {
payload: {
url: string,
fields: Identifiable[],
lastModified: Date
lastModified: Date,
order: string[],
pageSize: number,
page: number
};
/**
@@ -47,13 +54,49 @@ export class InitializeFieldsAction implements Action {
* the unique url of the page for which the fields are being initialized
* @param fields The identifiable fields of which the updates are kept track of
* @param lastModified The last modified date of the object that belongs to the page
* @param order A custom order to keep track of objects moving around
* @param pageSize The page size used to fill empty pages for the custom order
* @param page The first page to populate in the custom order
*/
constructor(
url: string,
fields: Identifiable[],
lastModified: Date
lastModified: Date,
order: string[] = [],
pageSize: number = 9999,
page: number = 0
) {
this.payload = { url, fields, lastModified };
this.payload = { url, fields, lastModified, order, pageSize, page };
}
}
/**
* An ngrx action to initialize a new page's fields in the ObjectUpdates state
*/
export class AddPageToCustomOrderAction implements Action {
type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER;
payload: {
url: string,
fields: Identifiable[],
order: string[],
page: number
};
/**
* Create a new AddPageToCustomOrderAction
*
* @param url The unique url of the page for which the fields are being added
* @param fields The identifiable fields of which the updates are kept track of
* @param order A custom order to keep track of objects moving around
* @param page The page to populate in the custom order
*/
constructor(
url: string,
fields: Identifiable[],
order: string[] = [],
page: number = 0
) {
this.payload = { url, fields, order, page };
}
}
@@ -180,7 +223,8 @@ export class DiscardObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.DISCARD;
payload: {
url: string,
notification: INotification
notification: INotification,
discardAll: boolean;
};
/**
@@ -189,12 +233,14 @@ export class DiscardObjectUpdatesAction implements Action {
* @param url
* the unique url of the page for which the changes should be discarded
* @param notification The notification that is raised when changes are discarded
* @param discardAll discard all
*/
constructor(
url: string,
notification: INotification
notification: INotification,
discardAll = false
) {
this.payload = { url, notification };
this.payload = { url, notification, discardAll };
}
}
@@ -242,6 +288,13 @@ export class RemoveObjectUpdatesAction implements Action {
}
}
/**
* An ngrx action to remove all previously discarded updates in the ObjectUpdates state
*/
export class RemoveAllObjectUpdatesAction implements Action {
type = ObjectUpdatesActionTypes.REMOVE_ALL;
}
/**
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
*/
@@ -267,6 +320,43 @@ export class RemoveFieldUpdateAction implements Action {
}
}
/**
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
*/
export class MoveFieldUpdateAction implements Action {
type = ObjectUpdatesActionTypes.MOVE;
payload: {
url: string,
from: number,
to: number,
fromPage: number,
toPage: number,
field?: Identifiable
};
/**
* Create a new RemoveObjectUpdatesAction
*
* @param url
* the unique url of the page for which a field's change should be removed
* @param from The index of the object to move
* @param to The index to move the object to
* @param fromPage The page to move the object from
* @param toPage The page to move the object to
* @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages)
*/
constructor(
url: string,
from: number,
to: number,
fromPage: number,
toPage: number,
field?: Identifiable
) {
this.payload = { url, from, to, fromPage, toPage, field };
}
}
/* tslint:enable:max-classes-per-file */
/**
@@ -279,6 +369,9 @@ export type ObjectUpdatesAction
| ReinstateObjectUpdatesAction
| RemoveObjectUpdatesAction
| RemoveFieldUpdateAction
| MoveFieldUpdateAction
| AddPageToCustomOrderAction
| RemoveAllObjectUpdatesAction
| SelectVirtualMetadataAction
| SetEditableFieldUpdateAction
| SetValidFieldUpdateAction;

View File

@@ -3,12 +3,12 @@ import { Actions, Effect, ofType } from '@ngrx/effects';
import {
DiscardObjectUpdatesAction,
ObjectUpdatesAction,
ObjectUpdatesActionTypes,
ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction,
RemoveObjectUpdatesAction
} from './object-updates.actions';
import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { of as observableOf, race as observableRace, Subject } from 'rxjs';
import { hasNoValue } from '../../../shared/empty.util';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { INotification } from '../../../shared/notifications/models/notification.model';
import {
@@ -16,6 +16,7 @@ import {
NotificationsActionTypes,
RemoveNotificationAction
} from '../../../shared/notifications/notifications.actions';
import { Action } from '@ngrx/store';
/**
* NGRX effects for ObjectUpdatesActions
@@ -53,13 +54,14 @@ export class ObjectUpdatesEffects {
.pipe(
ofType(...Object.values(ObjectUpdatesActionTypes)),
map((action: ObjectUpdatesAction) => {
const url: string = action.payload.url;
if (hasValue((action as any).payload)) {
const url: string = (action as any).payload.url;
if (hasNoValue(this.actionMap$[url])) {
this.actionMap$[url] = new Subject<ObjectUpdatesAction>();
}
this.actionMap$[url].next(action);
}
)
})
);
/**
@@ -91,9 +93,15 @@ export class ObjectUpdatesEffects {
const url: string = action.payload.url;
const notification: INotification = action.payload.notification;
const timeOut = notification.options.timeOut;
let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url);
if (action.payload.discardAll) {
removeAction = new RemoveAllObjectUpdatesAction();
}
return observableRace(
// Either wait for the delay and perform a remove action
observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)),
observableOf(removeAction).pipe(delay(timeOut)),
// Or wait for a a user action
this.actionMap$[url].pipe(
take(1),
@@ -106,19 +114,19 @@ export class ObjectUpdatesEffects {
return { type: 'NO_ACTION' }
}
// If someone performed another action, assume the user does not want to reinstate and remove all changes
return new RemoveObjectUpdatesAction(action.payload.url);
return removeAction
})
),
this.notificationActionMap$[notification.id].pipe(
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION),
map(() => {
return new RemoveObjectUpdatesAction(action.payload.url);
return removeAction;
})
),
this.notificationActionMap$[this.allIdentifier].pipe(
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS),
map(() => {
return new RemoveObjectUpdatesAction(action.payload.url);
return removeAction;
})
)
)

View File

@@ -1,10 +1,10 @@
import * as deepFreeze from 'deep-freeze';
import {
AddFieldUpdateAction,
AddFieldUpdateAction, AddPageToCustomOrderAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
ReinstateObjectUpdatesAction,
InitializeFieldsAction, MoveFieldUpdateAction,
ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction,
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction,
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions';
@@ -85,6 +85,16 @@ describe('objectUpdatesReducer', () => {
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
customOrder: {
initialOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
newOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
pageSize: 10,
changed: false
}
}
};
@@ -111,6 +121,16 @@ describe('objectUpdatesReducer', () => {
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
customOrder: {
initialOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
newOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
pageSize: 10,
changed: false
}
},
[url + OBJECT_UPDATES_TRASH_PATH]: {
fieldStates: {
@@ -145,6 +165,16 @@ describe('objectUpdatesReducer', () => {
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
customOrder: {
initialOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
newOrderPages: [
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
],
pageSize: 10,
changed: false
}
}
};
@@ -213,7 +243,7 @@ describe('objectUpdatesReducer', () => {
});
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate);
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0);
const expectedState = {
[url]: {
@@ -231,7 +261,17 @@ describe('objectUpdatesReducer', () => {
},
fieldUpdates: {},
virtualMetadataSources: {},
lastModified: modDate
lastModified: modDate,
customOrder: {
initialOrderPages: [
{ order: [identifiable1.uuid, identifiable3.uuid] }
],
newOrderPages: [
{ order: [identifiable1.uuid, identifiable3.uuid] }
],
pageSize: 10,
changed: false
}
}
};
const newState = objectUpdatesReducer(testState, action);
@@ -283,10 +323,44 @@ describe('objectUpdatesReducer', () => {
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
});
it('should remove all updates from the state when the REMOVE_ALL action is dispatched', () => {
const action = new RemoveAllObjectUpdatesAction();
const newState = objectUpdatesReducer(discardedTestState, action as any);
expect(newState[url].fieldUpdates).toBeUndefined();
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
});
it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => {
const action = new RemoveFieldUpdateAction(url, uuid);
const newState = objectUpdatesReducer(testState, action);
expect(newState[url].fieldUpdates[uuid]).toBeUndefined();
});
it('should move the custom order from the state when the MOVE action is dispatched', () => {
const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0);
const newState = objectUpdatesReducer(testState, action);
expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]);
expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]);
expect(newState[url].customOrder.changed).toEqual(true);
});
it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => {
const identifiable4 = {
uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955',
key: 'dc.description.abstract',
language: null,
value: 'Extra value'
};
const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2);
const newState = objectUpdatesReducer(testState, action);
// Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values
expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10);
expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined();
// Verify the new page is correct
expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid);
});
});

View File

@@ -1,8 +1,8 @@
import {
AddFieldUpdateAction,
AddFieldUpdateAction, AddPageToCustomOrderAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
InitializeFieldsAction, MoveFieldUpdateAction,
ObjectUpdatesAction,
ObjectUpdatesActionTypes,
ReinstateObjectUpdatesAction,
@@ -12,7 +12,9 @@ import {
SetValidFieldUpdateAction,
SelectVirtualMetadataAction,
} from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { from } from 'rxjs/internal/observable/from';
import {Relationship} from '../../shared/item-relationships/relationship.model';
/**
@@ -46,7 +48,7 @@ export interface Identifiable {
/**
* The state of a single field update
*/
export interface FieldUpdate {
export interface FieldUpdate {
field: Identifiable,
changeType: FieldChangeType
}
@@ -81,6 +83,20 @@ export interface DeleteRelationship extends Relationship {
keepRightVirtualMetadata: boolean,
}
/**
* A custom order given to the list of objects
*/
export interface CustomOrder {
initialOrderPages: OrderPage[],
newOrderPages: OrderPage[],
pageSize: number;
changed: boolean
}
export interface OrderPage {
order: string[]
}
/**
* The updated state of a single page
*/
@@ -89,6 +105,7 @@ export interface ObjectUpdatesEntry {
fieldUpdates: FieldUpdates;
virtualMetadataSources: VirtualMetadataSources;
lastModified: Date;
customOrder: CustomOrder
}
/**
@@ -121,6 +138,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
return initializeFieldsUpdate(state, action as InitializeFieldsAction);
}
case ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER: {
return addPageToCustomOrder(state, action as AddPageToCustomOrderAction);
}
case ObjectUpdatesActionTypes.ADD_FIELD: {
return addFieldUpdate(state, action as AddFieldUpdateAction);
}
@@ -136,6 +156,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
case ObjectUpdatesActionTypes.REMOVE: {
return removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
}
case ObjectUpdatesActionTypes.REMOVE_ALL: {
return removeAllObjectUpdates(state);
}
case ObjectUpdatesActionTypes.REMOVE_FIELD: {
return removeFieldUpdate(state, action as RemoveFieldUpdateAction);
}
@@ -145,6 +168,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
case ObjectUpdatesActionTypes.SET_VALID_FIELD: {
return setValidFieldUpdate(state, action as SetValidFieldUpdateAction);
}
case ObjectUpdatesActionTypes.MOVE: {
return moveFieldUpdate(state, action as MoveFieldUpdateAction);
}
default: {
return state;
}
@@ -160,18 +186,50 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
const url: string = action.payload.url;
const fields: Identifiable[] = action.payload.fields;
const lastModifiedServer: Date = action.payload.lastModified;
const order = action.payload.order;
const pageSize = action.payload.pageSize;
const page = action.payload.page;
const fieldStates = createInitialFieldStates(fields);
const initialOrderPages = addOrderToPages([], order, pageSize, page);
const newPageState = Object.assign(
{},
state[url],
{ fieldStates: fieldStates },
{ fieldUpdates: {} },
{ virtualMetadataSources: {} },
{ lastModified: lastModifiedServer }
{ lastModified: lastModifiedServer },
{ customOrder: {
initialOrderPages: initialOrderPages,
newOrderPages: initialOrderPages,
pageSize: pageSize,
changed: false }
}
);
return Object.assign({}, state, { [url]: newPageState });
}
/**
* Add a page of objects to the state of a specific url and update a specific page of the custom order
* @param state The current state
* @param action The action to perform on the current state
*/
function addPageToCustomOrder(state: any, action: AddPageToCustomOrderAction) {
const url: string = action.payload.url;
const fields: Identifiable[] = action.payload.fields;
const fieldStates = createInitialFieldStates(fields);
const order = action.payload.order;
const page = action.payload.page;
const pageState: ObjectUpdatesEntry = state[url] || {};
const newPageState = Object.assign({}, pageState, {
fieldStates: Object.assign({}, pageState.fieldStates, fieldStates),
customOrder: Object.assign({}, pageState.customOrder, {
newOrderPages: addOrderToPages(pageState.customOrder.newOrderPages, order, pageState.customOrder.pageSize, page),
initialOrderPages: addOrderToPages(pageState.customOrder.initialOrderPages, order, pageState.customOrder.pageSize, page)
})
});
return Object.assign({}, state, { [url]: newPageState });
}
/**
* Add a new update for a specific field to the store
* @param state The current state
@@ -252,7 +310,24 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction)
* @param action The action to perform on the current state
*/
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
const url: string = action.payload.url;
if (action.payload.discardAll) {
let newState = Object.assign({}, state);
Object.keys(state).filter((path: string) => !path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => {
newState = discardObjectUpdatesFor(path, newState);
});
return newState;
} else {
const url: string = action.payload.url;
return discardObjectUpdatesFor(url, state);
}
}
/**
* Discard all updates for a specific action's url in the store
* @param url The action's url
* @param state The current state
*/
function discardObjectUpdatesFor(url: string, state: any) {
const pageState: ObjectUpdatesEntry = state[url];
const newFieldStates = {};
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
@@ -263,9 +338,19 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
}
});
const newCustomOrder = Object.assign({}, pageState.customOrder);
if (pageState.customOrder.changed) {
const initialOrder = pageState.customOrder.initialOrderPages;
if (isNotEmpty(initialOrder)) {
newCustomOrder.newOrderPages = initialOrder;
newCustomOrder.changed = false;
}
}
const discardedPageState = Object.assign({}, pageState, {
fieldUpdates: {},
fieldStates: newFieldStates
fieldStates: newFieldStates,
customOrder: newCustomOrder
});
return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState });
}
@@ -305,6 +390,18 @@ function removeObjectUpdatesByURL(state: any, url: string) {
return newState;
}
/**
* Remove all updates in the store
* @param state The current state
*/
function removeAllObjectUpdates(state: any) {
const newState = Object.assign({}, state);
Object.keys(state).filter((path: string) => path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => {
delete newState[path];
});
return newState;
}
/**
* Discard the update for a specific action's url and field UUID in the store
* @param state The current state
@@ -407,3 +504,121 @@ function createInitialFieldStates(fields: Identifiable[]) {
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
return fieldStates;
}
/**
* Method to add a list of objects to an existing FieldStates object
* @param fieldStates FieldStates to add states to
* @param fields Identifiable objects The list of objects to add to the FieldStates
*/
function addFieldStates(fieldStates: FieldStates, fields: Identifiable[]) {
const uuids = fields.map((field: Identifiable) => field.uuid);
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
return fieldStates;
}
/**
* Move an object within the custom order of a page state
* @param state The current state
* @param action The move action to perform
*/
function moveFieldUpdate(state: any, action: MoveFieldUpdateAction) {
const url = action.payload.url;
const fromIndex = action.payload.from;
const toIndex = action.payload.to;
const fromPage = action.payload.fromPage;
const toPage = action.payload.toPage;
const field = action.payload.field;
const pageState: ObjectUpdatesEntry = state[url];
const initialOrderPages = pageState.customOrder.initialOrderPages;
const customOrderPages = [...pageState.customOrder.newOrderPages];
// Create a copy of the custom orders for the from- and to-pages
const fromPageOrder = [...customOrderPages[fromPage].order];
const toPageOrder = [...customOrderPages[toPage].order];
if (fromPage === toPage) {
if (isNotEmpty(customOrderPages[fromPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex]) && isNotEmpty(customOrderPages[fromPage].order[toIndex])) {
// Move an item from one index to another within the same page
moveItemInArray(fromPageOrder, fromIndex, toIndex);
// Update the custom order for this page
customOrderPages[fromPage] = { order: fromPageOrder };
}
} else {
if (isNotEmpty(customOrderPages[fromPage]) && hasValue(customOrderPages[toPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex])) {
// Move an item from one index of one page to an index in another page
transferArrayItem(fromPageOrder, toPageOrder, fromIndex, toIndex);
// Update the custom order for both pages
customOrderPages[fromPage] = { order: fromPageOrder };
customOrderPages[toPage] = { order: toPageOrder };
}
}
// Create a field update if it doesn't exist for this field yet
let fieldUpdate = {};
if (hasValue(field)) {
fieldUpdate = pageState.fieldUpdates[field.uuid];
if (hasNoValue(fieldUpdate)) {
fieldUpdate = { field: field, changeType: undefined }
}
}
// Update the store's state with new values and return
return Object.assign({}, state, { [url]: Object.assign({}, pageState, {
fieldUpdates: Object.assign({}, pageState.fieldUpdates, hasValue(field) ? { [field.uuid]: fieldUpdate } : {}),
customOrder: Object.assign({}, pageState.customOrder, { newOrderPages: customOrderPages, changed: checkForOrderChanges(initialOrderPages, customOrderPages) })
})})
}
/**
* Compare two lists of OrderPage objects and return whether there's at least one change in the order of objects within
* @param initialOrderPages The initial list of OrderPages
* @param customOrderPages The changed list of OrderPages
*/
function checkForOrderChanges(initialOrderPages: OrderPage[], customOrderPages: OrderPage[]) {
let changed = false;
initialOrderPages.forEach((orderPage: OrderPage, page: number) => {
if (isNotEmpty(orderPage) && isNotEmpty(orderPage.order) && isNotEmpty(customOrderPages[page]) && isNotEmpty(customOrderPages[page].order)) {
orderPage.order.forEach((id: string, index: number) => {
if (id !== customOrderPages[page].order[index]) {
changed = true;
return;
}
});
if (changed) {
return;
}
}
});
return changed;
}
/**
* Initialize a custom order page by providing the list of all pages, a list of UUIDs, pageSize and the page to populate
* @param initialPages The initial list of OrderPage objects
* @param order The list of UUIDs to create a page for
* @param pageSize The pageSize used to populate empty spacer pages
* @param page The index of the page to add
*/
function addOrderToPages(initialPages: OrderPage[], order: string[], pageSize: number, page: number): OrderPage[] {
const result = [...initialPages];
const orderPage: OrderPage = { order: order };
if (page < result.length) {
// The page we're trying to add already exists in the list. Overwrite it.
result[page] = orderPage;
} else if (page === result.length) {
// The page we're trying to add is the next page in the list, add it.
result.push(orderPage);
} else {
// The page we're trying to add is at least one page ahead of the list, fill the list with empty pages before adding the page.
const emptyOrder = [];
for (let i = 0; i < pageSize; i++) {
emptyOrder.push(undefined);
}
const emptyOrderPage: OrderPage = { order: emptyOrder };
for (let i = result.length; i < page; i++) {
result.push(emptyOrderPage);
}
result.push(orderPage);
}
return result;
}

View File

@@ -2,6 +2,7 @@ import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { ObjectUpdatesService } from './object-updates.service';
import {
AddPageToCustomOrderAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction,
@@ -12,6 +13,8 @@ import { Notification } from '../../../shared/notifications/models/notification.
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
import {Relationship} from '../../shared/item-relationships/relationship.model';
import { MoveOperation } from 'fast-json-patch/lib/core';
import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service';
describe('ObjectUpdatesService', () => {
let service: ObjectUpdatesService;
@@ -44,7 +47,7 @@ describe('ObjectUpdatesService', () => {
};
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = (new ObjectUpdatesService(store));
service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer<string>());
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
@@ -60,6 +63,25 @@ describe('ObjectUpdatesService', () => {
});
});
describe('initializeWithCustomOrder', () => {
const pageSize = 20;
const page = 0;
it('should dispatch an INITIALIZE action with the correct URL, initial identifiables, last modified , custom order, page size and page', () => {
service.initializeWithCustomOrder(url, identifiables, modDate, pageSize, page);
expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate, identifiables.map((identifiable) => identifiable.uuid), pageSize, page));
});
});
describe('addPageToCustomOrder', () => {
const page = 2;
it('should dispatch an ADD_PAGE_TO_CUSTOM_ORDER action with the correct URL, identifiables, custom order and page number to add', () => {
service.addPageToCustomOrder(url, identifiables, page);
expect(store.dispatch).toHaveBeenCalledWith(new AddPageToCustomOrderAction(url, identifiables, identifiables.map((identifiable) => identifiable.uuid), page));
});
});
describe('getFieldUpdates', () => {
it('should return the list of all fields, including their update if there is one', () => {
const result$ = service.getFieldUpdates(url, identifiables);
@@ -77,6 +99,66 @@ describe('ObjectUpdatesService', () => {
});
});
describe('getFieldUpdatesExclusive', () => {
it('should return the list of all fields, including their update if there is one, excluding updates that aren\'t part of the initial values provided', (done) => {
const result$ = service.getFieldUpdatesExclusive(url, identifiables);
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
const expectedResult = {
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE },
[identifiable2.uuid]: { field: identifiable2, changeType: undefined }
};
result$.subscribe((result) => {
expect(result).toEqual(expectedResult);
done();
});
});
});
describe('getFieldUpdatesByCustomOrder', () => {
beforeEach(() => {
const fieldStates = {
[identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
[identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
[identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
};
const customOrder = {
initialOrderPages: [{
order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid]
}],
newOrderPages: [{
order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid]
}],
pageSize: 20,
changed: true
};
const objectEntry = {
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder
};
(service as any).getObjectEntry.and.returnValue(observableOf(objectEntry))
});
it('should return the list of all fields, including their update if there is one, ordered by their custom order', (done) => {
const result$ = service.getFieldUpdatesByCustomOrder(url, identifiables);
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
const expectedResult = {
[identifiable2.uuid]: { field: identifiable2, changeType: undefined },
[identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD },
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }
};
result$.subscribe((result) => {
expect(result).toEqual(expectedResult);
done();
});
});
});
describe('isEditable', () => {
it('should return false if this identifiable is currently not editable in the store', () => {
const result$ = service.isEditable(url, identifiable1.uuid);
@@ -192,7 +274,11 @@ describe('ObjectUpdatesService', () => {
});
describe('when updates are emtpy', () => {
beforeEach(() => {
(service as any).getObjectEntry.and.returnValue(observableOf({}))
(service as any).getObjectEntry.and.returnValue(observableOf({
customOrder: {
changed: false
}
}))
});
it('should return false when there are no updates', () => {
@@ -259,4 +345,45 @@ describe('ObjectUpdatesService', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true));
});
});
describe('getMoveOperations', () => {
beforeEach(() => {
const fieldStates = {
[identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
[identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
[identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
};
const customOrder = {
initialOrderPages: [{
order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid]
}],
newOrderPages: [{
order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid]
}],
pageSize: 20,
changed: true
};
const objectEntry = {
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder
};
(service as any).getObjectEntry.and.returnValue(observableOf(objectEntry))
});
it('should return the expected move operations', (done) => {
const result$ = service.getMoveOperations(url);
const expectedResult = [
{ op: 'move', from: '/0', path: '/2' }
] as MoveOperation[];
result$.subscribe((result) => {
expect(result).toEqual(expectedResult);
done();
});
});
});
});

View File

@@ -8,15 +8,16 @@ import {
Identifiable,
OBJECT_UPDATES_TRASH_PATH,
ObjectUpdatesEntry,
ObjectUpdatesState,
ObjectUpdatesState, OrderPage,
VirtualMetadataSource
} from './object-updates.reducer';
import { Observable } from 'rxjs';
import {
AddFieldUpdateAction,
AddFieldUpdateAction, AddPageToCustomOrderAction,
DiscardObjectUpdatesAction,
FieldChangeType,
InitializeFieldsAction,
MoveFieldUpdateAction,
ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction,
SelectVirtualMetadataAction,
@@ -26,6 +27,9 @@ import {
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model';
import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service';
import { MoveOperation } from 'fast-json-patch/lib/core';
import { flatten } from '@angular/compiler';
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
@@ -48,7 +52,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
*/
@Injectable()
export class ObjectUpdatesService {
constructor(private store: Store<CoreState>) {
constructor(private store: Store<CoreState>,
private comparator: ArrayMoveChangeAnalyzer<string>) {
}
@@ -62,6 +67,28 @@ export class ObjectUpdatesService {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
}
/**
* Method to dispatch an InitializeFieldsAction to the store and keeping track of the order objects are stored
* @param url The page's URL for which the changes are being mapped
* @param fields The initial fields for the page's object
* @param lastModified The date the object was last modified
* @param pageSize The page size to use for adding pages to the custom order
* @param page The first page to populate the custom order with
*/
initializeWithCustomOrder(url, fields: Identifiable[], lastModified: Date, pageSize = 9999, page = 0): void {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, fields.map((field) => field.uuid), pageSize, page));
}
/**
* Method to dispatch an AddPageToCustomOrderAction, adding a new page to an already existing custom order tracking
* @param url The URL for which the changes are being mapped
* @param fields The fields to add a new page for
* @param page The page number (starting from index 0)
*/
addPageToCustomOrder(url, fields: Identifiable[], page: number): void {
this.store.dispatch(new AddPageToCustomOrderAction(url, fields, fields.map((field) => field.uuid), page));
}
/**
* Method to dispatch an AddFieldUpdateAction to the store
* @param url The page's URL for which the changes are saved
@@ -94,14 +121,15 @@ export class ObjectUpdatesService {
* a FieldUpdates object
* @param url The URL of the page for which the FieldUpdates should be requested
* @param initialFields The initial values of the fields
* @param ignoreStates Ignore the fieldStates to loop over the fieldUpdates instead
*/
getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(
switchMap((objectEntry) => {
const fieldUpdates: FieldUpdates = {};
if (hasValue(objectEntry)) {
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => {
fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid];
});
}
@@ -138,6 +166,31 @@ export class ObjectUpdatesService {
}))
}
/**
* Method that combines the state's updates with the initial values (when there's no update),
* sorted by their custom order 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
* @param page The page to retrieve
*/
getFieldUpdatesByCustomOrder(url: string, initialFields: Identifiable[], page = 0): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => {
const fieldUpdates: FieldUpdates = {};
if (hasValue(objectEntry) && hasValue(objectEntry.customOrder) && isNotEmpty(objectEntry.customOrder.newOrderPages) && page < objectEntry.customOrder.newOrderPages.length) {
for (const uuid of objectEntry.customOrder.newOrderPages[page].order) {
let fieldUpdate = objectEntry.fieldUpdates[uuid];
if (isEmpty(fieldUpdate)) {
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid);
fieldUpdate = {field: identifiable, changeType: undefined};
}
fieldUpdates[uuid] = fieldUpdate;
}
}
return fieldUpdates;
}))
}
/**
* Method to check if a specific field is currently editable in the store
* @param url The URL of the page on which the field resides
@@ -207,6 +260,19 @@ export class ObjectUpdatesService {
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
}
/**
* Dispatches a MoveFieldUpdateAction
* @param url The page's URL for which the changes are saved
* @param from The index of the object to move
* @param to The index to move the object to
* @param fromPage The page to move the object from
* @param toPage The page to move the object to
* @param field Optional field to add to the fieldUpdates list (useful if we want to track updates across multiple pages)
*/
saveMoveFieldUpdate(url: string, from: number, to: number, fromPage = 0, toPage = 0, field?: Identifiable) {
this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field));
}
/**
* Check whether the virtual metadata of a given item is selected to be saved as real metadata
* @param url The URL of the page on which the field resides
@@ -264,6 +330,15 @@ export class ObjectUpdatesService {
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification));
}
/**
* Method to dispatch a DiscardObjectUpdatesAction to the store with discardAll set to true
* @param url The page's URL for which the changes should be discarded
* @param undoNotification The notification which is should possibly be canceled
*/
discardAllFieldUpdates(url: string, undoNotification: INotification) {
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification, true));
}
/**
* Method to dispatch an ReinstateObjectUpdatesAction to the store
* @param url The page's URL for which the changes should be reinstated
@@ -312,7 +387,7 @@ export class ObjectUpdatesService {
* @param url The page's url to check for in the store
*/
hasUpdates(url: string): Observable<boolean> {
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates)));
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && (isNotEmpty(objectEntry.fieldUpdates) || objectEntry.customOrder.changed)));
}
/**
@@ -330,4 +405,19 @@ export class ObjectUpdatesService {
getLastModified(url: string): Observable<Date> {
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
}
/**
* Get move operations based on the custom order
* @param url The page's url
*/
getMoveOperations(url: string): Observable<MoveOperation[]> {
return this.getObjectEntry(url).pipe(
map((objectEntry) => objectEntry.customOrder),
map((customOrder) => this.comparator.diff(
flatten(customOrder.initialOrderPages.map((orderPage: OrderPage) => orderPage.order)),
flatten(customOrder.newOrderPages.map((orderPage: OrderPage) => orderPage.order)))
)
);
}
}

View File

@@ -0,0 +1,41 @@
import { DataService } from './data.service';
import { WorkflowAction } from '../tasks/models/workflow-action-object.model';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FindListOptions } from './request.models';
import { Observable } from 'rxjs/internal/Observable';
import { Injectable } from '@angular/core';
import { dataService } from '../cache/builders/build-decorators';
import { WORKFLOW_ACTION } from '../tasks/models/workflow-action-object.resource-type';
/**
* A service responsible for fetching/sending data from/to the REST API on the workflowactions endpoint
*/
@Injectable()
@dataService(WORKFLOW_ACTION)
export class WorkflowActionDataService extends DataService<WorkflowAction> {
protected linkPath = 'workflowactions';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<WorkflowAction>) {
super();
}
getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
}

View File

@@ -54,7 +54,7 @@ export class Bitstream extends DSpaceObject implements HALResource {
* The BitstreamFormat of this Bitstream
* Will be undefined unless the format {@link HALLink} has been resolved.
*/
@link(BITSTREAM_FORMAT)
@link(BITSTREAM_FORMAT, false, 'format')
format?: Observable<RemoteData<BitstreamFormat>>;
}

View File

@@ -1,5 +1,5 @@
import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize';
import { hasNoValue, isUndefined } from '../../shared/empty.util';
import { hasNoValue, hasValue, isUndefined } from '../../shared/empty.util';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
import { typedObject } from '../cache/builders/build-decorators';
import { CacheableObject } from '../cache/object-cache.reducer';
@@ -79,6 +79,9 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
* The name for this DSpaceObject
*/
set name(name) {
if (hasValue(this.firstMetadata('dc.title'))) {
this.firstMetadata('dc.title').value = name;
}
this._name = name;
}

View File

@@ -7,6 +7,7 @@ import {
MetadatumViewModel
} from './metadata.models';
import { Metadata } from './metadata.utils';
import { beforeEach } from 'selenium-webdriver/testing';
const mdValue = (value: string, language?: string, authority?: string): MetadataValue => {
return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined });
@@ -216,4 +217,26 @@ describe('Metadata', () => {
testToMetadataMap(multiViewModelList, multiMap);
});
describe('setFirstValue method', () => {
const metadataMap = {
'dc.description': [mdValue('Test description')],
'dc.title': [mdValue('Test title 1'), mdValue('Test title 2')]
};
const testSetFirstValue = (map: MetadataMap, key: string, value: string) => {
describe(`with field ${key} and value ${value}`, () => {
Metadata.setFirstValue(map, key, value);
it(`should set first value of ${key} to ${value}`, () => {
expect(map[key][0].value).toEqual(value);
});
});
};
testSetFirstValue(metadataMap, 'dc.description', 'New Description');
testSetFirstValue(metadataMap, 'dc.title', 'New Title');
testSetFirstValue(metadataMap, 'dc.format', 'Completely new field and value');
});
});

View File

@@ -1,4 +1,4 @@
import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
import { isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
import {
MetadataMapInterface,
MetadataValue,
@@ -217,4 +217,19 @@ export class Metadata {
});
return metadataMap;
}
/**
* Set the first value of a metadata by field key
* Creates a new MetadataValue if the field doesn't exist yet
* @param mdMap The map to add/change values in
* @param key The metadata field
* @param value The value to add
*/
public static setFirstValue(mdMap: MetadataMapInterface, key: string, value: string) {
if (isNotEmpty(mdMap[key])) {
mdMap[key][0].value = value;
} else {
mdMap[key] = [Object.assign(new MetadataValue(), { value: value })]
}
}
}

View File

@@ -1,6 +1,6 @@
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, find, flatMap, map, tap } from 'rxjs/operators';
import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
@@ -228,3 +228,13 @@ export const getFirstOccurrence = () =>
source.pipe(
map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined }))
);
/**
* Operator for turning the current page of bitstreams into an array
*/
export const paginatedListToArray = () =>
<T extends DSpaceObject>(source: Observable<RemoteData<PaginatedList<T>>>): Observable<T[]> =>
source.pipe(
hasValueOperator(),
map((objectRD: RemoteData<PaginatedList<T>>) => objectRD.payload.page.filter((object: T) => hasValue(object)))
);

View File

@@ -52,8 +52,7 @@ describe('ClaimedTaskDataService', () => {
options.headers = headers;
});
describe('approveTask', () => {
describe('submitTask', () => {
it('should call postToEndpoint method', () => {
const scopeId = '1234';
const body = {
@@ -63,33 +62,13 @@ describe('ClaimedTaskDataService', () => {
spyOn(service, 'postToEndpoint');
requestService.uriEncodeBody.and.returnValue(body);
service.approveTask(scopeId);
expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
});
});
describe('rejectTask', () => {
it('should call postToEndpoint method', () => {
const scopeId = '1234';
const reason = 'test reject';
const body = {
submit_reject: 'true',
reason
};
spyOn(service, 'postToEndpoint');
requestService.uriEncodeBody.and.returnValue(body);
service.rejectTask(reason, scopeId);
service.submitTask(scopeId, body);
expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
});
});
describe('returnToPoolTask', () => {
it('should call deleteById method', () => {
const scopeId = '1234';

View File

@@ -35,7 +35,6 @@ export class ClaimedTaskDataService extends TasksService<ClaimedTask> {
*
* @param {RequestService} requestService
* @param {RemoteDataBuildService} rdbService
* @param {NormalizedObjectBuildService} linkService
* @param {Store<CoreState>} store
* @param {ObjectCacheService} objectCache
* @param {HALEndpointService} halService
@@ -56,35 +55,16 @@ export class ClaimedTaskDataService extends TasksService<ClaimedTask> {
}
/**
* Make a request to approve the given task
* Make a request for the given task
*
* @param scopeId
* The task id
* @param body
* The request body
* @return {Observable<ProcessTaskResponse>}
* Emit the server response
*/
public approveTask(scopeId: string): Observable<ProcessTaskResponse> {
const body = {
submit_approve: 'true'
};
return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
}
/**
* Make a request to reject the given task
*
* @param reason
* The reason of reject
* @param scopeId
* The task id
* @return {Observable<ProcessTaskResponse>}
* Emit the server response
*/
public rejectTask(reason: string, scopeId: string): Observable<ProcessTaskResponse> {
const body = {
submit_reject: 'true',
reason
};
public submitTask(scopeId: string, body: any): Observable<ProcessTaskResponse> {
return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
}

View File

@@ -13,6 +13,8 @@ import { HALLink } from '../../shared/hal-link.model';
import { WorkflowItem } from '../../submission/models/workflowitem.model';
import { TASK_OBJECT } from './task-object.resource-type';
import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type';
import { WORKFLOW_ACTION } from './workflow-action-object.resource-type';
import { WorkflowAction } from './workflow-action-object.model';
/**
* An abstract model class for a TaskObject.
@@ -34,12 +36,6 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
@autoserialize
step: string;
/**
* The task action type
*/
@autoserialize
action: string;
/**
* The {@link HALLink}s for this TaskObject
*/
@@ -49,6 +45,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
owner: HALLink;
group: HALLink;
workflowitem: HALLink;
action: HALLink;
};
/**
@@ -72,4 +69,11 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
@link(WORKFLOWITEM)
workflowitem?: Observable<RemoteData<WorkflowItem>> | WorkflowItem;
/**
* The task action type
* Will be undefined unless the group {@link HALLink} has been resolved.
*/
@link(WORKFLOW_ACTION, false, 'action')
action: Observable<RemoteData<WorkflowAction>>;
}

View File

@@ -0,0 +1,25 @@
import { inheritSerialization, autoserialize } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { WORKFLOW_ACTION } from './workflow-action-object.resource-type';
/**
* A model class for a WorkflowAction
*/
@typedObject
@inheritSerialization(DSpaceObject)
export class WorkflowAction extends DSpaceObject {
static type = WORKFLOW_ACTION;
/**
* The workflow action's identifier
*/
@autoserialize
id: string;
/**
* The options available for this workflow action
*/
@autoserialize
options: string[];
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for WorkflowAction
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const WORKFLOW_ACTION = new ResourceType('workflowaction');

View File

@@ -76,6 +76,8 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a
import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components';
import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-group/dynamic-relation-group.model';
import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component';
import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model';
import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component';
import { map, startWith, switchMap, find } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { SearchResult } from '../../../search/search-result.model';
@@ -158,6 +160,9 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<
case DYNAMIC_FORM_CONTROL_TYPE_DISABLED:
return DsDynamicDisabledComponent;
case DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH:
return CustomSwitchComponent;
default:
return null;
}
@@ -293,6 +298,10 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
}
}
get isCheckbox(): boolean {
return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX || this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH;
}
ngOnChanges(changes: SimpleChanges) {
if (changes) {
super.ngOnChanges(changes);

View File

@@ -0,0 +1,20 @@
<div [formGroup]="group" class="form-check custom-control custom-switch" [class.disabled]="model.disabled">
<input type="checkbox" class="form-check-input custom-control-input"
[checked]="model.checked"
[class.is-invalid]="showErrorMessages"
[dynamicId]="bindId && model.id"
[formControlName]="model.id"
[indeterminate]="model.indeterminate"
[name]="model.name"
[ngClass]="getClass('element', 'control')"
[required]="model.required"
[tabindex]="model.tabIndex"
[value]="model.value"
(blur)="onBlur($event)"
(change)="onChange($event)"
(focus)="onFocus($event)"/>
<label class="form-check-label custom-control-label" [for]="bindId && model.id">
<span [innerHTML]="model.label"
[ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></span>
</label>
</div>

View File

@@ -0,0 +1,99 @@
import { DynamicFormsCoreModule, DynamicFormService } from '@ng-dynamic-forms/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TextMaskModule } from 'angular2-text-mask';
import { By } from '@angular/platform-browser';
import { DynamicCustomSwitchModel } from './custom-switch.model';
import { CustomSwitchComponent } from './custom-switch.component';
describe('CustomSwitchComponent', () => {
const testModel = new DynamicCustomSwitchModel({id: 'switch'});
const formModel = [testModel];
let formGroup: FormGroup;
let fixture: ComponentFixture<CustomSwitchComponent>;
let component: CustomSwitchComponent;
let debugElement: DebugElement;
let testElement: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
ReactiveFormsModule,
NoopAnimationsModule,
TextMaskModule,
DynamicFormsCoreModule.forRoot()
],
declarations: [CustomSwitchComponent]
}).compileComponents().then(() => {
fixture = TestBed.createComponent(CustomSwitchComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
});
}));
beforeEach(inject([DynamicFormService], (service: DynamicFormService) => {
formGroup = service.createFormGroup(formModel);
component.group = formGroup;
component.model = testModel;
fixture.detectChanges();
testElement = debugElement.query(By.css(`input[id='${testModel.id}']`));
}));
it('should initialize correctly', () => {
expect(component.bindId).toBe(true);
expect(component.group instanceof FormGroup).toBe(true);
expect(component.model instanceof DynamicCustomSwitchModel).toBe(true);
expect(component.blur).toBeDefined();
expect(component.change).toBeDefined();
expect(component.focus).toBeDefined();
expect(component.onBlur).toBeDefined();
expect(component.onChange).toBeDefined();
expect(component.onFocus).toBeDefined();
expect(component.hasFocus).toBe(false);
expect(component.isValid).toBe(true);
expect(component.isInvalid).toBe(false);
});
it('should have an input element', () => {
expect(testElement instanceof DebugElement).toBe(true);
});
it('should have an input element of type checkbox', () => {
expect(testElement.nativeElement.getAttribute('type')).toEqual('checkbox');
});
it('should emit blur event', () => {
spyOn(component.blur, 'emit');
component.onBlur(null);
expect(component.blur.emit).toHaveBeenCalled();
});
it('should emit change event', () => {
spyOn(component.change, 'emit');
component.onChange(null);
expect(component.change.emit).toHaveBeenCalled();
});
it('should emit focus event', () => {
spyOn(component.focus, 'emit');
component.onFocus(null);
expect(component.focus.emit).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,55 @@
import { DynamicNGBootstrapCheckboxComponent } from '@ng-dynamic-forms/ui-ng-bootstrap';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { DynamicCustomSwitchModel } from './custom-switch.model';
@Component({
selector: 'ds-custom-switch',
styleUrls: ['./custom-switch.component.scss'],
templateUrl: './custom-switch.component.html',
})
/**
* Component displaying a custom switch usable in dynamic forms
* Extends from bootstrap's checkbox component but displays a switch instead
*/
export class CustomSwitchComponent extends DynamicNGBootstrapCheckboxComponent {
/**
* Use the model's ID for the input element
*/
@Input() bindId = true;
/**
* The formgroup containing this component
*/
@Input() group: FormGroup;
/**
* The model used for displaying the switch
*/
@Input() model: DynamicCustomSwitchModel;
/**
* Emit an event when the input is selected
*/
@Output() selected = new EventEmitter<number>();
/**
* Emit an event when the input value is removed
*/
@Output() remove = new EventEmitter<number>();
/**
* Emit an event when the input is blurred out
*/
@Output() blur = new EventEmitter<any>();
/**
* Emit an event when the input value changes
*/
@Output() change = new EventEmitter<any>();
/**
* Emit an event when the input is focused
*/
@Output() focus = new EventEmitter<any>();
}

View File

@@ -0,0 +1,20 @@
import {
DynamicCheckboxModel,
DynamicCheckboxModelConfig,
DynamicFormControlLayout,
serializable
} from '@ng-dynamic-forms/core';
export const DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH = 'CUSTOM_SWITCH';
/**
* Model class for displaying a custom switch input in a form
* Functions like a checkbox, but displays a switch instead
*/
export class DynamicCustomSwitchModel extends DynamicCheckboxModel {
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH;
constructor(config: DynamicCheckboxModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
}
}

View File

@@ -50,9 +50,9 @@
<div class="form-group row">
<div class="col text-right">
<button type="reset" class="btn btn-default" (click)="reset()">{{'form.cancel' | translate}}</button>
<button type="reset" class="btn btn-default" (click)="reset()">{{cancelLabel | translate}}</button>
<button type="submit" class="btn btn-primary" (click)="onSubmit()"
[disabled]="!(isValid() | async)">{{'form.submit' | translate}}
[disabled]="!(isValid() | async)">{{submitLabel | translate}}
</button>
</div>
</div>

View File

@@ -53,6 +53,16 @@ export class FormComponent implements OnDestroy, OnInit {
*/
@Input() formId: string;
/**
* i18n key for the submit button
*/
@Input() submitLabel = 'form.submit';
/**
* i18n key for the cancel button
*/
@Input() cancelLabel = 'form.cancel';
/**
* An array of DynamicFormControlModel type
*/

View File

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

View File

@@ -0,0 +1,61 @@
import { EventEmitter, Input, Output } from '@angular/core';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response';
/**
* Abstract component for rendering a claimed task's action
* To create a child-component for a new option:
* - Set the "option" of the component
* - Add a @rendersWorkflowTaskOption annotation to your component providing the same enum value
* - Optionally overwrite createBody if the request body requires more than just the option
*/
export abstract class ClaimedTaskActionsAbstractComponent {
/**
* The workflow task option the child component represents
*/
abstract option: string;
/**
* The Claimed Task to display an action for
*/
@Input() object: ClaimedTask;
/**
* Emits the success or failure of a processed action
*/
@Output() processCompleted: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* A boolean representing if the operation is pending
*/
processing$ = new BehaviorSubject<boolean>(false);
constructor(protected claimedTaskService: ClaimedTaskDataService) {
}
/**
* Create a request body for submitting the task
* Overwrite this method in the child component if the body requires more than just the option
*/
createbody(): any {
return {
[this.option]: 'true'
};
}
/**
* Submit the task for this option
* While the task is submitting, processing$ is set to true and processCompleted emits the response's status when
* completed
*/
submitTask() {
this.processing$.next(true);
this.claimedTaskService.submitTask(this.object.id, this.createbody())
.subscribe((res: ProcessTaskResponse) => {
this.processing$.next(false);
this.processCompleted.emit(res.hasSucceeded);
});
}
}

View File

@@ -1,8 +1,8 @@
<button type="button"
[className]="'btn btn-success ' + wrapperClass"
[className]="'btn btn-success'"
ngbTooltip="{{'submission.workflow.tasks.claimed.approve_help' | translate}}"
[disabled]="processingApprove"
(click)="confirmApprove()">
<span *ngIf="processingApprove"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!processingApprove"><i class="fa fa-thumbs-up"></i> {{'submission.workflow.tasks.claimed.approve' | translate}}</span>
[disabled]="processing$ | async"
(click)="submitTask()">
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fa fa-thumbs-up"></i> {{'submission.workflow.tasks.claimed.approve' | translate}}</span>
</button>

View File

@@ -2,14 +2,23 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { ClaimedTaskActionsApproveComponent } from './claimed-task-actions-approve.component';
import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
let component: ClaimedTaskActionsApproveComponent;
let fixture: ComponentFixture<ClaimedTaskActionsApproveComponent>;
describe('ClaimedTaskActionsApproveComponent', () => {
const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' });
const claimedTaskService = jasmine.createSpyObj('claimedTaskService', {
submitTask: observableOf(new ProcessTaskResponse(true))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
@@ -20,6 +29,9 @@ describe('ClaimedTaskActionsApproveComponent', () => {
}
})
],
providers: [
{ provide: ClaimedTaskDataService, useValue: claimedTaskService }
],
declarations: [ClaimedTaskActionsApproveComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedTaskActionsApproveComponent, {
@@ -30,14 +42,10 @@ describe('ClaimedTaskActionsApproveComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ClaimedTaskActionsApproveComponent);
component = fixture.componentInstance;
component.object = object;
fixture.detectChanges();
});
afterEach(() => {
fixture = null;
component = null;
});
it('should display approve button', () => {
const btn = fixture.debugElement.query(By.css('.btn-success'));
@@ -45,7 +53,7 @@ describe('ClaimedTaskActionsApproveComponent', () => {
});
it('should display spin icon when approve is pending', () => {
component.processingApprove = true;
component.processing$.next(true);
fixture.detectChanges();
const span = fixture.debugElement.query(By.css('.btn-success .fa-spin'));
@@ -53,13 +61,27 @@ describe('ClaimedTaskActionsApproveComponent', () => {
expect(span).toBeDefined();
});
it('should emit approve event', () => {
spyOn(component.approve, 'emit');
describe('submitTask', () => {
let expectedBody;
component.confirmApprove();
fixture.detectChanges();
beforeEach(() => {
spyOn(component.processCompleted, 'emit');
expect(component.approve.emit).toHaveBeenCalled();
expectedBody = {
[component.option]: 'true'
};
component.submitTask();
fixture.detectChanges();
});
it('should call claimedTaskService\'s submitTask with the expected body', () => {
expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody)
});
it('should emit a successful processCompleted event', () => {
expect(component.processCompleted.emit).toHaveBeenCalledWith(true);
});
});
});

View File

@@ -1,32 +1,26 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component } from '@angular/core';
import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component';
import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
export const WORKFLOW_TASK_OPTION_APPROVE = 'submit_approve';
@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_APPROVE)
@Component({
selector: 'ds-claimed-task-actions-approve',
styleUrls: ['./claimed-task-actions-approve.component.scss'],
templateUrl: './claimed-task-actions-approve.component.html',
})
export class ClaimedTaskActionsApproveComponent {
/**
* Component for displaying and processing the approve action on a workflow task item
*/
export class ClaimedTaskActionsApproveComponent extends ClaimedTaskActionsAbstractComponent {
/**
* A boolean representing if a reject operation is pending
* This component represents the approve option
*/
@Input() processingApprove: boolean;
option = WORKFLOW_TASK_OPTION_APPROVE;
/**
* CSS classes to append to reject button
*/
@Input() wrapperClass: string;
/**
* An event fired when a approve action is confirmed.
*/
@Output() approve: EventEmitter<any> = new EventEmitter<any>();
/**
* Emit approve event
*/
confirmApprove() {
this.approve.emit();
constructor(protected claimedTaskService: ClaimedTaskDataService) {
super(claimedTaskService);
}
}

View File

@@ -1,20 +1,13 @@
<a [class.disabled]="!(object.workflowitem | async)?.hasSucceeded"
class="btn btn-primary mt-1 mb-3"
ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}"
[routerLink]="['/workflowitems/' + (object?.workflowitem | async)?.payload?.id + '/edit']"
role="button">
<i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}}
</a>
<ds-claimed-task-actions-approve [processingApprove]="(processingApprove$ | async)"
[wrapperClass]="'mt-1 mb-3'"
(approve)="approve()"></ds-claimed-task-actions-approve>
<ds-claimed-task-actions-reject [processingReject]="(processingReject$ | async)"
[wrapperClass]="'mt-1 mb-3'"
(reject)="reject($event)"></ds-claimed-task-actions-reject>
<ds-claimed-task-actions-return-to-pool [processingReturnToPool]="(processingReturnToPool$ | async)"
[wrapperClass]="'mt-1 mb-3'"
(returnToPool)="returnToPool()"></ds-claimed-task-actions-return-to-pool>
<ng-container *ngVar="(actionRD$ | async)?.payload as workflowAction">
<div class="mt-1 mb-3">
<ds-claimed-task-actions-loader *ngFor="let option of workflowAction?.options"
[option]="option"
[object]="object"
(processCompleted)="handleActionResponse($event)">
</ds-claimed-task-actions-loader>
<ds-claimed-task-actions-loader [option]="returnToPoolOption"
[object]="object"
(processCompleted)="handleActionResponse($event)">
</ds-claimed-task-actions-loader>
</div>
</ng-container>

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { of as observableOf } from 'rxjs';
import { cold } from 'jasmine-marbles';
@@ -16,11 +15,14 @@ import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.se
import { ClaimedTaskActionsComponent } from './claimed-task-actions.component';
import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model';
import { WorkflowItem } from '../../../core/submission/models/workflowitem.model';
import { createSuccessfulRemoteDataObject } from '../../testing/utils';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../testing/utils';
import { getMockSearchService } from '../../mocks/mock-search-service';
import { getMockRequestService } from '../../mocks/mock-request.service';
import { RequestService } from '../../../core/data/request.service';
import { SearchService } from '../../../core/shared/search/search.service';
import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service';
import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model';
import { VarDirective } from '../../utils/var.directive';
let component: ClaimedTaskActionsComponent;
let fixture: ComponentFixture<ClaimedTaskActionsComponent>;
@@ -30,15 +32,15 @@ let notificationsServiceStub: NotificationsServiceStub;
let router: RouterStub;
let mockDataService;
let searchService;
let requestServce;
let workflowActionService: WorkflowActionDataService;
let item;
let rdItem;
let workflowitem;
let rdWorkflowitem;
let workflowAction;
function init() {
mockDataService = jasmine.createSpyObj('ClaimedTaskDataService', {
@@ -46,9 +48,7 @@ function init() {
rejectTask: jasmine.createSpy('rejectTask'),
returnToPoolTask: jasmine.createSpy('returnToPoolTask'),
});
searchService = getMockSearchService();
requestServce = getMockRequestService();
item = Object.assign(new Item(), {
@@ -84,7 +84,11 @@ function init() {
workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) });
rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem);
mockObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' });
workflowAction = Object.assign(new WorkflowAction(), { id: 'action-1', options: ['option-1', 'option-2'] });
workflowActionService = jasmine.createSpyObj('workflowActionService', {
findById: createSuccessfulRemoteDataObject$(workflowAction)
});
}
describe('ClaimedTaskActionsComponent', () => {
@@ -99,14 +103,15 @@ describe('ClaimedTaskActionsComponent', () => {
}
})
],
declarations: [ClaimedTaskActionsComponent],
declarations: [ClaimedTaskActionsComponent, VarDirective],
providers: [
{ provide: Injector, useValue: {} },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: Router, useValue: new RouterStub() },
{ provide: ClaimedTaskDataService, useValue: mockDataService },
{ provide: SearchService, useValue: searchService },
{ provide: RequestService, useValue: requestServce }
{ provide: RequestService, useValue: requestServce },
{ provide: WorkflowActionDataService, useValue: workflowActionService }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedTaskActionsComponent, {
@@ -123,11 +128,6 @@ describe('ClaimedTaskActionsComponent', () => {
fixture.detectChanges();
});
afterEach(() => {
fixture = null;
component = null;
});
it('should init objects properly', () => {
component.object = null;
component.initObjects(mockObject);
@@ -136,46 +136,14 @@ describe('ClaimedTaskActionsComponent', () => {
expect(component.workflowitem$).toBeObservable(cold('(b|)', {
b: rdWorkflowitem.payload
}))
}));
});
it('should display edit task button', () => {
const btn = fixture.debugElement.query(By.css('.btn-info'));
expect(btn).toBeDefined();
});
it('should call approveTask method when approving a task', fakeAsync(() => {
spyOn(component, 'reload');
mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true}));
component.approve();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(mockDataService.approveTask).toHaveBeenCalledWith(mockObject.id);
});
}));
it('should display a success notification on approve success', async(() => {
spyOn(component, 'reload');
mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true}));
component.approve();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(notificationsServiceStub.success).toHaveBeenCalled();
});
}));
it('should reload page on approve success', async(() => {
it('should reload page on process completed', async(() => {
spyOn(router, 'navigateByUrl');
router.url = 'test.url/test';
mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true}));
component.approve();
component.handleActionResponse(true);
fixture.detectChanges();
fixture.whenStable().then(() => {
@@ -183,108 +151,8 @@ describe('ClaimedTaskActionsComponent', () => {
});
}));
it('should display an error notification on approve failure', async(() => {
mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: false}));
component.approve();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(notificationsServiceStub.error).toHaveBeenCalled();
});
}));
it('should call rejectTask method when rejecting a task', fakeAsync(() => {
spyOn(component, 'reload');
mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true}));
component.reject('test reject');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(mockDataService.rejectTask).toHaveBeenCalledWith('test reject', mockObject.id);
});
}));
it('should display a success notification on reject success', async(() => {
spyOn(component, 'reload');
mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true}));
component.reject('test reject');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(notificationsServiceStub.success).toHaveBeenCalled();
});
}));
it('should reload page on reject success', async(() => {
spyOn(router, 'navigateByUrl');
router.url = 'test.url/test';
mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true}));
component.reject('test reject');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test');
});
}));
it('should display an error notification on reject failure', async(() => {
mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: false}));
component.reject('test reject');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(notificationsServiceStub.error).toHaveBeenCalled();
});
}));
it('should call returnToPoolTask method when returning a task to pool', fakeAsync(() => {
spyOn(component, 'reload');
mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true}));
component.returnToPool();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(mockDataService.returnToPoolTask).toHaveBeenCalledWith( mockObject.id);
});
}));
it('should display a success notification on return to pool success', async(() => {
spyOn(component, 'reload');
mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true}));
component.returnToPool();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(notificationsServiceStub.success).toHaveBeenCalled();
});
}));
it('should reload page on return to pool success', async(() => {
spyOn(router, 'navigateByUrl');
router.url = 'test.url/test';
mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true}));
component.returnToPool();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test');
});
}));
it('should display an error notification on return to pool failure', async(() => {
mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: false}));
component.returnToPool();
it('should display an error notification on process failure', async(() => {
component.handleActionResponse(false);
fixture.detectChanges();
fixture.whenStable().then(() => {

View File

@@ -1,13 +1,12 @@
import { Component, Injector, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service';
import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model';
import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response';
import { isNotUndefined } from '../../empty.util';
import { WorkflowItem } from '../../../core/submission/models/workflowitem.model';
import { RemoteData } from '../../../core/data/remote-data';
@@ -15,6 +14,9 @@ import { MyDSpaceActionsComponent } from '../mydspace-actions';
import { NotificationsService } from '../../notifications/notifications.service';
import { RequestService } from '../../../core/data/request.service';
import { SearchService } from '../../../core/shared/search/search.service';
import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model';
import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service';
import { WORKFLOW_TASK_OPTION_RETURN_TO_POOL } from './return-to-pool/claimed-task-actions-return-to-pool.component';
/**
* This component represents actions related to ClaimedTask object.
@@ -37,19 +39,15 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
public workflowitem$: Observable<WorkflowItem>;
/**
* A boolean representing if an approve operation is pending
* The workflow action available for this task
*/
public processingApprove$ = new BehaviorSubject<boolean>(false);
public actionRD$: Observable<RemoteData<WorkflowAction>>;
/**
* A boolean representing if a reject operation is pending
* The option used to render the "return to pool" component
* Every claimed task contains this option
*/
public processingReject$ = new BehaviorSubject<boolean>(false);
/**
* A boolean representing if a return to pool operation is pending
*/
public processingReturnToPool$ = new BehaviorSubject<boolean>(false);
public returnToPoolOption = WORKFLOW_TASK_OPTION_RETURN_TO_POOL;
/**
* Initialize instance variables
@@ -60,13 +58,15 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
* @param {TranslateService} translate
* @param {SearchService} searchService
* @param {RequestService} requestService
* @param workflowActionService
*/
constructor(protected injector: Injector,
protected router: Router,
protected notificationsService: NotificationsService,
protected translate: TranslateService,
protected searchService: SearchService,
protected requestService: RequestService) {
protected requestService: RequestService,
protected workflowActionService: WorkflowActionDataService) {
super(ClaimedTask.type, injector, router, notificationsService, translate, searchService, requestService);
}
@@ -75,6 +75,7 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
*/
ngOnInit() {
this.initObjects(this.object);
this.initAction(this.object);
}
/**
@@ -90,39 +91,12 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent<Claime
}
/**
* Approve the task.
* Init the WorkflowAction
*
* @param object
*/
approve() {
this.processingApprove$.next(true);
this.objectDataService.approveTask(this.object.id)
.subscribe((res: ProcessTaskResponse) => {
this.processingApprove$.next(false);
this.handleActionResponse(res.hasSucceeded);
});
}
/**
* Reject the task.
*/
reject(reason) {
this.processingReject$.next(true);
this.objectDataService.rejectTask(reason, this.object.id)
.subscribe((res: ProcessTaskResponse) => {
this.processingReject$.next(false);
this.handleActionResponse(res.hasSucceeded);
});
}
/**
* Return task to the pool.
*/
returnToPool() {
this.processingReturnToPool$.next(true);
this.objectDataService.returnToPoolTask(this.object.id)
.subscribe((res: ProcessTaskResponse) => {
this.processingReturnToPool$.next(false);
this.handleActionResponse(res.hasSucceeded);
});
initAction(object: ClaimedTask) {
this.actionRD$ = object.action;
}
}

View File

@@ -0,0 +1,7 @@
<a *ngIf="object"
class="btn btn-primary"
ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}"
[routerLink]="['/workflowitems/' + (object.workflowitem | async)?.payload.id + '/edit']"
role="button">
<i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}}
</a>

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ClaimedTaskActionsEditMetadataComponent } from './claimed-task-actions-edit-metadata.component';
import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
let component: ClaimedTaskActionsEditMetadataComponent;
let fixture: ComponentFixture<ClaimedTaskActionsEditMetadataComponent>;
describe('ClaimedTaskActionsEditMetadataComponent', () => {
const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' });
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})
],
providers: [
{ provide: ClaimedTaskDataService, useValue: {} }
],
declarations: [ClaimedTaskActionsEditMetadataComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedTaskActionsEditMetadataComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ClaimedTaskActionsEditMetadataComponent);
component = fixture.componentInstance;
component.object = object;
fixture.detectChanges();
});
it('should display edit button', () => {
const btn = fixture.debugElement.query(By.css('.btn-primary'));
expect(btn).toBeDefined();
});
});

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component';
import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
export const WORKFLOW_TASK_OPTION_EDIT_METADATA = 'submit_edit_metadata';
@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_EDIT_METADATA)
@Component({
selector: 'ds-claimed-task-actions-edit-metadata',
styleUrls: ['./claimed-task-actions-edit-metadata.component.scss'],
templateUrl: './claimed-task-actions-edit-metadata.component.html',
})
/**
* Component for displaying the edit metadata action on a workflow task item
*/
export class ClaimedTaskActionsEditMetadataComponent extends ClaimedTaskActionsAbstractComponent {
/**
* This component represents the edit metadata option
*/
option = WORKFLOW_TASK_OPTION_EDIT_METADATA;
constructor(protected claimedTaskService: ClaimedTaskDataService) {
super(claimedTaskService);
}
}

View File

@@ -1,10 +1,10 @@
<ng-template #rejectTipContent><p [innerHTML]="'submission.workflow.tasks.claimed.reject_help' | translate"></p></ng-template>
<button [className]="'btn btn-danger ' + wrapperClass"
<button [className]="'btn btn-danger'"
[ngbTooltip]="rejectTipContent"
[disabled]="processingReject"
[disabled]="processing$ | async"
(click)="openRejectModal(rejectModal)" >
<span *ngIf="processingReject"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!processingReject"><i class="fa fa-trash"></i> {{'submission.workflow.tasks.claimed.reject.submit' | translate}}</span>
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fa fa-trash"></i> {{'submission.workflow.tasks.claimed.reject.submit' | translate}}</span>
</button>
<ng-template #rejectModal let-c="close" let-d="dismiss">
@@ -21,17 +21,17 @@
<div class="alert alert-info" role="alert">
{{'submission.workflow.tasks.claimed.reject.reason.info' | translate}}
</div>
<form (ngSubmit)="confirmReject(rejectModal);" [formGroup]="rejectForm" >
<form (ngSubmit)="submitTask(rejectModal);" [formGroup]="rejectForm" >
<textarea style="width: 100%"
formControlName="reason"
rows="4"
placeholder="{{'submission.workflow.tasks.claimed.reject.reason.placeholder' | translate}}"></textarea>
<button id="btn-chat"
class="btn btn-danger btn-lg btn-block mt-3"
[disabled]="!rejectForm.valid || processingReject"
[disabled]="!rejectForm.valid || (processing$ | async)"
type="submit">
<span *ngIf="processingReject"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!processingReject">{{'submission.workflow.tasks.claimed.reject.reason.submit' | translate}}</span>
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)">{{'submission.workflow.tasks.claimed.reject.reason.submit' | translate}}</span>
</button>
</form>
</div>

View File

@@ -8,6 +8,10 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ClaimedTaskActionsRejectComponent } from './claimed-task-actions-reject.component';
import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
let component: ClaimedTaskActionsRejectComponent;
let fixture: ComponentFixture<ClaimedTaskActionsRejectComponent>;
@@ -15,6 +19,11 @@ let formBuilder: FormBuilder;
let modalService: NgbModal;
describe('ClaimedTaskActionsRejectComponent', () => {
const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' });
const claimedTaskService = jasmine.createSpyObj('claimedTaskService', {
submitTask: observableOf(new ProcessTaskResponse(true))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
@@ -29,6 +38,7 @@ describe('ClaimedTaskActionsRejectComponent', () => {
],
declarations: [ClaimedTaskActionsRejectComponent],
providers: [
{ provide: ClaimedTaskDataService, useValue: claimedTaskService },
FormBuilder,
NgbModal
],
@@ -43,17 +53,11 @@ describe('ClaimedTaskActionsRejectComponent', () => {
component = fixture.componentInstance;
formBuilder = TestBed.get(FormBuilder);
modalService = TestBed.get(NgbModal);
component.object = object;
component.modalRef = modalService.open('ok');
fixture.detectChanges();
});
afterEach(() => {
fixture = null;
component = null;
modalService = null;
formBuilder = null;
});
it('should init reject form properly', () => {
expect(component.rejectForm).toBeDefined();
expect(component.rejectForm instanceof FormGroup).toBeTruthy();
@@ -67,7 +71,7 @@ describe('ClaimedTaskActionsRejectComponent', () => {
});
it('should display spin icon when reject is pending', () => {
component.processingReject = true;
component.processing$.next(true);
fixture.detectChanges();
const span = fixture.debugElement.query(By.css('.btn-danger .fa-spin'));
@@ -87,22 +91,34 @@ describe('ClaimedTaskActionsRejectComponent', () => {
component.modalRef.close()
});
it('should call confirmReject on form submit', () => {
spyOn(component.reject, 'emit');
describe('on form submit', () => {
let expectedBody;
const btn = fixture.debugElement.query(By.css('.btn-danger'));
btn.nativeElement.click();
fixture.detectChanges();
beforeEach(() => {
spyOn(component.processCompleted, 'emit');
expect(component.modalRef).toBeDefined();
expectedBody = {
[component.option]: 'true',
reason: null
};
const form = ((document as any).querySelector('form'));
form.dispatchEvent(new Event('ngSubmit'));
fixture.detectChanges();
const btn = fixture.debugElement.query(By.css('.btn-danger'));
btn.nativeElement.click();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(component.reject.emit).toHaveBeenCalled();
expect(component.modalRef).toBeDefined();
const form = ((document as any).querySelector('form'));
form.dispatchEvent(new Event('ngSubmit'));
fixture.detectChanges();
});
it('should call claimedTaskService\'s submitTask with the expected body', () => {
expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody)
});
it('should emit a successful processCompleted event', () => {
expect(component.processCompleted.emit).toHaveBeenCalledWith(true);
});
});
});

View File

@@ -1,31 +1,27 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator';
export const WORKFLOW_TASK_OPTION_REJECT = 'submit_reject';
@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_REJECT)
@Component({
selector: 'ds-claimed-task-actions-reject',
styleUrls: ['./claimed-task-actions-reject.component.scss'],
templateUrl: './claimed-task-actions-reject.component.html',
})
export class ClaimedTaskActionsRejectComponent implements OnInit {
/**
* Component for displaying and processing the reject action on a workflow task item
*/
export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstractComponent implements OnInit {
/**
* A boolean representing if a reject operation is pending
* This component represents the reject option
*/
@Input() processingReject: boolean;
/**
* CSS classes to append to reject button
*/
@Input() wrapperClass: string;
/**
* An event fired when a reject action is confirmed.
* Event's payload equals to reject reason.
*/
@Output() reject: EventEmitter<string> = new EventEmitter<string>();
option = WORKFLOW_TASK_OPTION_REJECT;
/**
* The reject form group
@@ -42,8 +38,12 @@ export class ClaimedTaskActionsRejectComponent implements OnInit {
*
* @param {FormBuilder} formBuilder
* @param {NgbModal} modalService
* @param claimedTaskService
*/
constructor(private formBuilder: FormBuilder, private modalService: NgbModal) {
constructor(protected claimedTaskService: ClaimedTaskDataService,
private formBuilder: FormBuilder,
private modalService: NgbModal) {
super(claimedTaskService);
}
/**
@@ -53,17 +53,23 @@ export class ClaimedTaskActionsRejectComponent implements OnInit {
this.rejectForm = this.formBuilder.group({
reason: ['', Validators.required]
});
}
/**
* Close modal and emit reject event
* Create the request body for rejecting a workflow task
* Includes the reason from the form
*/
confirmReject() {
this.processingReject = true;
this.modalRef.close('Send Button');
createbody(): any {
const reason = this.rejectForm.get('reason').value;
this.reject.emit(reason);
return Object.assign(super.createbody(), { reason });
}
/**
* Submit a reject option for the task
*/
submitTask() {
this.modalRef.close('Send Button');
super.submitTask();
}
/**

View File

@@ -1,8 +1,8 @@
<button type="button"
[className]="'btn btn-secondary ' + wrapperClass"
[className]="'btn btn-secondary'"
ngbTooltip="{{'submission.workflow.tasks.claimed.return_help' | translate}}"
[disabled]="processingReturnToPool"
(click)="confirmReturnToPool()">
<span *ngIf="processingReturnToPool"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!processingReturnToPool"><i class="fa fa-undo"></i> {{'submission.workflow.tasks.claimed.return' | translate}}</span>
[disabled]="processing$ | async"
(click)="submitTask()">
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'submission.workflow.tasks.generic.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fa fa-undo"></i> {{'submission.workflow.tasks.claimed.return' | translate}}</span>
</button>

View File

@@ -5,11 +5,20 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ClaimedTaskActionsReturnToPoolComponent } from './claimed-task-actions-return-to-pool.component';
import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
let component: ClaimedTaskActionsReturnToPoolComponent;
let fixture: ComponentFixture<ClaimedTaskActionsReturnToPoolComponent>;
describe('ClaimedTaskActionsReturnToPoolComponent', () => {
const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' });
const claimedTaskService = jasmine.createSpyObj('claimedTaskService', {
returnToPoolTask: observableOf(new ProcessTaskResponse(true))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
@@ -20,6 +29,9 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => {
}
})
],
providers: [
{ provide: ClaimedTaskDataService, useValue: claimedTaskService }
],
declarations: [ClaimedTaskActionsReturnToPoolComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedTaskActionsReturnToPoolComponent, {
@@ -30,14 +42,10 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ClaimedTaskActionsReturnToPoolComponent);
component = fixture.componentInstance;
component.object = object;
fixture.detectChanges();
});
afterEach(() => {
fixture = null;
component = null;
});
it('should display return to pool button', () => {
const btn = fixture.debugElement.query(By.css('.btn-secondary'));
@@ -45,7 +53,7 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => {
});
it('should display spin icon when return to pool action is pending', () => {
component.processingReturnToPool = true;
component.processing$.next(true);
fixture.detectChanges();
const span = fixture.debugElement.query(By.css('.btn-secondary .fa-spin'));
@@ -53,13 +61,21 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => {
expect(span).toBeDefined();
});
it('should emit return to pool event', () => {
spyOn(component.returnToPool, 'emit');
describe('submitTask', () => {
beforeEach(() => {
spyOn(component.processCompleted, 'emit');
component.confirmReturnToPool();
fixture.detectChanges();
component.submitTask();
fixture.detectChanges();
});
expect(component.returnToPool.emit).toHaveBeenCalled();
it('should call claimedTaskService\'s returnToPoolTask', () => {
expect(claimedTaskService.returnToPoolTask).toHaveBeenCalledWith(object.id)
});
it('should emit a successful processCompleted event', () => {
expect(component.processCompleted.emit).toHaveBeenCalledWith(true);
});
});
});

View File

@@ -1,32 +1,39 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component } from '@angular/core';
import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component';
import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response';
export const WORKFLOW_TASK_OPTION_RETURN_TO_POOL = 'return_to_pool';
@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_RETURN_TO_POOL)
@Component({
selector: 'ds-claimed-task-actions-return-to-pool',
styleUrls: ['./claimed-task-actions-return-to-pool.component.scss'],
templateUrl: './claimed-task-actions-return-to-pool.component.html',
})
/**
* Component for displaying and processing the return to pool action on a workflow task item
*/
export class ClaimedTaskActionsReturnToPoolComponent extends ClaimedTaskActionsAbstractComponent {
/**
* This component represents the return to pool option
*/
option = WORKFLOW_TASK_OPTION_RETURN_TO_POOL;
export class ClaimedTaskActionsReturnToPoolComponent {
constructor(protected claimedTaskService: ClaimedTaskDataService) {
super(claimedTaskService);
}
/**
* A boolean representing if a return to pool operation is pending
* Submit a return to pool option for the task
*/
@Input() processingReturnToPool: boolean;
/**
* CSS classes to append to return to pool button
*/
@Input() wrapperClass: string;
/**
* An event fired when a return to pool action is confirmed.
*/
@Output() returnToPool: EventEmitter<any> = new EventEmitter<any>();
/**
* Emit returnToPool event
*/
confirmReturnToPool() {
this.returnToPool.emit();
submitTask() {
this.processing$.next(true);
this.claimedTaskService.returnToPoolTask(this.object.id)
.subscribe((res: ProcessTaskResponse) => {
this.processing$.next(false);
this.processCompleted.emit(res.hasSucceeded);
});
}
}

View File

@@ -0,0 +1,39 @@
import { getComponentByWorkflowTaskOption, rendersWorkflowTaskOption } from './claimed-task-actions-decorator';
describe('ClaimedTaskActions decorator function', () => {
const option1 = 'test_option_1';
const option2 = 'test_option_2';
const option3 = 'test_option_3';
/* tslint:disable:max-classes-per-file */
class Test1Action {};
class Test2Action {};
class Test3Action {};
/* tslint:enable:max-classes-per-file */
beforeAll(() => {
rendersWorkflowTaskOption(option1)(Test1Action);
rendersWorkflowTaskOption(option2)(Test2Action);
rendersWorkflowTaskOption(option3)(Test3Action);
});
describe('If there\'s an exact match', () => {
it('should return the matching class', () => {
const component = getComponentByWorkflowTaskOption(option1);
expect(component).toEqual(Test1Action);
const component2 = getComponentByWorkflowTaskOption(option2);
expect(component2).toEqual(Test2Action);
const component3 = getComponentByWorkflowTaskOption(option3);
expect(component3).toEqual(Test3Action);
});
});
describe('If there\'s no match', () => {
it('should return unidentified', () => {
const component = getComponentByWorkflowTaskOption('non-existing-option');
expect(component).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,23 @@
import { hasNoValue } from '../../../empty.util';
const map = new Map();
/**
* Decorator used for rendering ClaimedTaskActions pages by option type
*/
export function rendersWorkflowTaskOption(option: string) {
return function decorator(component: any) {
if (hasNoValue(map.get(option))) {
map.set(option, component);
} else {
throw new Error(`There can't be more than one component to render ClaimedTaskActions for option "${option}"`);
}
};
}
/**
* Get the component used for rendering a ClaimedTaskActions page by option type
*/
export function getComponentByWorkflowTaskOption(option: string) {
return map.get(option);
}

View File

@@ -0,0 +1 @@
<ng-template dsClaimedTaskActions></ng-template>

View File

@@ -0,0 +1,51 @@
import { ClaimedTaskActionsLoaderComponent } from './claimed-task-actions-loader.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy, Component, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core';
import { spyOnExported } from '../../../testing/utils';
import * as decorators from './claimed-task-actions-decorator';
import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { TranslateModule } from '@ngx-translate/core';
import { ClaimedTaskActionsEditMetadataComponent } from '../edit-metadata/claimed-task-actions-edit-metadata.component';
import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service';
describe('ClaimedTaskActionsLoaderComponent', () => {
let comp: ClaimedTaskActionsLoaderComponent;
let fixture: ComponentFixture<ClaimedTaskActionsLoaderComponent>;
const option = 'test_option';
const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' });
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ClaimedTaskActionsLoaderComponent, ClaimedTaskActionsEditMetadataComponent, ClaimedTaskActionsDirective],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{ provide: ClaimedTaskDataService, useValue: {} },
ComponentFactoryResolver
]
}).overrideComponent(ClaimedTaskActionsLoaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
entryComponents: [ClaimedTaskActionsEditMetadataComponent]
}
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(ClaimedTaskActionsLoaderComponent);
comp = fixture.componentInstance;
comp.object = object;
comp.option = option;
spyOnExported(decorators, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent);
fixture.detectChanges();
}));
describe('When the component is rendered', () => {
it('should call the getComponentByWorkflowTaskOption function with the right option', () => {
expect(decorators.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option);
})
});
});

View File

@@ -0,0 +1,85 @@
import {
Component,
ComponentFactoryResolver,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { getComponentByWorkflowTaskOption } from './claimed-task-actions-decorator';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive';
import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component';
import { hasValue } from '../../../empty.util';
import { Subscription } from 'rxjs/internal/Subscription';
@Component({
selector: 'ds-claimed-task-actions-loader',
templateUrl: './claimed-task-actions-loader.component.html'
})
/**
* Component for loading a ClaimedTaskAction component depending on the "option" input
* Passes on the ClaimedTask to the component and subscribes to the processCompleted output
*/
export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy {
/**
* The ClaimedTask object
*/
@Input() object: ClaimedTask;
/**
* The name of the option to render
* Passed on to the decorator to fetch the relevant component for this option
*/
@Input() option: string;
/**
* Emits the success or failure of a processed action
*/
@Output() processCompleted: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* Directive to determine where the dynamic child component is located
*/
@ViewChild(ClaimedTaskActionsDirective, {static: true}) claimedTaskActionsDirective: ClaimedTaskActionsDirective;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
protected subs: Subscription[] = [];
constructor(private componentFactoryResolver: ComponentFactoryResolver) {
}
/**
* Fetch, create and initialize the relevant component
*/
ngOnInit(): void {
const comp = getComponentByWorkflowTaskOption(this.option);
if (hasValue(comp)) {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(comp);
const viewContainerRef = this.claimedTaskActionsDirective.viewContainerRef;
viewContainerRef.clear();
const componentRef = viewContainerRef.createComponent(componentFactory);
const componentInstance = (componentRef.instance as ClaimedTaskActionsAbstractComponent);
componentInstance.object = this.object;
if (hasValue(componentInstance.processCompleted)) {
this.subs.push(componentInstance.processCompleted.subscribe((success) => this.processCompleted.emit(success)));
}
}
}
/**
* Unsubscribe from open subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -0,0 +1,11 @@
import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[dsClaimedTaskActions]',
})
/**
* Directive used as a hook to know where to inject the dynamic Claimed Task Actions component
*/
export class ClaimedTaskActionsDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}

View File

@@ -84,7 +84,12 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => {
it('should init workflowitem properly', (done) => {
component.workflowitemRD$.subscribe((workflowitemRD) => {
expect(linkService.resolveLink).toHaveBeenCalled();
// Make sure the necessary links are being resolved
expect(linkService.resolveLinks).toHaveBeenCalledWith(
component.dso,
jasmine.objectContaining({ name: 'workflowitem' }),
jasmine.objectContaining({ name: 'action' })
);
expect(workflowitemRD.payload).toEqual(workflowitem);
done();
});

View File

@@ -49,10 +49,10 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD
*/
ngOnInit() {
super.ngOnInit();
this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true,
followLink('item', null, true, followLink('bundles')),
followLink('submitter')
));
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;
}

View File

@@ -86,7 +86,11 @@ describe('PoolSearchResultDetailElementComponent', () => {
it('should init workflowitem properly', (done) => {
component.workflowitemRD$.subscribe((workflowitemRD) => {
expect(linkService.resolveLink).toHaveBeenCalled();
expect(linkService.resolveLinks).toHaveBeenCalledWith(
component.dso,
jasmine.objectContaining({ name: 'workflowitem' }),
jasmine.objectContaining({ name: 'action' })
);
expect(workflowitemRD.payload).toEqual(workflowitem);
done();
});

View File

@@ -48,10 +48,10 @@ export class PoolSearchResultDetailElementComponent extends SearchResultDetailEl
*/
ngOnInit() {
super.ngOnInit();
this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true,
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true,
followLink('item', null, true, followLink('bundles')),
followLink('submitter')
));
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;
}

View File

@@ -18,6 +18,7 @@ import { CollectionSearchResult } from '../../../object-collection/shared/collec
import { TruncatableService } from '../../../truncatable/truncatable.service';
import { TruncatePipe } from '../../../utils/truncate.pipe';
import { CollectionSearchResultGridElementComponent } from './collection-search-result-grid-element.component';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
let collectionSearchResultGridElementComponent: CollectionSearchResultGridElementComponent;
let fixture: ComponentFixture<CollectionSearchResultGridElementComponent>;
@@ -70,6 +71,7 @@ describe('CollectionSearchResultGridElementComponent', () => {
{ provide: HttpClient, useValue: {} },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamFormatDataService, useValue: {} },
],
schemas: [ NO_ERRORS_SCHEMA ]

View File

@@ -18,6 +18,7 @@ import { CommunitySearchResult } from '../../../object-collection/shared/communi
import { TruncatableService } from '../../../truncatable/truncatable.service';
import { TruncatePipe } from '../../../utils/truncate.pipe';
import { CommunitySearchResultGridElementComponent } from './community-search-result-grid-element.component';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
let communitySearchResultGridElementComponent: CommunitySearchResultGridElementComponent;
let fixture: ComponentFixture<CommunitySearchResultGridElementComponent>;
@@ -70,6 +71,7 @@ describe('CommunitySearchResultGridElementComponent', () => {
{ provide: HttpClient, useValue: {} },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamFormatDataService, useValue: {} },
],
schemas: [ NO_ERRORS_SCHEMA ]

View File

@@ -0,0 +1 @@
<div>{{object.name}}</div>

Some files were not shown because too many files have changed in this diff Show More