Merge pull request #3109 from alexandrevryghem/w2p-113560_edit-item-add-relationships-one-by-one_contribute-7_x

[Port dspace-7_x] Fixed caching & same entity type relationship with different left/rightwardtype bugs on edit item relationships
This commit is contained in:
Tim Donohue
2024-06-11 14:43:12 -05:00
committed by GitHub
23 changed files with 533 additions and 69 deletions

View File

@@ -125,7 +125,8 @@ describe('RelationshipDataService', () => {
const itemService = jasmine.createSpyObj('itemService', { const itemService = jasmine.createSpyObj('itemService', {
findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)), findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)),
findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]) findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]),
getIDHrefObs: (uuid: string) => observableOf(`https://demo.dspace.org/server/api/core/items/${uuid}`),
}); });
function initTestService() { function initTestService() {
@@ -240,6 +241,16 @@ describe('RelationshipDataService', () => {
}); });
}); });
describe('searchByItemsAndType', () => {
it('should call addDependency for each item to invalidate the request when one of the items is update', () => {
spyOn(service as any, 'addDependency');
service.searchByItemsAndType(relationshipType.id, item.id, relationshipType.leftwardType, ['item-id-1', 'item-id-2']);
expect((service as any).addDependency).toHaveBeenCalledTimes(2);
});
});
describe('resolveMetadataRepresentation', () => { describe('resolveMetadataRepresentation', () => {
const parentItem: Item = Object.assign(new Item(), { const parentItem: Item = Object.assign(new Item(), {
id: 'parent-item', id: 'parent-item',

View File

@@ -533,13 +533,18 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
); );
}); });
return this.searchBy( const searchRD$: Observable<RemoteData<PaginatedList<Relationship>>> = this.searchBy(
'byItemsAndType', 'byItemsAndType',
{ {
searchParams: searchParams searchParams: searchParams
}, },
) as Observable<RemoteData<PaginatedList<Relationship>>>; ) as Observable<RemoteData<PaginatedList<Relationship>>>;
arrayOfItemIds.forEach((itemId: string) => {
this.addDependency(searchRD$, this.itemService.getIDHrefObs(encodeURIComponent(itemId)));
});
return searchRD$;
} }
/** /**

View File

@@ -46,6 +46,9 @@ import { ResultsBackButtonModule } from '../../shared/results-back-button/result
import { import {
AccessControlFormModule AccessControlFormModule
} from '../../shared/access-control-form-container/access-control-form.module'; } from '../../shared/access-control-form-container/access-control-form.module';
import {
EditRelationshipListWrapperComponent
} from './item-relationships/edit-relationship-list-wrapper/edit-relationship-list-wrapper.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
@@ -94,6 +97,7 @@ import {
ItemRegisterDoiComponent, ItemRegisterDoiComponent,
ItemCurateComponent, ItemCurateComponent,
ItemAccessControlComponent, ItemAccessControlComponent,
EditRelationshipListWrapperComponent,
], ],
providers: [ providers: [
BundleDataService, BundleDataService,

View File

@@ -185,6 +185,7 @@ describe('EditItemRelationshipsService', () => {
expect(itemService.invalidateByHref).toHaveBeenCalledWith(currentItem.self); expect(itemService.invalidateByHref).toHaveBeenCalledWith(currentItem.self);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem1.self); expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem1.self);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem2.self);
expect(notificationsService.success).toHaveBeenCalledTimes(1); expect(notificationsService.success).toHaveBeenCalledTimes(1);
}); });
@@ -265,6 +266,116 @@ describe('EditItemRelationshipsService', () => {
}); });
}); });
describe('isProvidedItemTypeLeftType', () => {
it('should return true if the provided item corresponds to the left type of the relationship', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'leftType'}),
rightType: createSuccessfulRemoteDataObject$({id: 'rightType'}),
});
const itemType = Object.assign(new ItemType(), {id: 'leftType'} );
const item = Object.assign(new Item(), {uuid: 'item-uuid'});
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeTrue();
done();
});
});
it('should return false if the provided item corresponds to the right type of the relationship', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'leftType'}),
rightType: createSuccessfulRemoteDataObject$({id: 'rightType'}),
});
const itemType = Object.assign(new ItemType(), {id: 'rightType'} );
const item = Object.assign(new Item(), {uuid: 'item-uuid'});
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return undefined if the provided item corresponds does not match any of the relationship types', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'leftType'}),
rightType: createSuccessfulRemoteDataObject$({id: 'rightType'}),
});
const itemType = Object.assign(new ItemType(), {id: 'something-else'} );
const item = Object.assign(new Item(), {uuid: 'item-uuid'});
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeUndefined();
done();
});
});
});
describe('relationshipMatchesBothSameTypes', () => {
it('should return true if both left and right type of the relationship type are the same and match the provided itemtype', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'sameType'}),
rightType: createSuccessfulRemoteDataObject$({id:'sameType'}),
leftwardType: 'isDepartmentOfDivision',
rightwardType: 'isDivisionOfDepartment',
});
const itemType = Object.assign(new ItemType(), {id: 'sameType'} );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeTrue();
done();
});
});
it('should return false if both left and right type of the relationship type are the same and match the provided itemtype but the leftwardType & rightwardType is identical', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
leftwardType: 'isOrgUnitOfOrgUnit',
rightwardType: 'isOrgUnitOfOrgUnit',
});
const itemType = Object.assign(new ItemType(), { id: 'sameType' });
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return false if both left and right type of the relationship type are the same and do not match the provided itemtype', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'sameType'}),
rightType: createSuccessfulRemoteDataObject$({id: 'sameType'}),
leftwardType: 'isDepartmentOfDivision',
rightwardType: 'isDivisionOfDepartment',
});
const itemType = Object.assign(new ItemType(), {id: 'something-else'} );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return false if both left and right type of the relationship type are different', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({id: 'leftType'}),
rightType: createSuccessfulRemoteDataObject$({id: 'rightType'}),
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
const itemType = Object.assign(new ItemType(), {id: 'leftType'} );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
});
describe('displayNotifications', () => { describe('displayNotifications', () => {
it('should show one success notification when multiple requests succeeded', () => { it('should show one success notification when multiple requests succeeded', () => {
service.displayNotifications([ service.displayNotifications([

View File

@@ -10,7 +10,7 @@ import {
} from '../../../core/data/object-updates/object-updates.reducer'; } from '../../../core/data/object-updates/object-updates.reducer';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { EMPTY, Observable, BehaviorSubject, Subscription } from 'rxjs'; import { EMPTY, Observable, BehaviorSubject, Subscription, combineLatest as observableCombineLatest } from 'rxjs';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
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';
@@ -20,6 +20,9 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service'; import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -58,7 +61,17 @@ export class EditItemRelationshipsService {
// process each update one by one, while waiting for the previous to finish // process each update one by one, while waiting for the previous to finish
concatMap((update: FieldUpdate) => { concatMap((update: FieldUpdate) => {
if (update.changeType === FieldChangeType.REMOVE) { if (update.changeType === FieldChangeType.REMOVE) {
return this.deleteRelationship(update.field as DeleteRelationship).pipe(take(1)); return this.deleteRelationship(update.field as DeleteRelationship).pipe(
take(1),
switchMap((deleteRD: RemoteData<NoContent>) => {
if (deleteRD.hasSucceeded) {
return this.itemService.invalidateByHref((update.field as DeleteRelationship).relatedItem._links.self.href).pipe(
map(() => deleteRD),
);
}
return [deleteRD];
}),
);
} else if (update.changeType === FieldChangeType.ADD) { } else if (update.changeType === FieldChangeType.ADD) {
return this.addRelationship(update.field as RelationshipIdentifiable).pipe( return this.addRelationship(update.field as RelationshipIdentifiable).pipe(
take(1), take(1),
@@ -169,6 +182,55 @@ export class EditItemRelationshipsService {
} }
} }
isProvidedItemTypeLeftType(relationshipType: RelationshipType, itemType: ItemType, item: Item): Observable<boolean> {
return this.getRelationshipLeftAndRightType(relationshipType).pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
if (leftType.id === itemType.id) {
return true;
}
if (rightType.id === itemType.id) {
return false;
}
// should never happen...
console.warn(`The item ${item.uuid} is not on the right or the left side of relationship type ${relationshipType.uuid}`);
return undefined;
})
);
}
/**
* Whether both side of the relationship need to be displayed on the edit relationship page or not.
*
* @param relationshipType The relationship type
* @param itemType The item type
*/
shouldDisplayBothRelationshipSides(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
return this.getRelationshipLeftAndRightType(relationshipType).pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
return leftType.id === itemType.id && rightType.id === itemType.id && relationshipType.leftwardType !== relationshipType.rightwardType;
}),
);
}
protected getRelationshipLeftAndRightType(relationshipType: RelationshipType): Observable<[ItemType, ItemType]> {
const leftType$: Observable<ItemType> = relationshipType.leftType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
const rightType$: Observable<ItemType> = relationshipType.rightType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
return observableCombineLatest([
leftType$,
rightType$,
]);
}
/** /**
@@ -185,6 +247,5 @@ export class EditItemRelationshipsService {
*/ */
getNotificationContent(key: string): string { getNotificationContent(key: string): string {
return this.translateService.instant(this.notificationsPrefix + key + '.content'); return this.translateService.instant(this.notificationsPrefix + key + '.content');
} }
} }

