Merge branch 'master' into w2p-62589_Item-mapper-update

Conflicts:
	src/app/+item-page/edit-item-page/edit-item-page.module.ts
	src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
	src/app/+search-page/search-service/search.service.ts
This commit is contained in:
Kristof De Langhe
2019-09-06 12:52:36 +02:00
46 changed files with 1908 additions and 184 deletions

View File

@@ -1,3 +1,5 @@
module.exports = { module.exports = {
theme: {
name: 'default',
}
}; };

View File

@@ -290,12 +290,26 @@
"item.edit.reinstate.error": "An error occurred while reinstating the item", "item.edit.reinstate.error": "An error occurred while reinstating the item",
"item.edit.reinstate.header": "Reinstate item: {{ id }}", "item.edit.reinstate.header": "Reinstate item: {{ id }}",
"item.edit.reinstate.success": "The item was reinstated successfully", "item.edit.reinstate.success": "The item was reinstated successfully",
"item.edit.relationships.discard-button": "Discard",
"item.edit.relationships.edit.buttons.remove": "Remove",
"item.edit.relationships.edit.buttons.undo": "Undo changes",
"item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button",
"item.edit.relationships.notifications.discarded.title": "Changes discarded",
"item.edit.relationships.notifications.failed.title": "Error deleting relationship",
"item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts",
"item.edit.relationships.notifications.outdated.title": "Changes outdated",
"item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.",
"item.edit.relationships.notifications.saved.title": "Relationships saved",
"item.edit.relationships.reinstate-button": "Undo",
"item.edit.relationships.save-button": "Save",
"item.edit.tabs.bitstreams.head": "Item Bitstreams", "item.edit.tabs.bitstreams.head": "Item Bitstreams",
"item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams",
"item.edit.tabs.curate.head": "Curate", "item.edit.tabs.curate.head": "Curate",
"item.edit.tabs.curate.title": "Item Edit - Curate", "item.edit.tabs.curate.title": "Item Edit - Curate",
"item.edit.tabs.metadata.head": "Item Metadata", "item.edit.tabs.metadata.head": "Item Metadata",
"item.edit.tabs.metadata.title": "Item Edit - Metadata", "item.edit.tabs.metadata.title": "Item Edit - Metadata",
"item.edit.tabs.relationships.head": "Item Relationships",
"item.edit.tabs.relationships.title": "Item Edit - Relationships",
"item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...",
"item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies", "item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies",
"item.edit.tabs.status.buttons.delete.button": "Permanently delete", "item.edit.tabs.status.buttons.delete.button": "Permanently delete",

View File

@@ -0,0 +1,179 @@
import { Component, Inject, Injectable, OnInit } from '@angular/core';
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable';
import { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { first, map } from 'rxjs/operators';
import { RemoteData } from '../../../core/data/remote-data';
@Injectable()
/**
* Abstract component for managing object updates of an item
*/
export abstract class AbstractItemUpdateComponent implements OnInit {
/**
* The item to display the edit page for
*/
item: Item;
/**
* The current values and updates for all this item's fields
* Should be initialized in the initializeUpdates method of the child component
*/
updates$: Observable<FieldUpdates>;
/**
* The current url of this page
*/
url: string;
/**
* Prefix for this component's notification translate keys
* Should be initialized in the initializeNotificationsPrefix method of the child component
*/
notificationsPrefix;
/**
* The time span for being able to undo discarding changes
*/
discardTimeOut: number;
constructor(
protected itemService: ItemDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected router: Router,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected route: ActivatedRoute
) {
}
/**
* Initialize common properties between item-update components
*/
ngOnInit(): void {
this.route.parent.data.pipe(map((data) => data.item))
.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.item = item;
});
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
this.url = this.router.url;
if (this.url.indexOf('?') > 0) {
this.url = this.url.substr(0, this.url.indexOf('?'));
}
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
if (!hasChanges) {
this.initializeOriginalFields();
} else {
this.checkLastModified();
}
});
this.initializeNotificationsPrefix();
this.initializeUpdates();
}
/**
* Initialize the values and updates of the current item's fields
*/
abstract initializeUpdates(): void;
/**
* Initialize the prefix for notification messages
*/
abstract initializeNotificationsPrefix(): void;
/**
* Sends all initial values of this item to the object updates service
*/
abstract initializeOriginalFields(): void;
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
}
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Check if the current page is entirely valid
*/
protected isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}
/**
* Checks if the current item is still in sync with the version in the store
* If it's not, a notification is shown and the changes are removed
*/
private checkLastModified() {
const currentVersion = this.item.lastModified;
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
(updateVersion: Date) => {
if (updateVersion.getDate() !== currentVersion.getDate()) {
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
this.initializeOriginalFields();
}
}
);
}
/**
* Submit the current changes
*/
abstract submit(): void;
/**
* Request the object updates service to discard all current changes to this item
* Shows a notification to remind the user that they can undo this
*/
discard() {
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
* Get translated notification title
* @param key
*/
protected getNotificationTitle(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
protected getNotificationContent(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
}

View File

@@ -17,6 +17,9 @@ import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/e
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { SearchPageModule } from '../../+search-page/search-page.module'; import { SearchPageModule } from '../../+search-page/search-page.module';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.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
@@ -40,8 +43,11 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col
ItemDeleteComponent, ItemDeleteComponent,
ItemStatusComponent, ItemStatusComponent,
ItemMetadataComponent, ItemMetadataComponent,
ItemRelationshipsComponent,
ItemBitstreamsComponent, ItemBitstreamsComponent,
EditInPlaceFieldComponent, EditInPlaceFieldComponent,
EditRelationshipComponent,
EditRelationshipListComponent,
ItemCollectionMapperComponent ItemCollectionMapperComponent
] ]
}) })

View File

