Merge pull request #530 from atmire/Keep-virtual-metadata-on-relationship-delete

Keep virtual metadata on relationship delete
This commit is contained in:
Tim Donohue
2020-01-28 10:34:07 -06:00
committed by GitHub
28 changed files with 1071 additions and 280 deletions

View File

@@ -13,7 +13,7 @@ describe('protractor App', () => {
}); });
it('should contain a news section', () => { it('should contain a news section', () => {
page.navigateTo(); page.navigateTo()
expect<any>(page.getHomePageNewsText()).toBeDefined(); .then(() => expect<any>(page.getHomePageNewsText()).toBeDefined());
}); });
}); });

View File

@@ -11,6 +11,6 @@ export class ProtractorPage {
} }
getHomePageNewsText() { getHomePageNewsText() {
return element(by.xpath('//ds-home-news')).getText(); return element(by.css('ds-home-news')).getText();
} }
} }

View File

@@ -11,33 +11,36 @@ describe('protractor SearchPage', () => {
it('should contain query value when navigating to page with query parameter', () => { it('should contain query value when navigating to page with query parameter', () => {
const queryString = 'Interesting query string'; const queryString = 'Interesting query string';
page.navigateToSearchWithQueryParameter(queryString); page.navigateToSearchWithQueryParameter(queryString)
page.getCurrentQuery().then((query: string) => { .then(() => page.getCurrentQuery())
expect<string>(query).toEqual(queryString); .then((query: string) => {
}); expect<string>(query).toEqual(queryString);
});
}); });
it('should have right scope selected when navigating to page with scope parameter', () => { it('should have right scope selected when navigating to page with scope parameter', () => {
const scope: promise.Promise<string> = page.getRandomScopeOption(); page.navigateToSearch()
scope.then((scopeString: string) => { .then(() => page.getRandomScopeOption())
page.navigateToSearchWithScopeParameter(scopeString); .then((scopeString: string) => {
page.getCurrentScope().then((s: string) => { page.navigateToSearchWithScopeParameter(scopeString);
expect<string>(s).toEqual(scopeString); page.getCurrentScope().then((s: string) => {
expect<string>(s).toEqual(scopeString);
});
}); });
});
}); });
it('should redirect to the correct url when scope was set and submit button was triggered', () => { it('should redirect to the correct url when scope was set and submit button was triggered', () => {
const scope: promise.Promise<string> = page.getRandomScopeOption(); page.navigateToSearch()
scope.then((scopeString: string) => { .then(() => page.getRandomScopeOption())
page.setCurrentScope(scopeString); .then((scopeString: string) => {
page.submitSearchForm(); page.setCurrentScope(scopeString);
browser.wait(() => { page.submitSearchForm();
return browser.getCurrentUrl().then((url: string) => { browser.wait(() => {
return url.indexOf('scope=' + encodeURI(scopeString)) !== -1; return browser.getCurrentUrl().then((url: string) => {
return url.indexOf('scope=' + encodeURI(scopeString)) !== -1;
});
}); });
}); });
});
}); });
it('should redirect to the correct url when query was set and submit button was triggered', () => { it('should redirect to the correct url when query was set and submit button was triggered', () => {

View File

@@ -1976,5 +1976,6 @@
"uploader.queue-length": "Queue length", "uploader.queue-length": "Queue length",
"virtual-metadata.delete-relationship.modal-head": "Select the items for which you want to save the virtual metadata as real metadata",
} }

View File

@@ -21,6 +21,7 @@ import { ItemRelationshipsComponent } from './item-relationships/item-relationsh
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
/** /**
* Module that contains all components related to the Edit Item page administrator functionality * Module that contains all components related to the Edit Item page administrator functionality
@@ -51,6 +52,7 @@ import { ItemMoveComponent } from './item-move/item-move.component';
EditRelationshipListComponent, EditRelationshipListComponent,
ItemCollectionMapperComponent, ItemCollectionMapperComponent,
ItemMoveComponent, ItemMoveComponent,
VirtualMetadataComponent,
] ]
}) })
export class EditItemPageModule { export class EditItemPageModule {

View File

@@ -1,15 +1,15 @@
<ng-container *ngVar="(updates$ | async) as updates"> <h5>{{getRelationshipMessageKey() | async | translate}}</h5>
<div *ngIf="updates"> <ng-container *ngVar="updates$ | async as updates">
<h5>{{getRelationshipMessageKey(relationshipLabel) | translate}}</h5> <ng-container *ngIf="updates">
<ng-container *ngVar="(updates | dsObjectValues) as updateValues"> <ng-container *ngVar="updates | dsObjectValues as updateValues">
<div *ngFor="let updateValue of updateValues; trackBy: trackUpdate" <ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
ds-edit-relationship class="relationship-row d-block"
class="relationship-row d-block" [fieldUpdate]="updateValue"
[fieldUpdate]="updateValue || {}" [url]="url"
[url]="url" [editItem]="item"
[ngClass]="{'alert alert-danger': updateValue.changeType === 2}"> [ngClass]="{'alert alert-danger': updateValue?.changeType === 2}">
</div> </ds-edit-relationship>
<ds-loading *ngIf="updateValues.length == 0" message="{{'loading.items' | translate}}"></ds-loading> </ng-container>
</ng-container> </ng-container>
</div> <div *ngIf="!updates">no relationships</div>
</ng-container> </ng-container>

View File

@@ -1,27 +1,26 @@
import { EditRelationshipListComponent } from './edit-relationship-list.component'; import {EditRelationshipListComponent} from './edit-relationship-list.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model';
import { ResourceType } from '../../../../core/shared/resource-type'; import {Relationship} from '../../../../core/shared/item-relationships/relationship.model';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import {of as observableOf} from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs/internal/observable/of'; import {RemoteData} from '../../../../core/data/remote-data';
import { RemoteData } from '../../../../core/data/remote-data'; import {Item} from '../../../../core/shared/item.model';
import { Item } from '../../../../core/shared/item.model'; import {PaginatedList} from '../../../../core/data/paginated-list';
import { PaginatedList } from '../../../../core/data/paginated-list'; import {PageInfo} from '../../../../core/shared/page-info.model';
import { PageInfo } from '../../../../core/shared/page-info.model'; import {FieldChangeType} from '../../../../core/data/object-updates/object-updates.actions';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import {SharedModule} from '../../../../shared/shared.module';
import { SharedModule } from '../../../../shared/shared.module'; import {TranslateModule} from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core'; import {ObjectUpdatesService} from '../../../../core/data/object-updates/object-updates.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import { RelationshipService } from '../../../../core/data/relationship.service'; import {By} from '@angular/platform-browser';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import {ItemType} from '../../../../core/shared/item-relationships/item-type.model';
import { By } from '@angular/platform-browser';
let comp: EditRelationshipListComponent; let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>; let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement; let de: DebugElement;
let objectUpdatesService; let objectUpdatesService;
let relationshipService; let entityTypeService;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
@@ -30,42 +29,66 @@ let author1;
let author2; let author2;
let fieldUpdate1; let fieldUpdate1;
let fieldUpdate2; let fieldUpdate2;
let relationships; let relationship1;
let relationship2;
let relationshipType; let relationshipType;
let entityType;
let relatedEntityType;
describe('EditRelationshipListComponent', () => { describe('EditRelationshipListComponent', () => {
beforeEach(async(() => {
beforeEach(() => {
entityType = Object.assign(new ItemType(), {
id: 'entityType',
});
relatedEntityType = Object.assign(new ItemType(), {
id: 'relatedEntityType',
});
relationshipType = Object.assign(new RelationshipType(), { relationshipType = Object.assign(new RelationshipType(), {
id: '1', id: '1',
uuid: '1', uuid: '1',
leftwardType: 'isAuthorOfPublication', leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor' rightwardType: 'isPublicationOfAuthor',
leftType: observableOf(new RemoteData(false, false, true, undefined, entityType)),
rightType: observableOf(new RemoteData(false, false, true, undefined, relatedEntityType)),
}); });
relationships = [ relationship1 = Object.assign(new Relationship(), {
Object.assign(new Relationship(), { self: url + '/2',
self: url + '/2', id: '2',
id: '2', uuid: '2',
uuid: '2', leftId: 'author1',
leftId: 'author1', rightId: 'publication',
rightId: 'publication', leftItem: observableOf(new RemoteData(false, false, true, undefined, item)),
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) rightItem: observableOf(new RemoteData(false, false, true, undefined, author1)),
}), relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
Object.assign(new Relationship(), { });
self: url + '/3',
id: '3', relationship2 = Object.assign(new Relationship(), {
uuid: '3', self: url + '/3',
leftId: 'author2', id: '3',
rightId: 'publication', uuid: '3',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) leftId: 'author2',
}) rightId: 'publication',
]; leftItem: observableOf(new RemoteData(false, false, true, undefined, item)),
rightItem: observableOf(new RemoteData(false, false, true, undefined, author2)),
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
});
item = Object.assign(new Item(), { item = Object.assign(new Item(), {
self: 'fake-item-url/publication', self: 'fake-item-url/publication',
id: 'publication', id: 'publication',
uuid: 'publication', uuid: 'publication',
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) relationships: observableOf(new RemoteData(
false,
false,
true,
undefined,
new PaginatedList(new PageInfo(), [relationship1, relationship2])
))
}); });
author1 = Object.assign(new Item(), { author1 = Object.assign(new Item(), {
@@ -88,16 +111,29 @@ describe('EditRelationshipListComponent', () => {
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{ {
getFieldUpdatesExclusive: observableOf({ getFieldUpdates: observableOf({
[author1.uuid]: fieldUpdate1, [author1.uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2 [author2.uuid]: fieldUpdate2
}) })
} }
); );
relationshipService = jasmine.createSpyObj('relationshipService', entityTypeService = jasmine.createSpyObj('entityTypeService',
{ {
getRelatedItemsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [author1, author2]))), getEntityTypeByLabel: observableOf(new RemoteData(
false,
false,
true,
null,
entityType,
)),
getEntityTypeRelationships: observableOf(new RemoteData(
false,
false,
true,
null,
new PaginatedList(new PageInfo(), [relationshipType]),
)),
} }
); );
@@ -106,29 +142,27 @@ describe('EditRelationshipListComponent', () => {
declarations: [EditRelationshipListComponent], declarations: [EditRelationshipListComponent],
providers: [ providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: RelationshipService, useValue: relationshipService }
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
}).compileComponents(); }).compileComponents();
}));
beforeEach(() => {
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 = item;
comp.itemType = entityType;
comp.url = url; comp.url = url;
comp.relationshipLabel = relationshipType.leftwardType; comp.relationshipType = relationshipType;
fixture.detectChanges(); fixture.detectChanges();
}); });
describe('changeType is REMOVE', () => { describe('changeType is REMOVE', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('the div should have class alert-danger', () => { it('the div should have class alert-danger', () => {
fieldUpdate1.changeType = FieldChangeType.REMOVE;
const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement;
expect(element.classList).toContain('alert-danger'); expect(element.classList).toContain('alert-danger');
}); });

View File

@@ -1,13 +1,15 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer'; import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipService } from '../../../../core/data/relationship.service'; import {Item} from '../../../../core/shared/item.model';
import { Item } from '../../../../core/shared/item.model'; import {map, switchMap} from 'rxjs/operators';
import { map, switchMap } from 'rxjs/operators'; import {hasValue} from '../../../../shared/empty.util';
import { hasValue } from '../../../../shared/empty.util'; import {Relationship} from '../../../../core/shared/item-relationships/relationship.model';
import { RemoteData } from '../../../../core/data/remote-data'; import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model';
import { PaginatedList } from '../../../../core/data/paginated-list'; import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators';
import {combineLatest as observableCombineLatest, combineLatest} from 'rxjs';
import {ItemType} from '../../../../core/shared/item-relationships/item-type.model';
@Component({ @Component({
selector: 'ds-edit-relationship-list', selector: 'ds-edit-relationship-list',
@@ -18,12 +20,15 @@ import { PaginatedList } from '../../../../core/data/paginated-list';
* A component creating a list of editable relationships of a certain type * A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items * The relationships are rendered as a list of related items
*/ */
export class EditRelationshipListComponent implements OnInit, OnChanges { export class EditRelationshipListComponent implements OnInit {
/** /**
* The item to display related items for * The item to display related items for
*/ */
@Input() item: Item; @Input() item: Item;
@Input() itemType: ItemType;
/** /**
* The URL to the current page * The URL to the current page
* Used to fetch updates for the current item from the store * Used to fetch updates for the current item from the store
@@ -33,7 +38,7 @@ export class EditRelationshipListComponent implements OnInit, OnChanges {
/** /**
* The label of the relationship-type we're rendering a list for * The label of the relationship-type we're rendering a list for
*/ */
@Input() relationshipLabel: string; @Input() relationshipType: RelationshipType;
/** /**
* The FieldUpdates for the relationships in question * The FieldUpdates for the relationships in question
@@ -42,53 +47,42 @@ export class EditRelationshipListComponent implements OnInit, OnChanges {
constructor( constructor(
protected objectUpdatesService: ObjectUpdatesService, protected objectUpdatesService: ObjectUpdatesService,
protected relationshipService: RelationshipService
) { ) {
} }
ngOnInit(): void { /**
this.initUpdates(); * Get the i18n message key for this relationship type
} */
public getRelationshipMessageKey(): Observable<string> {
ngOnChanges(changes: SimpleChanges): void { return this.getLabel().pipe(
this.initUpdates(); map((label) => {
if (hasValue(label) && label.indexOf('Of') > -1) {
return `relationships.${label.substring(0, label.indexOf('Of') + 2)}`
} else {
return label;
}
}),
);
} }
/** /**
* Initialize the FieldUpdates using the related items * Get the relevant label for this relationship type
*/ */
initUpdates() { private getLabel(): Observable<string> {
this.updates$ = this.getUpdatesByLabel(this.relationshipLabel);
}
/** return combineLatest([
* Transform the item's relationships of a specific type into related items this.relationshipType.leftType,
* @param label The relationship type's label this.relationshipType.rightType,
*/ ].map((itemTypeRD) => itemTypeRD.pipe(
public getRelatedItemsByLabel(label: string): Observable<RemoteData<PaginatedList<Item>>> { getSucceededRemoteData(),
return this.relationshipService.getRelatedItemsByLabel(this.item, label); getRemoteDataPayload(),
} ))).pipe(
map((itemTypes) => [
/** this.relationshipType.leftwardType,
* Get FieldUpdates for the relationships of a specific type this.relationshipType.rightwardType,
* @param label The relationship type's label ][itemTypes.findIndex((itemType) => itemType.id === this.itemType.id)]),
*/ );
public getUpdatesByLabel(label: string): Observable<FieldUpdates> {
return this.getRelatedItemsByLabel(label).pipe(
switchMap((itemsRD) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, itemsRD.payload.page))
)
}
/**
* Get the i18n message key for a relationship
* @param label The relationship type's label
*/
public getRelationshipMessageKey(label: string): string {
if (hasValue(label) && label.indexOf('Of') > -1) {
return `relationships.${label.substring(0, label.indexOf('Of') + 2)}`
} else {
return label;
}
} }
/** /**
@@ -98,4 +92,26 @@ export class EditRelationshipListComponent implements OnInit, OnChanges {
return update && update.field ? update.field.uuid : undefined; return update && update.field ? update.field.uuid : undefined;
} }
ngOnInit(): void {
this.updates$ = this.item.relationships.pipe(
map((relationships) => relationships.payload.page.filter((relationship) => relationship)),
switchMap((itemRelationships) =>
observableCombineLatest(
itemRelationships
.map((relationship) => relationship.relationshipType.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
))
).pipe(
map((relationshipTypes) => itemRelationships.filter(
(relationship, index) => relationshipTypes[index].id === this.relationshipType.id)
),
map((relationships) => relationships.map((relationship) =>
Object.assign(new Relationship(), relationship, {uuid: relationship.id})
)),
)
),
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields)),
);
}
} }

View File

@@ -1,10 +1,10 @@
<div class="row" *ngIf="item"> <div class="row" *ngIf="relatedItem$ | async">
<div class="col-10 relationship"> <div class="col-10 relationship">
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader> <ds-listable-object-component-loader [object]="relatedItem$ | async" [viewMode]="viewMode"></ds-listable-object-component-loader>
</div> </div>
<div class="col-2"> <div class="col-2">
<div class="btn-group relationship-action-buttons"> <div class="btn-group relationship-action-buttons">
<button [disabled]="!canRemove()" (click)="remove()" <button [disabled]="!canRemove()" (click)="openVirtualMetadataModal(virtualMetadataModal)"
class="btn btn-outline-danger btn-sm" class="btn btn-outline-danger btn-sm"
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}"> title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
@@ -17,3 +17,14 @@
</div> </div>
</div> </div>
</div> </div>
<ng-template #virtualMetadataModal>
<ds-virtual-metadata
[relationshipId]="relationship.id"
[leftItem]="leftItem$ | async"
[rightItem]="rightItem$ | async"
[url]="url"
(close)="closeVirtualMetadataModal()"
(save)="remove()"
>
</ds-virtual-metadata>
</ng-template>

View File

@@ -11,11 +11,13 @@ import { Item } from '../../../../core/shared/item.model';
import { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
let objectUpdatesService: ObjectUpdatesService; let objectUpdatesService;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
let item; let item;
let relatedItem;
let author1; let author1;
let author2; let author2;
let fieldUpdate1; let fieldUpdate1;
@@ -29,7 +31,9 @@ let de;
let el; let el;
describe('EditRelationshipComponent', () => { describe('EditRelationshipComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
relationshipType = Object.assign(new RelationshipType(), { relationshipType = Object.assign(new RelationshipType(), {
id: '1', id: '1',
uuid: '1', uuid: '1',
@@ -37,6 +41,17 @@ describe('EditRelationshipComponent', () => {
rightwardType: 'isPublicationOfAuthor' rightwardType: 'isPublicationOfAuthor'
}); });
item = Object.assign(new Item(), {
self: 'fake-item-url/publication',
id: 'publication',
uuid: 'publication',
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships)))
});
relatedItem = Object.assign(new Item(), {
uuid: 'related item id',
});
relationships = [ relationships = [
Object.assign(new Relationship(), { Object.assign(new Relationship(), {
self: url + '/2', self: url + '/2',
@@ -44,7 +59,9 @@ describe('EditRelationshipComponent', () => {
uuid: '2', uuid: '2',
leftId: 'author1', leftId: 'author1',
rightId: 'publication', rightId: 'publication',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)),
leftItem: observableOf(new RemoteData(false, false, true, undefined, relatedItem)),
rightItem: observableOf(new RemoteData(false, false, true, undefined, item)),
}), }),
Object.assign(new Relationship(), { Object.assign(new Relationship(), {
self: url + '/3', self: url + '/3',
@@ -56,13 +73,6 @@ describe('EditRelationshipComponent', () => {
}) })
]; ];
item = Object.assign(new Item(), {
self: 'fake-item-url/publication',
id: 'publication',
uuid: 'publication',
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships)))
});
author1 = Object.assign(new Item(), { author1 = Object.assign(new Item(), {
id: 'author1', id: 'author1',
uuid: 'author1' uuid: 'author1'
@@ -73,38 +83,44 @@ describe('EditRelationshipComponent', () => {
}); });
fieldUpdate1 = { fieldUpdate1 = {
field: author1, field: relationships[0],
changeType: undefined changeType: undefined
}; };
fieldUpdate2 = { fieldUpdate2 = {
field: author2, field: relationships[1],
changeType: FieldChangeType.REMOVE changeType: FieldChangeType.REMOVE
}; };
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', const itemSelection = {};
{ itemSelection[relatedItem.uuid] = false;
saveChangeFieldUpdate: {}, itemSelection[item.uuid] = true;
saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {}, objectUpdatesService = {
setValidFieldUpdate: {}, isSelectedVirtualMetadata: () => null,
removeSingleFieldUpdate: {}, removeSingleFieldUpdate: jasmine.createSpy('removeSingleFieldUpdate'),
isEditable: observableOf(false), // should always return something --> its in ngOnInit saveRemoveFieldUpdate: jasmine.createSpy('saveRemoveFieldUpdate'),
isValid: observableOf(true) // should always return something --> its in ngOnInit };
}
); spyOn(objectUpdatesService, 'isSelectedVirtualMetadata').and.callFake((a, b, uuid) => observableOf(itemSelection[uuid]));
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [EditRelationshipComponent], declarations: [EditRelationshipComponent],
providers: [ providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService } { provide: ObjectUpdatesService, useValue: objectUpdatesService },
], schemas: [ { provide: NgbModal, useValue: {
open: () => {/*comment*/
}
},
},
], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipComponent); fixture = TestBed.createComponent(EditRelationshipComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
@@ -112,7 +128,8 @@ describe('EditRelationshipComponent', () => {
comp.url = url; comp.url = url;
comp.fieldUpdate = fieldUpdate1; comp.fieldUpdate = fieldUpdate1;
comp.item = item; comp.editItem = item;
comp.relatedItem$ = observableOf(relatedItem);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -156,23 +173,30 @@ describe('EditRelationshipComponent', () => {
}); });
describe('remove', () => { describe('remove', () => {
beforeEach(() => { beforeEach(() => {
spyOn(comp, 'closeVirtualMetadataModal');
comp.ngOnChanges();
comp.remove(); comp.remove();
}); });
it('should call saveRemoveFieldUpdate with the correct arguments', () => { it('should close the virtual metadata modal and call saveRemoveFieldUpdate with the correct arguments', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); expect(comp.closeVirtualMetadataModal).toHaveBeenCalled();
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(
url,
Object.assign({}, fieldUpdate1.field, {
keepLeftVirtualMetadata: false,
keepRightVirtualMetadata: true,
}),
);
}); });
}); });
describe('undo', () => { describe('undo', () => {
beforeEach(() => {
comp.undo();
});
it('should call removeSingleFieldUpdate with the correct arguments', () => { it('should call removeSingleFieldUpdate with the correct arguments', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid); comp.undo();
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, relationships[0].uuid);
}); });
}); });
}); });

