Merge remote-tracking branch 'remotes/DSpacegithub/main' into DS-4515_submit-external-source

This commit is contained in:
Giuseppe Digilio
2020-07-27 09:49:33 +02:00
198 changed files with 3427 additions and 1374 deletions

View File

@@ -1,4 +1,6 @@
# This workflow runs whenever a new pull request is created
# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs).
# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818
name: Pull Request opened
# Only run for newly opened PRs against the "main" branch

View File

@@ -20,7 +20,7 @@
<button class="btn btn-light" [disabled]="!(canDelete$ | async)">
<i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
<button *ngIf="!isImpersonated" class="btn btn-light" [disabled]="!(canImpersonate$ | async)" (click)="impersonate()">
<button *ngIf="!isImpersonated" class="btn btn-light" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-light" (click)="stopImpersonating()">

View File

@@ -33,6 +33,9 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
describe('EPersonFormComponent', () => {
let component: EPersonFormComponent;
@@ -43,6 +46,8 @@ describe('EPersonFormComponent', () => {
let mockEPeople;
let ePersonDataServiceStub: any;
let authService: AuthServiceStub;
let authorizationService: AuthorizationDataService;
let groupsDataService: GroupDataService;
beforeEach(async(() => {
mockEPeople = [EPersonMock, EPersonMock2];
@@ -108,6 +113,13 @@ describe('EPersonFormComponent', () => {
builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: ''
});
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
@@ -130,6 +142,8 @@ describe('EPersonFormComponent', () => {
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: GroupDataService, useValue: groupsDataService },
EPeopleRegistryComponent
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -9,7 +9,7 @@ import {
import { TranslateService } from '@ngx-translate/core';
import { Subscription, combineLatest, of } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { take } from 'rxjs/operators';
import { switchMap, take } from 'rxjs/operators';
import { RestResponse } from '../../../../core/cache/response.models';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
@@ -23,6 +23,8 @@ import { FormBuilderService } from '../../../../shared/form/builder/form-builder
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
@Component({
selector: 'ds-eperson-form',
@@ -120,9 +122,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
/**
* Observable whether or not the admin is allowed to impersonate the EPerson
* TODO: Initialize the observable once the REST API supports this (currently hardcoded to return true)
*/
canImpersonate$: Observable<boolean> = of(true);
canImpersonate$: Observable<boolean>;
/**
* List of subscriptions
@@ -158,7 +159,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private authService: AuthService) {
private authService: AuthService,
private authorizationService: AuthorizationDataService) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
if (hasValue(eperson)) {
@@ -242,6 +244,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
requireCertificate: eperson != null ? eperson.requireCertificate : false
});
}));
this.canImpersonate$ = this.epersonService.getActiveEPerson().pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
);
});
}

View File

@@ -209,7 +209,7 @@ describe('BitstreamFormatsComponent', () => {
selectBitstreamFormat: {},
deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {},
delete: observableOf(true),
delete: observableOf({ isSuccessful: true }),
clearBitStreamFormatRequests: observableOf('cleared')
});

View File

@@ -11,6 +11,7 @@ import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { RestResponse } from '../../../core/cache/response.models';
/**
* This component renders a list of bitstream formats
@@ -64,7 +65,7 @@ export class BitstreamFormatsComponent implements OnInit {
const tasks$ = [];
for (const format of formats) {
if (hasValue(format.id)) {
tasks$.push(this.bitstreamFormatService.delete(format.id));
tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RestResponse) => response.isSuccessful)));
}
}
zip(...tasks$).subscribe((results: boolean[]) => {

View File

@@ -9,19 +9,23 @@ import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service
import { CSSVariableServiceStub } from '../../shared/testing/css-variable-service.stub';
import { AuthServiceStub } from '../../shared/testing/auth-service.stub';
import { AuthService } from '../../core/auth/auth.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
let fixture: ComponentFixture<AdminSidebarComponent>;
const menuService = new MenuServiceStub();
let authorizationService: AuthorizationDataService;
beforeEach(async(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule],
declarations: [AdminSidebarComponent],
@@ -31,6 +35,7 @@ describe('AdminSidebarComponent', () => {
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: ActivatedRoute, useValue: {} },
{ provide: AuthorizationDataService, useValue: authorizationService },
{
provide: NgbModal, useValue: {
open: () => {/*comment*/}

View File

@@ -18,6 +18,8 @@ import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model
import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
/**
* Component representing the admin sidebar
@@ -61,7 +63,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
protected injector: Injector,
private variableService: CSSVariableService,
private authService: AuthService,
private modalService: NgbModal
private modalService: NgbModal,
private authorizationService: AuthorizationDataService
) {
super(menuService, injector);
}
@@ -71,6 +74,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
ngOnInit(): void {
this.createMenu();
this.createSiteAdministratorMenuSections();
super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth');
this.authService.isAuthenticated()
@@ -311,113 +315,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
} as LinkMenuItemModel,
},
/* Access Control */
{
id: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/admin/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/admin/access-control/groups'
} as LinkMenuItemModel,
},
{
id: 'access_control_authorizations',
parentID: 'access_control',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_authorizations',
link: ''
} as LinkMenuItemModel,
},
/* Admin Search */
{
id: 'admin_search',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search'
} as LinkMenuItemModel,
icon: 'search',
index: 5
},
/* Registries */
{
id: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: ''
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Statistics */
{
id: 'statistics_task',
@@ -445,6 +342,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
icon: 'cogs',
index: 9
},
/* Processes */
{
id: 'processes',
@@ -458,24 +356,144 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
icon: 'terminal',
index: 10
},
/* Workflow */
{
id: 'workflow',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.workflow',
link: '/admin/workflow'
} as LinkMenuItemModel,
icon: 'user-check',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
}
/**
* Create menu sections dependent on whether or not the current user is a site administrator
*/
createSiteAdministratorMenuSections() {
this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => {
const menuList = [
/* Access Control */
{
id: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/admin/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/admin/access-control/groups'
} as LinkMenuItemModel,
},
{
id: 'access_control_authorizations',
parentID: 'access_control',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_authorizations',
link: ''
} as LinkMenuItemModel,
},
/* Admin Search */
{
id: 'admin_search',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search'
} as LinkMenuItemModel,
icon: 'search',
index: 5
},
/* Registries */
{
id: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: ''
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Workflow */
{
id: 'workflow',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.workflow',
link: '/admin/workflow'
} as LinkMenuItemModel,
icon: 'user-check',
index: 11
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**

View File

@@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { map, tap, find } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
@@ -29,18 +28,15 @@ export class CreateCollectionPageGuard implements CanActivate {
this.router.navigate(['/404']);
return observableOf(false);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
return this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
})
find((communityRD: RemoteData<Community>) => hasValue(communityRD.payload) || hasValue(communityRD.error)),
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
})
);
}
}

View File

@@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { map, tap, find } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
@@ -29,18 +28,16 @@ export class CreateCommunityPageGuard implements CanActivate {
return observableOf(true);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
return this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
find((communityRD: RemoteData<Community>) => hasValue(communityRD.payload) || hasValue(communityRD.error)),
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
}
})
)
);
}
}

View File

@@ -114,7 +114,7 @@ describe('ItemBitstreamsComponent', () => {
}
);
bitstreamService = jasmine.createSpyObj('bitstreamService', {
deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse')
delete: jasmine.createSpy('delete')
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
@@ -182,12 +182,25 @@ describe('ItemBitstreamsComponent', () => {
comp.submit();
});
it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => {
expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id);
it('should call delete on the bitstreamService for the marked field', () => {
expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id);
});
it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id);
it('should not call delete on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id);
});
});
describe('when dropBitstream is called', () => {
const event = {
fromIndex: 0,
toIndex: 50,
// tslint:disable-next-line:no-empty
finish: () => {}
};
beforeEach(() => {
comp.dropBitstream(bundle, event);
});
});

View File

@@ -165,7 +165,7 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
take(1),
switchMap((removedBistreams: Bitstream[]) => {
if (isNotEmpty(removedBistreams)) {
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id)));
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id)));
} else {
return observableOf(undefined);
}

View File