@@ -11,6 +11,7 @@ import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -51,6 +52,11 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
component: ItemMetadataComponent, component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title' } data: { title: 'item.edit.tabs.metadata.title' }
}, },
{
path: 'relationships',
component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title' }
},
{ {
path: 'view', path: 'view',
/* TODO - change when view page exists */ /* TODO - change when view page exists */

View File

@@ -31,7 +31,7 @@ import {
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../../shared/testing/utils'; } from '../../../shared/testing/utils';
let comp: ItemMetadataComponent; let comp: any;
let fixture: ComponentFixture<ItemMetadataComponent>; let fixture: ComponentFixture<ItemMetadataComponent>;
let de: DebugElement; let de: DebugElement;
let el: HTMLElement; let el: HTMLElement;

View File

@@ -1,4 +1,4 @@
import { Component, Inject, Input, OnInit } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
@@ -6,8 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
FieldUpdate,
FieldUpdates,
Identifiable Identifiable
} from '../../../core/data/object-updates/object-updates.reducer'; } from '../../../core/data/object-updates/object-updates.reducer';
import { first, map, switchMap, take, tap } from 'rxjs/operators'; import { first, map, switchMap, take, tap } from 'rxjs/operators';
@@ -19,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
import { RegistryService } from '../../../core/registry/registry.service'; import { RegistryService } from '../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils'; import { Metadata } from '../../../core/shared/metadata.utils';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { MetadataField } from '../../../core/metadata/metadata-field.model';
@Component({ @Component({
@@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model';
/** /**
* Component for displaying an item's metadata edit page * Component for displaying an item's metadata edit page
*/ */
export class ItemMetadataComponent implements OnInit { export class ItemMetadataComponent extends AbstractItemUpdateComponent {
/**
* The item to display the edit page for
*/
item: Item;
/**
* The current values and updates for all this item's metadata fields
*/
updates$: Observable<FieldUpdates>;
/**
* The current url of this page
*/
url: string;
/**
* The time span for being able to undo discarding changes
*/
private discardTimeOut: number;
/**
* Prefix for this component's notification translate keys
*/
private notificationsPrefix = 'item.edit.metadata.notifications.';
/** /**
* Observable with a list of strings with all existing metadata field keys * Observable with a list of strings with all existing metadata field keys
@@ -58,44 +36,38 @@ export class ItemMetadataComponent implements OnInit {
metadataFields$: Observable<string[]>; metadataFields$: Observable<string[]>;
constructor( constructor(
private itemService: ItemDataService, protected itemService: ItemDataService,
private objectUpdatesService: ObjectUpdatesService, protected objectUpdatesService: ObjectUpdatesService,
private router: Router, protected router: Router,
private notificationsService: NotificationsService, protected notificationsService: NotificationsService,
private translateService: TranslateService, protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
private route: ActivatedRoute, protected route: ActivatedRoute,
private metadataFieldService: RegistryService, protected metadataFieldService: RegistryService,
) { ) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
} }
/** /**
* Set up and initialize all fields * Set up and initialize all fields
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit();
this.metadataFields$ = this.findMetadataFields(); this.metadataFields$ = this.findMetadataFields();
this.route.parent.data.pipe(map((data) => data.item)) }
.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.item = item;
});
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; /**
this.url = this.router.url; * Initialize the values and updates of the current item's metadata fields
if (this.url.indexOf('?') > 0) { */
this.url = this.url.substr(0, this.url.indexOf('?')); public initializeUpdates(): void {
} this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
this.hasChanges().pipe(first()).subscribe((hasChanges) => { }
if (!hasChanges) {
this.initializeOriginalFields(); /**
} else { * Initialize the prefix for notification messages
this.checkLastModified(); */
} public initializeNotificationsPrefix(): void {
}); this.notificationsPrefix = 'item.edit.metadata.notifications.';
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
} }
/** /**
@@ -104,47 +76,23 @@ export class ItemMetadataComponent implements OnInit {
*/ */
add(metadata: MetadatumViewModel = new MetadatumViewModel()) { add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
}
/**
* Request the object updates service to discard all current changes to this item
* Shows a notification to remind the user that they can undo this
*/
discard() {
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.url);
} }
/** /**
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
private initializeOriginalFields() { public initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified);
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
} }
/** /**
* Requests all current metadata for this item and requests the item service to update the item * Requests all current metadata for this item and requests the item service to update the item
* Makes sure the new version of the item is rendered on the page * Makes sure the new version of the item is rendered on the page
*/ */
submit() { public submit() {
this.isValid().pipe(first()).subscribe((isValid) => { this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) { if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>; const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.getMetadataAsListExcludingRelationships()) as Observable<MetadatumViewModel[]>;
metadata$.pipe( metadata$.pipe(
first(), first(),
switchMap((metadata: MetadatumViewModel[]) => { switchMap((metadata: MetadatumViewModel[]) => {
@@ -157,7 +105,7 @@ export class ItemMetadataComponent implements OnInit {
(rd: RemoteData<Item>) => { (rd: RemoteData<Item>) => {
this.item = rd.payload; this.item = rd.payload;
this.initializeOriginalFields(); this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
} }
) )
@@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit {
}); });
} }
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
* Checks if the current item is still in sync with the version in the store
* If it's not, a notification is shown and the changes are removed
*/
private checkLastModified() {
const currentVersion = this.item.lastModified;
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
(updateVersion: Date) => {
if (updateVersion.getDate() !== currentVersion.getDate()) {
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
this.initializeOriginalFields();
}
}
);
}
/**
* Check if the current page is entirely valid
*/
private isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}
/**
* Get translated notification title
* @param key
*/
private getNotificationTitle(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
private getNotificationContent(key: string) {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
/** /**
* Method to request all metadata fields and convert them to a list of strings * Method to request all metadata fields and convert them to a list of strings
*/ */
@@ -230,4 +124,8 @@ export class ItemMetadataComponent implements OnInit {
take(1), take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
} }
getMetadataAsListExcludingRelationships(): MetadatumViewModel[] {
return this.item.metadataAsList.filter((metadata: MetadatumViewModel) => !metadata.key.startsWith('relation.') && !metadata.key.startsWith('relationship.'));
}
} }

View File

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

View File

@@ -0,0 +1,12 @@
@import '../../../../../styles/variables.scss';
.relationship-row:not(.alert-danger) {
padding: $alert-padding-y 0;
}
.relationship-row.alert-danger {
margin-left: -$alert-padding-x;
margin-right: -$alert-padding-x;
margin-top: -1px;
margin-bottom: -1px;
}

View File

@@ -0,0 +1,136 @@
import { EditRelationshipListComponent } from './edit-relationship-list.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
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 { of as observableOf } from 'rxjs/internal/observable/of';
import { RemoteData } from '../../../../core/data/remote-data';
import { Item } from '../../../../core/shared/item.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { SharedModule } from '../../../../shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { RelationshipService } from '../../../../core/data/relationship.service';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement;
let objectUpdatesService;
let relationshipService;
const url = 'http://test-url.com/test-url';
let item;
let author1;
let author2;
let fieldUpdate1;
let fieldUpdate2;
let relationships;
let relationshipType;
describe('EditRelationshipListComponent', () => {
beforeEach(async(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
});
relationships = [
Object.assign(new Relationship(), {
self: url + '/2',
id: '2',
uuid: '2',
leftId: 'author1',
rightId: 'publication',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
}),
Object.assign(new Relationship(), {
self: url + '/3',
id: '3',
uuid: '3',
leftId: 'author2',
rightId: 'publication',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
})
];
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(), {
id: 'author1',
uuid: 'author1'
});
author2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2'
});
fieldUpdate1 = {
field: author1,
changeType: undefined
};
fieldUpdate2 = {
field: author2,
changeType: FieldChangeType.REMOVE
};
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdatesExclusive: observableOf({
[author1.uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2
})
}
);
relationshipService = jasmine.createSpyObj('relationshipService',
{
getRelatedItemsByLabel: observableOf([author1, author2]),
}
);
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListComponent],
providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: RelationshipService, useValue: relationshipService }
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.url = url;
comp.relationshipLabel = relationshipType.leftLabel;
fixture.detectChanges();
});
describe('changeType is REMOVE', () => {
beforeEach(() => {
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');
});
});
});

View File

@@ -0,0 +1,99 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { Observable } from 'rxjs/internal/Observable';
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 { switchMap } from 'rxjs/operators';
import { hasValue } from '../../../../shared/empty.util';
@Component({
selector: 'ds-edit-relationship-list',
styleUrls: ['./edit-relationship-list.component.scss'],
templateUrl: './edit-relationship-list.component.html',
})
/**
* A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items
*/
export class EditRelationshipListComponent implements OnInit, OnChanges {
/**
* The item to display related items for
*/
@Input() item: Item;
/**
* The URL to the current page
* Used to fetch updates for the current item from the store
*/
@Input() url: string;
/**
* The label of the relationship-type we're rendering a list for
*/
@Input() relationshipLabel: string;
/**
* The FieldUpdates for the relationships in question
*/
updates$: Observable<FieldUpdates>;
constructor(
protected objectUpdatesService: ObjectUpdatesService,
protected relationshipService: RelationshipService
) {
}
ngOnInit(): void {
this.initUpdates();
}
ngOnChanges(changes: SimpleChanges): void {
this.initUpdates();
}
/**
* Initialize the FieldUpdates using the related items
*/
initUpdates() {
this.updates$ = this.getUpdatesByLabel(this.relationshipLabel);
}
/**
* Transform the item's relationships of a specific type into related items
* @param label The relationship type's label
*/
public getRelatedItemsByLabel(label: string): Observable<Item[]> {
return this.relationshipService.getRelatedItemsByLabel(this.item, label);
}
/**
* Get FieldUpdates for the relationships of a specific type
* @param label The relationship type's label
*/
public getUpdatesByLabel(label: string): Observable<FieldUpdates> {
return this.getRelatedItemsByLabel(label).pipe(
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items))
)
}
/**
* 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;
}
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
*/
trackUpdate(index, update: FieldUpdate) {
return update && update.field ? update.field.uuid : undefined;
}
}