View File

@@ -1,14 +1,19 @@
import { Component, Input, OnChanges } from '@angular/core'; import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { cloneDeep } from 'lodash'; import { filter, map, switchMap, take, tap } from 'rxjs/operators';
import { Item } from '../../../../core/shared/item.model';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { DeleteRelationship, FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
@Component({ @Component({
// tslint:disable-next-line:component-selector // tslint:disable-next-line:component-selector
selector: '[ds-edit-relationship]', selector: 'ds-edit-relationship',
styleUrls: ['./edit-relationship.component.scss'], styleUrls: ['./edit-relationship.component.scss'],
templateUrl: './edit-relationship.component.html', templateUrl: './edit-relationship.component.html',
}) })
@@ -23,38 +28,108 @@ export class EditRelationshipComponent implements OnChanges {
*/ */
@Input() url: string; @Input() url: string;
/**
* The item being edited
*/
@Input() editItem: Item;
/**
* The relationship being edited
*/
get relationship(): Relationship {
return this.fieldUpdate.field as Relationship;
}
private leftItem$: Observable<Item>;
private rightItem$: Observable<Item>;
/** /**
* The related item of this relationship * The related item of this relationship
*/ */
item: Item; relatedItem$: Observable<Item>;
/** /**
* The view-mode we're currently on * The view-mode we're currently on
*/ */
viewMode = ViewMode.ListElement; viewMode = ViewMode.ListElement;
constructor(private objectUpdatesService: ObjectUpdatesService) { /**
* Reference to NgbModal
*/
public modalRef: NgbModalRef;
constructor(
private objectUpdatesService: ObjectUpdatesService,
private modalService: NgbModal,
) {
} }
/** /**
* Sets the current relationship based on the fieldUpdate input field * Sets the current relationship based on the fieldUpdate input field
*/ */
ngOnChanges(): void { ngOnChanges(): void {
this.item = cloneDeep(this.fieldUpdate.field) as Item; this.leftItem$ = this.relationship.leftItem.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid))
);
this.rightItem$ = this.relationship.rightItem.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid))
);
this.relatedItem$ = observableCombineLatest(
this.leftItem$,
this.rightItem$,
).pipe(
map((items: Item[]) =>
items.find((item) => item.uuid !== this.editItem.uuid)
)
);
} }
/** /**
* Sends a new remove update for this field to the object updates service * Sends a new remove update for this field to the object updates service
*/ */
remove(): void { remove(): void {
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); this.closeVirtualMetadataModal();
observableCombineLatest(
this.leftItem$,
this.rightItem$,
).pipe(
map((items: Item[]) =>
items.map((item) => this.objectUpdatesService
.isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid))
),
switchMap((selection$) => observableCombineLatest(selection$)),
map((selection: boolean[]) => {
return Object.assign({},
this.fieldUpdate.field,
{
keepLeftVirtualMetadata: selection[0] === true,
keepRightVirtualMetadata: selection[1] === true,
}
) as DeleteRelationship
}),
take(1),
).subscribe((deleteRelationship: DeleteRelationship) =>
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship)
);
}
openVirtualMetadataModal(content: any) {
this.modalRef = this.modalService.open(content);
}
closeVirtualMetadataModal() {
this.modalRef.close();
} }
/** /**
* Cancels the current update for this field in the object updates service * Cancels the current update for this field in the object updates service
*/ */
undo(): void { undo(): void {
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.fieldUpdate.field.uuid);
} }
/** /**

View File

@@ -17,8 +17,13 @@
<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>
<div *ngFor="let label of relationLabels$ | async" class="mb-4"> <div *ngFor="let relationshipType of relationshipTypes$ | async" class="mb-4">
<ds-edit-relationship-list [item]="item" [url]="url" [relationshipLabel]="label" ></ds-edit-relationship-list> <ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="entityType$ | async"
[relationshipType]="relationshipType"
></ds-edit-relationship-list>
</div> </div>
<div class="button-row bottom"> <div class="button-row bottom">
<div class="float-right"> <div class="float-right">

View File

@@ -13,9 +13,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GLOBAL_CONFIG } from '../../../../config'; import { GLOBAL_CONFIG } from '../../../../config';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { ResourceType } from '../../../core/shared/resource-type';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
@@ -26,6 +25,8 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { RestResponse } from '../../../core/cache/response.models'; import { RestResponse } from '../../../core/cache/response.models';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { EntityTypeService } from '../../../core/data/entity-type.service';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
let comp: any; let comp: any;
let fixture: ComponentFixture<ItemRelationshipsComponent>; let fixture: ComponentFixture<ItemRelationshipsComponent>;
@@ -34,6 +35,7 @@ let el: HTMLElement;
let objectUpdatesService; let objectUpdatesService;
let relationshipService; let relationshipService;
let requestService; let requestService;
let entityTypeService;
let objectCache; let objectCache;
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
@@ -58,6 +60,7 @@ let author1;
let author2; let author2;
let fieldUpdate1; let fieldUpdate1;
let fieldUpdate2; let fieldUpdate2;
let entityType;
let relationships; let relationships;
let relationshipType; let relationshipType;
@@ -95,6 +98,10 @@ describe('ItemRelationshipsComponent', () => {
lastModified: date lastModified: date
}); });
entityType = Object.assign(new ItemType(), {
id: 'entityType',
});
author1 = Object.assign(new Item(), { author1 = Object.assign(new Item(), {
id: 'author1', id: 'author1',
uuid: 'author1' uuid: 'author1'
@@ -110,11 +117,14 @@ describe('ItemRelationshipsComponent', () => {
relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item));
fieldUpdate1 = { fieldUpdate1 = {
field: author1, field: relationships[0],
changeType: undefined changeType: undefined
}; };
fieldUpdate2 = { fieldUpdate2 = {
field: author2, field: Object.assign(
relationships[1],
{keepLeftVirtualMetadata: true, keepRightVirtualMetadata: false}
),
changeType: FieldChangeType.REMOVE changeType: FieldChangeType.REMOVE
}; };
@@ -130,12 +140,12 @@ describe('ItemRelationshipsComponent', () => {
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{ {
getFieldUpdates: observableOf({ getFieldUpdates: observableOf({
[author1.uuid]: fieldUpdate1, [relationships[0].uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2 [relationships[1].uuid]: fieldUpdate2
}), }),
getFieldUpdatesExclusive: observableOf({ getFieldUpdatesExclusive: observableOf({
[author1.uuid]: fieldUpdate1, [relationships[0].uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2 [relationships[1].uuid]: fieldUpdate2
}), }),
saveAddFieldUpdate: {}, saveAddFieldUpdate: {},
discardFieldUpdates: {}, discardFieldUpdates: {},
@@ -173,6 +183,25 @@ describe('ItemRelationshipsComponent', () => {
remove: undefined remove: undefined
}); });
entityTypeService = jasmine.createSpyObj('entityTypeService',
{
getEntityTypeByLabel: observableOf(new RemoteData(
false,
false,
true,
null,
entityType,
)),
getEntityTypeRelationships: observableOf(new RemoteData(
false,
false,
true,
null,
new PaginatedList(new PageInfo(), [relationshipType]),
)),
}
);
scheduler = getTestScheduler(); scheduler = getTestScheduler();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
@@ -185,6 +214,7 @@ describe('ItemRelationshipsComponent', () => {
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
{ provide: RelationshipService, useValue: relationshipService }, { provide: RelationshipService, useValue: relationshipService },
{ provide: EntityTypeService, useValue: entityTypeService },
{ provide: ObjectCacheService, useValue: objectCache }, { provide: ObjectCacheService, useValue: objectCache },
{ provide: RequestService, useValue: requestService }, { provide: RequestService, useValue: requestService },
ChangeDetectorRef ChangeDetectorRef
@@ -229,7 +259,7 @@ describe('ItemRelationshipsComponent', () => {
}); });
it('it should delete the correct relationship', () => { it('it should delete the correct relationship', () => {
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left');
}); });
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { DeleteRelationship, FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
import { zip as observableZip } from 'rxjs'; import { zip as observableZip } from 'rxjs';
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';
@@ -12,15 +12,18 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { RelationshipService } from '../../../core/data/relationship.service'; import { RelationshipService } from '../../../core/data/relationship.service';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
import { isNotEmptyOperator } from '../../../shared/empty.util';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getSucceededRemoteData } from '../../../core/shared/operators'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { Subscription } from 'rxjs/internal/Subscription'; import { Subscription } from 'rxjs/internal/Subscription';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { EntityTypeService } from '../../../core/data/entity-type.service';
import { isNotEmptyOperator } from '../../../shared/empty.util';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
@Component({ @Component({
selector: 'ds-item-relationships', selector: 'ds-item-relationships',
@@ -35,13 +38,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
/** /**
* The labels of all different relations within this item * The labels of all different relations within this item
*/ */
relationLabels$: Observable<string[]>; relationshipTypes$: Observable<RelationshipType[]>;
/** /**
* A subscription that checks when the item is deleted in cache and reloads the item by sending a new request * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request
* This is used to update the item in cache after relationships are deleted * This is used to update the item in cache after relationships are deleted
*/ */
itemUpdateSubscription: Subscription; itemUpdateSubscription: Subscription;
entityType$: Observable<ItemType>;
constructor( constructor(
protected itemService: ItemDataService, protected itemService: ItemDataService,
@@ -54,7 +58,8 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
protected relationshipService: RelationshipService, protected relationshipService: RelationshipService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected cdRef: ChangeDetectorRef protected entityTypeService: EntityTypeService,
protected cdr: ChangeDetectorRef,
) { ) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
} }
@@ -64,21 +69,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit(); super.ngOnInit();
this.relationLabels$ = this.relationshipService.getRelationshipTypeLabelsByItem(this.item);
this.initializeItemUpdate();
}
/**
* Update the item (and view) when it's removed in the request cache
*/
public initializeItemUpdate(): void {
this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe(
filter((exists: boolean) => !exists), filter((exists: boolean) => !exists),
switchMap(() => this.itemService.findById(this.item.uuid)), switchMap(() => this.itemService.findById(this.item.uuid)),
getSucceededRemoteData(), getSucceededRemoteData(),
).subscribe((itemRD: RemoteData<Item>) => { ).subscribe((itemRD: RemoteData<Item>) => {
this.item = itemRD.payload; this.item = itemRD.payload;
this.cdRef.detectChanges(); this.cdr.detectChanges();
this.initializeUpdates();
}); });
} }
@@ -86,8 +84,22 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
* Initialize the values and updates of the current item's relationship fields * Initialize the values and updates of the current item's relationship fields
*/ */
public initializeUpdates(): void { public initializeUpdates(): void {
this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe(
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items)) this.entityType$ = this.entityTypeService.getEntityTypeByLabel(
this.item.firstMetadataValue('relationship.type')
).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
);
this.relationshipTypes$ = this.entityType$.pipe(
switchMap((entityType) =>
this.entityTypeService.getEntityTypeRelationships(entityType.id).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
)
),
); );
} }
@@ -103,26 +115,41 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
* 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 {
// Get all IDs of related items of which their relationship with the current item is about to be removed
const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe(
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable<FieldUpdates>),
map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)),
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]),
isNotEmptyOperator()
);
// Get all the relationships that should be removed // Get all the relationships that should be removed
const removedRelationships$ = removedItemIds$.pipe( this.relationshipService.getItemRelationshipsArray(this.item).pipe(
flatMap((uuids) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids)) map((relationships: Relationship[]) => relationships.map((relationship) =>
); Object.assign(new Relationship(), relationship, {uuid: relationship.id})
// const removedRelationships$ = removedItemIds$.pipe(flatMap((uuids: string[]) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids))); )),
// Request a delete for every relationship found in the observable created above switchMap((relationships: Relationship[]) => {
removedRelationships$.pipe( 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)
),
isNotEmptyOperator(),
take(1), take(1),
map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), switchMap((deleteRelationships: DeleteRelationship[]) =>
switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) observableZip(...deleteRelationships.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);
}
))
),
).subscribe((responses: RestResponse[]) => { ).subscribe((responses: RestResponse[]) => {
this.displayNotifications(responses); this.itemUpdateSubscription.add(() => {
this.reset(); this.displayNotifications(responses);
});
}); });
} }
@@ -144,22 +171,12 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
} }
} }
/**
* Re-initialize fields and subscriptions
*/
reset() {
this.initializeOriginalFields();
this.initializeUpdates();
this.initializeItemUpdate();
}
/** /**
* 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() {
this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => { const initialFields = [];
this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); this.objectUpdatesService.initialize(this.url, initialFields, this.item.lastModified);
});
} }
/** /**
@@ -168,5 +185,4 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
ngOnDestroy(): void { ngOnDestroy(): void {
this.itemUpdateSubscription.unsubscribe(); this.itemUpdateSubscription.unsubscribe();
} }
} }

View File

@@ -0,0 +1,38 @@
<div>
<div class="modal-header">{{'virtual-metadata.delete-relationship.modal-head' | translate}}
<button type="button" class="close" (click)="close.emit()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<ng-container *ngFor="let item of items; trackBy: trackItem">
<div *ngVar="(isSelectedVirtualMetadataItem(item) | async) as selected"
(click)="setSelectedVirtualMetadataItem(item, !selected)"
class="item d-flex flex-row">
<div class="m-2">
<label>
<input class="select" type="checkbox" [checked]="selected">
</label>
</div>
<div class="flex-column">
<ds-listable-object-component-loader [object]="item">
</ds-listable-object-component-loader>
<div *ngFor="let metadata of virtualMetadata.get(item.uuid)">
<div class="font-weight-bold">
{{metadata.metadataField}}
</div>
<div>
{{metadata.metadataValue.value}}
</div>
</div>
</div>
</div>
</ng-container>
<div class="d-flex flex-row-reverse m-2">
<button class="btn btn-primary save"
(click)="save.emit()">
<i class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {of as observableOf} from 'rxjs/internal/observable/of';
import {TranslateModule} from '@ngx-translate/core';
import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser';
import {VirtualMetadataComponent} from './virtual-metadata.component';
import {Item} from '../../../core/shared/item.model';
import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service';
import {VarDirective} from '../../../shared/utils/var.directive';
describe('VirtualMetadataComponent', () => {
let comp: VirtualMetadataComponent;
let fixture: ComponentFixture<VirtualMetadataComponent>;
let de: DebugElement;
let objectUpdatesService;
const url = 'http://test-url.com/test-url';
let item;
let relatedItem;
let relationshipId;
beforeEach(() => {
relationshipId = 'relationship id';
item = Object.assign(new Item(), {
uuid: 'publication',
metadata: [],
});
relatedItem = Object.assign(new Item(), {
uuid: 'relatedItem',
metadata: [],
});
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', {
isSelectedVirtualMetadata: observableOf(false),
setSelectedVirtualMetadata: null,
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [VirtualMetadataComponent, VarDirective],
providers: [
{provide: ObjectUpdatesService, useValue: objectUpdatesService},
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
fixture = TestBed.createComponent(VirtualMetadataComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.url = url;
comp.leftItem = item;
comp.rightItem = relatedItem;
comp.relationshipId = relationshipId;
fixture.detectChanges();
});
describe('when clicking the save button', () => {
it('should emit a save event', () => {
spyOn(comp.save, 'emit');
fixture.debugElement
.query(By.css('button.save'))
.triggerEventHandler('click', null);
expect(comp.save.emit).toHaveBeenCalled();
});
});
describe('when clicking the close button', () => {
it('should emit a close event', () => {
spyOn(comp.close, 'emit');
fixture.debugElement
.query(By.css('button.close'))
.triggerEventHandler('click', null);
expect(comp.close.emit).toHaveBeenCalled();
});
});
describe('when selecting an item', () => {
it('should call the updates service setSelectedVirtualMetadata method', () => {
fixture.debugElement
.query(By.css('div.item'))
.triggerEventHandler('click', null);
expect(objectUpdatesService.setSelectedVirtualMetadata).toHaveBeenCalledWith(
url,
relationshipId,
item.uuid,
true
);
});
})
});

View File

@@ -0,0 +1,120 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {Observable} from 'rxjs';
import {Item} from '../../../core/shared/item.model';
import {MetadataValue} from '../../../core/shared/metadata.models';
import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service';
@Component({
selector: 'ds-virtual-metadata',
templateUrl: './virtual-metadata.component.html'
})
/**
* Component that lists both items of a relationship, along with their virtual metadata of the relationship.
* The component is shown when a relationship is marked to be deleted.
* Each item has a checkbox to indicate whether its virtual metadata should be saved as real metadata.
*/
export class VirtualMetadataComponent implements OnInit {
/**
* The current url of this page
*/
@Input() url: string;
/**
* The id of the relationship to be deleted.
*/
@Input() relationshipId: string;
/**
* The left item of the relationship to be deleted.
*/
@Input() leftItem: Item;
/**
* The right item of the relationship to be deleted.
*/
@Input() rightItem: Item;
/**
* Emits when the close button is pressed.
*/
@Output() close = new EventEmitter();
/**
* Emits when the save button is pressed.
*/
@Output() save = new EventEmitter();
/**
* Get an array of the left and the right item of the relationship to be deleted.
*/
get items() {
return [this.leftItem, this.rightItem];
}
private virtualMetadata: Map<string, VirtualMetadata[]> = new Map<string, VirtualMetadata[]>();
constructor(
protected objectUpdatesService: ObjectUpdatesService,
) {
}
/**
* Get the virtual metadata of a given item corresponding to this relationship.
* @param item the item to get the virtual metadata for
*/
getVirtualMetadata(item: Item): VirtualMetadata[] {
return Object.entries(item.metadata)
.map(([key, value]) =>
value
.filter((metadata: MetadataValue) =>
!key.startsWith('relation') && metadata.authority && metadata.authority.endsWith(this.relationshipId))
.map((metadata: MetadataValue) => {
return {
metadataField: key,
metadataValue: metadata,
}
})
)
.reduce((previous, current) => previous.concat(current), []);
}
/**
* Select/deselect the virtual metadata of an item to be saved as real metadata.
* @param item the item for which (not) to save the virtual metadata as real metadata
* @param selected whether or not to save the virtual metadata as real metadata
*/
setSelectedVirtualMetadataItem(item: Item, selected: boolean) {
this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid, selected);
}
/**
* Check whether the virtual metadata of a given item is selected to be saved as real metadata
* @param item the item for which to check whether the virtual metadata is selected to be saved as real metadata
*/
isSelectedVirtualMetadataItem(item: Item): Observable<boolean> {
return this.objectUpdatesService.isSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid);
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackItem(index, item: Item) {
return item && item.uuid;
}
ngOnInit(): void {
this.items.forEach((item) => {
this.virtualMetadata.set(item.uuid, this.getVirtualMetadata(item));
});
}
}
/**
* Represents a virtual metadata entry.
*/
export interface VirtualMetadata {
metadataField: string,
metadataValue: MetadataValue,
}

