118223: Implement bitstream reordering with keyboard

This commit is contained in:
Andreas Awouters
2024-09-18 11:57:41 +02:00
parent 1f909dc6ea
commit 181ea6d7c9
10 changed files with 727 additions and 203 deletions

View File

@@ -33,7 +33,6 @@
[item]="item"
[columnSizes]="columnSizes"
[isFirstTable]="isFirst"
(dropObject)="dropBitstream(bundle, $event)"
aria-describedby="reorder-description">
</ds-item-edit-bitstream-bundle>
</div>

View File

@@ -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<ItemBitstreamsComponent>;
@@ -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();
});
});

View File

@@ -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<Bundle>) => 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<Bundle>) => {
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

View File

@@ -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,
);
});

View File

@@ -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<BitstreamFormat>,
/**
* 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<SelectedBitstreamTableEntry> = 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<SelectedBitstreamTableEntry> {
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<Bundle>) => 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);
}
}

View File

@@ -5,7 +5,8 @@
[hidePagerWhenSinglePage]="true"
[hidePaginationDetail]="true"
[paginationOptions]="paginationOptions"
[collectionSize]="bitstreamsList.totalElements">
[collectionSize]="bitstreamsList.totalElements"
[retainScrollPosition]="true">
<ng-container *ngIf="(updates$ | async) as updates">
<table class="table" [class.mt-n1]="!isFirstTable"
@@ -43,18 +44,26 @@
title="{{'item.edit.bitstreams.bundle.edit.buttons.upload' | translate}}">
<i class="fas fa-upload fa-fw"></i>
</button>
<div ngbDropdown #paginationControls="ngbDropdown" class="btn-group float-right btn-sm p-0" placement="bottom-right">
<button class="btn btn-outline-secondary" id="paginationControls" ngbDropdownToggle [title]="'pagination.options.description' | translate" [attr.aria-label]="'pagination.options.description' | translate" aria-haspopup="true" aria-expanded="false">
<div ngbDropdown #paginationControls="ngbDropdown" class="btn-group float-right btn-sm p-0"
placement="bottom-right">
<button class="btn btn-outline-secondary" id="paginationControls" ngbDropdownToggle
[title]="'pagination.options.description' | translate"
[attr.aria-label]="'pagination.options.description' | translate" aria-haspopup="true"
aria-expanded="false">
<i class="fas fa-cog" aria-hidden="true"></i>
</button>
<ul id="paginationControlsDropdownMenu" aria-labelledby="paginationControls" role="menu" ngbDropdownMenu>
<ul id="paginationControlsDropdownMenu" aria-labelledby="paginationControls" role="menu"
ngbDropdownMenu>
<li role="menuitem">
<span class="dropdown-header" id="pagination-control_results-per-page" role="heading">{{ 'pagination.results-per-page' | translate}}</span>
<span class="dropdown-header" id="pagination-control_results-per-page"
role="heading">{{ 'pagination.results-per-page' | translate}}</span>
<ul aria-labelledby="pagination-control_results-per-page" class="list-unstyled" role="listbox">
<li *ngFor="let size of pageSizeOptions" role="option" [attr.aria-selected]="size === (pageSize$ | async)">
<li *ngFor="let size of paginationOptions.pageSizeOptions" role="option"
[attr.aria-selected]="size === (pageSize$ | async)">
<button (click)="doPageSizeChange(size)" class="dropdown-item">
<i [ngClass]="{'invisible': size !== (pageSize$ | async) }" class="fas fa-check" aria-hidden="true"></i> {{size}}
<i [ngClass]="{'invisible': size !== (pageSize$ | async) }" class="fas fa-check"
aria-hidden="true"></i> {{size}}
</button>
</li>
</ul>
@@ -67,13 +76,15 @@
</tr>
<ng-container *ngFor="let entry of (tableEntries$ | async)">
<tr *ngIf="updates[entry.id] as update" [ngClass]="getRowClass(update)" class="bitstream-row" cdkDrag
(cdkDragStarted)="dragStart(entry.name)" (cdkDragEnded)="dragEnd(entry.name)">
<tr *ngIf="updates[entry.id] as update" [ngClass]="getRowClass(update, entry)" class="bitstream-row" cdkDrag
(cdkDragStarted)="dragStart()" (cdkDragEnded)="dragEnd()">
<th class="bitstream-name row-element {{ columnSizes.columns[0].buildClasses() }}"
scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name">
<div class="drag-handle text-muted float-left p-1 mr-2" tabindex="0" cdkDragHandle>
<i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
<div class="drag-handle text-muted float-left p-1 mr-2" tabindex="0" cdkDragHandle
(keyup.enter)="select(entry)" (keyup.space)="select(entry)" (click)="select(entry)">
<i class="fas fa-grip-vertical fa-fw"
[title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
</div>
{{ entry.name }}
</th>

View File

@@ -58,6 +58,7 @@ describe('ItemEditBitstreamBundleComponent', () => {
currentPage: 1,
pageSize: 9999
}),
getSelectedBitstream$: observableOf({}),
});
beforeEach(waitForAsync(() => {

View File

@@ -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<BitstreamFormat>,
/**
* 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<any> = new EventEmitter<any>();
/**
* 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<BitstreamTableEntry[]> = new BehaviorSubject(null);
tableEntries$: BehaviorSubject<BitstreamTableEntry[]> = new BehaviorSubject([]);
/**
* The initial page options to use for fetching the bitstreams
@@ -158,11 +124,6 @@ export class ItemEditBitstreamBundleComponent implements OnInit {
*/
currentPaginationOptions$: BehaviorSubject<PaginationComponentOptions>;
/**
* The available page size options
*/
pageSizeOptions: number[];
/**
* The currently selected page size
*/
@@ -178,6 +139,11 @@ export class ItemEditBitstreamBundleComponent implements OnInit {
*/
updates$: BehaviorSubject<FieldUpdates> = 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<any>) {
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);
}
}

View File

@@ -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');
}

View File

@@ -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.",