Files
dspace-angular/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts
Tim Donohue a078932857 Merge pull request #3143 from atmire/w2p-115284_add-support-for-non-repeatable-relationships_dspace-7-x
[Port dspace-7_x] Add support for non repeatable relationships
2025-01-06 11:59:13 -06:00

604 lines
22 KiB
TypeScript

import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
EMPTY,
from as observableFrom,
Observable,
Subscription
} from 'rxjs';
import {
RelationshipIdentifiable
} from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipDataService } from '../../../../core/data/relationship-data.service';
import { Item } from '../../../../core/shared/item.model';
import {
defaultIfEmpty,
map,
mergeMap,
startWith,
switchMap,
take,
tap,
toArray,
concatMap
} from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import {
RelationshipType
} from '../../../../core/shared/item-relationships/relationship-type.model';
import {
getAllSucceededRemoteData,
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
} from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import {
DsDynamicLookupRelationModalComponent
} from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
import {
RelationshipOptions
} from '../../../../shared/form/builder/models/relationship-options.model';
import {
SelectableListService
} from '../../../../shared/object-list/selectable-list/selectable-list.service';
import {
ItemSearchResult
} from '../../../../shared/object-collection/shared/item-search-result.model';
import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
import {
PaginationComponentOptions
} from '../../../../shared/pagination/pagination-component-options.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model';
import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface';
import { itemLinksToFollow } from '../../../../shared/utils/relation-query.utils';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
import { RequestParam } from '../../../../core/cache/models/request-param.model';
@Component({
selector: 'ds-edit-relationship-list',
styleUrls: ['./edit-relationship-list.component.scss'],
templateUrl: './edit-relationship-list.component.html',
})
/**
* A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items
*/
export class EditRelationshipListComponent implements OnInit, OnDestroy {
/**
* The item to display related items for
*/
@Input() item: Item;
@Input() itemType: ItemType;
/**
* The URL to the current page
* Used to fetch updates for the current item from the store
*/
@Input() url: string;
/**
* The label of the relationship-type we're rendering a list for
*/
@Input() relationshipType: RelationshipType;
/**
* If updated information has changed
*/
@Input() hasChanges!: Observable<boolean>;
/**
* The event emmiter to submit the new information
*/
@Output() submitModal: EventEmitter<void> = new EventEmitter();
/**
* Observable that emits the left and right item type of {@link relationshipType} simultaneously.
*/
private relationshipLeftAndRightType$: Observable<[ItemType, ItemType]>;
/**
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/
@Input() currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined);
relatedEntityType$: Observable<ItemType>;
/**
* The translation key for the entity type
*/
relationshipMessageKey$: Observable<string>;
/**
* The list ID to save selected entities under
*/
listId: string;
/**
* The FieldUpdates for the relationships in question
*/
updates$: BehaviorSubject<FieldUpdates> = new BehaviorSubject(undefined);
/**
* The RemoteData for the relationships
*/
relationshipsRd$: BehaviorSubject<RemoteData<PaginatedList<Relationship>>> = new BehaviorSubject(undefined);
/**
* Whether the current page is the last page
*/
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* Whether we're loading
*/
loading$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* The number of added fields that haven't been saved yet
*/
nbAddedFields$: BehaviorSubject<number> = new BehaviorSubject(0);
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
/**
* The pagination config
*/
paginationConfig: PaginationComponentOptions;
/**
* A reference to the lookup window
*/
modalRef: NgbModalRef;
/**
* Determines whether to ask for the embedded item thumbnail.
*/
fetchThumbnail: boolean;
constructor(
protected objectUpdatesService: ObjectUpdatesService,
protected linkService: LinkService,
protected relationshipService: RelationshipDataService,
protected modalService: NgbModal,
protected paginationService: PaginationService,
protected selectableListService: SelectableListService,
protected editItemRelationshipsService: EditItemRelationshipsService,
@Inject(APP_CONFIG) protected appConfig: AppConfig
) {
this.fetchThumbnail = this.appConfig.browseBy.showThumbnails;
}
/**
* Get the i18n message key for this relationship type
*/
public getRelationshipMessageKey(): Observable<string> {
return observableCombineLatest([
this.getLabel(),
this.relatedEntityType$,
]).pipe(
map(([label, relatedEntityType]) => {
if (hasValue(label) && label.indexOf('is') > -1 && label.indexOf('Of') > -1) {
const relationshipLabel = `${label.substring(2, label.indexOf('Of'))}`;
if (relationshipLabel !== relatedEntityType.label) {
return `relationships.is${relationshipLabel}Of.${relatedEntityType.label}`;
} else {
return `relationships.is${relationshipLabel}Of`;
}
} else {
return label;
}
}),
);
}
/**
* Get the relevant label for this relationship type
*/
private getLabel(): Observable<string> {
return this.currentItemIsLeftItem$.pipe(
map((currentItemIsLeftItem) => {
if (currentItemIsLeftItem) {
return this.relationshipType.leftwardType;
} else {
return this.relationshipType.rightwardType;
}
})
);
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
}
/**
* Check whether the current entity can have multiple relationships of this type
* This is based on the max cardinality of the relationship
* @private
*/
private isRepeatable(): boolean {
const isLeft = this.currentItemIsLeftItem$.getValue();
if (isLeft) {
const leftMaxCardinality = this.relationshipType.leftMaxCardinality;
return hasNoValue(leftMaxCardinality) || leftMaxCardinality > 1;
} else {
const rightMaxCardinality = this.relationshipType.rightMaxCardinality;
return hasNoValue(rightMaxCardinality) || rightMaxCardinality > 1;
}
}
/**
* Open the dynamic lookup modal to search for items to add as relationships
*/
openLookup() {
this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, {
size: 'lg'
});
const modalComp: DsDynamicLookupRelationModalComponent = this.modalRef.componentInstance;
modalComp.repeatable = true;
modalComp.isEditRelationship = true;
modalComp.listId = this.listId;
modalComp.item = this.item;
modalComp.relationshipType = this.relationshipType;
modalComp.currentItemIsLeftItem$ = this.currentItemIsLeftItem$;
modalComp.toAdd = [];
modalComp.toRemove = [];
modalComp.isPending = false;
modalComp.repeatable = this.isRepeatable();
modalComp.hiddenQuery = '-search.resourceid:' + this.item.uuid;
this.item.owningCollection.pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((collection: Collection) => {
modalComp.collection = collection;
});
modalComp.select = (...selectableObjects: ItemSearchResult[]) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
const foundIndex = modalComp.toRemove.findIndex((itemSearchResult: ItemSearchResult) => itemSearchResult.indexableObject.uuid === relatedItem.uuid);
if (foundIndex !== -1) {
modalComp.toRemove.splice(foundIndex,1);
} else {
this.getRelationFromId(relatedItem)
.subscribe((relationship: Relationship) => {
if (!relationship ) {
modalComp.toAdd.push(searchResult);
} else {
const foundIndexRemove = modalComp.toRemove.findIndex( el => el.indexableObject.uuid === relatedItem.uuid);
if (foundIndexRemove !== -1) {
modalComp.toRemove.splice(foundIndexRemove,1);
}
}
this.loading$.next(isNotEmpty(modalComp.toAdd) || isNotEmpty(modalComp.toRemove));
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
});
}
});
};
modalComp.deselect = (...selectableObjects: ItemSearchResult[]) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
const foundIndex = modalComp.toAdd.findIndex( el => el.indexableObject.uuid === relatedItem.uuid);
if (foundIndex !== -1) {
modalComp.toAdd.splice(foundIndex,1);
} else {
modalComp.toRemove.push(searchResult);
}
this.loading$.next(isNotEmpty(modalComp.toAdd) || isNotEmpty(modalComp.toRemove));
});
};
modalComp.submitEv = () => {
modalComp.isPending = true;
const isLeft = this.currentItemIsLeftItem$.getValue();
const addOperations = modalComp.toAdd.map((searchResult: ItemSearchResult) => ({ type: 'add', searchResult }));
const removeOperations = modalComp.toRemove.map((searchResult: ItemSearchResult) => ({ type: 'remove', searchResult }));
observableFrom([...addOperations, ...removeOperations]).pipe(
concatMap(({ type, searchResult }: { type: string, searchResult: ItemSearchResult }) => {
const relatedItem: Item = searchResult.indexableObject;
if (type === 'add') {
return this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe(
switchMap((nameVariant) => {
const update = {
uuid: `${this.relationshipType.id}-${relatedItem.uuid}`,
nameVariant,
type: this.relationshipType,
originalIsLeft: isLeft,
originalItem: this.item,
relatedItem,
} as RelationshipIdentifiable;
return this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
}),
take(1)
);
} else if (type === 'remove') {
return this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe(
switchMap((nameVariant) => {
return this.getRelationFromId(searchResult.indexableObject).pipe(
map( (relationship: Relationship) => {
const update = {
uuid: relationship.id,
nameVariant,
type: this.relationshipType,
originalIsLeft: isLeft,
originalItem: this.item,
relatedItem,
relationship,
} as RelationshipIdentifiable;
return this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update);
}),
);
}),
take(1)
);
} else {
return EMPTY;
}
}),
toArray(),
).subscribe({
complete: () => {
this.editItemRelationshipsService.submit(this.item, this.url);
this.submitModal.emit();
}
});
};
modalComp.discardEv = () => {
modalComp.toAdd.forEach( (searchResult) => {
this.selectableListService.deselectSingle(this.listId,searchResult);
});
modalComp.toRemove.forEach( (searchResult) => {
this.selectableListService.selectSingle(this.listId,searchResult);
});
modalComp.toAdd = [];
modalComp.toRemove = [];
this.loading$.next(false);
};
modalComp.closeEv = () => {
this.loading$.next(false);
};
this.relatedEntityType$
.pipe(take(1))
.subscribe((relatedEntityType) => {
modalComp.relationshipOptions = Object.assign(
new RelationshipOptions(), {
relationshipType: relatedEntityType.label,
searchConfiguration: relatedEntityType.label.toLowerCase(),
nameVariants: 'true',
}
);
});
this.selectableListService.deselectAll(this.listId);
}
getRelationFromId(relatedItem) {
const relationshipLabel = this.currentItemIsLeftItem$.getValue() ? this.relationshipType.leftwardType : this.relationshipType.rightwardType;
return this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, relationshipLabel ,[relatedItem.id] ).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map( (res: PaginatedList<Relationship>) => res.page[0])
);
}
ngOnInit(): void {
// store the left and right type of the relationship in a single observable
this.relationshipLeftAndRightType$ = observableCombineLatest([
this.relationshipType.leftType,
this.relationshipType.rightType,
].map((type) => type.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
))) as Observable<[ItemType, ItemType]>;
this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
if (leftType.uuid !== this.itemType.uuid) {
return leftType;
} else {
return rightType;
}
}),
hasValueOperator(),
);
this.relatedEntityType$.pipe(
take(1)
).subscribe(
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
);
this.relationshipMessageKey$ = this.getRelationshipMessageKey();
// initialize the pagination options
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = `er${this.relationshipType.id}`;
this.paginationConfig.pageSize = 5;
this.paginationConfig.currentPage = 1;
// get the pagination params from the route
const currentPagination$ = this.paginationService.getCurrentPagination(
this.paginationConfig.id,
this.paginationConfig
).pipe(
tap(() => this.loading$.next(true))
);
// this adds thumbnail images when required by configuration
let linksToFollow: FollowLinkConfig<Relationship>[] = itemLinksToFollow(this.fetchThumbnail, this.appConfig.item.showAccessStatuses);
this.subs.push(
observableCombineLatest([
currentPagination$,
this.currentItemIsLeftItem$,
this.relatedEntityType$,
]).pipe(
switchMap(([currentPagination, currentItemIsLeftItem, relatedEntityType]: [PaginationComponentOptions, boolean, ItemType]) => {
// get the relationships for the current page, item, relationship type and related entity type
return this.relationshipService.getItemRelationshipsByLabel(
this.item,
currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType,
{
elementsPerPage: currentPagination.pageSize,
currentPage: currentPagination.currentPage,
searchParams: [
new RequestParam('relatedEntityType', relatedEntityType.label),
],
},
true,
true,
...linksToFollow
);
}),
tap((rd: RemoteData<PaginatedList<Relationship>>) => {
this.relationshipsRd$.next(rd);
}),
getAllSucceededRemoteData(),
getRemoteDataPayload(),
).subscribe((relationshipPaginatedList: PaginatedList<Relationship>) => {
this.objectUpdatesService.initialize(this.url, relationshipPaginatedList.page, new Date());
}),
);
// keep isLastPage$ up to date based on relationshipsRd$
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData()
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.isLastPage$.next(hasNoValue(rd.payload._links.next));
}));
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData(),
switchMap((rd: RemoteData<PaginatedList<Relationship>>) =>
// emit each relationship in the page separately
observableFrom(rd.payload.page).pipe(
mergeMap((relationship: Relationship) =>
// check for each relationship whether it's the left item
this.relationshipService.isLeftItem(relationship, this.item).pipe(
// emit an array containing both the relationship and whether it's the left item,
// as we'll need both
switchMap((isLeftItem: boolean) => {
if (isLeftItem) {
return relationship.rightItem.pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload(),
map((relatedItem: Item) => [relationship, isLeftItem, relatedItem]),
);
} else {
return relationship.leftItem.pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload(),
map((relatedItem: Item) => [relationship, isLeftItem, relatedItem]),
);
}
}),
),
),
map(([relationship, isLeftItem, relatedItem]: [Relationship, boolean, Item]) => {
// turn it into a RelationshipIdentifiable, an
const nameVariant =
isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
return {
uuid: relationship.id,
type: this.relationshipType,
relationship,
originalIsLeft: isLeftItem,
originalItem: this.item,
relatedItem: relatedItem,
nameVariant,
} as RelationshipIdentifiable;
}),
// wait until all relationships have been processed, and emit them all as a single array
toArray(),
// if the pipe above completes without emitting anything, emit an empty array instead
defaultIfEmpty([])
)),
switchMap((nextFields: RelationshipIdentifiable[]) => {
// Get a list that contains the unsaved changes for the page, as well as the page of
// RelationshipIdentifiables, as a single list of FieldUpdates
return this.objectUpdatesService.getFieldUpdates(this.url, nextFields).pipe(
map((fieldUpdates: FieldUpdates) => {
const fieldUpdatesFiltered: FieldUpdates = {};
this.nbAddedFields$.next(0);
// iterate over the fieldupdates and filter out the ones that pertain to this
// relationshiptype
Object.keys(fieldUpdates).forEach((uuid) => {
if (hasValue(fieldUpdates[uuid])) {
const field = fieldUpdates[uuid].field as RelationshipIdentifiable;
// only include fieldupdates regarding this RelationshipType
if (field.type.id === this.relationshipType.id) {
// if it's a newly added relationship
if (fieldUpdates[uuid].changeType === FieldChangeType.ADD) {
// increase the counter that tracks new relationships
this.nbAddedFields$.next(this.nbAddedFields$.getValue() + 1);
if (this.isLastPage$.getValue() === true) {
// only include newly added relationships to the output if we're on the last
// page
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
} else {
// include all others
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
}
}
});
return fieldUpdatesFiltered;
}),
);
}),
startWith({}),
).subscribe((updates: FieldUpdates) => {
this.loading$.next(false);
this.updates$.next(updates);
}));
}
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}