View File

@@ -122,6 +122,7 @@ import { BrowseDefinition } from './shared/browse-definition.model';
import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service';
import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service';
import { ObjectSelectService } from '../shared/object-select/object-select.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service';
import {EntityTypeService} from './data/entity-type.service';
import { SiteDataService } from './data/site-data.service'; import { SiteDataService } from './data/site-data.service';
import { NormalizedSite } from './cache/models/normalized-site.model'; import { NormalizedSite } from './cache/models/normalized-site.model';
@@ -245,6 +246,7 @@ const PROVIDERS = [
TaskResponseParsingService, TaskResponseParsingService,
ClaimedTaskDataService, ClaimedTaskDataService,
PoolTaskDataService, PoolTaskDataService,
EntityTypeService,
ContentSourceResponseParsingService, ContentSourceResponseParsingService,
SearchService, SearchService,
SidebarService, SidebarService,

View File

@@ -0,0 +1,103 @@
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { Injectable } from '@angular/core';
import { GetRequest } from './request.models';
import { Observable } from 'rxjs/internal/Observable';
import {switchMap, take, tap} from 'rxjs/operators';
import { RemoteData } from './remote-data';
import {RelationshipType} from '../shared/item-relationships/relationship-type.model';
import {PaginatedList} from './paginated-list';
import {ItemType} from '../shared/item-relationships/item-type.model';
/**
* Service handling all ItemType requests
*/
@Injectable()
export class EntityTypeService extends DataService<ItemType> {
protected linkPath = 'entitytypes';
protected forceBypassCache = false;
constructor(protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected dataBuildService: NormalizedObjectBuildService,
protected store: Store<CoreState>,
protected halService: HALEndpointService,
protected objectCache: ObjectCacheService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ItemType>) {
super();
}
getBrowseEndpoint(options, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Get the endpoint for the item type's allowed relationship types
* @param entityTypeId
*/
getRelationshipTypesEndpoint(entityTypeId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`))
);
}
/**
* Get the allowed relationship types for an entity type
* @param entityTypeId
*/
getEntityTypeRelationships(entityTypeId: string): Observable<RemoteData<PaginatedList<RelationshipType>>> {
const href$ = this.getRelationshipTypesEndpoint(entityTypeId);
href$.pipe(take(1)).subscribe((href) => {
const request = new GetRequest(this.requestService.generateRequestId(), href);
this.requestService.configure(request);
});
return this.rdbService.buildList(href$);
}
/**
* Get an entity type by their label
* @param label
*/
getEntityTypeByLabel(label: string): Observable<RemoteData<ItemType>> {
// TODO: Remove mock data once REST API supports this
/*
href$.pipe(take(1)).subscribe((href) => {
const request = new GetRequest(this.requestService.generateRequestId(), href);
this.requestService.configure(request);
});
return this.rdbService.buildSingle<EntityType>(href$);
*/
// Mock:
const index = [
'Publication',
'Person',
'Project',
'OrgUnit',
'Journal',
'JournalVolume',
'JournalIssue',
'DataPackage',
'DataFile',
].indexOf(label);
return this.findById((index + 1) + '');
}
}

View File

@@ -4,7 +4,7 @@ import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models'; import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { FacetValue } from '../../shared/search/facet-value.model'; import {FacetValue} from '../../shared/search/facet-value.model';
import { BaseResponseParsingService } from './base-response-parsing.service'; import { BaseResponseParsingService } from './base-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';

View File

@@ -1,7 +1,7 @@
import { type } from '../../../shared/ngrx/type'; import {type} from '../../../shared/ngrx/type';
import { Action } from '@ngrx/store'; import {Action} from '@ngrx/store';
import { Identifiable } from './object-updates.reducer'; import {Identifiable} from './object-updates.reducer';
import { INotification } from '../../../shared/notifications/models/notification.model'; import {INotification} from '../../../shared/notifications/models/notification.model';
/** /**
* The list of ObjectUpdatesAction type definitions * The list of ObjectUpdatesAction type definitions
@@ -11,6 +11,7 @@ export const ObjectUpdatesActionTypes = {
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
SELECT_VIRTUAL_METADATA: type('dspace/core/cache/object-updates/SELECT_VIRTUAL_METADATA'),
DISCARD: type('dspace/core/cache/object-updates/DISCARD'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
REMOVE: type('dspace/core/cache/object-updates/REMOVE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
@@ -83,6 +84,41 @@ export class AddFieldUpdateAction implements Action {
} }
} }
/**
* An ngrx action to select/deselect virtual metadata in the ObjectUpdates state for a certain page url
*/
export class SelectVirtualMetadataAction implements Action {
type = ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA;
payload: {
url: string,
source: string,
uuid: string,
select: boolean;
};
/**
* Create a new SelectVirtualMetadataAction
*
* @param url
* the unique url of the page for which a field update is added
* @param source
* the id of the relationship which adds the virtual metadata
* @param uuid
* the id of the item which has the virtual metadata
* @param select
* whether to select or deselect the virtual metadata to be saved as real metadata
*/
constructor(
url: string,
source: string,
uuid: string,
select: boolean,
) {
this.payload = { url, source, uuid, select: select};
}
}
/** /**
* An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url
*/ */
@@ -242,4 +278,5 @@ export type ObjectUpdatesAction
| DiscardObjectUpdatesAction | DiscardObjectUpdatesAction
| ReinstateObjectUpdatesAction | ReinstateObjectUpdatesAction
| RemoveObjectUpdatesAction | RemoveObjectUpdatesAction
| RemoveFieldUpdateAction; | RemoveFieldUpdateAction
| SelectVirtualMetadataAction;

View File

@@ -5,10 +5,11 @@ import {
FieldChangeType, FieldChangeType,
InitializeFieldsAction, InitializeFieldsAction,
ReinstateObjectUpdatesAction, ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction,
SetEditableFieldUpdateAction, SetValidFieldUpdateAction SetEditableFieldUpdateAction, SetValidFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer';
import {Relationship} from '../../shared/item-relationships/relationship.model';
class NullAction extends RemoveFieldUpdateAction { class NullAction extends RemoveFieldUpdateAction {
type = null; type = null;
@@ -44,6 +45,7 @@ const identifiable3 = {
language: null, language: null,
value: 'Unchanged value' value: 'Unchanged value'
}; };
const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'});
const modDate = new Date(2010, 2, 11); const modDate = new Date(2010, 2, 11);
const uuid = identifiable1.uuid; const uuid = identifiable1.uuid;
@@ -79,7 +81,10 @@ describe('objectUpdatesReducer', () => {
changeType: FieldChangeType.ADD changeType: FieldChangeType.ADD
} }
}, },
lastModified: modDate lastModified: modDate,
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
} }
}; };
@@ -102,7 +107,10 @@ describe('objectUpdatesReducer', () => {
isValid: true isValid: true
}, },
}, },
lastModified: modDate lastModified: modDate,
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
}, },
[url + OBJECT_UPDATES_TRASH_PATH]: { [url + OBJECT_UPDATES_TRASH_PATH]: {
fieldStates: { fieldStates: {
@@ -133,7 +141,10 @@ describe('objectUpdatesReducer', () => {
changeType: FieldChangeType.ADD changeType: FieldChangeType.ADD
} }
}, },
lastModified: modDate lastModified: modDate,
virtualMetadataSources: {
[relationship.uuid]: {[identifiable1.uuid]: true}
},
} }
}; };
@@ -195,6 +206,12 @@ describe('objectUpdatesReducer', () => {
objectUpdatesReducer(testState, action); objectUpdatesReducer(testState, action);
}); });
it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => {
const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true);
// testState has already been frozen above
objectUpdatesReducer(testState, action);
});
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate);
@@ -213,6 +230,7 @@ describe('objectUpdatesReducer', () => {
}, },
}, },
fieldUpdates: {}, fieldUpdates: {},
virtualMetadataSources: {},
lastModified: modDate lastModified: modDate
} }
}; };

View File

@@ -7,9 +7,13 @@ import {
ObjectUpdatesActionTypes, ObjectUpdatesActionTypes,
ReinstateObjectUpdatesAction, ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction, RemoveFieldUpdateAction,
RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction RemoveObjectUpdatesAction,
SetEditableFieldUpdateAction,
SetValidFieldUpdateAction,
SelectVirtualMetadataAction,
} from './object-updates.actions'; } from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { hasNoValue, hasValue } from '../../../shared/empty.util';
import {Relationship} from '../../shared/item-relationships/relationship.model';
/** /**
* Path where discarded objects are saved * Path where discarded objects are saved
@@ -42,7 +46,7 @@ export interface Identifiable {
/** /**
* The state of a single field update * The state of a single field update
*/ */
export interface FieldUpdate { export interface FieldUpdate {
field: Identifiable, field: Identifiable,
changeType: FieldChangeType changeType: FieldChangeType
} }
@@ -54,12 +58,36 @@ export interface FieldUpdates {
[uuid: string]: FieldUpdate; [uuid: string]: FieldUpdate;
} }
/**
* The states of all virtual metadata selections available for a single page, mapped by the relationship uuid
*/
export interface VirtualMetadataSources {
[source: string]: VirtualMetadataSource
}
/**
* The selection of virtual metadata for a relationship, mapped by the uuid of either the item or the relationship type
*/
export interface VirtualMetadataSource {
[uuid: string]: boolean,
}
/**
* A fieldupdate interface which represents a relationship selected to be deleted,
* along with a selection of the virtual metadata to keep
*/
export interface DeleteRelationship extends Relationship {
keepLeftVirtualMetadata: boolean,
keepRightVirtualMetadata: boolean,
}
/** /**
* The updated state of a single page * The updated state of a single page
*/ */
export interface ObjectUpdatesEntry { export interface ObjectUpdatesEntry {
fieldStates: FieldStates; fieldStates: FieldStates;
fieldUpdates: FieldUpdates fieldUpdates: FieldUpdates;
virtualMetadataSources: VirtualMetadataSources;
lastModified: Date; lastModified: Date;
} }
@@ -96,6 +124,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
case ObjectUpdatesActionTypes.ADD_FIELD: { case ObjectUpdatesActionTypes.ADD_FIELD: {
return addFieldUpdate(state, action as AddFieldUpdateAction); return addFieldUpdate(state, action as AddFieldUpdateAction);
} }
case ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA: {
return selectVirtualMetadata(state, action as SelectVirtualMetadataAction);
}
case ObjectUpdatesActionTypes.DISCARD: { case ObjectUpdatesActionTypes.DISCARD: {
return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); return discardObjectUpdates(state, action as DiscardObjectUpdatesAction);
} }
@@ -135,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
state[url], state[url],
{ fieldStates: fieldStates }, { fieldStates: fieldStates },
{ fieldUpdates: {} }, { fieldUpdates: {} },
{ virtualMetadataSources: {} },
{ lastModified: lastModifiedServer } { lastModified: lastModifiedServer }
); );
return Object.assign({}, state, { [url]: newPageState }); return Object.assign({}, state, { [url]: newPageState });
@@ -169,6 +201,51 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) {
return Object.assign({}, state, { [url]: newPageState }); return Object.assign({}, state, { [url]: newPageState });
} }
/**
* Update the selected virtual metadata in the store
* @param state The current state
* @param action The action to perform on the current state
*/
function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) {
const url: string = action.payload.url;
const source: string = action.payload.source;
const uuid: string = action.payload.uuid;
const select: boolean = action.payload.select;
const pageState: ObjectUpdatesEntry = state[url] || {};
const virtualMetadataSource = Object.assign(
{},
pageState.virtualMetadataSources[source],
{
[uuid]: select,
},
);
const virtualMetadataSources = Object.assign(
{},
pageState.virtualMetadataSources,
{
[source]: virtualMetadataSource,
},
);
const newPageState = Object.assign(
{},
pageState,
{virtualMetadataSources: virtualMetadataSources},
);
return Object.assign(
{},
state,
{
[url]: newPageState,
}
);
}
/** /**
* Discard all updates for a specific action's url in the store * Discard all updates for a specific action's url in the store
* @param state The current state * @param state The current state

View File

@@ -4,13 +4,14 @@ import { ObjectUpdatesService } from './object-updates.service';
import { import {
DiscardObjectUpdatesAction, DiscardObjectUpdatesAction,
FieldChangeType, FieldChangeType,
InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction,
SetEditableFieldUpdateAction SetEditableFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { Notification } from '../../../shared/notifications/models/notification.model'; import { Notification } from '../../../shared/notifications/models/notification.model';
import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
import {Relationship} from '../../shared/item-relationships/relationship.model';
describe('ObjectUpdatesService', () => { describe('ObjectUpdatesService', () => {
let service: ObjectUpdatesService; let service: ObjectUpdatesService;
@@ -22,6 +23,7 @@ describe('ObjectUpdatesService', () => {
const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' };
const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' };
const identifiables = [identifiable1, identifiable2]; const identifiables = [identifiable1, identifiable2];
const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'});
const fieldUpdates = { const fieldUpdates = {
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE },
@@ -38,11 +40,11 @@ describe('ObjectUpdatesService', () => {
}; };
const objectEntry = { const objectEntry = {
fieldStates, fieldUpdates, lastModified: modDate fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}
}; };
store = new Store<CoreState>(undefined, undefined, undefined); store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch'); spyOn(store, 'dispatch');
service = new ObjectUpdatesService(store); service = (new ObjectUpdatesService(store));
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
spyOn(service as any, 'getFieldState').and.callFake((uuid) => { spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
@@ -251,4 +253,10 @@ describe('ObjectUpdatesService', () => {
}); });
}); });
describe('setSelectedVirtualMetadata', () => {
it('should dispatch a SELECT_VIRTUAL_METADATA action with the correct URL, relationship, identifiable and boolean', () => {
service.setSelectedVirtualMetadata(url, relationship.uuid, identifiable1.uuid, true);
expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true));
});
});
}); });

View File

@@ -8,7 +8,8 @@ import {
Identifiable, Identifiable,
OBJECT_UPDATES_TRASH_PATH, OBJECT_UPDATES_TRASH_PATH,
ObjectUpdatesEntry, ObjectUpdatesEntry,
ObjectUpdatesState ObjectUpdatesState,
VirtualMetadataSource
} from './object-updates.reducer'; } from './object-updates.reducer';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
@@ -18,10 +19,11 @@ import {
InitializeFieldsAction, InitializeFieldsAction,
ReinstateObjectUpdatesAction, ReinstateObjectUpdatesAction,
RemoveFieldUpdateAction, RemoveFieldUpdateAction,
SelectVirtualMetadataAction,
SetEditableFieldUpdateAction, SetEditableFieldUpdateAction,
SetValidFieldUpdateAction SetValidFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model'; import { INotification } from '../../../shared/notifications/models/notification.model';
@@ -37,6 +39,10 @@ function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): Memoiz
return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]);
} }
function virtualMetadataSourceSelector(url: string, source: string): MemoizedSelector<CoreState, VirtualMetadataSource> {
return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.virtualMetadataSources[source]);
}
/** /**
* Service that dispatches and reads from the ObjectUpdates' state in the store * Service that dispatches and reads from the ObjectUpdates' state in the store
*/ */
@@ -91,20 +97,24 @@ export class ObjectUpdatesService {
*/ */
getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> { getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url); const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => { return objectUpdates.pipe(
const fieldUpdates: FieldUpdates = {}; switchMap((objectEntry) => {
if (hasValue(objectEntry)) { const fieldUpdates: FieldUpdates = {};
Object.keys(objectEntry.fieldStates).forEach((uuid) => { if (hasValue(objectEntry)) {
let fieldUpdate = objectEntry.fieldUpdates[uuid]; Object.keys(objectEntry.fieldStates).forEach((uuid) => {
if (isEmpty(fieldUpdate)) { fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid];
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); });
fieldUpdate = {field: identifiable, changeType: undefined}; }
} return this.getFieldUpdatesExclusive(url, initialFields).pipe(
fieldUpdates[uuid] = fieldUpdate; map((fieldUpdatesExclusive) => {
}); Object.keys(fieldUpdatesExclusive).forEach((uuid) => {
} fieldUpdates[uuid] = fieldUpdatesExclusive[uuid];
return fieldUpdates; });
})) return fieldUpdates;
})
);
}),
);
} }
/** /**
@@ -197,6 +207,34 @@ export class ObjectUpdatesService {
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
} }
/**
* Check whether the virtual metadata of a given item is selected to be saved as real metadata
* @param url The URL of the page on which the field resides
* @param relationship The id of the relationship for which to check whether the virtual metadata is selected to be
* saved as real metadata
* @param item The id of the item for which to check whether the virtual metadata is selected to be
* saved as real metadata
*/
isSelectedVirtualMetadata(url: string, relationship: string, item: string): Observable<boolean> {
return this.store
.pipe(
select(virtualMetadataSourceSelector(url, relationship)),
map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]),
);
}
/**
* Method to dispatch a SelectVirtualMetadataAction to the store
* @param url The page's URL for which the changes are saved
* @param relationship the relationship for which virtual metadata is selected
* @param uuid the selection identifier, can either be the item uuid or the relationship type uuid
* @param selected whether or not to select the virtual metadata to be saved
*/
setSelectedVirtualMetadata(url: string, relationship: string, uuid: string, selected: boolean) {
this.store.dispatch(new SelectVirtualMetadataAction(url, relationship, uuid, selected));
}
/** /**
* Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state
* @param url The URL of the page on which the field resides * @param url The URL of the page on which the field resides

View File

@@ -54,10 +54,12 @@ describe('RelationshipService', () => {
}); });
const relatedItem1 = Object.assign(new Item(), { const relatedItem1 = Object.assign(new Item(), {
self: 'fake-item-url/author1',
id: 'author1', id: 'author1',
uuid: 'author1' uuid: 'author1'
}); });
const relatedItem2 = Object.assign(new Item(), { const relatedItem2 = Object.assign(new Item(), {
self: 'fake-item-url/author2',
id: 'author2', id: 'author2',
uuid: 'author2' uuid: 'author2'
}); });
@@ -112,19 +114,19 @@ describe('RelationshipService', () => {
beforeEach(() => { beforeEach(() => {
spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1));
spyOn(objectCache, 'remove'); spyOn(objectCache, 'remove');
service.deleteRelationship(relationships[0].uuid).subscribe(); service.deleteRelationship(relationships[0].uuid, 'right').subscribe();
}); });
it('should send a DeleteRequest', () => { it('should send a DeleteRequest', () => {
const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid + '?copyVirtualMetadata=right');
expect(requestService.configure).toHaveBeenCalledWith(expected); expect(requestService.configure).toHaveBeenCalledWith(expected);
}); });
it('should clear the related items their cache', () => { it('should clear the cache of the related items', () => {
expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self);
expect(objectCache.remove).toHaveBeenCalledWith(item.self); expect(objectCache.remove).toHaveBeenCalledWith(item.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self);
}); });
}); });

View File

@@ -81,15 +81,22 @@ export class RelationshipService extends DataService<Relationship> {
* Send a delete request for a relationship by ID * Send a delete request for a relationship by ID
* @param id * @param id
*/ */
deleteRelationship(id: string): Observable<RestResponse> { deleteRelationship(id: string, copyVirtualMetadata: string): Observable<RestResponse> {
return this.getRelationshipEndpoint(id).pipe( return this.getRelationshipEndpoint(id).pipe(
isNotEmptyOperator(), isNotEmptyOperator(),
take(1), take(1),
map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), distinctUntilChanged(),
map((endpointURL: string) =>
new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata)
),
configureRequest(this.requestService), configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(), getResponseFromEntry(),
tap(() => this.removeRelationshipItemsFromCacheByRelationship(id)) switchMap((response) =>
this.clearRelatedCache(id).pipe(
map(() => response),
)
),
); );
} }
@@ -417,4 +424,26 @@ export class RelationshipService extends DataService<Relationship> {
return update$; return update$;
} }
/**
* Clear object and request caches of the items related to a relationship (left and right items)
* @param uuid The uuid of the relationship for which to clear the related items from the cache
*/
clearRelatedCache(uuid: string): Observable<void> {
return this.findById(uuid).pipe(
getSucceededRemoteData(),
switchMap((rd: RemoteData<Relationship>) =>
observableCombineLatest(
rd.payload.leftItem.pipe(getSucceededRemoteData()),
rd.payload.rightItem.pipe(getSucceededRemoteData())
)
),
take(1),
map(([leftItem, rightItem]) => {
this.objectCache.remove(leftItem.payload.self);
this.objectCache.remove(rightItem.payload.self);
this.requestService.removeByHrefSubstring(leftItem.payload.self);
this.requestService.removeByHrefSubstring(rightItem.payload.self);
}),
);
}
} }

View File

@@ -128,7 +128,7 @@ export class RelationshipEffects {
this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe(
take(1), take(1),
hasValueOperator(), hasValueOperator(),
mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id)), mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id, 'none')),
take(1) take(1)
).subscribe(); ).subscribe();
} }