diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 4fee874bb3..c55865f3b8 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -268,6 +268,42 @@ + "bitstream.edit.bitstream": "Bitstream: ", + + "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", + + "bitstream.edit.form.description.label": "Description", + + "bitstream.edit.form.embargo.hint": "The first day from which access is allowed. This date cannot be modified on this form. To set an embargo date for a bitstream, go to the Item Status tab, click Authorizations..., create or edit the bitstream's READ policy, and set the Start Date 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, \"ACMESoft SuperApp version 1.5\").", + + "bitstream.edit.form.primaryBitstream.label": "Primary bitstream", + + "bitstream.edit.form.selectedFormat.hint": "If the format is not in the above list, select \"format not in list\" above 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...", diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts new file mode 100644 index 0000000000..14d688064c --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -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 { +} diff --git a/src/app/+bitstream-page/bitstream-page.module.ts b/src/app/+bitstream-page/bitstream-page.module.ts new file mode 100644 index 0000000000..24b4cd512f --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.module.ts @@ -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 { +} diff --git a/src/app/+bitstream-page/bitstream-page.resolver.ts b/src/app/+bitstream-page/bitstream-page.resolver.ts new file mode 100644 index 0000000000..8e9f64fcc1 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.resolver.ts @@ -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> { + 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<> 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> { + return this.bitstreamService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html new file mode 100644 index 0000000000..fd13e249a0 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -0,0 +1,29 @@ + +
+
+
+ +
+
+
+
+
+

{{bitstreamRD?.payload?.name}} ({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})

+
+
+
+ +
+
+ + +
+
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss new file mode 100644 index 0000000000..d212b5347c --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss @@ -0,0 +1,8 @@ +:host { + ::ng-deep { + .switch { + position: absolute; + top: $spacer*2.5; + } + } +} diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts new file mode 100644 index 0000000000..c802622dc4 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -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; + + 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(); + }); + }); + }); +}); diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts new file mode 100644 index 0000000000..cce6932cd1 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -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>; + + /** + * The formats their remote data observable + * Tracks changes and updates the view + */ + bitstreamFormatsRD$: Observable>>; + + /** + * 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()); + } + +} diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html new file mode 100644 index 0000000000..289ede209a --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html @@ -0,0 +1,41 @@ +
+ +
+
+

{{'item.bitstreams.upload.title' | translate}}

+ +
+ {{'item.bitstreams.upload.item' | translate}} + {{item.name}} +
+
+
+
+ + + + + + +
+
+
+
diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts new file mode 100644 index 0000000000..15a13884f3 --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts @@ -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; + + 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(); + } + +}); diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts new file mode 100644 index 0000000000..536fc0931f --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts @@ -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>; + + /** + * The item's bundles + */ + bundlesRD$: Observable>>; + + /** + * 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) => 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()); + } + +} diff --git a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index c49def3dd2..3cffcf91b6 100644 --- a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -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; - /** - * 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) => 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 { - 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 { - 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'); - - } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 13507c31ce..c828a30369 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -15,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 { diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index b80e6e0678..dc017a9f92 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -1,3 +1,68 @@ -
+
+
+ + + + +
+
+
+
+ + {{'item.edit.bitstreams.headers.name' | translate}} +
+
{{'item.edit.bitstreams.headers.description' | translate}}
+
{{'item.edit.bitstreams.headers.format' | translate}}
+
{{'item.edit.bitstreams.headers.actions' | translate}}
+
+ + +
+ + + +
+
+ + + +
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index e69de29bb2..0400e765de 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -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); +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts new file mode 100644 index 0000000000..9184889257 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -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; + +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); +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 71f25cd5cf..bdb1ec23a5 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,4 +1,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; + + /** + * 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) => 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) => { + 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 { + 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 { + 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(); + } + } } diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html new file mode 100644 index 0000000000..58273bb931 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -0,0 +1,21 @@ + +
+
+ +
+ {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundle.name } }} +
+
+
+
+ +
+
+
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts new file mode 100644 index 0000000000..e15a9d7996 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts new file mode 100644 index 0000000000..115e326241 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -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); + } +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html new file mode 100644 index 0000000000..25941f472e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html @@ -0,0 +1,30 @@ + +
+
+ +
+ +
+
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts new file mode 100644 index 0000000000..704fa0122e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts @@ -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; + 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); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts new file mode 100644 index 0000000000..5548da4029 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts @@ -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 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; + } +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html new file mode 100644 index 0000000000..0561f78e97 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts new file mode 100644 index 0000000000..e6d72cbd57 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts @@ -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); + } + +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html new file mode 100644 index 0000000000..62014f06bd --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -0,0 +1,43 @@ + +
+ +
+ {{ bitstreamName }} +
+
+
+
+ {{ bitstream?.firstMetadataValue('dc.description') }} +
+
+
+
+ {{ (format$ | async)?.shortDescription }} +
+
+
+
+
+ + + + + + +
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts new file mode 100644 index 0000000000..30b5e0d376 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts @@ -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; + +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) + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts new file mode 100644 index 0000000000..5a02b9cac4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts @@ -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; + + 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; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 71acceeb4c..3111e23589 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -37,14 +37,14 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent { metadataFields$: Observable; 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); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 36ccca357c..1958dd0f88 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -49,18 +49,18 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl entityType$: Observable; 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); } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 5caf0e3036..52faf96236 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -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] } ], } diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 8d5d78ddd1..4c3a64e117 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -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, ], diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 2927cd4e65..258848ce83 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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', diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index fd398f2971..84f0312385 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -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'; diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index c86a0d5654..d79dd51da4 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -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; } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 04127ca9de..4a627fffc1 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -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({ diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts new file mode 100644 index 0000000000..5f5388d935 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -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(); + + 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); + }); + }); + } +}); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts new file mode 100644 index 0000000000..39d22fc463 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -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 { + + /** + * 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; + } +} diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts new file mode 100644 index 0000000000..fca0f6b650 --- /dev/null +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -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)); + }); + }); +}); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index c571c7f96c..4c24f5d78b 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -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 { protected http: HttpClient, protected comparator: DSOChangeAnalyzer, protected bundleService: BundleDataService, + protected bitstreamFormatService: BitstreamFormatDataService ) { super(); } @@ -167,4 +174,37 @@ export class BitstreamDataService extends DataService { ); } + /** + * Set the format of a bitstream + * @param bitstream + * @param format + */ + updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable { + 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() + ); + } + } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 64d58eb8ec..160ea0ff0d 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -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 { protected linkPath = 'bundles'; + protected bitstreamsEndpoint = 'bitstreams'; constructor( protected requestService: RequestService, @@ -81,4 +84,34 @@ export class BundleDataService extends DataService { }), ); } + + /** + * Get the bitstreams endpoint for a bundle + * @param bundleId + */ + getBitstreamsEndpoint(bundleId: string): Observable { + 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>): Observable>> { + 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(hrefObs, ...linksToFollow); + } } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 5f68dddeca..26af540193 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -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 { * @return an observable that emits true when the deletion was successful, false when it failed */ delete(dsoID: string, copyVirtualMetadata?: string[]): Observable { + 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 { + 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 { }) ).subscribe(); - return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response.isSuccessful) - ); + return requestId; } /** diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 06adfd5143..2519c90973 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -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))); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index a23eb27f4a..562050c802 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -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 { protected notificationsService: NotificationsService, protected http: HttpClient, protected comparator: DSOChangeAnalyzer, + protected bundleService: BundleDataService ) { super(); } @@ -219,6 +225,76 @@ export class ItemDataService extends DataService { ); } + /** + * Get the endpoint for an item's bundles + * @param itemId + */ + public getBundlesEndpoint(itemId: string): Observable { + 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>> { + 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(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> { + 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; + + return selfLink$.pipe( + switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)), + ); + } + /** * Get the endpoint to move the item * @param itemId diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 9df9acec8f..94918157ee 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -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; diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 88cd3bc718..239fee9477 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -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(); } 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; }) ) ) diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index faae4732bc..bdf202049e 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -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); + }); }); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index cffd41856d..759a9f5c87 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -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; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 730ee5ad43..780a402a84 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -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(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = (new ObjectUpdatesService(store)); + service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer()); 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(); + }); + }); + }); + }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 367b73ee30..c9a7f47e81 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -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 { 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) { + constructor(private store: Store, + private comparator: ArrayMoveChangeAnalyzer) { } @@ -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 { + getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable { 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 { + 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 { - 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 { 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 { + 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))) + ) + ); + } + } diff --git a/src/app/core/data/workflow-action-data.service.ts b/src/app/core/data/workflow-action-data.service.ts new file mode 100644 index 0000000000..be2a170ac5 --- /dev/null +++ b/src/app/core/data/workflow-action-data.service.ts @@ -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 { + protected linkPath = 'workflowactions'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 231d44eeff..ab9d1548b7 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -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>; } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 60a1160d3e..a9256fbb7f 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -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; } diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index f4b3517649..016ef594b1 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -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'); + + }); + }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 334c430968..24ff06f4c9 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -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 })] + } + } } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 715e59df1a..a307b144d2 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -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 = () => + (source: Observable>>): Observable => + source.pipe( + hasValueOperator(), + map((objectRD: RemoteData>) => objectRD.payload.page.filter((object: T) => hasValue(object))) + ); diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts index 90d449b22b..078fe1e63f 100644 --- a/src/app/core/tasks/claimed-task-data.service.spec.ts +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -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'; diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts index 0a9de20530..5815dad6e5 100644 --- a/src/app/core/tasks/claimed-task-data.service.ts +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -35,7 +35,6 @@ export class ClaimedTaskDataService extends TasksService { * * @param {RequestService} requestService * @param {RemoteDataBuildService} rdbService - * @param {NormalizedObjectBuildService} linkService * @param {Store} store * @param {ObjectCacheService} objectCache * @param {HALEndpointService} halService @@ -56,35 +55,16 @@ export class ClaimedTaskDataService extends TasksService { } /** - * 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} * Emit the server response */ - public approveTask(scopeId: string): Observable { - 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} - * Emit the server response - */ - public rejectTask(reason: string, scopeId: string): Observable { - const body = { - submit_reject: 'true', - reason - }; + public submitTask(scopeId: string, body: any): Observable { return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); } diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts index b56cec3a7e..86e0b46f36 100644 --- a/src/app/core/tasks/models/task-object.model.ts +++ b/src/app/core/tasks/models/task-object.model.ts @@ -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> | WorkflowItem; + /** + * The task action type + * Will be undefined unless the group {@link HALLink} has been resolved. + */ + @link(WORKFLOW_ACTION, false, 'action') + action: Observable>; + } diff --git a/src/app/core/tasks/models/workflow-action-object.model.ts b/src/app/core/tasks/models/workflow-action-object.model.ts new file mode 100644 index 0000000000..720d817859 --- /dev/null +++ b/src/app/core/tasks/models/workflow-action-object.model.ts @@ -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[]; +} diff --git a/src/app/core/tasks/models/workflow-action-object.resource-type.ts b/src/app/core/tasks/models/workflow-action-object.resource-type.ts new file mode 100644 index 0000000000..d48ffd18f4 --- /dev/null +++ b/src/app/core/tasks/models/workflow-action-object.resource-type.ts @@ -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'); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 4d26f3948d..2089ce8bca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -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); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html new file mode 100644 index 0000000000..9d059b4bee --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html @@ -0,0 +1,20 @@ +
+ + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts new file mode 100644 index 0000000000..6c2502a92b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts new file mode 100644 index 0000000000..ab02fc159d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts @@ -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(); + + /** + * Emit an event when the input value is removed + */ + @Output() remove = new EventEmitter(); + + /** + * Emit an event when the input is blurred out + */ + @Output() blur = new EventEmitter(); + + /** + * Emit an event when the input value changes + */ + @Output() change = new EventEmitter(); + + /** + * Emit an event when the input is focused + */ + @Output() focus = new EventEmitter(); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts new file mode 100644 index 0000000000..97cf71c4a0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts @@ -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); + } +} diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 510bf7291b..24948680c7 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -50,9 +50,9 @@
- +
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 077def0060..def61cb5b2 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -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 */ diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index 23101b6feb..da297f56ac 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -11,9 +11,7 @@ export function getMockRequestService(requestEntry$: Observable = 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) }); } diff --git a/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts new file mode 100644 index 0000000000..dafc148147 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts @@ -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 = new EventEmitter(); + + /** + * A boolean representing if the operation is pending + */ + processing$ = new BehaviorSubject(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); + }); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html index 3c41fdbb07..7944d24d96 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts index 552d31675e..1cbfdb7c46 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts @@ -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; 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); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts index 8e7c0dab60..8f51ac393c 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts @@ -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 = new EventEmitter(); - - /** - * Emit approve event - */ - confirmApprove() { - this.approve.emit(); + constructor(protected claimedTaskService: ClaimedTaskDataService) { + super(claimedTaskService); } } diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html index df8fb0eae7..aa569bbfc8 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -1,20 +1,13 @@ - - - {{'submission.workflow.tasks.claimed.edit' | translate}} - - - - - - - + +
+ + + + +
+
diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts index 71991bdf25..f30feb4163 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts @@ -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; @@ -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(() => { diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts index 81d24fa1d7..c82154af09 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -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; /** - * A boolean representing if an approve operation is pending + * The workflow action available for this task */ - public processingApprove$ = new BehaviorSubject(false); + public actionRD$: Observable>; /** - * 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(false); - - /** - * A boolean representing if a return to pool operation is pending - */ - public processingReturnToPool$ = new BehaviorSubject(false); + public returnToPoolOption = WORKFLOW_TASK_OPTION_RETURN_TO_POOL; /** * Initialize instance variables @@ -60,13 +58,15 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent { - 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; } } diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html new file mode 100644 index 0000000000..4a42378f7e --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html @@ -0,0 +1,7 @@ + + {{'submission.workflow.tasks.claimed.edit' | translate}} + diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.scss b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts new file mode 100644 index 0000000000..912671bd4b --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts @@ -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; + +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(); + }); + +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts new file mode 100644 index 0000000000..c0ce9cd4e5 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts @@ -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); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html index 91edee66bd..7c7b83cd8a 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html @@ -1,10 +1,10 @@

- @@ -21,17 +21,17 @@ -
+
diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts index 0e5102d538..ea18f97537 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts @@ -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; @@ -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); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts index b66c104695..46d40cbb64 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts @@ -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 = new EventEmitter(); + 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(); } /** diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html index 702ce75e7f..66f8e2a058 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts index d461d9e055..9b5a949d60 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts @@ -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; 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); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts index 1dfe91eb5b..c53bf30fad 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts @@ -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 = new EventEmitter(); - - /** - * 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); + }); } } diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts new file mode 100644 index 0000000000..04c3183a74 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts new file mode 100644 index 0000000000..a115c4e5b8 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts @@ -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); +} diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html new file mode 100644 index 0000000000..364443c47f --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts new file mode 100644 index 0000000000..b71adc7a25 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts @@ -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; + + 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); + }) + }); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts new file mode 100644 index 0000000000..d8c8ecccec --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts @@ -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 = new EventEmitter(); + + /** + * 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()); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts new file mode 100644 index 0000000000..a4a55b541b --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts @@ -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) { } +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts index bdeba1a894..f7ed66488d 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts @@ -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(); }); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts index 4647a4d4a7..359d3abcdc 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts @@ -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>; } diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts index f60cc44410..2e4f0ae608 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts @@ -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(); }); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts index 423931225e..3094d8a98d 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts @@ -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>; } diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index de19f5b74a..d065f9c7e4 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -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; @@ -70,6 +71,7 @@ describe('CollectionSearchResultGridElementComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts index b97c574970..0d59273111 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts @@ -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; @@ -70,6 +71,7 @@ describe('CommunitySearchResultGridElementComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html new file mode 100644 index 0000000000..dfe08144a8 --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html @@ -0,0 +1 @@ +
{{object.name}}
diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts new file mode 100644 index 0000000000..55eb5b116e --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts @@ -0,0 +1,16 @@ +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { Component } from '@angular/core'; +import { listableObjectComponent } from '../../object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-bundle-list-element', + templateUrl: './bundle-list-element.component.html' +}) +/** + * This component is automatically used to create a list view for Bundle objects + */ +@listableObjectComponent(Bundle, ViewMode.ListElement) +export class BundleListElementComponent extends AbstractListableElementComponent { +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts index 03cc46725b..e2102fe9b7 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts @@ -86,7 +86,11 @@ describe('ClaimedSearchResultListElementComponent', () => { 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(); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index cb46e25282..d149595514 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -55,9 +55,9 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle */ ngOnInit() { super.ngOnInit(); - this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true, + this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, followLink('item'), followLink('submitter') - )); + ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts index 39f567bb2e..adf534bb57 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts @@ -86,7 +86,11 @@ describe('PoolSearchResultListElementComponent', () => { 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(); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index 8ab00f4b9b..0953af3c76 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -58,9 +58,9 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen */ ngOnInit() { super.ngOnInit(); - this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true, + this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, followLink('item'), followLink('submitter') - )); + ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts new file mode 100644 index 0000000000..84f3381880 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts @@ -0,0 +1,178 @@ +import { AbstractPaginatedDragAndDropListComponent } from './abstract-paginated-drag-and-drop-list.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { ElementRef } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { createPaginatedList, createSuccessfulRemoteDataObject } from '../testing/utils'; +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { PaginationComponent } from '../pagination/pagination.component'; + +class MockAbstractPaginatedDragAndDropListComponent extends AbstractPaginatedDragAndDropListComponent { + + constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef, + protected mockUrl: string, + protected mockObjectsRD$: Observable>>) { + super(objectUpdatesService, elRef); + } + + initializeObjectsRD(): void { + this.objectsRD$ = this.mockObjectsRD$; + } + + initializeURL(): void { + this.url = this.mockUrl; + } +} + +describe('AbstractPaginatedDragAndDropListComponent', () => { + let component: MockAbstractPaginatedDragAndDropListComponent; + let objectUpdatesService: ObjectUpdatesService; + let elRef: ElementRef; + + const url = 'mock-abstract-paginated-drag-and-drop-list-component'; + + const object1 = Object.assign(new DSpaceObject(), { uuid: 'object-1' }); + const object2 = Object.assign(new DSpaceObject(), { uuid: 'object-2' }); + const objectsRD = createSuccessfulRemoteDataObject(createPaginatedList([object1, object2])); + let objectsRD$: BehaviorSubject>>; + + const updates = { + [object1.uuid]: { field: object1, changeType: undefined }, + [object2.uuid]: { field: object2, changeType: undefined } + } as FieldUpdates; + + let paginationComponent: PaginationComponent; + + beforeEach(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + initializeWithCustomOrder: {}, + addPageToCustomOrder: {}, + getFieldUpdatesByCustomOrder: observableOf(updates), + saveMoveFieldUpdate: {} + }); + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {} + }) + }; + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: {} + }); + objectsRD$ = new BehaviorSubject(objectsRD); + component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, url, objectsRD$); + component.paginationComponent = paginationComponent; + component.ngOnInit(); + }); + + it('should call initializeWithCustomOrder to initialize the first page and add it to initializedPages', (done) => { + expect(component.initializedPages.indexOf(0)).toBeLessThan(0); + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.initializeWithCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(0)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + it('should initialize the updates correctly', (done) => { + component.updates$.pipe(take(1)).subscribe((fieldUpdates) => { + expect(fieldUpdates).toEqual(updates); + done(); + }); + }); + + describe('when a new page is loaded', () => { + const page = 5; + + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('should call addPageToCustomOrder to initialize the new page and add it to initializedPages', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(page - 1)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + describe('twice', () => { + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('shouldn\'t call addPageToCustomOrder again, as the page has already been initialized', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); + + describe('switchPage', () => { + const page = 3; + + beforeEach(() => { + component.switchPage(page); + }); + + it('should set currentPage$ to the new page', () => { + expect(component.currentPage$.value).toEqual(page); + }); + }); + + describe('drop', () => { + const event = { + previousIndex: 0, + currentIndex: 1, + item: { element: { nativeElement: { id: object1.uuid } } } + } as any; + + describe('when the user is hovering over a new page', () => { + const hoverPage = 3; + const hoverElement = { textContent: '' + hoverPage }; + + beforeEach(() => { + elRef.nativeElement.querySelector.and.returnValue(hoverElement); + component.initializedPages.push(hoverPage - 1); + component.drop(event); + }); + + it('should detect the page and set currentPage$ to its value', () => { + expect(component.currentPage$.value).toEqual(hoverPage); + }); + + it('should detect the page and update the pagination component with its value', () => { + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(hoverPage); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, 0, 0, hoverPage - 1, object1); + }); + }); + + describe('when the user is not hovering over a new page', () => { + beforeEach(() => { + component.drop(event); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, event.currentIndex, 0, 0); + }); + }); + }); +}); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts new file mode 100644 index 0000000000..a34b5d5bc0 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -0,0 +1,195 @@ +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { paginatedListToArray } from '../../core/shared/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { ElementRef, ViewChild } from '@angular/core'; +import { PaginationComponent } from '../pagination/pagination.component'; + +/** + * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated + * list. This implementation supports being able to drag and drop objects between pages. + * Dragging an object on top of a page number will automatically detect the page it's being dropped on, send an update + * to the store and add the object on top of that page. + * + * To extend this component, it is important to make sure to: + * - Initialize objectsRD$ within the initializeObjectsRD() method + * - Initialize a unique URL for this component/page within the initializeURL() method + * - Add (cdkDropListDropped)="drop($event)" to the cdkDropList element in your template + * - Add (pageChange)="switchPage($event)" to the ds-pagination element in your template + * - Use the updates$ observable for building your list of cdkDrag elements in your template + * + * An example component extending from this abstract component: PaginatedDragAndDropBitstreamListComponent + */ +export abstract class AbstractPaginatedDragAndDropListComponent { + /** + * A view on the child pagination component + */ + @ViewChild(PaginationComponent, {static: false}) paginationComponent: PaginationComponent; + + /** + * The URL to use for accessing the object updates from this list + */ + url: string; + + /** + * The objects to retrieve data for and transform into field updates + */ + objectsRD$: Observable>>; + + /** + * The updates to the current list + */ + updates$: Observable; + + /** + * The amount of objects to display per page + */ + pageSize = 10; + + /** + * The page options to use for fetching the objects + * Start at page 1 and always use the set page size + */ + options = Object.assign(new PaginationComponentOptions(),{ + id: 'paginated-drag-and-drop-options', + currentPage: 1, + pageSize: this.pageSize + }); + + /** + * The current page being displayed + */ + currentPage$ = new BehaviorSubject(1); + + /** + * A list of pages that have been initialized in the field-update store + */ + initializedPages: number[] = []; + + /** + * An object storing information about an update that should be fired whenever fireToUpdate is called + */ + toUpdate: { + fromIndex: number, + toIndex: number, + fromPage: number, + toPage: number, + field?: T + }; + + protected constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef) { + } + + /** + * Initialize the observables + */ + ngOnInit() { + this.initializeObjectsRD(); + this.initializeURL(); + this.initializeUpdates(); + } + + /** + * Overwrite this method to define how the list of objects is initialized and updated + */ + abstract initializeObjectsRD(): void; + + /** + * Overwrite this method to define how the URL is set + */ + abstract initializeURL(): void; + + /** + * Initialize the field-updates in the store + * This method ensures (new) pages displayed are automatically added to the field-update store when the objectsRD updates + */ + initializeUpdates(): void { + this.updates$ = this.objectsRD$.pipe( + paginatedListToArray(), + tap((objects: T[]) => { + // Pages in the field-update store are indexed starting at 0 (because they're stored in an array of pages) + const updatesPage = this.currentPage$.value - 1; + if (isEmpty(this.initializedPages)) { + // No updates have been initialized yet for this list, initialize the first page + this.objectUpdatesService.initializeWithCustomOrder(this.url, objects, new Date(), this.pageSize, updatesPage); + this.initializedPages.push(updatesPage); + } else if (this.initializedPages.indexOf(updatesPage) < 0) { + // Updates were initialized for this list, but not the page we're on. Add the current page to the field-update store for this list + this.objectUpdatesService.addPageToCustomOrder(this.url, objects, updatesPage); + this.initializedPages.push(updatesPage); + } + + // The new page is loaded into the store, check if there are any updates waiting and fire those as well + this.fireToUpdate(); + }), + switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesByCustomOrder(this.url, objects, this.currentPage$.value - 1)) + ); + } + + /** + * Update the current page + * @param page + */ + switchPage(page: number) { + this.currentPage$.next(page); + } + + /** + * An object was moved, send updates to the store. + * When the object is dropped on a page within the pagination of this component, the object moves to the top of that + * page and the pagination automatically loads and switches the view to that page. + * @param event + */ + drop(event: CdkDragDrop) { + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object + const droppedOnElement = this.elRef.nativeElement.querySelector('.page-item:hover'); + if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent)) { + // The user is hovering over a page, fetch the page's number from the element + const page = Number(droppedOnElement.textContent); + if (hasValue(page) && !Number.isNaN(page)) { + const id = event.item.element.nativeElement.id; + this.updates$.pipe(take(1)).subscribe((updates: FieldUpdates) => { + const field = hasValue(updates[id]) ? updates[id].field : undefined; + this.toUpdate = Object.assign({ + fromIndex: event.previousIndex, + toIndex: 0, + fromPage: this.currentPage$.value - 1, + toPage: page - 1, + field + }); + // Switch to the dropped-on page and force a page update for the pagination component + this.currentPage$.next(page); + this.paginationComponent.doPageChange(page); + if (this.initializedPages.indexOf(page - 1) >= 0) { + // The page the object is being dropped to has already been loaded before, directly fire an update to the store. + // For pages that haven't been loaded before, the updates$ observable will call fireToUpdate after the new page + // has loaded + this.fireToUpdate(); + } + }); + } + } else { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, event.previousIndex, event.currentIndex, this.currentPage$.value - 1, this.currentPage$.value - 1); + } + } + + /** + * Method checking if there's an update ready to be fired. Send out a MoveFieldUpdate to the store if there's an + * update present and clear the update afterwards. + */ + fireToUpdate() { + if (hasValue(this.toUpdate)) { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, this.toUpdate.fromIndex, this.toUpdate.toIndex, this.toUpdate.fromPage, this.toUpdate.toPage, this.toUpdate.field); + this.toUpdate = undefined; + } + } +} diff --git a/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts new file mode 100644 index 0000000000..843d0f043e --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts @@ -0,0 +1,22 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; + +describe('ResponsiveColumnSizes', () => { + const xs = 2; + const sm = 3; + const md = 4; + const lg = 6; + const xl = 8; + const column = new ResponsiveColumnSizes(xs, sm, md, lg, xl); + + describe('buildClasses', () => { + let classes: string; + + beforeEach(() => { + classes = column.buildClasses(); + }); + + it('should return the correct bootstrap classes', () => { + expect(classes).toEqual(`col-${xs} col-sm-${sm} col-md-${md} col-lg-${lg} col-xl-${xl}`); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts new file mode 100644 index 0000000000..84651f3ef5 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts @@ -0,0 +1,46 @@ +/** + * A helper class storing the sizes in which to render a single column + * The values in this class are expected to be between 1 and 12 + * There are used to be added to bootstrap classes such as col-xs-{this.xs} + */ +export class ResponsiveColumnSizes { + /** + * The extra small bootstrap size + */ + xs: number; + + /** + * The small bootstrap size + */ + sm: number; + + /** + * The medium bootstrap size + */ + md: number; + + /** + * The large bootstrap size + */ + lg: number; + + /** + * The extra large bootstrap size + */ + xl: number; + + constructor(xs: number, sm: number, md: number, lg: number, xl: number) { + this.xs = xs; + this.sm = sm; + this.md = md; + this.lg = lg; + this.xl = xl; + } + + /** + * Build the bootstrap responsive column classes matching the values of this object + */ + buildClasses(): string { + return `col-${this.xs} col-sm-${this.sm} col-md-${this.md} col-lg-${this.lg} col-xl-${this.xl}` + } +} diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts new file mode 100644 index 0000000000..23df9b1c25 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts @@ -0,0 +1,76 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { ResponsiveTableSizes } from './responsive-table-sizes'; + +describe('ResponsiveColumnSizes', () => { + const column0 = new ResponsiveColumnSizes(2, 3, 4, 6, 8); + const column1 = new ResponsiveColumnSizes(8, 7, 4, 2, 1); + const column2 = new ResponsiveColumnSizes(1, 1, 4, 2, 1); + const column3 = new ResponsiveColumnSizes(1, 1, 4, 2, 2); + const table = new ResponsiveTableSizes([column0, column1, column2, column3]); + + describe('combineColumns', () => { + describe('when start value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(-1, 2); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when end value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 5); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is greater than end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(2, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is equal to end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when provided with valid values', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 2); + }); + + it('should combine the sizes of each column within the range into one', () => { + expect(combined.xs).toEqual(column0.xs + column1.xs + column2.xs); + expect(combined.sm).toEqual(column0.sm + column1.sm + column2.sm); + expect(combined.md).toEqual(column0.md + column1.md + column2.md); + expect(combined.lg).toEqual(column0.lg + column1.lg + column2.lg); + expect(combined.xl).toEqual(column0.xl + column1.xl + column2.xl); + }); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts new file mode 100644 index 0000000000..b68774d46f --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts @@ -0,0 +1,42 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { hasValue } from '../empty.util'; + +/** + * A helper class storing the sizes in which to render a table + * It stores a list of columns, which in turn store their own bootstrap column sizes + */ +export class ResponsiveTableSizes { + /** + * A list of all the columns and their responsive sizes within this table + */ + columns: ResponsiveColumnSizes[]; + + constructor(columns: ResponsiveColumnSizes[]) { + this.columns = columns; + } + + /** + * Combine the values of multiple columns into a single ResponsiveColumnSizes + * Useful when a row element stretches over multiple columns + * @param start Index of the first column + * @param end Index of the last column (inclusive) + */ + combineColumns(start: number, end: number): ResponsiveColumnSizes { + if (start < end && hasValue(this.columns[start]) && hasValue(this.columns[end])) { + let xs = this.columns[start].xs; + let sm = this.columns[start].sm; + let md = this.columns[start].md; + let lg = this.columns[start].lg; + let xl = this.columns[start].xl; + for (let i = start + 1; i < end + 1; i++) { + xs += this.columns[i].xs; + sm += this.columns[i].sm; + md += this.columns[i].md; + lg += this.columns[i].lg; + xl += this.columns[i].xl; + } + return new ResponsiveColumnSizes(xs, sm, md, lg, xl); + } + return undefined; + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b7b3a9bbff..c8c4db5692 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -184,8 +184,13 @@ import { LogInContainerComponent } from './log-in/container/log-in-container.com import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component'; import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component'; import { LogInComponent } from './log-in/log-in.component'; +import { CustomSwitchComponent } from './form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component'; +import { BundleListElementComponent } from './object-list/bundle-list-element/bundle-list-element.component'; import { MissingTranslationHelper } from './translate/missing-translation.helper'; import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; +import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; +import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; +import { ClaimedTaskActionsEditMetadataComponent } from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; import { ResourcePoliciesComponent } from './resource-policies/resource-policies.component'; import { NgForTrackByIdDirective } from './ng-for-track-by-id.directive'; import { ResourcePolicyFormComponent } from './resource-policies/form/resource-policy-form'; @@ -297,6 +302,8 @@ const COMPONENTS = [ ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, ClaimedTaskActionsReturnToPoolComponent, + ClaimedTaskActionsEditMetadataComponent, + ClaimedTaskActionsLoaderComponent, ItemActionsComponent, PoolTaskActionsComponent, WorkflowitemActionsComponent, @@ -351,6 +358,9 @@ const COMPONENTS = [ AbstractTrackableComponent, ComcolMetadataComponent, ItemTypeBadgeComponent, + BrowseByComponent, + AbstractTrackableComponent, + CustomSwitchComponent, ItemSelectComponent, CollectionSelectComponent, MetadataRepresentationLoaderComponent, @@ -413,6 +423,7 @@ const ENTRY_COMPONENTS = [ PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, + CustomSwitchComponent, ItemMetadataRepresentationListElementComponent, SearchResultsComponent, CollectionSearchResultGridElementComponent, @@ -433,7 +444,12 @@ const ENTRY_COMPONENTS = [ LogInPasswordComponent, LogInShibbolethComponent, ItemVersionsComponent, - ItemVersionsNoticeComponent + BundleListElementComponent, + ItemVersionsNoticeComponent, + ClaimedTaskActionsApproveComponent, + ClaimedTaskActionsRejectComponent, + ClaimedTaskActionsReturnToPoolComponent, + ClaimedTaskActionsEditMetadataComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -464,6 +480,7 @@ const DIRECTIVES = [ RoleDirective, MetadataRepresentationDirective, ListableObjectDirective, + ClaimedTaskActionsDirective, NgForTrackByIdDirective ]; diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts index bb1f4b31b4..e1a99d90b9 100644 --- a/src/app/shared/trackable/abstract-trackable.component.ts +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -63,7 +63,7 @@ export class AbstractTrackableComponent { * Get translated notification title * @param key */ - protected getNotificationTitle(key: string) { + getNotificationTitle(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.title'); } @@ -71,7 +71,7 @@ export class AbstractTrackableComponent { * Get translated notification content * @param key */ - protected getNotificationContent(key: string) { + getNotificationContent(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.content'); } diff --git a/src/app/shared/uploader/uploader-properties.model.ts b/src/app/shared/uploader/uploader-properties.model.ts new file mode 100644 index 0000000000..bc0376b809 --- /dev/null +++ b/src/app/shared/uploader/uploader-properties.model.ts @@ -0,0 +1,21 @@ +import { MetadataMap } from '../../core/shared/metadata.models'; + +/** + * Properties to send to the REST API for uploading a bitstream + */ +export class UploaderProperties { + /** + * A custom name for the bitstream + */ + name: string; + + /** + * Metadata for the bitstream (e.g. dc.description) + */ + metadata: MetadataMap; + + /** + * The name of the bundle to upload the bitstream to + */ + bundleName: string; +} diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index 935d196d08..72a38d1eb1 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -15,8 +15,9 @@ import { uniqueId } from 'lodash'; import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; import { UploaderOptions } from './uploader-options.model'; -import { isNotEmpty, isUndefined } from '../empty.util'; +import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; +import { UploaderProperties } from './uploader-properties.model'; @Component({ selector: 'ds-uploader', @@ -53,6 +54,11 @@ export class UploaderComponent { */ @Input() uploadFilesOptions: UploaderOptions; + /** + * Extra properties to be passed with the form-data of the upload + */ + @Input() uploadProperties: UploaderProperties; + /** * The function to call when upload is completed */ @@ -131,6 +137,11 @@ export class UploaderComponent { }; this.scrollToService.scrollTo(config); }; + if (hasValue(this.uploadProperties)) { + this.uploader.onBuildItemForm = (item, form) => { + form.append('properties', JSON.stringify(this.uploadProperties)) + }; + } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { if (isNotEmpty(response)) { const responsePath = JSON.parse(response); diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts index bb511b4e5c..8c9d863372 100644 --- a/src/app/shared/utils/object-values-pipe.ts +++ b/src/app/shared/utils/object-values-pipe.ts @@ -1,6 +1,10 @@ import { PipeTransform, Pipe } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; -@Pipe({name: 'dsObjectValues'}) +@Pipe({ + name: 'dsObjectValues', + pure: true +}) /** * Pipe for parsing all values of an object to an array of values */ @@ -12,7 +16,9 @@ export class ObjectValuesPipe implements PipeTransform { */ transform(value, args: string[]): any { const values = []; - Object.values(value).forEach((v) => values.push(v)); + if (isNotEmpty(value)) { + Object.values(value).forEach((v) => values.push(v)); + } return values; } }