diff --git a/src/app/core/data/object-updates/object-updates.service.stub.ts b/src/app/core/data/object-updates/object-updates.service.stub.ts new file mode 100644 index 00000000000..c41728a338e --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.stub.ts @@ -0,0 +1,28 @@ +export class ObjectUpdatesServiceStub { + + initialize = jasmine.createSpy('initialize'); + saveFieldUpdate = jasmine.createSpy('saveFieldUpdate'); + getObjectEntry = jasmine.createSpy('getObjectEntry'); + getFieldState = jasmine.createSpy('getFieldState'); + getFieldUpdates = jasmine.createSpy('getFieldUpdates'); + getFieldUpdatesExclusive = jasmine.createSpy('getFieldUpdatesExclusive'); + isValid = jasmine.createSpy('isValid'); + isValidPage = jasmine.createSpy('isValidPage'); + saveAddFieldUpdate = jasmine.createSpy('saveAddFieldUpdate'); + saveRemoveFieldUpdate = jasmine.createSpy('saveRemoveFieldUpdate'); + saveChangeFieldUpdate = jasmine.createSpy('saveChangeFieldUpdate'); + isSelectedVirtualMetadata = jasmine.createSpy('isSelectedVirtualMetadata'); + setSelectedVirtualMetadata = jasmine.createSpy('setSelectedVirtualMetadata'); + setEditableFieldUpdate = jasmine.createSpy('setEditableFieldUpdate'); + setValidFieldUpdate = jasmine.createSpy('setValidFieldUpdate'); + discardFieldUpdates = jasmine.createSpy('discardFieldUpdates'); + discardAllFieldUpdates = jasmine.createSpy('discardAllFieldUpdates'); + reinstateFieldUpdates = jasmine.createSpy('reinstateFieldUpdates'); + removeSingleFieldUpdate = jasmine.createSpy('removeSingleFieldUpdate'); + getUpdateFields = jasmine.createSpy('getUpdateFields'); + hasUpdates = jasmine.createSpy('hasUpdates'); + isReinstatable = jasmine.createSpy('isReinstatable'); + getLastModified = jasmine.createSpy('getLastModified'); + createPatch = jasmine.createSpy('getPatch'); + +} diff --git a/src/app/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index 60aa2828bd8..06e5799adfe 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -15,7 +15,6 @@ import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; -import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component'; import { SearchPageModule } from '../../search-page/search-page.module'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; @@ -26,8 +25,6 @@ import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemEditBitstreamBundleComponent } from './item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component'; import { BundleDataService } from '../../core/data/bundle-data.service'; import { DragDropModule } from '@angular/cdk/drag-drop'; -import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; -import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; @@ -83,14 +80,11 @@ import { ItemRelationshipsComponent, ItemBitstreamsComponent, ItemVersionHistoryComponent, - ItemEditBitstreamComponent, ItemEditBitstreamBundleComponent, - PaginatedDragAndDropBitstreamListComponent, EditRelationshipComponent, EditRelationshipListComponent, ItemCollectionMapperComponent, ItemMoveComponent, - ItemEditBitstreamDragHandleComponent, VirtualMetadataComponent, ItemAuthorizationsComponent, IdentifierDataComponent, diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index 834ad25c7f1..2df1c363b0d 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -1,4 +1,8 @@
+
+ +
+
-
-
-
- - {{'item.edit.bitstreams.headers.name' | translate}} -
-
{{'item.edit.bitstreams.headers.description' | translate}}
-
{{'item.edit.bitstreams.headers.format' | translate}}
-
{{'item.edit.bitstreams.headers.actions' | translate}}
-
- + + [isFirstTable]="isFirst" + aria-describedby="reorder-description">
+ + 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 7de575b7855..7fd1f4b31e7 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 @@ -1,23 +1,4 @@ -.header-row { - color: var(--bs-table-dark-color); - background-color: var(--bs-table-dark-bg); - border-color: var(--bs-table-dark-border-color); -} - -.bundle-row { - color: var(--bs-table-head-color); - background-color: var(--bs-table-head-bg); - border-color: var(--bs-table-border-color); -} - -.row-element { - padding: 12px; - padding: 0.75em; - border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); -} - .drag-handle { - visibility: hidden; &:hover { cursor: move; } @@ -27,10 +8,6 @@ cursor: move; } -:host ::ng-deep .bitstream-row:hover .drag-handle, :host ::ng-deep .bitstream-row-drag-handle:focus .drag-handle { - visibility: visible !important; -} - .cdk-drag-preview { margin-left: 0; box-sizing: border-box; @@ -54,3 +31,25 @@ :host ::ng-deep .larger-tooltip .tooltip-inner { max-width: 500px; } + +.table-border { + border: 1px solid #dee2e6; +} + +:host ::ng-deep .pagination { + padding-top: 0.5rem; +} + +.scrollable-table { + overflow-x: auto; +} + +.disabled-overlay { + opacity: 0.6; +} + +.loading-overlay { + position: fixed; + top: 50%; + left: 50%; +} 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 index 10e18121313..d26f8153168 100644 --- 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 @@ -18,7 +18,6 @@ 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'; import { RouterStub } from '../../../shared/testing/router.stub'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; @@ -26,6 +25,8 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } f import { createPaginatedList } from '../../../shared/testing/utils.test'; import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub'; +import { ItemBitstreamsService } from './item-bitstreams.service'; +import { getItemBitstreamsServiceStub, ItemBitstreamsServiceStub } from './item-bitstreams.service.stub'; let comp: ItemBitstreamsComponent; let fixture: ComponentFixture; @@ -77,6 +78,7 @@ let objectCache: ObjectCacheService; let requestService: RequestService; let searchConfig: SearchConfigurationService; let bundleService: BundleDataService; +let itemBitstreamsService: ItemBitstreamsServiceStub; describe('ItemBitstreamsComponent', () => { beforeEach(waitForAsync(() => { @@ -145,9 +147,11 @@ describe('ItemBitstreamsComponent', () => { url: url }); bundleService = jasmine.createSpyObj('bundleService', { - patch: observableOf(new RestResponse(true, 200, 'OK')) + patch: createSuccessfulRemoteDataObject$({}), }); + itemBitstreamsService = getItemBitstreamsServiceStub(); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective], @@ -162,6 +166,7 @@ describe('ItemBitstreamsComponent', () => { { provide: RequestService, useValue: requestService }, { provide: SearchConfigurationService, useValue: searchConfig }, { provide: BundleDataService, useValue: bundleService }, + { provide: ItemBitstreamsService, useValue: itemBitstreamsService }, ChangeDetectorRef ], schemas: [ NO_ERRORS_SCHEMA @@ -182,56 +187,132 @@ describe('ItemBitstreamsComponent', () => { comp.submit(); }); - it('should call removeMultiple on the bitstreamService for the marked field', () => { - expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]); + it('should call removeMarkedBitstreams on the itemBitstreamsService', () => { + expect(itemBitstreamsService.removeMarkedBitstreams).toHaveBeenCalled(); }); + }); - it('should not call removeMultiple on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]); + describe('discard', () => { + it('should discard ALL field updates', () => { + comp.discard(); + expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled(); }); }); - describe('when dropBitstream is called', () => { - const event = { - fromIndex: 0, - toIndex: 50, - // eslint-disable-next-line no-empty,@typescript-eslint/no-empty-function - finish: () => { - } - }; + describe('reinstate', () => { + it('should reinstate field updates on the bundle', () => { + comp.reinstate(); + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self); + }); + }); - beforeEach(() => { - comp.dropBitstream(bundle, event); + describe('moveUp', () => { + it('should move the selected bitstream up', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveUp(event); + + expect(itemBitstreamsService.moveSelectedBitstreamUp).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveUp(event); + + expect(itemBitstreamsService.moveSelectedBitstreamUp).not.toHaveBeenCalled(); }); }); - describe('when dropBitstream is called', () => { - beforeEach((done) => { - comp.dropBitstream(bundle, { - fromIndex: 0, - toIndex: 50, - finish: () => { - done(); - } - }); + describe('moveDown', () => { + it('should move the selected bitstream down', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveDown(event); + + expect(itemBitstreamsService.moveSelectedBitstreamDown).toHaveBeenCalled(); }); - it('should send out a patch for the move operation', () => { - expect(bundleService.patch).toHaveBeenCalled(); + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveDown(event); + + expect(itemBitstreamsService.moveSelectedBitstreamDown).not.toHaveBeenCalled(); }); }); - describe('discard', () => { - it('should discard ALL field updates', () => { - comp.discard(); - expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled(); + describe('cancelSelection', () => { + it('should cancel the selection', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.cancelSelection(event); + + expect(itemBitstreamsService.cancelSelection).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.cancelSelection(event); + + expect(itemBitstreamsService.cancelSelection).not.toHaveBeenCalled(); }); }); - describe('reinstate', () => { - it('should reinstate field updates on the bundle', () => { - comp.reinstate(); - expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self); + describe('clearSelection', () => { + it('should clear the selection', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + target: document.createElement('BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + target: document.createElement('BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).not.toHaveBeenCalled(); + }); + + it('should not do anything if the event target is not \'BODY\'', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + target: document.createElement('NOT-BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).not.toHaveBeenCalled(); }); }); }); 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 a9aad655e96..fe929c452a1 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,30 +1,28 @@ -import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone, OnDestroy, HostListener } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { Observable, Subscription, zip as observableZip, combineLatest } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { Observable, Subscription, combineLatest } from 'rxjs'; 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 { BitstreamDataService } from '../../../core/data/bitstream-data.service'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { Bundle } from '../../../core/shared/bundle.model'; -import { Bitstream } from '../../../core/shared/bitstream.model'; import { BundleDataService } from '../../../core/data/bundle-data.service'; import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; -import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; import { NoContent } from '../../../core/shared/NoContent.model'; -import { Operation } from 'fast-json-patch'; -import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; -import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; -import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { ItemBitstreamsService } from './item-bitstreams.service'; +import { AlertType } from '../../../shared/alert/alert-type'; @Component({ selector: 'ds-item-bitstreams', @@ -36,33 +34,18 @@ import { FieldChangeType } from '../../../core/data/object-updates/field-change- */ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy { + // Declared for use in template + protected readonly AlertType = AlertType; + /** * 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) - ]); + columnSizes: ResponsiveTableSizes; /** * Are we currently submitting the changes? @@ -76,6 +59,11 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ itemUpdateSubscription: Subscription; + /** + * An observable which emits a boolean which represents whether the service is currently handling a 'move' request + */ + isProcessingMoveRequest: Observable; + constructor( public itemService: ItemDataService, public objectUpdatesService: ObjectUpdatesService, @@ -88,22 +76,83 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme public requestService: RequestService, public cdRef: ChangeDetectorRef, public bundleService: BundleDataService, - public zone: NgZone + public zone: NgZone, + public itemBitstreamsService: ItemBitstreamsService, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, route); + + this.columnSizes = this.itemBitstreamsService.getColumnSizes(); } /** * 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( + const bundlesOptions = this.itemBitstreamsService.getInitialBundlesPaginationOptions(); + this.isProcessingMoveRequest = this.itemBitstreamsService.getPerformingMoveRequest$(); + + this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: bundlesOptions})).pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), map((bundlePage: PaginatedList) => bundlePage.page) ); } + /** + * Handles keyboard events that should move the currently selected bitstream up + */ + @HostListener('document:keydown.arrowUp', ['$event']) + moveUp(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.moveSelectedBitstreamUp(); + } + } + + /** + * Handles keyboard events that should move the currently selected bitstream down + */ + @HostListener('document:keydown.arrowDown', ['$event']) + moveDown(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.moveSelectedBitstreamDown(); + } + } + + /** + * Handles keyboard events that should cancel the currently selected bitstream. + * A cancel means that the selected bitstream is returned to its original position and is no longer selected. + * @param event + */ + @HostListener('document:keyup.escape', ['$event']) + cancelSelection(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.cancelSelection(); + } + } + + /** + * Handles keyboard events that should clear the currently selected bitstream. + * A clear means that the selected bitstream remains in its current position but is no longer selected. + */ + @HostListener('document:keydown.enter', ['$event']) + @HostListener('document:keydown.space', ['$event']) + clearSelection(event: KeyboardEvent) { + // Only when no specific element is in focus do we want to clear the currently selected bitstream + // Otherwise we might clear the selection when a different action was intended, e.g. clicking a button or selecting + // a different bitstream. + if ( + this.itemBitstreamsService.hasSelectedBitstream() && + event.target instanceof Element && + event.target.tagName === 'BODY' + ) { + event.preventDefault(); + this.itemBitstreamsService.clearSelection(); + } + } + /** * Initialize the notification messages prefix */ @@ -119,84 +168,16 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ submit() { this.submitting = true; - const bundlesOnce$ = this.bundles$.pipe(take(1)); - - // 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$: Observable> = removedBitstreams$.pipe( - take(1), - switchMap((removedBitstreams: Bitstream[]) => { - return this.bitstreamService.removeMultiple(removedBitstreams); - }) - ); + const removedResponses$ = this.itemBitstreamsService.removeMarkedBitstreams(this.bundles$); // Perform the setup actions from above in order and display notifications removedResponses$.subscribe((responses: RemoteData) => { - this.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); + this.itemBitstreamsService.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); this.submitting = false; }); } - /** - * A bitstream was dropped in a new location. Send out a Move Patch request to the REST API, display notifications, - * refresh the bundle's cache (so the lists can properly reload) and call the event's callback function (which will - * navigate the user to the correct page) - * @param bundle The bundle to send patch requests to - * @param event The event containing the index the bitstream came from and was dropped to - */ - dropBitstream(bundle: Bundle, event: any) { - this.zone.runOutsideAngular(() => { - if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { - const moveOperation = { - op: 'move', - from: `/_links/bitstreams/${event.fromIndex}/href`, - path: `/_links/bitstreams/${event.toIndex}/href` - } as Operation; - this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RemoteData) => { - this.zone.run(() => { - this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); - // Remove all cached requests from this bundle and call the event's callback when the requests are cleared - this.requestService.removeByHrefSubstring(bundle.self).pipe( - filter((isCached) => isCached), - take(1) - ).subscribe(() => event.finish()); - }); - }); - } - }); - } - - /** - * 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: RemoteData[]) { - if (isNotEmpty(responses)) { - const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); - const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); - - failedResponses.forEach((response: RemoteData) => { - 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 diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts new file mode 100644 index 00000000000..a0277ef064b --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts @@ -0,0 +1,705 @@ +import { ItemBitstreamsService, SelectedBitstreamTableEntry } from './item-bitstreams.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { ObjectUpdatesServiceStub } from '../../../core/data/object-updates/object-updates.service.stub'; +import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub'; +import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { TranslateService } from '@ngx-translate/core'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { + createSuccessfulRemoteDataObject$, + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject +} from '../../../shared/remote-data.utils'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { LiveRegionService } from '../../../shared/live-region/live-region.service'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { of } from 'rxjs'; +import { getLiveRegionServiceStub } from '../../../shared/live-region/live-region.service.stub'; +import { fakeAsync, tick } from '@angular/core/testing'; +import createSpy = jasmine.createSpy; +import { MoveOperation } from 'fast-json-patch'; + +describe('ItemBitstreamsService', () => { + let service: ItemBitstreamsService; + let notificationsService: NotificationsService; + let translateService: TranslateService; + let objectUpdatesService: ObjectUpdatesService; + let bitstreamDataService: BitstreamDataService; + let bundleDataService: BundleDataService; + let dsoNameService: DSONameService; + let requestService: RequestService; + let liveRegionService: LiveRegionService; + + beforeEach(() => { + notificationsService = new NotificationsServiceStub() as any; + translateService = getMockTranslateService(); + objectUpdatesService = new ObjectUpdatesServiceStub() as any; + bitstreamDataService = new BitstreamDataServiceStub() as any; + bundleDataService = jasmine.createSpyObj('bundleDataService', { + patch: createSuccessfulRemoteDataObject$(new Bundle()), + }); + dsoNameService = new DSONameServiceMock() as any; + requestService = jasmine.createSpyObj('requestService', { + setStaleByHrefSubstring: of(true), + }); + liveRegionService = getLiveRegionServiceStub(); + + service = new ItemBitstreamsService( + notificationsService, + translateService, + objectUpdatesService, + bitstreamDataService, + bundleDataService, + dsoNameService, + requestService, + liveRegionService, + ); + }); + + const defaultEntry: SelectedBitstreamTableEntry = { + bitstream: { + name: 'bitstream name', + } as any, + bundle: Object.assign(new Bundle(), { + _links: { self: { href: 'self_link' }}, + }), + bundleSize: 10, + currentPosition: 0, + originalPosition: 0, + }; + + describe('selectBitstreamEntry', () => { + it('should correctly make getSelectedBitstream$ emit', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + })); + + it('should correctly make getSelectedBitstream return the bitstream', () => { + expect(service.getSelectedBitstream()).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + expect(service.getSelectedBitstream()).toEqual(entry); + }); + + it('should correctly make hasSelectedBitstream return', () => { + expect(service.hasSelectedBitstream()).toBeFalse(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + expect(service.hasSelectedBitstream()).toBeTrue(); + }); + + it('should do nothing if no entry was provided', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.selectBitstreamEntry(null); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + })); + + it('should announce the selected bitstream', () => { + const entry = Object.assign({}, defaultEntry); + + spyOn(service, 'announceSelect'); + + service.selectBitstreamEntry(entry); + expect(service.announceSelect).toHaveBeenCalledWith(entry.bitstream.name); + }); + }); + + describe('clearSelection', () => { + it('should clear the selected bitstream', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.clearSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cleared', selectedEntry: entry }); + })); + + it('should not do anything if there is no selected bitstream', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + service.clearSelection(); + tick(); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + })); + + it('should announce the cleared bitstream', () => { + const entry = Object.assign({}, defaultEntry); + + spyOn(service, 'announceClear'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.announceClear).toHaveBeenCalledWith(entry.bitstream.name); + }); + + it('should display a notification if the selected bitstream was moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + } + ); + + spyOn(service, 'displaySuccessNotification'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.displaySuccessNotification).toHaveBeenCalled(); + }); + + it('should not display a notification if the selected bitstream is in its original position', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + } + ); + + spyOn(service, 'displaySuccessNotification'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.displaySuccessNotification).not.toHaveBeenCalled(); + }); + }); + + describe('cancelSelection', () => { + it('should clear the selected bitstream if it has not moved', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.cancelSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cleared', selectedEntry: entry }); + })); + + it('should cancel the selected bitstream if it has moved', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry, { + originalPosition: 0, + currentPosition: 3, + }); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.cancelSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cancelled', selectedEntry: entry }); + })); + + it('should announce a clear if the bitstream has not moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + } + ); + + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.announceClear).toHaveBeenCalledWith(entry.bitstream.name); + expect(service.announceCancel).not.toHaveBeenCalled(); + }); + + it('should announce a cancel if the bitstream has moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + } + ); + + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.announceClear).not.toHaveBeenCalled(); + expect(service.announceCancel).toHaveBeenCalledWith(entry.bitstream.name, entry.originalPosition); + }); + + it('should return the bitstream to its original position if it has moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + } + ); + + spyOn(service, 'performBitstreamMoveRequest'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, entry.currentPosition, entry.originalPosition); + }); + + it('should not move the bitstream if it has not moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + } + ); + + spyOn(service, 'performBitstreamMoveRequest'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + spyOn(service, 'performBitstreamMoveRequest'); + + service.cancelSelection(); + + expect(service.announceClear).not.toHaveBeenCalled(); + expect(service.announceCancel).not.toHaveBeenCalled(); + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + + describe('moveSelectedBitstream', () => { + beforeEach(() => { + spyOn(service, 'performBitstreamMoveRequest').and.callThrough(); + }); + + describe('up', () => { + it('should move the selected bitstream one position up', () => { + const startPosition = 7; + const endPosition = startPosition - 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, startPosition, endPosition, jasmine.any(Function)); + expect(service.getSelectedBitstream()).toEqual(movedEntry); + }); + + it('should emit the move', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const startPosition = 7; + const endPosition = startPosition - 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + } + ); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.moveSelectedBitstreamUp(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Moved', selectedEntry: movedEntry }); + })); + + it('should announce the move', () => { + const startPosition = 7; + const endPosition = startPosition - 1; + + spyOn(service, 'announceMove'); + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + + expect(service.announceMove).toHaveBeenCalledWith(entry.bitstream.name, endPosition); + }); + + it('should not do anything if the bitstream is already at the top', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 0, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + service.moveSelectedBitstreamUp(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + + describe('down', () => { + it('should move the selected bitstream one position down', () => { + const startPosition = 7; + const endPosition = startPosition + 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, startPosition, endPosition, jasmine.any(Function)); + expect(service.getSelectedBitstream()).toEqual(movedEntry); + }); + + it('should emit the move', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const startPosition = 7; + const endPosition = startPosition + 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + } + ); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.moveSelectedBitstreamDown(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Moved', selectedEntry: movedEntry }); + })); + + it('should announce the move', () => { + const startPosition = 7; + const endPosition = startPosition + 1; + + spyOn(service, 'announceMove'); + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + + expect(service.announceMove).toHaveBeenCalledWith(entry.bitstream.name, endPosition); + }); + + it('should not do anything if the bitstream is already at the bottom of the bundle', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 9, + } + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + service.moveSelectedBitstreamDown(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + }); + + describe('performBitstreamMoveRequest', () => { + const bundle: Bundle = defaultEntry.bundle; + const from = 5; + const to = 7; + const callback = createSpy('callbackFunction'); + + it('should correctly create the Move request', () => { + const expectedOperation: MoveOperation = { + op: 'move', + from: `/_links/bitstreams/${from}/href`, + path: `/_links/bitstreams/${to}/href`, + }; + + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(bundleDataService.patch).toHaveBeenCalledWith(bundle, [expectedOperation]); + }); + + it('should correctly make the bundle\'s self link stale', () => { + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(bundle._links.self.href); + }); + + it('should attempt to show a message should the request have failed', () => { + spyOn(service, 'displayFailedResponseNotifications'); + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(service.displayFailedResponseNotifications).toHaveBeenCalled(); + }); + + it('should correctly call the provided function once the request has finished', () => { + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should emit at the start and end of the request', fakeAsync(() => { + const emittedActions = []; + + service.getPerformingMoveRequest$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeFalse(); + + service.performBitstreamMoveRequest(bundle, from, to, callback); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[1]).toBeTrue(); + expect(emittedActions[2]).toBeFalse(); + })); + }); + + describe('displayNotifications', () => { + it('should display an error notification if a response failed', () => { + const responses = [ + createFailedRemoteDataObject(), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.failed.title'); + }); + + it('should display a success notification if a response succeeded', () => { + const responses = [ + createSuccessfulRemoteDataObject(undefined), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(notificationsService.error).not.toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + }); + + it('should display both notifications if some failed and some succeeded', () => { + const responses = [ + createFailedRemoteDataObject(), + createSuccessfulRemoteDataObject(undefined), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + }); + }); + + describe('mapBitstreamsToTableEntries', () => { + it('should correctly map a Bitstream to a BitstreamTableEntry', () => { + const format: BitstreamFormat = new BitstreamFormat(); + + const bitstream: Bitstream = Object.assign(new Bitstream(), { + uuid: 'testUUID', + format: createSuccessfulRemoteDataObject$(format), + }); + + spyOn(dsoNameService, 'getName').and.returnValue('Test Name'); + spyOn(bitstream, 'firstMetadataValue').and.returnValue('description'); + + const tableEntry = service.mapBitstreamsToTableEntries([bitstream])[0]; + + expect(tableEntry.name).toEqual('Test Name'); + expect(tableEntry.nameStripped).toEqual('TestName'); + expect(tableEntry.bitstream).toBe(bitstream); + expect(tableEntry.id).toEqual('testUUID'); + expect(tableEntry.description).toEqual('description'); + expect(tableEntry.downloadUrl).toEqual('/bitstreams/testUUID/download'); + }); + }); + + describe('nameToHeader', () => { + it('should correctly transform a string to an appropriate header ID', () => { + const stringA = 'Test String'; + const stringAResult = 'TestString'; + expect(service.nameToHeader(stringA)).toEqual(stringAResult); + + const stringB = 'Test String Two'; + const stringBResult = 'TestStringTwo'; + expect(service.nameToHeader(stringB)).toEqual(stringBResult); + + const stringC = 'Test String Three'; + const stringCResult = 'TestStringThree'; + expect(service.nameToHeader(stringC)).toEqual(stringCResult); + }); + }); + +}); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts new file mode 100644 index 00000000000..f60693f7263 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts @@ -0,0 +1,78 @@ +import { of } from 'rxjs'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; + +export function getItemBitstreamsServiceStub(): ItemBitstreamsServiceStub { + return new ItemBitstreamsServiceStub(); +} + +export class ItemBitstreamsServiceStub { + getSelectionAction$ = jasmine.createSpy('getSelectedBitstream$').and + .returnValue(of(null)); + + getSelectedBitstream = jasmine.createSpy('getSelectedBitstream').and + .returnValue(null); + + hasSelectedBitstream = jasmine.createSpy('hasSelectedBitstream').and + .returnValue(false); + + selectBitstreamEntry = jasmine.createSpy('selectBitstreamEntry'); + + clearSelection = jasmine.createSpy('clearSelection'); + + cancelSelection = jasmine.createSpy('cancelSelection'); + + moveSelectedBitstreamUp = jasmine.createSpy('moveSelectedBitstreamUp'); + + moveSelectedBitstreamDown = jasmine.createSpy('moveSelectedBitstreamDown'); + + performBitstreamMoveRequest = jasmine.createSpy('performBitstreamMoveRequest'); + + getPerformingMoveRequest = jasmine.createSpy('getPerformingMoveRequest').and.returnValue(false); + + getPerformingMoveRequest$ = jasmine.createSpy('getPerformingMoveRequest$').and.returnValue(of(false)); + + getInitialBundlesPaginationOptions = jasmine.createSpy('getInitialBundlesPaginationOptions').and + .returnValue(new PaginationComponentOptions()); + + getInitialBitstreamsPaginationOptions = jasmine.createSpy('getInitialBitstreamsPaginationOptions').and + .returnValue(new PaginationComponentOptions()); + + getColumnSizes = jasmine.createSpy('getColumnSizes').and + .returnValue( + 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) + ]) + ); + + displayNotifications = jasmine.createSpy('displayNotifications'); + + displayFailedResponseNotifications = jasmine.createSpy('displayFailedResponseNotifications'); + + displayErrorNotification = jasmine.createSpy('displayErrorNotification'); + + displaySuccessFulResponseNotifications = jasmine.createSpy('displaySuccessFulResponseNotifications'); + + displaySuccessNotification = jasmine.createSpy('displaySuccessNotification'); + + removeMarkedBitstreams = jasmine.createSpy('removeMarkedBitstreams').and + .returnValue(createSuccessfulRemoteDataObject$({})); + + mapBitstreamsToTableEntries = jasmine.createSpy('mapBitstreamsToTableEntries').and + .returnValue([]); + + nameToHeader = jasmine.createSpy('nameToHeader').and.returnValue('header'); + + stripWhiteSpace = jasmine.createSpy('stripWhiteSpace').and.returnValue('string'); + + announceSelect = jasmine.createSpy('announceSelect'); + + announceMove = jasmine.createSpy('announceMove'); + + announceCancel = jasmine.createSpy('announceCancel'); +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts new file mode 100644 index 00000000000..bc771971d3c --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts @@ -0,0 +1,552 @@ +import { Injectable } from '@angular/core'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { RemoteData } from '../../../core/data/remote-data'; +import { hasValue, hasNoValue } from '../../../shared/empty.util'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, zip as observableZip, BehaviorSubject } from 'rxjs'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { take, switchMap, map, tap } from 'rxjs/operators'; +import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { getFirstSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { getBitstreamDownloadRoute } from '../../../app-routing-paths'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { MoveOperation } from 'fast-json-patch'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { LiveRegionService } from '../../../shared/live-region/live-region.service'; + +export const MOVE_KEY = 'item.edit.bitstreams.notifications.move'; + +/** + * Interface storing all the information necessary to create a row in the bitstream edit table + */ +export interface BitstreamTableEntry { + /** + * The bitstream + */ + bitstream: Bitstream, + /** + * The uuid of the Bitstream + */ + id: string, + /** + * The name of the Bitstream + */ + name: string, + /** + * The name of the Bitstream with all whitespace removed + */ + nameStripped: string, + /** + * The description of the Bitstream + */ + description: string, + /** + * Observable emitting the Format of the Bitstream + */ + format: Observable, + /** + * The download url of the Bitstream + */ + downloadUrl: string, +} + +/** + * Interface storing information necessary to highlight and reorder the selected bitstream entry + */ +export interface SelectedBitstreamTableEntry { + /** + * The selected entry + */ + bitstream: BitstreamTableEntry, + /** + * The bundle the bitstream belongs to + */ + bundle: Bundle, + /** + * The total number of bitstreams in the bundle + */ + bundleSize: number, + /** + * The original position of the bitstream within the bundle. + */ + originalPosition: number, + /** + * The current position of the bitstream within the bundle. + */ + currentPosition: number, +} + +/** + * Interface storing data regarding a change in selected bitstream + */ +export interface SelectionAction { + /** + * The different types of actions: + * - Selected: Bitstream was selected + * - Moved: Bitstream was moved + * - Cleared: Selection was cleared, bitstream remains at its current position + * - Cancelled: Selection was cancelled, bitstream returns to its original position + */ + action: 'Selected' | 'Moved' | 'Cleared' | 'Cancelled' + /** + * The table entry to which the selection action applies + */ + selectedEntry: SelectedBitstreamTableEntry, +} + +/** + * This service handles the selection and updating of the bitstreams and their order on the + * 'Edit Item' -> 'Bitstreams' page. + */ +@Injectable( + { providedIn: 'root' }, +) +export class ItemBitstreamsService { + + /** + * BehaviorSubject which emits every time the selected bitstream changes. + */ + protected selectionAction$: BehaviorSubject = new BehaviorSubject(null); + + protected isPerformingMoveRequest: BehaviorSubject = new BehaviorSubject(false); + + constructor( + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + protected objectUpdatesService: ObjectUpdatesService, + protected bitstreamService: BitstreamDataService, + protected bundleService: BundleDataService, + protected dsoNameService: DSONameService, + protected requestService: RequestService, + protected liveRegionService: LiveRegionService, + ) { + } + + /** + * Returns the observable emitting the selection actions + */ + getSelectionAction$(): Observable { + return this.selectionAction$; + } + + /** + * Returns the latest selection action + */ + getSelectionAction(): SelectionAction { + const action = this.selectionAction$.value; + + if (hasNoValue(action)) { + return null; + } + + return Object.assign({}, action); + } + + /** + * Returns true if there currently is a selected bitstream + */ + hasSelectedBitstream(): boolean { + const selectionAction = this.getSelectionAction(); + + if (hasNoValue(selectionAction)) { + return false; + } + + const action = selectionAction.action; + + return action === 'Selected' || action === 'Moved'; + } + + /** + * Returns a copy of the currently selected bitstream + */ + getSelectedBitstream(): SelectedBitstreamTableEntry { + if (!this.hasSelectedBitstream()) { + return null; + } + + const selectionAction = this.getSelectionAction(); + return Object.assign({}, selectionAction.selectedEntry); + } + + /** + * Select the provided entry + */ + selectBitstreamEntry(entry: SelectedBitstreamTableEntry) { + if (hasValue(entry) && entry.bitstream !== this.getSelectedBitstream()?.bitstream) { + this.announceSelect(entry.bitstream.name); + this.updateSelectionAction({ action: 'Selected', selectedEntry: entry }); + } + } + + /** + * Makes the {@link selectionAction$} observable emit the provided {@link SelectedBitstreamTableEntry}. + * @protected + */ + protected updateSelectionAction(action: SelectionAction) { + this.selectionAction$.next(action); + } + + /** + * Unselects the selected bitstream. Does nothing if no bitstream is selected. + */ + clearSelection() { + const selected = this.getSelectedBitstream(); + + if (hasValue(selected)) { + this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected }); + this.announceClear(selected.bitstream.name); + + if (selected.currentPosition !== selected.originalPosition) { + this.displaySuccessNotification(MOVE_KEY); + } + } + } + + /** + * Returns the currently selected bitstream to its original position and unselects the bitstream. + * Does nothing if no bitstream is selected. + */ + cancelSelection() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + + const originalPosition = selected.originalPosition; + const currentPosition = selected.currentPosition; + + // If the selected bitstream has not moved, there is no need to return it to its original position + if (currentPosition === originalPosition) { + this.announceClear(selected.bitstream.name); + this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected }); + } else { + this.announceCancel(selected.bitstream.name, originalPosition); + this.performBitstreamMoveRequest(selected.bundle, currentPosition, originalPosition); + this.updateSelectionAction({ action: 'Cancelled', selectedEntry: selected }); + } + } + + /** + * Moves the selected bitstream one position up in the bundle. Does nothing if no bitstream is selected or the + * selected bitstream already is at the beginning of the bundle. + */ + moveSelectedBitstreamUp() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + const originalPosition = selected.currentPosition; + if (originalPosition > 0) { + const newPosition = originalPosition - 1; + selected.currentPosition = newPosition; + + const onRequestCompleted = () => { + this.announceMove(selected.bitstream.name, newPosition); + }; + + this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted); + this.updateSelectionAction({ action: 'Moved', selectedEntry: selected }); + } + } + + /** + * Moves the selected bitstream one position down in the bundle. Does nothing if no bitstream is selected or the + * selected bitstream already is at the end of the bundle. + */ + moveSelectedBitstreamDown() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + const originalPosition = selected.currentPosition; + if (originalPosition < selected.bundleSize - 1) { + const newPosition = originalPosition + 1; + selected.currentPosition = newPosition; + + const onRequestCompleted = () => { + this.announceMove(selected.bitstream.name, newPosition); + }; + + this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted); + this.updateSelectionAction({ action: 'Moved', selectedEntry: selected }); + } + } + + /** + * Sends out a Move Patch request to the REST API, display notifications, + * refresh the bundle's cache (so the lists can properly reload) + * @param bundle The bundle to send patch requests to + * @param fromIndex The index to move from + * @param toIndex The index to move to + * @param finish Optional: Function to execute once the response has been received + */ + performBitstreamMoveRequest(bundle: Bundle, fromIndex: number, toIndex: number, finish?: () => void) { + if (this.getPerformingMoveRequest()) { + console.warn('Attempted to perform move request while previous request has not completed yet'); + return; + } + + const moveOperation: MoveOperation = { + op: 'move', + from: `/_links/bitstreams/${fromIndex}/href`, + path: `/_links/bitstreams/${toIndex}/href`, + }; + + this.announceLoading(); + this.isPerformingMoveRequest.next(true); + this.bundleService.patch(bundle, [moveOperation]).pipe( + getFirstCompletedRemoteData(), + tap((response: RemoteData) => this.displayFailedResponseNotifications(MOVE_KEY, [response])), + switchMap(() => this.requestService.setStaleByHrefSubstring(bundle.self)), + take(1), + ).subscribe(() => { + this.isPerformingMoveRequest.next(false); + finish?.(); + }); + } + + /** + * Whether the service currently is processing a 'move' request + */ + getPerformingMoveRequest(): boolean { + return this.isPerformingMoveRequest.value; + } + + /** + * Returns an observable which emits when the service starts, or ends, processing a 'move' request + */ + getPerformingMoveRequest$(): Observable { + return this.isPerformingMoveRequest; + } + + /** + * Returns the pagination options to use when fetching the bundles + */ + getInitialBundlesPaginationOptions(): PaginationComponentOptions { + return Object.assign(new PaginationComponentOptions(), { + id: 'bundles-pagination-options', + currentPage: 1, + pageSize: 9999 + }); + } + + /** + * Returns the initial pagination options to use when fetching the bitstreams + * @param bundleName The name of the bundle, will be as pagination id. + */ + getInitialBitstreamsPaginationOptions(bundleName: string): PaginationComponentOptions { + return Object.assign(new PaginationComponentOptions(),{ + id: bundleName, // This might behave unexpectedly if the item contains two bundles with the same name + currentPage: 1, + pageSize: 10 + }); + } + + /** + * Returns the {@link ResponsiveTableSizes} for use in the columns of the edit bitstreams table + */ + getColumnSizes(): ResponsiveTableSizes { + return 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) + ]); + } + + /** + * 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: RemoteData[]) { + this.displayFailedResponseNotifications(key, responses); + this.displaySuccessFulResponseNotifications(key, responses); + } + + /** + * Display an error notification for each failed response with their message + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displayFailedResponseNotifications(key: string, responses: RemoteData[]) { + const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); + failedResponses.forEach((response: RemoteData) => { + this.displayErrorNotification(key, response.errorMessage); + }); + } + + /** + * Display an error notification with the provided key and message + * @param key The i18n key for the notification messages + * @param errorMessage The error message to display + */ + displayErrorNotification(key: string, errorMessage: string) { + this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), errorMessage); + } + + /** + * Display a 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 + */ + displaySuccessFulResponseNotifications(key: string, responses: RemoteData[]) { + const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); + if (successfulResponses.length > 0) { + this.displaySuccessNotification(key); + } + } + + /** + * Display a success notification with the provided key + * @param key The i18n key for the notification messages + */ + displaySuccessNotification(key: string) { + this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`)); + } + + /** + * Removes the bitstreams marked for deletion from the Bundles emitted by the provided observable. + * @param bundles$ + */ + removeMarkedBitstreams(bundles$: Observable): Observable> { + const bundlesOnce$ = bundles$.pipe(take(1)); + + // 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 + return removedBitstreams$.pipe( + take(1), + switchMap((removedBitstreams: Bitstream[]) => { + return this.bitstreamService.removeMultiple(removedBitstreams); + }) + ); + } + + /** + * Creates an array of {@link BitstreamTableEntry}s from an array of {@link Bitstream}s + * @param bitstreams The bitstreams array to map to table entries + */ + mapBitstreamsToTableEntries(bitstreams: Bitstream[]): BitstreamTableEntry[] { + return bitstreams.map((bitstream) => { + const name = this.dsoNameService.getName(bitstream); + + return { + bitstream: bitstream, + id: bitstream.uuid, + name: name, + nameStripped: this.nameToHeader(name), + description: bitstream.firstMetadataValue('dc.description'), + format: bitstream.format.pipe(getFirstSucceededRemoteDataPayload()), + downloadUrl: getBitstreamDownloadRoute(bitstream), + }; + }); + } + + /** + * Returns a string appropriate to be used as header ID + * @param name + */ + nameToHeader(name: string): string { + // Whitespace is stripped from the Bitstream names for accessibility reasons. + // To make it clear which headers are relevant for a specific field in the table, the 'headers' attribute is used to + // refer to specific headers. The Bitstream's name is used as header ID for the row containing information regarding + // that bitstream. As the 'headers' attribute contains a space-separated string of header IDs, the Bitstream's header + // ID can not contain spaces itself. + return this.stripWhiteSpace(name); + } + + /** + * Returns a string equal to the input string with all whitespace removed. + * @param str + */ + stripWhiteSpace(str: string): string { + // '/\s+/g' matches all occurrences of any amount of whitespace characters + return str.replace(/\s+/g, ''); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name was selected. + * @param bitstreamName The name of the bitstream that was selected. + */ + announceSelect(bitstreamName: string) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.select', + { bitstream: bitstreamName }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name was moved to the provided + * position. + * @param bitstreamName The name of the bitstream that moved. + * @param toPosition The zero-indexed position that the bitstream moved to. + */ + announceMove(bitstreamName: string, toPosition: number) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.move', + { bitstream: bitstreamName, toIndex: toPosition + 1 }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected and + * was returned to the provided position. + * @param bitstreamName The name of the bitstream that is no longer selected + * @param toPosition The zero-indexed position the bitstream returned to. + */ + announceCancel(bitstreamName: string, toPosition: number) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.cancel', + { bitstream: bitstreamName, toIndex: toPosition + 1 }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected. + * @param bitstreamName The name of the bitstream that is no longer selected. + */ + announceClear(bitstreamName: string) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.clear', + { bitstream: bitstreamName }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the + */ + announceLoading() { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.loading'); + this.liveRegionService.addMessage(message); + } +} 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 index 42b9b9eb647..06201b1cbe7 100644 --- 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 @@ -1,22 +1,140 @@ -
-
- -
- {{'item.edit.bitstreams.bundle.name' | translate:{ name: dsoNameService.getName(bundle) } }} -
-
-
-
- -
-
-
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{'item.edit.bitstreams.headers.name' | translate}} + + {{'item.edit.bitstreams.headers.description' | translate}} + + {{'item.edit.bitstreams.headers.format' | translate}} + + {{'item.edit.bitstreams.headers.actions' | translate}} +
+ {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundleName } }} + + +
+ +
+ + + +
+
+ +
+
+ +
+ {{ entry.name }} +
+ {{ entry.description }} + + {{ (entry.format | async)?.shortDescription }} + +
+
+ + + + + + +
+
+
+ +
+
+
diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss new file mode 100644 index 00000000000..bbd4e1e75ce --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss @@ -0,0 +1,24 @@ +.header-row { + color: var(--bs-table-dark-color); + background-color: var(--bs-table-dark-bg); + border-color: var(--bs-table-dark-bg); +} + +.bundle-row { + color: var(--bs-table-head-color); + background-color: var(--bs-table-head-bg); + border-color: var(--bs-table-border-color); +} + +.row-element { + padding: 0.75em; + border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); +} + +.bitstream-name { + font-weight: normal; +} + +.table { + margin-bottom: 0; +} 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 index c26f99eb8fa..26a1b0e9139 100644 --- 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 @@ -6,6 +6,20 @@ 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'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { of as observableOf, of, Subject } from 'rxjs'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockRequestService } from '../../../../shared/mocks/request.service.mock'; +import { ItemBitstreamsService, BitstreamTableEntry, SelectedBitstreamTableEntry } from '../item-bitstreams.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { getItemBitstreamsServiceStub, ItemBitstreamsServiceStub } from '../item-bitstreams.service.stub'; +import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; describe('ItemEditBitstreamBundleComponent', () => { let comp: ItemEditBitstreamBundleComponent; @@ -31,10 +45,33 @@ describe('ItemEditBitstreamBundleComponent', () => { } }); + const restEndpoint = 'fake-rest-endpoint'; + const bundleService = jasmine.createSpyObj('bundleService', { + getBitstreamsEndpoint: observableOf(restEndpoint), + getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([])), + }); + + let objectUpdatesService: any; + let itemBitstreamsService: ItemBitstreamsServiceStub; + beforeEach(waitForAsync(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + initialize: undefined, + getFieldUpdatesExclusive: of(null), + }); + + itemBitstreamsService = getItemBitstreamsServiceStub(); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ItemEditBitstreamBundleComponent], + providers: [ + { provide: BundleDataService, useValue: bundleService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: PaginationService, useValue: new PaginationServiceStub() }, + { provide: RequestService, useValue: getMockRequestService() }, + { provide: ItemBitstreamsService, useValue: itemBitstreamsService }, + ], schemas: [ NO_ERRORS_SCHEMA ] @@ -55,4 +92,251 @@ describe('ItemEditBitstreamBundleComponent', () => { it('should create an embedded view of the component', () => { expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled(); }); + + describe('on selected entry change', () => { + let paginationComponent: any; + let testSubject: Subject = new Subject(); + + beforeEach(() => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }); + comp.paginationComponent = paginationComponent; + + spyOn(comp, 'getCurrentPageSize').and.returnValue(2); + }); + + it('should move to the page the selected entry is on if were not on that page', () => { + + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 1, + currentPosition: 2, + }; + + comp.handleSelectionAction({ action: 'Moved', selectedEntry: entry }); + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(2); + }); + + it('should not change page when we are already on the correct page', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 0, + currentPosition: 1, + }; + + comp.handleSelectionAction({ action: 'Moved', selectedEntry: entry }); + expect(paginationComponent.doPageChange).not.toHaveBeenCalled(); + }); + + it('should change to the original page when cancelling', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 3, + currentPosition: 0, + }; + + comp.handleSelectionAction({ action: 'Cancelled', selectedEntry: entry }); + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(2); + }); + + it('should not change page when we are already on the correct page when cancelling', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 0, + currentPosition: 3, + }; + + comp.handleSelectionAction({ action: 'Cancelled', selectedEntry: entry }); + expect(paginationComponent.doPageChange).not.toHaveBeenCalled(); + }); + }); + + describe('getRowClass', () => { + it('should return \'table-info\' when the bitstream is the selected bitstream', () => { + itemBitstreamsService.getSelectedBitstream.and.returnValue({ + bitstream: { id: 'bitstream-id'} + }); + + const bitstreamEntry = { + id: 'bitstream-id', + } as BitstreamTableEntry; + + expect(comp.getRowClass(undefined, bitstreamEntry)).toEqual('table-info'); + }); + + it('should return \'table-warning\' when the update is of type \'UPDATE\'', () => { + const update = { + changeType: FieldChangeType.UPDATE, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-warning'); + }); + + it('should return \'table-success\' when the update is of type \'ADD\'', () => { + const update = { + changeType: FieldChangeType.ADD, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-success'); + }); + + it('should return \'table-danger\' when the update is of type \'REMOVE\'', () => { + const update = { + changeType: FieldChangeType.REMOVE, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-danger'); + }); + + it('should return \'bg-white\' in any other case', () => { + const update = { + changeType: undefined, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('bg-white'); + }); + }); + + describe('drag', () => { + let dragTooltip; + let paginationComponent; + + beforeEach(() => { + dragTooltip = jasmine.createSpyObj('dragTooltip', { + open: undefined, + close: undefined, + }); + comp.dragTooltip = dragTooltip; + }); + + describe('Start', () => { + it('should open the tooltip when there are multiple pages', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(true), + }); + comp.paginationComponent = paginationComponent; + + comp.dragStart(); + expect(dragTooltip.open).toHaveBeenCalled(); + }); + + it('should not open the tooltip when there is only a single page', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(false), + }); + comp.paginationComponent = paginationComponent; + + comp.dragStart(); + expect(dragTooltip.open).not.toHaveBeenCalled(); + }); + }); + + describe('end', () => { + it('should always close the tooltip', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(false), + }); + comp.paginationComponent = paginationComponent; + + comp.dragEnd(); + expect(dragTooltip.close).toHaveBeenCalled(); + }); + }); + }); + + describe('drop', () => { + it('should correctly move the bitstream on drop', () => { + const event = { + previousIndex: 1, + currentIndex: 8, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).toHaveBeenCalledWith(jasmine.any(Bundle), 1, 8, jasmine.any(Function)); + }); + + it('should not move the bitstream if dropped in the same place', () => { + const event = { + previousIndex: 1, + currentIndex: 1, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should move to a different page if dropped on a page number', () => { + spyOn(document, 'elementFromPoint').and.returnValue({ + textContent: '2', + classList: { contains: (token: string) => true }, + } as Element); + + const event = { + previousIndex: 1, + currentIndex: 1, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).toHaveBeenCalledWith(jasmine.any(Bundle), 1, 20, jasmine.any(Function)); + }); + }); + + describe('select', () => { + it('should select the bitstream', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(false); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).toHaveBeenCalledWith(jasmine.objectContaining({ bitstream: entry })); + }); + + it('should cancel the selection if the bitstream already is selected', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(false); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + itemBitstreamsService.getSelectedBitstream.and.returnValue({ bitstream: entry }); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).not.toHaveBeenCalled(); + expect(itemBitstreamsService.cancelSelection).toHaveBeenCalled(); + }); + + it('should not do anything if the user is holding down the select key', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(true); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + itemBitstreamsService.getSelectedBitstream.and.returnValue({ bitstream: entry }); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).not.toHaveBeenCalled(); + expect(itemBitstreamsService.cancelSelection).not.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 index 70f4b632178..a751a89e1b6 100644 --- 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 @@ -1,14 +1,48 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core'; +import { + Component, + Input, + OnInit, + ViewChild, + ViewContainerRef, OnDestroy, +} 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'; import { getItemPageRoute } from '../../../item-page-routing-paths'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { PaginatedList } from 'src/app/core/data/paginated-list.model'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Observable, BehaviorSubject, switchMap, shareReplay, Subscription } from 'rxjs'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model'; +import { PaginatedSearchOptions } from '../../../../shared/search/models/paginated-search-options.model'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; +import { + getAllSucceededRemoteData, + paginatedListToArray, +} from '../../../../core/shared/operators'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { map, take, filter, tap } from 'rxjs/operators'; +import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; +import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { RequestService } from '../../../../core/data/request.service'; +import { + ItemBitstreamsService, + BitstreamTableEntry, + SelectedBitstreamTableEntry, + MOVE_KEY, SelectionAction +} from '../item-bitstreams.service'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { hasValue, hasNoValue } from '../../../../shared/empty.util'; @Component({ selector: 'ds-item-edit-bitstream-bundle', - styleUrls: ['../item-bitstreams.component.scss'], + styleUrls: ['../item-bitstreams.component.scss', './item-edit-bitstream-bundle.component.scss'], templateUrl: './item-edit-bitstream-bundle.component.html', }) /** @@ -16,13 +50,24 @@ import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; * 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 { +export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy { + protected readonly FieldChangeType = FieldChangeType; /** * The view on the bundle information and bitstreams */ @ViewChild('bundleView', {static: true}) bundleView; + /** + * The view on the pagination component + */ + @ViewChild(PaginationComponent) paginationComponent: PaginationComponent; + + /** + * The view on the drag tooltip + */ + @ViewChild('dragTooltip') dragTooltip; + /** * The bundle to display bitstreams for */ @@ -39,11 +84,9 @@ export class ItemEditBitstreamBundleComponent implements OnInit { @Input() columnSizes: ResponsiveTableSizes; /** - * Send an event when the user drops an object on the pagination - * The event contains details about the index the object came from and is dropped to (across the entirety of the list, - * not just within a single page) + * Whether this is the first in a series of bundle tables */ - @Output() dropObject: EventEmitter = new EventEmitter(); + @Input() isFirstTable = false; /** * The bootstrap sizes used for the Bundle Name column @@ -56,9 +99,65 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ itemPageRoute: string; + /** + * The name of the bundle + */ + bundleName: string; + + /** + * The number of bitstreams in the bundle + */ + bundleSize: number; + + /** + * The bitstreams to show in the table + */ + bitstreamsRD$: Observable>>; + + /** + * The data to show in the table + */ + tableEntries$: BehaviorSubject = new BehaviorSubject([]); + + /** + * The initial page options to use for fetching the bitstreams + */ + paginationOptions: PaginationComponentOptions; + + /** + * The current page options + */ + currentPaginationOptions$: BehaviorSubject; + + /** + * The currently selected page size + */ + pageSize$: BehaviorSubject; + + /** + * The self url of the bundle, also used when retrieving fieldUpdates + */ + bundleUrl: string; + + /** + * The updates to the current bitstreams + */ + updates$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Array containing all subscriptions created by this component + */ + subscriptions: Subscription[] = []; + + constructor( protected viewContainerRef: ViewContainerRef, public dsoNameService: DSONameService, + protected bundleService: BundleDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected paginationService: PaginationService, + protected requestService: RequestService, + protected itemBitstreamsService: ItemBitstreamsService, ) { } @@ -66,5 +165,383 @@ export class ItemEditBitstreamBundleComponent implements OnInit { this.bundleNameColumn = this.columnSizes.combineColumns(0, 2); this.viewContainerRef.createEmbeddedView(this.bundleView); this.itemPageRoute = getItemPageRoute(this.item); + this.bundleName = this.dsoNameService.getName(this.bundle); + this.bundleUrl = this.bundle.self; + + this.initializePagination(); + this.initializeBitstreams(); + this.initializeSelectionActions(); + } + + ngOnDestroy() { + this.viewContainerRef.clear(); + this.subscriptions.forEach(sub => sub?.unsubscribe()); + } + + protected initializePagination() { + this.paginationOptions = this.itemBitstreamsService.getInitialBitstreamsPaginationOptions(this.bundleName); + + this.currentPaginationOptions$ = new BehaviorSubject(this.paginationOptions); + this.pageSize$ = new BehaviorSubject(this.paginationOptions.pageSize); + + this.subscriptions.push( + this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions) + .subscribe((pagination) => { + this.currentPaginationOptions$.next(pagination); + this.pageSize$.next(pagination.pageSize); + }) + ); + + } + + protected initializeBitstreams() { + this.bitstreamsRD$ = this.currentPaginationOptions$.pipe( + switchMap((page: PaginationComponentOptions) => { + const paginatedOptions = new PaginatedSearchOptions({ pagination: Object.assign({}, page) }); + return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( + switchMap((href) => this.requestService.hasByHref$(href)), + switchMap(() => this.bundleService.getBitstreams( + this.bundle.id, + paginatedOptions, + followLink('format') + )) + ); + }), + getAllSucceededRemoteData(), + shareReplay(1), + ); + + this.subscriptions.push( + this.bitstreamsRD$.pipe( + take(1), + tap(bitstreamsRD => this.bundleSize = bitstreamsRD.payload.totalElements), + paginatedListToArray(), + ).subscribe((bitstreams) => { + this.objectUpdatesService.initialize(this.bundleUrl, bitstreams, new Date()); + }), + + this.bitstreamsRD$.pipe( + paginatedListToArray(), + switchMap((bitstreams) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUrl, bitstreams)) + ).subscribe((updates) => this.updates$.next(updates)), + + this.bitstreamsRD$.pipe( + paginatedListToArray(), + map((bitstreams) => this.itemBitstreamsService.mapBitstreamsToTableEntries(bitstreams)), + ).subscribe((tableEntries) => this.tableEntries$.next(tableEntries)), + ); + } + + protected initializeSelectionActions() { + this.subscriptions.push( + this.itemBitstreamsService.getSelectionAction$().subscribe( + selectionAction => this.handleSelectionAction(selectionAction)) + ); + } + + /** + * Handles a change in selected bitstream by changing the pagination if the change happened on a different page + * @param selectionAction + */ + handleSelectionAction(selectionAction: SelectionAction) { + if (hasNoValue(selectionAction) || selectionAction.selectedEntry.bundle !== this.bundle) { + return; + } + + if (selectionAction.action === 'Moved') { + // If the currently selected bitstream belongs to this bundle, it has possibly moved to a different page. + // In that case we want to change the pagination to the new page. + this.redirectToCurrentPage(selectionAction.selectedEntry); + } + + if (selectionAction.action === 'Cancelled') { + // If the selection is cancelled (and returned to its original position), it is possible the previously selected + // bitstream is returned to a different page. In that case we want to change the pagination to the place where + // the bitstream was returned to. + this.redirectToOriginalPage(selectionAction.selectedEntry); + } + + if (selectionAction.action === 'Cleared') { + // If the selection is cleared, it is possible the previously selected bitstream is on a different page. In that + // case we want to change the pagination to the place where the bitstream is. + this.redirectToCurrentPage(selectionAction.selectedEntry); + } + } + + /** + * Redirect the user to the current page of the provided bitstream if it is on a different page. + * @param bitstreamEntry The entry that the current position will be taken from to determine the page to move to + * @protected + */ + protected redirectToCurrentPage(bitstreamEntry: SelectedBitstreamTableEntry) { + const currentPage = this.getCurrentPage(); + const selectedEntryPage = this.bundleIndexToPage(bitstreamEntry.currentPosition); + + if (currentPage !== selectedEntryPage) { + this.changeToPage(selectedEntryPage); + } + } + + /** + * Redirect the user to the original page of the provided bitstream if it is on a different page. + * @param bitstreamEntry The entry that the original position will be taken from to determine the page to move to + * @protected + */ + protected redirectToOriginalPage(bitstreamEntry: SelectedBitstreamTableEntry) { + const currentPage = this.getCurrentPage(); + const originPage = this.bundleIndexToPage(bitstreamEntry.originalPosition); + + if (currentPage !== originPage) { + this.changeToPage(originPage); + } + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(fieldUpdate: FieldUpdate): boolean { + return fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(fieldUpdate: FieldUpdate): boolean { + return fieldUpdate.changeType >= 0; + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(bitstream: Bitstream): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, bitstream); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(bitstream: Bitstream): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, bitstream.uuid); + } + + /** + * Returns the css class for a table row depending on the state of the table entry. + * @param update + * @param bitstream + */ + getRowClass(update: FieldUpdate, bitstream: BitstreamTableEntry): string { + const selected = this.itemBitstreamsService.getSelectedBitstream(); + + if (hasValue(selected) && bitstream.id === selected.bitstream.id) { + return 'table-info'; + } + + switch (update.changeType) { + case FieldChangeType.UPDATE: + return 'table-warning'; + case FieldChangeType.ADD: + return 'table-success'; + case FieldChangeType.REMOVE: + return 'table-danger'; + default: + return 'bg-white'; + } + } + + /** + * Changes the page size to the provided page size. + * @param pageSize + */ + public doPageSizeChange(pageSize: number) { + this.paginationComponent.doPageSizeChange(pageSize); + } + + /** + * Handles start of dragging by opening the tooltip mentioning that it is possible to drag a bitstream to a different + * page by dropping it on the page number, only if there are multiple pages. + */ + dragStart() { + // Only open the drag tooltip when there are multiple pages + this.paginationComponent.shouldShowBottomPager.pipe( + take(1), + filter((hasMultiplePages) => hasMultiplePages), + ).subscribe(() => { + this.dragTooltip.open(); + }); + } + + /** + * Handles end of dragging by closing the tooltip. + */ + dragEnd() { + this.dragTooltip.close(); + } + + /** + * Handles dropping by calculation the target position, and changing the page if the bitstream was dropped on a + * different page. + * @param event + */ + drop(event: CdkDragDrop) { + const dragIndex = event.previousIndex; + let dropIndex = event.currentIndex; + const dragPage = this.getCurrentPage(); + let dropPage = this.getCurrentPage(); + + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object + const droppedOnElement = document.elementFromPoint(event.dropPoint.x, event.dropPoint.y); + if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent) && droppedOnElement.classList.contains('page-link')) { + // The user is hovering over a page, fetch the page's number from the element + let droppedPage = Number(droppedOnElement.textContent); + if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) { + droppedPage -= 1; + + if (droppedPage !== dragPage) { + dropPage = droppedPage; + + if (dropPage > dragPage) { + // When moving to later page, place bitstream at the top + dropIndex = 0; + } else { + // When moving to earlier page, place bitstream at the bottom + dropIndex = this.getCurrentPageSize() - 1; + } + } + } + } + + const fromIndex = this.pageIndexToBundleIndex(dragIndex, dragPage); + const toIndex = this.pageIndexToBundleIndex(dropIndex, dropPage); + + if (fromIndex === toIndex) { + return; + } + + const selectedBitstream = this.tableEntries$.value[dragIndex]; + + const finish = () => { + this.itemBitstreamsService.announceMove(selectedBitstream.name, toIndex); + + if (dropPage !== this.getCurrentPage()) { + this.changeToPage(dropPage); + } + + this.itemBitstreamsService.displaySuccessNotification(MOVE_KEY); + }; + + this.itemBitstreamsService.performBitstreamMoveRequest(this.bundle, fromIndex, toIndex, finish); + } + + /** + * Handles a select action for the provided bitstream entry. + * If the selected bitstream is currently selected, the selection is cleared. + * If no, or a different bitstream, is selected, the provided bitstream becomes the selected bitstream. + * @param event The event that triggered the select action + * @param bitstream The bitstream that is the target of the select action + */ + select(event: UIEvent, bitstream: BitstreamTableEntry) { + event.preventDefault(); + + if (event instanceof KeyboardEvent && event.repeat) { + // Don't handle hold events, otherwise it would change rapidly between being selected and unselected + return; + } + + const selectedBitstream = this.itemBitstreamsService.getSelectedBitstream(); + + if (hasValue(selectedBitstream) && selectedBitstream.bitstream === bitstream) { + this.itemBitstreamsService.cancelSelection(); + } else { + const selectionObject = this.createBitstreamSelectionObject(bitstream); + + if (hasNoValue(selectionObject)) { + console.warn('Failed to create selection object'); + return; + } + + this.itemBitstreamsService.selectBitstreamEntry(selectionObject); + } + } + + /** + * Creates a {@link SelectedBitstreamTableEntry} from the provided {@link BitstreamTableEntry} so it can be given + * to the {@link ItemBitstreamsService} to select the table entry. + * @param bitstream The table entry to create the selection object from. + * @protected + */ + protected createBitstreamSelectionObject(bitstream: BitstreamTableEntry): SelectedBitstreamTableEntry { + const pageIndex = this.findBitstreamPageIndex(bitstream); + + if (pageIndex === -1) { + return null; + } + + const position = this.pageIndexToBundleIndex(pageIndex, this.getCurrentPage()); + + return { + bitstream: bitstream, + bundle: this.bundle, + bundleSize: this.bundleSize, + currentPosition: position, + originalPosition: position, + }; + } + + /** + * Returns the index of the provided {@link BitstreamTableEntry} relative to the current page + * If the current page size is 10, it will return a value from 0 to 9 (inclusive) + * Returns -1 if the provided bitstream could not be found + * @protected + */ + protected findBitstreamPageIndex(bitstream: BitstreamTableEntry): number { + const entries = this.tableEntries$.value; + return entries.findIndex(entry => entry === bitstream); + } + + /** + * Returns the current zero-indexed page + * @protected + */ + protected getCurrentPage(): number { + // The pagination component uses one-based numbering while zero-based numbering is more convenient for calculations + return this.currentPaginationOptions$.value.currentPage - 1; + } + + /** + * Returns the current page size + * @protected + */ + protected getCurrentPageSize(): number { + return this.currentPaginationOptions$.value.pageSize; + } + + /** + * Converts an index relative to the page to an index relative to the bundle + * @param index The index relative to the page + * @param page The zero-indexed page number + * @protected + */ + protected pageIndexToBundleIndex(index: number, page: number) { + return page * this.getCurrentPageSize() + index; + } + + /** + * Calculates the zero-indexed page number from the index relative to the bundle + * @param index The index relative to the bundle + * @protected + */ + protected bundleIndexToPage(index: number) { + return Math.floor(index / this.getCurrentPageSize()); + } + + /** + * Change the pagination for this bundle to the provided zero-indexed page + * @param page The zero-indexed page to change to + * @protected + */ + protected changeToPage(page: number) { + // Increments page by one because zero-indexing is way easier for calculations but the pagination component + // uses one-indexing. + this.paginationComponent.doPageChange(page + 1); } } 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 deleted file mode 100644 index a31ab953489..00000000000 --- 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 +++ /dev/null @@ -1,32 +0,0 @@ - - -
- -
- -
- -
-
-
-
-
-
- -
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 deleted file mode 100644 index 7317eb93be0..00000000000 --- 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 +++ /dev/null @@ -1,150 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } 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 { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model'; -import { of as observableOf } from 'rxjs'; -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'; -import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; -import { createPaginatedList } from '../../../../../shared/testing/utils.test'; -import { RequestService } from '../../../../../core/data/request.service'; -import { PaginationService } from '../../../../../core/pagination/pagination.service'; -import { PaginationServiceStub } from '../../../../../shared/testing/pagination-service.stub'; - -describe('PaginatedDragAndDropBitstreamListComponent', () => { - let comp: PaginatedDragAndDropBitstreamListComponent; - let fixture: ComponentFixture; - let objectUpdatesService: ObjectUpdatesService; - let bundleService: BundleDataService; - let objectValuesPipe: ObjectValuesPipe; - let requestService: RequestService; - let paginationService; - - 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: createSuccessfulRemoteDataObject$(format) - }); - const fieldUpdate1 = { - field: bitstream1, - changeType: undefined - }; - const bitstream2 = Object.assign(new Bitstream(), { - uuid: 'bitstreamUUID2', - name: 'Fake Bitstream 2', - bundleName: 'ORIGINAL', - description: 'Description', - format: createSuccessfulRemoteDataObject$(format) - }); - const fieldUpdate2 = { - field: bitstream2, - changeType: undefined - }; - - beforeEach(waitForAsync(() => { - 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])), - getBitstreamsEndpoint: observableOf('') - }); - - objectValuesPipe = new ObjectValuesPipe(); - - requestService = jasmine.createSpyObj('requestService', { - hasByHref$: observableOf(true) - }); - - paginationService = new PaginationServiceStub(); - - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective], - providers: [ - { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: BundleDataService, useValue: bundleService }, - { provide: ObjectValuesPipe, useValue: objectValuesPipe }, - { provide: RequestService, useValue: requestService }, - { provide: PaginationService, useValue: paginationService } - ], 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 deleted file mode 100644 index 2c81a4e2cb9..00000000000 --- 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 +++ /dev/null @@ -1,76 +0,0 @@ -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/models/paginated-search-options.model'; -import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { followLink } from '../../../../../shared/utils/follow-link-config.model'; -import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; -import { RequestService } from '../../../../../core/data/request.service'; -import { PaginationService } from '../../../../../core/pagination/pagination.service'; -import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.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 objectValuesPipe: ObjectValuesPipe, - protected bundleService: BundleDataService, - protected paginationService: PaginationService, - protected requestService: RequestService) { - super(objectUpdatesService, elRef, objectValuesPipe, paginationService); - } - - ngOnInit() { - super.ngOnInit(); - } - - /** - * Initialize the bitstreams observable depending on currentPage$ - */ - initializeObjectsRD(): void { - this.objectsRD$ = this.currentPage$.pipe( - switchMap((page: PaginationComponentOptions) => { - const paginatedOptions = new PaginatedSearchOptions({pagination: Object.assign({}, page)}); - return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( - switchMap((href) => this.requestService.hasByHref$(href)), - switchMap(() => this.bundleService.getBitstreams( - this.bundle.id, - paginatedOptions, - 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 deleted file mode 100644 index 1bce8667ee1..00000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html +++ /dev/null @@ -1,5 +0,0 @@ - -
- -
-
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 deleted file mode 100644 index e5cb9ba4034..00000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Component, OnInit, 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 deleted file mode 100644 index f0671d3d300..00000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ /dev/null @@ -1,55 +0,0 @@ - -
- -
- - {{ 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 deleted file mode 100644 index f3c897aa738..00000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { ItemEditBitstreamComponent } from './item-edit-bitstream.component'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { of as observableOf } from 'rxjs'; -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 { 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'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { getBitstreamDownloadRoute } from '../../../../app-routing-paths'; -import { By } from '@angular/platform-browser'; -import { BrowserOnlyMockPipe } from '../../../../shared/testing/browser-only-mock.pipe'; -import { RouterTestingModule } from '@angular/router/testing'; - -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', - _links: { - content: { href: 'content-link' } - }, - - format: createSuccessfulRemoteDataObject$(format) -}); -const fieldUpdate = { - field: bitstream, - changeType: undefined -}; -const date = new Date(); -const url = 'thisUrl'; - -let objectUpdatesService: ObjectUpdatesService; - -describe('ItemEditBitstreamComponent', () => { - beforeEach(waitForAsync(() => { - 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: [ - RouterTestingModule.withRoutes([]), - TranslateModule.forRoot(), - ], - declarations: [ - ItemEditBitstreamComponent, - VarDirective, - BrowserOnlyMockPipe, - ], - 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); - }); - }); - - describe('when the component loads', () => { - it('should contain download button with a valid link to the bitstreams download page', () => { - fixture.detectChanges(); - const downloadBtnHref = fixture.debugElement.query(By.css('[data-test="download-button"]')).nativeElement.getAttribute('href'); - expect(downloadBtnHref).toEqual(comp.bitstreamDownloadUrl); - }); - }); - - describe('when the bitstreamDownloadUrl property gets populated', () => { - it('should contain the bitstream download page route', () => { - expect(comp.bitstreamDownloadUrl).not.toEqual(bitstream._links.content.href); - expect(comp.bitstreamDownloadUrl).toEqual(getBitstreamDownloadRoute(bitstream)); - }); - }); -}); 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 deleted file mode 100644 index fcb5c706ac7..00000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import cloneDeep from 'lodash/cloneDeep'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { Observable } from 'rxjs'; -import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../../core/shared/operators'; -import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; -import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; -import { getBitstreamDownloadRoute } from '../../../../app-routing-paths'; - -@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 bitstream's download url - */ - bitstreamDownloadUrl: 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.bitstreamDownloadUrl = getBitstreamDownloadRoute(this.bitstream); - this.format$ = this.bitstream.format.pipe( - getFirstSucceededRemoteData(), - 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/shared/live-region/live-region.service.stub.ts b/src/app/shared/live-region/live-region.service.stub.ts new file mode 100644 index 00000000000..4f10b46a4c6 --- /dev/null +++ b/src/app/shared/live-region/live-region.service.stub.ts @@ -0,0 +1,30 @@ +import { of } from 'rxjs'; +import { LiveRegionService } from './live-region.service'; + +export function getLiveRegionServiceStub(): LiveRegionService { + return new LiveRegionServiceStub() as unknown as LiveRegionService; +} + +export class LiveRegionServiceStub { + getMessages = jasmine.createSpy('getMessages').and.returnValue( + ['Message One', 'Message Two'] + ); + + getMessages$ = jasmine.createSpy('getMessages$').and.returnValue( + of(['Message One', 'Message Two']) + ); + + addMessage = jasmine.createSpy('addMessage').and.returnValue('messageId'); + + clear = jasmine.createSpy('clear'); + + clearMessageByUUID = jasmine.createSpy('clearMessageByUUID'); + + getLiveRegionVisibility = jasmine.createSpy('getLiveRegionVisibility').and.returnValue(false); + + setLiveRegionVisibility = jasmine.createSpy('setLiveRegionVisibility'); + + getMessageTimeOutMs = jasmine.createSpy('getMessageTimeOutMs').and.returnValue(30000); + + setMessageTimeOutMs = jasmine.createSpy('setMessageTimeOutMs'); +} diff --git a/src/app/shared/notifications/models/notification-options.model.ts b/src/app/shared/notifications/models/notification-options.model.ts index 65011496b3f..c891781d9d0 100644 --- a/src/app/shared/notifications/models/notification-options.model.ts +++ b/src/app/shared/notifications/models/notification-options.model.ts @@ -4,19 +4,25 @@ export interface INotificationOptions { timeOut: number; clickToClose: boolean; animate: NotificationAnimationsType | string; + announceContentInLiveRegion: boolean; } export class NotificationOptions implements INotificationOptions { public timeOut: number; public clickToClose: boolean; public animate: any; + public announceContentInLiveRegion: boolean; - constructor(timeOut = 5000, - clickToClose = true, - animate: NotificationAnimationsType | string = NotificationAnimationsType.Scale) { + constructor( + timeOut = 5000, + clickToClose = true, + animate: NotificationAnimationsType | string = NotificationAnimationsType.Scale, + announceContentInLiveRegion: boolean = true, + ) { this.timeOut = timeOut; this.clickToClose = clickToClose; this.animate = animate; + this.announceContentInLiveRegion = announceContentInLiveRegion; } } diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts index 08b9585a8c7..73f4e6b1b1f 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, inject, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing'; import { BrowserModule, By } from '@angular/platform-browser'; import { ChangeDetectorRef } from '@angular/core'; @@ -15,14 +15,20 @@ import uniqueId from 'lodash/uniqueId'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { cold } from 'jasmine-marbles'; +import { LiveRegionService } from '../../live-region/live-region.service'; +import { LiveRegionServiceStub } from '../../live-region/live-region.service.stub'; +import { NotificationOptions } from '../models/notification-options.model'; export const bools = { f: false, t: true }; describe('NotificationsBoardComponent', () => { let comp: NotificationsBoardComponent; let fixture: ComponentFixture; + let liveRegionService: LiveRegionServiceStub; beforeEach(waitForAsync(() => { + liveRegionService = new LiveRegionServiceStub(); + TestBed.configureTestingModule({ imports: [ BrowserModule, @@ -36,7 +42,9 @@ describe('NotificationsBoardComponent', () => { declarations: [NotificationsBoardComponent, NotificationComponent], // declare the test component providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, - ChangeDetectorRef] + { provide: LiveRegionService, useValue: liveRegionService }, + ChangeDetectorRef, + ] }).compileComponents(); // compile template and css })); @@ -106,5 +114,42 @@ describe('NotificationsBoardComponent', () => { }); }); + describe('add', () => { + beforeEach(() => { + liveRegionService.addMessage.calls.reset(); + }); + + it('should announce content to the live region', fakeAsync(() => { + const notification = new Notification('id', NotificationType.Info, 'title', 'content'); + comp.add(notification); + + flush(); + + expect(liveRegionService.addMessage).toHaveBeenCalledWith('content'); + })); + + it('should not announce anything if there is no content', fakeAsync(() => { + const notification = new Notification('id', NotificationType.Info, 'title'); + comp.add(notification); + + flush(); + + expect(liveRegionService.addMessage).not.toHaveBeenCalled(); + })); + + it('should not announce the content if disabled', fakeAsync(() => { + const options = new NotificationOptions(); + options.announceContentInLiveRegion = false; + + const notification = new Notification('id', NotificationType.Info, 'title', 'content'); + notification.options = options; + comp.add(notification); + + flush(); + + expect(liveRegionService.addMessage).not.toHaveBeenCalled(); + })); + }); + }) ; diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.ts index 97ae09c1a67..eaba6596786 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { select, Store } from '@ngrx/store'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription, of as observableOf } from 'rxjs'; import difference from 'lodash/difference'; import { NotificationsService } from '../notifications.service'; @@ -18,6 +18,9 @@ import { notificationsStateSelector } from '../selectors'; import { INotification } from '../models/notification.model'; import { NotificationsState } from '../notifications.reducers'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; +import { LiveRegionService } from '../../live-region/live-region.service'; +import { hasNoValue, isNotEmptyOperator } from '../../empty.util'; +import { take } from 'rxjs/operators'; @Component({ selector: 'ds-notifications-board', @@ -49,9 +52,12 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { */ public isPaused$: BehaviorSubject = new BehaviorSubject(false); - constructor(private service: NotificationsService, - private store: Store, - private cdr: ChangeDetectorRef) { + constructor( + private service: NotificationsService, + private store: Store, + private cdr: ChangeDetectorRef, + protected liveRegionService: LiveRegionService, + ) { } ngOnInit(): void { @@ -85,6 +91,7 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { this.notifications.splice(this.notifications.length - 1, 1); } this.notifications.splice(0, 0, item); + this.addContentToLiveRegion(item); } else { // Remove the notification from the store // This notification was in the store, but not in this.notifications @@ -93,29 +100,44 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { } } + /** + * Adds the content of the notification (if any) to the live region, so it can be announced by screen readers. + */ + private addContentToLiveRegion(item: INotification) { + let content = item.content; + + if (!item.options.announceContentInLiveRegion || hasNoValue(content)) { + return; + } + + if (typeof content === 'string') { + content = observableOf(content); + } + + content.pipe( + isNotEmptyOperator(), + take(1), + ).subscribe(contentStr => this.liveRegionService.addMessage(contentStr)); + } + + /** + * Whether to block the provided item because a duplicate notification with the exact same information already + * exists within the notifications array. + * @param item The item to check + * @return true if the notifications array already contains a notification with the exact same information as the + * provided item. false otherwise. + * @private + */ private block(item: INotification): boolean { const toCheck = item.html ? this.checkHtml : this.checkStandard; + this.notifications.forEach((notification) => { if (toCheck(notification, item)) { return true; } }); - if (this.notifications.length > 0) { - this.notifications.forEach((notification) => { - if (toCheck(notification, item)) { - return true; - } - }); - } - - let comp: INotification; - if (this.notifications.length > 0) { - comp = this.notifications[0]; - } else { - return false; - } - return toCheck(comp, item); + return false; } private checkStandard(checker: INotification, item: INotification): boolean { diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts deleted file mode 100644 index bac6b89583f..00000000000 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { AbstractPaginatedDragAndDropListComponent } from './abstract-paginated-drag-and-drop-list.component'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; -import { Component, ElementRef } from '@angular/core'; -import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { take } from 'rxjs/operators'; -import { PaginationComponent } from '../pagination/pagination.component'; -import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; -import { createPaginatedList } from '../testing/utils.test'; -import { ObjectValuesPipe } from '../utils/object-values-pipe'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { PaginationServiceStub } from '../testing/pagination-service.stub'; -import { FieldUpdates } from '../../core/data/object-updates/field-updates.model'; - -@Component({ - selector: 'ds-mock-paginated-drag-drop-abstract', - template: '' -}) -class MockAbstractPaginatedDragAndDropListComponent extends AbstractPaginatedDragAndDropListComponent { - - constructor(protected objectUpdatesService: ObjectUpdatesService, - protected elRef: ElementRef, - protected objectValuesPipe: ObjectValuesPipe, - protected mockUrl: string, - protected paginationService: PaginationService, - protected mockObjectsRD$: Observable>>) { - super(objectUpdatesService, elRef, objectValuesPipe, paginationService); - } - - initializeObjectsRD(): void { - this.objectsRD$ = this.mockObjectsRD$; - } - - initializeURL(): void { - this.url = this.mockUrl; - } -} - -describe('AbstractPaginatedDragAndDropListComponent', () => { - let component: MockAbstractPaginatedDragAndDropListComponent; - let objectUpdatesService: ObjectUpdatesService; - let elRef: ElementRef; - let objectValuesPipe: ObjectValuesPipe; - - const url = 'mock-abstract-paginated-drag-and-drop-list-component'; - - - const object1 = Object.assign(new DSpaceObject(), { uuid: 'object-1' }); - const object2 = Object.assign(new DSpaceObject(), { uuid: 'object-2' }); - const objectsRD = createSuccessfulRemoteDataObject(createPaginatedList([object1, object2])); - let objectsRD$: BehaviorSubject>>; - let paginationService; - - const updates = { - [object1.uuid]: { field: object1, changeType: undefined }, - [object2.uuid]: { field: object2, changeType: undefined } - } as FieldUpdates; - - let paginationComponent: PaginationComponent; - - beforeEach(() => { - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { - initialize: {}, - getFieldUpdatesExclusive: observableOf(updates) - }); - elRef = { - nativeElement: jasmine.createSpyObj('nativeElement', { - querySelector: {} - }) - }; - objectValuesPipe = new ObjectValuesPipe(); - paginationComponent = jasmine.createSpyObj('paginationComponent', { - doPageChange: {} - }); - paginationService = new PaginationServiceStub(); - objectsRD$ = new BehaviorSubject(objectsRD); - component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, objectValuesPipe, url, paginationService, objectsRD$); - component.paginationComponent = paginationComponent; - component.ngOnInit(); - }); - - it('should call initialize to initialize the objects in the store', () => { - expect(objectUpdatesService.initialize).toHaveBeenCalled(); - }); - - it('should initialize the updates correctly', (done) => { - component.updates$.pipe(take(1)).subscribe((fieldUpdates) => { - expect(fieldUpdates).toEqual(updates); - done(); - }); - }); - - describe('drop', () => { - const event = { - previousIndex: 0, - currentIndex: 1, - item: { element: { nativeElement: { id: object1.uuid } } } - } as any; - - describe('when the user is hovering over a new page', () => { - const hoverPage = 3; - const hoverElement = { textContent: '' + hoverPage }; - - beforeEach(() => { - elRef.nativeElement.querySelector.and.returnValue(hoverElement); - spyOn(component.dropObject, 'emit'); - component.drop(event); - }); - - it('should send out a dropObject event with the expected processed paginated indexes', () => { - expect(component.dropObject.emit).toHaveBeenCalledWith(Object.assign({ - fromIndex: ((component.currentPage$.value.currentPage - 1) * component.pageSize) + event.previousIndex, - toIndex: ((hoverPage - 1) * component.pageSize), - finish: jasmine.anything() - })); - }); - }); - - describe('when the user is not hovering over a new page', () => { - beforeEach(() => { - spyOn(component.dropObject, 'emit'); - component.drop(event); - }); - - it('should send out a dropObject event with the expected properties', () => { - expect(component.dropObject.emit).toHaveBeenCalledWith(Object.assign({ - fromIndex: event.previousIndex, - toIndex: event.currentIndex, - finish: jasmine.anything() - })); - }); - }); - }); -}); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts deleted file mode 100644 index 8dba47566fa..00000000000 --- a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; -import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; -import { hasValue } from '../empty.util'; -import { - paginatedListToArray, - getFirstSucceededRemoteData, - getAllSucceededRemoteData -} from '../../core/shared/operators'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { Component, ElementRef, EventEmitter, OnDestroy, Output, ViewChild } from '@angular/core'; -import { PaginationComponent } from '../pagination/pagination.component'; -import { ObjectValuesPipe } from '../utils/object-values-pipe'; -import { compareArraysUsing } from '../../item-page/simple/item-types/shared/item-relationships-utils'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { FieldUpdate } from '../../core/data/object-updates/field-update.model'; -import { FieldUpdates } from '../../core/data/object-updates/field-updates.model'; - -/** - * Operator used for comparing {@link FieldUpdate}s by their field's UUID - */ -export const compareArraysUsingFieldUuids = () => - compareArraysUsing((fieldUpdate: FieldUpdate) => (hasValue(fieldUpdate) && hasValue(fieldUpdate.field)) ? fieldUpdate.field.uuid : undefined); - -/** - * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated - * list. This implementation supports being able to drag and drop objects between pages. - * Dragging an object on top of a page number will automatically detect the page it's being dropped on and send a - * dropObject event to the parent component containing detailed information about the indexes the object was dropped from - * and to. - * - * To extend this component, it is important to make sure to: - * - Initialize objectsRD$ within the initializeObjectsRD() method - * - Initialize a unique URL for this component/page within the initializeURL() method - * - Add (cdkDropListDropped)="drop($event)" to the cdkDropList element in your template - * - Add (pageChange)="switchPage($event)" to the ds-pagination element in your template - * - Use the updates$ observable for building your list of cdkDrag elements in your template - * - * An example component extending from this abstract component: PaginatedDragAndDropBitstreamListComponent - */ -@Component({ - selector: 'ds-paginated-drag-drop-abstract', - template: '' -}) -export abstract class AbstractPaginatedDragAndDropListComponent implements OnDestroy { - /** - * A view on the child pagination component - */ - @ViewChild(PaginationComponent) paginationComponent: PaginationComponent; - - /** - * Send an event when the user drops an object on the pagination - * The event contains details about the index the object came from and is dropped to (across the entirety of the list, - * not just within a single page) - */ - @Output() dropObject: EventEmitter = new EventEmitter(); - - /** - * The URL to use for accessing the object updates from this list - */ - url: string; - - /** - * The objects to retrieve data for and transform into field updates - */ - objectsRD$: Observable>>; - - /** - * The updates to the current list - */ - updates$: Observable; - - /** - * A list of object UUIDs - * This is the order the objects will be displayed in - */ - customOrder: string[]; - - /** - * The amount of objects to display per page - */ - pageSize = 10; - - /** - * The page options to use for fetching the objects - * Start at page 1 and always use the set page size - */ - options = Object.assign(new PaginationComponentOptions(),{ - id: 'dad', - currentPage: 1, - pageSize: this.pageSize - }); - - /** - * The current page being displayed - */ - currentPage$ = new BehaviorSubject(this.options); - - /** - * Whether or not we should display a loading animation - * This is used to display a loading page when the user drops a bitstream onto a new page. The loading animation - * should stop once the bitstream has moved to the new page and the new page's response has loaded and contains the - * dropped object on top (see this.stopLoadingWhenFirstIs below) - */ - loading$: BehaviorSubject = new BehaviorSubject(false); - - /** - * List of subscriptions - */ - subs: Subscription[] = []; - - protected constructor(protected objectUpdatesService: ObjectUpdatesService, - protected elRef: ElementRef, - protected objectValuesPipe: ObjectValuesPipe, - protected paginationService: PaginationService - ) { - } - - /** - * Initialize the observables - */ - ngOnInit() { - this.initializeObjectsRD(); - this.initializeURL(); - this.initializeUpdates(); - this.initializePagination(); - } - - /** - * Overwrite this method to define how the list of objects is initialized and updated - */ - abstract initializeObjectsRD(): void; - - /** - * Overwrite this method to define how the URL is set - */ - abstract initializeURL(): void; - - /** - * Initialize the current pagination retrieval from the paginationService and push to the currentPage$ - */ - initializePagination() { - this.paginationService.getCurrentPagination(this.options.id, this.options).subscribe((currentPagination) => { - this.currentPage$.next(currentPagination); - }); - } - - /** - * Initialize the field-updates in the store - */ - initializeUpdates(): void { - this.objectsRD$.pipe( - getFirstSucceededRemoteData(), - paginatedListToArray(), - ).subscribe((objects: T[]) => { - this.objectUpdatesService.initialize(this.url, objects, new Date()); - }); - this.updates$ = this.objectsRD$.pipe( - getAllSucceededRemoteData(), - paginatedListToArray(), - switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, objects)) - ); - this.subs.push( - this.updates$.pipe( - map((fieldUpdates) => this.objectValuesPipe.transform(fieldUpdates)), - distinctUntilChanged(compareArraysUsingFieldUuids()) - ).subscribe((updateValues) => { - this.customOrder = updateValues.map((fieldUpdate) => fieldUpdate.field.uuid); - // We received new values, stop displaying a loading indicator if it's present - this.loading$.next(false); - }), - // Disable the pagination when objects are loading - this.loading$.subscribe((loading) => this.options.disabled = loading) - ); - } - - /** - * An object was moved, send updates to the dropObject EventEmitter - * When the object is dropped on a page within the pagination of this component, the object moves to the top of that - * page and the pagination automatically loads and switches the view to that page (this is done by calling the event's - * finish() method after sending patch requests to the REST API) - * @param event - */ - drop(event: CdkDragDrop) { - const dragIndex = event.previousIndex; - let dropIndex = event.currentIndex; - const dragPage = this.currentPage$.value.currentPage - 1; - let dropPage = this.currentPage$.value.currentPage - 1; - - // Check if the user is hovering over any of the pagination's pages at the time of dropping the object - const droppedOnElement = this.elRef.nativeElement.querySelector('.page-item:hover'); - if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent)) { - // The user is hovering over a page, fetch the page's number from the element - const droppedPage = Number(droppedOnElement.textContent); - if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) { - dropPage = droppedPage - 1; - dropIndex = 0; - } - } - - const isNewPage = dragPage !== dropPage; - // Move the object in the custom order array if the drop happened within the same page - // This allows us to instantly display a change in the order, instead of waiting for the REST API's response first - if (!isNewPage && dragIndex !== dropIndex) { - moveItemInArray(this.customOrder, dragIndex, dropIndex); - } - - const redirectPage = dropPage + 1; - const fromIndex = (dragPage * this.pageSize) + dragIndex; - const toIndex = (dropPage * this.pageSize) + dropIndex; - // Send out a drop event (and navigate to the new page) when the "from" and "to" indexes are different from each other - if (fromIndex !== toIndex) { - if (isNewPage) { - this.loading$.next(true); - } - this.dropObject.emit(Object.assign({ - fromIndex, - toIndex, - finish: () => { - if (isNewPage) { - this.paginationComponent.doPageChange(redirectPage); - } - } - })); - } - } - - /** - * unsub all subscriptions - */ - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - this.paginationService.clearPagination(this.options.id); - } -} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 56439dee436..a3c98f73468 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1990,6 +1990,10 @@ "item.edit.bitstreams.bundle.name": "BUNDLE: {{ name }}", + "item.edit.bitstreams.bundle.table.aria-label": "Bitstreams in the {{ bundle }} Bundle", + + "item.edit.bitstreams.bundle.tooltip": "You can move a bitstream to a different page by dropping it on the page number.", + "item.edit.bitstreams.discard-button": "Discard", "item.edit.bitstreams.edit.buttons.download": "Download", @@ -2002,8 +2006,20 @@ "item.edit.bitstreams.edit.buttons.undo": "Undo changes", + "item.edit.bitstreams.edit.live.cancel": "{{ bitstream }} was returned to position {{ toIndex }} and is no longer selected.", + + "item.edit.bitstreams.edit.live.clear": "{{ bitstream }} is no longer selected.", + + "item.edit.bitstreams.edit.live.loading": "Waiting for move to complete.", + + "item.edit.bitstreams.edit.live.select": "{{ bitstream }} is selected.", + + "item.edit.bitstreams.edit.live.move": "{{ bitstream }} is now in position {{ toIndex }}.", + "item.edit.bitstreams.empty": "This item doesn't contain any bitstreams. Click the upload button to create one.", + "item.edit.bitstreams.info-alert": "Bitstreams can be reordered within their bundles by holding the drag handle and moving the mouse. Alternatively, bitstreams can be moved using the keyboard in the following way: Select the bitstream by pressing enter when the bitstream's drag handle is in focus. Move the bitstream up or down using the arrow keys. Press enter again to confirm the current position of the bitstream.", + "item.edit.bitstreams.headers.actions": "Actions", "item.edit.bitstreams.headers.bundle": "Bundle",