View File

@@ -0,0 +1,19 @@
<div class="row" *ngIf="item">
<div class="col-10 relationship">
<ds-item-type-switcher [object]="item" [viewMode]="viewMode"></ds-item-type-switcher>
</div>
<div class="col-2">
<div class="btn-group relationship-action-buttons">
<button [disabled]="!canRemove()" (click)="remove()"
class="btn btn-outline-danger btn-sm"
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<button [disabled]="!canUndo()" (click)="undo()"
class="btn btn-outline-warning btn-sm"
title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
@import '../../../../../styles/variables.scss';
.btn[disabled] {
color: $gray-600;
border-color: $gray-600;
z-index: 0; // prevent border colors jumping on hover
}
.relationship-action-buttons {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -0,0 +1,179 @@
import { async, TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { TranslateModule } from '@ngx-translate/core';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { EditRelationshipComponent } from './edit-relationship.component';
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 { RemoteData } from '../../../../core/data/remote-data';
import { Item } from '../../../../core/shared/item.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
let objectUpdatesService: ObjectUpdatesService;
const url = 'http://test-url.com/test-url';
let item;
let author1;
let author2;
let fieldUpdate1;
let fieldUpdate2;
let relationships;
let relationshipType;
let fixture;
let comp: EditRelationshipComponent;
let de;
let el;
describe('EditRelationshipComponent', () => {
beforeEach(async(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
});
relationships = [
Object.assign(new Relationship(), {
self: url + '/2',
id: '2',
uuid: '2',
leftId: 'author1',
rightId: 'publication',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
}),
Object.assign(new Relationship(), {
self: url + '/3',
id: '3',
uuid: '3',
leftId: 'author2',
rightId: 'publication',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
})
];
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(), {
id: 'author1',
uuid: 'author1'
});
author2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2'
});
fieldUpdate1 = {
field: author1,
changeType: undefined
};
fieldUpdate2 = {
field: author2,
changeType: FieldChangeType.REMOVE
};
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
saveChangeFieldUpdate: {},
saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {},
setValidFieldUpdate: {},
removeSingleFieldUpdate: {},
isEditable: observableOf(false), // should always return something --> its in ngOnInit
isValid: observableOf(true) // should always return something --> its in ngOnInit
}
);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditRelationshipComponent],
providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
comp.fieldUpdate = fieldUpdate1;
comp.item = item;
fixture.detectChanges();
});
describe('when fieldUpdate has no changeType', () => {
beforeEach(() => {
comp.fieldUpdate = fieldUpdate1;
fixture.detectChanges();
});
describe('canRemove', () => {
it('should return true', () => {
expect(comp.canRemove()).toBe(true);
});
});
describe('canUndo', () => {
it('should return false', () => {
expect(comp.canUndo()).toBe(false);
});
});
});
describe('when fieldUpdate has DELETE as changeType', () => {
beforeEach(() => {
comp.fieldUpdate = fieldUpdate2;
fixture.detectChanges();
});
describe('canRemove', () => {
it('should return false', () => {
expect(comp.canRemove()).toBe(false);
});
});
describe('canUndo', () => {
it('should return true', () => {
expect(comp.canUndo()).toBe(true);
});
});
});
describe('remove', () => {
beforeEach(() => {
comp.remove();
});
it('should call saveRemoveFieldUpdate with the correct arguments', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item);
});
});
describe('undo', () => {
beforeEach(() => {
comp.undo();
});
it('should call removeSingleFieldUpdate with the correct arguments', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid);
});
});
});

View File

@@ -0,0 +1,74 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { cloneDeep } from 'lodash';
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 { ItemViewMode } from '../../../../shared/items/item-type-decorator';
@Component({
// tslint:disable-next-line:component-selector
selector: '[ds-edit-relationship]',
styleUrls: ['./edit-relationship.component.scss'],
templateUrl: './edit-relationship.component.html',
})
export class EditRelationshipComponent implements OnChanges {
/**
* The current field, value and state of the relationship
*/
@Input() fieldUpdate: FieldUpdate;
/**
* The current url of this page
*/
@Input() url: string;
/**
* The related item of this relationship
*/
item: Item;
/**
* The view-mode we're currently on
*/
viewMode = ItemViewMode.Element;
constructor(private objectUpdatesService: ObjectUpdatesService) {
}
/**
* Sets the current relationship based on the fieldUpdate input field
*/
ngOnChanges(): void {
this.item = cloneDeep(this.fieldUpdate.field) as Item;
}
/**
* Sends a new remove update for this field to the object updates service
*/
remove(): void {
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item);
}
/**
* Cancels the current update for this field in the object updates service
*/
undo(): void {
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid);
}
/**
* Check if a user should be allowed to remove this field
*/
canRemove(): boolean {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
}
/**
* Check if a user should be allowed to cancel the update to this field
*/
canUndo(): boolean {
return this.fieldUpdate.changeType >= 0;
}
}

View File

@@ -0,0 +1,43 @@
<div class="item-relationships">
<div class="button-row top d-flex">
<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()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<div *ngFor="let label of relationLabels$ | async" class="mb-4">
<ds-edit-relationship-list [item]="item" [url]="url" [relationshipLabel]="label" ></ds-edit-relationship-list>
</div>
<div class="button-row bottom">
<div class="float-right">
<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>
</div>

View File

@@ -0,0 +1,22 @@
@import '../../../../styles/variables.scss';
.button-row {
.btn {
margin-right: 0.5 * $spacer;
&:last-child {
margin-right: 0;
}
@media screen and (min-width: map-get($grid-breakpoints, sm)) {
min-width: $edit-item-button-min-width;
}
}
&.top .btn {
margin-top: $spacer/2;
margin-bottom: $spacer/2;
}
}

View File

