Merge remote-tracking branch 'upstream/main' into 74609-Fix-type-error-on-search-pages

This commit is contained in:
Yana De Pauw
2020-11-19 14:03:16 +01:00
114 changed files with 2407 additions and 406 deletions

View File

@@ -40,7 +40,7 @@
"clean:bld": "rimraf build", "clean:bld": "rimraf build",
"clean:node": "rimraf node_modules", "clean:node": "rimraf node_modules",
"clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld",
"clean": "yarn run clean:prod && yarn run clean:node && yarn run clean:env", "clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
"clean:env": "rimraf src/environments/environment.ts", "clean:env": "rimraf src/environments/environment.ts",
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts" "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts"
}, },

View File

@@ -4,7 +4,8 @@
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut> <div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
<div *ngIf="collectionRD?.payload as collection"> <div *ngIf="collectionRD?.payload as collection">
<ds-view-tracker [object]="collection"></ds-view-tracker> <ds-view-tracker [object]="collection"></ds-view-tracker>
<header class="comcol-header border-bottom mb-4 pb-4"> <div class="d-flex flex-row border-bottom mb-4 pb-4">
<header class="comcol-header mr-auto">
<!-- Collection Name --> <!-- Collection Name -->
<ds-comcol-page-header <ds-comcol-page-header
[name]="collection.name"> [name]="collection.name">
@@ -32,8 +33,11 @@
[hasInnerHtml]="true" [hasInnerHtml]="true"
[title]="'collection.page.news'"> [title]="'collection.page.news'">
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'collections'" [dso]="collection" [tooltipMsg]="'collection.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<!-- Browse-By Links --> <!-- Browse-By Links -->
<ds-comcol-page-browse-by <ds-comcol-page-browse-by

View File

@@ -2,7 +2,8 @@
<div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut> <div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
<div *ngIf="communityRD?.payload; let communityPayload"> <div *ngIf="communityRD?.payload; let communityPayload">
<ds-view-tracker [object]="communityPayload"></ds-view-tracker> <ds-view-tracker [object]="communityPayload"></ds-view-tracker>
<header class="comcol-header border-bottom mb-4 pb-4"> <div class="d-flex flex-row border-bottom mb-4 pb-4">
<header class="comcol-header mr-auto">
<!-- Community name --> <!-- Community name -->
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header> <ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
<!-- Community logo --> <!-- Community logo -->
@@ -18,8 +19,11 @@
<ds-comcol-page-content [content]="communityPayload.sidebarText" [hasInnerHtml]="true" <ds-comcol-page-content [content]="communityPayload.sidebarText" [hasInnerHtml]="true"
[title]="'community.page.news'"> [title]="'community.page.news'">
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'communities'" [dso]="communityPayload" [tooltipMsg]="'community.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<!-- Browse-By Links --> <!-- Browse-By Links -->
<ds-comcol-page-browse-by [id]="communityPayload.id" [contentType]="communityPayload.type"> <ds-comcol-page-browse-by [id]="communityPayload.id" [contentType]="communityPayload.type">

View File

@@ -48,7 +48,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
ngOnInit(): void { ngOnInit(): void {
observableCombineLatest(this.route.data, this.route.parent.data).pipe( observableCombineLatest(this.route.data, this.route.parent.data).pipe(
map(([data, parentData]) => Object.assign({}, data, parentData)), map(([data, parentData]) => Object.assign({}, data, parentData)),
map((data) => data.item), map((data) => data.dso),
first(), first(),
map((data: RemoteData<Item>) => data.payload) map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => { ).subscribe((item: Item) => {

View File

@@ -47,7 +47,7 @@ export class EditItemPageComponent implements OnInit {
this.pages = this.route.routeConfig.children this.pages = this.route.routeConfig.children
.map((child: any) => child.path) .map((child: any) => child.path)
.filter((path: string) => isNotEmpty(path)); // ignore reroutes .filter((path: string) => isNotEmpty(path)); // ignore reroutes
this.itemRD$ = this.route.data.pipe(map((data) => data.item)); this.itemRD$ = this.route.data.pipe(map((data) => data.dso));
} }
/** /**

View File

@@ -74,7 +74,7 @@ describe('ItemAuthorizationsComponent test suite', () => {
const routeStub = { const routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject(item) dso: createSuccessfulRemoteDataObject(item)
}) })
}; };

View File

@@ -75,7 +75,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy {
*/ */
ngOnInit(): void { ngOnInit(): void {
this.item$ = this.route.data.pipe( this.item$ = this.route.data.pipe(
map((data) => data.item), map((data) => data.dso),
getFirstSucceededRemoteDataWithNotEmptyPayload(), getFirstSucceededRemoteDataWithNotEmptyPayload(),
map((item: Item) => this.linkService.resolveLink( map((item: Item) => this.linkService.resolveLink(
item, item,

View File

@@ -140,7 +140,7 @@ describe('ItemBitstreamsComponent', () => {
}); });
route = Object.assign({ route = Object.assign({
parent: { parent: {
data: observableOf({ item: createMockRD(item) }) data: observableOf({ dso: createMockRD(item) })
}, },
data: observableOf({}), data: observableOf({}),
url: url url: url

View File

@@ -89,7 +89,7 @@ describe('ItemCollectionMapperComponent', () => {
clearDiscoveryRequests: () => {} clearDiscoveryRequests: () => {}
/* tslint:enable:no-empty */ /* tslint:enable:no-empty */
}); });
const activatedRouteStub = new ActivatedRouteStub({}, { item: mockItemRD }); const activatedRouteStub = new ActivatedRouteStub({}, { dso: mockItemRD });
const translateServiceStub = { const translateServiceStub = {
get: () => of('test-message of item ' + mockItem.name), get: () => of('test-message of item ' + mockItem.name),
onLangChange: new EventEmitter(), onLangChange: new EventEmitter(),

View File

@@ -92,7 +92,7 @@ export class ItemCollectionMapperComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>; this.itemRD$ = this.route.data.pipe(map((data) => data.dso)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>;
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.loadCollectionLists(); this.loadCollectionLists();
} }

View File

@@ -138,7 +138,7 @@ describe('ItemDeleteComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject(mockItem) dso: createSuccessfulRemoteDataObject(mockItem)
}) })
}; };

View File

@@ -130,7 +130,7 @@ describe('ItemMetadataComponent', () => {
routeStub = { routeStub = {
data: observableOf({}), data: observableOf({}),
parent: { parent: {
data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) data: observableOf({ dso: createSuccessfulRemoteDataObject(item) })
} }
}; };
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]); paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);

View File

@@ -44,7 +44,7 @@ describe('ItemMoveComponent', () => {
const routeStub = { const routeStub = {
data: observableOf({ data: observableOf({
item: new RemoteData(false, false, true, null, { dso: new RemoteData(false, false, true, null, {
id: 'item1' id: 'item1'
}) })
}) })

View File

@@ -55,7 +55,7 @@ export class ItemMoveComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.item), getSucceededRemoteData()) as Observable<RemoteData<Item>>; this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getSucceededRemoteData()) as Observable<RemoteData<Item>>;
this.itemRD$.subscribe((rd) => { this.itemRD$.subscribe((rd) => {
this.itemId = rd.payload.id; this.itemId = rd.payload.id;
} }

View File

@@ -51,7 +51,7 @@ describe('ItemPrivateComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject({ dso: createSuccessfulRemoteDataObject({
id: 'fake-id' id: 'fake-id'
}) })
}) })

View File

@@ -51,7 +51,7 @@ describe('ItemPublicComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject({ dso: createSuccessfulRemoteDataObject({
id: 'fake-id' id: 'fake-id'
}) })
}) })

View File

@@ -51,7 +51,7 @@ describe('ItemReinstateComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject({ dso: createSuccessfulRemoteDataObject({
id: 'fake-id' id: 'fake-id'
}) })
}) })

View File

@@ -1,15 +1,26 @@
<h5>{{getRelationshipMessageKey() | async | translate}}</h5> <h5>
{{getRelationshipMessageKey() | async | translate}}
<button class="ml-2 btn btn-success" (click)="openLookup()">
<i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.relationships.edit.buttons.add" | translate}}</span>
</button>
</h5>
<ng-container *ngVar="updates$ | async as updates"> <ng-container *ngVar="updates$ | async as updates">
<ng-container *ngIf="updates"> <ng-container *ngIf="updates">
<ng-container *ngVar="updates | dsObjectValues as updateValues"> <ng-container *ngVar="updates | dsObjectValues as updateValues">
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate" <ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
class="relationship-row d-block" class="relationship-row d-block alert"
[fieldUpdate]="updateValue" [fieldUpdate]="updateValue || {}"
[url]="url" [url]="url"
[editItem]="item" [editItem]="item"
[ngClass]="{'alert alert-danger': updateValue?.changeType === 2}"> [ngClass]="{
'alert-success': updateValue.changeType === 1,
'alert-warning': updateValue.changeType === 0,
'alert-danger': updateValue.changeType === 2
}">
</ds-edit-relationship> </ds-edit-relationship>
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
<div *ngIf="!updates">no relationships</div> <ds-loading *ngIf="!updates"></ds-loading>
</ng-container> </ng-container>

View File

