+
+
+
+ {{"item.edit.bitstreams.upload-button" | translate}}
+
+
+ {{"item.edit.bitstreams.discard-button" | translate}}
+
+
+ {{"item.edit.bitstreams.reinstate-button" | translate}}
+
+
+ {{"item.edit.bitstreams.save-button" | translate}}
+
+
+
0" class="container table-bordered mt-4">
+
+
+
+
+
+ {{'item.edit.bitstreams.empty' | 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 @@
+
+ pageSize}"
+ *ngVar="((updates$ | async) | dsObjectValues) as updateValues" cdkDropList (cdkDropListDropped)="drop($event)">
+
+
+
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 @@
- {{'submission.workflow.tasks.generic.processing' | translate}}
- {{'submission.workflow.tasks.claimed.approve' | translate}}
+ [disabled]="processing$ | async"
+ (click)="submitTask()">
+ {{'submission.workflow.tasks.generic.processing' | translate}}
+ {{'submission.workflow.tasks.claimed.approve' | translate}}
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 @@
-
- {{'submission.workflow.tasks.generic.processing' | translate}}
- {{'submission.workflow.tasks.claimed.reject.submit' | translate}}
+ {{'submission.workflow.tasks.generic.processing' | translate}}
+ {{'submission.workflow.tasks.claimed.reject.submit' | translate}}
@@ -21,17 +21,17 @@
{{'submission.workflow.tasks.claimed.reject.reason.info' | translate}}
-
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