@@ -0,0 +1,233 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemRelationshipsComponent } from './item-relationships.component';
import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { RouterStub } from '../../../shared/testing/router-stub';
import { TestScheduler } from 'rxjs/testing';
import { SharedModule } from '../../../shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GLOBAL_CONFIG } from '../../../../config';
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 { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { RelationshipService } from '../../../core/data/relationship.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getTestScheduler } from 'jasmine-marbles';
import { RestResponse } from '../../../core/cache/response.models';
import { RequestService } from '../../../core/data/request.service';
let comp: any;
let fixture: ComponentFixture<ItemRelationshipsComponent>;
let de: DebugElement;
let el: HTMLElement;
let objectUpdatesService;
let relationshipService;
let requestService;
let objectCache;
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
const notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
const router = new RouterStub();
let routeStub;
let itemService;
const url = 'http://test-url.com/test-url';
router.url = url;
let scheduler: TestScheduler;
let item;
let author1;
let author2;
let fieldUpdate1;
let fieldUpdate2;
let relationships;
let relationshipType;
describe('ItemRelationshipsComponent', () => {
beforeEach(async(() => {
const date = new Date();
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
});
relationships = [
Object.assign(new Relationship(), {
self: url + '/2',
id: '2',
uuid: '2',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
}),
Object.assign(new Relationship(), {
self: url + '/3',
id: '3',
uuid: '3',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
})
];
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))),
lastModified: date
});
author1 = Object.assign(new Item(), {
id: 'author1',
uuid: 'author1'
});
author2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2'
});
relationships[0].leftItem = observableOf(new RemoteData(false, false, true, undefined, author1));
relationships[0].rightItem = observableOf(new RemoteData(false, false, true, undefined, item));
relationships[1].leftItem = observableOf(new RemoteData(false, false, true, undefined, author2));
relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item));
fieldUpdate1 = {
field: author1,
changeType: undefined
};
fieldUpdate2 = {
field: author2,
changeType: FieldChangeType.REMOVE
};
itemService = jasmine.createSpyObj('itemService', {
findById: observableOf(new RemoteData(false, false, true, undefined, item))
});
routeStub = {
parent: {
data: observableOf({ item: new RemoteData(false, false, true, null, item) })
}
};
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[author1.uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2
}),
getFieldUpdatesExclusive: observableOf({
[author1.uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2
}),
saveAddFieldUpdate: {},
discardFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([author1, author2]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true)
}
);
relationshipService = jasmine.createSpyObj('relationshipService',
{
getItemRelationshipLabels: observableOf(['isAuthorOfPublication']),
getRelatedItems: observableOf([author1, author2]),
getRelatedItemsByLabel: observableOf([author1, author2]),
getItemRelationshipsArray: observableOf(relationships),
deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')),
getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships))
}
);
requestService = jasmine.createSpyObj('requestService',
{
removeByHrefSubstring: {},
hasByHrefObservable: observableOf(false)
}
);
objectCache = jasmine.createSpyObj('objectCache', {
remove: undefined
});
scheduler = getTestScheduler();
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [ItemRelationshipsComponent],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
{ provide: RelationshipService, useValue: relationshipService },
{ provide: ObjectCacheService, useValue: objectCache },
{ provide: RequestService, useValue: requestService },
ChangeDetectorRef
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemRelationshipsComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
fixture.detectChanges();
});
describe('discard', () => {
beforeEach(() => {
comp.discard();
});
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
});
});
describe('reinstate', () => {
beforeEach(() => {
comp.reinstate();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
});
});
describe('submit', () => {
beforeEach(() => {
comp.submit();
});
it('it should delete the correct relationship', () => {
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid);
});
});
});

View File

@@ -0,0 +1,172 @@
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable';
import { filter, map, switchMap, take, tap } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
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 { isNotEmptyOperator } from '../../../shared/empty.util';
import { RemoteData } from '../../../core/data/remote-data';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RequestService } from '../../../core/data/request.service';
import { Subscription } from 'rxjs/internal/Subscription';
import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils';
@Component({
selector: 'ds-item-relationships',
styleUrls: ['./item-relationships.component.scss'],
templateUrl: './item-relationships.component.html',
})
/**
* Component for displaying an item's relationships edit page
*/
export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy {
/**
* The labels of all different relations within this item
*/
relationLabels$: Observable<string[]>;
/**
* 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
*/
itemUpdateSubscription: Subscription;
constructor(
protected itemService: ItemDataService,
protected objectUpdatesService: ObjectUpdatesService,
protected router: Router,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected route: ActivatedRoute,
protected relationshipService: RelationshipService,
protected objectCache: ObjectCacheService,
protected requestService: RequestService,
protected cdRef: ChangeDetectorRef
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
}
/**
* Set up and initialize all fields
*/
ngOnInit(): void {
super.ngOnInit();
this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(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(
filter((exists: boolean) => !exists),
switchMap(() => this.itemService.findById(this.item.uuid)),
getSucceededRemoteData(),
).subscribe((itemRD: RemoteData<Item>) => {
this.item = itemRD.payload;
this.cdRef.detectChanges();
});
}
/**
* Initialize the values and updates of the current item's relationship fields
*/
public initializeUpdates(): void {
this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe(
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items))
);
}
/**
* Initialize the prefix for notification messages
*/
public initializeNotificationsPrefix(): void {
this.notificationsPrefix = 'item.edit.relationships.notifications.';
}
/**
* 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(): 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
const removedRelationships$ = removedItemIds$.pipe(
getRelationsByRelatedItemIds(this.item, this.relationshipService)
);
// Request a delete for every relationship found in the observable created above
removedRelationships$.pipe(
take(1),
map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)),
switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid))))
).subscribe((responses: RestResponse[]) => {
this.displayNotifications(responses);
this.reset();
});
}
/**
* 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: RestResponse[]) {
const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
const successfulResponses = responses.filter((response: RestResponse) => response.isSuccessful);
failedResponses.forEach((response: ErrorResponse) => {
this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage);
});
if (successfulResponses.length > 0) {
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
}
/**
* Re-initialize fields and subscriptions
*/
reset() {
this.initializeOriginalFields();
this.initializeUpdates();
this.initializeItemUpdate();
}
/**
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields() {
this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => {
this.objectUpdatesService.initialize(this.url, items, this.item.lastModified);
});
}
/**
* Unsubscribe from the item update when the component is destroyed
*/
ngOnDestroy(): void {
this.itemUpdateSubscription.unsubscribe();
}
}

View File

@@ -7,10 +7,12 @@ import { hasNoValue, hasValue } from '../../../../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
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 { distinctUntilChanged, flatMap, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, filter, flatMap, map, switchMap, tap } from 'rxjs/operators';
import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs'; import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs';
import { ItemDataService } from '../../../../core/data/item-data.service'; import { ItemDataService } from '../../../../core/data/item-data.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { RelationshipService } from '../../../../core/data/relationship.service';
/** /**
* Operator for comparing arrays using a mapping function * Operator for comparing arrays using a mapping function
@@ -147,3 +149,17 @@ export const relationsToRepresentations = (parentId: string, itemType: string, m
) )
) )
); );
/**
* Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup)
* Only relationships where leftItem or rightItem's ID is present in the list provided will be returned
* @param item
* @param relationshipService
*/
export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) =>
(source: Observable<string[]>): Observable<Relationship[]> =>
source.pipe(
flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe(
map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1))
))
);

View File

@@ -109,7 +109,7 @@ export class SearchPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.searchOptions$ = this.getSearchOptions(); this.searchOptions$ = this.getSearchOptions();
this.sub = this.searchOptions$.pipe( this.sub = this.searchOptions$.pipe(
switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(observableOf(undefined))))) switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined))))
.subscribe((results) => { .subscribe((results) => {
this.resultsRD$.next(results); this.resultsRD$.next(results);
}); });

View File