@@ -1,24 +1,27 @@
import {Component, Input, OnInit} from '@angular/core';
import {filter, first, map, switchMap, take} from 'rxjs/operators';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component';
import {getItemEditPath} from '../../item-page-routing.module';
import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap';
import {combineLatest as observableCombineLatest, combineLatest, Observable} from 'rxjs';
import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model';
import {VirtualMetadata} from '../virtual-metadata/virtual-metadata.component';
import {Relationship} from '../../../core/shared/item-relationships/relationship.model';
import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators';
import {hasValue, isNotEmpty} from '../../../shared/empty.util';
import {Item} from '../../../core/shared/item.model';
import {MetadataValue} from '../../../core/shared/metadata.models';
import {ViewMode} from '../../../core/shared/view-mode.model';
import {ActivatedRoute, Router} from '@angular/router';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {ItemDataService} from '../../../core/data/item-data.service';
import {TranslateService} from '@ngx-translate/core';
import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service';
import {RelationshipService} from '../../../core/data/relationship.service';
import {EntityTypeService} from '../../../core/data/entity-type.service';
import { Component, Input, OnInit } from '@angular/core';
import { defaultIfEmpty, filter, first, map, switchMap, take } from 'rxjs/operators';
import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import { getItemEditPath } from '../../item-page-routing.module';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { combineLatest as observableCombineLatest, combineLatest, Observable, of as observableOf } from 'rxjs';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { VirtualMetadata } from '../virtual-metadata/virtual-metadata.component';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { Item } from '../../../core/shared/item.model';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { ViewMode } from '../../../core/shared/view-mode.model';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { TranslateService } from '@ngx-translate/core';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { RelationshipService } from '../../../core/data/relationship.service';
import { EntityTypeService } from '../../../core/data/entity-type.service';
import { LinkService } from '../../../core/cache/builders/link.service';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { RestResponse } from '../../../core/cache/response.models';
@Component({
selector: 'ds-item-delete',
@@ -80,6 +83,7 @@ export class ItemDeleteComponent
protected objectUpdatesService: ObjectUpdatesService,
protected relationshipService: RelationshipService,
protected entityTypeService: EntityTypeService,
protected linkService: LinkService,
) {
super(
route,
@@ -98,30 +102,33 @@ export class ItemDeleteComponent
super.ngOnInit();
this.url = this.router.url;
this.types$ = this.entityTypeService.getEntityTypeByLabel(
this.item.firstMetadataValue('relationship.type')
).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)),
getSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
switchMap((types) =>
combineLatest(types.map((type) => this.getRelationships(type))).pipe(
map((relationships) =>
types.reduce<RelationshipType[]>((includedTypes, type, index) => {
if (!includedTypes.some((includedType) => includedType.id === type.id)
&& !(relationships[index].length === 0)) {
return [...includedTypes, type];
} else {
return includedTypes;
}
}, [])
),
)
),
);
const label = this.item.firstMetadataValue('relationship.type');
if (label !== undefined) {
this.types$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)),
getSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
switchMap((types) =>
combineLatest(types.map((type) => this.getRelationships(type))).pipe(
map((relationships) =>
types.reduce<RelationshipType[]>((includedTypes, type, index) => {
if (!includedTypes.some((includedType) => includedType.id === type.id)
&& !(relationships[index].length === 0)) {
return [...includedTypes, type];
} else {
return includedTypes;
}
}, [])
),
)
),
);
} else {
this.types$ = observableOf([]);
}
this.types$.pipe(
take(1),
@@ -187,6 +194,7 @@ export class ItemDeleteComponent
observableCombineLatest(
relationships.map((relationship) => this.getRelationshipType(relationship))
).pipe(
defaultIfEmpty([]),
map((types) => relationships.filter(
(relationship, index) => relationshipType.id === types[index].id
)),
@@ -205,6 +213,12 @@ export class ItemDeleteComponent
*/
private getRelationshipType(relationship: Relationship): Observable<RelationshipType> {
this.linkService.resolveLinks(
relationship,
followLink('relationshipType'),
followLink('leftItem'),
followLink('rightItem'),
);
return relationship.relationshipType.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
@@ -305,6 +319,7 @@ export class ItemDeleteComponent
combineLatest(
types.map((type) => this.isSelected(type))
).pipe(
defaultIfEmpty([]),
map((selection) => types.filter(
(type, index) => selection[index]
)),
@@ -313,8 +328,8 @@ export class ItemDeleteComponent
),
).subscribe((types) => {
this.itemDataService.delete(this.item.id, types).pipe(first()).subscribe(
(succeeded: boolean) => {
this.notify(succeeded);
(response: RestResponse) => {
this.notify(response.isSuccessful);
}
);
});

View File

@@ -1,48 +1,56 @@
<div class="item-relationships">
<div class="button-row top d-flex">
<button class="btn btn-danger ml-auto" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning ml-auto" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<div *ngFor="let relationshipType of relationshipTypes$ | async" class="mb-4">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="entityType$ | async"
[relationshipType]="relationshipType"
></ds-edit-relationship-list>
</div>
<div class="button-row bottom">
<div class="float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
<ng-container *ngVar="entityType$ | async as entityType">
<ng-container *ngIf="entityType">
<div class="button-row top d-flex">
<button class="btn btn-danger ml-auto" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning ml-auto" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<div *ngFor="let relationshipType of relationshipTypes$ | async" class="mb-4">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="entityType"
[relationshipType]="relationshipType"
></ds-edit-relationship-list>
</div>
<div class="button-row bottom">
<div class="float-right">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div>
</ng-container>
<div *ngIf="!entityType"
class="alert alert-info mt-2" role="alert">
{{ 'item.edit.relationships.no-entity-type' | translate }}
</div>
</div>
</ng-container>
</div>

View File

@@ -3,7 +3,7 @@ import { Item } from '../../../core/shared/item.model';
import { DeleteRelationship, FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { zip as observableZip } from 'rxjs';
import { of as observableOf, zip as observableZip} from 'rxjs';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { ItemDataService } from '../../../core/data/item-data.service';
@@ -87,26 +87,30 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl
*/
public initializeUpdates(): void {
this.entityType$ = this.entityTypeService.getEntityTypeByLabel(
this.item.firstMetadataValue('relationship.type')
).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
);
const label = this.item.firstMetadataValue('relationship.type');
if (label !== undefined) {
this.relationshipTypes$ = this.entityType$.pipe(
switchMap((entityType) =>
this.entityTypeService.getEntityTypeRelationships(
entityType.id,
followLink('leftType'),
followLink('rightType'))
.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
)
),
);
this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
);
this.relationshipTypes$ = this.entityType$.pipe(
switchMap((entityType) =>
this.entityTypeService.getEntityTypeRelationships(
entityType.id,
followLink('leftType'),
followLink('rightType'))
.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
)
),
);
} else {
this.entityType$ = observableOf(undefined);
}
}
/**

View File

@@ -5,7 +5,7 @@ import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model';
import { getFinishedRemoteData, getSucceededRemoteData } from '../../../../core/shared/operators';
import { getFirstSucceededRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { hasValue } from '../../../../shared/empty.util';
/**
@@ -75,16 +75,19 @@ export const paginatedRelationsToItems = (thisId: string) =>
getSucceededRemoteData(),
switchMap((relationshipsRD: RemoteData<PaginatedList<Relationship>>) => {
return observableCombineLatest(
...relationshipsRD.payload.page.map((rel: Relationship) => observableCombineLatest(rel.leftItem.pipe(getFinishedRemoteData()), rel.rightItem.pipe(getFinishedRemoteData())))
).pipe(
relationshipsRD.payload.page.map((rel: Relationship) =>
observableCombineLatest([
rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()),
rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())]
)
)).pipe(
map((arr) =>
arr
.filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded)
.map(([leftItem, rightItem]) => {
if (leftItem.payload.id === thisId) {
return rightItem.payload;
} else if (rightItem.payload.id === thisId) {
return leftItem.payload;
if (leftItem.id === thisId) {
return rightItem;
} else if (rightItem.id === thisId) {
return leftItem;
}
})
.filter((item: Item) => hasValue(item))

View File

@@ -7,6 +7,8 @@ import { RouteService } from '../../core/services/route.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { RequestService } from '../../core/data/request.service';
import { map } from 'rxjs/operators';
import { RestResponse } from '../../core/cache/response.models';
@Component({
selector: 'ds-workflow-item-delete',
@@ -39,6 +41,6 @@ export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent
*/
sendRequest(id: string): Observable<boolean> {
this.requestService.removeByHrefSubstring('/discover');
return this.workflowItemService.delete(id);
return this.workflowItemService.delete(id).pipe(map((response: RestResponse) => response.isSuccessful));
}
}

View File

