Merge branch 'w2p-113560_edit-item-add-relationships-one-by-one' into w2p-113560_edit-item-add-relationships-one-by-one_contribute-7_x

# Conflicts:
#	src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html
This commit is contained in:
Alexandre Vryghem
2024-05-16 10:17:34 +02:00
20 changed files with 1092 additions and 501 deletions

View File

@@ -1,4 +1,4 @@
import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, take, withLatestFrom, delay } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
@@ -114,7 +114,10 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
ngAfterViewInit() { ngAfterViewInit() {
this.router.events.subscribe((event) => { this.router.events.pipe(
// delay(0) to prevent "Expression has changed after it was checked" errors
delay(0)
).subscribe((event) => {
if (event instanceof NavigationStart) { if (event instanceof NavigationStart) {
distinctNext(this.isRouteLoading$, true); distinctNext(this.isRouteLoading$, true);
} else if ( } else if (

View File

@@ -58,6 +58,8 @@ export interface VirtualMetadataSource {
export interface RelationshipIdentifiable extends Identifiable { export interface RelationshipIdentifiable extends Identifiable {
nameVariant?: string; nameVariant?: string;
originalItem: Item;
originalIsLeft: boolean
relatedItem: Item; relatedItem: Item;
relationship: Relationship; relationship: Relationship;
type: RelationshipType; type: RelationshipType;

View File

@@ -114,8 +114,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* @param id the ID of the relationship to delete * @param id the ID of the relationship to delete
* @param copyVirtualMetadata whether to copy this relationship's virtual metadata to the related Items * @param copyVirtualMetadata whether to copy this relationship's virtual metadata to the related Items
* accepted values: none, all, left, right, configured * accepted values: none, all, left, right, configured
* @param shouldRefresh refresh the cache for the items in the relationship after creating
* it. Disable this if you want to add relationships in bulk, and
* want to refresh the cachemanually at the end
*/ */
deleteRelationship(id: string, copyVirtualMetadata: string): Observable<RemoteData<NoContent>> { deleteRelationship(id: string, copyVirtualMetadata: string, shouldRefresh = true): Observable<RemoteData<NoContent>> {
return this.getRelationshipEndpoint(id).pipe( return this.getRelationshipEndpoint(id).pipe(
isNotEmptyOperator(), isNotEmptyOperator(),
take(1), take(1),
@@ -126,7 +129,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
sendRequest(this.requestService), sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), tap(() => {
if (shouldRefresh) {
this.refreshRelationshipItemsInCacheByRelationship(id);
}
}),
); );
} }
@@ -137,8 +144,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* @param item2 The second item of the relationship * @param item2 The second item of the relationship
* @param leftwardValue The leftward value of the relationship * @param leftwardValue The leftward value of the relationship
* @param rightwardValue The rightward value of the relationship * @param rightwardValue The rightward value of the relationship
* @param shouldRefresh refresh the cache for the items in the relationship after creating it.
* Disable this if you want to add relationships in bulk, and want to refresh
* the cache manually at the end
*/ */
addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable<RemoteData<Relationship>> { addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string, shouldRefresh = true): Observable<RemoteData<Relationship>> {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list'); headers = headers.append('Content-Type', 'text/uri-list');
@@ -153,8 +163,12 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
sendRequest(this.requestService), sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
tap(() => this.refreshRelationshipItemsInCache(item1)), tap(() => {
tap(() => this.refreshRelationshipItemsInCache(item2)), if (shouldRefresh) {
this.refreshRelationshipItemsInCache(item1);
this.refreshRelationshipItemsInCache(item2);
}
}),
) as Observable<RemoteData<Relationship>>; ) as Observable<RemoteData<Relationship>>;
} }
@@ -182,7 +196,7 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* Method to remove an item that's part of a relationship from the cache * Method to remove an item that's part of a relationship from the cache
* @param item The item to remove from the cache * @param item The item to remove from the cache
*/ */
public refreshRelationshipItemsInCache(item) { public refreshRelationshipItemsInCache(item: Item): void {
this.objectCache.remove(item._links.self.href); this.objectCache.remove(item._links.self.href);
this.requestService.removeByHrefSubstring(item.uuid); this.requestService.removeByHrefSubstring(item.uuid);
observableCombineLatest([ observableCombineLatest([
@@ -295,7 +309,19 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
} else { } else {
findListOptions.searchParams = searchParams; findListOptions.searchParams = searchParams;
} }
return this.searchBy('byLabel', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
// always set reRequestOnStale to false here, so it doesn't happen automatically in BaseDataService
const result$ = this.searchBy('byLabel', findListOptions, useCachedVersionIfAvailable, false, ...linksToFollow);
// add this result as a dependency of the item, meaning that if the item is invalided, this
// result will be as well
this.addDependency(result$, item._links.self.href);
// do the reRequestOnStale call here, to ensure any re-requests also get added as dependencies
return result$.pipe(
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.getItemRelationshipsByLabel(item, label, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
} }
/** /**

View File

@@ -0,0 +1,303 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import { FieldUpdate } from '../../../core/data/object-updates/field-update.model';
import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model';
import {
DeleteRelationship,
RelationshipIdentifiable,
} from '../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { Item } from '../../../core/shared/item.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {
createFailedRemoteDataObject,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../../shared/remote-data.utils';
import { EntityTypeDataServiceStub } from '../../../shared/testing/entity-type-data.service.stub';
import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { ObjectUpdatesServiceStub } from '../../../shared/testing/object-updates.service.stub';
import { RelationshipDataServiceStub } from '../../../shared/testing/relationship-data.service.stub';
import { EditItemRelationshipsService } from './edit-item-relationships.service';
describe('EditItemRelationshipsService', () => {
let service: EditItemRelationshipsService;
let itemService: ItemDataServiceStub;
let objectUpdatesService: ObjectUpdatesServiceStub;
let notificationsService: NotificationsServiceStub;
let relationshipService: RelationshipDataServiceStub;
let entityTypeDataService: EntityTypeDataServiceStub;
let currentItem: Item;
let relationshipItem1: Item;
let relationshipIdentifiable1: RelationshipIdentifiable;
let relationship1: Relationship;
let relationshipItem2: Item;
let relationshipIdentifiable2: RelationshipIdentifiable;
let relationship2: Relationship;
let orgUnitType: ItemType;
let orgUnitToOrgUnitType: RelationshipType;
beforeEach(() => {
itemService = new ItemDataServiceStub();
objectUpdatesService = new ObjectUpdatesServiceStub();
notificationsService = new NotificationsServiceStub();
relationshipService = new RelationshipDataServiceStub();
entityTypeDataService = new EntityTypeDataServiceStub();
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: RelationshipDataService, useValue: relationshipService },
{ provide: EntityTypeDataService, useValue: entityTypeDataService },
],
});
service = TestBed.inject(EditItemRelationshipsService);
});
beforeEach(() => {
currentItem = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink1',
},
},
});
relationshipItem1 = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink2',
},
},
});
relationshipIdentifiable1 = {
originalItem: currentItem,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable;
relationship1 = Object.assign(new Relationship(), {
_links: {
leftItem: currentItem._links.self,
rightItem: relationshipItem1._links.self,
},
});
relationshipItem2 = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink3',
},
},
});
relationshipIdentifiable2 = {
originalItem: currentItem,
relatedItem: relationshipItem2,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem2.uuid}`,
} as RelationshipIdentifiable;
relationship2 = Object.assign(new Relationship(), {
_links: {
leftItem: currentItem._links.self,
rightItem: relationshipItem2._links.self,
},
});
orgUnitType = Object.assign(new ItemType(), {
id: '2',
label: 'OrgUnit',
});
orgUnitToOrgUnitType = Object.assign(new RelationshipType(), {
id: '1',
leftMaxCardinality: null,
leftMinCardinality: 0,
leftType: createSuccessfulRemoteDataObject$(orgUnitType),
leftwardType: 'isOrgUnitOfOrgUnit',
rightMaxCardinality: null,
rightMinCardinality: 0,
rightType: createSuccessfulRemoteDataObject$(orgUnitType),
rightwardType: 'isOrgUnitOfOrgUnit',
uuid: 'relationshiptype-1',
});
});
describe('submit', () => {
let fieldUpdateAddRelationship1: FieldUpdate;
let fieldUpdateRemoveRelationship2: FieldUpdate;
beforeEach(() => {
fieldUpdateAddRelationship1 = {
changeType: FieldChangeType.ADD,
field: relationshipIdentifiable1,
};
fieldUpdateRemoveRelationship2 = {
changeType: FieldChangeType.REMOVE,
field: relationshipIdentifiable2,
};
spyOn(service, 'addRelationship').withArgs(relationshipIdentifiable1).and.returnValue(createSuccessfulRemoteDataObject$(relationship1));
spyOn(service, 'deleteRelationship').withArgs(relationshipIdentifiable2 as DeleteRelationship).and.returnValue(createSuccessfulRemoteDataObject$({}));
spyOn(itemService, 'invalidateByHref').and.callThrough();
});
it('should support performing multiple relationships manipulations in one submit() call', () => {
spyOn(objectUpdatesService, 'getFieldUpdates').and.returnValue(observableOf({
[`1-${relationshipItem1.uuid}`]: fieldUpdateAddRelationship1,
[`1-${relationshipItem2.uuid}`]: fieldUpdateRemoveRelationship2,
} as FieldUpdates));
service.submit(currentItem, `/entities/orgunit/${currentItem.uuid}/edit/relationships`);
expect(service.addRelationship).toHaveBeenCalledWith(relationshipIdentifiable1);
expect(service.deleteRelationship).toHaveBeenCalledWith(relationshipIdentifiable2 as DeleteRelationship);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(currentItem.self);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem1.self);
// TODO currently this isn't done yet
// expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem2.self);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
});
});
describe('deleteRelationship', () => {
beforeEach(() => {
spyOn(relationshipService, 'deleteRelationship').and.callThrough();
});
it('should pass "all" as copyVirtualMetadata when the user want to keep the data on both sides', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: true,
keepRightVirtualMetadata: true,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'all', false);
});
it('should pass "left" as copyVirtualMetadata when the user only want to keep the data on the left side', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: true,
keepRightVirtualMetadata: false,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'left', false);
});
it('should pass "right" as copyVirtualMetadata when the user only want to keep the data on the right side', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: false,
keepRightVirtualMetadata: true,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'right', false);
});
it('should pass "none" as copyVirtualMetadata when the user doesn\'t want to keep the virtual metadata', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: false,
keepRightVirtualMetadata: false,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'none', false);
});
});
describe('addRelationship', () => {
beforeEach(() => {
spyOn(relationshipService, 'addRelationship').and.callThrough();
});
it('should call the addRelationship from relationshipService correctly when original item is on the right', () => {
service.addRelationship({
originalItem: currentItem,
originalIsLeft: false,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable);
expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, relationshipItem1, currentItem, undefined, null, false);
});
it('should call the addRelationship from relationshipService correctly when original item is on the left', () => {
service.addRelationship({
originalItem: currentItem,
originalIsLeft: true,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable);
expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, currentItem, relationshipItem1, null, undefined, false);
});
});
describe('displayNotifications', () => {
it('should show one success notification when multiple requests succeeded', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createSuccessfulRemoteDataObject({}),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
});
it('should show one success notification even when some requests failed', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed'),
createSuccessfulRemoteDataObject({}),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
expect(notificationsService.error).toHaveBeenCalledTimes(1);
});
it('should show a separate error notification for each failed request', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed 1'),
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed 2'),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
expect(notificationsService.error).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,190 @@
import { Injectable } from '@angular/core';
import { take, map, switchMap, concatMap, toArray } from 'rxjs/operators';
import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model';
import { FieldUpdate } from '../../../core/data/object-updates/field-update.model';
import { hasValue } from '../../../shared/empty.util';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import {
DeleteRelationship,
RelationshipIdentifiable
} from '../../../core/data/object-updates/object-updates.reducer';
import { RemoteData } from '../../../core/data/remote-data';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { EMPTY, Observable, BehaviorSubject, Subscription } from 'rxjs';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { Item } from '../../../core/shared/item.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { TranslateService } from '@ngx-translate/core';
@Injectable({
providedIn: 'root'
})
export class EditItemRelationshipsService {
public notificationsPrefix = 'item.edit.relationships.notifications.';
public isSaving$: BehaviorSubject<boolean> = new BehaviorSubject(false);
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
public notificationsService: NotificationsService,
protected modalService: NgbModal,
public relationshipService: RelationshipDataService,
public entityTypeService: EntityTypeDataService,
public translateService: TranslateService,
) { }
/**
* Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found
* Make sure the lists are refreshed afterwards and notifications are sent for success and errors
*/
public submit(item: Item, url: string): void {
this.isSaving$.next(true);
this.objectUpdatesService.getFieldUpdates(url, [], true).pipe(
map((fieldUpdates: FieldUpdates) =>
Object.values(fieldUpdates)
.filter((fieldUpdate: FieldUpdate) => hasValue(fieldUpdate))
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.ADD || fieldUpdate.changeType === FieldChangeType.REMOVE)
),
take(1),
// emit each update in the array separately
switchMap((updates) => updates),
// process each update one by one, while waiting for the previous to finish
concatMap((update: FieldUpdate) => {
if (update.changeType === FieldChangeType.REMOVE) {
return this.deleteRelationship(update.field as DeleteRelationship).pipe(take(1));
} else if (update.changeType === FieldChangeType.ADD) {
return this.addRelationship(update.field as RelationshipIdentifiable).pipe(
take(1),
switchMap((relationshipRD: RemoteData<Relationship>) => {
if (relationshipRD.hasSucceeded) {
// Set the newly related item to stale, so its relationships will update to include
// the new one. Only set the current item to stale at the very end so we only do it
// once
const { leftItem, rightItem } = relationshipRD.payload._links;
if (leftItem.href === item.self) {
return this.itemService.invalidateByHref(rightItem.href).pipe(
// when it's invalidated, emit the original relationshipRD for use in the pipe below
map(() => relationshipRD)
);
} else {
return this.itemService.invalidateByHref(leftItem.href).pipe(
// when it's invalidated, emit the original relationshipRD for use in the pipe below
map(() => relationshipRD)
);
}
} else {
return [relationshipRD];
}
})
);
} else {
return EMPTY;
}
}),
toArray(),
switchMap((responses) => {
// once all relationships are made and all related items have been invalidated, invalidate
// the current item
return this.itemService.invalidateByHref(item.self).pipe(
map(() => responses)
);
})
).subscribe((responses) => {
if (responses.length > 0) {
this.initializeOriginalFields(item, url);
this.displayNotifications(responses);
this.modalService.dismissAll();
this.isSaving$.next(false);
}
});
}
/**
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields(item: Item, url: string): Subscription {
return this.relationshipService.getRelatedItems(item).pipe(
take(1),
).subscribe((items: Item[]) => {
this.objectUpdatesService.initialize(url, items, item.lastModified);
});
}
deleteRelationship(deleteRelationship: DeleteRelationship): Observable<RemoteData<NoContent>> {
let copyVirtualMetadata: string;
if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'all';
} else if (deleteRelationship.keepLeftVirtualMetadata) {
copyVirtualMetadata = 'left';
} else if (deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'right';
} else {
copyVirtualMetadata = 'none';
}
return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata, false);
}
addRelationship(addRelationship: RelationshipIdentifiable): Observable<RemoteData<Relationship>> {
let leftItem: Item;
let rightItem: Item;
let leftwardValue: string;
let rightwardValue: string;
if (addRelationship.originalIsLeft) {
leftItem = addRelationship.originalItem;
rightItem = addRelationship.relatedItem;
leftwardValue = null;
rightwardValue = addRelationship.nameVariant;
} else {
leftItem = addRelationship.relatedItem;
rightItem = addRelationship.originalItem;
leftwardValue = addRelationship.nameVariant;
rightwardValue = null;
}
return this.relationshipService.addRelationship(addRelationship.type.id, leftItem, rightItem, leftwardValue, rightwardValue, false);
}
/**
* Display notifications
* - Error notification for each failed response with their message
* - Success notification in case there's at least one successful response
* @param responses
*/
displayNotifications(responses: RemoteData<NoContent>[]): void {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
const successfulResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
failedResponses.forEach((response: RemoteData<NoContent>) => {
this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage);
});
if (successfulResponses.length > 0) {
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
}
/**
* Get translated notification title
* @param key
*/
getNotificationTitle(key: string): string {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
getNotificationContent(key: string): string {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
}

View File

@@ -1,5 +1,5 @@
<h2 class="h4"> <h2 class="h4">
{{getRelationshipMessageKey() | async | translate}} {{relationshipMessageKey$ | async | translate}}
<button class="ml-2 btn btn-success" [disabled]="(hasChanges | async)" (click)="openLookup()"> <button class="ml-2 btn btn-success" [disabled]="(hasChanges | async)" (click)="openLookup()">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.relationships.edit.buttons.add" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.relationships.edit.buttons.add" | translate}}</span>

View File

@@ -32,67 +32,70 @@ import { ConfigurationProperty } from '../../../../core/shared/configuration-pro
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RouterMock } from '../../../../shared/mocks/router.mock'; import { RouterMock } from '../../../../shared/mocks/router.mock';
import { APP_CONFIG } from '../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../config/app-config.interface';
import { EditItemRelationshipsServiceStub } from '../../../../shared/testing/edit-item-relationships.service.stub';
let comp: EditRelationshipListComponent; import { EditItemRelationshipsService } from '../edit-item-relationships.service';
let fixture: ComponentFixture<EditRelationshipListComponent>; import { cold } from 'jasmine-marbles';
let de: DebugElement;
let linkService;
let objectUpdatesService;
let relationshipService;
let selectableListService;
let paginationService;
let hostWindowService;
const relationshipTypeService = {};
const url = 'http://test-url.com/test-url';
let item;
let entityType;
let relatedEntityType;
let author1;
let author2;
let fieldUpdate1;
let fieldUpdate2;
let relationships;
let relationshipType;
let paginationOptions;
describe('EditRelationshipListComponent', () => { describe('EditRelationshipListComponent', () => {
let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement;
let linkService;
let objectUpdatesService;
let relationshipService;
let selectableListService;
let paginationService: PaginationServiceStub;
let hostWindowService: HostWindowServiceStub;
const relationshipTypeService = {};
let editItemRelationshipsService: EditItemRelationshipsServiceStub;
const url = 'http://test-url.com/test-url';
let itemLeft: Item;
let entityTypeLeft: ItemType;
let entityTypeRight: ItemType;
let itemRight1: Item;
let itemRight2: Item;
let fieldUpdate1;
let fieldUpdate2;
let relationships: Relationship[];
let relationshipType: RelationshipType;
let paginationOptions: PaginationComponentOptions;
const resetComponent = () => { const resetComponent = () => {
fixture = TestBed.createComponent(EditRelationshipListComponent); fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
comp.item = item; comp.item = itemLeft;
comp.itemType = entityType; comp.itemType = entityTypeLeft;
comp.url = url; comp.url = url;
comp.relationshipType = relationshipType; comp.relationshipType = relationshipType;
comp.hasChanges = observableOf(false); comp.hasChanges = observableOf(false);
fixture.detectChanges(); fixture.detectChanges();
}; };
beforeEach(waitForAsync(() => { function init(leftType: string, rightType: string): void {
entityTypeLeft = Object.assign(new ItemType(), {
entityType = Object.assign(new ItemType(), { id: leftType,
id: 'Publication', uuid: leftType,
uuid: 'Publication', label: leftType,
label: 'Publication',
}); });
relatedEntityType = Object.assign(new ItemType(), { entityTypeRight = Object.assign(new ItemType(), {
id: 'Author', id: rightType,
uuid: 'Author', uuid: rightType,
label: 'Author', label: rightType,
}); });
relationshipType = Object.assign(new RelationshipType(), { relationshipType = Object.assign(new RelationshipType(), {
id: '1', id: '1',
uuid: '1', uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType), leftType: createSuccessfulRemoteDataObject$(entityTypeLeft),
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), rightType: createSuccessfulRemoteDataObject$(entityTypeRight),
leftwardType: 'isAuthorOfPublication', leftwardType: `is${rightType}Of${leftType}`,
rightwardType: 'isPublicationOfAuthor', rightwardType: `is${leftType}Of${rightType}`,
}); });
paginationOptions = Object.assign(new PaginationComponentOptions(), { paginationOptions = Object.assign(new PaginationComponentOptions(), {
@@ -101,13 +104,13 @@ describe('EditRelationshipListComponent', () => {
currentPage: 1, currentPage: 1,
}); });
author1 = Object.assign(new Item(), { itemRight1 = Object.assign(new Item(), {
id: 'author1', id: `${rightType}-1`,
uuid: 'author1' uuid: `${rightType}-1`,
}); });
author2 = Object.assign(new Item(), { itemRight2 = Object.assign(new Item(), {
id: 'author2', id: `${rightType}-2`,
uuid: 'author2' uuid: `${rightType}-2`,
}); });
relationships = [ relationships = [
@@ -116,25 +119,25 @@ describe('EditRelationshipListComponent', () => {
id: '2', id: '2',
uuid: '2', uuid: '2',
relationshipType: createSuccessfulRemoteDataObject$(relationshipType), relationshipType: createSuccessfulRemoteDataObject$(relationshipType),
leftItem: createSuccessfulRemoteDataObject$(item), leftItem: createSuccessfulRemoteDataObject$(itemLeft),
rightItem: createSuccessfulRemoteDataObject$(author1), rightItem: createSuccessfulRemoteDataObject$(itemRight1),
}), }),
Object.assign(new Relationship(), { Object.assign(new Relationship(), {
self: url + '/3', self: url + '/3',
id: '3', id: '3',
uuid: '3', uuid: '3',
relationshipType: createSuccessfulRemoteDataObject$(relationshipType), relationshipType: createSuccessfulRemoteDataObject$(relationshipType),
leftItem: createSuccessfulRemoteDataObject$(item), leftItem: createSuccessfulRemoteDataObject$(itemLeft),
rightItem: createSuccessfulRemoteDataObject$(author2), rightItem: createSuccessfulRemoteDataObject$(itemRight2),
}) })
]; ];
item = Object.assign(new Item(), { itemLeft = Object.assign(new Item(), {
_links: { _links: {
self: { href: 'fake-item-url/publication' } self: { href: 'fake-item-url/publication' }
}, },
id: 'publication', id: `1-${leftType}`,
uuid: 'publication', uuid: `1-${leftType}`,
relationships: createSuccessfulRemoteDataObject$(createPaginatedList(relationships)) relationships: createSuccessfulRemoteDataObject$(createPaginatedList(relationships))
}); });
@@ -166,7 +169,7 @@ describe('EditRelationshipListComponent', () => {
relationshipService = jasmine.createSpyObj('relationshipService', relationshipService = jasmine.createSpyObj('relationshipService',
{ {
getRelatedItemsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList([author1, author2])), getRelatedItemsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList([itemRight1, itemRight2])),
getItemRelationshipsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList(relationships)), getItemRelationshipsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList(relationships)),
isLeftItem: observableOf(true), isLeftItem: observableOf(true),
} }
@@ -202,6 +205,8 @@ describe('EditRelationshipListComponent', () => {
})) }))
}); });
editItemRelationshipsService = new EditItemRelationshipsServiceStub();
const environmentUseThumbs = { const environmentUseThumbs = {
browseBy: { browseBy: {
showThumbnails: true showThumbnails: true
@@ -224,6 +229,7 @@ describe('EditRelationshipListComponent', () => {
{ provide: LinkHeadService, useValue: linkHeadService }, { provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService }, { provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
{ provide: EditItemRelationshipsService, useValue: editItemRelationshipsService },
{ provide: APP_CONFIG, useValue: environmentUseThumbs } { provide: APP_CONFIG, useValue: environmentUseThumbs }
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
@@ -231,114 +237,127 @@ describe('EditRelationshipListComponent', () => {
}).compileComponents(); }).compileComponents();
resetComponent(); resetComponent();
})); }
describe('changeType is REMOVE', () => { describe('Publication - Author relationship', () => {
beforeEach(() => { beforeEach(waitForAsync(() => init('Publication', 'Author')));
fieldUpdate1.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('the div should have class alert-danger', () => {
const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement;
expect(element.classList).toContain('alert-danger');
});
});
describe('pagination component', () => { describe('changeType is REMOVE', () => {
let paginationComp: PaginationComponent;
beforeEach(() => {
paginationComp = de.query(By.css('ds-pagination')).componentInstance;
});
it('should receive the correct pagination config', () => {
expect(paginationComp.paginationOptions).toEqual(paginationOptions);
});
it('should receive correct collection size', () => {
expect(paginationComp.collectionSize).toEqual(relationships.length);
});
});
describe('relationshipService.getItemRelationshipsByLabel', () => {
it('should receive the correct pagination info', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const findListOptions = callArgs[2];
const linksToFollow = callArgs[5];
expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize);
expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage);
expect(linksToFollow.linksToFollow[0].name).toEqual('thumbnail');
});
describe('when the publication is on the left side of the relationship', () => {
beforeEach(() => { beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), { fieldUpdate1.changeType = FieldChangeType.REMOVE;
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType), // publication
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('when the publication is on the right side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
rightType: createSuccessfulRemoteDataObject$(entityType), // publication
leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('changes managment for add buttons', () => {
it('should show enabled add buttons', () => {
const element = de.query(By.css('.btn-success'));
expect(element.nativeElement?.disabled).toBeFalse();
});
it('after hash changes changed', () => {
comp.hasChanges = observableOf(true);
fixture.detectChanges(); fixture.detectChanges();
const element = de.query(By.css('.btn-success')); });
expect(element.nativeElement?.disabled).toBeTrue(); it('the div should have class alert-danger', () => {
const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement;
expect(element.classList).toContain('alert-danger');
}); });
}); });
describe('pagination component', () => {
let paginationComp: PaginationComponent;
beforeEach(() => {
paginationComp = de.query(By.css('ds-pagination')).componentInstance;
});
it('should receive the correct pagination config', () => {
expect(paginationComp.paginationOptions).toEqual(paginationOptions);
});
it('should receive correct collection size', () => {
expect(paginationComp.collectionSize).toEqual(relationships.length);
});
});
describe('relationshipService.getItemRelationshipsByLabel', () => {
it('should receive the correct pagination info', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const findListOptions = callArgs[2];
const linksToFollow = callArgs[5];
expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize);
expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage);
expect(linksToFollow.linksToFollow[0].name).toEqual('thumbnail');
});
describe('when the publication is on the left side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityTypeLeft), // publication
rightType: createSuccessfulRemoteDataObject$(entityTypeRight), // author
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('when the publication is on the right side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityTypeRight), // author
rightType: createSuccessfulRemoteDataObject$(entityTypeLeft), // publication
leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('changes managment for add buttons', () => {
it('should show enabled add buttons', () => {
const element = de.query(By.css('.btn-success'));
expect(element.nativeElement?.disabled).toBeFalse();
});
it('after hash changes changed', () => {
comp.hasChanges = observableOf(true);
fixture.detectChanges();
const element = de.query(By.css('.btn-success'));
expect(element.nativeElement?.disabled).toBeTrue();
});
});
});
}); });
describe('OrgUnit - OrgUnit relationship', () => {
beforeEach(waitForAsync(() => init('OrgUnit', 'OrgUnit')));
it('should emit the relatedEntityType$ even for same entity relationships', () => {
expect(comp.relatedEntityType$).toBeObservable(cold('(a|)', {
a: entityTypeRight,
}));
});
});
}); });

View File

@@ -5,6 +5,7 @@ import { ObjectUpdatesService } from '../../../../core/data/object-updates/objec
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
EMPTY,
from as observableFrom, from as observableFrom,
Observable, Observable,
Subscription Subscription
@@ -14,10 +15,22 @@ import {
} from '../../../../core/data/object-updates/object-updates.reducer'; } from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipDataService } from '../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../core/data/relationship-data.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { defaultIfEmpty, map, mergeMap, startWith, switchMap, take, tap, toArray } from 'rxjs/operators'; import {
defaultIfEmpty,
map,
mergeMap,
startWith,
switchMap,
take,
tap,
toArray,
concatMap
} from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator } from '../../../../shared/empty.util'; import { hasNoValue, hasValue, hasValueOperator } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import {
RelationshipType
} from '../../../../core/shared/item-relationships/relationship-type.model';
import { import {
getAllSucceededRemoteData, getAllSucceededRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
@@ -25,22 +38,32 @@ import {
getRemoteDataPayload, getRemoteDataPayload,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; 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 {
import { RelationshipOptions } from '../../../../shared/form/builder/models/relationship-options.model'; DsDynamicLookupRelationModalComponent
import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
import { SearchResult } from '../../../../shared/search/models/search-result.model'; 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 { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import {
PaginationComponentOptions
} from '../../../../shared/pagination/pagination-component-options.model';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { RelationshipTypeDataService } from '../../../../core/data/relationship-type-data.service';
import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model';
import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model'; import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface';
import { itemLinksToFollow } from '../../../../shared/utils/relation-query.utils'; import { itemLinksToFollow } from '../../../../shared/utils/relation-query.utils';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
@Component({ @Component({
selector: 'ds-edit-relationship-list', selector: 'ds-edit-relationship-list',
@@ -79,7 +102,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
/** /**
* The event emmiter to submit the new information * The event emmiter to submit the new information
*/ */
@Output() submit: EventEmitter<any> = new EventEmitter(); @Output() submitModal: EventEmitter<void> = new EventEmitter();
/** /**
* Observable that emits the left and right item type of {@link relationshipType} simultaneously. * Observable that emits the left and right item type of {@link relationshipType} simultaneously.
@@ -90,9 +113,14 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType}, * 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. * false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/ */
private currentItemIsLeftItem$: Observable<boolean>; private currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined);
private relatedEntityType$: Observable<ItemType>; relatedEntityType$: Observable<ItemType>;
/**
* The translation key for the entity type
*/
relationshipMessageKey$: Observable<string>;
/** /**
* The list ID to save selected entities under * The list ID to save selected entities under
@@ -149,10 +177,10 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
protected objectUpdatesService: ObjectUpdatesService, protected objectUpdatesService: ObjectUpdatesService,
protected linkService: LinkService, protected linkService: LinkService,
protected relationshipService: RelationshipDataService, protected relationshipService: RelationshipDataService,
protected relationshipTypeService: RelationshipTypeDataService,
protected modalService: NgbModal, protected modalService: NgbModal,
protected paginationService: PaginationService, protected paginationService: PaginationService,
protected selectableListService: SelectableListService, protected selectableListService: SelectableListService,
protected editItemRelationshipsService: EditItemRelationshipsService,
@Inject(APP_CONFIG) protected appConfig: AppConfig @Inject(APP_CONFIG) protected appConfig: AppConfig
) { ) {
this.fetchThumbnail = this.appConfig.browseBy.showThumbnails; this.fetchThumbnail = this.appConfig.browseBy.showThumbnails;
@@ -162,11 +190,10 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
* Get the i18n message key for this relationship type * Get the i18n message key for this relationship type
*/ */
public getRelationshipMessageKey(): Observable<string> { public getRelationshipMessageKey(): Observable<string> {
return observableCombineLatest([
return observableCombineLatest(
this.getLabel(), this.getLabel(),
this.relatedEntityType$, this.relatedEntityType$,
).pipe( ]).pipe(
map(([label, relatedEntityType]) => { map(([label, relatedEntityType]) => {
if (hasValue(label) && label.indexOf('is') > -1 && label.indexOf('Of') > -1) { if (hasValue(label) && label.indexOf('is') > -1 && label.indexOf('Of') > -1) {
const relationshipLabel = `${label.substring(2, label.indexOf('Of'))}`; const relationshipLabel = `${label.substring(2, label.indexOf('Of'))}`;
@@ -211,7 +238,6 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
* Open the dynamic lookup modal to search for items to add as relationships * Open the dynamic lookup modal to search for items to add as relationships
*/ */
openLookup() { openLookup() {
this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, { this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, {
size: 'lg' size: 'lg'
}); });
@@ -232,11 +258,11 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
modalComp.collection = collection; modalComp.collection = collection;
}); });
modalComp.select = (...selectableObjects: SearchResult<Item>[]) => { modalComp.select = (...selectableObjects: ItemSearchResult[]) => {
selectableObjects.forEach((searchResult) => { selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject; const relatedItem: Item = searchResult.indexableObject;
const foundIndex = modalComp.toRemove.findIndex( el => el.uuid === relatedItem.uuid); const foundIndex = modalComp.toRemove.findIndex((itemSearchResult: ItemSearchResult) => itemSearchResult.indexableObject.uuid === relatedItem.uuid);
if (foundIndex !== -1) { if (foundIndex !== -1) {
modalComp.toRemove.splice(foundIndex,1); modalComp.toRemove.splice(foundIndex,1);
@@ -260,7 +286,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
} }
}); });
}; };
modalComp.deselect = (...selectableObjects: SearchResult<Item>[]) => { modalComp.deselect = (...selectableObjects: ItemSearchResult[]) => {
selectableObjects.forEach((searchResult) => { selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject; const relatedItem: Item = searchResult.indexableObject;
@@ -277,51 +303,59 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
modalComp.submitEv = () => { modalComp.submitEv = () => {
modalComp.isPending = true;
const subscriptions = []; const isLeft = this.currentItemIsLeftItem$.getValue();
const addOperations = modalComp.toAdd.map((searchResult: any) => ({ type: 'add', searchResult }));
modalComp.toAdd.forEach((searchResult: SearchResult<Item>) => { const removeOperations = modalComp.toRemove.map((searchResult: any) => ({ type: 'remove', searchResult }));
const relatedItem = searchResult.indexableObject; observableFrom([...addOperations, ...removeOperations]).pipe(
subscriptions.push(this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe( concatMap(({ type, searchResult }: { type: string, searchResult: any }) => {
map((nameVariant) => { if (type === 'add') {
const update = { const relatedItem = searchResult.indexableObject;
uuid: this.relationshipType.id + '-' + searchResult.indexableObject.uuid, return this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe(
nameVariant, map((nameVariant) => {
type: this.relationshipType,
relatedItem,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
return update;
})
));
});
modalComp.toRemove.forEach( (searchResult) => {
subscriptions.push(this.relationshipService.getNameVariant(this.listId, searchResult.indexableObjectuuid).pipe(
switchMap((nameVariant) => {
return this.getRelationFromId(searchResult.indexableObject).pipe(
map( (relationship: Relationship) => {
const update = { const update = {
uuid: relationship.id, uuid: this.relationshipType.id + '-' + searchResult.indexableObject.uuid,
nameVariant, nameVariant,
type: this.relationshipType, type: this.relationshipType,
relationship, originalIsLeft: isLeft,
originalItem: this.item,
relatedItem,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update); this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
return update; return update;
}) }),
take(1)
); );
}) } else if (type === 'remove') {
)); return this.relationshipService.getNameVariant(this.listId, searchResult.indexableObjectuuid).pipe(
}); switchMap((nameVariant) => {
return this.getRelationFromId(searchResult.indexableObject).pipe(
observableCombineLatest(subscriptions).subscribe( (res) => { map( (relationship: Relationship) => {
// Wait until the states changes since there are multiple items const update = {
setTimeout( () => { uuid: relationship.id,
this.submit.emit(); nameVariant,
},1000); type: this.relationshipType,
originalIsLeft: isLeft,
modalComp.isPending = true; originalItem: this.item,
relationship,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update);
return update;
})
);
}),
take(1)
);
} else {
return EMPTY;
}
}),
toArray(),
).subscribe({
complete: () => {
this.editItemRelationshipsService.submit(this.item, this.url);
this.submitModal.emit();
}
}); });
}; };
@@ -355,92 +389,15 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
} }
getRelationFromId(relatedItem) { getRelationFromId(relatedItem) {
return this.currentItemIsLeftItem$.pipe( const relationshipLabel = this.currentItemIsLeftItem$.getValue() ? this.relationshipType.leftwardType : this.relationshipType.rightwardType;
take(1), return this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, relationshipLabel ,[relatedItem.id] ).pipe(
switchMap( isLeft => {
let apiCall;
if (isLeft) {
apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.leftwardType ,[relatedItem.id] ).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
} else {
apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.rightwardType ,[relatedItem.id] ).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
}
return apiCall.pipe(
map( (res: PaginatedList<Relationship>) => res.page[0])
);
}
));
}
/**
* Get the existing field updates regarding a relationship with a given item
* @param relatedItem The item for which to get the existing field updates
*/
private getFieldUpdatesForRelatedItem(relatedItem: Item): Observable<RelationshipIdentifiable[]> {
return this.updates$.pipe(
take(1),
map((updates) => Object.values(updates)
.map((update) => update.field as RelationshipIdentifiable)
.filter((field) => field.relationship)
),
mergeMap((identifiables) =>
observableCombineLatest(
identifiables.map((identifiable) => this.getRelatedItem(identifiable.relationship))
).pipe(
defaultIfEmpty([]),
map((relatedItems) => {
return identifiables.filter( (identifiable, index) => {
return relatedItems[index].uuid === relatedItem.uuid;
});
}
),
)
)
);
}
/**
* Check if the given item is related with the item we are editing relationships
* @param relatedItem The item for which to get the existing field updates
*/
private getIsRelatedItem(relatedItem: Item): Observable<boolean> {
return this.currentItemIsLeftItem$.pipe(
take(1),
map( isLeft => {
if (isLeft) {
const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.leftwardType );
return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid );
} else {
const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.rightwardType );
return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid );
}
})
);
}
/**
* Get the related item for a given relationship
* @param relationship The relationship for which to get the related item
*/
private getRelatedItem(relationship: Relationship): Observable<Item> {
return this.relationshipService.isLeftItem(relationship, this.item).pipe(
switchMap((isLeftItem) => isLeftItem ? relationship.rightItem : relationship.leftItem),
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
) as Observable<Item>; map( (res: PaginatedList<Relationship>) => res.page[0])
);
} }
ngOnInit(): void { ngOnInit(): void {
// store the left and right type of the relationship in a single observable // store the left and right type of the relationship in a single observable
this.relationshipLeftAndRightType$ = observableCombineLatest([ this.relationshipLeftAndRightType$ = observableCombineLatest([
this.relationshipType.leftType, this.relationshipType.leftType,
@@ -451,8 +408,14 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
))) as Observable<[ItemType, ItemType]>; ))) as Observable<[ItemType, ItemType]>;
this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe( this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)), map(([leftType, rightType]: [ItemType, ItemType]) => {
hasValueOperator() if (leftType.uuid !== this.itemType.uuid) {
return leftType;
} else {
return rightType;
}
}),
hasValueOperator(),
); );
this.relatedEntityType$.pipe( this.relatedEntityType$.pipe(
@@ -461,7 +424,9 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}` (relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
); );
this.currentItemIsLeftItem$ = this.relationshipLeftAndRightType$.pipe( this.relationshipMessageKey$ = this.getRelationshipMessageKey();
this.subs.push(this.relationshipLeftAndRightType$.pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => { map(([leftType, rightType]: [ItemType, ItemType]) => {
if (leftType.id === this.itemType.id) { if (leftType.id === this.itemType.id) {
return true; return true;
@@ -475,7 +440,9 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`); console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`);
return undefined; return undefined;
}) })
); ).subscribe((nextValue: boolean) => {
this.currentItemIsLeftItem$.next(nextValue);
}));
// initialize the pagination options // initialize the pagination options
@@ -500,19 +467,20 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
currentPagination$, currentPagination$,
this.currentItemIsLeftItem$, this.currentItemIsLeftItem$,
]).pipe( ]).pipe(
switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) => switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) => {
// get the relationships for the current item, relationshiptype and page // get the relationships for the current item, relationshiptype and page
this.relationshipService.getItemRelationshipsByLabel( return this.relationshipService.getItemRelationshipsByLabel(
this.item, this.item,
currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType, currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType,
{ {
elementsPerPage: currentPagination.pageSize, elementsPerPage: currentPagination.pageSize,
currentPage: currentPagination.currentPage currentPage: currentPagination.currentPage
}, },
false, true,
true, true,
...linksToFollow ...linksToFollow
)), );
}),
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => { ).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.relationshipsRd$.next(rd); this.relationshipsRd$.next(rd);
}) })
@@ -548,6 +516,8 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
uuid: relationship.id, uuid: relationship.id,
type: this.relationshipType, type: this.relationshipType,
relationship, relationship,
originalIsLeft: isLeftItem,
originalItem: this.item,
nameVariant, nameVariant,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
}), }),

View File

@@ -108,10 +108,10 @@ export class EditRelationshipComponent implements OnChanges {
*/ */
remove(): void { remove(): void {
this.closeVirtualMetadataModal(); this.closeVirtualMetadataModal();
observableCombineLatest( observableCombineLatest([
this.leftItem$, this.leftItem$,
this.rightItem$, this.rightItem$,
).pipe( ]).pipe(
map((items: Item[]) => map((items: Item[]) =>
items.map((item) => this.objectUpdatesService items.map((item) => this.objectUpdatesService
.isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid)) .isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid))
@@ -127,9 +127,9 @@ export class EditRelationshipComponent implements OnChanges {
) as DeleteRelationship; ) as DeleteRelationship;
}), }),
take(1), take(1),
).subscribe((deleteRelationship: DeleteRelationship) => ).subscribe((deleteRelationship: DeleteRelationship) => {
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship) this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship);
); });
} }
openVirtualMetadataModal(content: any) { openVirtualMetadataModal(content: any) {

View File

@@ -1,60 +1,58 @@
<div class="item-relationships"> <div class="item-relationships">
<ng-container *ngVar="entityType$ | async as entityType"> <ng-container *ngIf="entityType$ | async as entityType">
<ng-container *ngIf="entityType"> <div class="button-row top d-flex space-children-mr">
<div class="button-row top d-flex space-children-mr"> <button class="btn btn-danger ml-auto" *ngIf="!(isReinstatable() | async)"
<button class="btn btn-danger ml-auto" *ngIf="!(isReinstatable() | async)" [disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning ml-auto" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><span *ngIf="isSaving$ | async" class="spinner-border spinner-border-sm" role="status"
aria-hidden="true"></span>
<i *ngIf="!(isSaving$ | async)" class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<div *ngIf="relationshipTypes$ | async as relationshipTypes; else loading" class="mb-4">
<div *ngFor="let relationshipType of relationshipTypes; trackBy: trackById" class="mb-4">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="entityType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges()"
></ds-edit-relationship-list>
</div>
</div>
<ng-template #loading>
<ds-themed-loading></ds-themed-loading>
</ng-template>
<div class="button-row bottom">
<div class="float-right space-children-mr ml-gap">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)" [disabled]="!(hasChanges() | async)"
(click)="discard()"><i (click)="discard()"><i
class="fas fa-times"></i> class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button> </button>
<button class="btn btn-warning ml-auto" *ngIf="isReinstatable() | async" <button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i (click)="reinstate()"><i
class="fas fa-undo-alt"></i> class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button> </button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)" <button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i (click)="submit()"><i
class="fas fa-save"></i> class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button> </button>
</div> </div>
<ng-container *ngVar="relationshipTypes$ | async as relationshipTypes"> </div>
<ng-container *ngIf="relationshipTypes">
<div *ngFor="let relationshipType of relationshipTypes" class="mb-4">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="entityType$ | async"
[relationshipType]="relationshipType"
[hasChanges] = hasChanges()
(submit) = submit()
></ds-edit-relationship-list>
</div>
</ng-container>
<ds-themed-loading *ngIf="!relationshipTypes"></ds-themed-loading>
</ng-container>
<div class="button-row bottom">
<div class="float-right space-children-mr ml-gap">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div>
</ng-container>
<div *ngIf="!entityType" <div *ngIf="!entityType"
class="alert alert-info mt-2" role="alert"> class="alert alert-info mt-2" role="alert">
{{ 'item.edit.relationships.no-entity-type' | translate }} {{ 'item.edit.relationships.no-entity-type' | translate }}

View File

@@ -29,6 +29,7 @@ import { RelationshipTypeDataService } from '../../../core/data/relationship-typ
import { relationshipTypes } from '../../../shared/testing/relationship-types.mock'; import { relationshipTypes } from '../../../shared/testing/relationship-types.mock';
import { ThemeService } from '../../../shared/theme-support/theme.service'; import { ThemeService } from '../../../shared/theme-support/theme.service';
import { getMockThemeService } from '../../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../../shared/mocks/theme-service.mock';
import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub';
let comp: any; let comp: any;
let fixture: ComponentFixture<ItemRelationshipsComponent>; let fixture: ComponentFixture<ItemRelationshipsComponent>;
@@ -52,7 +53,7 @@ const notificationsService = jasmine.createSpyObj('notificationsService',
const router = new RouterStub(); const router = new RouterStub();
let relationshipTypeService; let relationshipTypeService;
let routeStub; let routeStub;
let itemService; let itemService: ItemDataServiceStub;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
router.url = url; router.url = url;
@@ -137,10 +138,7 @@ describe('ItemRelationshipsComponent', () => {
changeType: FieldChangeType.REMOVE changeType: FieldChangeType.REMOVE
}; };
itemService = jasmine.createSpyObj('itemService', { itemService = new ItemDataServiceStub();
findByHref: createSuccessfulRemoteDataObject$(item),
findById: createSuccessfulRemoteDataObject$(item)
});
routeStub = { routeStub = {
data: observableOf({}), data: observableOf({}),
parent: { parent: {
@@ -232,6 +230,8 @@ describe('ItemRelationshipsComponent', () => {
})); }));
beforeEach(() => { beforeEach(() => {
spyOn(itemService, 'findByHref').and.returnValue(item);
spyOn(itemService, 'findById').and.returnValue(item);
fixture = TestBed.createComponent(ItemRelationshipsComponent); fixture = TestBed.createComponent(ItemRelationshipsComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
@@ -266,7 +266,7 @@ describe('ItemRelationshipsComponent', () => {
}); });
it('it should delete the correct relationship', () => { it('it should delete the correct relationship', () => {
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left'); expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left', false);
}); });
}); });

View File

@@ -1,11 +1,14 @@
import { ChangeDetectorRef, Component } from '@angular/core'; import { ChangeDetectorRef, Component } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { import {
DeleteRelationship, map,
RelationshipIdentifiable, distinctUntilChanged
} from '../../../core/data/object-updates/object-updates.reducer'; } from 'rxjs/operators';
import { map, startWith, switchMap, take } from 'rxjs/operators'; import {
import { combineLatest as observableCombineLatest, of as observableOf, zip as observableZip, Observable } from 'rxjs'; Observable,
BehaviorSubject
} from 'rxjs';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
@@ -14,22 +17,17 @@ import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { RemoteData } from '../../../core/data/remote-data';
import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service'; import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { hasValue } from '../../../shared/empty.util';
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 { RelationshipTypeDataService } from '../../../core/data/relationship-type-data.service'; import { RelationshipTypeDataService } from '../../../core/data/relationship-type-data.service';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { EditItemRelationshipsService } from './edit-item-relationships.service';
import { compareArraysUsingIds } from '../../simple/item-types/shared/item-relationships-utils';
@Component({ @Component({
selector: 'ds-item-relationships', selector: 'ds-item-relationships',
@@ -50,7 +48,11 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
/** /**
* The item's entity type as an observable * The item's entity type as an observable
*/ */
entityType$: Observable<ItemType>; entityType$: BehaviorSubject<ItemType> = new BehaviorSubject(undefined);
get isSaving$(): BehaviorSubject<boolean> {
return this.editItemRelationshipsService.isSaving$;
}
constructor( constructor(
public itemService: ItemDataService, public itemService: ItemDataService,
@@ -66,6 +68,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
protected relationshipTypeService: RelationshipTypeDataService, protected relationshipTypeService: RelationshipTypeDataService,
public cdr: ChangeDetectorRef, public cdr: ChangeDetectorRef,
protected modalService: NgbModal, protected modalService: NgbModal,
protected editItemRelationshipsService: EditItemRelationshipsService,
) { ) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route); super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
} }
@@ -79,16 +82,17 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
if (label !== undefined) { if (label !== undefined) {
this.relationshipTypes$ = this.relationshipTypeService.searchByEntityType(label, true, true, ...this.getRelationshipTypeFollowLinks()) this.relationshipTypes$ = this.relationshipTypeService.searchByEntityType(label, true, true, ...this.getRelationshipTypeFollowLinks())
.pipe( .pipe(
map((relationshipTypes: PaginatedList<RelationshipType>) => relationshipTypes.page) map((relationshipTypes: PaginatedList<RelationshipType>) => relationshipTypes.page),
distinctUntilChanged(compareArraysUsingIds())
); );
this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe( this.entityTypeService.getEntityTypeByLabel(label).pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
); ).subscribe((type) => this.entityType$.next(type));
} else { } else {
this.entityType$ = observableOf(undefined); this.entityType$.next(undefined);
} }
} }
@@ -104,127 +108,24 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
* Make sure the lists are refreshed afterwards and notifications are sent for success and errors * Make sure the lists are refreshed afterwards and notifications are sent for success and errors
*/ */
public submit(): void { public submit(): void {
this.editItemRelationshipsService.submit(this.item, this.url);
// Get all the relationships that should be removed
const removedRelationshipIDs$: Observable<DeleteRelationship[]> = this.relationshipService.getItemRelationshipsArray(this.item).pipe(
startWith([]),
map((relationships: Relationship[]) => relationships.map((relationship) =>
Object.assign(new Relationship(), relationship, { uuid: relationship.id })
)),
switchMap((relationships: Relationship[]) => {
return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable<FieldUpdates>;
}),
map((fieldUpdates: FieldUpdates) =>
Object.values(fieldUpdates)
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)
.map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship)
),
);
const addRelatedItems$: Observable<RelationshipIdentifiable[]> = this.objectUpdatesService.getFieldUpdates(this.url, []).pipe(
map((fieldUpdates: FieldUpdates) =>
Object.values(fieldUpdates)
.filter((fieldUpdate: FieldUpdate) => hasValue(fieldUpdate))
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.ADD)
.map((fieldUpdate: FieldUpdate) => fieldUpdate.field as RelationshipIdentifiable)
),
);
observableCombineLatest(
removedRelationshipIDs$,
addRelatedItems$,
).pipe(
take(1),
).subscribe(([removeRelationshipIDs, addRelatedItems]) => {
const actions = [
this.deleteRelationships(removeRelationshipIDs),
this.addRelationships(addRelatedItems),
];
actions.forEach((action) =>
action.subscribe((response) => {
if (response.length > 0) {
this.initializeOriginalFields();
this.cdr.detectChanges();
this.displayNotifications(response);
this.modalService.dismissAll();
}
})
);
});
} }
deleteRelationships(deleteRelationshipIDs: DeleteRelationship[]): Observable<RemoteData<NoContent>[]> {
return observableZip(...deleteRelationshipIDs.map((deleteRelationship) => {
let copyVirtualMetadata: string;
if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'all';
} else if (deleteRelationship.keepLeftVirtualMetadata) {
copyVirtualMetadata = 'left';
} else if (deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'right';
} else {
copyVirtualMetadata = 'none';
}
return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata);
}
));
}
addRelationships(addRelatedItems: RelationshipIdentifiable[]): Observable<RemoteData<Relationship>[]> {
return observableZip(...addRelatedItems.map((addRelationship) =>
this.entityType$.pipe(
switchMap((entityType) => this.entityTypeService.isLeftType(addRelationship.type, entityType)),
switchMap((isLeftType) => {
let leftItem: Item;
let rightItem: Item;
let leftwardValue: string;
let rightwardValue: string;
if (isLeftType) {
leftItem = this.item;
rightItem = addRelationship.relatedItem;
leftwardValue = null;
rightwardValue = addRelationship.nameVariant;
} else {
leftItem = addRelationship.relatedItem;
rightItem = this.item;
leftwardValue = addRelationship.nameVariant;
rightwardValue = null;
}
return this.relationshipService.addRelationship(addRelationship.type.id, leftItem, rightItem, leftwardValue, rightwardValue);
}),
)
));
}
/**
* Display notifications
* - Error notification for each failed response with their message
* - Success notification in case there's at least one successful response
* @param responses
*/
displayNotifications(responses: RemoteData<NoContent>[]) {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
const successfulResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
failedResponses.forEach((response: RemoteData<NoContent>) => {
this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage);
});
if (successfulResponses.length > 0) {
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
}
/** /**
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
public initializeOriginalFields() { public initializeOriginalFields() {
return this.relationshipService.getRelatedItems(this.item).pipe( return this.editItemRelationshipsService.initializeOriginalFields(this.item, this.url);
take(1),
).subscribe((items: Item[]) => {
this.objectUpdatesService.initialize(this.url, items, this.item.lastModified);
});
} }
/**
* Method to prevent unnecessary for loop re-rendering
*/
trackById(index: number, relationshipType: RelationshipType): string {
return relationshipType.id;
}
getRelationshipTypeFollowLinks() { getRelationshipTypeFollowLinks() {
return [ return [
followLink('leftType'), followLink('leftType'),

View File

@@ -87,7 +87,7 @@
(click)="submitEv()"> (click)="submitEv()">
<span *ngIf="isPending" class="spinner-border spinner-border-sm" role="status" <span *ngIf="isPending" class="spinner-border spinner-border-sm" role="status"
aria-hidden="true"></span> aria-hidden="true"></span>
<i class="fas fa-save"></i> <i *ngIf="!isPending" class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button> </button>
</ng-container> </ng-container>

View File

@@ -29,6 +29,7 @@ import { RemoteDataBuildService } from '../../../../../core/cache/builders/remot
import { getAllSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; import { getAllSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { followLink } from '../../../../utils/follow-link-config.model'; import { followLink } from '../../../../utils/follow-link-config.model';
import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
@Component({ @Component({
selector: 'ds-dynamic-lookup-relation-modal', selector: 'ds-dynamic-lookup-relation-modal',
@@ -141,12 +142,12 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
/** /**
* Maintain the list of the related items to be added * Maintain the list of the related items to be added
*/ */
toAdd = []; toAdd: ItemSearchResult[] = [];
/** /**
* Maintain the list of the related items to be removed * Maintain the list of the related items to be removed
*/ */
toRemove = []; toRemove: ItemSearchResult[] = [];
/** /**
* Disable buttons while the submit button is pressed * Disable buttons while the submit button is pressed

View File

@@ -1,11 +1,18 @@
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { CacheableObject } from '../../core/cache/cacheable-object.model'; import { CacheableObject } from '../../core/cache/cacheable-object.model';
import { RemoteData } from '../../core/data/remote-data';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { FollowLinkConfig } from '../utils/follow-link-config.model';
/** /**
* Stub class for {@link BaseDataService} * Stub class for {@link BaseDataService}
*/ */
export abstract class BaseDataServiceStub<T extends CacheableObject> { export abstract class BaseDataServiceStub<T extends CacheableObject> {
findByHref(_href$: string | Observable<string>, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
return createSuccessfulRemoteDataObject$(undefined);
}
invalidateByHref(_href: string): Observable<boolean> { invalidateByHref(_href: string): Observable<boolean> {
return observableOf(true); return observableOf(true);
} }

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-empty, @typescript-eslint/no-empty-function */
import {
Observable,
Subscription,
} from 'rxjs';
import {
DeleteRelationship,
RelationshipIdentifiable,
} from '../../core/data/object-updates/object-updates.reducer';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { Relationship } from '../../core/shared/item-relationships/relationship.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
/**
* Stub class of {@link EditItemRelationshipsService}
*/
export class EditItemRelationshipsServiceStub {
submit(_item: Item, _url: string): void {
}
initializeOriginalFields(_item: Item, _url: string): Subscription {
return new Subscription();
}
deleteRelationship(_deleteRelationship: DeleteRelationship): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
}
addRelationship(_addRelationship: RelationshipIdentifiable): Observable<RemoteData<Relationship>> {
return createSuccessfulRemoteDataObject$(undefined);
}
displayNotifications(_responses: RemoteData<NoContent>[]): void {
}
getNotificationTitle(_key: string): string {
return '';
}
getNotificationContent(_key: string): string {
return '';
}
}

View File

@@ -0,0 +1,5 @@
/**
* Stub class of {@link EntityTypeDataService}
*/
export class EntityTypeDataServiceStub {
}

View File

@@ -0,0 +1,8 @@
import { Item } from '../../core/shared/item.model';
import { IdentifiableDataServiceStub } from './identifiable-data-service.stub';
/**
* Stub class of {@link ItemDataService}
*/
export class ItemDataServiceStub extends IdentifiableDataServiceStub<Item> {
}

View File

@@ -0,0 +1,24 @@
/* eslint-disable no-empty, @typescript-eslint/no-empty-function */
import {
Observable,
of as observableOf,
} from 'rxjs';
import { FieldUpdates } from '../../core/data/object-updates/field-updates.model';
import { Identifiable } from '../../core/data/object-updates/identifiable.model';
import { PatchOperationService } from '../../core/data/object-updates/patch-operation-service/patch-operation.service';
import { GenericConstructor } from '../../core/shared/generic-constructor';
/**
* Stub class of {@link ObjectUpdatesService}
*/
export class ObjectUpdatesServiceStub {
initialize(_url: string, _fields: Identifiable[], _lastModified: Date, _patchOperationService?: GenericConstructor<PatchOperationService>): void {
}
getFieldUpdates(_url: string, _initialFields: Identifiable[], _ignoreStates?: boolean): Observable<FieldUpdates> {
return observableOf({});
}
}

View File

@@ -0,0 +1,86 @@
/* eslint-disable no-empty, @typescript-eslint/no-empty-function */
import {
Observable,
of as observableOf,
} from 'rxjs';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { Relationship } from '../../core/shared/item-relationships/relationship.model';
import { MetadataValue } from '../../core/shared/metadata.models';
import { MetadataRepresentation } from '../../core/shared/metadata-representation/metadata-representation.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { FollowLinkConfig } from '../utils/follow-link-config.model';
/**
* Stub class of {@link RelationshipDataService}
*/
export class RelationshipDataServiceStub {
deleteRelationship(_id: string, _copyVirtualMetadata: string, _shouldRefresh = true): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
}
addRelationship(_typeId: string, _item1: Item, _item2: Item, _leftwardValue?: string, _rightwardValue?: string, _shouldRefresh = true): Observable<RemoteData<Relationship>> {
return createSuccessfulRemoteDataObject$(new Relationship());
}
refreshRelationshipItemsInCache(_item: Item): void {
}
getItemRelationshipsArray(_item: Item, ..._linksToFollow: FollowLinkConfig<Relationship>[]): Observable<Relationship[]> {
return observableOf([]);
}
getRelatedItems(_item: Item): Observable<Item[]> {
return observableOf([]);
}
getRelatedItemsByLabel(_item: Item, _label: string, _options?: FindListOptions): Observable<RemoteData<PaginatedList<Item>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<Item>());
}
getItemRelationshipsByLabel(_item: Item, _label: string, _options?: FindListOptions, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<Relationship>());
}
getRelationshipByItemsAndLabel(_item1: Item, _item2: Item, _label: string, _options?: FindListOptions): Observable<Relationship> {
return observableOf(new Relationship());
}
setNameVariant(_listID: string, _itemID: string, _nameVariant: string): void {
}
getNameVariant(_listID: string, _itemID: string): Observable<string> {
return observableOf('');
}
updateNameVariant(_item1: Item, _item2: Item, _relationshipLabel: string, _nameVariant: string): Observable<RemoteData<Relationship>> {
return createSuccessfulRemoteDataObject$(new Relationship());
}
isLeftItem(_relationship: Relationship, _item: Item): Observable<boolean> {
return observableOf(false);
}
update(_object: Relationship): Observable<RemoteData<Relationship>> {
return createSuccessfulRemoteDataObject$(new Relationship());
}
searchByItemsAndType(_typeId: string, _itemUuid: string, _relationshipLabel: string, _arrayOfItemIds: string[]): Observable<RemoteData<PaginatedList<Relationship>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<Relationship>());
}
searchBy(_searchMethod: string, _options?: FindListOptions, _useCachedVersionIfAvailable?: boolean, _reRequestOnStale?: boolean, ..._linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<Relationship>());
}
resolveMetadataRepresentation(_metadatum: MetadataValue, _parentItem: DSpaceObject, _itemType: string): Observable<MetadataRepresentation> {
return observableOf({} as MetadataRepresentation);
}
}