View File

@@ -0,0 +1,31 @@
<ng-container *ngIf="shouldDisplayBothRelationshipSides$ | async">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="isLeftItem$"
class="d-block mb-4"
></ds-edit-relationship-list>
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="isRightItem$"
></ds-edit-relationship-list>
</ng-container>
<ng-container *ngIf="(shouldDisplayBothRelationshipSides$ | async) === false">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="currentItemIsLeftItem$"
></ds-edit-relationship-list>
</ng-container>

View File

@@ -0,0 +1,109 @@
import { ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { EditRelationshipListWrapperComponent } from './edit-relationship-list-wrapper.component';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
import { By } from '@angular/platform-browser';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { Item } from '../../../../core/shared/item.model';
import { cold } from 'jasmine-marbles';
describe('EditRelationshipListWrapperComponent', () => {
let editItemRelationshipsService: EditItemRelationshipsService;
let comp: EditRelationshipListWrapperComponent;
let fixture: ComponentFixture<EditRelationshipListWrapperComponent>;
const leftType = Object.assign(new ItemType(), {id: 'leftType', label: 'leftTypeString'});
const rightType = Object.assign(new ItemType(), {id: 'rightType', label: 'rightTypeString'});
const relationshipType = Object.assign(new RelationshipType(), {
id: '1',
leftMaxCardinality: null,
leftMinCardinality: 0,
leftType: createSuccessfulRemoteDataObject$(leftType),
leftwardType: 'isOrgUnitOfOrgUnit',
rightMaxCardinality: null,
rightMinCardinality: 0,
rightType: createSuccessfulRemoteDataObject$(rightType),
rightwardType: 'isOrgUnitOfOrgUnit',
uuid: 'relationshiptype-1',
});
const item = Object.assign(new Item(), {uuid: 'item-uuid'});
beforeEach(waitForAsync(() => {
editItemRelationshipsService = jasmine.createSpyObj('editItemRelationshipsService', {
isProvidedItemTypeLeftType: observableOf(true),
shouldDisplayBothRelationshipSides: observableOf(false),
});
TestBed.configureTestingModule({
// imports: [NoopAnimationsModule, SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListWrapperComponent],
providers: [
{provide: EditItemRelationshipsService, useValue: editItemRelationshipsService},
ChangeDetectorRef
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipListWrapperComponent);
comp = fixture.componentInstance;
comp.relationshipType = relationshipType;
comp.itemType = leftType;
comp.item = item;
fixture.detectChanges();
});
describe('onInit', () => {
it('should render the component', () => {
expect(comp).toBeTruthy();
});
it('should set currentItemIsLeftItem$ and bothItemsMatchType$ based on the provided relationshipType, itemType and item', () => {
expect(editItemRelationshipsService.isProvidedItemTypeLeftType).toHaveBeenCalledWith(relationshipType, leftType, item);
expect(editItemRelationshipsService.shouldDisplayBothRelationshipSides).toHaveBeenCalledWith(relationshipType, leftType);
expect(comp.currentItemIsLeftItem$.getValue()).toEqual(true);
expect(comp.shouldDisplayBothRelationshipSides$).toBeObservable(cold('(a|)', { a: false }));
});
});
describe('when the current item is left', () => {
it('should render one relationship list section', () => {
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(1);
});
});
describe('when the current item is right', () => {
it('should render one relationship list section', () => {
(editItemRelationshipsService.isProvidedItemTypeLeftType as jasmine.Spy).and.returnValue(observableOf(false));
comp.ngOnInit();
fixture.detectChanges();
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(1);
});
});
describe('when the current item is both left and right', () => {
it('should render two relationship list sections', () => {
(editItemRelationshipsService.shouldDisplayBothRelationshipSides as jasmine.Spy).and.returnValue(observableOf(true));
comp.ngOnInit();
fixture.detectChanges();
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(2);
});
});
});

View File

@@ -0,0 +1,88 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { Item } from '../../../../core/shared/item.model';
import { hasValue } from '../../../../shared/empty.util';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
@Component({
selector: 'ds-edit-relationship-list-wrapper',
styleUrls: ['./edit-relationship-list-wrapper.component.scss'],
templateUrl: './edit-relationship-list-wrapper.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 EditRelationshipListWrapperComponent implements OnInit, OnDestroy {
/**
* The item to display related items for
*/
@Input() item: Item;
@Input() itemType: ItemType;
/**
* The URL to the current page
* Used to fetch updates for the current item from the store
*/
@Input() url: string;
/**
* The label of the relationship-type we're rendering a list for
*/
@Input() relationshipType: RelationshipType;
/**
* If updated information has changed
*/
@Input() hasChanges!: Observable<boolean>;
/**
* The event emmiter to submit the new information
*/
@Output() submitModal: EventEmitter<void> = new EventEmitter();
/**
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/
currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined);
isLeftItem$ = new BehaviorSubject(true);
isRightItem$ = new BehaviorSubject(false);
shouldDisplayBothRelationshipSides$: Observable<boolean>;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
constructor(
protected editItemRelationshipsService: EditItemRelationshipsService,
) {
}
ngOnInit(): void {
this.subs.push(this.editItemRelationshipsService.isProvidedItemTypeLeftType(this.relationshipType, this.itemType, this.item)
.subscribe((nextValue: boolean) => {
this.currentItemIsLeftItem$.next(nextValue);
}));
this.shouldDisplayBothRelationshipSides$ = this.editItemRelationshipsService.shouldDisplayBothRelationshipSides(this.relationshipType, this.itemType);
}
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -2,7 +2,7 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs'; import { BehaviorSubject, of as observableOf } from 'rxjs';
import { LinkService } from '../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../core/cache/builders/link.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { RelationshipDataService } from '../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../core/data/relationship-data.service';
@@ -63,6 +63,7 @@ describe('EditRelationshipListComponent', () => {
let relationships: Relationship[]; let relationships: Relationship[];
let relationshipType: RelationshipType; let relationshipType: RelationshipType;
let paginationOptions: PaginationComponentOptions; let paginationOptions: PaginationComponentOptions;
let currentItemIsLeftItem$ = new BehaviorSubject<boolean>(true);
const resetComponent = () => { const resetComponent = () => {
fixture = TestBed.createComponent(EditRelationshipListComponent); fixture = TestBed.createComponent(EditRelationshipListComponent);
@@ -73,6 +74,7 @@ describe('EditRelationshipListComponent', () => {
comp.url = url; comp.url = url;
comp.relationshipType = relationshipType; comp.relationshipType = relationshipType;
comp.hasChanges = observableOf(false); comp.hasChanges = observableOf(false);
comp.currentItemIsLeftItem$ = currentItemIsLeftItem$;
fixture.detectChanges(); fixture.detectChanges();
}; };
@@ -296,6 +298,7 @@ describe('EditRelationshipListComponent', () => {
leftwardType: 'isAuthorOfPublication', leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor', rightwardType: 'isPublicationOfAuthor',
}); });
currentItemIsLeftItem$ = new BehaviorSubject<boolean>(true);
relationshipService.getItemRelationshipsByLabel.calls.reset(); relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent(); resetComponent();
}); });
@@ -320,6 +323,7 @@ describe('EditRelationshipListComponent', () => {
leftwardType: 'isPublicationOfAuthor', leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication', rightwardType: 'isAuthorOfPublication',
}); });
currentItemIsLeftItem$ = new BehaviorSubject<boolean>(false);
relationshipService.getItemRelationshipsByLabel.calls.reset(); relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent(); resetComponent();
}); });

View File

@@ -26,13 +26,14 @@ import {
toArray, toArray,
concatMap concatMap
} from 'rxjs/operators'; } from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator } from '../../../../shared/empty.util'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { import {
RelationshipType RelationshipType
} from '../../../../core/shared/item-relationships/relationship-type.model'; } from '../../../../core/shared/item-relationships/relationship-type.model';
import { import {
getAllSucceededRemoteData, getAllSucceededRemoteData,
getFirstCompletedRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload,
getRemoteDataPayload, getRemoteDataPayload,
@@ -114,7 +115,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType}, * Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side. * false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/ */
private currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined); @Input() currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined);
relatedEntityType$: Observable<ItemType>; relatedEntityType$: Observable<ItemType>;
@@ -214,17 +215,14 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
* Get the relevant label for this relationship type * Get the relevant label for this relationship type
*/ */
private getLabel(): Observable<string> { private getLabel(): Observable<string> {
return observableCombineLatest([ return this.currentItemIsLeftItem$.pipe(
this.relationshipType.leftType, map((currentItemIsLeftItem) => {
this.relationshipType.rightType, if (currentItemIsLeftItem) {
].map((itemTypeRD) => itemTypeRD.pipe( return this.relationshipType.leftwardType;
getFirstSucceededRemoteData(), } else {
getRemoteDataPayload(), return this.relationshipType.rightwardType;
))).pipe( }
map((itemTypes: ItemType[]) => [ })
this.relationshipType.leftwardType,
this.relationshipType.rightwardType,
][itemTypes.findIndex((itemType) => itemType.id === this.itemType.id)]),
); );
} }
@@ -252,6 +250,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
modalComp.toAdd = []; modalComp.toAdd = [];
modalComp.toRemove = []; modalComp.toRemove = [];
modalComp.isPending = false; modalComp.isPending = false;
modalComp.hiddenQuery = '-search.resourceid:' + this.item.uuid;
this.item.owningCollection.pipe( this.item.owningCollection.pipe(
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
@@ -280,7 +279,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
} }
} }
this.loading$.next(true); this.loading$.next(isNotEmpty(modalComp.toAdd) || isNotEmpty(modalComp.toRemove));
// emit the last page again to trigger a fieldupdates refresh // emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue()); this.relationshipsRd$.next(this.relationshipsRd$.getValue());
}); });
@@ -298,6 +297,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
} else { } else {
modalComp.toRemove.push(searchResult); modalComp.toRemove.push(searchResult);
} }
this.loading$.next(isNotEmpty(modalComp.toAdd) || isNotEmpty(modalComp.toRemove));
}); });
}; };
@@ -337,6 +337,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
type: this.relationshipType, type: this.relationshipType,
originalIsLeft: isLeft, originalIsLeft: isLeft,
originalItem: this.item, originalItem: this.item,
relatedItem,
relationship, relationship,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
return this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update); return this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update);
@@ -370,6 +371,11 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
modalComp.toAdd = []; modalComp.toAdd = [];
modalComp.toRemove = []; modalComp.toRemove = [];
this.loading$.next(false);
};
modalComp.closeEv = () => {
this.loading$.next(false);
}; };
this.relatedEntityType$ this.relatedEntityType$
@@ -425,24 +431,6 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
this.relationshipMessageKey$ = this.getRelationshipMessageKey(); this.relationshipMessageKey$ = this.getRelationshipMessageKey();
this.subs.push(this.relationshipLeftAndRightType$.pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
if (leftType.id === this.itemType.id) {
return true;
}
if (rightType.id === this.itemType.id) {
return false;
}
// should never happen...
console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`);
return undefined;
})
).subscribe((nextValue: boolean) => {
this.currentItemIsLeftItem$.next(nextValue);
}));
// initialize the pagination options // initialize the pagination options
this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig = new PaginationComponentOptions();
@@ -513,10 +501,24 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
this.relationshipService.isLeftItem(relationship, this.item).pipe( this.relationshipService.isLeftItem(relationship, this.item).pipe(
// emit an array containing both the relationship and whether it's the left item, // emit an array containing both the relationship and whether it's the left item,
// as we'll need both // as we'll need both
map((isLeftItem: boolean) => [relationship, isLeftItem]) switchMap((isLeftItem: boolean) => {
) if (isLeftItem) {
return relationship.rightItem.pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload(),
map((relatedItem: Item) => [relationship, isLeftItem, relatedItem]),
);
} else {
return relationship.leftItem.pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload(),
map((relatedItem: Item) => [relationship, isLeftItem, relatedItem]),
);
}
}),
), ),
map(([relationship, isLeftItem]: [Relationship, boolean]) => { ),
map(([relationship, isLeftItem, relatedItem]: [Relationship, boolean, Item]) => {
// turn it into a RelationshipIdentifiable, an // turn it into a RelationshipIdentifiable, an
const nameVariant = const nameVariant =
isLeftItem ? relationship.rightwardValue : relationship.leftwardValue; isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
@@ -526,6 +528,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
relationship, relationship,
originalIsLeft: isLeftItem, originalIsLeft: isLeftItem,
originalItem: this.item, originalItem: this.item,
relatedItem: relatedItem,
nameVariant, nameVariant,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
}), }),

View File

@@ -90,13 +90,11 @@ export class EditRelationshipComponent implements OnChanges {
getRemoteDataPayload(), getRemoteDataPayload(),
filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid))
); );
this.relatedItem$ = observableCombineLatest( this.relatedItem$ = observableCombineLatest([
this.leftItem$, this.leftItem$,
this.rightItem$, this.rightItem$,
).pipe( ]).pipe(
map((items: Item[]) => map(([leftItem, rightItem]: [Item, Item]) => leftItem.uuid === this.editItem.uuid ? rightItem : leftItem),
items.find((item) => item.uuid !== this.editItem.uuid)
)
); );
} else { } else {
this.relatedItem$ = of(this.update.relatedItem); this.relatedItem$ = of(this.update.relatedItem);

View File

@@ -5,13 +5,13 @@
</div> </div>
<div *ngIf="relationshipTypes$ | async as relationshipTypes; else loading" class="mb-4"> <div *ngIf="relationshipTypes$ | async as relationshipTypes; else loading" class="mb-4">
<div *ngFor="let relationshipType of relationshipTypes; trackBy: trackById" class="mb-4"> <div *ngFor="let relationshipType of relationshipTypes; trackBy: trackById" class="mb-4">
<ds-edit-relationship-list <ds-edit-relationship-list-wrapper
[url]="url" [url]="url"
[item]="item" [item]="item"
[itemType]="entityType" [itemType]="entityType"
[relationshipType]="relationshipType" [relationshipType]="relationshipType"
[hasChanges]="hasChanges$" [hasChanges]="hasChanges$"
></ds-edit-relationship-list> ></ds-edit-relationship-list-wrapper>
</div> </div>
</div> </div>
<div class="button-row bottom"> <div class="button-row bottom">

View File

@@ -1,4 +1,4 @@
import { combineLatest as observableCombineLatest, Observable, zip as observableZip } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
import { distinctUntilChanged, map, mergeMap, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, map, mergeMap, switchMap } from 'rxjs/operators';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
@@ -45,17 +45,19 @@ export const compareArraysUsingIds = <T extends { id: string }>() =>
/** /**
* Operator for turning a list of relationships into a list of the relevant items * Operator for turning a list of relationships into a list of the relevant items
* @param {string} thisId The item's id of which the relations belong to * @param {string} thisId The item's id of which the relations belong to
* @returns {(source: Observable<Relationship[]>) => Observable<Item[]>}
*/ */
export const relationsToItems = (thisId: string) => export const relationsToItems = (thisId: string): (source: Observable<Relationship[]>) => Observable<Item[]> =>
(source: Observable<Relationship[]>): Observable<Item[]> => (source: Observable<Relationship[]>): Observable<Item[]> =>
source.pipe( source.pipe(
mergeMap((rels: Relationship[]) => mergeMap((relationships: Relationship[]) => {
observableZip( if (relationships.length === 0) {
...rels.map((rel: Relationship) => observableCombineLatest(rel.leftItem, rel.rightItem)) return observableOf([]);
) }
), return observableZip(
map((arr) => ...relationships.map((rel: Relationship) => observableCombineLatest([rel.leftItem, rel.rightItem])),
);
}),
map((arr: [RemoteData<Item>, RemoteData<Item>][]) =>
arr arr
.filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded) .filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded)
.map(([leftItem, rightItem]) => { .map(([leftItem, rightItem]) => {
@@ -74,9 +76,9 @@ export const relationsToItems = (thisId: string) =>
* Operator for turning a paginated list of relationships into a paginated list of the relevant items * Operator for turning a paginated list of relationships into a paginated list of the relevant items
* The result is wrapped in the original RemoteData and PaginatedList * The result is wrapped in the original RemoteData and PaginatedList
* @param {string} thisId The item's id of which the relations belong to * @param {string} thisId The item's id of which the relations belong to
* @returns {(source: Observable<Relationship[]>) => Observable<Item[]>}
*/ */
export const paginatedRelationsToItems = (thisId: string) => (source: Observable<RemoteData<PaginatedList<Relationship>>>): Observable<RemoteData<PaginatedList<Item>>> => export const paginatedRelationsToItems = (thisId: string): (source: Observable<RemoteData<PaginatedList<Relationship>>>) => Observable<RemoteData<PaginatedList<Item>>> =>
(source: Observable<RemoteData<PaginatedList<Relationship>>>): Observable<RemoteData<PaginatedList<Item>>> =>
source.pipe( source.pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
switchMap((relationshipsRD: RemoteData<PaginatedList<Relationship>>) => { switchMap((relationshipsRD: RemoteData<PaginatedList<Relationship>>) => {

View File

@@ -19,6 +19,7 @@
[repeatable]="repeatable" [repeatable]="repeatable"
[context]="context" [context]="context"
[query]="query" [query]="query"
[hiddenQuery]="hiddenQuery"
[relationshipType]="relationshipType" [relationshipType]="relationshipType"
[isLeft]="isLeft" [isLeft]="isLeft"
[item]="item" [item]="item"

View File

@@ -7,7 +7,6 @@ import { DsDynamicLookupRelationModalComponent } from './dynamic-lookup-relation
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { RelationshipDataService } from '../../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../../core/data/relationship-data.service';
import { RelationshipTypeDataService } from '../../../../../core/data/relationship-type-data.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
@@ -119,7 +118,6 @@ describe('DsDynamicLookupRelationModalComponent', () => {
{ {
provide: RelationshipDataService, useValue: { getNameVariant: () => observableOf(nameVariant) } provide: RelationshipDataService, useValue: { getNameVariant: () => observableOf(nameVariant) }
}, },
{ provide: RelationshipTypeDataService, useValue: {} },
{ provide: RemoteDataBuildService, useValue: rdbService }, { provide: RemoteDataBuildService, useValue: rdbService },
{ {
provide: Store, useValue: { provide: Store, useValue: {

View File

@@ -17,7 +17,6 @@ import {
UpdateRelationshipNameVariantAction, UpdateRelationshipNameVariantAction,
} from './relationship.actions'; } from './relationship.actions';
import { RelationshipDataService } from '../../../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../../../core/data/relationship-data.service';
import { RelationshipTypeDataService } from '../../../../../core/data/relationship-type-data.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '../../../../../app.reducer'; import { AppState } from '../../../../../app.reducer';
import { Context } from '../../../../../core/shared/context.model'; import { Context } from '../../../../../core/shared/context.model';
@@ -97,6 +96,11 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
query: string; query: string;
/**
* A hidden query that will be used but not displayed in the url/searchbar
*/
hiddenQuery: string;
/** /**
* A map of subscriptions within this component * A map of subscriptions within this component
*/ */
@@ -159,7 +163,6 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
public modal: NgbActiveModal, public modal: NgbActiveModal,
private selectableListService: SelectableListService, private selectableListService: SelectableListService,
private relationshipService: RelationshipDataService, private relationshipService: RelationshipDataService,
private relationshipTypeService: RelationshipTypeDataService,
private externalSourceService: ExternalSourceDataService, private externalSourceService: ExternalSourceDataService,
private lookupRelationService: LookupRelationService, private lookupRelationService: LookupRelationService,
private searchConfigService: SearchConfigurationService, private searchConfigService: SearchConfigurationService,
@@ -212,6 +215,7 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
this.toAdd = []; this.toAdd = [];
this.toRemove = []; this.toRemove = [];
this.modal.close(); this.modal.close();
this.closeEv();
} }
/** /**
@@ -306,13 +310,19 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
/** /**
* Called when discard button is clicked, emit discard event to parent to conclude functionality * Called when close button is clicked
*/
closeEv(): void {
}
/**
* Called when discard button is clicked
*/ */
discardEv(): void { discardEv(): void {
} }
/** /**
* Called when submit button is clicked, emit submit event to parent to conclude functionality * Called when submit button is clicked
*/ */
submitEv(): void { submitEv(): void {
} }

View File

@@ -2,6 +2,7 @@
[configuration]="this.relationship.searchConfiguration" [configuration]="this.relationship.searchConfiguration"
[context]="context" [context]="context"
[fixedFilterQuery]="this.relationship.filter" [fixedFilterQuery]="this.relationship.filter"
[hiddenQuery]="hiddenQuery"
[inPlaceSearch]="true" [inPlaceSearch]="true"
[linkType]="linkTypes.ExternalLink" [linkType]="linkTypes.ExternalLink"
[searchFormPlaceholder]="'submission.sections.describe.relationship-lookup.search-tab.search-form.placeholder'" [searchFormPlaceholder]="'submission.sections.describe.relationship-lookup.search-tab.search-form.placeholder'"

View File

@@ -93,6 +93,11 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
*/ */
@Input() isEditRelationship: boolean; @Input() isEditRelationship: boolean;
/**
* A hidden query that will be used but not displayed in the url/searchbar
*/
@Input() hiddenQuery: string;
/** /**
* Send an event to deselect an object from the list * Send an event to deselect an object from the list
*/ */

View File

@@ -18,7 +18,7 @@ import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model'
}) })
export class ThemedDynamicLookupRelationSearchTabComponent extends ThemedComponent<DsDynamicLookupRelationSearchTabComponent> { export class ThemedDynamicLookupRelationSearchTabComponent extends ThemedComponent<DsDynamicLookupRelationSearchTabComponent> {
protected inAndOutputNames: (keyof DsDynamicLookupRelationSearchTabComponent & keyof this)[] = ['relationship', 'listId', protected inAndOutputNames: (keyof DsDynamicLookupRelationSearchTabComponent & keyof this)[] = ['relationship', 'listId',
'query', 'repeatable', 'selection$', 'context', 'relationshipType', 'item', 'isLeft', 'toRemove', 'isEditRelationship', 'query', 'hiddenQuery', 'repeatable', 'selection$', 'context', 'relationshipType', 'item', 'isLeft', 'toRemove', 'isEditRelationship',
'deselectObject', 'selectObject', 'resultFound']; 'deselectObject', 'selectObject', 'resultFound'];
@Input() relationship: RelationshipOptions; @Input() relationship: RelationshipOptions;
@@ -27,6 +27,8 @@ export class ThemedDynamicLookupRelationSearchTabComponent extends ThemedCompone
@Input() query: string; @Input() query: string;
@Input() hiddenQuery: string;
@Input() repeatable: boolean; @Input() repeatable: boolean;
@Input() selection$: Observable<ListableObject[]>; @Input() selection$: Observable<ListableObject[]>;

View File

@@ -106,6 +106,7 @@ const searchServiceStub = jasmine.createSpyObj('SearchService', {
trackSearch: {}, trackSearch: {},
}) as SearchService; }) as SearchService;
const queryParam = 'test query'; const queryParam = 'test query';
const hiddenQuery = 'hidden query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
const defaultSearchOptions = new PaginatedSearchOptions({ pagination }); const defaultSearchOptions = new PaginatedSearchOptions({ pagination });
@@ -237,6 +238,7 @@ describe('SearchComponent', () => {
comp = fixture.componentInstance; // SearchComponent test instance comp = fixture.componentInstance; // SearchComponent test instance
comp.inPlaceSearch = false; comp.inPlaceSearch = false;
comp.paginationId = paginationId; comp.paginationId = paginationId;
comp.hiddenQuery = hiddenQuery;
spyOn((comp as any), 'getSearchOptions').and.returnValue(paginatedSearchOptions$.asObservable()); spyOn((comp as any), 'getSearchOptions').and.returnValue(paginatedSearchOptions$.asObservable());
}); });

View File

@@ -75,6 +75,11 @@ export class SearchComponent implements OnDestroy, OnInit {
*/ */
@Input() fixedFilterQuery: string; @Input() fixedFilterQuery: string;
/**
* A hidden query that will be used but not displayed in the url/searchbar
*/
@Input() hiddenQuery: string;
/** /**
* If this is true, the request will only be sent if there's * If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true * no valid cached version. Defaults to true
@@ -454,8 +459,18 @@ export class SearchComponent implements OnDestroy, OnInit {
if (this.configuration === 'supervision') { if (this.configuration === 'supervision') {
followLinks.push(followLink<WorkspaceItem>('supervisionOrders', { isOptional: true }) as any); followLinks.push(followLink<WorkspaceItem>('supervisionOrders', { isOptional: true }) as any);
} }
const searchOptionsWithHidden = Object.assign (new PaginatedSearchOptions({}), searchOptions);
if (isNotEmpty(this.hiddenQuery)) {
if (isNotEmpty(searchOptionsWithHidden.query)) {
searchOptionsWithHidden.query = searchOptionsWithHidden.query + ' AND ' + this.hiddenQuery;
} else {
searchOptionsWithHidden.query = this.hiddenQuery;
}
}
this.service.search( this.service.search(
searchOptions, searchOptionsWithHidden,
undefined, undefined,
this.useCachedVersionIfAvailable, this.useCachedVersionIfAvailable,
true, true,
@@ -464,7 +479,7 @@ export class SearchComponent implements OnDestroy, OnInit {
.subscribe((results: RemoteData<SearchObjects<DSpaceObject>>) => { .subscribe((results: RemoteData<SearchObjects<DSpaceObject>>) => {
if (results.hasSucceeded) { if (results.hasSucceeded) {
if (this.trackStatistics) { if (this.trackStatistics) {
this.service.trackSearch(searchOptions, results.payload); this.service.trackSearch(searchOptionsWithHidden, results.payload);
} }
if (results.payload?.page?.length > 0) { if (results.payload?.page?.length > 0) {
this.resultFound.emit(results.payload); this.resultFound.emit(results.payload);

View File

@@ -23,6 +23,7 @@ export class ThemedSearchComponent extends ThemedComponent<SearchComponent> {
'context', 'context',
'configuration', 'configuration',
'fixedFilterQuery', 'fixedFilterQuery',
'hiddenQuery',
'useCachedVersionIfAvailable', 'useCachedVersionIfAvailable',
'inPlaceSearch', 'inPlaceSearch',
'linkType', 'linkType',
@@ -55,6 +56,8 @@ export class ThemedSearchComponent extends ThemedComponent<SearchComponent> {
@Input() fixedFilterQuery: string; @Input() fixedFilterQuery: string;
@Input() hiddenQuery: string;
@Input() useCachedVersionIfAvailable: boolean; @Input() useCachedVersionIfAvailable: boolean;
@Input() inPlaceSearch: boolean; @Input() inPlaceSearch: boolean;