@@ -1,7 +1,7 @@
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router';
import { first, map, switchMap, take } from 'rxjs/operators'; import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { import {
FacetConfigSuccessResponse, FacetConfigSuccessResponse,
@@ -23,7 +23,7 @@ import {
getSucceededRemoteData getSucceededRemoteData
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { NormalizedSearchResult } from '../normalized-search-result.model'; import { NormalizedSearchResult } from '../normalized-search-result.model';
import { SearchOptions } from '../search-options.model'; import { SearchOptions } from '../search-options.model';
import { SearchResult } from '../search-result.model'; import { SearchResult } from '../search-result.model';
@@ -103,11 +103,18 @@ export class SearchService implements OnDestroy {
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found * @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
*/ */
search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> { search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe( const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
map((url: string) => { map((url: string) => {
if (hasValue(searchOptions)) { if (hasValue(searchOptions)) {
url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); return (searchOptions as PaginatedSearchOptions).toRestUrl(url);
} else {
return url;
} }
})
);
const requestObs = hrefObs.pipe(
map((url: string) => {
const request = new this.request(this.requestService.generateRequestId(), url); const request = new this.request(this.requestService.generateRequestId(), url);
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => { const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
@@ -136,10 +143,11 @@ export class SearchService implements OnDestroy {
map((sqr: SearchQueryResponse) => { map((sqr: SearchQueryResponse) => {
return sqr.objects return sqr.objects
.filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject))
.map((nsr: NormalizedSearchResult) => { .map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject))
return this.rdb.buildSingle(nsr.indexableObject);
})
}), }),
// Send a request for each item to ensure fresh cache
tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))),
map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))),
switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)), switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
); );
@@ -168,11 +176,20 @@ export class SearchService implements OnDestroy {
const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe( const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe(
map(([tDomainList, pageInfo]) => { map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList); return new PaginatedList(pageInfo, tDomainList.filter((obj) => hasValue(obj)));
}) })
); );
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe(
switchMap(([href, tDomainList, requestEntry]) => {
if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) {
this.requestService.removeByHrefSubstring(href);
return this.search(searchOptions)
} else {
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
}
})
);
} }
/** /**

View File

@@ -44,6 +44,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { RouteService } from './shared/services/route.service'; import { RouteService } from './shared/services/route.service';
import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockActivatedRoute } from './shared/mocks/mock-active-router';
import { MockRouter } from './shared/mocks/mock-router'; import { MockRouter } from './shared/mocks/mock-router';
import { CookieService } from './shared/services/cookie.service';
import { MockCookieService } from './shared/mocks/mock-cookie.service';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
@@ -78,6 +80,7 @@ describe('App component', () => {
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: CookieService, useValue: new MockCookieService()},
AppComponent, AppComponent,
RouteService RouteService
], ],

View File

@@ -32,6 +32,11 @@ import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
import { slideSidebarPadding } from './shared/animations/slide'; import { slideSidebarPadding } from './shared/animations/slide';
import { HostWindowService } from './shared/host-window.service'; import { HostWindowService } from './shared/host-window.service';
import { Theme } from '../config/theme.inferface'; import { Theme } from '../config/theme.inferface';
import { ClientCookieService } from './shared/services/client-cookie.service';
import { isNotEmpty } from './shared/empty.util';
import { CookieService } from './shared/services/cookie.service';
export const LANG_COOKIE = 'language_cookie';
@Component({ @Component({
selector: 'ds-app', selector: 'ds-app',
@@ -61,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService, private cssService: CSSVariableService,
private menuService: MenuService, private menuService: MenuService,
private windowService: HostWindowService, private windowService: HostWindowService,
private cookie: CookieService
) { ) {
// Load all the languages that are defined as active from the config file // Load all the languages that are defined as active from the config file
translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
@@ -68,11 +74,20 @@ export class AppComponent implements OnInit, AfterViewInit {
// Load the default language from the config file // Load the default language from the config file
translate.setDefaultLang(config.defaultLanguage); translate.setDefaultLang(config.defaultLanguage);
// Attempt to get the browser language from the user // Attempt to get the language from a cookie
if (translate.getLangs().includes(translate.getBrowserLang())) { const lang = cookie.get(LANG_COOKIE);
translate.use(translate.getBrowserLang()); if (isNotEmpty(lang)) {
// Cookie found
// Use the language from the cookie
translate.use(lang);
} else { } else {
translate.use(config.defaultLanguage); // Cookie not found
// Attempt to get the browser language from the user
if (translate.getLangs().includes(translate.getBrowserLang())) {
translate.use(translate.getBrowserLang());
} else {
translate.use(config.defaultLanguage);
}
} }
metadata.listenForRouteChange(); metadata.listenForRouteChange();

View File

@@ -39,6 +39,7 @@ import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/e
import { NavbarModule } from './navbar/navbar.module'; import { NavbarModule } from './navbar/navbar.module';
import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module'; import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module';
import { ClientCookieService } from './shared/services/client-cookie.service';
export function getConfig() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;
@@ -97,7 +98,8 @@ const PROVIDERS = [
{ {
provide: RouterStateSerializer, provide: RouterStateSerializer,
useClass: DSpaceRouterStateSerializer useClass: DSpaceRouterStateSerializer
} },
ClientCookieService
]; ];
const DECLARATIONS = [ const DECLARATIONS = [

View File

@@ -4,7 +4,7 @@ import { applyPatch, Operation } from 'fast-json-patch';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors'; import { coreSelector } from '../core.selectors';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
@@ -68,8 +68,8 @@ export class ObjectCacheService {
* @param href * @param href
* The unique href of the object to be removed * The unique href of the object to be removed
*/ */
remove(uuid: string): void { remove(href: string): void {
this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); this.store.dispatch(new RemoveFromObjectCacheAction(href));
} }
/** /**
@@ -224,6 +224,18 @@ export class ObjectCacheService {
return result; return result;
} }
/**
* Create an observable that emits a new value whenever the availability of the cached object changes.
* The value it emits is a boolean stating if the object exists in cache or not.
* @param selfLink The self link of the object to observe
*/
hasBySelfLinkObservable(selfLink: string): Observable<boolean> {
return this.store.pipe(
select(entryFromSelfLinkSelector(selfLink)),
map((entry: ObjectCacheEntry) => this.isValid(entry))
);
}
/** /**
* Check whether an ObjectCacheEntry should still be cached * Check whether an ObjectCacheEntry should still be cached
* *

View File

@@ -101,6 +101,7 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi
import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model';
import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model';
import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model';
import { RelationshipService } from './data/relationship.service';
import { RoleService } from './roles/role.service'; import { RoleService } from './roles/role.service';
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
@@ -204,6 +205,7 @@ const PROVIDERS = [
MenuService, MenuService,
ObjectUpdatesService, ObjectUpdatesService,
SearchService, SearchService,
RelationshipService,
MyDSpaceGuard, MyDSpaceGuard,
RoleService, RoleService,
TaskResponseParsingService, TaskResponseParsingService,

View File

@@ -105,6 +105,27 @@ export class ObjectUpdatesService {
})) }))
} }
/**
* Method that combines the state's updates (excluding updates that aren't part of the initialFields) with
* the initial values (when there's no update) to create a FieldUpdates object
* @param url The URL of the page for which the FieldUpdates should be requested
* @param initialFields The initial values of the fields
*/
getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(map((objectEntry) => {
const fieldUpdates: FieldUpdates = {};
for (const object of initialFields) {
let fieldUpdate = objectEntry.fieldUpdates[object.uuid];
if (isEmpty(fieldUpdate)) {
fieldUpdate = { field: object, changeType: undefined };
}
fieldUpdates[object.uuid] = fieldUpdate;
}
return fieldUpdates;
}))
}
/** /**
* Method to check if a specific field is currently editable in the store * Method to check if a specific field is currently editable in the store
* @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

@@ -0,0 +1,157 @@
import { RelationshipService } from './relationship.service';
import { RequestService } from './request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { RequestEntry } from './request.reducer';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { ResourceType } from '../shared/resource-type';
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RemoteData } from './remote-data';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { Item } from '../shared/item.model';
import { PaginatedList } from './paginated-list';
import { PageInfo } from '../shared/page-info.model';
import { DeleteRequest } from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { Observable } from 'rxjs/internal/Observable';
describe('RelationshipService', () => {
let service: RelationshipService;
let requestService: RequestService;
const restEndpointURL = 'https://rest.api/';
const relationshipsEndpointURL = `${restEndpointURL}/relationships`;
const halService: any = new HALEndpointServiceStub(restEndpointURL);
const rdbService = getMockRemoteDataBuildService();
const objectCache = Object.assign({
/* tslint:disable:no-empty */
remove: () => {}
/* tslint:enable:no-empty */
}) as ObjectCacheService;
const relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
});
const relationship1 = Object.assign(new Relationship(), {
self: relationshipsEndpointURL + '/2',
id: '2',
uuid: '2',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
});
const relationship2 = Object.assign(new Relationship(), {
self: relationshipsEndpointURL + '/3',
id: '3',
uuid: '3',
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
});
const relationships = [ relationship1, relationship2 ];
const 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)))
});
const relatedItem1 = Object.assign(new Item(), {
id: 'author1',
uuid: 'author1'
});
const relatedItem2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2'
});
relationship1.leftItem = getRemotedataObservable(relatedItem1);
relationship1.rightItem = getRemotedataObservable(item);
relationship2.leftItem = getRemotedataObservable(relatedItem2);
relationship2.rightItem = getRemotedataObservable(item);
const relatedItems = [relatedItem1, relatedItem2];
const itemService = jasmine.createSpyObj('itemService', {
findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0])
});
function initTestService() {
return new RelationshipService(
requestService,
halService,
rdbService,
itemService,
objectCache
);
}
const getRequestEntry$ = (successful: boolean) => {
return observableOf({
response: { isSuccessful: successful, payload: relationships } as any
} as RequestEntry)
};
beforeEach(() => {
requestService = getMockRequestService(getRequestEntry$(true));
service = initTestService();
});
describe('deleteRelationship', () => {
beforeEach(() => {
spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1));
spyOn(objectCache, 'remove');
service.deleteRelationship(relationships[0].uuid).subscribe();
});
it('should send a DeleteRequest', () => {
const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid);
expect(requestService.configure).toHaveBeenCalledWith(expected, undefined);
});
it('should clear the related items their cache', () => {
expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self);
expect(objectCache.remove).toHaveBeenCalledWith(item.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self);
});
});
describe('getItemRelationshipsArray', () => {
it('should return the item\'s relationships in the form of an array', () => {
service.getItemRelationshipsArray(item).subscribe((result) => {
expect(result).toEqual(relationships);
});
});
});
describe('getItemRelationshipLabels', () => {
it('should return the correct labels', () => {
service.getItemRelationshipLabels(item).subscribe((result) => {
expect(result).toEqual([relationshipType.rightLabel]);
});
});
});
describe('getRelatedItems', () => {
it('should return the related items', () => {
service.getRelatedItems(item).subscribe((result) => {
expect(result).toEqual(relatedItems);
});
});
});
describe('getRelatedItemsByLabel', () => {
it('should return the related items by label', () => {
service.getRelatedItemsByLabel(item, relationshipType.rightLabel).subscribe((result) => {
expect(result).toEqual(relatedItems);
});
});
})
});
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {
return observableOf(new RemoteData(false, false, true, undefined, obj));
}