@@ -10,6 +10,8 @@ import { Collection } from './core/shared/collection.model';
import { Item } from './core/shared/item.model';
import { getItemPageRoute } from './+item-page/item-page-routing.module';
import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module';
import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
const ITEM_MODULE_PATH = 'items';
@@ -76,6 +78,12 @@ export function getDSOPath(dso: DSpaceObject): string {
}
}
const UNAUTHORIZED_PATH = 'unauthorized';
export function getUnauthorizedPath() {
return `/${UNAUTHORIZED_PATH}`;
}
@NgModule({
imports: [
RouterModule.forRoot([
@@ -98,7 +106,7 @@ export function getDSOPath(dso: DSpaceObject): string {
},
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
@@ -116,6 +124,7 @@ export function getDSOPath(dso: DSpaceObject): string {
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
},
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
],
{

View File

@@ -40,6 +40,7 @@ import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
import { environment } from '../environments/environment';
import { BrowserModule } from '@angular/platform-browser';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
export function getBase() {
return environment.ui.nameSpace;
@@ -123,6 +124,7 @@ const EXPORTS = [
declarations: [
...DECLARATIONS,
BreadcrumbsComponent,
UnauthorizedComponent,
],
exports: [
...EXPORTS

View File

@@ -27,9 +27,6 @@ export class ServerAuthService extends AuthService {
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
// NB this is used to pass server client IP check.
const clientIp = this.req.get('x-forwarded-for') || this.req.connection.remoteAddress;
headers = headers.append('X-Forwarded-For', clientIp);
options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe(

View File

@@ -1,13 +1,7 @@
import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import {
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotUndefined
} from '../../../shared/empty.util';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../data/paginated-list';
@@ -15,12 +9,7 @@ import { RemoteData } from '../../data/remote-data';
import { RemoteDataError } from '../../data/remote-data-error';
import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import {
filterSuccessfulResponses,
getRequestFromRequestHref,
getRequestFromRequestUUID,
getResourceLinksFromResponse
} from '../../shared/operators';
import { filterSuccessfulResponses, getRequestFromRequestHref, getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators';
import { PageInfo } from '../../shared/page-info.model';
import { CacheableObject } from '../object-cache.reducer';
import { ObjectCacheService } from '../object-cache.service';
@@ -98,7 +87,8 @@ export class RemoteDataBuildService {
let error: RemoteDataError;
const response = reqEntry ? reqEntry.response : undefined;
if (hasValue(response)) {
isSuccessful = response.isSuccessful;
isSuccessful = response.statusCode === 204 ||
response.statusCode >= 200 && response.statusCode < 300 && hasValue(payload);
const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) {
error = new RemoteDataError(
@@ -155,7 +145,7 @@ export class RemoteDataBuildService {
})
);
const payload$ = observableCombineLatest(tDomainList$, pageInfo$).pipe(
const payload$ = observableCombineLatest([tDomainList$, pageInfo$]).pipe(
map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList);
})

View File

@@ -1,7 +1,5 @@
import { autoserialize, deserialize } from 'cerialize';
import { HALLink } from '../shared/hal-link.model';
import { HALResource } from '../shared/hal-resource.model';
import { excludeFromEquals } from '../utilities/equals.decorators';
import {
ObjectCacheAction,
ObjectCacheActionTypes,
@@ -15,12 +13,6 @@ import { CacheEntry } from './cache-entry';
import { ResourceType } from '../shared/resource-type';
import { applyPatch, Operation } from 'fast-json-patch';
export enum DirtyType {
Created = 'Created',
Updated = 'Updated',
Deleted = 'Deleted'
}
/**
* An interface to represent a JsonPatch
*/
@@ -72,6 +64,7 @@ export class ObjectCacheEntry implements CacheEntry {
patches: Patch[] = [];
isDirty: boolean;
}
/* tslint:enable:max-classes-per-file */
/**

View File

@@ -9,7 +9,7 @@ import { RestRequestMethod } from '../data/rest-request-method';
/**
* An entry in the ServerSyncBufferState
* href: unique href of an ObjectCacheEntry
* href: unique href of an ServerSyncBufferEntry
* method: RestRequestMethod type
*/
export class ServerSyncBufferEntry {
@@ -48,6 +48,7 @@ export function serverSyncBufferReducer(state = initialState, action: ServerSync
case ServerSyncBufferActionTypes.EMPTY: {
return emptyServerSyncQueue(state, action as EmptySSBAction);
}
default: {
return state;
}

View File

@@ -177,6 +177,7 @@ describe('ConfigResponseParsingService', () => {
Object.assign(new SubmissionDefinitionModel(), {
isDefault: true,
name: 'traditional',
id: 'traditional',
type: 'submissiondefinition',
_links: {
sections: { href: 'https://rest.api/config/submissiondefinitions/traditional/sections' },
@@ -187,6 +188,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
id: 'traditionalpageone',
visibility: {
main: null,
other: 'READONLY'
@@ -201,6 +203,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
id: 'traditionalpagetwo',
visibility: {
main: null,
other: 'READONLY'
@@ -215,6 +218,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
id: 'upload',
visibility: {
main: null,
other: 'READONLY'
@@ -229,6 +233,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
id: 'license',
visibility: {
main: null,
other: 'READONLY'

View File

@@ -6,6 +6,12 @@ import { excludeFromEquals } from '../../utilities/equals.decorators';
export abstract class ConfigObject implements CacheableObject {
/**
* The name for this configuration
*/
@autoserialize
public id: string;
/**
* The name for this configuration
*/

View File

@@ -147,6 +147,11 @@ import { WorkflowAction } from './tasks/models/workflow-action-object.model';
import { LocaleInterceptor } from './locale/locale.interceptor';
import { ItemTemplateDataService } from './data/item-template-data.service';
import { TemplateItem } from './shared/template-item.model';
import { Feature } from './shared/feature.model';
import { Authorization } from './shared/authorization.model';
import { FeatureDataService } from './data/feature-authorization/feature-data.service';
import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service';
import { SiteAdministratorGuard } from './data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { Registration } from './shared/registration.model';
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
import { MetadataFieldDataService } from './data/metadata-field-data.service';
@@ -275,6 +280,9 @@ const PROVIDERS = [
ProcessDataService,
ScriptDataService,
ProcessFilesResponseParsingService,
FeatureDataService,
AuthorizationDataService,
SiteAdministratorGuard,
MetadataSchemaDataService,
MetadataFieldDataService,
TokenResponseParsingService,
@@ -340,6 +348,8 @@ export const models =
VersionHistory,
WorkflowAction,
TemplateItem,
Feature,
Authorization,
Registration
];

View File

@@ -281,7 +281,7 @@ describe('BitstreamFormatDataService', () => {
format.uuid = 'format-uuid';
format.id = 'format-id';
const expected = cold('(b|)', {b: true});
const expected = cold('(b|)', { b: responseCacheEntry.response });
const result = service.delete(format.id);
expect(result).toBeObservable(expected);

View File

@@ -155,9 +155,9 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
/**
* Delete an existing DSpace Object on the server
* @param formatID The DSpace Object'id to be removed
* Return an observable that emits true when the deletion was successful, false when it failed
* @return the RestResponse as an Observable
*/
delete(formatID: string): Observable<boolean> {
delete(formatID: string): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -173,7 +173,7 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
map((request: RequestEntry) => request.response)
);
}

View File

@@ -0,0 +1,94 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { compare, Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service';
import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { BundleDataService } from './bundle-data.service';
import { HALLink } from '../shared/hal-link.model';
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
diff(object1: Item, object2: Item): Operation[] {
return compare((object1 as any).metadata, (object2 as any).metadata);
}
}
describe('BundleDataService', () => {
let service: BundleDataService;
let requestService;
let halService;
let rdbService;
let notificationsService;
let http;
let comparator;
let objectCache;
let store;
let item;
let bundleLink;
let bundleHALLink;
function initTestService(): BundleDataService {
bundleLink = '/items/0fdc0cd7-ff8c-433d-b33c-9b56108abc07/bundles';
bundleHALLink = new HALLink();
bundleHALLink.href = bundleLink;
item = new Item();
item._links = {
bundles: bundleHALLink
};
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = {} as RemoteDataBuildService;
notificationsService = {} as NotificationsService;
http = {} as HttpClient;
comparator = new DummyChangeAnalyzer() as any;
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
}
} as any;
store = {} as Store<CoreState>;
return new BundleDataService(
requestService,
rdbService,
store,
objectCache,
halService,
notificationsService,
http,
comparator,
);
}
beforeEach(() => {
service = initTestService();
});
describe('findAllByItem', () => {
beforeEach(() => {
spyOn(service, 'findAllByHref');
service.findAllByItem(item);
});
it('should call findAllByHref with the item\'s bundles link', () => {
expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined);
})
});
});

View File

@@ -73,7 +73,12 @@ describe('CollectionDataService', () => {
describe('when the requests are successful', () => {
beforeEach(() => {
createService();
createService(observableOf({
request: {
href: 'https://rest.api/request'
},
completed: true
}));
});
describe('when calling getContentSource', () => {
@@ -133,7 +138,7 @@ describe('CollectionDataService', () => {
});
it('should return a RemoteData<PaginatedList<Colletion>> for the getAuthorizedCollection', () => {
const result = service.getAuthorizedCollection(queryString)
const result = service.getAuthorizedCollection(queryString);
const expected = cold('a|', {
a: paginatedListRD
});
@@ -148,7 +153,7 @@ describe('CollectionDataService', () => {
});
it('should return a RemoteData<PaginatedList<Colletion>> for the getAuthorizedCollectionByCommunity', () => {
const result = service.getAuthorizedCollectionByCommunity(communityId, queryString)
const result = service.getAuthorizedCollectionByCommunity(communityId, queryString);
const expected = cold('a|', {
a: paginatedListRD
});

View File

@@ -2,19 +2,8 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
first,
map,
mergeMap,
skipWhile,
switchMap,
take,
tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -28,25 +17,12 @@ import { CoreState } from '../core.reducers';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
getRemoteDataPayload,
getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { ChangeAnalyzer } from './change-analyzer';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import {
CreateRequest,
DeleteByIDRequest,
FindByIDRequest,
FindListOptions,
FindListRequest,
GetRequest,
PatchRequest, PutRequest
} from './request.models';
import { CreateRequest, DeleteByIDRequest, FindByIDRequest, FindListOptions, FindListRequest, GetRequest, PatchRequest, PutRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service';
import { RestRequestMethod } from './rest-request-method';
@@ -353,26 +329,24 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
hrefObs.pipe(
find((href: string) => hasValue(href))
).subscribe((href: string) => {
const request = new FindListRequest(requestId, href, options);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
});
this.requestService.configure(request);
}
return this.requestService.getByUUID(requestId).pipe(
find((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((requestEntry) =>
this.rdbService.buildList<T>(requestEntry.request.href, ...linksToFollow)
),
switchMap((href) => this.requestService.getByHref(href)),
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((href) =>
this.rdbService.buildList<T>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<T>>>
)
);
}
@@ -391,6 +365,9 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PatchRequest(requestId, href, operations);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
})
).subscribe();
@@ -464,7 +441,13 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)))
map((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
return request
})
);
// Execute the post request
@@ -513,7 +496,13 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)))
map((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
return request
})
);
// Execute the post request
@@ -542,42 +531,9 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return an observable that emits true when the deletion was successful, false when it failed
* @return the RestResponse as an Observable
*/
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => isNotEmpty(request) && request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
}
/**
* Delete an existing DSpace Object on the server
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return an observable of the completed response
*/
deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
hasValueOperator(),
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
/**
* Delete an existing DSpace Object on the server
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return the delete request's ID
*/
private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string {
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getIDHrefObs(dsoID);
@@ -593,11 +549,17 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
);
}
const request = new DeleteByIDRequest(requestId, href, dsoID);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
})
).subscribe();
return requestId;
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
/**
@@ -608,4 +570,15 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
this.requestService.commit(method);
}
/**
* Return the links to traverse from the root of the api to the
* endpoint this DataService represents
*
* e.g. if the api root links to 'foo', and the endpoint at 'foo'
* links to 'bar' the linkPath for the BarDataService would be
* 'foo/bar'
*/
getLinkPath(): string {
return this.linkPath;
}
}

View File

@@ -0,0 +1,198 @@
import { AuthorizationDataService } from './authorization-data.service';
import { SiteDataService } from '../site-data.service';
import { AuthService } from '../../auth/auth.service';
import { Site } from '../../shared/site.model';
import { EPerson } from '../../eperson/models/eperson.model';
import { of as observableOf } from 'rxjs';
import { FindListOptions } from '../request.models';
import { FeatureID } from './feature-id';
import { hasValue } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model';
import { Authorization } from '../../shared/authorization.model';
import { RemoteData } from '../remote-data';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { Feature } from '../../shared/feature.model';
describe('AuthorizationDataService', () => {
let service: AuthorizationDataService;
let siteService: SiteDataService;
let authService: AuthService;
let site: Site;
let ePerson: EPerson;
function init() {
site = Object.assign(new Site(), {
id: 'test-site',
_links: {
self: { href: 'test-site-href' }
}
});
ePerson = Object.assign(new EPerson(), {
id: 'test-eperson',
uuid: 'test-eperson'
});
siteService = jasmine.createSpyObj('siteService', {
find: observableOf(site)
});
authService = {
isAuthenticated: () => observableOf(true),
getAuthenticatedUserFromStore: () => observableOf(ePerson)
} as AuthService;
service = new AuthorizationDataService(undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, authService, siteService);
}
beforeEach(() => {
init();
spyOn(service, 'searchBy').and.returnValue(observableOf(undefined));
});
describe('searchByObject', () => {
const objectUrl = 'fake-object-url';
const ePersonUuid = 'fake-eperson-uuid';
function createExpected(providedObjectUrl: string, providedEPersonUuid?: string, providedFeatureId?: FeatureID): FindListOptions {
const searchParams = [new RequestParam('uri', providedObjectUrl)];
if (hasValue(providedFeatureId)) {
searchParams.push(new RequestParam('feature', providedFeatureId));
}
if (hasValue(providedEPersonUuid)) {
searchParams.push(new RequestParam('eperson', providedEPersonUuid));
}
return Object.assign(new FindListOptions(), { searchParams });
}
describe('when no arguments are provided and a user is authenticated', () => {
beforeEach(() => {
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid));
});
});
describe('when no arguments except for a feature are provided and a user is authenticated', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
});
it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf));
});
});
describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
});
it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf));
});
});
describe('when all arguments are provided', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl, ePersonUuid).subscribe();
});
it('should call searchBy with the object\'s url, user\'s uuid and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf));
});
});
describe('when no arguments are provided and no user is authenticated', () => {
beforeEach(() => {
spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false));
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
});
describe('isAuthorized', () => {
const featureID = FeatureID.AdministratorOf;
const validPayload = [
Object.assign(new Authorization(), {
feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), {
id: 'invalid-feature'
}))
}),
Object.assign(new Authorization(), {
feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), {
id: featureID
}))
})
];
const invalidPayload = [
Object.assign(new Authorization(), {
feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), {
id: 'invalid-feature'
}))
}),
Object.assign(new Authorization(), {
feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), {
id: 'another-invalid-feature'
}))
})
];
const emptyPayload = [];
describe('when searchByObject returns a 401', () => {
beforeEach(() => {
spyOn(service, 'searchByObject').and.returnValue(observableOf(new RemoteData(false, false, true, undefined, undefined, 401)));
});
it('should return false', (done) => {
service.isAuthorized(featureID).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
});
describe('when searchByObject returns an empty list', () => {
beforeEach(() => {
spyOn(service, 'searchByObject').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(emptyPayload)));
});
it('should return false', (done) => {
service.isAuthorized(featureID).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
});
describe('when searchByObject returns an invalid list', () => {
beforeEach(() => {
spyOn(service, 'searchByObject').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(invalidPayload)));
});
it('should return true', (done) => {
service.isAuthorized(featureID).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
});
describe('when searchByObject returns a valid list', () => {
beforeEach(() => {
spyOn(service, 'searchByObject').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(validPayload)));
});
it('should return true', (done) => {
service.isAuthorized(featureID).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
});
});

