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 1c13154bfa..b9af2a7d18 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 @@ -33,7 +33,6 @@ [item]="item" [columnSizes]="columnSizes" [isFirstTable]="isFirst" - (dropObject)="dropBitstream(bundle, $event)" aria-describedby="reorder-description"> 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 6ce7394473..a5549a6ba0 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 @@ -25,6 +25,10 @@ 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 { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; let comp: ItemBitstreamsComponent; let fixture: ComponentFixture; @@ -76,6 +80,7 @@ let objectCache: ObjectCacheService; let requestService: RequestService; let searchConfig: SearchConfigurationService; let bundleService: BundleDataService; +let itemBitstreamsService: ItemBitstreamsService; describe('ItemBitstreamsComponent', () => { beforeEach(waitForAsync(() => { @@ -147,6 +152,19 @@ describe('ItemBitstreamsComponent', () => { patch: createSuccessfulRemoteDataObject$({}), }); + itemBitstreamsService = jasmine.createSpyObj('itemBitstreamsService', { + getColumnSizes: 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) + ]), + getSelectedBitstream$: observableOf({}), + getInitialBundlesPaginationOptions: new PaginationComponentOptions(), + removeMarkedBitstreams: createSuccessfulRemoteDataObject$({}), + displayNotifications: undefined, + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective], @@ -161,6 +179,7 @@ describe('ItemBitstreamsComponent', () => { { provide: RequestService, useValue: requestService }, { provide: SearchConfigurationService, useValue: searchConfig }, { provide: BundleDataService, useValue: bundleService }, + { provide: ItemBitstreamsService, useValue: itemBitstreamsService }, ChangeDetectorRef ], schemas: [ NO_ERRORS_SCHEMA @@ -181,28 +200,8 @@ describe('ItemBitstreamsComponent', () => { comp.submit(); }); - it('should call removeMultiple on the bitstreamService for the marked field', () => { - expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]); - }); - - it('should not call removeMultiple on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]); - }); - }); - - describe('when dropBitstream is called', () => { - beforeEach((done) => { - comp.dropBitstream(bundle, { - fromIndex: 0, - toIndex: 50, - finish: () => { - done(); - } - }); - }); - - it('should send out a patch for the move operation', () => { - expect(bundleService.patch).toHaveBeenCalled(); + it('should call removeMarkedBitstreams on the itemBitstreamsService', () => { + expect(itemBitstreamsService.removeMarkedBitstreams).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 7757170f4e..4ced3dd649 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,4 +1,4 @@ -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 { map, switchMap, take } from 'rxjs/operators'; import { Observable, Subscription, zip as observableZip } from 'rxjs'; @@ -8,13 +8,11 @@ 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 } from '../../../shared/empty.util'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; import { getFirstSucceededRemoteData, getRemoteDataPayload, - getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list.model'; @@ -23,7 +21,6 @@ import { BundleDataService } from '../../../core/data/bundle-data.service'; import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; import { NoContent } from '../../../core/shared/NoContent.model'; -import { Operation } from 'fast-json-patch'; import { ItemBitstreamsService } from './item-bitstreams.service'; import { AlertType } from '../../../shared/alert/aletr-type'; @@ -88,13 +85,63 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme postItemInit(): void { const bundlesOptions = this.itemBitstreamsService.getInitialBundlesPaginationOptions(); - this. bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: bundlesOptions})).pipe( + 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 (event.target instanceof Element && event.target.tagName === 'BODY') { + this.itemBitstreamsService.clearSelection(); + } + } + /** * Initialize the notification messages prefix */ @@ -120,36 +167,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme }); } - /** - * 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( - getFirstCompletedRemoteData(), - ).subscribe((response: RemoteData) => { - this.zone.run(() => { - this.itemBitstreamsService.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.setStaleByHrefSubstring(bundle.self).pipe( - take(1) - ).subscribe(() => event.finish()); - }); - }); - } - }); - } - /** * 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 index 89ecfb518f..e144e81ec7 100644 --- 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 @@ -16,6 +16,12 @@ import { 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'; describe('ItemBitstreamsService', () => { let service: ItemBitstreamsService; @@ -23,21 +29,34 @@ describe('ItemBitstreamsService', () => { 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, ); }); 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 index 487df77b28..21dc415198 100644 --- 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 @@ -3,38 +3,277 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio 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 { isNotEmpty, hasValue } from '../../../shared/empty.util'; +import { isNotEmpty, 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 } from 'rxjs'; +import { Observable, zip as observableZip, BehaviorSubject } from 'rxjs'; import { NoContent } from '../../../core/shared/NoContent.model'; -import { take, switchMap, map } from 'rxjs/operators'; +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 { BitstreamTableEntry } from './item-edit-bitstream-bundle/item-edit-bitstream-bundle.component'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +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'; +/** + * 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, +} + +/** + * 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 selectedBitstream$: BehaviorSubject = new BehaviorSubject(null); + + protected isPerformingMoveRequest = 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 currently selected bitstream + */ + getSelectedBitstream$(): Observable { + return this.selectedBitstream$; + } + + /** + * Returns a copy of the currently selected bitstream + */ + getSelectedBitstream(): SelectedBitstreamTableEntry { + const selected = this.selectedBitstream$.getValue(); + + if (hasNoValue(selected)) { + return selected; + } + + return Object.assign({}, selected); + } + + hasSelectedBitstream(): boolean { + return hasValue(this.getSelectedBitstream()); + } + + /** + * Select the provided entry + */ + selectBitstreamEntry(entry: SelectedBitstreamTableEntry) { + if (entry !== this.selectedBitstream$.getValue()) { + this.announceSelect(entry.bitstream.name); + this.updateSelectedBitstream(entry); + } + } + + /** + * Makes the {@link selectedBitstream$} observable emit the provided {@link SelectedBitstreamTableEntry}. + * @protected + */ + protected updateSelectedBitstream(entry: SelectedBitstreamTableEntry) { + this.selectedBitstream$.next(entry); + } + + /** + * Unselects the selected bitstream. Does nothing if no bitstream is selected. + */ + clearSelection() { + const selected = this.getSelectedBitstream(); + + if (hasValue(selected)) { + this.updateSelectedBitstream(null); + this.announceClear(selected.bitstream.name); + } + } + + /** + * 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.isPerformingMoveRequest) { + return; + } + + this.selectedBitstream$.next(null); + + 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); + } else { + this.announceCancel(selected.bitstream.name, originalPosition); + this.performBitstreamMoveRequest(selected.bundle, currentPosition, originalPosition); + } + } + + /** + * 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.isPerformingMoveRequest) { + 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.updateSelectedBitstream(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.isPerformingMoveRequest) { + 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.updateSelectedBitstream(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.isPerformingMoveRequest) { + 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.isPerformingMoveRequest = true; + this.bundleService.patch(bundle, [moveOperation]).pipe( + getFirstCompletedRemoteData(), + tap((response: RemoteData) => this.displayNotifications('item.edit.bitstreams.notifications.move', [response])), + switchMap(() => this.requestService.setStaleByHrefSubstring(bundle.self)), + take(1), + ).subscribe(() => { + this.isPerformingMoveRequest = false; + finish?.(); + }); + } + /** * Returns the pagination options to use when fetching the bundles */ @@ -46,6 +285,10 @@ export class ItemBitstreamsService { }); } + /** + * 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 @@ -118,6 +361,10 @@ export class ItemBitstreamsService { ); } + /** + * 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); @@ -143,7 +390,7 @@ export class ItemBitstreamsService { // 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 strings itself. + // ID can not contain spaces itself. return this.stripWhiteSpace(name); } @@ -155,4 +402,48 @@ export class ItemBitstreamsService { // '/\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); + } } 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 d530fb38d1..06fb571ce4 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 @@ -5,7 +5,8 @@ [hidePagerWhenSinglePage]="true" [hidePaginationDetail]="true" [paginationOptions]="paginationOptions" - [collectionSize]="bitstreamsList.totalElements"> + [collectionSize]="bitstreamsList.totalElements" + [retainScrollPosition]="true"> -
- -
+ 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 1502ad2311..25274b8941 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 @@ -58,6 +58,7 @@ describe('ItemEditBitstreamBundleComponent', () => { currentPage: 1, pageSize: 9999 }), + getSelectedBitstream$: observableOf({}), }); beforeEach(waitForAsync(() => { 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 24319f4a83..7d2a519baf 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,4 +1,10 @@ -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'; @@ -8,7 +14,7 @@ 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 } from 'rxjs'; +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'; @@ -17,55 +23,17 @@ import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { getAllSucceededRemoteData, paginatedListToArray, - getFirstSucceededRemoteData } from '../../../../core/shared/operators'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; -import { map, take, filter } from 'rxjs/operators'; +import { map, take, filter, tap, pairwise } 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 } from '../item-bitstreams.service'; -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { hasValue } from '../../../../shared/empty.util'; -import { LiveRegionService } from '../../../../shared/live-region/live-region.service'; -import { TranslateService } from '@ngx-translate/core'; - -/** - * 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, -} +import { ItemBitstreamsService, BitstreamTableEntry, SelectedBitstreamTableEntry } 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', @@ -77,7 +45,7 @@ export interface BitstreamTableEntry { * 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; /** @@ -115,13 +83,6 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ @Input() isFirstTable = false; - /** - * 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 bootstrap sizes used for the Bundle Name column * This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit @@ -138,6 +99,11 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ bundleName: string; + /** + * The number of bitstreams in the bundle + */ + bundleSize: number; + /** * The bitstreams to show in the table */ @@ -146,7 +112,7 @@ export class ItemEditBitstreamBundleComponent implements OnInit { /** * The data to show in the table */ - tableEntries$: BehaviorSubject = new BehaviorSubject(null); + tableEntries$: BehaviorSubject = new BehaviorSubject([]); /** * The initial page options to use for fetching the bitstreams @@ -158,11 +124,6 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ currentPaginationOptions$: BehaviorSubject; - /** - * The available page size options - */ - pageSizeOptions: number[]; - /** * The currently selected page size */ @@ -178,6 +139,11 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ updates$: BehaviorSubject = new BehaviorSubject(null); + /** + * Array containing all subscriptions created by this component + */ + subscriptions: Subscription[] = []; + constructor( protected viewContainerRef: ViewContainerRef, @@ -187,8 +153,6 @@ export class ItemEditBitstreamBundleComponent implements OnInit { protected paginationService: PaginationService, protected requestService: RequestService, protected itemBitstreamsService: ItemBitstreamsService, - protected liveRegionService: LiveRegionService, - protected translateService: TranslateService, ) { } @@ -201,23 +165,27 @@ export class ItemEditBitstreamBundleComponent implements OnInit { this.initializePagination(); this.initializeBitstreams(); + this.initializeSelectionActions(); + } - // this.bitstreamsRD = this. + ngOnDestroy() { + this.subscriptions.forEach(sub => sub?.unsubscribe()); } protected initializePagination() { this.paginationOptions = this.itemBitstreamsService.getInitialBitstreamsPaginationOptions(this.bundleName); - this.pageSizeOptions = this.paginationOptions.pageSizeOptions; - this.currentPaginationOptions$ = new BehaviorSubject(this.paginationOptions); this.pageSize$ = new BehaviorSubject(this.paginationOptions.pageSize); - this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions) - .subscribe((pagination) => { - this.currentPaginationOptions$.next(pagination); - this.pageSize$.next(pagination.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() { @@ -233,26 +201,88 @@ export class ItemEditBitstreamBundleComponent implements OnInit { )) ); }), + getAllSucceededRemoteData(), + shareReplay(1), ); - this.bitstreamsRD$.pipe( - getFirstSucceededRemoteData(), - paginatedListToArray(), - ).subscribe((bitstreams) => { - this.objectUpdatesService.initialize(this.bundleUrl, bitstreams, new Date()); - }); + 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( - getAllSucceededRemoteData(), - paginatedListToArray(), - switchMap((bitstreams) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUrl, bitstreams)) - ).subscribe((updates) => this.updates$.next(updates)); + this.bitstreamsRD$.pipe( + paginatedListToArray(), + switchMap((bitstreams) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUrl, bitstreams)) + ).subscribe((updates) => this.updates$.next(updates)), - this.bitstreamsRD$.pipe( - getAllSucceededRemoteData(), - paginatedListToArray(), - map((bitstreams) => this.itemBitstreamsService.mapBitstreamsToTableEntries(bitstreams)), - ).subscribe((tableEntries) => this.tableEntries$.next(tableEntries)); + this.bitstreamsRD$.pipe( + paginatedListToArray(), + map((bitstreams) => this.itemBitstreamsService.mapBitstreamsToTableEntries(bitstreams)), + ).subscribe((tableEntries) => this.tableEntries$.next(tableEntries)), + ); + } + + protected initializeSelectionActions() { + this.subscriptions.push( + this.itemBitstreamsService.getSelectedBitstream$().pipe(pairwise()).subscribe( + ([previousSelection, currentSelection]) => + this.handleSelectedEntryChange(previousSelection, currentSelection)) + ); + } + + /** + * Handles a change in selected bitstream by changing the pagination if the change happened on a different page + * @param previousSelectedEntry The previously selected entry + * @param currentSelectedEntry The currently selected entry + * @protected + */ + protected handleSelectedEntryChange( + previousSelectedEntry: SelectedBitstreamTableEntry, + currentSelectedEntry: SelectedBitstreamTableEntry + ) { + if (hasValue(currentSelectedEntry) && currentSelectedEntry.bundle === this.bundle) { + // 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(currentSelectedEntry); + } + + // If the selection is cancelled or cleared, it is possible the selected bitstream is currently on a different page + // In that case we want to change the pagination to the place where the bitstream was returned to + if (hasNoValue(currentSelectedEntry) && hasValue(previousSelectedEntry) && previousSelectedEntry.bundle === this.bundle) { + this.redirectToOriginalPage(previousSelectedEntry); + } + } + + /** + * 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); + } } /** @@ -283,7 +313,18 @@ export class ItemEditBitstreamBundleComponent implements OnInit { this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, bitstream.uuid); } - getRowClass(update: FieldUpdate): string { + /** + * 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'; @@ -296,11 +337,19 @@ export class ItemEditBitstreamBundleComponent implements OnInit { } } + /** + * Changes the page size to the provided page size. + * @param pageSize + */ public doPageSizeChange(pageSize: number) { this.paginationComponent.doPageSizeChange(pageSize); } - dragStart(bitstreamName: string) { + /** + * 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), @@ -308,66 +357,170 @@ export class ItemEditBitstreamBundleComponent implements OnInit { ).subscribe(() => { this.dragTooltip.open(); }); - - const message = this.translateService.instant('item.edit.bitstreams.edit.live.drag', - { bitstream: bitstreamName }); - this.liveRegionService.addMessage(message); } - dragEnd(bitstreamName: string) { + /** + * Handles end of dragging by closing the tooltip. + */ + dragEnd() { this.dragTooltip.close(); - - const message = this.translateService.instant('item.edit.bitstreams.edit.live.drop', - { bitstream: bitstreamName }); - this.liveRegionService.addMessage(message); } - + /** + * 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.currentPaginationOptions$.value.currentPage - 1; - let dropPage = this.currentPaginationOptions$.value.currentPage - 1; + 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 - const droppedPage = Number(droppedOnElement.textContent); + let droppedPage = Number(droppedOnElement.textContent); if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) { - dropPage = droppedPage - 1; - dropIndex = 0; + 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 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) { - const currentEntries = [...this.tableEntries$.value]; - moveItemInArray(currentEntries, dragIndex, dropIndex); - this.tableEntries$.next(currentEntries); + const fromIndex = this.pageIndexToBundleIndex(dragIndex, dragPage); + const toIndex = this.pageIndexToBundleIndex(dropIndex, dropPage); + + if (fromIndex === toIndex) { + return; } - const pageSize = this.currentPaginationOptions$.value.pageSize; - const redirectPage = dropPage + 1; - const fromIndex = (dragPage * pageSize) + dragIndex; - const toIndex = (dropPage * 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); - } - } - })); + const selectedBitstream = this.tableEntries$.value[dragIndex]; + + const finish = () => { + this.itemBitstreamsService.announceMove(selectedBitstream.name, toIndex); + + if (dropPage !== this.getCurrentPage()) { + this.changeToPage(dropPage); + } + }; + + 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 bitstream + */ + select(bitstream: BitstreamTableEntry) { + 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/shared/live-region/live-region.service.stub.ts b/src/app/shared/live-region/live-region.service.stub.ts new file mode 100644 index 0000000000..4f10b46a4c --- /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/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ab6f3792ca..519189ed69 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1950,9 +1950,13 @@ "item.edit.bitstreams.edit.buttons.undo": "Undo changes", - "item.edit.bitstreams.edit.live.drag": "{{ bitstream }} grabbed", + "item.edit.bitstreams.edit.live.cancel": "{{ bitstream }} was returned to position {{ toIndex }} and is no longer selected.", - "item.edit.bitstreams.edit.live.drop": "{{ bitstream }} dropped", + "item.edit.bitstreams.edit.live.clear": "{{ bitstream }} is no longer selected.", + + "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.",
-
- +
+
{{ entry.name }}