View File

@@ -0,0 +1,235 @@
import { Injectable } from '@angular/core';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util';
import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
import {
configureRequest,
filterSuccessfulResponses,
getRemoteDataPayload, getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { DeleteRequest, RestRequest } from './request.models';
import { Observable } from 'rxjs/internal/Observable';
import { RestResponse } from '../cache/response.models';
import { Item } from '../shared/item.model';
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { RemoteData } from './remote-data';
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
import { zip as observableZip } from 'rxjs';
import { PaginatedList } from './paginated-list';
import { ItemDataService } from './item-data.service';
import {
compareArraysUsingIds, filterRelationsByTypeLabel,
relationsToItems
} from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { ObjectCacheService } from '../cache/object-cache.service';
/**
* The service handling all relationship requests
*/
@Injectable()
export class RelationshipService {
protected linkPath = 'relationships';
constructor(protected requestService: RequestService,
protected halService: HALEndpointService,
protected rdbService: RemoteDataBuildService,
protected itemService: ItemDataService,
protected objectCache: ObjectCacheService) {
}
/**
* Get the endpoint for a relationship by ID
* @param uuid
*/
getRelationshipEndpoint(uuid: string) {
return this.halService.getEndpoint(this.linkPath).pipe(
map((href: string) => `${href}/${uuid}`)
);
}
/**
* Find a relationship by its UUID
* @param uuid
*/
findById(uuid: string): Observable<RemoteData<Relationship>> {
const href$ = this.getRelationshipEndpoint(uuid);
return this.rdbService.buildSingle<Relationship>(href$);
}
/**
* Send a delete request for a relationship by ID
* @param uuid
*/
deleteRelationship(uuid: string): Observable<RestResponse> {
return this.getRelationshipEndpoint(uuid).pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
tap(() => this.clearRelatedCache(uuid))
);
}
/**
* Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types
* This is used for easier access of a relationship's type because they exist as observables
* @param item
*/
getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> {
return observableCombineLatest(
this.getItemRelationshipsArray(item),
this.getItemRelationshipTypesArray(item)
);
}
/**
* Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types
* This is used for easier access of a relationship's type and left and right items because they exist as observables
* @param item
*/
getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> {
return observableCombineLatest(
this.getItemLeftRelatedItemArray(item),
this.getItemRightRelatedItemArray(item),
this.getItemRelationshipTypesArray(item)
);
}
/**
* Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves
* This is used for easier access of the relationship and their left and right items because they exist as observables
* @param item
*/
getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> {
return observableCombineLatest(
this.getItemLeftRelatedItemArray(item),
this.getItemRightRelatedItemArray(item),
this.getItemRelationshipsArray(item)
);
}
/**
* Get an item their relationships in the form of an array
* @param item
*/
getItemRelationshipsArray(item: Item): Observable<Relationship[]> {
return item.relationships.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((rels: PaginatedList<Relationship>) => rels.page),
hasValueOperator(),
distinctUntilChanged(compareArraysUsingIds())
);
}
/**
* Get an item their relationship types in the form of an array
* @param item
*/
getItemRelationshipTypesArray(item: Item): Observable<RelationshipType[]> {
return this.getItemRelationshipsArray(item).pipe(
flatMap((rels: Relationship[]) =>
observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe(
map(([...arr]: Array<RemoteData<RelationshipType>>) => arr.map((d: RemoteData<RelationshipType>) => d.payload).filter((type) => hasValue(type))),
filter((arr) => arr.length === rels.length)
)
),
distinctUntilChanged(compareArraysUsingIds())
);
}
/**
* Get an item his relationship's left-side related items in the form of an array
* @param item
*/
getItemLeftRelatedItemArray(item: Item): Observable<Item[]> {
return this.getItemRelationshipsArray(item).pipe(
flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe(
map(([...arr]: Array<RemoteData<Item>>) => arr.map((rd: RemoteData<Item>) => rd.payload).filter((i) => hasValue(i))),
filter((arr) => arr.length === rels.length)
)),
distinctUntilChanged(compareArraysUsingIds())
);
}
/**
* Get an item his relationship's right-side related items in the form of an array
* @param item
*/
getItemRightRelatedItemArray(item: Item): Observable<Item[]> {
return this.getItemRelationshipsArray(item).pipe(
flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe(
map(([...arr]: Array<RemoteData<Item>>) => arr.map((rd: RemoteData<Item>) => rd.payload).filter((i) => hasValue(i))),
filter((arr) => arr.length === rels.length)
)),
distinctUntilChanged(compareArraysUsingIds())
);
}
/**
* Get an array of an item their unique relationship type's labels
* The array doesn't contain any duplicate labels
* @param item
*/
getItemRelationshipLabels(item: Item): Observable<string[]> {
return this.getItemResolvedRelatedItemsAndTypes(item).pipe(
map(([leftItems, rightItems, relTypesCurrentPage]) => {
return relTypesCurrentPage.map((type, index) => {
if (leftItems[index].uuid === item.uuid) {
return type.leftLabel;
} else {
return type.rightLabel;
}
});
}),
map((labels: string[]) => Array.from(new Set(labels)))
)
}
/**
* Resolve a given item's relationships into related items and return the items as an array
* @param item
*/
getRelatedItems(item: Item): Observable<Item[]> {
return this.getItemRelationshipsArray(item).pipe(
relationsToItems(item.uuid)
);
}
/**
* Resolve a given item's relationships into related items, filtered by a relationship label
* and return the items as an array
* @param item
* @param label
*/
getRelatedItemsByLabel(item: Item, label: string): Observable<Item[]> {
return this.getItemResolvedRelsAndTypes(item).pipe(
filterRelationsByTypeLabel(label),
relationsToItems(item.uuid)
);
}
/**
* Clear object and request caches of the items related to a relationship (left and right items)
* @param uuid
*/
clearRelatedCache(uuid: string) {
this.findById(uuid).pipe(
getSucceededRemoteData(),
flatMap((rd: RemoteData<Relationship>) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))),
take(1)
).subscribe(([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

@@ -65,8 +65,7 @@ const uuidsFromHrefSubstringSelector =
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => { const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
let result = []; let result = [];
if (isNotEmpty(state)) { if (isNotEmpty(state)) {
result = Object.values(state) result = Object.keys(state).filter((key) => key.startsWith(href)).map((key) => state[key]);
.filter((value: string) => value.startsWith(href));
} }
return result; return result;
}; };
@@ -315,4 +314,15 @@ export class RequestService {
return result; return result;
} }
/**
* Create an observable that emits a new value whenever the availability of the cached request changes.
* The value it emits is a boolean stating if the request exists in cache or not.
* @param href The href of the request to observe
*/
hasByHrefObservable(href: string): Observable<boolean> {
return this.getByHref(href).pipe(
map((requestEntry: RequestEntry) => this.isValid(requestEntry))
);
}
} }