View File

@@ -0,0 +1,150 @@
import { of as observableOf } from 'rxjs';
import { Injectable } from '@angular/core';
import { AUTHORIZATION } from '../../shared/authorization.resource-type';
import { dataService } from '../../cache/builders/build-decorators';
import { DataService } from '../data.service';
import { Authorization } from '../../shared/authorization.model';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../dso-change-analyzer.service';
import { AuthService } from '../../auth/auth.service';
import { SiteDataService } from '../site-data.service';
import { FindListOptions, FindListRequest } from '../request.models';
import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../remote-data';
import { PaginatedList } from '../paginated-list';
import { find, map, switchMap, tap } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params';
import {
addAuthenticatedUserUuidIfEmpty,
addSiteObjectUrlIfEmpty,
oneAuthorizationMatchesFeature
} from './authorization-utils';
import { FeatureID } from './feature-id';
/**
* A service to retrieve {@link Authorization}s from the REST API
*/
@Injectable()
@dataService(AUTHORIZATION)
export class AuthorizationDataService extends DataService<Authorization> {
protected linkPath = 'authorizations';
protected searchByObjectPath = 'object';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Authorization>,
protected authService: AuthService,
protected siteService: SiteDataService
) {
super();
}
/**
* Checks if an {@link EPerson} (or anonymous) has access to a specific object within a {@link Feature}
* @param objectUrl URL to the object to search {@link Authorization}s for.
* If not provided, the repository's {@link Site} will be used.
* @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for.
* If not provided, the UUID of the currently authenticated {@link EPerson} will be used.
* @param featureId ID of the {@link Feature} to check {@link Authorization} for
*/
isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable<boolean> {
return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, followLink('feature')).pipe(
map((authorizationRD) => {
if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) {
return authorizationRD.payload.page;
} else {
return [];
}
}),
oneAuthorizationMatchesFeature(featureId)
);
}
/**
* Search for a list of {@link Authorization}s using the "object" search endpoint and providing optional object url,
* {@link EPerson} uuid and/or {@link Feature} id
* @param objectUrl URL to the object to search {@link Authorization}s for.
* If not provided, the repository's {@link Site} will be used.
* @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for.
* If not provided, the UUID of the currently authenticated {@link EPerson} will be used.
* @param featureId ID of the {@link Feature} to search {@link Authorization}s for
* @param options {@link FindListOptions} to provide pagination and/or additional arguments
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> {
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
addSiteObjectUrlIfEmpty(this.siteService),
addAuthenticatedUserUuidIfEmpty(this.authService),
switchMap((params: AuthorizationSearchParams) => {
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow);
})
);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return {Observable<RemoteData<PaginatedList<Authorization>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);
}
),
switchMap((href) => this.requestService.getByHref(href)),
switchMap((href) =>
this.rdbService.buildList<Authorization>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<Authorization>>>
)
);
}
/**
* Create {@link FindListOptions} with {@link RequestParam}s containing a "uri", "feature" and/or "eperson" parameter
* @param objectUrl Required parameter value to add to {@link RequestParam} "uri"
* @param options Optional initial {@link FindListOptions} to add parameters to
* @param ePersonUuid Optional parameter value to add to {@link RequestParam} "eperson"
* @param featureId Optional parameter value to add to {@link RequestParam} "feature"
*/
private createSearchOptions(objectUrl: string, options: FindListOptions = {}, ePersonUuid?: string, featureId?: FeatureID): FindListOptions {
let params = [];
if (isNotEmpty(options.searchParams)) {
params = [...options.searchParams];
}
params.push(new RequestParam('uri', objectUrl))
if (hasValue(featureId)) {
params.push(new RequestParam('feature', featureId));
}
if (hasValue(ePersonUuid)) {
params.push(new RequestParam('eperson', ePersonUuid));
}
return Object.assign(new FindListOptions(), options, {
searchParams: [...params]
});
}
}

View File

@@ -0,0 +1,16 @@
import { FeatureID } from './feature-id';
/**
* Search parameters for retrieving authorizations from the REST API
*/
export class AuthorizationSearchParams {
objectUrl: string;
ePersonUuid: string;
featureId: FeatureID;
constructor(objectUrl?: string, ePersonUuid?: string, featureId?: FeatureID) {
this.objectUrl = objectUrl;
this.ePersonUuid = ePersonUuid;
this.featureId = featureId;
}
}

View File

@@ -0,0 +1,84 @@
import { map, switchMap } from 'rxjs/operators';
import { Observable } from 'rxjs/internal/Observable';
import { AuthorizationSearchParams } from './authorization-search-params';
import { SiteDataService } from '../site-data.service';
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs';
import { AuthService } from '../../auth/auth.service';
import { Authorization } from '../../shared/authorization.model';
import { Feature } from '../../shared/feature.model';
import { FeatureID } from './feature-id';
import { getFirstSucceededRemoteDataPayload } from '../../shared/operators';
/**
* Operator accepting {@link AuthorizationSearchParams} and adding the current {@link Site}'s selflink to the parameter's
* objectUrl property, if this property is empty
* @param siteService The {@link SiteDataService} used for retrieving the repository's {@link Site}
*/
export const addSiteObjectUrlIfEmpty = (siteService: SiteDataService) =>
(source: Observable<AuthorizationSearchParams>): Observable<AuthorizationSearchParams> =>
source.pipe(
switchMap((params: AuthorizationSearchParams) => {
if (hasNoValue(params.objectUrl)) {
return siteService.find().pipe(
map((site) => Object.assign({}, params, { objectUrl: site.self }))
);
} else {
return observableOf(params);
}
})
);
/**
* Operator accepting {@link AuthorizationSearchParams} and adding the authenticated user's uuid to the parameter's
* ePersonUuid property, if this property is empty and an {@link EPerson} is currently authenticated
* @param authService The {@link AuthService} used for retrieving the currently authenticated {@link EPerson}
*/
export const addAuthenticatedUserUuidIfEmpty = (authService: AuthService) =>
(source: Observable<AuthorizationSearchParams>): Observable<AuthorizationSearchParams> =>
source.pipe(
switchMap((params: AuthorizationSearchParams) => {
if (hasNoValue(params.ePersonUuid)) {
return authService.isAuthenticated().pipe(
switchMap((authenticated) => {
if (authenticated) {
return authService.getAuthenticatedUserFromStore().pipe(
map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid }))
);
} else {
return observableOf(params)
}
})
);
} else {
return observableOf(params);
}
})
);
/**
* Operator checking if at least one of the provided {@link Authorization}s contains a {@link Feature} that matches the
* provided {@link FeatureID}
* Note: This expects the {@link Authorization}s to contain a resolved link to their {@link Feature}. If they don't,
* this observable will always emit false.
* @param featureID
* @returns true if at least one {@link Feature} matches, false if none do
*/
export const oneAuthorizationMatchesFeature = (featureID: FeatureID) =>
(source: Observable<Authorization[]>): Observable<boolean> =>
source.pipe(
switchMap((authorizations: Authorization[]) => {
if (isNotEmpty(authorizations)) {
return observableCombineLatest(
...authorizations
.filter((authorization: Authorization) => hasValue(authorization.feature))
.map((authorization: Authorization) => authorization.feature.pipe(
getFirstSucceededRemoteDataPayload()
))
);
} else {
return observableOf([]);
}
}),
map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID).length > 0)
);

