Files
dspace-angular/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts
2024-10-17 08:26:39 +02:00

548 lines
18 KiB
TypeScript

import {
Component,
Input,
OnInit,
ViewChild,
ViewContainerRef, OnDestroy,
} from '@angular/core';
import { Bundle } from '../../../../core/shared/bundle.model';
import { Item } from '../../../../core/shared/item.model';
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
import { getItemPageRoute } from '../../../item-page-routing-paths';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { RemoteData } from 'src/app/core/data/remote-data';
import { PaginatedList } from 'src/app/core/data/paginated-list.model';
import { Bitstream } from 'src/app/core/shared/bitstream.model';
import { Observable, BehaviorSubject, switchMap, shareReplay, Subscription } from 'rxjs';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model';
import { PaginatedSearchOptions } from '../../../../shared/search/models/paginated-search-options.model';
import { BundleDataService } from '../../../../core/data/bundle-data.service';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import {
getAllSucceededRemoteData,
paginatedListToArray,
} from '../../../../core/shared/operators';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { map, take, filter, tap } from 'rxjs/operators';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { RequestService } from '../../../../core/data/request.service';
import {
ItemBitstreamsService,
BitstreamTableEntry,
SelectedBitstreamTableEntry,
MOVE_KEY, SelectionAction
} from '../item-bitstreams.service';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { hasValue, hasNoValue } from '../../../../shared/empty.util';
@Component({
selector: 'ds-item-edit-bitstream-bundle',
styleUrls: ['../item-bitstreams.component.scss', './item-edit-bitstream-bundle.component.scss'],
templateUrl: './item-edit-bitstream-bundle.component.html',
})
/**
* Component that displays a single bundle of an item on the item bitstreams edit page
* Creates an embedded view of the contents. This is to ensure the table structure won't break.
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element)
*/
export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy {
protected readonly FieldChangeType = FieldChangeType;
/**
* The view on the bundle information and bitstreams
*/
@ViewChild('bundleView', {static: true}) bundleView;
/**
* The view on the pagination component
*/
@ViewChild(PaginationComponent) paginationComponent: PaginationComponent;
/**
* The view on the drag tooltip
*/
@ViewChild('dragTooltip') dragTooltip;
/**
* The bundle to display bitstreams for
*/
@Input() bundle: Bundle;
/**
* The item the bundle belongs to
*/
@Input() item: Item;
/**
* The bootstrap sizes used for the columns within this table
*/
@Input() columnSizes: ResponsiveTableSizes;
/**
* Whether this is the first in a series of bundle tables
*/
@Input() isFirstTable = false;
/**
* The bootstrap sizes used for the Bundle Name column
* This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit
*/
bundleNameColumn: ResponsiveColumnSizes;
/**
* Route to the item's page
*/
itemPageRoute: string;
/**
* The name of the bundle
*/
bundleName: string;
/**
* The number of bitstreams in the bundle
*/
bundleSize: number;
/**
* The bitstreams to show in the table
*/
bitstreamsRD$: Observable<RemoteData<PaginatedList<Bitstream>>>;
/**
* The data to show in the table
*/
tableEntries$: BehaviorSubject<BitstreamTableEntry[]> = new BehaviorSubject([]);
/**
* The initial page options to use for fetching the bitstreams
*/
paginationOptions: PaginationComponentOptions;
/**
* The current page options
*/
currentPaginationOptions$: BehaviorSubject<PaginationComponentOptions>;
/**
* The currently selected page size
*/
pageSize$: BehaviorSubject<number>;
/**
* The self url of the bundle, also used when retrieving fieldUpdates
*/
bundleUrl: string;
/**
* The updates to the current bitstreams
*/
updates$: BehaviorSubject<FieldUpdates> = new BehaviorSubject(null);
/**
* Array containing all subscriptions created by this component
*/
subscriptions: Subscription[] = [];
constructor(
protected viewContainerRef: ViewContainerRef,
public dsoNameService: DSONameService,
protected bundleService: BundleDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected paginationService: PaginationService,
protected requestService: RequestService,
protected itemBitstreamsService: ItemBitstreamsService,
) {
}
ngOnInit(): void {
this.bundleNameColumn = this.columnSizes.combineColumns(0, 2);
this.viewContainerRef.createEmbeddedView(this.bundleView);
this.itemPageRoute = getItemPageRoute(this.item);
this.bundleName = this.dsoNameService.getName(this.bundle);
this.bundleUrl = this.bundle.self;
this.initializePagination();
this.initializeBitstreams();
this.initializeSelectionActions();
}
ngOnDestroy() {
this.viewContainerRef.clear();
this.subscriptions.forEach(sub => sub?.unsubscribe());
}
protected initializePagination() {
this.paginationOptions = this.itemBitstreamsService.getInitialBitstreamsPaginationOptions(this.bundleName);
this.currentPaginationOptions$ = new BehaviorSubject(this.paginationOptions);
this.pageSize$ = new BehaviorSubject(this.paginationOptions.pageSize);
this.subscriptions.push(
this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions)
.subscribe((pagination) => {
this.currentPaginationOptions$.next(pagination);
this.pageSize$.next(pagination.pageSize);
})
);
}
protected initializeBitstreams() {
this.bitstreamsRD$ = this.currentPaginationOptions$.pipe(
switchMap((page: PaginationComponentOptions) => {
const paginatedOptions = new PaginatedSearchOptions({ pagination: Object.assign({}, page) });
return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe(
switchMap((href) => this.requestService.hasByHref$(href)),
switchMap(() => this.bundleService.getBitstreams(
this.bundle.id,
paginatedOptions,
followLink('format')
))
);
}),
getAllSucceededRemoteData(),
shareReplay(1),
);
this.subscriptions.push(
this.bitstreamsRD$.pipe(
take(1),
tap(bitstreamsRD => this.bundleSize = bitstreamsRD.payload.totalElements),
paginatedListToArray(),
).subscribe((bitstreams) => {
this.objectUpdatesService.initialize(this.bundleUrl, bitstreams, new Date());
}),
this.bitstreamsRD$.pipe(
paginatedListToArray(),
switchMap((bitstreams) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUrl, bitstreams))
).subscribe((updates) => this.updates$.next(updates)),
this.bitstreamsRD$.pipe(
paginatedListToArray(),
map((bitstreams) => this.itemBitstreamsService.mapBitstreamsToTableEntries(bitstreams)),
).subscribe((tableEntries) => this.tableEntries$.next(tableEntries)),
);
}
protected initializeSelectionActions() {
this.subscriptions.push(
this.itemBitstreamsService.getSelectionAction$().subscribe(
selectionAction => this.handleSelectionAction(selectionAction))
);
}
/**
* Handles a change in selected bitstream by changing the pagination if the change happened on a different page
* @param selectionAction
*/
handleSelectionAction(selectionAction: SelectionAction) {
if (hasNoValue(selectionAction) || selectionAction.selectedEntry.bundle !== this.bundle) {
return;
}
if (selectionAction.action === 'Moved') {
// If the currently selected bitstream belongs to this bundle, it has possibly moved to a different page.
// In that case we want to change the pagination to the new page.
this.redirectToCurrentPage(selectionAction.selectedEntry);
}
if (selectionAction.action === 'Cancelled') {
// If the selection is cancelled (and returned to its original position), it is possible the previously selected
// bitstream is returned to a different page. In that case we want to change the pagination to the place where
// the bitstream was returned to.
this.redirectToOriginalPage(selectionAction.selectedEntry);
}
if (selectionAction.action === 'Cleared') {
// If the selection is cleared, it is possible the previously selected bitstream is on a different page. In that
// case we want to change the pagination to the place where the bitstream is.
this.redirectToCurrentPage(selectionAction.selectedEntry);
}
}
/**
* Redirect the user to the current page of the provided bitstream if it is on a different page.
* @param bitstreamEntry The entry that the current position will be taken from to determine the page to move to
* @protected
*/
protected redirectToCurrentPage(bitstreamEntry: SelectedBitstreamTableEntry) {
const currentPage = this.getCurrentPage();
const selectedEntryPage = this.bundleIndexToPage(bitstreamEntry.currentPosition);
if (currentPage !== selectedEntryPage) {
this.changeToPage(selectedEntryPage);
}
}
/**
* Redirect the user to the original page of the provided bitstream if it is on a different page.
* @param bitstreamEntry The entry that the original position will be taken from to determine the page to move to
* @protected
*/
protected redirectToOriginalPage(bitstreamEntry: SelectedBitstreamTableEntry) {
const currentPage = this.getCurrentPage();
const originPage = this.bundleIndexToPage(bitstreamEntry.originalPosition);
if (currentPage !== originPage) {
this.changeToPage(originPage);
}
}
/**
* Check if a user should be allowed to remove this field
*/
canRemove(fieldUpdate: FieldUpdate): boolean {
return fieldUpdate.changeType !== FieldChangeType.REMOVE;
}
/**
* Check if a user should be allowed to cancel the update to this field
*/
canUndo(fieldUpdate: FieldUpdate): boolean {
return fieldUpdate.changeType >= 0;
}
/**
* Sends a new remove update for this field to the object updates service
*/
remove(bitstream: Bitstream): void {
this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, bitstream);
}
/**
* Cancels the current update for this field in the object updates service
*/
undo(bitstream: Bitstream): void {
this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, bitstream.uuid);
}
/**
* Returns the css class for a table row depending on the state of the table entry.
* @param update
* @param bitstream
*/
getRowClass(update: FieldUpdate, bitstream: BitstreamTableEntry): string {
const selected = this.itemBitstreamsService.getSelectedBitstream();
if (hasValue(selected) && bitstream.id === selected.bitstream.id) {
return 'table-info';
}
switch (update.changeType) {
case FieldChangeType.UPDATE:
return 'table-warning';
case FieldChangeType.ADD:
return 'table-success';
case FieldChangeType.REMOVE:
return 'table-danger';
default:
return 'bg-white';
}
}
/**
* Changes the page size to the provided page size.
* @param pageSize
*/
public doPageSizeChange(pageSize: number) {
this.paginationComponent.doPageSizeChange(pageSize);
}
/**
* Handles start of dragging by opening the tooltip mentioning that it is possible to drag a bitstream to a different
* page by dropping it on the page number, only if there are multiple pages.
*/
dragStart() {
// Only open the drag tooltip when there are multiple pages
this.paginationComponent.shouldShowBottomPager.pipe(
take(1),
filter((hasMultiplePages) => hasMultiplePages),
).subscribe(() => {
this.dragTooltip.open();
});
}
/**
* Handles end of dragging by closing the tooltip.
*/
dragEnd() {
this.dragTooltip.close();
}
/**
* Handles dropping by calculation the target position, and changing the page if the bitstream was dropped on a
* different page.
* @param event
*/
drop(event: CdkDragDrop<any>) {
const dragIndex = event.previousIndex;
let dropIndex = event.currentIndex;
const dragPage = this.getCurrentPage();
let dropPage = this.getCurrentPage();
// Check if the user is hovering over any of the pagination's pages at the time of dropping the object
const droppedOnElement = document.elementFromPoint(event.dropPoint.x, event.dropPoint.y);
if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent) && droppedOnElement.classList.contains('page-link')) {
// The user is hovering over a page, fetch the page's number from the element
let droppedPage = Number(droppedOnElement.textContent);
if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) {
droppedPage -= 1;
if (droppedPage !== dragPage) {
dropPage = droppedPage;
if (dropPage > dragPage) {
// When moving to later page, place bitstream at the top
dropIndex = 0;
} else {
// When moving to earlier page, place bitstream at the bottom
dropIndex = this.getCurrentPageSize() - 1;
}
}
}
}
const fromIndex = this.pageIndexToBundleIndex(dragIndex, dragPage);
const toIndex = this.pageIndexToBundleIndex(dropIndex, dropPage);
if (fromIndex === toIndex) {
return;
}
const selectedBitstream = this.tableEntries$.value[dragIndex];
const finish = () => {
this.itemBitstreamsService.announceMove(selectedBitstream.name, toIndex);
if (dropPage !== this.getCurrentPage()) {
this.changeToPage(dropPage);
}
this.itemBitstreamsService.displaySuccessNotification(MOVE_KEY);
};
this.itemBitstreamsService.performBitstreamMoveRequest(this.bundle, fromIndex, toIndex, finish);
}
/**
* Handles a select action for the provided bitstream entry.
* If the selected bitstream is currently selected, the selection is cleared.
* If no, or a different bitstream, is selected, the provided bitstream becomes the selected bitstream.
* @param event The event that triggered the select action
* @param bitstream The bitstream that is the target of the select action
*/
select(event: UIEvent, bitstream: BitstreamTableEntry) {
event.preventDefault();
if (event instanceof KeyboardEvent && event.repeat) {
// Don't handle hold events, otherwise it would change rapidly between being selected and unselected
return;
}
const selectedBitstream = this.itemBitstreamsService.getSelectedBitstream();
if (hasValue(selectedBitstream) && selectedBitstream.bitstream === bitstream) {
this.itemBitstreamsService.cancelSelection();
} else {
const selectionObject = this.createBitstreamSelectionObject(bitstream);
if (hasNoValue(selectionObject)) {
console.warn('Failed to create selection object');
return;
}
this.itemBitstreamsService.selectBitstreamEntry(selectionObject);
}
}
/**
* Creates a {@link SelectedBitstreamTableEntry} from the provided {@link BitstreamTableEntry} so it can be given
* to the {@link ItemBitstreamsService} to select the table entry.
* @param bitstream The table entry to create the selection object from.
* @protected
*/
protected createBitstreamSelectionObject(bitstream: BitstreamTableEntry): SelectedBitstreamTableEntry {
const pageIndex = this.findBitstreamPageIndex(bitstream);
if (pageIndex === -1) {
return null;
}
const position = this.pageIndexToBundleIndex(pageIndex, this.getCurrentPage());
return {
bitstream: bitstream,
bundle: this.bundle,
bundleSize: this.bundleSize,
currentPosition: position,
originalPosition: position,
};
}
/**
* Returns the index of the provided {@link BitstreamTableEntry} relative to the current page
* If the current page size is 10, it will return a value from 0 to 9 (inclusive)
* Returns -1 if the provided bitstream could not be found
* @protected
*/
protected findBitstreamPageIndex(bitstream: BitstreamTableEntry): number {
const entries = this.tableEntries$.value;
return entries.findIndex(entry => entry === bitstream);
}
/**
* Returns the current zero-indexed page
* @protected
*/
protected getCurrentPage(): number {
// The pagination component uses one-based numbering while zero-based numbering is more convenient for calculations
return this.currentPaginationOptions$.value.currentPage - 1;
}
/**
* Returns the current page size
* @protected
*/
protected getCurrentPageSize(): number {
return this.currentPaginationOptions$.value.pageSize;
}
/**
* Converts an index relative to the page to an index relative to the bundle
* @param index The index relative to the page
* @param page The zero-indexed page number
* @protected
*/
protected pageIndexToBundleIndex(index: number, page: number) {
return page * this.getCurrentPageSize() + index;
}
/**
* Calculates the zero-indexed page number from the index relative to the bundle
* @param index The index relative to the bundle
* @protected
*/
protected bundleIndexToPage(index: number) {
return Math.floor(index / this.getCurrentPageSize());
}
/**
* Change the pagination for this bundle to the provided zero-indexed page
* @param page The zero-indexed page to change to
* @protected
*/
protected changeToPage(page: number) {
// Increments page by one because zero-indexing is way easier for calculations but the pagination component
// uses one-indexing.
this.paginationComponent.doPageChange(page + 1);
}
}