View File

@@ -5,10 +5,11 @@ import { DSpaceObject } from './dspace-object.model';
import { Collection } from './collection.model'; import { Collection } from './collection.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Bitstream } from './bitstream.model'; import { Bitstream } from './bitstream.model';
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { PaginatedList } from '../data/paginated-list'; import { PaginatedList } from '../data/paginated-list';
import { Relationship } from './item-relationships/relationship.model'; import { Relationship } from './item-relationships/relationship.model';
import { ResourceType } from './resource-type'; import { ResourceType } from './resource-type';
import { getSucceededRemoteData } from './operators';
export class Item extends DSpaceObject { export class Item extends DSpaceObject {
static type = new ResourceType('item'); static type = new ResourceType('item');
@@ -97,7 +98,7 @@ export class Item extends DSpaceObject {
*/ */
getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> { getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
return this.bitstreams.pipe( return this.bitstreams.pipe(
filter((rd: RemoteData<PaginatedList<Bitstream>>) => !rd.isResponsePending && isNotUndefined(rd.payload)), getSucceededRemoteData(),
map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page), map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page),
filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)),
take(1), take(1),

View File

@@ -91,7 +91,7 @@ export const toDSpaceObjectListRD = () =>
source.pipe( source.pipe(
filter((rd: RemoteData<PaginatedList<SearchResult<T>>>) => rd.hasSucceeded), filter((rd: RemoteData<PaginatedList<SearchResult<T>>>) => rd.hasSucceeded),
map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => { map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => {
const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.indexableObject); const dsoPage: T[] = rd.payload.page.filter((result) => hasValue(result)).map((searchResult: SearchResult<T>) => searchResult.indexableObject);
const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList<T>; const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList<T>;
return Object.assign(rd, { payload: payload }); return Object.assign(rd, { payload: payload });
}) })

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component'; import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component'; import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { JournalGridElementComponent } from './journal-grid-element.component'; import { JournalGridElementComponent } from './journal-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -39,7 +42,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { OrgunitGridElementComponent } from './orgunit-grid-element.component'; import { OrgunitGridElementComponent } from './orgunit-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -39,7 +42,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { PersonGridElementComponent } from './person-grid-element.component'; import { PersonGridElementComponent } from './person-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -33,7 +36,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -3,11 +3,14 @@ import { Item } from '../../../../core/shared/item.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
import { ProjectGridElementComponent } from './project-grid-element.component'; import { ProjectGridElementComponent } from './project-grid-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -27,7 +30,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {

View File

@@ -4,7 +4,7 @@
</a> </a>
<ul ngbDropdownMenu class="dropdown-menu" aria-labelledby="dropdownLang"> <ul ngbDropdownMenu class="dropdown-menu" aria-labelledby="dropdownLang">
<li class="dropdown-item" #langSelect *ngFor="let lang of translate.getLangs()" <li class="dropdown-item" #langSelect *ngFor="let lang of translate.getLangs()"
(click)="translate.use(lang)" (click)="useLang(lang)"
[class.active]="lang === translate.currentLang"> [class.active]="lang === translate.currentLang">
{{ langLabel(lang) }} {{ langLabel(lang) }}
</li> </li>

View File

@@ -6,6 +6,9 @@ import {HttpClientTestingModule, HttpTestingController} from '@angular/common/ht
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';
import {LangConfig} from '../../../config/lang-config.interface'; import {LangConfig} from '../../../config/lang-config.interface';
import {Observable, of} from 'rxjs'; import {Observable, of} from 'rxjs';
import { By } from '@angular/platform-browser';
import { CookieService } from '../services/cookie.service';
import { MockCookieService } from '../mocks/mock-cookie.service';
// This test is completely independent from any message catalogs or keys in the codebase // This test is completely independent from any message catalogs or keys in the codebase
// The translation module is instantiated with these bogus messages that we aren't using anyway. // The translation module is instantiated with these bogus messages that we aren't using anyway.
@@ -28,8 +31,14 @@ class CustomLoader implements TranslateLoader {
/* tslint:enable:quotemark */ /* tslint:enable:quotemark */
/* tslint:enable:object-literal-key-quotes */ /* tslint:enable:object-literal-key-quotes */
let cookie: CookieService;
describe('LangSwitchComponent', () => { describe('LangSwitchComponent', () => {
beforeEach(() => {
cookie = Object.assign(new MockCookieService());
});
describe('with English and Deutsch activated, English as default', () => { describe('with English and Deutsch activated, English as default', () => {
let component: LangSwitchComponent; let component: LangSwitchComponent;
let fixture: ComponentFixture<LangSwitchComponent>; let fixture: ComponentFixture<LangSwitchComponent>;
@@ -61,7 +70,11 @@ describe('LangSwitchComponent', () => {
)], )],
declarations: [LangSwitchComponent], declarations: [LangSwitchComponent],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
providers: [TranslateService, {provide: GLOBAL_CONFIG, useValue: mockConfig}] providers: [
TranslateService,
{ provide: GLOBAL_CONFIG, useValue: mockConfig },
{ provide: CookieService, useValue: cookie }
]
}).compileComponents() }).compileComponents()
.then(() => { .then(() => {
translate = TestBed.get(TranslateService); translate = TestBed.get(TranslateService);
@@ -73,6 +86,7 @@ describe('LangSwitchComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
langSwitchElement = de.nativeElement; langSwitchElement = de.nativeElement;
fixture.detectChanges();
}); });
})); }));
@@ -93,6 +107,24 @@ describe('LangSwitchComponent', () => {
it('should define the main A HREF in the UI', (() => { it('should define the main A HREF in the UI', (() => {
expect(langSwitchElement.querySelector('a')).toBeDefined(); expect(langSwitchElement.querySelector('a')).toBeDefined();
})); }));
describe('when selecting a language', () => {
beforeEach(() => {
spyOn(translate, 'use');
spyOn(cookie, 'set');
const langItem = fixture.debugElement.query(By.css('.dropdown-item')).nativeElement;
langItem.click();
fixture.detectChanges();
});
it('should translate the app', () => {
expect(translate.use).toHaveBeenCalled();
});
it('should set the client\'s language cookie', () => {
expect(cookie.set).toHaveBeenCalled();
});
});
}); });
describe('with English as the only active and also default language', () => { describe('with English as the only active and also default language', () => {
@@ -127,7 +159,11 @@ describe('LangSwitchComponent', () => {
)], )],
declarations: [LangSwitchComponent], declarations: [LangSwitchComponent],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
providers: [TranslateService, {provide: GLOBAL_CONFIG, useValue: mockConfig}] providers: [
TranslateService,
{ provide: GLOBAL_CONFIG, useValue: mockConfig },
{ provide: CookieService, useValue: cookie }
]
}).compileComponents(); }).compileComponents();
translate = TestBed.get(TranslateService); translate = TestBed.get(TranslateService);
translate.addLangs(mockConfig.languages.filter((MyLangConfig) => MyLangConfig.active === true).map((a) => a.code)); translate.addLangs(mockConfig.languages.filter((MyLangConfig) => MyLangConfig.active === true).map((a) => a.code));