View File

@@ -0,0 +1,66 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
import { Router } from '@angular/router';
/**
* Test implementation of abstract class FeatureAuthorizationGuard
* Provide the return values of the overwritten getters as constructor arguments
*/
class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected featureId: FeatureID,
protected objectUrl: string,
protected ePersonUuid: string) {
super(authorizationService, router);
}
getFeatureID(): FeatureID {
return this.featureId;
}
getObjectUrl(): string {
return this.objectUrl;
}
getEPersonUuid(): string {
return this.ePersonUuid;
}
}
describe('FeatureAuthorizationGuard', () => {
let guard: FeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService;
let router: Router;
let featureId: FeatureID;
let objectUrl: string;
let ePersonUuid: string;
function init() {
featureId = FeatureID.LoginOnBehalfOf;
objectUrl = 'fake-object-url';
ePersonUuid = 'fake-eperson-uuid';
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
guard = new FeatureAuthorizationGuardImpl(authorizationService, router, featureId, objectUrl, ePersonUuid);
}
beforeEach(() => {
init();
});
describe('canActivate', () => {
it('should call authorizationService.isAuthenticated with the appropriate arguments', () => {
guard.canActivate(undefined, undefined).subscribe();
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid);
});
});
});

View File

@@ -0,0 +1,52 @@
import {
ActivatedRouteSnapshot,
CanActivate,
Router,
RouterStateSnapshot,
UrlTree
} from '@angular/router';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
* doesn't have authorized rights on a specific feature and/or object.
* Override the desired getters in the parent class for checking specific authorization on a feature and/or object.
*/
export abstract class FeatureAuthorizationGuard implements CanActivate {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router) {
}
/**
* True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router));
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(): FeatureID;
/**
* The URL of the object to check if the user has authorized rights for
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
*/
getObjectUrl(): string {
return undefined;
}
/**
* The UUID of the user to check authorization rights for
* Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used.
*/
getEPersonUuid(): string {
return undefined;
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service';
import { Router } from '@angular/router';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
* rights to the {@link Site}
*/
@Injectable({
providedIn: 'root'
})
export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(): FeatureID {
return FeatureID.AdministratorOf;
}
}

View File

@@ -0,0 +1,72 @@
import { Observable } from 'rxjs/internal/Observable';
import { Injectable } from '@angular/core';
import { FEATURE } from '../../shared/feature.resource-type';
import { dataService } from '../../cache/builders/build-decorators';
import { DataService } from '../data.service';
import { Feature } from '../../shared/feature.model';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../dso-change-analyzer.service';
import { FindListOptions, FindListRequest } from '../request.models';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteData } from '../remote-data';
import { PaginatedList } from '../paginated-list';
import { find, skipWhile, switchMap, tap } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
/**
* A service to retrieve {@link Feature}s from the REST API
*/
@Injectable()
@dataService(FEATURE)
export class FeatureDataService extends DataService<Feature> {
protected linkPath = 'features';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Feature>
) {
super();
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return {Observable<RemoteData<PaginatedList<Feature>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Feature>>): Observable<RemoteData<PaginatedList<Feature>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);
}
),
switchMap((href) => this.requestService.getByHref(href)),
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((href) =>
this.rdbService.buildList<Feature>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<Feature>>>
)
);
}
}

View File

@@ -0,0 +1,7 @@
/**
* Enum object for all possible {@link Feature} IDs
*/
export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf'
}

View File

@@ -16,9 +16,10 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { HttpClient } from '@angular/common/http';
import { BrowseService } from '../browse/browse.service';
import { CollectionDataService } from './collection-data.service';
import { switchMap } from 'rxjs/operators';
import { switchMap, map } from 'rxjs/operators';
import { BundleDataService } from './bundle-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RestResponse } from '../cache/response.models';
/* tslint:disable:max-classes-per-file */
/**
@@ -121,7 +122,7 @@ class DataServiceImpl extends ItemDataService {
*/
deleteByCollectionID(item: Item, collectionID: string): Observable<boolean> {
this.setRegularEndpoint();
return super.delete(item.uuid);
return super.delete(item.uuid).pipe(map((response: RestResponse) => response.isSuccessful));
}
}

View File