@@ -1,8 +1,8 @@
.relationship-row:not(.alert-danger) { .relationship-row:not(.alert) {
padding: $alert-padding-y 0; padding: $alert-padding-y 0;
} }
.relationship-row.alert-danger { .relationship-row.alert {
margin-left: -$alert-padding-x; margin-left: -$alert-padding-x;
margin-right: -$alert-padding-x; margin-right: -$alert-padding-x;
margin-top: -1px; margin-top: -1px;

View File

@@ -1,5 +1,5 @@
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { async, 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/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
@@ -8,6 +8,7 @@ import { FieldChangeType } from '../../../../core/data/object-updates/object-upd
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; import { RelationshipTypeService } from '../../../../core/data/relationship-type.service';
import { RelationshipService } from '../../../../core/data/relationship.service';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
@@ -15,6 +16,7 @@ import { Relationship } from '../../../../core/shared/item-relationships/relatio
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
import { getMockLinkService } from '../../../../shared/mocks/link-service.mock'; import { getMockLinkService } from '../../../../shared/mocks/link-service.mock';
import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service';
import { SharedModule } from '../../../../shared/shared.module'; import { SharedModule } from '../../../../shared/shared.module';
import { EditRelationshipListComponent } from './edit-relationship-list.component'; import { EditRelationshipListComponent } from './edit-relationship-list.component';
@@ -22,72 +24,123 @@ let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>; let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement; let de: DebugElement;
let linkService;
let objectUpdatesService; let objectUpdatesService;
let entityTypeService; let relationshipService;
let selectableListService;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
let item; let item;
let entityType;
let relatedEntityType;
let author1; let author1;
let author2; let author2;
let fieldUpdate1; let fieldUpdate1;
let fieldUpdate2; let fieldUpdate2;
let relationship1; let relationships;
let relationship2;
let relationshipType; let relationshipType;
let entityType;
let relatedEntityType;
describe('EditRelationshipListComponent', () => { describe('EditRelationshipListComponent', () => {
beforeEach(() => { beforeEach(async(() => {
entityType = Object.assign(new ItemType(), { entityType = Object.assign(new ItemType(), {
id: 'entityType', id: 'Publication',
uuid: 'Publication',
label: 'Publication',
}); });
relatedEntityType = Object.assign(new ItemType(), { relatedEntityType = Object.assign(new ItemType(), {
id: 'relatedEntityType', id: 'Author',
uuid: 'Author',
label: 'Author',
}); });
relationshipType = Object.assign(new RelationshipType(), { relationshipType = Object.assign(new RelationshipType(), {
id: '1', id: '1',
uuid: '1', uuid: '1',
leftType: observableOf(new RemoteData(
false,
false,
true,
undefined,
entityType,
)),
rightType: observableOf(new RemoteData(
false,
false,
true,
undefined,
relatedEntityType,
)),
leftwardType: 'isAuthorOfPublication', leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor', rightwardType: 'isPublicationOfAuthor',
leftType: observableOf(new RemoteData(false, false, true, undefined, entityType)),
rightType: observableOf(new RemoteData(false, false, true, undefined, relatedEntityType)),
}); });
relationship1 = Object.assign(new Relationship(), { author1 = Object.assign(new Item(), {
_links: { id: 'author1',
self: { uuid: 'author1'
href: url + '/2' });
} author2 = Object.assign(new Item(), {
}, id: 'author2',
uuid: 'author2'
});
relationships = [
Object.assign(new Relationship(), {
self: url + '/2',
id: '2', id: '2',
uuid: '2', uuid: '2',
leftId: 'author1', relationshipType: observableOf(new RemoteData(
rightId: 'publication', false,
leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), false,
rightItem: observableOf(new RemoteData(false, false, true, undefined, author1)), true,
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) undefined,
}); relationshipType
)),
relationship2 = Object.assign(new Relationship(), { leftItem: observableOf(new RemoteData(
_links: { false,
self: { false,
href: url + '/3' true,
} undefined,
}, item,
)),
rightItem: observableOf(new RemoteData(
false,
false,
true,
undefined,
author1,
)),
}),
Object.assign(new Relationship(), {
self: url + '/3',
id: '3', id: '3',
uuid: '3', uuid: '3',
leftId: 'author2', relationshipType: observableOf(new RemoteData(
rightId: 'publication', false,
leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), false,
rightItem: observableOf(new RemoteData(false, false, true, undefined, author2)), true,
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) undefined,
}); relationshipType
)),
leftItem: observableOf(new RemoteData(
false,
false,
true,
undefined,
item,
)),
rightItem: observableOf(new RemoteData(
false,
false,
true,
undefined,
author2,
)),
})
];
item = Object.assign(new Item(), { item = Object.assign(new Item(), {
_links: { _links: {
@@ -100,84 +153,82 @@ describe('EditRelationshipListComponent', () => {
false, false,
true, true,
undefined, undefined,
new PaginatedList(new PageInfo(), [relationship1, relationship2]) new PaginatedList(new PageInfo(), relationships),
)) ))
}); });
author1 = Object.assign(new Item(), {
id: 'author1',
uuid: 'author1'
});
author2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2'
});
fieldUpdate1 = { fieldUpdate1 = {
field: author1, field: {
uuid: relationships[0].uuid,
relationship: relationships[0],
type: relationshipType,
},
changeType: undefined changeType: undefined
}; };
fieldUpdate2 = { fieldUpdate2 = {
field: author2, field: {
uuid: relationships[1].uuid,
relationship: relationships[1],
type: relationshipType,
},
changeType: FieldChangeType.REMOVE changeType: FieldChangeType.REMOVE
}; };
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{ {
getFieldUpdates: observableOf({ getFieldUpdates: observableOf({
[author1.uuid]: fieldUpdate1, [relationships[0].uuid]: fieldUpdate1,
[author2.uuid]: fieldUpdate2 [relationships[1].uuid]: fieldUpdate2
}) })
} }
); );
entityTypeService = jasmine.createSpyObj('entityTypeService', relationshipService = jasmine.createSpyObj('relationshipService',
{ {
getEntityTypeByLabel: observableOf(new RemoteData( getRelatedItemsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [author1, author2]))),
false, getItemRelationshipsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), relationships))),
false, isLeftItem: observableOf(true),
true,
null,
entityType,
)),
getEntityTypeRelationships: observableOf(new RemoteData(
false,
false,
true,
null,
new PaginatedList(new PageInfo(), [relationshipType]),
)),
} }
); );
selectableListService = {};
linkService = {
resolveLink: () => null,
resolveLinks: () => null,
};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListComponent], declarations: [EditRelationshipListComponent],
providers: [ providers: [
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: RelationshipTypeService, useValue: {} }, { provide: RelationshipService, useValue: relationshipService },
{ provide: LinkService, useValue: getMockLinkService() }, { provide: SelectableListService, useValue: selectableListService },
{ provide: LinkService, useValue: linkService },
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
}).compileComponents(); }).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipListComponent); fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
de = fixture.debugElement; de = fixture.debugElement;
comp.item = item; comp.item = item;
comp.itemType = entityType; comp.itemType = entityType;
comp.url = url; comp.url = url;
comp.relationshipType = relationshipType; comp.relationshipType = relationshipType;
fixture.detectChanges(); fixture.detectChanges();
}); });
describe('changeType is REMOVE', () => { describe('changeType is REMOVE', () => {
it('the div should have class alert-danger', () => { beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.REMOVE; fieldUpdate1.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('the div should have class alert-danger', () => {
const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement;
expect(element.classList).toContain('alert-danger'); expect(element.classList).toContain('alert-danger');
}); });

View File

@@ -1,11 +1,23 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { LinkService } from '../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../core/cache/builders/link.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer'; import {
FieldUpdate,
FieldUpdates,
RelationshipIdentifiable
} from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipService } from '../../../../core/data/relationship.service';
import {Item} from '../../../../core/shared/item.model'; import {Item} from '../../../../core/shared/item.model';
import { map, switchMap, tap } from 'rxjs/operators'; import {
import {hasValue} from '../../../../shared/empty.util'; defaultIfEmpty, filter, flatMap,
map,
switchMap,
take, tap,
} from 'rxjs/operators';
import { hasValue } from '../../../../shared/empty.util';
import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; import {Relationship} from '../../../../core/shared/item-relationships/relationship.model';
import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model';
import { import {
@@ -13,8 +25,13 @@ import {
getRemoteDataPayload, getRemoteDataPayload,
getSucceededRemoteData getSucceededRemoteData
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { combineLatest as observableCombineLatest } from 'rxjs'; import { combineLatest as observableCombineLatest, of } from 'rxjs';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
import { RelationshipOptions } from '../../../../shared/form/builder/models/relationship-options.model';
import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model';
import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service';
import { SearchResult } from '../../../../shared/search/search-result.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({ @Component({
@@ -46,14 +63,29 @@ export class EditRelationshipListComponent implements OnInit {
*/ */
@Input() relationshipType: RelationshipType; @Input() relationshipType: RelationshipType;
private relatedEntityType$: Observable<ItemType>;
/**
* The list ID to save selected entities under
*/
listId: string;
/** /**
* The FieldUpdates for the relationships in question * The FieldUpdates for the relationships in question
*/ */
updates$: Observable<FieldUpdates>; updates$: Observable<FieldUpdates>;
/**
* A reference to the lookup window
*/
modalRef: NgbModalRef;
constructor( constructor(
protected objectUpdatesService: ObjectUpdatesService, protected objectUpdatesService: ObjectUpdatesService,
protected linkService: LinkService protected linkService: LinkService,
protected relationshipService: RelationshipService,
protected modalService: NgbModal,
protected selectableListService: SelectableListService,
) { ) {
} }
@@ -62,10 +94,18 @@ export class EditRelationshipListComponent implements OnInit {
*/ */
public getRelationshipMessageKey(): Observable<string> { public getRelationshipMessageKey(): Observable<string> {
return this.getLabel().pipe( return observableCombineLatest(
map((label) => { this.getLabel(),
if (hasValue(label) && label.indexOf('Of') > -1) { this.relatedEntityType$,
return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` ).pipe(
map(([label, relatedEntityType]) => {
if (hasValue(label) && label.indexOf('is') > -1 && label.indexOf('Of') > -1) {
const relationshipLabel = `${label.substring(2, label.indexOf('Of'))}`;
if (relationshipLabel !== relatedEntityType.label) {
return `relationships.is${relationshipLabel}Of.${relatedEntityType.label}`
} else {
return `relationships.is${relationshipLabel}Of`
}
} else { } else {
return label; return label;
} }
@@ -77,7 +117,6 @@ export class EditRelationshipListComponent implements OnInit {
* 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 observableCombineLatest([
this.relationshipType.leftType, this.relationshipType.leftType,
this.relationshipType.rightType, this.relationshipType.rightType,
@@ -99,19 +138,197 @@ export class EditRelationshipListComponent implements OnInit {
return update && update.field ? update.field.uuid : undefined; return update && update.field ? update.field.uuid : undefined;
} }
/**
* Open the dynamic lookup modal to search for items to add as relationships
*/
openLookup() {
this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, {
size: 'lg'
});
const modalComp: DsDynamicLookupRelationModalComponent = this.modalRef.componentInstance;
modalComp.repeatable = true;
modalComp.listId = this.listId;
modalComp.item = this.item;
modalComp.select = (...selectableObjects: Array<SearchResult<Item>>) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
this.getFieldUpdatesForRelatedItem(relatedItem)
.subscribe((identifiables) => {
identifiables.forEach((identifiable) =>
this.objectUpdatesService.removeSingleFieldUpdate(this.url, identifiable.uuid)
);
if (identifiables.length === 0) {
this.relationshipService.getNameVariant(this.listId, relatedItem.uuid)
.subscribe((nameVariant) => {
const update = {
uuid: this.relationshipType.id + '-' + relatedItem.uuid,
nameVariant,
type: this.relationshipType,
relatedItem,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
})
}
});
})
};
modalComp.deselect = (...selectableObjects: Array<SearchResult<Item>>) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.relationshipType.id + '-' + relatedItem.uuid);
this.getFieldUpdatesForRelatedItem(relatedItem)
.subscribe((identifiables) =>
identifiables.forEach((identifiable) =>
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, identifiable)
)
);
})
};
this.relatedEntityType$
.pipe(take(1))
.subscribe((relatedEntityType) => {
modalComp.relationshipOptions = Object.assign(
new RelationshipOptions(), {
relationshipType: relatedEntityType.label,
// filter: this.getRelationshipMessageKey(),
searchConfiguration: relatedEntityType.label.toLowerCase(),
nameVariants: true,
}
);
});
this.selectableListService.deselectAll(this.listId);
this.updates$.pipe(
switchMap((updates) =>
Object.values(updates).length > 0 ?
observableCombineLatest(
Object.values(updates)
.filter((update) => update.changeType !== FieldChangeType.REMOVE)
.map((update) => {
const field = update.field as RelationshipIdentifiable;
if (field.relationship) {
return this.getRelatedItem(field.relationship);
} else {
return of(field.relatedItem);
}
})
) : of([])
),
take(1),
map((items) => items.map((item) => {
const searchResult = new ItemSearchResult();
searchResult.indexableObject = item;
searchResult.hitHighlights = {};
return searchResult;
})),
).subscribe((items) => {
this.selectableListService.select(this.listId, items);
});
}
/**
* Get the existing field updates regarding a relationship with a given item
* @param relatedItem The item for which to get the existing field updates
*/
private getFieldUpdatesForRelatedItem(relatedItem: Item): Observable<RelationshipIdentifiable[]> {
return this.updates$.pipe(
take(1),
map((updates) => Object.values(updates)
.map((update) => update.field as RelationshipIdentifiable)
.filter((field) => field.relationship)
),
flatMap((identifiables) =>
observableCombineLatest(
identifiables.map((identifiable) => this.getRelatedItem(identifiable.relationship))
).pipe(
defaultIfEmpty([]),
map((relatedItems) =>
identifiables.filter((identifiable, index) => relatedItems[index].uuid === relatedItem.uuid)
),
)
),
);
}
/**
* Get the related item for a given relationship
* @param relationship The relationship for which to get the related item
*/
private getRelatedItem(relationship: Relationship): Observable<Item> {
return this.relationshipService.isLeftItem(relationship, this.item).pipe(
switchMap((isLeftItem) => isLeftItem ? relationship.rightItem : relationship.leftItem),
getSucceededRemoteData(),
getRemoteDataPayload(),
)
}
ngOnInit(): void { ngOnInit(): void {
this.updates$ = this.item.relationships.pipe(
this.relatedEntityType$ =
observableCombineLatest([
this.relationshipType.leftType,
this.relationshipType.rightType,
].map((type) => type.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
))).pipe(
map((relatedTypes) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
);
this.relatedEntityType$.pipe(
take(1)
).subscribe(
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
);
this.updates$ = this.getItemRelationships().pipe(
switchMap((relationships) =>
observableCombineLatest(
relationships.map((relationship) => this.relationshipService.isLeftItem(relationship, this.item))
).pipe(
defaultIfEmpty([]),
map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => {
const relationship = relationships[index];
const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
return {
uuid: relationship.id,
type: this.relationshipType,
relationship,
nameVariant,
} as RelationshipIdentifiable
})),
)),
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe(
map((fieldUpdates) => {
const fieldUpdatesFiltered: FieldUpdates = {};
Object.keys(fieldUpdates).forEach((uuid) => {
const field = fieldUpdates[uuid].field;
if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) {
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
});
return fieldUpdatesFiltered;
}),
)),
);
}
private getItemRelationships() {
this.linkService.resolveLink(this.item, followLink('relationships'));
return this.item.relationships.pipe(
getAllSucceededRemoteData(), getAllSucceededRemoteData(),
map((relationships) => relationships.payload.page.filter((relationship) => relationship)), map((relationships) => relationships.payload.page.filter((relationship) => relationship)),
map((relationships: Relationship[]) => filter((relationships) => relationships.every((relationship) => !!relationship)),
relationships.map((relationship: Relationship) => { tap((relationships: Relationship[]) =>
relationships.forEach((relationship: Relationship) => {
this.linkService.resolveLinks( this.linkService.resolveLinks(
relationship, relationship,
followLink('relationshipType'), followLink('relationshipType'),
followLink('leftItem'), followLink('leftItem'),
followLink('rightItem'), followLink('rightItem'),
); );
return relationship;
}) })
), ),
switchMap((itemRelationships: Relationship[]) => switchMap((itemRelationships: Relationship[]) =>
@@ -122,15 +339,12 @@ export class EditRelationshipListComponent implements OnInit {
getRemoteDataPayload(), getRemoteDataPayload(),
)) ))
).pipe( ).pipe(
defaultIfEmpty([]),
map((relationshipTypes) => itemRelationships.filter( map((relationshipTypes) => itemRelationships.filter(
(relationship, index) => relationshipTypes[index].id === this.relationshipType.id) (relationship, index) => relationshipTypes[index].id === this.relationshipType.id)
), ),
map((relationships) => relationships.map((relationship) =>
Object.assign(new Relationship(), relationship, {uuid: relationship.id})
)),
) )
), ),
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields)),
); );
} }
} }

View File

@@ -1,6 +1,11 @@
<div class="row" *ngIf="relatedItem$ | async"> <div class="row" *ngIf="relatedItem$ | async">
<div class="col-10 relationship"> <div class="col-10 relationship">
<ds-listable-object-component-loader [object]="relatedItem$ | async" [viewMode]="viewMode"></ds-listable-object-component-loader> <ds-listable-object-component-loader
[object]="relatedItem$ | async"
[viewMode]="viewMode"
[value]="nameVariant"
>
</ds-listable-object-component-loader>
</div> </div>
<div class="col-2"> <div class="col-2">
<div class="btn-group relationship-action-buttons"> <div class="btn-group relationship-action-buttons">

View File

@@ -1,5 +1,5 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
@@ -25,7 +25,7 @@ let fieldUpdate2;
let relationships; let relationships;
let relationshipType; let relationshipType;
let fixture; let fixture: ComponentFixture<EditRelationshipComponent>;
let comp: EditRelationshipComponent; let comp: EditRelationshipComponent;
let de; let de;
let el; let el;
@@ -91,11 +91,17 @@ describe('EditRelationshipComponent', () => {
}); });
fieldUpdate1 = { fieldUpdate1 = {
field: relationships[0], field: {
uuid: relationships[0].uuid,
relationship: relationships[0],
},
changeType: undefined changeType: undefined
}; };
fieldUpdate2 = { fieldUpdate2 = {
field: relationships[1], field: {
uuid: relationships[1].uuid,
relationship: relationships[1],
},
changeType: FieldChangeType.REMOVE changeType: FieldChangeType.REMOVE
}; };

View File

@@ -1,10 +1,13 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs';
import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { filter, map, switchMap, take, tap } from 'rxjs/operators';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { DeleteRelationship, FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; import {
DeleteRelationship,
FieldUpdate,
RelationshipIdentifiable
} from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
@@ -36,8 +39,16 @@ export class EditRelationshipComponent implements OnChanges {
/** /**
* The relationship being edited * The relationship being edited
*/ */
get relationship(): Relationship { get relationship() {
return this.fieldUpdate.field as Relationship; return this.update.relationship;
}
get update() {
return this.fieldUpdate.field as RelationshipIdentifiable;
}
get nameVariant() {
return this.update.nameVariant;
} }
public leftItem$: Observable<Item>; public leftItem$: Observable<Item>;
@@ -68,6 +79,7 @@ export class EditRelationshipComponent implements OnChanges {
* Sets the current relationship based on the fieldUpdate input field * Sets the current relationship based on the fieldUpdate input field
*/ */
ngOnChanges(): void { ngOnChanges(): void {
if (this.relationship) {
this.leftItem$ = this.relationship.leftItem.pipe( this.leftItem$ = this.relationship.leftItem.pipe(
getSucceededRemoteData(), getSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
@@ -86,6 +98,9 @@ export class EditRelationshipComponent implements OnChanges {
items.find((item) => item.uuid !== this.editItem.uuid) items.find((item) => item.uuid !== this.editItem.uuid)
) )
); );
} else {
this.relatedItem$ = of(this.update.relatedItem);
}
} }
/** /**
@@ -136,7 +151,8 @@ export class EditRelationshipComponent implements OnChanges {
* Check if a user should be allowed to remove this field * Check if a user should be allowed to remove this field
*/ */
canRemove(): boolean { canRemove(): boolean {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; return this.fieldUpdate.changeType !== FieldChangeType.REMOVE
&& this.fieldUpdate.changeType !== FieldChangeType.ADD;
} }
/** /**

View File

@@ -19,14 +19,19 @@
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button> </button>
</div> </div>
<div *ngFor="let relationshipType of relationshipTypes$ | async" class="mb-4"> <ng-container *ngVar="relationshipTypes$ | async as relationshipTypes">
<ng-container *ngIf="relationshipTypes">
<div *ngFor="let relationshipType of relationshipTypes" class="mb-4">
<ds-edit-relationship-list <ds-edit-relationship-list
[url]="url" [url]="url"
[item]="item" [item]="item"
[itemType]="entityType" [itemType]="entityType$ | async"
[relationshipType]="relationshipType" [relationshipType]="relationshipType"
></ds-edit-relationship-list> ></ds-edit-relationship-list>
</div> </div>
</ng-container>
<ds-loading *ngIf="!relationshipTypes"></ds-loading>
</ng-container>
<div class="button-row bottom"> <div class="button-row bottom">
<div class="float-right"> <div class="float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)" <button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"

View File

@@ -142,7 +142,7 @@ describe('ItemRelationshipsComponent', () => {
routeStub = { routeStub = {
data: observableOf({}), data: observableOf({}),
parent: { parent: {
data: observableOf({ item: new RemoteData(false, false, true, null, item) }) data: observableOf({ dso: new RemoteData(false, false, true, null, item) })
} }
}; };

View File

@@ -1,9 +1,14 @@
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { DeleteRelationship, FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import {
DeleteRelationship,
FieldUpdate,
FieldUpdates,
RelationshipIdentifiable,
} from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, startWith, switchMap, take} from 'rxjs/operators';
import { of as observableOf, zip as observableZip} from 'rxjs'; import { combineLatest as observableCombineLatest, of as observableOf, zip as observableZip} from 'rxjs';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
@@ -17,11 +22,9 @@ import { RemoteData } from '../../../core/data/remote-data';
import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { Subscription } from 'rxjs/internal/Subscription';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { EntityTypeService } from '../../../core/data/entity-type.service'; import { EntityTypeService } from '../../../core/data/entity-type.service';
import { isNotEmptyOperator } from '../../../shared/empty.util';
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
@@ -33,18 +36,18 @@ import { Relationship } from '../../../core/shared/item-relationships/relationsh
/** /**
* Component for displaying an item's relationships edit page * Component for displaying an item's relationships edit page
*/ */
export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy { export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
itemRD$: Observable<RemoteData<Item>>;
/** /**
* The labels of all different relations within this item * The allowed relationship types for this type of item as an observable list
*/ */
relationshipTypes$: Observable<RelationshipType[]>; relationshipTypes$: Observable<RelationshipType[]>;
/** /**
* A subscription that checks when the item is deleted in cache and reloads the item by sending a new request * The item's entity type as an observable
* This is used to update the item in cache after relationships are deleted
*/ */
itemUpdateSubscription: Subscription;
entityType$: Observable<ItemType>; entityType$: Observable<ItemType>;
constructor( constructor(
@@ -68,15 +71,29 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit(); super.ngOnInit();
this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( this.initializeItemUpdate();
}
/**
* Update the item (and view) when it's removed in the request cache
*/
public initializeItemUpdate(): void {
this.itemRD$ = this.requestService.hasByHrefObservable(this.item.self).pipe(
filter((exists: boolean) => !exists), filter((exists: boolean) => !exists),
switchMap(() => this.itemService.findById(this.item.uuid, switchMap(() => this.itemService.findById(
this.item.uuid,
followLink('owningCollection'), followLink('owningCollection'),
followLink('bundles'), followLink('bundles'),
followLink('relationships'))), followLink('relationships')),
),
filter((itemRD) => !!itemRD.statusCode),
);
this.itemRD$.pipe(
getSucceededRemoteData(), getSucceededRemoteData(),
).subscribe((itemRD: RemoteData<Item>) => { getRemoteDataPayload(),
this.item = itemRD.payload; ).subscribe((item) => {
this.item = item;
this.cdr.detectChanges(); this.cdr.detectChanges();
this.initializeUpdates(); this.initializeUpdates();
}); });
@@ -125,10 +142,12 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
* Make sure the lists are refreshed afterwards and notifications are sent for success and errors * Make sure the lists are refreshed afterwards and notifications are sent for success and errors
*/ */
public submit(): void { public submit(): void {
// Get all the relationships that should be removed // Get all the relationships that should be removed
this.relationshipService.getItemRelationshipsArray(this.item).pipe( const removedRelationshipIDs$: Observable<DeleteRelationship[]> = this.relationshipService.getItemRelationshipsArray(this.item).pipe(
startWith([]),
map((relationships: Relationship[]) => relationships.map((relationship) => map((relationships: Relationship[]) => relationships.map((relationship) =>
Object.assign(new Relationship(), relationship, {uuid: relationship.id}) Object.assign(new Relationship(), relationship, { uuid: relationship.id })
)), )),
switchMap((relationships: Relationship[]) => { switchMap((relationships: Relationship[]) => {
return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable<FieldUpdates> return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable<FieldUpdates>
@@ -138,10 +157,42 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE) .filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)
.map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship) .map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship)
), ),
isNotEmptyOperator(), );
const addRelatedItems$: Observable<RelationshipIdentifiable[]> = this.objectUpdatesService.getFieldUpdates(this.url, []).pipe(
map((fieldUpdates: FieldUpdates) =>
Object.values(fieldUpdates)
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.ADD)
.map((fieldUpdate: FieldUpdate) => fieldUpdate.field as RelationshipIdentifiable)
),
);
observableCombineLatest(
removedRelationshipIDs$,
addRelatedItems$,
).pipe(
take(1), take(1),
switchMap((deleteRelationships: DeleteRelationship[]) => ).subscribe(([removeRelationshipIDs, addRelatedItems]) => {
observableZip(...deleteRelationships.map((deleteRelationship) => { const actions = [
this.deleteRelationships(removeRelationshipIDs),
this.addRelationships(addRelatedItems),
];
actions.forEach((action) =>
action.subscribe((response) => {
if (response.length > 0) {
this.itemRD$.subscribe(() => {
this.initializeOriginalFields();
this.cdr.detectChanges();
this.displayNotifications(response);
});
}
})
);
});
}
deleteRelationships(deleteRelationshipIDs: DeleteRelationship[]): Observable<RestResponse[]> {
return observableZip(...deleteRelationshipIDs.map((deleteRelationship) => {
let copyVirtualMetadata: string; let copyVirtualMetadata: string;
if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) { if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'all'; copyVirtualMetadata = 'all';
@@ -154,13 +205,33 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
} }
return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata); return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata);
} }
)) ));
), }
).subscribe((responses: RestResponse[]) => {
this.itemUpdateSubscription.add(() => { addRelationships(addRelatedItems: RelationshipIdentifiable[]): Observable<RestResponse[]> {
this.displayNotifications(responses); return observableZip(...addRelatedItems.map((addRelationship) =>
}); this.entityType$.pipe(
}); switchMap((entityType) => this.entityTypeService.isLeftType(addRelationship.type, entityType)),
switchMap((isLeftType) => {
let leftItem: Item;
let rightItem: Item;
let leftwardValue: string;
let rightwardValue: string;
if (isLeftType) {
leftItem = this.item;
rightItem = addRelationship.relatedItem;
leftwardValue = null;
rightwardValue = addRelationship.nameVariant;
} else {
leftItem = addRelationship.relatedItem;
rightItem = this.item;
leftwardValue = addRelationship.nameVariant;
rightwardValue = null;
}
return this.relationshipService.addRelationship(addRelationship.type.id, leftItem, rightItem, leftwardValue, rightwardValue);
}),
)
));
} }
/** /**
@@ -180,19 +251,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
} }
} }
/** /**
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
public initializeOriginalFields() { public initializeOriginalFields() {
const initialFields = []; return this.relationshipService.getRelatedItems(this.item).pipe(
this.objectUpdatesService.initialize(this.url, initialFields, this.item.lastModified); 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

@@ -31,7 +31,7 @@ describe('ItemStatusComponent', () => {
const routeStub = { const routeStub = {
parent: { parent: {
data: observableOf({ item: createSuccessfulRemoteDataObject(mockItem) }) data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) })
} }
}; };

View File

@@ -56,7 +56,7 @@ export class ItemStatusComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)); this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe( this.itemRD$.pipe(
first(), first(),
map((data: RemoteData<Item>) => data.payload) map((data: RemoteData<Item>) => data.payload)

View File

@@ -23,7 +23,7 @@ describe('ItemVersionHistoryComponent', () => {
declarations: [ItemVersionHistoryComponent, VarDirective], declarations: [ItemVersionHistoryComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [ providers: [
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) } } } { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) } } }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -30,6 +30,6 @@ export class ItemVersionHistoryComponent {
} }
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>; this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>;
} }
} }

View File

@@ -51,7 +51,7 @@ describe('ItemWithdrawComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject({ dso: createSuccessfulRemoteDataObject({
id: 'fake-id' id: 'fake-id'
}) })
}) })

View File

@@ -74,7 +74,7 @@ describe('AbstractSimpleItemActionComponent', () => {
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: createSuccessfulRemoteDataObject({ dso: createSuccessfulRemoteDataObject({
id: 'fake-id' id: 'fake-id'
}) })
}) })

View File

@@ -42,7 +42,7 @@ export class AbstractSimpleItemActionComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe( this.itemRD$ = this.route.data.pipe(
map((data) => data.item), map((data) => data.dso),
getSucceededRemoteData() getSucceededRemoteData()
)as Observable<RemoteData<Item>>; )as Observable<RemoteData<Item>>;

View File

@@ -3,7 +3,12 @@
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-item-versions-notice [item]="item"></ds-item-versions-notice> <ds-item-versions-notice [item]="item"></ds-item-versions-notice>
<ds-view-tracker [object]="item"></ds-view-tracker> <ds-view-tracker [object]="item"></ds-view-tracker>
<ds-item-page-title-field [item]="item"></ds-item-page-title-field> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="item" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="simple-view-link my-3"> <div class="simple-view-link my-3">
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]"> <a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]">
{{"item.page.link.simple" | translate}} {{"item.page.link.simple" | translate}}

View File

@@ -34,7 +34,7 @@ const mockItem: Item = Object.assign(new Item(), {
} }
}); });
const routeStub = Object.assign(new ActivatedRouteStub(), { const routeStub = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ item: createSuccessfulRemoteDataObject(mockItem) }) data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) })
}); });
const metadataServiceStub = { const metadataServiceStub = {
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */

View File

@@ -20,7 +20,7 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
{ {
path: ':id', path: ':id',
resolve: { resolve: {
item: ItemPageResolver, dso: ItemPageResolver,
breadcrumb: ItemBreadcrumbResolver breadcrumb: ItemBreadcrumbResolver
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',

View File

@@ -37,7 +37,7 @@ describe('ItemPageComponent', () => {
/* tslint:enable:no-empty */ /* tslint:enable:no-empty */
}; };
const mockRoute = Object.assign(new ActivatedRouteStub(), { const mockRoute = Object.assign(new ActivatedRouteStub(), {
data: observableOf({ item: createSuccessfulRemoteDataObject(mockItem) }) data: observableOf({ dso: createSuccessfulRemoteDataObject(mockItem) })
}); });
beforeEach(async(() => { beforeEach(async(() => {

View File

@@ -55,7 +55,7 @@ export class ItemPageComponent implements OnInit {
*/ */
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe( this.itemRD$ = this.route.data.pipe(
map((data) => data.item as RemoteData<Item>), map((data) => data.dso as RemoteData<Item>),
redirectOn404Or401(this.router) redirectOn404Or401(this.router)
); );
this.metadataService.processRemoteData(this.itemRD$); this.metadataService.processRemoteData(this.itemRD$);

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'publication.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values> {{'publication.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'publication.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -2,7 +2,7 @@
<nav *ngIf="showBreadcrumbs" aria-label="breadcrumb"> <nav *ngIf="showBreadcrumbs" aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<ng-container <ng-container
*ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'Home', url: '/'}"></ng-container> *ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'home.breadcrumbs', url: '/'}"></ng-container>
<ng-container *ngFor="let bc of breadcrumbs; let last = last;"> <ng-container *ngFor="let bc of breadcrumbs; let last = last;">
<ng-container *ngTemplateOutlet="!last ? breadcrumb : activeBreadcrumb; context: bc"></ng-container> <ng-container *ngTemplateOutlet="!last ? breadcrumb : activeBreadcrumb; context: bc"></ng-container>
</ng-container> </ng-container>

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators'; import { filter, take, tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -11,7 +11,6 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getFinishedRemoteData } from '../shared/operators';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
@@ -56,7 +55,7 @@ export class DsoRedirectDataService extends DataService<any> {
findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable<RemoteData<FindByIDRequest>> { findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable<RemoteData<FindByIDRequest>> {
this.setLinkPath(identifierType); this.setLinkPath(identifierType);
return this.findById(id).pipe( return this.findById(id).pipe(
getFinishedRemoteData(), filter((response) => hasValue(response.error) || hasValue(response.payload)),
take(1), take(1),
tap((response) => { tap((response) => {
if (response.hasSucceeded) { if (response.hasSucceeded) {

View File

@@ -12,11 +12,12 @@ import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { GetRequest } from './request.models'; import { GetRequest } from './request.models';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import {switchMap, take, tap} from 'rxjs/operators'; import {switchMap, take, map} from 'rxjs/operators';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import {RelationshipType} from '../shared/item-relationships/relationship-type.model'; import {RelationshipType} from '../shared/item-relationships/relationship-type.model';
import {PaginatedList} from './paginated-list'; import {PaginatedList} from './paginated-list';
import {ItemType} from '../shared/item-relationships/item-type.model'; import {ItemType} from '../shared/item-relationships/item-type.model';
import {getRemoteDataPayload, getSucceededRemoteData} from '../shared/operators';
/** /**
* Service handling all ItemType requests * Service handling all ItemType requests
@@ -51,6 +52,20 @@ export class EntityTypeService extends DataService<ItemType> {
); );
} }
/**
* Check whether a given entity type is the left type of a given relationship type, as an observable boolean
* @param relationshipType the relationship type for which to check whether the given entity type is the left type
* @param entityType the entity type for which to check whether it is the left type of the given relationship type
*/
isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
return relationshipType.leftType.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((leftType) => leftType.uuid === itemType.uuid),
);
}
/** /**
* Get the allowed relationship types for an entity type * Get the allowed relationship types for an entity type
* @param entityTypeId * @param entityTypeId

View File

@@ -5,6 +5,7 @@ export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf', LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf', AdministratorOf = 'administratorOf',
CanDelete = 'canDelete', CanDelete = 'canDelete',
CanEditMetadata = 'canEditMetadata',
WithdrawItem = 'withdrawItem', WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem', ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration', EPersonRegistration = 'epersonRegistration',

View File

@@ -1,6 +1,6 @@
import { ExternalSourceService } from './external-source.service'; import { ExternalSourceService } from './external-source.service';
import { SearchService } from '../shared/search/search.service'; import { SearchService } from '../shared/search/search.service';
import { concat, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { ReplaySubject } from 'rxjs/internal/ReplaySubject'; import { ReplaySubject } from 'rxjs/internal/ReplaySubject';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
@@ -91,7 +91,8 @@ export class LookupRelationService {
getAllSucceededRemoteData(), getAllSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
map((results: PaginatedList<ExternalSourceEntry>) => results.totalElements), map((results: PaginatedList<ExternalSourceEntry>) => results.totalElements),
startWith(0) startWith(0),
distinctUntilChanged()
); );
} }

View File

@@ -13,9 +13,11 @@ import {
SelectVirtualMetadataAction, SelectVirtualMetadataAction,
} from './object-updates.actions'; } from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { hasNoValue, hasValue } from '../../../shared/empty.util';
import {Relationship} from '../../shared/item-relationships/relationship.model'; import { Relationship} from '../../shared/item-relationships/relationship.model';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { PatchOperationService } from './patch-operation-service/patch-operation.service';
import { Item} from '../../shared/item.model';
import { RelationshipType} from '../../shared/item-relationships/relationship-type.model';
/** /**
* Path where discarded objects are saved * Path where discarded objects are saved
@@ -74,11 +76,18 @@ export interface VirtualMetadataSource {
[uuid: string]: boolean, [uuid: string]: boolean,
} }
export interface RelationshipIdentifiable extends Identifiable {
nameVariant?: string,
relatedItem: Item;
relationship: Relationship;
type: RelationshipType;
}
/** /**
* A fieldupdate interface which represents a relationship selected to be deleted, * A fieldupdate interface which represents a relationship selected to be deleted,
* along with a selection of the virtual metadata to keep * along with a selection of the virtual metadata to keep
*/ */
export interface DeleteRelationship extends Relationship { export interface DeleteRelationship extends RelationshipIdentifiable {
keepLeftVirtualMetadata: boolean, keepLeftVirtualMetadata: boolean,
keepRightVirtualMetadata: boolean, keepRightVirtualMetadata: boolean,
} }
@@ -189,7 +198,7 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) {
const url: string = action.payload.url; const url: string = action.payload.url;
const field: Identifiable = action.payload.field; const field: Identifiable = action.payload.field;
const changeType: FieldChangeType = action.payload.changeType; const changeType: FieldChangeType = action.payload.changeType;
const pageState: ObjectUpdatesEntry = state[url] || {}; const pageState: ObjectUpdatesEntry = state[url] || {fieldUpdates: {}};
let states = pageState.fieldStates; let states = pageState.fieldStates;
if (changeType === FieldChangeType.ADD) { if (changeType === FieldChangeType.ADD) {

View File

@@ -24,7 +24,7 @@ import {
SetValidFieldUpdateAction SetValidFieldUpdateAction
} from './object-updates.actions'; } from './object-updates.actions';
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model'; import { INotification } from '../../../shared/notifications/models/notification.model';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { PatchOperationService } from './patch-operation-service/patch-operation.service';
@@ -129,7 +129,7 @@ export class ObjectUpdatesService {
*/ */
getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> { getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
const objectUpdates = this.getObjectEntry(url); const objectUpdates = this.getObjectEntry(url);
return objectUpdates.pipe(isNotEmptyOperator(), map((objectEntry) => { return objectUpdates.pipe(map((objectEntry) => {
const fieldUpdates: FieldUpdates = {}; const fieldUpdates: FieldUpdates = {};
for (const object of initialFields) { for (const object of initialFields) {
let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; let fieldUpdate = objectEntry.fieldUpdates[object.uuid];

View File

@@ -102,8 +102,8 @@ export class RelationshipService extends DataService<Relationship> {
), ),
configureRequest(this.requestService), configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)), tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)),
getResponseFromEntry(),
); );
} }
@@ -129,9 +129,9 @@ export class RelationshipService extends DataService<Relationship> {
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, `${item1.self} \n ${item2.self}`, options)), map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, `${item1.self} \n ${item2.self}`, options)),
configureRequest(this.requestService), configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
tap(() => this.refreshRelationshipItemsInCache(item1)), tap(() => this.refreshRelationshipItemsInCache(item1)),
tap(() => this.refreshRelationshipItemsInCache(item2)) tap(() => this.refreshRelationshipItemsInCache(item2)),
getResponseFromEntry(),
) as Observable<RestResponse>; ) as Observable<RestResponse>;
} }
@@ -400,6 +400,20 @@ export class RelationshipService extends DataService<Relationship> {
); );
} }
/**
* Check whether a given item is the left item of a given relationship, as an observable boolean
* @param relationship the relationship for which to check whether the given item is the left item
* @param item the item for which to check whether it is the left item of the given relationship
*/
public isLeftItem(relationship: Relationship, item: Item): Observable<boolean> {
return relationship.leftItem.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
filter((leftItem: Item) => hasValue(leftItem) && isNotEmpty(leftItem.uuid)),
map((leftItem) => leftItem.uuid === item.uuid)
);
}
/** /**
* Method to update the the right or left place of a relationship * Method to update the the right or left place of a relationship
* The useLeftItem field in the reorderable relationship determines which place should be updated * The useLeftItem field in the reorderable relationship determines which place should be updated

View File

@@ -13,4 +13,6 @@ export enum Context {
EntitySearchModal = 'EntitySearchModal', EntitySearchModal = 'EntitySearchModal',
AdminSearch = 'adminSearch', AdminSearch = 'adminSearch',
AdminWorkflowSearch = 'adminWorkflowSearch', AdminWorkflowSearch = 'adminWorkflowSearch',
SideBarSearchModal = 'sideBarSearchModal',
SideBarSearchModalCurrent = 'sideBarSearchModalCurrent',
} }

View File

@@ -0,0 +1,42 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { JournalIssueSidebarSearchListElementComponent } from './journal-issue-sidebar-search-list-element.component';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'dc.title': [
{
value: 'title'
}
],
'publicationvolume.volumeNumber': [
{
value: '5'
}
],
'publicationissue.issueNumber': [
{
value: '7'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('JournalIssueSidebarSearchListElementComponent',
createSidebarSearchListElementTests(JournalIssueSidebarSearchListElementComponent, object, parent, 'parent title', 'title', '5 - 7')
);

View File

@@ -0,0 +1,39 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { isNotEmpty } from '../../../../../shared/empty.util';
@listableObjectComponent('JournalIssueSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('JournalIssueSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-journal-issue-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "JournalIssue" within the context of
* a sidebar search modal
*/
export class JournalIssueSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
/**
* Get the description of the Journal Issue by returning its volume number(s) and/or issue number(s)
*/
getDescription(): string {
const volumeNumbers = this.allMetadataValues(['publicationvolume.volumeNumber']);
const issueNumbers = this.allMetadataValues(['publicationissue.issueNumber']);
let description = '';
if (isNotEmpty(volumeNumbers)) {
description += volumeNumbers.join(', ');
}
if (isNotEmpty(description) && isNotEmpty(issueNumbers)) {
description += ' - ';
}
if (isNotEmpty(issueNumbers)) {
description += issueNumbers.join(', ');
}
return this.undefinedIfEmpty(description);
}
}

View File

@@ -0,0 +1,45 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { JournalVolumeSidebarSearchListElementComponent } from './journal-volume-sidebar-search-list-element.component';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'dc.title': [
{
value: 'title'
}
],
'journal.title': [
{
value: 'journal title'
}
],
'publicationvolume.volumeNumber': [
{
value: '1'
},
{
value: '2'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('JournalVolumeSidebarSearchListElementComponent',
createSidebarSearchListElementTests(JournalVolumeSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'journal title (1) (2)')
);

View File

@@ -0,0 +1,39 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { isNotEmpty } from '../../../../../shared/empty.util';
@listableObjectComponent('JournalVolumeSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('JournalVolumeSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-journal-volume-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "JournalVolume" within the context of
* a sidebar search modal
*/
export class JournalVolumeSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
/**
* Get the description of the Journal Volume by returning the journal title and volume number(s) (between parentheses)
*/
getDescription(): string {
const titles = this.allMetadataValues(['journal.title']);
const numbers = this.allMetadataValues(['publicationvolume.volumeNumber']);
let description = '';
if (isNotEmpty(titles)) {
description += titles.join(', ');
}
if (isNotEmpty(numbers)) {
if (isNotEmpty(description)) {
description += ' ';
}
description += numbers.map((n) => `(${n})`).join(' ');
}
return this.undefinedIfEmpty(description);
}
}

View File

@@ -0,0 +1,40 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { JournalSidebarSearchListElementComponent } from './journal-sidebar-search-list-element.component';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'dc.title': [
{
value: 'title'
}
],
'creativeworkseries.issn': [
{
value: '1234'
},
{
value: '5678'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('JournalSidebarSearchListElementComponent',
createSidebarSearchListElementTests(JournalSidebarSearchListElementComponent, object, parent, 'parent title', 'title', '1234, 5678')
);

View File

@@ -0,0 +1,32 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { isNotEmpty } from '../../../../../shared/empty.util';
@listableObjectComponent('JournalSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('JournalSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-journal-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "Journal" within the context of
* a sidebar search modal
*/
export class JournalSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
/**
* Get the description of the Journal by returning its ISSN(s)
*/
getDescription(): string {
const issns = this.allMetadataValues(['creativeworkseries.issn']);
let description = '';
if (isNotEmpty(issns)) {
description += issns.join(', ');
}
return this.undefinedIfEmpty(description);
}
}

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'journalissue.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values> {{'journalissue.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'journalissue.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'journalvolume.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values> {{'journalvolume.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'journalvolume.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'journal.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values> {{'journal.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'journal.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -18,6 +18,9 @@ import { JournalIssueSearchResultListElementComponent } from './item-list-elemen
import { JournalVolumeSearchResultListElementComponent } from './item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component'; import { JournalVolumeSearchResultListElementComponent } from './item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component';
import { JournalIssueSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component'; import { JournalIssueSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component';
import { JournalVolumeSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component'; import { JournalVolumeSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component';
import { JournalVolumeSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component';
import { JournalIssueSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component';
import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
JournalComponent, JournalComponent,
@@ -34,7 +37,10 @@ const ENTRY_COMPONENTS = [
JournalVolumeSearchResultListElementComponent, JournalVolumeSearchResultListElementComponent,
JournalIssueSearchResultGridElementComponent, JournalIssueSearchResultGridElementComponent,
JournalVolumeSearchResultGridElementComponent, JournalVolumeSearchResultGridElementComponent,
JournalSearchResultGridElementComponent JournalSearchResultGridElementComponent,
JournalVolumeSidebarSearchListElementComponent,
JournalIssueSidebarSearchListElementComponent,
JournalSidebarSearchListElementComponent,
]; ];
@NgModule({ @NgModule({

View File

@@ -1 +1,6 @@
<ds-person-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-person-search-result-list-element> <ds-person-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }"
[linkType]="linkType"
[showLabel]="showLabel"
[value]="value"
>
</ds-person-search-result-list-element>

View File

@@ -2,7 +2,7 @@
<ds-truncatable [id]="dso.id"> <ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer" <a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead" [routerLink]="['/items/' + dso.id]" class="lead"
[innerHTML]="firstMetadataValue('person.familyName') + ', ' + firstMetadataValue('person.givenName')"></a> [innerHTML]="name"></a>
<span *ngIf="linkType == linkTypes.None" <span *ngIf="linkType == linkTypes.None"
class="lead" class="lead"
[innerHTML]="firstMetadataValue('person.familyName') + ', ' + firstMetadataValue('person.givenName')"></span> [innerHTML]="firstMetadataValue('person.familyName') + ', ' + firstMetadataValue('person.givenName')"></span>

View File

@@ -15,4 +15,10 @@ import { Item } from '../../../../../core/shared/item.model';
* The component for displaying a list element for an item search result of the type Person * The component for displaying a list element for an item search result of the type Person
*/ */
export class PersonSearchResultListElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> { export class PersonSearchResultListElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> {
get name() {
return this.value ?
this.value :
this.firstMetadataValue('person.familyName') + ', ' + this.firstMetadataValue('person.givenName');
}
} }

View File

@@ -0,0 +1,37 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { OrgUnitSidebarSearchListElementComponent } from './org-unit-sidebar-search-list-element.component';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'organization.legalName': [
{
value: 'title'
}
],
'dc.description': [
{
value: 'description'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('OrgUnitSidebarSearchListElementComponent',
createSidebarSearchListElementTests(OrgUnitSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description')
);

View File

@@ -0,0 +1,33 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-org-unit-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "OrgUnit" within the context of
* a sidebar search modal
*/
export class OrgUnitSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
/**
* Get the title of the Org Unit by returning its legal name
*/
getTitle(): string {
return this.firstMetadataValue('organization.legalName');
}
/**
* Get the description of the Org Unit by returning its dc.description
*/
getDescription(): string {
return this.firstMetadataValue('dc.description');
}
}

View File

@@ -0,0 +1,45 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { PersonSidebarSearchListElementComponent } from './person-sidebar-search-list-element.component';
import { TranslateService } from '@ngx-translate/core';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'person.familyName': [
{
value: 'family name'
}
],
'person.givenName': [
{
value: 'given name'
}
],
'person.jobTitle': [
{
value: 'job title'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('PersonSidebarSearchListElementComponent',
createSidebarSearchListElementTests(PersonSidebarSearchListElementComponent, object, parent, 'parent title', 'family name, given name', 'job title', [
{ provide: TranslateService, useValue: jasmine.createSpyObj('translate', { instant: '' }) }
])
);

View File

@@ -0,0 +1,60 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
import { isNotEmpty } from '../../../../../shared/empty.util';
import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
import { LinkService } from '../../../../../core/cache/builders/link.service';
import { TranslateService } from '@ngx-translate/core';
@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-person-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "Person" within the context of
* a sidebar search modal
*/
export class PersonSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
constructor(protected truncatableService: TruncatableService,
protected linkService: LinkService,
protected translateService: TranslateService) {
super(truncatableService, linkService);
}
/**
* Get the title of the Person by returning a combination of its family name and given name (or "No name found")
*/
getTitle(): string {
const familyName = this.firstMetadataValue('person.familyName');
const givenName = this.firstMetadataValue('person.givenName');
let title = '';
if (isNotEmpty(familyName)) {
title = familyName;
}
if (isNotEmpty(title)) {
title += ', ';
}
if (isNotEmpty(givenName)) {
title += givenName;
}
return this.defaultIfEmpty(title, this.translateService.instant('person.listelement.no-title'));
}
/**
* Get the description of the Person by returning its job title(s)
*/
getDescription(): string {
const titles = this.allMetadataValues(['person.jobTitle']);
let description = '';
if (isNotEmpty(titles)) {
description += titles.join(', ');
}
return this.undefinedIfEmpty(description);
}
}

View File

@@ -0,0 +1,32 @@
import { Item } from '../../../../../core/shared/item.model';
import { Collection } from '../../../../../core/shared/collection.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { createSidebarSearchListElementTests } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec';
import { ProjectSidebarSearchListElementComponent } from './project-sidebar-search-list-element.component';
const object = Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: 'test-item',
metadata: {
'dc.title': [
{
value: 'title'
}
]
}
})
});
const parent = Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('ProjectSidebarSearchListElementComponent',
createSidebarSearchListElementTests(ProjectSidebarSearchListElementComponent, object, parent, 'parent title', 'title', undefined)
);

View File

@@ -0,0 +1,26 @@
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { Context } from '../../../../../core/shared/context.model';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { Component } from '@angular/core';
import { SidebarSearchListElementComponent } from '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component';
import { Item } from '../../../../../core/shared/item.model';
@listableObjectComponent('ProjectSearchResult', ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent('ProjectSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-project-sidebar-search-list-element',
templateUrl: '../../../../../shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link ItemSearchResult} of type "Project" within the context of
* a sidebar search modal
*/
export class ProjectSidebarSearchListElementComponent extends SidebarSearchListElementComponent<ItemSearchResult, Item> {
/**
* Projects currently don't support a description
*/
getDescription(): string {
return undefined;
}
}

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'orgunit.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['organization.legalName'])"></ds-metadata-values> {{'orgunit.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['organization.legalName'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'orgunit.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'person.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="[object?.firstMetadata('person.familyName'), object?.firstMetadata('person.givenName')]" [separator]="', '"></ds-metadata-values> {{'person.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="[object?.firstMetadata('person.familyName'), object?.firstMetadata('person.givenName')]" [separator]="', '"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'person.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -1,6 +1,11 @@
<h2 class="item-page-title-field"> <div class="d-flex flex-row">
<h2 class="item-page-title-field mr-auto">
{{'project.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values> {{'project.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2> </h2>
<div class="pl-2">
<ds-dso-page-edit-button [pageRoutePrefix]="'items'" [dso]="object" [tooltipMsg]="'project.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>

View File

@@ -26,6 +26,9 @@ import { NameVariantModalComponent } from './submission/name-variant-modal/name-
import { OrgUnitInputSuggestionsComponent } from './submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component'; import { OrgUnitInputSuggestionsComponent } from './submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component';
import { OrgUnitSearchResultListSubmissionElementComponent } from './submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component'; import { OrgUnitSearchResultListSubmissionElementComponent } from './submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component';
import { ExternalSourceEntryListSubmissionElementComponent } from './submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component'; import { ExternalSourceEntryListSubmissionElementComponent } from './submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component';
import { OrgUnitSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component';
import { PersonSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component';
import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
OrgUnitComponent, OrgUnitComponent,
@@ -50,7 +53,10 @@ const ENTRY_COMPONENTS = [
NameVariantModalComponent, NameVariantModalComponent,
OrgUnitSearchResultListSubmissionElementComponent, OrgUnitSearchResultListSubmissionElementComponent,
OrgUnitInputSuggestionsComponent, OrgUnitInputSuggestionsComponent,
ExternalSourceEntryListSubmissionElementComponent ExternalSourceEntryListSubmissionElementComponent,
OrgUnitSidebarSearchListElementComponent,
PersonSidebarSearchListElementComponent,
ProjectSidebarSearchListElementComponent,
]; ];
@NgModule({ @NgModule({

View File

@@ -7,6 +7,7 @@ import { Component, OnInit } from '@angular/core';
import { Metadata } from '../../../../../core/shared/metadata.utils'; import { Metadata } from '../../../../../core/shared/metadata.utils';
import { MetadataValue } from '../../../../../core/shared/metadata.models'; import { MetadataValue } from '../../../../../core/shared/metadata.models';
@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.EntitySearchModal)
@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({ @Component({
selector: 'ds-external-source-entry-list-submission-element', selector: 'ds-external-source-entry-list-submission-element',

View File

@@ -61,6 +61,7 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
} }
select(value) { select(value) {
this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value);
this.selectableListService.isObjectSelected(this.listID, this.object) this.selectableListService.isObjectSelected(this.listID, this.object)
.pipe(take(1)) .pipe(take(1))
.subscribe((selected) => { .subscribe((selected) => {
@@ -68,7 +69,6 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
this.selectableListService.selectSingle(this.listID, this.object); this.selectableListService.selectSingle(this.listID, this.object);
} }
}); });
this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value);
} }
selectCustom(value) { selectCustom(value) {

View File

@@ -0,0 +1,6 @@
<a *ngIf="isAuthorized$ | async"
[routerLink]="['/' + pageRoutePrefix, dso.id, 'edit']"
class="edit-button btn btn-dark text-light btn-sm"
[tooltip]="tooltipMsg | translate">
<i class="fas fa-pencil-alt fa-fw"></i>
</a>

View File

@@ -0,0 +1,3 @@
.btn-dark {
background-color: $admin-sidebar-bg;
}

View File

@@ -0,0 +1,76 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DsoPageEditButtonComponent } from './dso-page-edit-button.component';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { Item } from '../../../core/shared/item.model';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { By } from '@angular/platform-browser';
import { TooltipModule } from 'ngx-bootstrap';
describe('DsoPageEditButtonComponent', () => {
let component: DsoPageEditButtonComponent;
let fixture: ComponentFixture<DsoPageEditButtonComponent>;
let authorizationService: AuthorizationDataService;
let dso: DSpaceObject;
beforeEach(async(() => {
dso = Object.assign(new Item(), {
id: 'test-item',
_links: {
self: { href: 'test-item-selflink' }
}
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({
declarations: [ DsoPageEditButtonComponent ],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), TooltipModule.forRoot()],
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService }
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoPageEditButtonComponent);
component = fixture.componentInstance;
component.dso = dso;
component.pageRoutePrefix = 'test';
fixture.detectChanges();
});
it('should check the authorization of the current user', () => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanEditMetadata, dso.self);
});
describe('when the user is authorized', () => {
beforeEach(() => {
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
});
it('should render a link', () => {
const link = fixture.debugElement.query(By.css('a'));
expect(link).not.toBeNull();
});
});
describe('when the user is not authorized', () => {
beforeEach(() => {
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
});
it('should not render a link', () => {
const link = fixture.debugElement.query(By.css('a'));
expect(link).toBeNull();
});
});
});

View File

@@ -0,0 +1,43 @@
import { Component, Input, OnInit } from '@angular/core';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
@Component({
selector: 'ds-dso-page-edit-button',
templateUrl: './dso-page-edit-button.component.html',
styleUrls: ['./dso-page-edit-button.component.scss']
})
/**
* Display a button linking to the edit page of a DSpaceObject
*/
export class DsoPageEditButtonComponent implements OnInit {
/**
* The DSpaceObject to display a button to the edit page for
*/
@Input() dso: DSpaceObject;
/**
* The prefix of the route to the edit page (before the object's UUID, e.g. "items")
*/
@Input() pageRoutePrefix: string;
/**
* A message for the tooltip on the button
* Supports i18n keys
*/
@Input() tooltipMsg: string;
/**
* Whether or not the current user is authorized to edit the DSpaceObject
*/
isAuthorized$: Observable<boolean>;
constructor(protected authorizationService: AuthorizationDataService) { }
ngOnInit() {
this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanEditMetadata, this.dso.self);
}
}

View File

@@ -0,0 +1,56 @@
import { AuthorizedCollectionSelectorComponent } from './authorized-collection-selector.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VarDirective } from '../../../utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SearchService } from '../../../../core/shared/search/search.service';
import { CollectionDataService } from '../../../../core/data/collection-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils';
import { createPaginatedList } from '../../../testing/utils.test';
import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
describe('AuthorizedCollectionSelectorComponent', () => {
let component: AuthorizedCollectionSelectorComponent;
let fixture: ComponentFixture<AuthorizedCollectionSelectorComponent>;
let collectionService;
let collection;
beforeEach(async(() => {
collection = Object.assign(new Collection(), {
id: 'authorized-collection'
});
collectionService = jasmine.createSpyObj('collectionService', {
getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection]))
});
TestBed.configureTestingModule({
declarations: [AuthorizedCollectionSelectorComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: SearchService, useValue: {} },
{ provide: CollectionDataService, useValue: collectionService }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AuthorizedCollectionSelectorComponent);
component = fixture.componentInstance;
component.types = [DSpaceObjectType.COLLECTION];
fixture.detectChanges();
});
describe('search', () => {
it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => {
component.search('', 1).subscribe((result) => {
expect(collectionService.getAuthorizedCollection).toHaveBeenCalled();
expect(result.page.length).toEqual(1);
expect(result.page[0].indexableObject).toEqual(collection);
done();
});
});
});
});

View File

@@ -0,0 +1,48 @@
import { Component } from '@angular/core';
import { DSOSelectorComponent } from '../dso-selector.component';
import { SearchService } from '../../../../core/shared/search/search.service';
import { CollectionDataService } from '../../../../core/data/collection-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators';
import { map } from 'rxjs/operators';
import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model';
import { SearchResult } from '../../../search/search-result.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
@Component({
selector: 'ds-authorized-collection-selector',
styleUrls: ['../dso-selector.component.scss'],
templateUrl: '../dso-selector.component.html'
})
/**
* Component rendering a list of collections to select from
*/
export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent {
constructor(protected searchService: SearchService,
protected collectionDataService: CollectionDataService) {
super(searchService);
}
/**
* Get a query to send for retrieving the current DSO
*/
getCurrentDSOQuery(): string {
return this.currentDSOId;
}
/**
* Perform a search for authorized collections with the current query and page
* @param query Query to search objects for
* @param page Page to retrieve
*/
search(query: string, page: number): Observable<PaginatedList<SearchResult<DSpaceObject>>> {
return this.collectionDataService.getAuthorizedCollection(query, Object.assign({
currentPage: page,
elementsPerPage: this.defaultPagination.pageSize
})).pipe(
getFirstSucceededRemoteDataPayload(),
map((list) => new PaginatedList(list.pageInfo, list.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))))
);
}
}

View File

@@ -7,15 +7,29 @@
</div> </div>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<div class="scrollable-menu list-group"> <div class="scrollable-menu list-group">
<div
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollThrottle]="300"
[infiniteScrollContainer]="'.scrollable-menu'"
[fromRoot]="true"
(scrolled)="onScrollDown()">
<button class="list-group-item list-group-item-action border-0 disabled" <button class="list-group-item list-group-item-action border-0 disabled"
*ngIf="(listEntries$ | async)?.payload.page.length == 0"> *ngIf="listEntries.length == 0">
{{'dso-selector.no-results' | translate: { type: typesString } }} {{'dso-selector.no-results' | translate: { type: typesString } }}
</button> </button>
<button *ngFor="let listEntry of (listEntries$ | async)?.payload.page" <button *ngFor="let listEntry of listEntries"
class="list-group-item list-group-item-action border-0 list-entry" class="list-group-item list-group-item-action border-0 list-entry"
[ngClass]="{'bg-primary': listEntry.indexableObject.id === currentDSOId}"
title="{{ listEntry.indexableObject.name }}" title="{{ listEntry.indexableObject.name }}"
dsHoverClass="ds-hover"
(click)="onSelect.emit(listEntry.indexableObject)" #listEntryElement> (click)="onSelect.emit(listEntry.indexableObject)" #listEntryElement>
<ds-listable-object-component-loader [object]="listEntry" [viewMode]="viewMode" <ds-listable-object-component-loader [object]="listEntry" [viewMode]="viewMode"
[linkType]=linkTypes.None></ds-listable-object-component-loader> [linkType]=linkTypes.None [context]="getContext(listEntry.indexableObject.id)"></ds-listable-object-component-loader>
</button> </button>
<button *ngIf="hasNextPage"
class="list-group-item list-group-item-action border-0 list-entry">
<ds-loading [showMessage]="false"></ds-loading>
</button>
</div>
</div> </div>

View File

@@ -0,0 +1,5 @@
.scrollable-menu {
height: auto;
max-height: $dso-selector-list-max-height;
overflow-x: hidden;
}

View File

@@ -6,10 +6,10 @@ import { SearchService } from '../../../core/shared/search/search.service';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
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 { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
import { hasValue } from '../../empty.util';
import { createPaginatedList } from '../../testing/utils.test';
describe('DSOSelectorComponent', () => { describe('DSOSelectorComponent', () => {
let component: DSOSelectorComponent; let component: DSOSelectorComponent;
@@ -18,19 +18,46 @@ describe('DSOSelectorComponent', () => {
const currentDSOId = 'test-uuid-ford-sose'; const currentDSOId = 'test-uuid-ford-sose';
const type = DSpaceObjectType.ITEM; const type = DSpaceObjectType.ITEM;
const searchResult = new ItemSearchResult(); const searchResult = createSearchResult('current');
const item = new Item();
item.metadata = { const firstPageResults = [
'dc.title': [Object.assign(new MetadataValue(), { createSearchResult('1'),
value: 'Item title', createSearchResult('2'),
language: undefined createSearchResult('3'),
})] ];
};
searchResult.indexableObject = item; const nextPageResults = [
searchResult.hitHighlights = {}; createSearchResult('4'),
const searchService = jasmine.createSpyObj('searchService', { createSearchResult('5'),
search: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [searchResult])) createSearchResult('6'),
}); ];
const searchService = {
search: (options: PaginatedSearchOptions) => {
if (hasValue(options.query) && options.query.startsWith('search.resourceid')) {
return createSuccessfulRemoteDataObject$(createPaginatedList([searchResult]));
} else if (options.pagination.currentPage === 1) {
return createSuccessfulRemoteDataObject$(createPaginatedList(firstPageResults));
} else {
return createSuccessfulRemoteDataObject$(createPaginatedList(nextPageResults));
}
}
}
function createSearchResult(name: string): ItemSearchResult {
return Object.assign(new ItemSearchResult(), {
indexableObject: Object.assign(new Item(), {
id: `test-result-${name}`,
metadata: {
'dc.title': [
{
value: `test result - ${name}`
}
]
}
})
})
}
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -58,13 +85,23 @@ describe('DSOSelectorComponent', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('should initially call the search method on the SearchService with the given DSO uuid', () => { describe('populating listEntries', () => {
const searchOptions = new PaginatedSearchOptions({ it('should not be empty', () => {
query: currentDSOId, expect(component.listEntries.length).toBeGreaterThan(0);
dsoTypes: [type],
pagination: (component as any).defaultPagination
}); });
expect(searchService.search).toHaveBeenCalledWith(searchOptions); it('should contain a combination of the current DSO and first page results', () => {
expect(component.listEntries).toEqual([searchResult, ...firstPageResults]);
});
describe('when current page increases', () => {
beforeEach(() => {
component.currentPage$.next(2);
});
it('should contain a combination of the current DSO, as well as first and second page results', () => {
expect(component.listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
});
});
}); });
}); });

View File

@@ -3,28 +3,33 @@ import {
ElementRef, ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnDestroy,
OnInit, OnInit,
Output, Output,
QueryList, QueryList,
ViewChildren ViewChildren
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { debounceTime, startWith, switchMap } from 'rxjs/operators';
import { SearchService } from '../../../core/shared/search/search.service'; import { SearchService } from '../../../core/shared/search/search.service';
import { CollectionElementLinkType } from '../../object-collection/collection-element-link.type'; import { CollectionElementLinkType } from '../../object-collection/collection-element-link.type';
import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { SearchResult } from '../../search/search-result.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { ViewMode } from '../../../core/shared/view-mode.model'; import { ViewMode } from '../../../core/shared/view-mode.model';
import { Context } from '../../../core/shared/context.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { Subscription } from 'rxjs/internal/Subscription';
import { hasValue, isEmpty, isNotEmpty } from '../../empty.util';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { PaginatedList } from '../../../core/data/paginated-list';
import { SearchResult } from '../../search/search-result.model';
@Component({ @Component({
selector: 'ds-dso-selector', selector: 'ds-dso-selector',
// styleUrls: ['./dso-selector.component.scss'], styleUrls: ['./dso-selector.component.scss'],
templateUrl: './dso-selector.component.html' templateUrl: './dso-selector.component.html'
}) })
@@ -32,7 +37,7 @@ import { ViewMode } from '../../../core/shared/view-mode.model';
* Component to render a list of DSO's of which one can be selected * Component to render a list of DSO's of which one can be selected
* The user can search the list by using the input field * The user can search the list by using the input field
*/ */
export class DSOSelectorComponent implements OnInit { export class DSOSelectorComponent implements OnInit, OnDestroy {
/** /**
* The view mode of the listed objects * The view mode of the listed objects
*/ */
@@ -63,12 +68,29 @@ export class DSOSelectorComponent implements OnInit {
/** /**
* Default pagination for this feature * Default pagination for this feature
*/ */
private defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 5 } as any; defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 10 } as any;
/** /**
* List with search results of DSpace objects for the current query * List with search results of DSpace objects for the current query
*/ */
listEntries$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>; listEntries: Array<SearchResult<DSpaceObject>> = [];
/**
* The current page to load
* Dynamically goes up as the user scrolls down until it reaches the last page possible
*/
currentPage$ = new BehaviorSubject(1);
/**
* Whether or not the list contains a next page to load
* This allows us to avoid next pages from trying to load when there are none
*/
hasNextPage = false;
/**
* Whether or not the list should be reset next time it receives a page to load
*/
resetList = false;
/** /**
* List of element references to all elements * List of element references to all elements
@@ -85,31 +107,107 @@ export class DSOSelectorComponent implements OnInit {
*/ */
linkTypes = CollectionElementLinkType; linkTypes = CollectionElementLinkType;
constructor(private searchService: SearchService) { /**
* Track whether the element has the mouse over it
*/
isMouseOver = false
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
public subs: Subscription[] = [];
constructor(protected searchService: SearchService) {
} }
/** /**
* Fills the listEntries$ variable with search results based on the input field's current value * Fills the listEntries variable with search results based on the input field's current value and the current page
* The search will always start with the initial currentDSOId value * The search will always start with the initial currentDSOId value
*/ */
ngOnInit(): void { ngOnInit(): void {
this.input.setValue(this.currentDSOId);
this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', '); this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', ');
this.listEntries$ = this.input.valueChanges
.pipe( // Create an observable searching for the current DSO (return empty list if there's no current DSO)
let currentDSOResult$;
if (isNotEmpty(this.currentDSOId)) {
currentDSOResult$ = this.search(this.getCurrentDSOQuery(), 1);
} else {
currentDSOResult$ = observableOf(new PaginatedList(undefined, []));
}
// Combine current DSO, query and page
this.subs.push(observableCombineLatest(
currentDSOResult$,
this.input.valueChanges.pipe(
debounceTime(this.debounceTime), debounceTime(this.debounceTime),
startWith(this.currentDSOId), startWith(''),
switchMap((query) => { tap(() => this.currentPage$.next(1))
),
this.currentPage$
).pipe(
switchMap(([currentDSOResult, query, page]: [PaginatedList<SearchResult<DSpaceObject>>, string, number]) => {
if (page === 1) {
// The first page is loading, this means we should reset the list instead of adding to it
this.resetList = true;
}
return this.search(query, page).pipe(
map((list) => {
// If it's the first page and no query is entered, add the current DSO to the start of the list
// If no query is entered, filter out the current DSO from the results, as it'll be displayed at the start of the list already
list.page = [
...((isEmpty(query) && page === 1) ? currentDSOResult.page : []),
...list.page.filter((result) => isNotEmpty(query) || result.indexableObject.id !== this.currentDSOId)
];
return list;
})
);
})
).subscribe((list) => {
if (this.resetList) {
this.listEntries = list.page;
this.resetList = false;
} else {
this.listEntries.push(...list.page);
}
// Check if there are more pages available after the current one
this.hasNextPage = list.totalElements > this.listEntries.length;
}));
}
/**
* Get a query to send for retrieving the current DSO
*/
getCurrentDSOQuery(): string {
return `search.resourceid:${this.currentDSOId}`;
}
/**
* Perform a search for the current query and page
* @param query Query to search objects for
* @param page Page to retrieve
*/
search(query: string, page: number): Observable<PaginatedList<SearchResult<DSpaceObject>>> {
return this.searchService.search( return this.searchService.search(
new PaginatedSearchOptions({ new PaginatedSearchOptions({
query: query, query: query,
dsoTypes: this.types, dsoTypes: this.types,
pagination: this.defaultPagination pagination: Object.assign({}, this.defaultPagination, {
currentPage: page
}) })
) })
).pipe(
getFirstSucceededRemoteDataPayload()
);
}
/**
* When the user reaches the bottom of the page (or almost) and there's a next page available, increase the current page
*/
onScrollDown() {
if (this.hasNextPage) {
this.currentPage$.next(this.currentPage$.value + 1);
} }
)
)
} }
/** /**
@@ -120,4 +218,22 @@ export class DSOSelectorComponent implements OnInit {
this.listElements.first.nativeElement.click(); this.listElements.first.nativeElement.click();
} }
} }
/**
* Get the context for element with the given id
*/
getContext(id: string) {
if (id === this.currentDSOId) {
return Context.SideBarSearchModalCurrent;
} else {
return Context.SideBarSearchModal;
}
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
} }

View File

@@ -22,6 +22,7 @@ export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWra
objectType = DSpaceObjectType.COLLECTION; objectType = DSpaceObjectType.COLLECTION;
selectorTypes = [DSpaceObjectType.COMMUNITY]; selectorTypes = [DSpaceObjectType.COMMUNITY];
action = SelectorActionType.CREATE; action = SelectorActionType.CREATE;
header = 'dso-selector.create.collection.sub-level';
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route); super(activeModal, route);

View File

@@ -5,7 +5,7 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<ds-collection-dropdown (selectionChange)="selectObject($event.collection)"> <h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
</ds-collection-dropdown> <ds-authorized-collection-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-authorized-collection-selector>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,7 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo
objectType = DSpaceObjectType.ITEM; objectType = DSpaceObjectType.ITEM;
selectorTypes = [DSpaceObjectType.COLLECTION]; selectorTypes = [DSpaceObjectType.COLLECTION];
action = SelectorActionType.CREATE; action = SelectorActionType.CREATE;
header = 'dso-selector.create.item.sub-level';
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route); super(activeModal, route);

View File

@@ -5,6 +5,7 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid ? 'search.resourceid:' + dsoRD?.payload.uuid : null" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector> <h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,12 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit {
*/ */
@Input() dsoRD: RemoteData<DSpaceObject>; @Input() dsoRD: RemoteData<DSpaceObject>;
/**
* Optional header to display above the selection list
* Supports i18n keys
*/
@Input() header: string;
/** /**
* The type of the DSO that's being edited, created or exported * The type of the DSO that's being edited, created or exported
*/ */

View File

@@ -407,7 +407,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
modalComp.repeatable = this.model.repeatable; modalComp.repeatable = this.model.repeatable;
modalComp.listId = this.listId; modalComp.listId = this.listId;
modalComp.relationshipOptions = this.model.relationship; modalComp.relationshipOptions = this.model.relationship;
modalComp.label = this.model.label; modalComp.label = this.model.relationship.relationshipType;
modalComp.metadataFields = this.model.metadataFields; modalComp.metadataFields = this.model.metadataFields;
modalComp.item = this.item; modalComp.item = this.item;
modalComp.collection = this.collection; modalComp.collection = this.collection;

View File

@@ -54,7 +54,7 @@ describe('ExistingMetadataListElementComponent', () => {
relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ }); relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ });
submissionId = '1234'; submissionId = '1234';
reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId); reoRel = new ReorderableRelationship(relationship, true, {} as any, {} as any, submissionId);
} }
beforeEach(async(() => { beforeEach(async(() => {

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core';
import { combineLatest, Observable, Subscription, zip as observableZip } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { hasValue } from '../../../../empty.util'; import { hasValue } from '../../../../empty.util';
import { map, skip, switchMap, take } from 'rxjs/operators'; import { map, skip, switchMap, take } from 'rxjs/operators';
@@ -157,7 +157,7 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
select(...selectableObjects: Array<SearchResult<Item>>) { select(...selectableObjects: Array<SearchResult<Item>>) {
this.zone.runOutsideAngular( this.zone.runOutsideAngular(
() => { () => {
const obs: Observable<any[]> = combineLatest(...selectableObjects.map((sri: SearchResult<Item>) => { const obs: Observable<any[]> = observableCombineLatest(...selectableObjects.map((sri: SearchResult<Item>) => {
this.addNameVariantSubscription(sri); this.addNameVariantSubscription(sri);
return this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid) return this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid)
.pipe( .pipe(
@@ -223,7 +223,7 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
switchMap((options) => this.lookupRelationService.getTotalLocalResults(this.relationshipOptions, options)) switchMap((options) => this.lookupRelationService.getTotalLocalResults(this.relationshipOptions, options))
); );
const externalSourcesAndOptions$ = combineLatest( const externalSourcesAndOptions$ = observableCombineLatest(
this.externalSourcesRD$.pipe( this.externalSourcesRD$.pipe(
getAllSucceededRemoteData(), getAllSucceededRemoteData(),
getRemoteDataPayload() getRemoteDataPayload()
@@ -233,7 +233,7 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
this.totalExternal$ = externalSourcesAndOptions$.pipe( this.totalExternal$ = externalSourcesAndOptions$.pipe(
switchMap(([sources, options]) => switchMap(([sources, options]) =>
observableZip(...sources.page.map((source: ExternalSource) => this.lookupRelationService.getTotalExternalResults(source, options)))) observableCombineLatest(...sources.page.map((source: ExternalSource) => this.lookupRelationService.getTotalExternalResults(source, options))))
); );
} }

View File

@@ -0,0 +1,35 @@
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HoverClassDirective } from './hover-class.directive';
import { By } from '@angular/platform-browser';
@Component({
template: `<div dsHoverClass="ds-hover"></div>`
})
class TestComponent { }
describe('HoverClassDirective', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let el: DebugElement;
beforeEach(() => {
fixture = TestBed.configureTestingModule({
declarations: [TestComponent, HoverClassDirective]
}).createComponent(TestComponent);
fixture.detectChanges();
component = fixture.componentInstance;
el = fixture.debugElement.query(By.css('div'));
});
it('should add the class on mouseenter and remove on mouseleave', () => {
el.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(el.nativeElement.classList).toContain('ds-hover');
el.triggerEventHandler('mouseleave', null);
fixture.detectChanges();
expect(el.nativeElement.classList).not.toContain('ds-hover');
});
});

View File

@@ -0,0 +1,31 @@
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: '[dsHoverClass]'
})
/**
* A directive adding a class to an element when hovered over
*/
export class HoverClassDirective {
/**
* The name of the class to add on hover
*/
@Input('dsHoverClass') hoverClass: string;
constructor(public elementRef: ElementRef) { }
/**
* On mouse enter, add the class to the element's class list
*/
@HostListener('mouseenter') onMouseEnter() {
this.elementRef.nativeElement.classList.add(this.hoverClass);
}
/**
* On mouse leave, remove the class from the element's class list
*/
@HostListener('mouseleave') onMouseLeave() {
this.elementRef.nativeElement.classList.remove(this.hoverClass);
}
}

View File

@@ -1,4 +1,4 @@
<div class="d-inline-block mr-2"> <div class="mr-2">
<button (click)="importObject.emit(object)" <button (click)="importObject.emit(object)"
class="btn btn-outline-primary btn-sm float-left" class="btn btn-outline-primary btn-sm float-left"
title="{{importConfig?.buttonLabel | translate}}"> title="{{importConfig?.buttonLabel | translate}}">

View File

@@ -51,6 +51,11 @@ export class ListableObjectComponentLoaderComponent implements OnInit {
*/ */
@Input() showLabel = true; @Input() showLabel = true;
/**
* The value to display for this element
*/
@Input() value: string;
/** /**
* Directive hook used to place the dynamic child component * Directive hook used to place the dynamic child component
*/ */
@@ -76,6 +81,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit {
(componentRef.instance as any).showLabel = this.showLabel; (componentRef.instance as any).showLabel = this.showLabel;
(componentRef.instance as any).context = this.context; (componentRef.instance as any).context = this.context;
(componentRef.instance as any).viewMode = this.viewMode; (componentRef.instance as any).viewMode = this.viewMode;
(componentRef.instance as any).value = this.value;
} }
/** /**

View File

@@ -24,6 +24,11 @@ export class AbstractListableElementComponent<T extends ListableObject> {
*/ */
@Input() listID: string; @Input() listID: string;
/**
* The value to display for this element
*/
@Input() value: string;
/** /**
* The index of this element * The index of this element
*/ */

View File

@@ -12,19 +12,15 @@
(sortFieldChange)="onSortFieldChange($event)" (sortFieldChange)="onSortFieldChange($event)"
(paginationChange)="onPaginationChange($event)"> (paginationChange)="onPaginationChange($event)">
<ul *ngIf="objects?.hasSucceeded" class="list-unstyled" [ngClass]="{'ml-4': selectable}"> <ul *ngIf="objects?.hasSucceeded" class="list-unstyled" [ngClass]="{'ml-4': selectable}">
<li *ngFor="let object of objects?.payload?.page; let i = index; let last = last" class="mt-4 mb-4" [class.border-bottom]="hasBorder && !last"> <li *ngFor="let object of objects?.payload?.page; let i = index; let last = last" class="mt-4 mb-4 d-flex" [class.border-bottom]="hasBorder && !last">
<span *ngIf="selectable"> <ds-selectable-list-item-control *ngIf="selectable" [index]="i"
<ds-selectable-list-item-control [index]="i"
[object]="object" [object]="object"
[selectionConfig]="selectionConfig" [selectionConfig]="selectionConfig"
(deselectObject)="deselectObject.emit($event)" (deselectObject)="deselectObject.emit($event)"
(selectObject)="selectObject.emit($event)"></ds-selectable-list-item-control> (selectObject)="selectObject.emit($event)"></ds-selectable-list-item-control>
</span> <ds-importable-list-item-control *ngIf="importable" [object]="object"
<span *ngIf="importable">
<ds-importable-list-item-control [object]="object"
[importConfig]="importConfig" [importConfig]="importConfig"
(importObject)="importObject.emit($event)"></ds-importable-list-item-control> (importObject)="importObject.emit($event)"></ds-importable-list-item-control>
</span>
<ds-listable-object-component-loader [object]="object" [viewMode]="viewMode" [index]="i" [context]="context" [linkType]="linkType" <ds-listable-object-component-loader [object]="object" [viewMode]="viewMode" [index]="i" [context]="context" [linkType]="linkType"
[listID]="selectionConfig?.listId"></ds-listable-object-component-loader> [listID]="selectionConfig?.listId"></ds-listable-object-component-loader>
</li> </li>

View File

@@ -0,0 +1,37 @@
import { CollectionSidebarSearchListElementComponent } from './collection-sidebar-search-list-element.component';
import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model';
import { Collection } from '../../../../core/shared/collection.model';
import { Community } from '../../../../core/shared/community.model';
import { createSidebarSearchListElementTests } from '../sidebar-search-list-element.component.spec';
const object = Object.assign(new CollectionSearchResult(), {
indexableObject: Object.assign(new Collection(), {
id: 'test-collection',
metadata: {
'dc.title': [
{
value: 'title'
}
],
'dc.description.abstract': [
{
value: 'description'
}
]
}
})
});
const parent = Object.assign(new Community(), {
id: 'test-community',
metadata: {
'dc.title': [
{
value: 'parent title'
}
]
}
});
describe('CollectionSidebarSearchListElementComponent',
createSidebarSearchListElementTests(CollectionSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description')
);

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model';
import { Collection } from '../../../../core/shared/collection.model';
import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator';
import { Context } from '../../../../core/shared/context.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { SidebarSearchListElementComponent } from '../sidebar-search-list-element.component';
@listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.SideBarSearchModal)
@listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.SideBarSearchModalCurrent)
@Component({
selector: 'ds-collection-sidebar-search-list-element',
templateUrl: '../sidebar-search-list-element.component.html'
})
/**
* Component displaying a list element for a {@link CollectionSearchResult} within the context of a sidebar search modal
*/
export class CollectionSidebarSearchListElementComponent extends SidebarSearchListElementComponent<CollectionSearchResult, Collection> {
/**
* Get the description of the Collection by returning its abstract
*/
getDescription(): string {
return this.firstMetadataValue('dc.description.abstract');
}
}

Some files were not shown because too many files have changed in this diff Show More