+
+
+
+ {{"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 fda558a5dd..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,16 +67,30 @@ 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: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] },
+ { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
+ {
+ path: 'mydspace',
+ loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
+ canActivate: [AuthenticatedGuard]
+ },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
- { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' },
- { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
- { path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] },
+ {
+ path: 'workspaceitems',
+ loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
+ },
+ {
+ path: 'workflowitems',
+ loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
+ },
+ {
+ path: PROFILE_MODULE_PATH,
+ loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
+ },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
],
{
diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts
index 8773b1a9fb..e5c9210769 100644
--- a/src/app/core/auth/auth-request.service.ts
+++ b/src/app/core/auth/auth-request.service.ts
@@ -10,6 +10,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest }
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { getResponseFromEntry } from '../shared/operators';
+import { HttpClient } from '@angular/common/http';
@Injectable()
export class AuthRequestService {
@@ -18,7 +19,8 @@ export class AuthRequestService {
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService,
- protected requestService: RequestService) {
+ protected requestService: RequestService,
+ private http: HttpClient) {
}
protected fetchRequest(request: RestRequest): Observable {
@@ -38,7 +40,7 @@ export class AuthRequestService {
return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`;
}
- public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable {
+ public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable {
return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
@@ -67,4 +69,5 @@ export class AuthRequestService {
mergeMap((request: GetRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
+
}
diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts
index 2681ed39a2..2c2224e878 100644
--- a/src/app/core/auth/auth.actions.ts
+++ b/src/app/core/auth/auth.actions.ts
@@ -5,6 +5,8 @@ import { type } from '../../shared/ngrx/type';
// import models
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthStatus } from './models/auth-status.model';
export const AuthActionTypes = {
AUTHENTICATE: type('dspace/auth/AUTHENTICATE'),
@@ -14,12 +16,16 @@ export const AuthActionTypes = {
AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'),
AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'),
CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'),
- CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'),
+ CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'),
+ RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'),
+ RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'),
+ RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'),
REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'),
REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'),
REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'),
REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'),
REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'),
+ RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'),
ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'),
RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'),
LOG_OUT: type('dspace/auth/LOG_OUT'),
@@ -95,7 +101,7 @@ export class AuthenticatedErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -109,7 +115,7 @@ export class AuthenticationErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -138,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action {
/**
* Check Authentication Token Error.
- * @class CheckAuthenticationTokenErrorAction
+ * @class CheckAuthenticationTokenCookieAction
* @implements {Action}
*/
-export class CheckAuthenticationTokenErrorAction implements Action {
- public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR;
+export class CheckAuthenticationTokenCookieAction implements Action {
+ public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE;
}
/**
@@ -152,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action {
*/
export class LogOutAction implements Action {
public type: string = AuthActionTypes.LOG_OUT;
- constructor(public payload?: any) {}
+
+ constructor(public payload?: any) {
+ }
}
/**
@@ -165,7 +173,7 @@ export class LogOutErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -176,7 +184,9 @@ export class LogOutErrorAction implements Action {
*/
export class LogOutSuccessAction implements Action {
public type: string = AuthActionTypes.LOG_OUT_SUCCESS;
- constructor(public payload?: any) {}
+
+ constructor(public payload?: any) {
+ }
}
/**
@@ -189,7 +199,7 @@ export class RedirectWhenAuthenticationIsRequiredAction implements Action {
payload: string;
constructor(message: string) {
- this.payload = message ;
+ this.payload = message;
}
}
@@ -203,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action {
payload: string;
constructor(message: string) {
- this.payload = message ;
+ this.payload = message;
}
}
@@ -244,6 +254,15 @@ export class RefreshTokenErrorAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR;
}
+/**
+ * Retrieve authentication token.
+ * @class RetrieveTokenAction
+ * @implements {Action}
+ */
+export class RetrieveTokenAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_TOKEN;
+}
+
/**
* Sign up.
* @class RegistrationAction
@@ -268,7 +287,7 @@ export class RegistrationErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -309,6 +328,45 @@ export class ResetAuthenticationMessagesAction implements Action {
public type: string = AuthActionTypes.RESET_MESSAGES;
}
+// // Next three Actions are used by dynamic login methods
+/**
+ * Action that triggers an effect fetching the authentication methods enabled ant the backend
+ * @class RetrieveAuthMethodsAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
+
+ payload: AuthStatus;
+
+ constructor(authStatus: AuthStatus) {
+ this.payload = authStatus;
+ }
+}
+
+/**
+ * Get Authentication methods enabled at the backend
+ * @class RetrieveAuthMethodsSuccessAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsSuccessAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
+ payload: AuthMethod[];
+
+ constructor(authMethods: AuthMethod[] ) {
+ this.payload = authMethods;
+ }
+}
+
+/**
+ * Set password as default authentication method on error
+ * @class RetrieveAuthMethodsErrorAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsErrorAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
+}
+
/**
* Change the redirect url.
* @class SetRedirectUrlAction
@@ -319,7 +377,7 @@ export class SetRedirectUrlAction implements Action {
payload: string;
constructor(url: string) {
- this.payload = url ;
+ this.payload = url;
}
}
@@ -378,13 +436,21 @@ export type AuthActions
| AuthenticationErrorAction
| AuthenticationSuccessAction
| CheckAuthenticationTokenAction
- | CheckAuthenticationTokenErrorAction
+ | CheckAuthenticationTokenCookieAction
| RedirectWhenAuthenticationIsRequiredAction
| RedirectWhenTokenExpiredAction
| RegistrationAction
| RegistrationErrorAction
| RegistrationSuccessAction
| AddAuthenticationMessageAction
+ | RefreshTokenAction
+ | RefreshTokenErrorAction
+ | RefreshTokenSuccessAction
+ | ResetAuthenticationMessagesAction
+ | RetrieveAuthMethodsAction
+ | RetrieveAuthMethodsSuccessAction
+ | RetrieveAuthMethodsErrorAction
+ | RetrieveTokenAction
| ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction
diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts
index 34b900fe7e..1f6fa51afd 100644
--- a/src/app/core/auth/auth.effects.spec.ts
+++ b/src/app/core/auth/auth.effects.spec.ts
@@ -14,19 +14,24 @@ import {
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
- RetrieveAuthenticatedEpersonSuccessAction
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
+ RetrieveTokenAction
} from './auth.actions';
-import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
+import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { AuthService } from './auth.service';
import { AuthState } from './auth.reducer';
import { EPersonMock } from '../../shared/testing/eperson-mock';
+import { AuthStatus } from './models/auth-status.model';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
@@ -168,13 +173,56 @@ describe('AuthEffects', () => {
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } });
- const expected = cold('--b-', { b: new CheckAuthenticationTokenErrorAction() });
+ const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() });
expect(authEffects.checkToken$).toBeObservable(expected);
});
})
});
+ describe('checkTokenCookie$', () => {
+
+ describe('when check token succeeded', () => {
+ it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ {
+ authenticated: true
+ })
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+
+ const expected = cold('--b-', { b: new RetrieveTokenAction() });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+
+ it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ { authenticated: false })
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+ });
+
+ describe('when check token failed', () => {
+ it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test')));
+
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } });
+
+ const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+ })
+ });
+
describe('retrieveAuthenticatedEperson$', () => {
describe('when request is successful', () => {
@@ -231,6 +279,38 @@ describe('AuthEffects', () => {
})
});
+ describe('retrieveToken$', () => {
+ describe('when user is authenticated', () => {
+ it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => {
+ actions = hot('--a-', {
+ a: {
+ type: AuthActionTypes.RETRIEVE_TOKEN
+ }
+ });
+
+ const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) });
+
+ expect(authEffects.retrieveToken$).toBeObservable(expected);
+ });
+ });
+
+ describe('when user is not authenticated', () => {
+ it('should return a AUTHENTICATE_ERROR action in response to a RETRIEVE_TOKEN action', () => {
+ spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(new Error('Message Error test')));
+
+ actions = hot('--a-', {
+ a: {
+ type: AuthActionTypes.RETRIEVE_TOKEN
+ }
+ });
+
+ const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) });
+
+ expect(authEffects.retrieveToken$).toBeObservable(expected);
+ });
+ });
+ });
+
describe('logOut$', () => {
describe('when refresh token succeeded', () => {
@@ -256,4 +336,29 @@ describe('AuthEffects', () => {
});
})
});
+
+ describe('retrieveMethods$', () => {
+
+ describe('when retrieve authentication methods succeeded', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ });
+
+ describe('when retrieve authentication methods failed', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
+
+ actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ })
+ });
});
diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts
index 5ee63ccd92..d153748fb9 100644
--- a/src/app/core/auth/auth.effects.ts
+++ b/src/app/core/auth/auth.effects.ts
@@ -1,6 +1,6 @@
-import { of as observableOf, Observable } from 'rxjs';
+import { Observable, of as observableOf } from 'rxjs';
-import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators';
+import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
// import @ngrx
@@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store';
// import services
import { AuthService } from './auth.service';
+
+import { EPerson } from '../eperson/models/eperson.model';
+import { AuthStatus } from './models/auth-status.model';
+import { AuthTokenInfo } from './models/auth-token-info.model';
+import { AppState } from '../../app.reducer';
+import { isAuthenticated } from './selectors';
+import { StoreActionTypes } from '../../store.actions';
+import { AuthMethod } from './models/auth.method';
// import actions
import {
AuthActionTypes,
@@ -18,7 +26,7 @@ import {
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenAction,
@@ -29,14 +37,12 @@ import {
RegistrationSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
- RetrieveAuthenticatedEpersonSuccessAction
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
+ RetrieveTokenAction
} from './auth.actions';
-import { EPerson } from '../eperson/models/eperson.model';
-import { AuthStatus } from './models/auth-status.model';
-import { AuthTokenInfo } from './models/auth-token-info.model';
-import { AppState } from '../../app.reducer';
-import { isAuthenticated } from './selectors';
-import { StoreActionTypes } from '../../store.actions';
@Injectable()
export class AuthEffects {
@@ -47,45 +53,45 @@ export class AuthEffects {
*/
@Effect()
public authenticate$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATE),
- switchMap((action: AuthenticateAction) => {
- return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
- take(1),
- map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
- catchError((error) => observableOf(new AuthenticationErrorAction(error)))
- );
- })
- );
+ ofType(AuthActionTypes.AUTHENTICATE),
+ switchMap((action: AuthenticateAction) => {
+ return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
+ take(1),
+ map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
+ catchError((error) => observableOf(new AuthenticationErrorAction(error)))
+ );
+ })
+ );
@Effect()
public authenticateSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
- tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
- map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
- );
+ ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
+ tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
+ map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
+ );
@Effect()
public authenticated$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED),
- switchMap((action: AuthenticatedAction) => {
- return this.authService.authenticatedUser(action.payload).pipe(
- map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)),
- catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
- })
- );
+ ofType(AuthActionTypes.AUTHENTICATED),
+ switchMap((action: AuthenticatedAction) => {
+ return this.authService.authenticatedUser(action.payload).pipe(
+ map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)),
+ catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
+ })
+ );
@Effect()
public authenticatedSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
- map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
- );
+ ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
+ map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
+ );
// It means "reacts to this action but don't send another"
@Effect({ dispatch: false })
public authenticatedError$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED_ERROR),
- tap((action: LogOutSuccessAction) => this.authService.removeToken())
- );
+ ofType(AuthActionTypes.AUTHENTICATED_ERROR),
+ tap((action: LogOutSuccessAction) => this.authService.removeToken())
+ );
@Effect()
public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe(
@@ -99,42 +105,71 @@ export class AuthEffects {
@Effect()
public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN),
- switchMap(() => {
- return this.authService.hasValidAuthenticationToken().pipe(
- map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
- catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction()))
- );
- })
- );
+ switchMap(() => {
+ return this.authService.hasValidAuthenticationToken().pipe(
+ map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
+ catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction()))
+ );
+ })
+ );
+
+ @Effect()
+ public checkTokenCookie$: Observable = this.actions$.pipe(
+ ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE),
+ switchMap(() => {
+ return this.authService.checkAuthenticationCookie().pipe(
+ map((response: AuthStatus) => {
+ if (response.authenticated) {
+ return new RetrieveTokenAction();
+ } else {
+ return new RetrieveAuthMethodsAction(response);
+ }
+ }),
+ catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
+ );
+ })
+ );
@Effect()
public createUser$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.REGISTRATION),
- debounceTime(500), // to remove when functionality is implemented
- switchMap((action: RegistrationAction) => {
- return this.authService.create(action.payload).pipe(
- map((user: EPerson) => new RegistrationSuccessAction(user)),
- catchError((error) => observableOf(new RegistrationErrorAction(error)))
- );
- })
- );
+ ofType(AuthActionTypes.REGISTRATION),
+ debounceTime(500), // to remove when functionality is implemented
+ switchMap((action: RegistrationAction) => {
+ return this.authService.create(action.payload).pipe(
+ map((user: EPerson) => new RegistrationSuccessAction(user)),
+ catchError((error) => observableOf(new RegistrationErrorAction(error)))
+ );
+ })
+ );
+
+ @Effect()
+ public retrieveToken$: Observable = this.actions$.pipe(
+ ofType(AuthActionTypes.RETRIEVE_TOKEN),
+ switchMap((action: AuthenticateAction) => {
+ return this.authService.refreshAuthenticationToken(null).pipe(
+ take(1),
+ map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)),
+ catchError((error) => observableOf(new AuthenticationErrorAction(error)))
+ );
+ })
+ );
@Effect()
public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN),
- switchMap((action: RefreshTokenAction) => {
- return this.authService.refreshAuthenticationToken(action.payload).pipe(
- map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
- catchError((error) => observableOf(new RefreshTokenErrorAction()))
- );
- })
- );
+ switchMap((action: RefreshTokenAction) => {
+ return this.authService.refreshAuthenticationToken(action.payload).pipe(
+ map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
+ catchError((error) => observableOf(new RefreshTokenErrorAction()))
+ );
+ })
+ );
// It means "reacts to this action but don't send another"
@Effect({ dispatch: false })
public refreshTokenSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
- tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
- );
+ ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
+ tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
+ );
/**
* When the store is rehydrated in the browser,
@@ -188,6 +223,19 @@ export class AuthEffects {
tap(() => this.authService.redirectToLoginWhenTokenExpired())
);
+ @Effect()
+ public retrieveMethods$: Observable = this.actions$
+ .pipe(
+ ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
+ switchMap((action: RetrieveAuthMethodsAction) => {
+ return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload)
+ .pipe(
+ map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)),
+ catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction()))
+ )
+ })
+ );
+
/**
* @constructor
* @param {Actions} actions$
diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts
index 08e892bbd9..6d609a4ea3 100644
--- a/src/app/core/auth/auth.interceptor.ts
+++ b/src/app/core/auth/auth.interceptor.ts
@@ -6,6 +6,7 @@ import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
+ HttpHeaders,
HttpInterceptor,
HttpRequest,
HttpResponse,
@@ -17,10 +18,12 @@ import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
-import { isNotEmpty, isUndefined, isNotNull } from '../../shared/empty.util';
+import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
@@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor {
// we're creating a refresh token request list
protected refreshTokenRequestUrls = [];
- constructor(private inj: Injector, private router: Router, private store: Store) { }
+ constructor(private inj: Injector, private router: Router, private store: Store) {
+ }
+ /**
+ * Check if response status code is 401
+ *
+ * @param response
+ */
private isUnauthorized(response: HttpResponseBase): boolean {
// invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons
return response.status === 401;
}
+ /**
+ * Check if response status code is 200 or 204
+ *
+ * @param response
+ */
private isSuccess(response: HttpResponseBase): boolean {
return (response.status === 200 || response.status === 204);
}
+ /**
+ * Check if http request is to authn endpoint
+ *
+ * @param http
+ */
private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean {
return http && http.url
&& (http.url.endsWith('/authn/login')
@@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor {
|| http.url.endsWith('/authn/status'));
}
+ /**
+ * Check if response is from a login request
+ *
+ * @param http
+ */
private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean {
- return http.url && http.url.endsWith('/authn/login');
+ return http.url && http.url.endsWith('/authn/login')
}
+ /**
+ * Check if response is from a logout request
+ *
+ * @param http
+ */
private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/logout');
}
- private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string): AuthStatus {
+ /**
+ * Check if response is from a status request
+ *
+ * @param http
+ */
+ private isStatusResponse(http: HttpRequest | HttpResponseBase): boolean {
+ return http.url && http.url.endsWith('/authn/status');
+ }
+
+ /**
+ * Extract location url from the WWW-Authenticate header
+ *
+ * @param header
+ */
+ private parseLocation(header: string): string {
+ let location = header.trim();
+ location = location.replace('location="', '');
+ location = location.replace('"', '');
+ let re = /%3A%2F%2F/g;
+ location = location.replace(re, '://');
+ re = /%3A/g;
+ location = location.replace(re, ':');
+ return location.trim();
+ }
+
+ /**
+ * Sort authentication methods list
+ *
+ * @param authMethodModels
+ */
+ private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] {
+ const sortedAuthMethodModels: AuthMethod[] = [];
+ authMethodModels.forEach((method) => {
+ if (method.authMethodType === AuthMethodType.Password) {
+ sortedAuthMethodModels.push(method);
+ }
+ });
+
+ authMethodModels.forEach((method) => {
+ if (method.authMethodType !== AuthMethodType.Password) {
+ sortedAuthMethodModels.push(method);
+ }
+ });
+
+ return sortedAuthMethodModels;
+ }
+
+ /**
+ * Extract authentication methods list from the WWW-Authenticate headers
+ *
+ * @param headers
+ */
+ private parseAuthMethodsFromHeaders(headers: HttpHeaders): AuthMethod[] {
+ let authMethodModels: AuthMethod[] = [];
+ if (isNotEmpty(headers.get('www-authenticate'))) {
+ // get the realms from the header - a realm is a single auth method
+ const completeWWWauthenticateHeader = headers.get('www-authenticate');
+ const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g;
+ const realms = completeWWWauthenticateHeader.match(regex);
+
+ // tslint:disable-next-line:forin
+ for (const j in realms) {
+
+ const splittedRealm = realms[j].split(', ');
+ const methodName = splittedRealm[0].split(' ')[0].trim();
+
+ let authMethodModel: AuthMethod;
+ if (splittedRealm.length === 1) {
+ authMethodModel = new AuthMethod(methodName);
+ authMethodModels.push(authMethodModel);
+ } else if (splittedRealm.length > 1) {
+ let location = splittedRealm[1];
+ location = this.parseLocation(location);
+ authMethodModel = new AuthMethod(methodName, location);
+ authMethodModels.push(authMethodModel);
+ }
+ }
+
+ // make sure the email + password login component gets rendered first
+ authMethodModels = this.sortAuthMethods(authMethodModels);
+ } else {
+ authMethodModels.push(new AuthMethod(AuthMethodType.Password));
+ }
+
+ return authMethodModels;
+ }
+
+ /**
+ * Generate an AuthStatus object
+ *
+ * @param authenticated
+ * @param accessToken
+ * @param error
+ * @param httpHeaders
+ */
+ private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string, httpHeaders?: HttpHeaders): AuthStatus {
const authStatus = new AuthStatus();
+ // let authMethods: AuthMethodModel[];
+ if (httpHeaders) {
+ authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders);
+ }
+
authStatus.id = null;
+
authStatus.okay = true;
+ // authStatus.authMethods = authMethods;
+
if (authenticated) {
authStatus.authenticated = true;
authStatus.token = new AuthTokenInfo(accessToken);
@@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor {
return authStatus;
}
+ /**
+ * Intercept method
+ * @param req
+ * @param next
+ */
intercept(req: HttpRequest, next: HttpHandler): Observable> {
const authService = this.inj.get(AuthService);
- const token = authService.getToken();
- let newReq;
+ const token: AuthTokenInfo = authService.getToken();
+ let newReq: HttpRequest;
+ let authorization: string;
if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url);
@@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor {
}
});
// Get the auth header from the service.
- const Authorization = authService.buildAuthHeader(token);
+ authorization = authService.buildAuthHeader(token);
// Clone the request to add the new header.
- newReq = req.clone({headers: req.headers.set('authorization', Authorization)});
+ newReq = req.clone({ headers: req.headers.set('authorization', authorization) });
} else {
- newReq = req;
+ newReq = req.clone();
}
// Pass on the new request instead of the original request.
return next.handle(newReq).pipe(
+ // tap((response) => console.log('next.handle: ', response)),
map((response) => {
// Intercept a Login/Logout response
- if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) {
+ if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {
// It's a success Login/Logout response
let authRes: HttpResponse;
if (this.isLoginResponse(response)) {
// login successfully
const newToken = response.headers.get('authorization');
- authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)});
+ authRes = response.clone({
+ body: this.makeAuthStatusObject(true, newToken)
+ });
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
+ } else if (this.isStatusResponse(response)) {
+ authRes = response.clone({
+ body: Object.assign(response.body, {
+ authMethods: this.parseAuthMethodsFromHeaders(response.headers)
+ })
+ })
} else {
// logout successfully
- authRes = response.clone({body: this.makeAuthStatusObject(false)});
+ authRes = response.clone({
+ body: this.makeAuthStatusObject(false)
+ });
}
return authRes;
} else {
@@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((error, caught) => {
// Intercept an error response
if (error instanceof HttpErrorResponse) {
+
// Checks if is a response from a request to an authentication endpoint
if (this.isAuthRequest(error)) {
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
+
// Create a new HttpResponse and return it, so it can be handle properly by AuthService.
const authResponse = new HttpResponse({
- body: this.makeAuthStatusObject(false, null, error.error),
+ body: this.makeAuthStatusObject(false, null, error.error, error.headers),
headers: error.headers,
status: error.status,
statusText: error.statusText,
diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts
index f299696007..7a39ef3da4 100644
--- a/src/app/core/auth/auth.reducer.spec.ts
+++ b/src/app/core/auth/auth.reducer.spec.ts
@@ -8,7 +8,7 @@ import {
AuthenticationErrorAction,
AuthenticationSuccessAction,
CheckAuthenticationTokenAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutAction,
LogOutErrorAction,
LogOutSuccessAction,
@@ -17,11 +17,19 @@ import {
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
- ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction,
+ ResetAuthenticationMessagesAction,
+ RetrieveAuthenticatedEpersonErrorAction,
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { EPersonMock } from '../../shared/testing/eperson-mock';
+import { AuthStatus } from './models/auth-status.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
describe('authReducer', () => {
@@ -157,18 +165,18 @@ describe('authReducer', () => {
expect(newState).toEqual(state);
});
- it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => {
+ it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: true,
};
- const action = new CheckAuthenticationTokenErrorAction();
+ const action = new CheckAuthenticationTokenCookieAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
- loading: false,
+ loading: true,
};
expect(newState).toEqual(state);
});
@@ -451,4 +459,63 @@ describe('authReducer', () => {
};
expect(newState).toEqual(state);
});
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: []
+ };
+ const action = new RetrieveAuthMethodsAction(new AuthStatus());
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+ expect(newState).toEqual(state);
+ });
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+ const authMethods = [
+ new AuthMethod(AuthMethodType.Password),
+ new AuthMethod(AuthMethodType.Shibboleth, 'location')
+ ];
+ const action = new RetrieveAuthMethodsSuccessAction(authMethods);
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: authMethods
+ };
+ expect(newState).toEqual(state);
+ });
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+
+ const action = new RetrieveAuthMethodsErrorAction();
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: [new AuthMethod(AuthMethodType.Password)]
+ };
+ expect(newState).toEqual(state);
+ });
});
diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts
index 7d5e50c432..19fd162d3f 100644
--- a/src/app/core/auth/auth.reducer.ts
+++ b/src/app/core/auth/auth.reducer.ts
@@ -8,12 +8,16 @@ import {
LogOutErrorAction,
RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction,
- RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction,
+ RefreshTokenSuccessAction,
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
// import models
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
/**
* The auth state.
@@ -47,6 +51,10 @@ export interface AuthState {
// the authenticated user
user?: EPerson;
+
+ // all authentication Methods enabled at the backend
+ authMethods?: AuthMethod[];
+
}
/**
@@ -56,6 +64,7 @@ const initialState: AuthState = {
authenticated: false,
loaded: false,
loading: false,
+ authMethods: []
};
/**
@@ -75,6 +84,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
});
case AuthActionTypes.AUTHENTICATED:
+ case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
+ case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, {
loading: true
});
@@ -113,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loading: false
});
- case AuthActionTypes.AUTHENTICATED:
case AuthActionTypes.AUTHENTICATE_SUCCESS:
case AuthActionTypes.LOG_OUT:
return state;
- case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
- return Object.assign({}, state, {
- loading: true
- });
-
- case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR:
- return Object.assign({}, state, {
- loading: false
- });
-
case AuthActionTypes.LOG_OUT_ERROR:
return Object.assign({}, state, {
authenticated: true,
@@ -192,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
info: undefined,
});
+ // next three cases are used by dynamic rendering of login methods
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS:
+ return Object.assign({}, state, {
+ loading: true
+ });
+
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
+ return Object.assign({}, state, {
+ loading: false,
+ authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
+ });
+
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
+ return Object.assign({}, state, {
+ loading: false,
+ authMethods: [new AuthMethod(AuthMethodType.Password)]
+ });
+
case AuthActionTypes.SET_REDIRECT_URL:
return Object.assign({}, state, {
redirectUrl: (action as SetRedirectUrlAction).payload,
diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts
index 31649abe32..03759987bf 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -27,6 +27,8 @@ import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { EPersonDataService } from '../eperson/eperson-data.service';
+import { authMethodsMock } from '../../shared/testing/auth-service-stub';
+import { AuthMethod } from './models/auth.method';
describe('AuthService test', () => {
@@ -144,6 +146,26 @@ describe('AuthService test', () => {
expect(authService.logout.bind(null)).toThrow();
});
+ it('should return the authentication status object to check an Authentication Cookie', () => {
+ authService.checkAuthenticationCookie().subscribe((status: AuthStatus) => {
+ expect(status).toBeDefined();
+ });
+ });
+
+ it('should return the authentication methods available', () => {
+ const authStatus = new AuthStatus();
+
+ authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => {
+ expect(authMethods).toBeDefined();
+ expect(authMethods.length).toBe(0);
+ });
+
+ authStatus.authMethods = authMethodsMock;
+ authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => {
+ expect(authMethods).toBeDefined();
+ expect(authMethods.length).toBe(2);
+ });
+ });
});
describe('', () => {
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index f8847b0b2e..0f5c06bbc9 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -18,16 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp
import { CookieService } from '../services/cookie.service';
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer';
-import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
+import {
+ CheckAuthenticationTokenAction,
+ ResetAuthenticationMessagesAction,
+ SetRedirectUrlAction
+} from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
-import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators';
+import { getAllSucceededRemoteDataPayload } from '../shared/operators';
+import { AuthMethod } from './models/auth.method';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
-
export const REDIRECT_COOKIE = 'dsRedirectUrl';
/**
@@ -114,6 +118,21 @@ export class AuthService {
}
+ /**
+ * Checks if token is present into the request cookie
+ */
+ public checkAuthenticationCookie(): Observable {
+ // Determine if the user has an existing auth session on the server
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Accept', 'application/json');
+ options.headers = headers;
+ options.withCredentials = true;
+ return this.authRequestService.getRequest('status', options).pipe(
+ map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
+ );
+ }
+
/**
* Determines if the user is authenticated
* @returns {Observable}
@@ -154,10 +173,10 @@ export class AuthService {
}
/**
- * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
+ * Checks if token is present into browser storage and is valid.
*/
public checkAuthenticationToken() {
- return
+ this.store.dispatch(new CheckAuthenticationTokenAction());
}
/**
@@ -187,8 +206,11 @@ export class AuthService {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
- headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
+ if (token && token.accessToken) {
+ headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
+ }
options.headers = headers;
+ options.withCredentials = true;
return this.authRequestService.postToEndpoint('login', {}, options).pipe(
map((status: AuthStatus) => {
if (status.authenticated) {
@@ -206,6 +228,18 @@ export class AuthService {
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
+ /**
+ * Retrieve authentication methods available
+ * @returns {User}
+ */
+ public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable {
+ let authMethods: AuthMethod[] = [];
+ if (isNotEmpty(status.authMethods)) {
+ authMethods = status.authMethods;
+ }
+ return observableOf(authMethods);
+ }
+
/**
* Create a new user
* @returns {User}
diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts
index af0622cd19..7a2f39854c 100644
--- a/src/app/core/auth/authenticated.guard.ts
+++ b/src/app/core/auth/authenticated.guard.ts
@@ -1,17 +1,14 @@
-
-import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
-import {Observable, of} from 'rxjs';
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
-// reducers
import { CoreState } from '../core.reducers';
-import { isAuthenticated, isAuthenticationLoading } from './selectors';
+import { isAuthenticated } from './selectors';
import { AuthService } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
-import { isEmpty } from '../../shared/empty.util';
/**
* Prevent unauthorized activating and loading of routes
diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts
index edad46a7bc..197c025407 100644
--- a/src/app/core/auth/models/auth-status.model.ts
+++ b/src/app/core/auth/models/auth-status.model.ts
@@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators';
import { AuthError } from './auth-error.model';
import { AUTH_STATUS } from './auth-status.resource-type';
import { AuthTokenInfo } from './auth-token-info.model';
+import { AuthMethod } from './auth.method';
/**
* Object that represents the authenticated status of a user
@@ -79,5 +80,13 @@ export class AuthStatus implements CacheableObject {
* Authentication error if there was one for this status
*/
// TODO should be refactored to use the RemoteData error
+ @autoserialize
error?: AuthError;
+
+ /**
+ * All authentication methods enabled at the backend
+ */
+ @autoserialize
+ authMethods: AuthMethod[];
+
}
diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts
new file mode 100644
index 0000000000..f053515065
--- /dev/null
+++ b/src/app/core/auth/models/auth.method-type.ts
@@ -0,0 +1,7 @@
+export enum AuthMethodType {
+ Password = 'password',
+ Shibboleth = 'shibboleth',
+ Ldap = 'ldap',
+ Ip = 'ip',
+ X509 = 'x509'
+}
diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts
new file mode 100644
index 0000000000..617154080b
--- /dev/null
+++ b/src/app/core/auth/models/auth.method.ts
@@ -0,0 +1,38 @@
+import { AuthMethodType } from './auth.method-type';
+
+export class AuthMethod {
+ authMethodType: AuthMethodType;
+ location?: string;
+
+ // isStandalonePage? = true;
+
+ constructor(authMethodName: string, location?: string) {
+ switch (authMethodName) {
+ case 'ip': {
+ this.authMethodType = AuthMethodType.Ip;
+ break;
+ }
+ case 'ldap': {
+ this.authMethodType = AuthMethodType.Ldap;
+ break;
+ }
+ case 'shibboleth': {
+ this.authMethodType = AuthMethodType.Shibboleth;
+ this.location = location;
+ break;
+ }
+ case 'x509': {
+ this.authMethodType = AuthMethodType.X509;
+ break;
+ }
+ case 'password': {
+ this.authMethodType = AuthMethodType.Password;
+ break;
+ }
+
+ default: {
+ break;
+ }
+ }
+ }
+}
diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts
index 8c88e0fce5..4e51bc1fc9 100644
--- a/src/app/core/auth/selectors.ts
+++ b/src/app/core/auth/selectors.ts
@@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error;
*/
const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
+const _getAuthenticationMethods = (state: AuthState) => state.authMethods;
+
+/**
+ * Returns the authentication methods enabled at the backend
+ * @function getAuthenticationMethods
+ * @param {AuthState} state
+ * @param {any} props
+ * @return {any}
+ */
+export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods);
+
/**
* Returns the authenticated user
* @function getAuthenticatedUser
diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts
index c8cba0206b..30767be85a 100644
--- a/src/app/core/auth/server-auth.service.ts
+++ b/src/app/core/auth/server-auth.service.ts
@@ -1,11 +1,11 @@
-import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
+import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
+
import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
-import { CheckAuthenticationTokenAction } from './auth.actions';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
@@ -43,10 +43,23 @@ export class ServerAuthService extends AuthService {
}
/**
- * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
+ * Checks if token is present into the request cookie
*/
- public checkAuthenticationToken() {
- this.store.dispatch(new CheckAuthenticationTokenAction())
+ public checkAuthenticationCookie(): Observable {
+ // Determine if the user has an existing auth session on the server
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Accept', 'application/json');
+ if (isNotEmpty(this.req.protocol) && isNotEmpty(this.req.header('host'))) {
+ const referer = this.req.protocol + '://' + this.req.header('host') + this.req.path;
+ // use to allow the rest server to identify the real origin on SSR
+ headers = headers.append('X-Requested-With', referer);
+ }
+ options.headers = headers;
+ options.withCredentials = true;
+ return this.authRequestService.getRequest('status', options).pipe(
+ map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
+ );
}
/**
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 3a544fdf80..783b169291 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -1,24 +1,19 @@
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
-import {
- DynamicFormLayoutService,
- DynamicFormService,
- DynamicFormValidationService
-} from '@ng-dynamic-forms/core';
-import { EffectsModule } from '@ngrx/effects';
+import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
+import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
+
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config';
-
import { isNotEmpty } from '../shared/empty.util';
import { FormBuilderService } from '../shared/form/builder/form-builder.service';
import { FormService } from '../shared/form/form.service';
import { HostWindowService } from '../shared/host-window.service';
import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
-
import {
MOCK_RESPONSE_MAP,
MockResponseMap,
@@ -48,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
-
import { coreEffects } from './core.effects';
import { coreReducers } from './core.reducers';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service';
@@ -103,7 +97,6 @@ import { RegistryService } from './registry/registry.service';
import { RoleService } from './roles/role.service';
import { ApiService } from './services/api.service';
-import { RouteService } from './services/route.service';
import { ServerResponseService } from './services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from './services/window.service';
import { BitstreamFormat } from './shared/bitstream-format.model';
@@ -142,6 +135,8 @@ 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';
@@ -181,7 +176,11 @@ const PROVIDERS = [
SiteDataService,
DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
- { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]},
+ {
+ provide: DSpaceRESTv2Service,
+ useFactory: restServiceFactory,
+ deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]
+ },
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
@@ -217,7 +216,6 @@ const PROVIDERS = [
BrowseItemsResponseParsingService,
BrowseService,
ConfigResponseParsingService,
- RouteService,
SubmissionDefinitionsConfigService,
SubmissionFormsConfigService,
SubmissionRestService,
@@ -239,6 +237,7 @@ const PROVIDERS = [
DSpaceObjectDataService,
DSOChangeAnalyzer,
DefaultChangeAnalyzer,
+ ArrayMoveChangeAnalyzer,
ObjectSelectService,
CSSVariableService,
MenuService,
@@ -250,6 +249,7 @@ const PROVIDERS = [
TaskResponseParsingService,
ClaimedTaskDataService,
PoolTaskDataService,
+ BitstreamDataService,
EntityTypeService,
ContentSourceResponseParsingService,
SearchService,
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 135834b430..7cbfb2ad03 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';
@@ -475,6 +476,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(
@@ -495,10 +529,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/request.models.ts b/src/app/core/data/request.models.ts
index e17ffcac3f..0655333502 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -230,6 +230,8 @@ export class AuthPostRequest extends PostRequest {
}
export class AuthGetRequest extends GetRequest {
+ forceBypassCache = true;
+
constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options);
}
diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
index 91756d412c..6eb144580c 100644
--- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
+++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
@@ -90,6 +90,14 @@ export class DSpaceRESTv2Service {
requestOptions.headers = options.headers;
}
+ if (options && options.params) {
+ requestOptions.params = options.params;
+ }
+
+ if (options && options.withCredentials) {
+ requestOptions.withCredentials = options.withCredentials;
+ }
+
if (!requestOptions.headers.has('Content-Type')) {
// Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers
requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE);
diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts
index 441d058c4c..59ec899576 100644
--- a/src/app/core/services/route.service.ts
+++ b/src/app/core/services/route.service.ts
@@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState
/**
* Service to keep track of the current query parameters
*/
-@Injectable()
+@Injectable({
+ providedIn: 'root'
+})
export class RouteService {
constructor(private route: ActivatedRoute, private router: Router, private store: Store) {
this.saveRouting();
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 14d101a448..a51e711d26 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';
@@ -207,3 +207,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/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
index 86de30c23e..a05381fee8 100644
--- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
+++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
@@ -1,26 +1,33 @@
-
+
-
{{ 'nav.login' | translate }}
-
- {{ 'nav.login' | translate }}(current)
+ {{ 'nav.login' | translate }}(current)
- (current)
+ (current)
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
index 06f9843c6d..454a036b15 100644
--- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
+++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
@@ -137,7 +137,7 @@ describe('ComColFormComponent', () => {
type: Community.type
},
),
- uploader: {} as any,
+ uploader: undefined,
deleteLogo: false
}
);
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
index 35c6f50969..f8199d2aad 100644
--- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
+++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
@@ -39,7 +39,7 @@ export class ComColFormComponent
implements OnInit, OnDe
/**
* The logo uploader component
*/
- @ViewChild(UploaderComponent, {static: true}) uploaderComponent: UploaderComponent;
+ @ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent;
/**
* DSpaceObject that the form represents
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/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html
new file mode 100644
index 0000000000..bef6f43b66
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.html
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss
new file mode 100644
index 0000000000..0255b71dac
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.scss
@@ -0,0 +1,21 @@
+:host ::ng-deep .card {
+ margin-bottom: $submission-sections-margin-bottom;
+ overflow: unset;
+}
+
+.section-focus {
+ border-radius: $border-radius;
+ box-shadow: $btn-focus-box-shadow;
+}
+
+// TODO to remove the following when upgrading @ng-bootstrap
+:host ::ng-deep .card:first-of-type {
+ border-bottom: $card-border-width solid $card-border-color !important;
+ border-bottom-left-radius: $card-border-radius !important;
+ border-bottom-right-radius: $card-border-radius !important;
+}
+
+:host ::ng-deep .card-header button {
+ box-shadow: none !important;
+ width: 100%;
+}
diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts
new file mode 100644
index 0000000000..c819b0cc8d
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts
@@ -0,0 +1,108 @@
+import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+
+import { StoreModule } from '@ngrx/store';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { LogInContainerComponent } from './log-in-container.component';
+import { authReducer } from '../../../core/auth/auth.reducer';
+import { SharedModule } from '../../shared.module';
+import { createTestComponent } from '../../testing/utils';
+import { AuthService } from '../../../core/auth/auth.service';
+import { AuthMethod } from '../../../core/auth/models/auth.method';
+import { AuthServiceStub } from '../../testing/auth-service-stub';
+
+describe('LogInContainerComponent', () => {
+
+ let component: LogInContainerComponent;
+ let fixture: ComponentFixture;
+
+ const authMethod = new AuthMethod('password');
+
+ beforeEach(async(() => {
+ // refine the test module by declaring the test component
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ StoreModule.forRoot(authReducer),
+ SharedModule,
+ TranslateModule.forRoot()
+ ],
+ declarations: [
+ TestComponent
+ ],
+ providers: [
+ {provide: AuthService, useClass: AuthServiceStub},
+ LogInContainerComponent
+ ],
+ schemas: [
+ CUSTOM_ELEMENTS_SCHEMA
+ ]
+ })
+ .compileComponents();
+
+ }));
+
+ describe('', () => {
+ let testComp: TestComponent;
+ let testFixture: ComponentFixture;
+
+ // synchronous beforeEach
+ beforeEach(() => {
+ const html = ` `;
+
+ testFixture = createTestComponent(html, TestComponent) as ComponentFixture;
+ testComp = testFixture.componentInstance;
+ });
+
+ afterEach(() => {
+ testFixture.destroy();
+ });
+
+ it('should create LogInContainerComponent', inject([LogInContainerComponent], (app: LogInContainerComponent) => {
+
+ expect(app).toBeDefined();
+
+ }));
+ });
+
+ describe('', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LogInContainerComponent);
+ component = fixture.componentInstance;
+
+ spyOn(component, 'getAuthMethodContent').and.callThrough();
+ component.authMethod = authMethod;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ component = null;
+ });
+
+ it('should inject component properly', () => {
+
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.getAuthMethodContent).toHaveBeenCalled();
+
+ });
+
+ });
+
+});
+
+// declare a test component
+@Component({
+ selector: 'ds-test-cmp',
+ template: ``
+})
+class TestComponent {
+
+ isStandalonePage = true;
+
+}
diff --git a/src/app/shared/log-in/container/log-in-container.component.ts b/src/app/shared/log-in/container/log-in-container.component.ts
new file mode 100644
index 0000000000..660e616b9d
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.ts
@@ -0,0 +1,51 @@
+import { Component, Injector, Input, OnInit } from '@angular/core';
+
+import { rendersAuthMethodType } from '../methods/log-in.methods-decorator';
+import { AuthMethod } from '../../../core/auth/models/auth.method';
+
+/**
+ * This component represents a component container for log-in methods available.
+ */
+@Component({
+ selector: 'ds-log-in-container',
+ templateUrl: './log-in-container.component.html',
+ styleUrls: ['./log-in-container.component.scss']
+})
+export class LogInContainerComponent implements OnInit {
+
+ @Input() authMethod: AuthMethod;
+
+ /**
+ * Injector to inject a section component with the @Input parameters
+ * @type {Injector}
+ */
+ public objectInjector: Injector;
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {Injector} injector
+ */
+ constructor(private injector: Injector) {
+ }
+
+ /**
+ * Initialize all instance variables
+ */
+ ngOnInit() {
+ this.objectInjector = Injector.create({
+ providers: [
+ { provide: 'authMethodProvider', useFactory: () => (this.authMethod), deps: [] },
+ ],
+ parent: this.injector
+ });
+ }
+
+ /**
+ * Find the correct component based on the AuthMethod's type
+ */
+ getAuthMethodContent(): string {
+ return rendersAuthMethodType(this.authMethod.authMethodType)
+ }
+
+}
diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html
index fe9a506e71..8e23f00d9b 100644
--- a/src/app/shared/log-in/log-in.component.html
+++ b/src/app/shared/log-in/log-in.component.html
@@ -1,28 +1,13 @@
-