@@ -63,6 +63,7 @@ export class LookupRelationService {
concat(subject.pipe(take(1)))
)
) as any
,
) as Observable<RemoteData<PaginatedList<SearchResult<Item>>>>;
}

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { filter, find, map, switchMap } from 'rxjs/operators';
import { filter, find, map, mergeMap, switchMap } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { isNotUndefined } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -76,7 +76,7 @@ export class RelationshipTypeService extends DataService<RelationshipType> {
getSucceededRemoteData(),
/* Flatten the page so we can treat it like an observable */
switchMap((typeListRD: RemoteData<PaginatedList<RelationshipType>>) => typeListRD.payload.page),
switchMap((type: RelationshipType) => {
mergeMap((type: RelationshipType) => {
if (type.leftwardType === label) {
return this.checkType(type, firstType, secondType);
} else if (type.rightwardType === label) {
@@ -92,7 +92,7 @@ export class RelationshipTypeService extends DataService<RelationshipType> {
// returns a void observable if there's not match
// returns an observable that emits the relationship type when there is a match
private checkType(type: RelationshipType, firstType: string, secondType: string): Observable<RelationshipType> {
const entityTypes = observableCombineLatest(type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData()));
const entityTypes = observableCombineLatest([type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData())]);
return entityTypes.pipe(
find(([leftTypeRD, rightTypeRD]: [RemoteData<ItemType>, RemoteData<ItemType>]) => leftTypeRD.payload.label === firstType && rightTypeRD.payload.label === secondType),
filter((types) => isNotUndefined(types)),

View File

@@ -1,11 +1,6 @@
import { Observable } from 'rxjs/internal/Observable';
import { of as observableOf } from 'rxjs/internal/observable/of';
import * as ItemRelationshipsUtils from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { spyOnOperator } from '../../shared/testing/utils.test';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
@@ -13,11 +8,16 @@ import { Relationship } from '../shared/item-relationships/relationship.model';
import { Item } from '../shared/item.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from './paginated-list';
import { RelationshipService } from './relationship.service';
import { RemoteData } from './remote-data';
import { DeleteRequest, FindListOptions } from './request.models';
import { RequestEntry } from './request.reducer';
import { RelationshipService } from './relationship.service';
import { RequestService } from './request.service';
import { RemoteData } from './remote-data';
import { RequestEntry } from './request.reducer';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { spyOnOperator } from '../../shared/testing/utils.test';
describe('RelationshipService', () => {
let service: RelationshipService;
@@ -159,8 +159,8 @@ describe('RelationshipService', () => {
it('should clear the cache of the related items', () => {
expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1._links.self.href);
expect(objectCache.remove).toHaveBeenCalledWith(item._links.self.href);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid);
});
});
@@ -175,37 +175,6 @@ describe('RelationshipService', () => {
});
});
describe('getRelatedItems', () => {
let mockItem;
beforeEach(() => {
mockItem = { uuid: 'someid' } as Item;
spyOn(service, 'getItemRelationshipsArray').and.returnValue(observableOf(relationships));
spyOnOperator(ItemRelationshipsUtils, 'relationsToItems').and.returnValue((v) => v);
});
it('should call getItemRelationshipsArray with the correct params', (done) => {
service.getRelatedItems(mockItem).subscribe(() => {
expect(service.getItemRelationshipsArray).toHaveBeenCalledWith(
mockItem,
followLink('leftItem'),
followLink('rightItem'),
followLink('relationshipType')
);
done();
});
});
it('should use the relationsToItems operator', (done) => {
service.getRelatedItems(mockItem).subscribe(() => {
expect(ItemRelationshipsUtils.relationsToItems).toHaveBeenCalledWith(mockItem.uuid);
done();
});
});
});
describe('getRelatedItemsByLabel', () => {
let relationsList;
let mockItem;
@@ -258,7 +227,6 @@ describe('RelationshipService', () => {
});
});
})
});
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {

View File

@@ -1,21 +1,14 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import {
compareArraysUsingIds,
paginatedRelationsToItems,
relationsToItems
} from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { AppState, keySelector } from '../../app.reducer';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component';
import {
RemoveNameVariantAction,
SetNameVariantAction
} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions';
import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions';
import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -31,12 +24,7 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RELATIONSHIP } from '../shared/item-relationships/relationship.resource-type';
import { Item } from '../shared/item.model';
import {
configureRequest,
getRemoteDataPayload,
getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { configureRequest, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { ItemDataService } from './item-data.service';
@@ -55,6 +43,19 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele
return keySelector<string>(itemID, relationshipListStateSelector(listID));
};
/**
* Return true if the Item in the payload of the source observable matches
* the given Item by UUID
*
* @param itemCheck the Item to compare with
*/
const compareItemsByUUID = (itemCheck: Item) =>
(source: Observable<RemoteData<Item>>): Observable<boolean> =>
source.pipe(
getFirstSucceededRemoteDataPayload(),
map((item: Item) => item.uuid === itemCheck.uuid)
);
/**
* The service handling all relationship requests
*/
@@ -62,6 +63,7 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele
@dataService(RELATIONSHIP)
export class RelationshipService extends DataService<Relationship> {
protected linkPath = 'relationships';
protected responseMsToLive = 15 * 60 * 1000;
constructor(protected itemService: ItemDataService,
protected requestService: RequestService,
@@ -101,11 +103,7 @@ export class RelationshipService extends DataService<Relationship> {
configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
switchMap((response) =>
this.clearRelatedCache(id).pipe(
map(() => response),
)
),
tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)),
);
}
@@ -132,8 +130,8 @@ export class RelationshipService extends DataService<Relationship> {
configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
tap(() => this.removeRelationshipItemsFromCache(item1)),
tap(() => this.removeRelationshipItemsFromCache(item2))
tap(() => this.refreshRelationshipItemsInCache(item1)),
tap(() => this.refreshRelationshipItemsInCache(item2))
) as Observable<RestResponse>;
}
@@ -141,19 +139,19 @@ export class RelationshipService extends DataService<Relationship> {
* Method to remove two items of a relationship from the cache using the identifier of the relationship
* @param relationshipId The identifier of the relationship
*/
private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) {
this.findById(relationshipId).pipe(
private refreshRelationshipItemsInCacheByRelationship(relationshipId: string) {
this.findById(relationshipId, followLink('leftItem'), followLink('rightItem')).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((rel: Relationship) => combineLatest(
switchMap((rel: Relationship) => observableCombineLatest(
rel.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()),
rel.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload())
)
),
take(1)
).subscribe(([item1, item2]) => {
this.removeRelationshipItemsFromCache(item1);
this.removeRelationshipItemsFromCache(item2);
this.refreshRelationshipItemsInCache(item1);
this.refreshRelationshipItemsInCache(item2);
})
}
@@ -161,13 +159,13 @@ export class RelationshipService extends DataService<Relationship> {
* Method to remove an item that's part of a relationship from the cache
* @param item The item to remove from the cache
*/
private removeRelationshipItemsFromCache(item) {
public refreshRelationshipItemsInCache(item) {
this.objectCache.remove(item._links.self.href);
this.requestService.removeByHrefSubstring(item.uuid);
combineLatest(
observableCombineLatest([
this.objectCache.hasBySelfLinkObservable(item._links.self.href),
this.requestService.hasByHrefObservable(item.uuid)
).pipe(
this.requestService.hasByHrefObservable(item.self)
]).pipe(
filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC),
take(1),
switchMap(() => this.itemService.findByHref(item._links.self.href).pipe(take(1)))
@@ -176,7 +174,10 @@ export class RelationshipService extends DataService<Relationship> {
/**
* Get an item's relationships in the form of an array
* @param item
*
* @param item The {@link Item} to get {@link Relationship}s for
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s
* should be automatically resolved
*/
getItemRelationshipsArray(item: Item, ...linksToFollow: Array<FollowLinkConfig<Relationship>>): Observable<Relationship[]> {
return this.findAllByHref(item._links.relationships.href, undefined, ...linksToFollow).pipe(
@@ -275,10 +276,10 @@ export class RelationshipService extends DataService<Relationship> {
getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable<Relationship[]> {
return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem')).pipe(
switchMap((relationships: Relationship[]) => {
return observableCombineLatest(...relationships.map((relationship: Relationship) => {
return observableCombineLatest(relationships.map((relationship: Relationship) => {
const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids);
const isRightItem$ = this.isItemInUUIDArray(relationship.rightItem, uuids);
return observableCombineLatest(isLeftItem$, isRightItem$).pipe(
return observableCombineLatest([isLeftItem$, isRightItem$]).pipe(
filter(([isLeftItem, isRightItem]) => isLeftItem || isRightItem),
map(() => relationship),
startWith(undefined)
@@ -304,34 +305,31 @@ export class RelationshipService extends DataService<Relationship> {
* @param label The rightward or leftward type of the relationship
*/
getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string, options?: FindListOptions): Observable<Relationship> {
return this.getItemRelationshipsByLabel(item1, label, options, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem'))
.pipe(
return this.getItemRelationshipsByLabel(
item1,
label,
options,
followLink('relationshipType'),
followLink('leftItem'),
followLink('rightItem')
).pipe(
getSucceededRemoteData(),
isNotEmptyOperator(),
map((relationshipListRD: RemoteData<PaginatedList<Relationship>>) => relationshipListRD.payload.page),
mergeMap((relationships: Relationship[]) => {
return observableCombineLatest(...relationships.map((relationship: Relationship) => {
return observableCombineLatest(
this.isItemMatchWithItemRD(relationship.leftItem, item2),
this.isItemMatchWithItemRD(relationship.rightItem, item2)
).pipe(
map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem),
map((isMatch) => isMatch ? relationship : undefined)
);
}))
// the mergemap below will emit all elements of the list as separate events
mergeMap((relationshipListRD: RemoteData<PaginatedList<Relationship>>) => relationshipListRD.payload.page),
mergeMap((relationship: Relationship) => {
return observableCombineLatest([
this.itemService.findByHref(relationship._links.leftItem.href).pipe(compareItemsByUUID(item2)),
this.itemService.findByHref(relationship._links.rightItem.href).pipe(compareItemsByUUID(item2))
]).pipe(
map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem),
map((isMatch) => isMatch ? relationship : undefined)
);
}),
map((relationships: Relationship[]) => relationships.find(((relationship) => hasValue(relationship))))
filter((relationship) => hasValue(relationship)),
take(1)
)
}
private isItemMatchWithItemRD(itemRD$: Observable<RemoteData<Item>>, itemCheck: Item): Observable<boolean> {
return itemRD$.pipe(
getSucceededRemoteData(),
map((itemRD: RemoteData<Item>) => itemRD.payload),
map((item: Item) => item.uuid === itemCheck.uuid)
);
}
/**
* Method to set the name variant for specific list and item
* @param listID The list for which to save the name variant
@@ -378,7 +376,7 @@ export class RelationshipService extends DataService<Relationship> {
* @param nameVariant The name variant to set for the matching relationship
*/
public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable<RemoteData<Relationship>> {
const update$: Observable<RemoteData<Relationship>> = this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
.pipe(
switchMap((relation: Relationship) =>
relation.relationshipType.pipe(
@@ -400,16 +398,6 @@ export class RelationshipService extends DataService<Relationship> {
return this.update(updatedRelationship);
}),
);
update$.pipe(
filter((relationshipRD: RemoteData<Relationship>) => relationshipRD.state === RemoteDataState.RequestPending),
take(1),
).subscribe(() => {
this.removeRelationshipItemsFromCache(item1);
this.removeRelationshipItemsFromCache(item2);
});
return update$
}
/**
@@ -432,7 +420,7 @@ export class RelationshipService extends DataService<Relationship> {
take(1),
).subscribe((relationshipRD: RemoteData<Relationship>) => {
if (relationshipRD.state === RemoteDataState.ResponsePending) {
this.removeRelationshipItemsFromCacheByRelationship(reoRel.relationship.id);
this.refreshRelationshipItemsInCacheByRelationship(reoRel.relationship.id);
}
});
@@ -440,18 +428,11 @@ export class RelationshipService extends DataService<Relationship> {
}
/**
* Clear object and request caches of the items related to a relationship (left and right items)
* @param uuid The uuid of the relationship for which to clear the related items from the cache
* Patch isn't supported on the relationship endpoint, so use put instead.
*
* @param object the {@link Relationship} to update
*/
clearRelatedCache(uuid: string): Observable<void> {
return this.findById(uuid).pipe(
getSucceededRemoteData(),
map((rd: RemoteData<Relationship>) => {
this.objectCache.remove(rd.payload._links.leftItem.href);
this.objectCache.remove(rd.payload._links.rightItem.href);
this.requestService.removeByHrefSubstring(rd.payload._links.leftItem.href);
this.requestService.removeByHrefSubstring(rd.payload._links.rightItem.href);
})
);
update(object: Relationship): Observable<RemoteData<Relationship>> {
return this.put(object);
}
}

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch/lib/core';
import { Observable } from 'rxjs';
import { filter, find, map, take } from 'rxjs/operators';
import { filter, find, map, skipWhile, switchMap, take, tap } from 'rxjs/operators';
import {
EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction
@@ -224,7 +224,7 @@ export class EPersonDataService extends DataService<EPerson> {
* @param ePerson The EPerson to delete
*/
public deleteEPerson(ePerson: EPerson): Observable<boolean> {
return this.delete(ePerson.id);
return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**
@@ -300,4 +300,33 @@ export class EPersonDataService extends DataService<EPerson> {
);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return {Observable<RemoteData<PaginatedList<EPerson>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);
}
),
switchMap((href) => this.requestService.getByHref(href)),
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((href) =>
this.rdbService.buildList<EPerson>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<EPerson>>>
)
);
}
}

View File

@@ -135,7 +135,7 @@ export class GroupDataService extends DataService<Group> {
* @param id The group id to delete
*/
public deleteGroup(group: Group): Observable<boolean> {
return this.delete(group.id);
return this.delete(group.id).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**

View File

@@ -0,0 +1,44 @@
import { ForwardClientIpInterceptor } from './forward-client-ip.interceptor';
import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpRequest } from '@angular/common/http';
import { REQUEST } from '@nguniversal/express-engine/tokens';
describe('ForwardClientIpInterceptor', () => {
let service: DSpaceRESTv2Service;
let httpMock: HttpTestingController;
let requestUrl;
let clientIp;
beforeEach(() => {
requestUrl = 'test-url';
clientIp = '1.2.3.4';
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DSpaceRESTv2Service,
{
provide: HTTP_INTERCEPTORS,
useClass: ForwardClientIpInterceptor,
multi: true,
},
{ provide: REQUEST, useValue: { get: () => undefined, connection: { remoteAddress: clientIp } }}
],
});
service = TestBed.get(DSpaceRESTv2Service);
httpMock = TestBed.get(HttpTestingController);
});
it('should add an X-Forwarded-For header matching the client\'s IP', () => {
service.get(requestUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpMock.expectOne(requestUrl);
expect(httpRequest.request.headers.get('X-Forwarded-For')).toEqual(clientIp);
});
});

View File

@@ -0,0 +1,23 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { REQUEST } from '@nguniversal/express-engine/tokens';
@Injectable()
/**
* Http Interceptor intercepting Http Requests, adding the client's IP to their X-Forwarded-For header
*/
export class ForwardClientIpInterceptor implements HttpInterceptor {
constructor(@Inject(REQUEST) protected req: any) {
}
/**
* Intercept http requests and add the client's IP to the X-Forwarded-For header
* @param httpRequest
* @param next
*/
intercept(httpRequest: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const clientIp = this.req.get('x-forwarded-for') || this.req.connection.remoteAddress;
return next.handle(httpRequest.clone({ setHeaders: { 'X-Forwarded-For': clientIp } }));
}
}

View File

@@ -1,13 +1,9 @@
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import {
NewPatchAddOperationAction,
NewPatchRemoveOperationAction,
NewPatchReplaceOperationAction
} from '../json-patch-operations.actions';
import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions';
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core';
import { isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { dateToISOFormat } from '../../../shared/date.util';
import { AuthorityValue } from '../../integration/models/authority.value';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
@@ -53,12 +49,35 @@ export class JsonPatchOperationsBuilder {
* a boolean representing if the value to be added is a plain text value
*/
replace(path: JsonPatchOperationPathObject, value, plain = false) {
if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) {
this.remove(path);
} else {
this.store.dispatch(
new NewPatchReplaceOperationAction(
path.rootElement,
path.subRootElement,
path.path,
this.prepareValue(value, plain, false)));
}
}
/**
* Dispatch a new NewPatchMoveOperationAction
*
* @param path
* the new path tho move to
* @param prevPath
* the original path to move from
*/
move(path: JsonPatchOperationPathObject, prevPath: string) {
this.store.dispatch(
new NewPatchReplaceOperationAction(
new NewPatchMoveOperationAction(
path.rootElement,
path.subRootElement,
path.path,
this.prepareValue(value, plain, false)));
prevPath,
path.path
)
);
}
/**

View File

@@ -196,7 +196,8 @@ function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperati
body,
action.type,
action.payload.path,
hasValue(action.payload.value) ? action.payload.value : null);
hasValue(action.payload.value) ? action.payload.value : null,
hasValue(action.payload.from) ? action.payload.from : null);
if (hasValue(newState[ action.payload.resourceType ])
&& hasValue(newState[ action.payload.resourceType ].children)) {
@@ -293,7 +294,21 @@ function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOpera
}
}
function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) {
/**
* Add a new operation to a patch
*
* @param body
* The current patch
* @param actionType
* The type of operation to add
* @param targetPath
* The path for the operation
* @param value
* The new value
* @param fromPath
* The previous path (in case of a move operation)
*/
function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?, fromPath?) {
const newBody = Array.from(body);
switch (actionType) {
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION:
@@ -313,6 +328,9 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION:
newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath }));
break;
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION:
newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath }));
break;
}
return newBody;
}

View File

@@ -21,10 +21,8 @@ import {
RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
import { StoreMock } from '../../shared/testing/store.mock';
import { RequestEntry } from '../data/request.reducer';
import { catchError } from 'rxjs/operators';
import { storeModuleConfig } from '../../app.reducer';
class TestService extends JsonPatchOperationsService<SubmitDataResponseDefinitionObject, SubmissionPatchRequest> {
protected linkPath = '';
@@ -99,27 +97,22 @@ describe('JsonPatchOperationsService test suite', () => {
}
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({}, storeModuleConfig),
],
providers: [
{ provide: Store, useClass: StoreMock }
]
}).compileComponents();
}));
function getStore() {
return jasmine.createSpyObj('store', {
dispatch: {},
select: observableOf(mockState['json/patch'][testJsonPatchResourceType]),
pipe: observableOf(true)
});
}
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();
halService = new HALEndpointServiceStub(resourceEndpointURL);
service = initTestService();
spyOn(store, 'select').and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType]));
spyOn(store, 'dispatch').and.callThrough();
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
@@ -164,7 +157,7 @@ describe('JsonPatchOperationsService test suite', () => {
describe('when request is not successful', () => {
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(false));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();
@@ -227,7 +220,7 @@ describe('JsonPatchOperationsService test suite', () => {
describe('when request is not successful', () => {
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(false));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();

View File

@@ -168,7 +168,8 @@ describe('MetadataService', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
Meta,
Title,
ItemDataService,
// tslint:disable-next-line:no-empty
{ provide: ItemDataService, useValue: { findById: () => {} } },
BrowseService,
MetadataService
],

View File

@@ -127,7 +127,7 @@ describe('RegistryService', () => {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)),
findById: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
delete: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
@@ -136,7 +136,7 @@ describe('RegistryService', () => {
findById: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
create: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
put: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
delete: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
}

View File

@@ -223,7 +223,7 @@ export class RegistryService {
* @param id The id of the metadata schema to delete
*/
public deleteMetadataSchema(id: number): Observable<RestResponse> {
return this.metadataSchemaService.deleteAndReturnResponse(`${id}`);
return this.metadataSchemaService.delete(`${id}`);
}
/**
@@ -269,7 +269,7 @@ export class RegistryService {
* @param id The id of the metadata field to delete
*/
public deleteMetadataField(id: number): Observable<RestResponse> {
return this.metadataFieldService.deleteAndReturnResponse(`${id}`);
return this.metadataFieldService.delete(`${id}`);
}
/**
* Method that clears a cached metadata field request and returns its REST url

View File

@@ -96,6 +96,8 @@ describe('ResourcePolicyService', () => {
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.completed = true;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {

View File

@@ -24,6 +24,8 @@ import { PaginatedList } from '../data/paginated-list';
import { ActionType } from './models/action-type.model';
import { RequestParam } from '../cache/models/request-param.model';
import { isNotEmpty } from '../../shared/empty.util';
import { map } from 'rxjs/operators';
import { RestResponse } from '../cache/response.models';
/* tslint:disable:max-classes-per-file */
@@ -100,7 +102,7 @@ export class ResourcePolicyService {
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(resourcePolicyID: string): Observable<boolean> {
return this.dataService.delete(resourcePolicyID);
return this.dataService.delete(resourcePolicyID).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**

View File

@@ -20,6 +20,10 @@ export class ServerResponseService {
return this;
}
setUnauthorized(message = 'Unauthorized'): this {
return this.setStatus(401, message)
}
setNotFound(message = 'Not found'): this {
return this.setStatus(404, message)
}

View File

@@ -0,0 +1,54 @@
import { link, typedObject } from '../cache/builders/build-decorators';
import { AUTHORIZATION } from './authorization.resource-type';
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
import { HALLink } from './hal-link.model';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { EPerson } from '../eperson/models/eperson.model';
import { EPERSON } from '../eperson/models/eperson.resource-type';
import { FEATURE } from './feature.resource-type';
import { DSpaceObject } from './dspace-object.model';
import { Feature } from './feature.model';
import { ITEM } from './item.resource-type';
/**
* Class representing a DSpace Authorization
*/
@typedObject
@inheritSerialization(DSpaceObject)
export class Authorization extends DSpaceObject {
static type = AUTHORIZATION;
/**
* Unique identifier for this authorization
*/
@autoserialize
id: string;
@deserialize
_links: {
self: HALLink;
eperson: HALLink;
feature: HALLink;
object: HALLink;
};
/**
* The EPerson this Authorization belongs to
* Null if the authorization grants access to anonymous users
*/
@link(EPERSON)
eperson?: Observable<RemoteData<EPerson>>;
/**
* The Feature enabled by this Authorization
*/
@link(FEATURE)
feature?: Observable<RemoteData<Feature>>;
/**
* The Object this authorization applies to
*/
@link(ITEM)
object?: Observable<RemoteData<DSpaceObject>>;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';
/**
* The resource type for Authorization
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const AUTHORIZATION = new ResourceType('authorization');

View File

@@ -9,7 +9,8 @@ export enum Context {
Workflow = 'workflow',
Workspace = 'workspace',
AdminMenu = 'adminMenu',
SubmissionModal = 'submissionModal',
EntitySearchModalWithNameVariants = 'EntitySearchModalWithNameVariants',
EntitySearchModal = 'EntitySearchModal',
AdminSearch = 'adminSearch',
AdminWorkflowSearch = 'adminWorkflowSearch',
}

View File

@@ -0,0 +1,37 @@
import { typedObject } from '../cache/builders/build-decorators';
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
import { FEATURE } from './feature.resource-type';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';
/**
* Class representing a DSpace Feature
*/
@typedObject
@inheritSerialization(DSpaceObject)
export class Feature extends DSpaceObject {
static type = FEATURE;
/**
* Unique identifier for this feature
*/
@autoserialize
id: string;
/**
* A human readable description of the feature's purpose
*/
@autoserialize
description: string;
/**
* A list of resource types this feature applies to
*/
@autoserialize
resourcetypes: string[];
@deserialize
_links: {
self: HALLink;
};
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';
/**
* The resource type for Feature
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const FEATURE = new ResourceType('feature');

View File

@@ -3,7 +3,7 @@ import { autoserialize, Serialize, Deserialize } from 'cerialize';
import { hasValue } from '../../shared/empty.util';
/* tslint:disable:max-classes-per-file */
const VIRTUAL_METADATA_PREFIX = 'virtual::';
export const VIRTUAL_METADATA_PREFIX = 'virtual::';
/** A single metadata value and its properties. */
export interface MetadataValueInterface {

View File

@@ -1,4 +1,4 @@
import { Router } from '@angular/router';
import { Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
@@ -11,6 +11,7 @@ import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { getUnauthorizedPath } from '../../app-routing.module';
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -65,7 +66,7 @@ export const getPaginatedListPayload = () =>
export const getSucceededRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded));
source.pipe(filter((rd: RemoteData<T>) => rd.hasSucceeded), take(1));
export const getSucceededRemoteWithNotEmptyData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
@@ -180,6 +181,17 @@ export const redirectToPageNotFoundOn404 = (router: Router) =>
}
}));
/**
* Operator that returns a UrlTree to the unauthorized page when the boolean received is false
* @param router
*/
export const returnUnauthorizedUrlTreeOnFalse = (router: Router) =>
(source: Observable<boolean>): Observable<boolean | UrlTree> =>
source.pipe(
map((authorized: boolean) => {
return authorized ? authorized : router.parseUrl(getUnauthorizedPath())
}));
export const getFinishedRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => !rd.isLoading));

View File

@@ -7,12 +7,14 @@ import { SubmissionObjectDataService } from './submission-object-data.service';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';
import { WorkspaceitemDataService } from './workspaceitem-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
describe('SubmissionObjectDataService', () => {
let service: SubmissionObjectDataService;
let submissionService: SubmissionService;
let workspaceitemDataService: WorkspaceitemDataService;
let workflowItemDataService: WorkflowItemDataService;
let halService: HALEndpointService;
const submissionId = '1234';
const wsiResult = 'wsiResult' as any;
@@ -25,6 +27,9 @@ describe('SubmissionObjectDataService', () => {
workflowItemDataService = jasmine.createSpyObj('WorkflowItemDataService', {
findById: wfiResult
});
halService = jasmine.createSpyObj('HALEndpointService', {
getEndpoint: '/workspaceItem'
});
});
describe('findById', () => {
@@ -32,7 +37,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: {}
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
service.findById(submissionId);
expect(submissionService.getSubmissionScope).toHaveBeenCalled();
});
@@ -42,7 +47,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: SubmissionScopeType.WorkspaceItem
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('should forward the result of WorkspaceitemDataService.findByIdAndIDType()', () => {
@@ -57,7 +62,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: SubmissionScopeType.WorkflowItem
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('should forward the result of WorkflowItemDataService.findByIdAndIDType()', () => {
@@ -72,7 +77,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: 'Something else'
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('shouldn\'t call any data service methods', () => {

View File

@@ -8,6 +8,9 @@ import { SubmissionObject } from './models/submission-object.model';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';
import { WorkspaceitemDataService } from './workspaceitem-data.service';
import { DataService } from '../data/data.service';
import { map } from 'rxjs/operators';
import { HALEndpointService } from '../shared/hal-endpoint.service';
/**
* A service to retrieve submission objects (WorkspaceItem/WorkflowItem)
@@ -20,10 +23,22 @@ export class SubmissionObjectDataService {
constructor(
private workspaceitemDataService: WorkspaceitemDataService,
private workflowItemDataService: WorkflowItemDataService,
private submissionService: SubmissionService
private submissionService: SubmissionService,
private halService: HALEndpointService
) {
}
/**
* Create the HREF for a specific object based on its identifier
* @param id The identifier for the object
*/
getHrefByID(id): Observable<string> {
const dataService: DataService<SubmissionObject> = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem ? this.workspaceitemDataService : this.workflowItemDataService;
return this.halService.getEndpoint(dataService.getLinkPath()).pipe(
map((endpoint: string) => dataService.getIDHref(endpoint, encodeURIComponent(id))));
}
/**
* Retrieve a submission object based on its ID.
*

View File

@@ -176,5 +176,4 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
return definition;
}
}

View File

@@ -17,6 +17,7 @@ import { Observable } from 'rxjs';
import { find, map } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { RequestEntry } from '../data/request.reducer';
import { RestResponse } from '../cache/response.models';
/**
* A service that provides methods to make REST requests with workflow items endpoint.
@@ -44,7 +45,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* @param id The Workflow Item's id to be removed
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(id: string): Observable<boolean> {
delete(id: string): Observable<RestResponse> {
return this.deleteWFI(id, true)
}
@@ -54,7 +55,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* @return an observable that emits true when sending back the item was successful, false when it failed
*/
sendBack(id: string): Observable<boolean> {
return this.deleteWFI(id, false)
return this.deleteWFI(id, false).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**
@@ -64,7 +65,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* When true, the workflow item and its item will be permanently expunged on the server
* When false, the workflow item will be removed, but the item will still be available as a workspace item
*/
private deleteWFI(id: string, expunge: boolean): Observable<boolean> {
private deleteWFI(id: string, expunge: boolean): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -82,7 +83,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
map((request: RequestEntry) => request.response)
);
}
}

View File

@@ -21,7 +21,6 @@ import { WorkspaceItem } from './models/workspaceitem.model';
@dataService(WorkspaceItem.type)
export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
protected linkPath = 'workspaceitems';
protected responseMsToLive = 10 * 1000;
constructor(
protected comparator: DSOChangeAnalyzer<WorkspaceItem>,

View File

@@ -1 +1 @@
<ds-journal-issue-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-grid-element>
<ds-journal-issue-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-grid-element>

View File

@@ -1 +1 @@
<ds-journal-volume-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-grid-element>
<ds-journal-volume-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-grid-element>

View File

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

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="dso.firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1 +1 @@
<ds-org-unit-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-grid-element>
<ds-org-unit-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-grid-element>

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('organization.legalName')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title"
[innerHTML]="firstMetadataValue('person.familyName') + ', ' + firstMetadataValue('person.givenName')"></h4>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,5 +1,5 @@
<ds-truncatable [id]="dso.id">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"
[innerHTML]="firstMetadataValue('dc.title')"></a>

View File

@@ -1,7 +1,6 @@
<ng-template #descTemplate>
<span class="text-muted">
<span *ngIf="metadataRepresentation.allMetadata(['dc.description']).length > 0"
class="item-list-job-title">
<span class="item-list-job-title">
<span [innerHTML]="metadataRepresentation.firstMetadataValue(['dc.description'])"></span>
</span>
</span>
@@ -9,5 +8,5 @@
<ds-truncatable [id]="metadataRepresentation.id">
<a [routerLink]="['/items/' + metadataRepresentation.id]"
[innerHTML]="metadataRepresentation.getValue()"
[tooltip]="metadataRepresentation.allMetadata(['organization.legalName']).length > 0 ? descTemplate : null"></a>
[tooltip]="metadataRepresentation.allMetadata(['dc.description']).length > 0 ? descTemplate : null"></a>
</ds-truncatable>

View File

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

View File

@@ -1,10 +1,15 @@
<div class="d-flex">
<div class="person-thumbnail pr-2">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>
</div>
<!-- <div class="person-thumbnail pr-2">-->
<!-- <ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
<!-- </div>-->
<div class="flex-grow-1">
<ds-org-unit-input-suggestions [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"
<ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"
(submitSuggestion)="selectCustom($event)"></ds-org-unit-input-suggestions>
<div *ngIf="!useNameVariants"
class="lead"
[innerHTML]="firstMetadataValue('organization.legalName')"></div>
<span class="text-muted">
<span *ngIf="dso.allMetadata('organization.address.addressLocality').length > 0"
class="item-list-address-locality">

View File

@@ -20,7 +20,8 @@ import { ItemDataService } from '../../../../../core/data/item-data.service';
import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service';
import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component';
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SubmissionModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({
selector: 'ds-person-search-result-list-submission-element',
styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'],
@@ -34,6 +35,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
allSuggestions: string[];
selectedName: string;
alternativeField = 'dc.title.alternative';
useNameVariants = false;
constructor(protected truncatableService: TruncatableService,
private relationshipService: RelationshipService,
@@ -48,16 +50,21 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
ngOnInit() {
super.ngOnInit();
const defaultValue = this.firstMetadataValue('organization.legalName');
const alternatives = this.allMetadataValues(this.alternativeField);
this.allSuggestions = [defaultValue, ...alternatives];
this.relationshipService.getNameVariant(this.listID, this.dso.uuid)
.pipe(take(1))
.subscribe((nameVariant: string) => {
this.selectedName = nameVariant || defaultValue;
}
);
this.useNameVariants = this.context === Context.EntitySearchModalWithNameVariants;
if (this.useNameVariants) {
const defaultValue = this.firstMetadataValue('organization.legalName');
const alternatives = this.allMetadataValues(this.alternativeField);
this.allSuggestions = [defaultValue, ...alternatives];
this.relationshipService.getNameVariant(this.listID, this.dso.uuid)
.pipe(take(1))
.subscribe((nameVariant: string) => {
this.selectedName = nameVariant || defaultValue;
}
);
}
}
select(value) {
@@ -75,7 +82,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
if (!this.allSuggestions.includes(value)) {
this.openModal(value)
.then(() => {
// user clicked ok: store the name variant in the item
const newName: MetadataValue = new MetadataValue();
newName.value = value;
@@ -89,9 +96,12 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
},
});
this.itemDataService.update(updatedItem).pipe(take(1)).subscribe();
})
}).catch(() => {
// user clicked cancel: use the name variant only for this relation, no further action required
}).finally(() => {
this.select(value);
})
}
this.select(value);
}
openModal(value): Promise<any> {

View File

@@ -1,6 +1,7 @@
form {
z-index: 1;
&:before {
pointer-events: none; // prevent the icon from catching the click
position: absolute;
font-weight: 900;
font-family: "Font Awesome 5 Free";

View File

@@ -1,7 +1,4 @@
<div class="d-flex">
<div class="person-thumbnail pr-2">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.svg'"></ds-thumbnail>
</div>
<div class="flex-grow-1">
<ds-person-input-suggestions [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)" (submitSuggestion)="selectCustom($event)"></ds-person-input-suggestions>
<span class="text-muted">

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