View File

@@ -2,6 +2,9 @@ import {Component, Inject, OnInit} from '@angular/core';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {LangConfig} from '../../../config/lang-config.interface'; import {LangConfig} from '../../../config/lang-config.interface';
import { ClientCookieService } from '../services/client-cookie.service';
import { LANG_COOKIE } from '../../app.component';
import { CookieService } from '../services/cookie.service';
@Component({ @Component({
selector: 'ds-lang-switch', selector: 'ds-lang-switch',
@@ -23,7 +26,8 @@ export class LangSwitchComponent implements OnInit {
constructor( constructor(
@Inject(GLOBAL_CONFIG) public config: GlobalConfig, @Inject(GLOBAL_CONFIG) public config: GlobalConfig,
public translate: TranslateService public translate: TranslateService,
public cookie: CookieService
) { ) {
} }
@@ -46,4 +50,13 @@ export class LangSwitchComponent implements OnInit {
return this.activeLangs.find((MyLangConfig) => MyLangConfig.code === langcode).label; return this.activeLangs.find((MyLangConfig) => MyLangConfig.code === langcode).label;
} }
/**
* Switch to a language and store it in a cookie
* @param lang The language to switch to
*/
useLang(lang: string) {
this.translate.use(lang);
this.cookie.set(LANG_COOKIE, lang);
}
} }

View File

@@ -0,0 +1,26 @@
/**
* Mock for [[CookieService]]
*/
export class MockCookieService {
cookies: Map<string, string>;
constructor(cookies: Map<string, string> = new Map()) {
this.cookies = cookies;
}
set(name, value) {
this.cookies.set(name, value);
}
get(name) {
return this.cookies.get(name);
}
remove() {
return jasmine.createSpy('remove');
}
getAll() {
return jasmine.createSpy('getAll');
}
}

View File

@@ -1,6 +1,9 @@
import {of as observableOf, Observable } from 'rxjs'; import {of as observableOf, Observable } from 'rxjs';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { RemoteData } from '../../core/data/remote-data';
import { Bitstream } from '../../core/shared/bitstream.model';
import { PaginatedList } from '../../core/data/paginated-list';
/* tslint:disable:no-shadowed-variable */ /* tslint:disable:no-shadowed-variable */
export const MockItem: Item = Object.assign(new Item(), { export const MockItem: Item = Object.assign(new Item(), {
@@ -9,12 +12,19 @@ export const MockItem: Item = Object.assign(new Item(), {
isArchived: true, isArchived: true,
isDiscoverable: true, isDiscoverable: true,
isWithdrawn: false, isWithdrawn: false,
bitstreams: observableOf({ bitstreams: observableOf(Object.assign({
self: 'dspace-angular://aggregated/object/1507836003548', self: 'dspace-angular://aggregated/object/1507836003548',
requestPending: false, requestPending: false,
responsePending: false, responsePending: false,
isSuccessful: true, isSuccessful: true,
errorMessage: '', errorMessage: '',
state: '',
error: undefined,
isRequestPending: false,
isResponsePending: false,
isLoading: false,
hasFailed: false,
hasSucceeded: true,
statusCode: '202', statusCode: '202',
pageInfo: {}, pageInfo: {},
payload: { payload: {
@@ -97,7 +107,7 @@ export const MockItem: Item = Object.assign(new Item(), {
} }
] ]
} }
}), }) as RemoteData<PaginatedList<Bitstream>>),
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357',
uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357',

View File

@@ -12,12 +12,15 @@ import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component'; import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component';
import { FileSizePipe } from '../../../utils/file-size-pipe'; import { FileSizePipe } from '../../../utils/file-size-pipe';
import { VarDirective } from '../../../utils/var.directive'; import { VarDirective } from '../../../utils/var.directive';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
let component: ItemDetailPreviewComponent; let component: ItemDetailPreviewComponent;
let fixture: ComponentFixture<ItemDetailPreviewComponent>; let fixture: ComponentFixture<ItemDetailPreviewComponent>;
const mockItem: Item = Object.assign(new Item(), { const mockItem: Item = Object.assign(new Item(), {
bitstreams: observableOf([]), bitstreams: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), []))),
metadata: { metadata: {
'dc.contributor.author': [ 'dc.contributor.author': [
{ {

View File

@@ -9,11 +9,14 @@ import { of as observableOf } from 'rxjs/internal/observable/of';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ITEM } from '../../../../items/switcher/item-type-switcher.component'; import { ITEM } from '../../../../items/switcher/item-type-switcher.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../testing/utils';
import { PaginatedList } from '../../../../../core/data/paginated-list';
import { PageInfo } from '../../../../../core/shared/page-info.model';
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithMetadata.hitHighlights = {}; mockItemWithMetadata.hitHighlights = {};
mockItemWithMetadata.indexableObject = Object.assign(new Item(), { mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {
@@ -45,7 +48,7 @@ mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
mockItemWithoutMetadata.hitHighlights = {}; mockItemWithoutMetadata.hitHighlights = {};
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
bitstreams: observableOf({}), bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
{ {